changemaker.lite/api/dist/services/pangolin.client.js

243 lines
9.4 KiB
JavaScript

"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