243 lines
9.4 KiB
JavaScript
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
|