"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.pangolinClient = void 0; const env_1 = require("../config/env"); const logger_1 = require("../utils/logger"); // --- Client --- class PangolinClient { get baseUrl() { return env_1.env.PANGOLIN_API_URL; } get apiKey() { return env_1.env.PANGOLIN_API_KEY; } get orgId() { return env_1.env.PANGOLIN_ORG_ID; } get configured() { return !!(this.baseUrl && this.apiKey && this.orgId); } async request(method, path, body) { 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_1.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_1.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 {}; } finally { clearTimeout(timeout); } } /** * Unwrap Pangolin's response envelope: { data: {...}, success, status } * Returns the inner data object, or the raw response if not wrapped. */ unwrapResponse(json) { if (json && typeof json === 'object' && !Array.isArray(json)) { const obj = json; // If it has a 'data' key and 'success' key, it's a wrapped response if ('data' in obj && 'success' in obj) { logger_1.logger.debug('Unwrapped Pangolin response envelope'); return obj.data; } } return json; } async healthCheck() { 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_1.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() { const res = await this.request('GET', `/org/${this.orgId}/pick-site-defaults`); const obj = res; // Response format: { newtId, newtSecret, clientAddress, subnet, ... } // Note: `address` is the exit node address, `clientAddress` is the site address const newtId = obj.newtId || ''; const newtSecret = obj.newtSecret || obj.secret || ''; const address = obj.clientAddress || obj.address || ''; if (!newtId || !newtSecret) { logger_1.logger.warn('pickSiteDefaults response missing newtId/newtSecret:', JSON.stringify(res)); throw new Error('Pangolin did not return Newt credentials from pick-site-defaults'); } logger_1.logger.info(`pickSiteDefaults: newtId=${newtId}, address=${address}`); return { newtId, newtSecret, address }; } // --- Sites --- async listSites() { const res = await this.request('GET', `/org/${this.orgId}/sites`); return this.extractArray(res, 'sites', 'listSites'); } async listExitNodes() { 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_1.logger.info('Pangolin exit-nodes endpoint not available (self-hosted mode)'); return []; } logger_1.logger.warn('Failed to fetch exit nodes:', err); return []; } } async getSite(siteId) { return this.request('GET', `/site/${siteId}`); } async createSite(data) { return this.request('PUT', `/org/${this.orgId}/site`, data); } async deleteSite(siteId) { 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() { const res = await this.request('GET', `/org/${this.orgId}/resources`); return this.extractArray(res, 'resources', 'listResources'); } async getResource(resourceId) { return this.request('GET', `/resource/${resourceId}`); } /** * Create an HTTP proxy resource (public resource). * Endpoint: PUT /org/{orgId}/resource (NOT /site-resource) */ async createResource(data) { logger_1.logger.info(`createResource: ${data.name} (subdomain: ${data.subdomain || '(root)'})`); return this.request('PUT', `/org/${this.orgId}/resource`, data); } async updateResource(resourceId, data) { return this.request('POST', `/resource/${resourceId}`, data); } async deleteResource(resourceId) { 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, data) { logger_1.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) { 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) { await this.request('DELETE', `/target/${targetId}`); } // --- Domains --- async listDomains() { const res = await this.request('GET', `/org/${this.orgId}/domains`); return this.extractArray(res, 'domains', 'listDomains'); } // --- Certificates --- async getCertificate(domainId, domain) { return this.request('GET', `/org/${this.orgId}/certificate/${domainId}/${domain}`); } async updateCertificate(certId, data) { return this.request('POST', `/certificate/${certId}`, data); } // --- Clients --- async listClients(resourceId) { 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: [...] } */ extractArray(res, key, context) { if (Array.isArray(res)) { return res; } if (res && typeof res === 'object') { const obj = res; // Check named key (e.g., "sites", "resources", "domains") if (Array.isArray(obj[key])) { return obj[key]; } // Check nested data.{key} if (obj.data && typeof obj.data === 'object') { const dataObj = obj.data; if (Array.isArray(dataObj[key])) { return dataObj[key]; } } // Fallback: { data: [...] } if (Array.isArray(obj.data)) { return obj.data; } } logger_1.logger.warn(`${context}: could not extract array from response, returning empty`); return []; } } exports.pangolinClient = new PangolinClient(); //# sourceMappingURL=pangolin.client.js.map