import { env } from '../config/env'; import { logger } from '../utils/logger'; // --- Types --- export interface PangolinSite { siteId: string; name: string; orgId: string; niceId: string; pubKey?: string; subnet?: string; megabytesIn?: number; megabytesOut?: number; lastSeen?: string; online?: boolean; type?: string; address?: string; } export interface PangolinExitNode { exitNodeId: string; name: string; location?: string; region?: string; online: boolean; capacity?: number; latency?: number; } export interface PangolinResource { resourceId: string; name: string; subdomain?: string; fullDomain?: string; ssl?: boolean; blockAccess?: boolean; active?: boolean; proxyPort?: number; protocol?: string; domainBindings?: string[]; http?: boolean; // Target info (returned by list endpoints) targets?: PangolinTarget[]; } export interface PangolinTarget { targetId: string; resourceId: string; siteId: string; ip: string; port: number; method: string; enabled?: boolean; } export interface PangolinNewt { newtId: string; secret: string; siteId: string; } export interface PangolinSiteDefaults { newtId: string; newtSecret: string; address: string; } export interface CreateSitePayload { name: string; type?: string; subnet?: string; exitNodeId?: string; // Newt credentials from pickSiteDefaults newtId?: string; secret?: string; address?: string; } // HTTP Resource (public proxy) - Correct Pangolin API schema // Uses endpoint: PUT /org/{orgId}/resource (NOT /site-resource) export interface CreateHttpResourcePayload { name: string; domainId: string; subdomain: string; // Subdomain only (e.g., "app") or empty string for root http: true; // REQUIRED: marks as HTTP proxy resource protocol: 'tcp'; // REQUIRED for HTTP resources // Note: ssl and enabled are NOT valid creation fields — set via updateResource() } export interface CreateTargetPayload { siteId: string | number; // REQUIRED: which site routes this traffic (Pangolin expects numeric) ip: string; // Target hostname/IP (e.g., "nginx") port: number; // Target port (e.g., 80) method: 'http' | 'https'; enabled?: boolean; } export interface PangolinDomain { domainId: string; baseDomain: string; verified: boolean; type?: string; failed?: boolean; configManaged?: boolean; } export interface UpdateResourcePayload { name?: string; subdomain?: string; fullDomain?: string; ssl?: boolean; active?: boolean; blockAccess?: boolean; proxyPort?: number; protocol?: string; domainBindings?: string[]; } export interface PangolinCertificate { certId: string; domainId: string; domain: string; status: 'PENDING' | 'ACTIVE' | 'EXPIRED' | 'FAILED'; issuedAt?: string; expiresAt?: string; autoRenew?: boolean; issuer?: string; } export interface UpdateCertificatePayload { autoRenew?: boolean; } export interface PangolinConnectedClient { clientId: string; resourceId: string; ipAddress: string; connectedAt: string; lastSeen: string; bytesIn: number; bytesOut: number; online: boolean; } // --- Client --- class PangolinClient { private get baseUrl(): string { return env.PANGOLIN_API_URL; } private get apiKey(): string { return env.PANGOLIN_API_KEY; } private get orgId(): string { return env.PANGOLIN_ORG_ID; } get configured(): boolean { return !!(this.baseUrl && this.apiKey && this.orgId); } private async request(method: string, path: string, body?: unknown): Promise { if (!this.baseUrl || !this.apiKey) { throw new Error('Pangolin API not configured. Set PANGOLIN_API_URL and PANGOLIN_API_KEY.'); } const url = `${this.baseUrl}${path}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); try { logger.debug(`Pangolin ${method} ${path}${body ? ` body=${JSON.stringify(body)}` : ''}`); const res = await fetch(url, { method, headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', }, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); if (!res.ok) { const text = await res.text().catch(() => ''); logger.error(`Pangolin API ${method} ${path} returned ${res.status}: ${text}`); throw new Error(`Pangolin API ${method} ${path} returned ${res.status}: ${text}`); } const contentType = res.headers.get('content-type') || ''; if (contentType.includes('application/json')) { const json = await res.json(); // Pangolin wraps responses in { data: {...}, success, status } // Unwrap if present return this.unwrapResponse(json); } return {} as T; } finally { clearTimeout(timeout); } } /** * Unwrap Pangolin's response envelope: { data: {...}, success, status } * Returns the inner data object, or the raw response if not wrapped. */ private unwrapResponse(json: unknown): T { if (json && typeof json === 'object' && !Array.isArray(json)) { const obj = json as Record; // If it has a 'data' key and 'success' key, it's a wrapped response if ('data' in obj && 'success' in obj) { logger.debug('Unwrapped Pangolin response envelope'); return obj.data as T; } } return json as T; } async healthCheck(): Promise { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { const res = await fetch(`${this.baseUrl}/`, { headers: { 'Authorization': `Bearer ${this.apiKey}` }, signal: controller.signal, }); return res.ok; } finally { clearTimeout(timeout); } } catch (err) { logger.warn('Pangolin health check failed:', err instanceof Error ? err.message : err); return false; } } // --- Site Defaults --- /** * Get pre-generated Newt credentials for a new site. * Must be called BEFORE createSite() and passed into it. * Endpoint: GET /org/{orgId}/pick-site-defaults */ async pickSiteDefaults(): Promise { const res = await this.request('GET', `/org/${this.orgId}/pick-site-defaults`); const obj = res as Record; // Response format: { newtId, newtSecret, clientAddress, subnet, ... } // Note: `address` is the exit node address, `clientAddress` is the site address const newtId = obj.newtId as string || ''; const newtSecret = obj.newtSecret as string || obj.secret as string || ''; const address = obj.clientAddress as string || obj.address as string || ''; if (!newtId || !newtSecret) { logger.warn('pickSiteDefaults response missing newtId/newtSecret:', JSON.stringify(res)); throw new Error('Pangolin did not return Newt credentials from pick-site-defaults'); } logger.info(`pickSiteDefaults: newtId=${newtId}, address=${address}`); return { newtId, newtSecret, address }; } // --- Sites --- async listSites(): Promise { const res = await this.request('GET', `/org/${this.orgId}/sites`); return this.extractArray(res, 'sites', 'listSites'); } async listExitNodes(): Promise { try { const res = await this.request('GET', `/org/${this.orgId}/exit-nodes`); return this.extractArray(res, 'exitNodes', 'listExitNodes'); } catch (err) { if (err instanceof Error && err.message.includes('404')) { logger.info('Pangolin exit-nodes endpoint not available (self-hosted mode)'); return []; } logger.warn('Failed to fetch exit nodes:', err); return []; } } async getSite(siteId: string): Promise { return this.request('GET', `/site/${siteId}`); } async createSite(data: CreateSitePayload): Promise { return this.request( 'PUT', `/org/${this.orgId}/site`, data, ); } async deleteSite(siteId: string): Promise { await this.request('DELETE', `/site/${siteId}`); } // --- HTTP Resources (public proxy) --- /** * List HTTP proxy resources (public resources). * Endpoint: GET /org/{orgId}/resources (NOT /site-resources) */ async listResources(): Promise { const res = await this.request('GET', `/org/${this.orgId}/resources`); return this.extractArray(res, 'resources', 'listResources'); } async getResource(resourceId: string): Promise { return this.request('GET', `/resource/${resourceId}`); } /** * Create an HTTP proxy resource (public resource). * Endpoint: PUT /org/{orgId}/resource (NOT /site-resource) */ async createResource(data: CreateHttpResourcePayload): Promise { logger.info(`createResource: ${data.name} (subdomain: ${data.subdomain || '(root)'})`); return this.request( 'PUT', `/org/${this.orgId}/resource`, data, ); } async updateResource(resourceId: string, data: UpdateResourcePayload): Promise { return this.request('POST', `/resource/${resourceId}`, data); } async deleteResource(resourceId: string): Promise { await this.request('DELETE', `/resource/${resourceId}`); } // --- Targets --- /** * Create a target for a resource (routes traffic to a backend). * Endpoint: PUT /resource/{resourceId}/target (PUT, not POST) */ async createTarget(resourceId: string, data: CreateTargetPayload): Promise { logger.info(`createTarget: resource=${resourceId}, ip=${data.ip}:${data.port}, method=${data.method}`); // Pangolin expects siteId as a number, not a string const payload = { ...data, siteId: Number(data.siteId) }; return this.request( 'PUT', `/resource/${resourceId}/target`, payload, ); } /** * List targets for a resource. * Endpoint: GET /resource/{resourceId}/targets (plural — NOT /target) */ async listTargets(resourceId: string): Promise { const res = await this.request('GET', `/resource/${resourceId}/targets`); return this.extractArray(res, 'targets', 'listTargets'); } /** * Delete a target by ID. * Endpoint: DELETE /target/{targetId} (NOT nested under /resource/) */ async deleteTarget(targetId: string): Promise { await this.request('DELETE', `/target/${targetId}`); } // --- Domains --- async listDomains(): Promise { const res = await this.request('GET', `/org/${this.orgId}/domains`); return this.extractArray(res, 'domains', 'listDomains'); } // --- Certificates --- async getCertificate(domainId: string, domain: string): Promise { return this.request( 'GET', `/org/${this.orgId}/certificate/${domainId}/${domain}`, ); } async updateCertificate(certId: string, data: UpdateCertificatePayload): Promise { return this.request('POST', `/certificate/${certId}`, data); } // --- Clients --- async listClients(resourceId: string): Promise { const res = await this.request('GET', `/resource/${resourceId}/clients`); return this.extractArray(res, 'clients', 'listClients'); } // --- Helpers --- /** * Extract an array from a Pangolin response that may be: * - A direct array * - An object with the array under a named key (e.g., { sites: [...] }) * - An object with { data: [...] } */ private extractArray(res: unknown, key: string, context: string): T[] { if (Array.isArray(res)) { return res as T[]; } if (res && typeof res === 'object') { const obj = res as Record; // Check named key (e.g., "sites", "resources", "domains") if (Array.isArray(obj[key])) { return obj[key] as T[]; } // Check nested data.{key} if (obj.data && typeof obj.data === 'object') { const dataObj = obj.data as Record; if (Array.isArray(dataObj[key])) { return dataObj[key] as T[]; } } // Fallback: { data: [...] } if (Array.isArray(obj.data)) { return obj.data as T[]; } } logger.warn(`${context}: could not extract array from response, returning empty`); return []; } } export const pangolinClient = new PangolinClient();