"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.pangolinRouter = void 0; const express_1 = require("express"); const express_rate_limit_1 = __importDefault(require("express-rate-limit")); const rate_limit_redis_1 = __importDefault(require("rate-limit-redis")); const fs_1 = require("fs"); const path_1 = require("path"); const yaml_1 = require("yaml"); const auth_middleware_1 = require("../../middleware/auth.middleware"); const rbac_middleware_1 = require("../../middleware/rbac.middleware"); const redis_1 = require("../../config/redis"); const pangolin_client_1 = require("../../services/pangolin.client"); const docker_service_1 = require("../../services/docker.service"); const env_1 = require("../../config/env"); const logger_1 = require("../../utils/logger"); // Rate limiter for expensive Pangolin operations const pangolinSetupLimiter = (0, express_rate_limit_1.default)({ windowMs: 5 * 60 * 1000, // 5 minutes max: 3, // Max 3 operations per 5 minutes per user keyGenerator: (req) => `pangolin-setup:${req.user?.id || req.ip}`, standardHeaders: true, legacyHeaders: false, store: new rate_limit_redis_1.default({ sendCommand: (command, ...args) => redis_1.redis.call(command, ...args), prefix: 'rl:pangolin-setup:', }), message: { error: { message: 'Too many Pangolin setup operations. Please wait 5 minutes before trying again.', code: 'PANGOLIN_RATE_LIMIT_EXCEEDED', }, }, }); /** * Load resource definitions from YAML config file */ function loadResourceDefinitions() { try { const configPath = (0, path_1.join)(__dirname, '../../../configs/pangolin/resources.yml'); const fileContent = (0, fs_1.readFileSync)(configPath, 'utf8'); const config = (0, yaml_1.parse)(fileContent); if (!config || !Array.isArray(config.resources)) { logger_1.logger.error('Invalid resources.yml format'); return []; } logger_1.logger.info(`Loaded ${config.resources.length} resource definitions from YAML`); return config.resources; } catch (err) { logger_1.logger.error('Failed to load resource definitions:', err); return []; } } /** * Validate container is running before creating resource * @returns { valid: boolean, warning?: string, shouldSkip: boolean } */ async function validateContainer(def) { try { // Check monitoring profile if (def.profile === 'monitoring') { const grafanaStatus = await docker_service_1.dockerService.getContainerStatus('grafana-changemaker'); if (!grafanaStatus.running) { return { valid: false, warning: `Monitoring profile not active, skipping ${def.name}`, shouldSkip: true, }; } } // Check container status const status = await docker_service_1.dockerService.getContainerStatus(def.container); if (!status.running) { if (def.required) { return { valid: false, warning: `Required container not running: ${def.container}`, shouldSkip: false, }; } else { return { valid: false, warning: `Optional container not running: ${def.container}, skipping`, shouldSkip: true, }; } } return { valid: true, shouldSkip: false }; } catch (err) { logger_1.logger.error(`Container validation failed for ${def.container}:`, err); return { valid: false, warning: `Failed to check container status: ${def.container}`, shouldSkip: !def.required, }; } } /** * Check if resource needs update (compare existing vs desired state) */ function resourceNeedsUpdate(existing, desired) { return (existing.name !== desired.name || existing.ssl !== desired.ssl || existing.proxyPort !== desired.proxyPort || existing.protocol !== desired.protocol || existing.fullDomain !== desired.fullDomain); } /** * Suggest next available subnet based on existing sites * Returns subnet in format "100.89.128.X/30" (increments by 4) */ function suggestNextSubnet(sites) { if (!sites || sites.length === 0) { return '100.89.128.4/30'; // Default first subnet (start at .4, not .0) } // Extract subnet field from existing sites (NOT address field) const subnets = sites .map(s => s.subnet) // Use subnet field specifically .filter((s) => Boolean(s)) .map(s => { // Parse octet for numeric sorting const match = s.match(/^100\.89\.128\.(\d+)\/30$/); return { subnet: s, octet: match ? parseInt(match[1], 10) : 0 }; }) .sort((a, b) => a.octet - b.octet); // Sort numerically by octet if (subnets.length === 0) { return '100.89.128.4/30'; } // Get the highest octet value const lastSubnet = subnets[subnets.length - 1].subnet; if (!lastSubnet) { return '100.89.128.4/30'; } // Match format: 100.89.128.X/30 const match = lastSubnet.match(/^100\.89\.128\.(\d+)\/30$/); if (match && match[1]) { const lastOctet = parseInt(match[1], 10); const nextOctet = lastOctet + 4; // /30 subnets increment by 4 if (nextOctet <= 252) { // Max is 252 (252-255 is last /30) return `100.89.128.${nextOctet}/30`; } } // Fallback if parsing fails return '100.89.128.4/30'; } const router = (0, express_1.Router)(); // --- Internal Endpoints (No Auth Required) --- // POST /api/pangolin/sync-daemon — Internal sync endpoint for cron job (no auth, no rate limit) // This endpoint is called by nginx cron job every hour router.post('/sync-daemon', async (_req, res) => { try { if (!pangolin_client_1.pangolinClient.configured) { res.status(400).json({ error: { message: 'Pangolin not configured', code: 'NOT_CONFIGURED' } }); return; } const siteId = env_1.env.PANGOLIN_SITE_ID; if (!siteId) { // Silently skip if not configured (not an error state) logger_1.logger.info('Pangolin sync skipped - PANGOLIN_SITE_ID not set'); res.json({ success: true, skipped: true, reason: 'not_configured' }); return; } const domain = env_1.env.DOMAIN; // Load resource definitions from YAML const resourceDefs = loadResourceDefinitions(); if (resourceDefs.length === 0) { logger_1.logger.warn('No resource definitions found - check configs/pangolin/resources.yml'); res.json({ success: false, error: 'No resource definitions found' }); return; } // Get existing resources const existing = await pangolin_client_1.pangolinClient.listResources(); const existingByDomain = new Map(existing.map(r => [r.fullDomain || '', r])); const created = []; const updated = []; const skipped = []; const warnings = []; const errors = []; for (const def of resourceDefs) { const fullDomain = def.subdomain ? `${def.subdomain}.${domain}` : domain; // Validate container before creating/updating resource const validation = await validateContainer(def); if (!validation.valid) { if (validation.shouldSkip) { warnings.push(`${fullDomain}: ${validation.warning}`); continue; } else { errors.push(`${fullDomain}: ${validation.warning}`); continue; } } const existingResource = existingByDomain.get(fullDomain); if (existingResource) { // Check if resource needs update const desired = { name: def.name, ssl: true, proxyPort: 80, protocol: 'http', fullDomain, }; if (resourceNeedsUpdate(existingResource, desired)) { try { await pangolin_client_1.pangolinClient.updateResource(existingResource.resourceId, { name: desired.name, ssl: desired.ssl, proxyPort: desired.proxyPort, protocol: desired.protocol, fullDomain: desired.fullDomain, }); updated.push(fullDomain); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; errors.push(`${fullDomain} (update): ${msg}`); } } else { skipped.push(fullDomain); } } else { // Create new resource try { await pangolin_client_1.pangolinClient.createResource({ name: def.name, siteId, subdomain: def.subdomain || undefined, fullDomain, ssl: true, http: true, protocol: 'http', proxyPort: 80, }); created.push(fullDomain); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; errors.push(`${fullDomain}: ${msg}`); } } } // Log summary if (created.length > 0 || updated.length > 0 || errors.length > 0) { logger_1.logger.info(`Pangolin sync completed: ${created.length} created, ${updated.length} updated, ${errors.length} errors`); } res.json({ success: true, created: created.length, updated: updated.length, skipped: skipped.length, warnings: warnings.length, errors: errors.length, details: { created, updated, skipped, warnings, errors }, }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; logger_1.logger.error('Pangolin sync daemon failed:', err); res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); } }); // --- Authenticated Endpoints (Require SUPER_ADMIN) --- router.use(auth_middleware_1.authenticate); router.use((0, rbac_middleware_1.requireRole)('SUPER_ADMIN')); // GET /api/pangolin/status — Health + connection info router.get('/status', async (_req, res) => { try { const configured = pangolin_client_1.pangolinClient.configured; let healthy = false; if (configured) { healthy = await pangolin_client_1.pangolinClient.healthCheck(); } res.json({ configured, healthy, pangolinUrl: env_1.env.PANGOLIN_API_URL || null, orgId: env_1.env.PANGOLIN_ORG_ID || null, siteId: env_1.env.PANGOLIN_SITE_ID || null, newtConfigured: !!(env_1.env.PANGOLIN_NEWT_ID && env_1.env.PANGOLIN_NEWT_SECRET), }); } catch (err) { logger_1.logger.error('Pangolin status check failed:', err); res.status(500).json({ error: { message: 'Failed to check Pangolin status', code: 'PANGOLIN_ERROR' } }); } }); // GET /api/pangolin/config — Current env config router.get('/config', (_req, res) => { res.json({ pangolinApiUrl: env_1.env.PANGOLIN_API_URL || null, pangolinEndpoint: env_1.env.PANGOLIN_ENDPOINT || null, orgId: env_1.env.PANGOLIN_ORG_ID || null, siteId: env_1.env.PANGOLIN_SITE_ID || null, newtId: env_1.env.PANGOLIN_NEWT_ID || null, newtConfigured: !!(env_1.env.PANGOLIN_NEWT_ID && env_1.env.PANGOLIN_NEWT_SECRET), domain: env_1.env.DOMAIN, }); }); // GET /api/pangolin/newt-status — Check newt container status router.get('/newt-status', async (_req, res) => { try { const containerStatus = await docker_service_1.dockerService.getContainerStatus('newt-changemaker'); res.json({ newtConfigured: !!(env_1.env.PANGOLIN_NEWT_ID && env_1.env.PANGOLIN_NEWT_SECRET), containerRunning: containerStatus.running, containerStatus: containerStatus.status, ready: containerStatus.running && !!(env_1.env.PANGOLIN_NEWT_ID && env_1.env.PANGOLIN_NEWT_SECRET), error: containerStatus.error, }); } catch (err) { logger_1.logger.error('Newt status check failed:', err); res.status(500).json({ error: { message: 'Failed to check newt status', code: 'DOCKER_ERROR' } }); } }); // POST /api/pangolin/newt-restart — Restart/recreate newt container router.post('/newt-restart', async (_req, res) => { try { if (!env_1.env.PANGOLIN_NEWT_ID || !env_1.env.PANGOLIN_NEWT_SECRET) { res.status(400).json({ error: { message: 'Newt credentials not configured. Run setup first and update .env file.', code: 'NOT_CONFIGURED' }, }); return; } const result = await docker_service_1.dockerService.restartContainer('newt'); if (!result.success) { res.status(500).json({ error: { message: 'Failed to restart container', code: 'DOCKER_ERROR' }, details: result.output, }); return; } res.json({ success: true, message: 'Newt container restarted successfully', output: result.output, }); } catch (err) { logger_1.logger.error('Newt restart failed:', err); res.status(500).json({ error: { message: 'Failed to restart newt container', code: 'DOCKER_ERROR' } }); } }); // GET /api/pangolin/sites — List sites router.get('/sites', async (_req, res) => { try { const sites = await pangolin_client_1.pangolinClient.listSites(); res.json({ sites }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); } }); // GET /api/pangolin/exit-nodes — List available exit nodes router.get('/exit-nodes', async (_req, res) => { try { const exitNodes = await pangolin_client_1.pangolinClient.listExitNodes(); res.json({ exitNodes }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; logger_1.logger.warn('Failed to list exit nodes:', msg); res.status(500).json({ error: { message: 'Failed to retrieve exit nodes from Pangolin API', code: 'PANGOLIN_ERROR' } }); } }); // GET /api/pangolin/resources — List resources router.get('/resources', async (_req, res) => { try { const resources = await pangolin_client_1.pangolinClient.listResources(); res.json({ resources }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); } }); // POST /api/pangolin/setup — Create site + all resources router.post('/setup', pangolinSetupLimiter, async (req, res) => { try { if (!pangolin_client_1.pangolinClient.configured) { res.status(400).json({ error: { message: 'Pangolin not configured. Set PANGOLIN_API_URL, PANGOLIN_API_KEY, and PANGOLIN_ORG_ID in .env', code: 'NOT_CONFIGURED' }, }); return; } const domain = env_1.env.DOMAIN; const siteName = req.body?.siteName || `changemaker-${domain}`; const subnet = req.body?.subnet; const exitNodeId = req.body?.exitNodeId; // Validate subnet format if provided if (subnet) { // Basic format check const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/; if (!cidrRegex.test(subnet)) { res.status(400).json({ error: { message: 'Invalid subnet format. Expected CIDR notation (e.g., "100.90.128.0/24")', code: 'INVALID_SUBNET' }, }); return; } // Validate IP octets and prefix const [ip, prefixStr] = subnet.split('/'); const octets = ip.split('.').map(Number); const prefix = parseInt(prefixStr, 10); // Check octet ranges (0-255) if (octets.some((o) => isNaN(o) || o < 0 || o > 255)) { res.status(400).json({ error: { message: 'Invalid subnet IP address. Octets must be 0-255.', code: 'INVALID_SUBNET' }, }); return; } // Check prefix range (0-32 for IPv4) if (isNaN(prefix) || prefix < 0 || prefix > 32) { res.status(400).json({ error: { message: 'Invalid CIDR prefix. Must be 0-32 for IPv4.', code: 'INVALID_SUBNET' }, }); return; } // Block reserved IP ranges const firstOctet = octets[0]; const isReserved = firstOctet === 0 || // 0.0.0.0/8 - Broadcast firstOctet === 127 || // 127.0.0.0/8 - Loopback firstOctet >= 224; // 224.0.0.0/4 - Multicast, Reserved if (isReserved) { res.status(400).json({ error: { message: 'Subnet uses reserved IP range (loopback, multicast, or broadcast)', code: 'INVALID_SUBNET' }, }); return; } } // Validate exit node ID ONLY if provided if (exitNodeId !== undefined && exitNodeId !== null && exitNodeId !== '') { // Basic format check if (typeof exitNodeId !== 'string') { res.status(400).json({ error: { message: 'Exit node ID must be a string', code: 'INVALID_EXIT_NODE' }, }); return; } // Length validation (IDs should be reasonable length) if (exitNodeId.length < 3 || exitNodeId.length > 100) { res.status(400).json({ error: { message: 'Invalid exit node ID format', code: 'INVALID_EXIT_NODE' }, }); return; } // Verify exit node exists in org's available nodes (only if we can list them) try { const availableNodes = await pangolin_client_1.pangolinClient.listExitNodes(); if (availableNodes.length > 0) { // Only validate if exit nodes are available const node = availableNodes.find(n => n.exitNodeId === exitNodeId); if (!node) { res.status(400).json({ error: { message: 'Exit node not found in available nodes', code: 'EXIT_NODE_NOT_FOUND' }, }); return; } if (!node.online) { logger_1.logger.warn(`Creating site with offline exit node: ${exitNodeId} (${node.name})`); } } } catch (err) { logger_1.logger.warn('Could not verify exit node (exit-nodes endpoint unavailable)'); // Don't fail - proceed with site creation } } else { logger_1.logger.info('Creating site without exit node (self-hosted mode)'); } // Create site (returns newt credentials) logger_1.logger.info(`Creating Pangolin site: ${siteName}${subnet ? ` with subnet ${subnet}` : ''}${exitNodeId ? ` via exit node ${exitNodeId}` : ''}`); const site = await pangolin_client_1.pangolinClient.createSite({ name: siteName, type: 'newt', ...(subnet && { subnet }), ...(exitNodeId && { exitNodeId }) }); const siteId = site.siteId; logger_1.logger.info(`Site created: ${siteId}`); // Load resource definitions from YAML const resourceDefs = loadResourceDefinitions(); if (resourceDefs.length === 0) { logger_1.logger.error('No resource definitions found - check configs/pangolin/resources.yml'); } // Create resources for all subdomains const created = []; const errors = []; const warnings = []; for (const def of resourceDefs) { const fullDomain = def.subdomain ? `${def.subdomain}.${domain}` : domain; // Validate container before creating resource const validation = await validateContainer(def); if (!validation.valid) { if (validation.shouldSkip) { warnings.push(`${fullDomain}: ${validation.warning}`); logger_1.logger.warn(validation.warning); continue; } else { errors.push(`${fullDomain}: ${validation.warning}`); logger_1.logger.error(validation.warning); continue; } } try { const resource = await pangolin_client_1.pangolinClient.createResource({ name: def.name, siteId, subdomain: def.subdomain || undefined, fullDomain, ssl: true, http: true, protocol: 'http', proxyPort: 80, }); created.push({ subdomain: def.subdomain || '(root)', name: def.name, resourceId: resource.resourceId }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; errors.push(`${fullDomain}: ${msg}`); logger_1.logger.warn(`Failed to create resource for ${fullDomain}: ${msg}`); } } res.json({ success: true, site: { siteId, name: siteName, newt: site.newt ? { newtId: site.newt.newtId, secret: site.newt.secret, } : null, }, resources: { created: created.length, total: resourceDefs.length, items: created, errors, warnings }, instructions: site.newt ? [ 'Add these to your .env file:', `PANGOLIN_SITE_ID=${siteId}`, `PANGOLIN_NEWT_ID=${site.newt.newtId}`, `PANGOLIN_NEWT_SECRET=${site.newt.secret}`, `PANGOLIN_ENDPOINT=${env_1.env.PANGOLIN_ENDPOINT || 'https://api.bnkserve.org'}`, `# Assigned subnet: ${site.subnet || subnet || 'auto-assigned'}`, 'Then restart the newt container: docker compose up -d newt', ] : ['Site created but no Newt credentials returned. Check Pangolin dashboard.'], }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; logger_1.logger.error('Pangolin setup failed:', err); res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); } }); // POST /api/pangolin/setup-automated — Automated one-command setup (create site, update .env, restart Newt) router.post('/setup-automated', pangolinSetupLimiter, async (req, res) => { try { if (!pangolin_client_1.pangolinClient.configured) { res.status(400).json({ error: { message: 'Pangolin not configured. Set PANGOLIN_API_URL, PANGOLIN_API_KEY, and PANGOLIN_ORG_ID in .env', code: 'NOT_CONFIGURED' }, }); return; } const domain = req.body?.domain || env_1.env.DOMAIN; const siteName = req.body?.siteName || `changemaker-${domain}`; logger_1.logger.info(`Starting automated Pangolin setup for domain: ${domain}`); // --- Step 1: Check for existing site --- let siteId; let newtId; let newtSecret; let siteCreated = false; const existingSites = await pangolin_client_1.pangolinClient.listSites(); // Debug: log existing sites subnets logger_1.logger.info(`Existing sites: ${existingSites.map(s => `${s.name} (subnet: ${s.subnet || s.address})`).join(', ')}`); // Auto-allocate subnet if not provided let subnet = req.body?.subnet; if (!subnet) { subnet = suggestNextSubnet(existingSites); logger_1.logger.info(`Auto-allocated subnet: ${subnet}`); } // Auto-select exit node if not provided let exitNodeId = req.body?.exitNodeId; if (!exitNodeId) { // Try to get exit nodes from dedicated endpoint const exitNodes = await pangolin_client_1.pangolinClient.listExitNodes(); if (exitNodes && exitNodes.length > 0) { // Prefer online exit nodes, fallback to any available const onlineNode = exitNodes.find(n => n.online); const selectedNode = onlineNode || exitNodes[0]; exitNodeId = selectedNode.exitNodeId; logger_1.logger.info(`Auto-selected exit node: ${exitNodeId} (${selectedNode.name || 'unnamed'})`); } else { // Fallback: reuse exit node from existing site (self-hosted mode) logger_1.logger.info('No dedicated exit nodes endpoint, checking existing sites for exit node ID'); if (existingSites.length > 0) { // Find a site with an exit node configured const siteWithExitNode = existingSites.find(s => s.exitNodeId); if (siteWithExitNode && siteWithExitNode.exitNodeId) { exitNodeId = siteWithExitNode.exitNodeId; logger_1.logger.info(`Reusing exit node from existing site: ${exitNodeId}`); } else { logger_1.logger.warn('Existing sites found but none have exitNodeId configured'); // Last resort: try creating without exitNodeId (might work for self-hosted) logger_1.logger.info('Attempting site creation without exitNodeId (self-hosted mode)'); exitNodeId = undefined; } } else { logger_1.logger.warn('No existing sites to reuse exit node from'); // Try without exitNodeId for self-hosted mode exitNodeId = undefined; } } } // Ensure existingSites is an array (handle API errors gracefully) if (!Array.isArray(existingSites)) { logger_1.logger.error('listSites did not return an array:', existingSites); res.status(500).json({ error: { message: 'Failed to retrieve existing sites from Pangolin API', code: 'PANGOLIN_ERROR' } }); return; } const existingSite = existingSites.find(s => s.name === siteName); if (existingSite && existingSite.siteId) { logger_1.logger.info(`Using existing site: ${existingSite.siteId}`); siteId = existingSite.siteId; // Use existing credentials if available if (env_1.env.PANGOLIN_NEWT_ID && env_1.env.PANGOLIN_NEWT_SECRET) { newtId = env_1.env.PANGOLIN_NEWT_ID; newtSecret = env_1.env.PANGOLIN_NEWT_SECRET; } } else { // Create new site const logMsg = exitNodeId ? `Creating new site: ${siteName} with subnet ${subnet} and exit node ${exitNodeId}` : `Creating new site: ${siteName} with subnet ${subnet} (no exit node - self-hosted mode)`; logger_1.logger.info(logMsg); const payload = { name: siteName, type: 'newt', subnet, ...(exitNodeId && { exitNodeId: String(exitNodeId) }) // Ensure exitNodeId is a string }; logger_1.logger.info(`Pangolin createSite payload: ${JSON.stringify(payload)}`); const site = await pangolin_client_1.pangolinClient.createSite(payload); siteId = site.siteId; newtId = site.newt?.newtId; newtSecret = site.newt?.secret; siteCreated = true; logger_1.logger.info(`Site created: ${siteId}`); } // --- Step 2: Load resource definitions and create resources --- const resourceDefs = loadResourceDefinitions(); if (resourceDefs.length === 0) { logger_1.logger.warn('No resource definitions found - check configs/pangolin/resources.yml'); } const created = []; const updated = []; const skipped = []; const warnings = []; const errors = []; // Get domain ID for the domain const domains = await pangolin_client_1.pangolinClient.listDomains(); logger_1.logger.info(`Fetched ${domains.length} domains from Pangolin: ${domains.map(d => d.baseDomain).join(', ')}`); const matchingDomain = domains.find(d => domain.endsWith(d.baseDomain)); if (!matchingDomain) { logger_1.logger.error(`Domain not found in Pangolin: ${domain}. Available domains: ${domains.map(d => d.baseDomain).join(', ')}`); res.status(400).json({ error: { message: `Domain "${domain}" not registered in Pangolin. Please register it first in the Pangolin dashboard under Domains.`, code: 'DOMAIN_NOT_FOUND', availableDomains: domains.map(d => d.baseDomain) } }); return; } logger_1.logger.info(`Using domain: ${matchingDomain.baseDomain} (ID: ${matchingDomain.domainId})`); // Get existing resources const existingResources = await pangolin_client_1.pangolinClient.listResources(); const existingByDomain = new Map(existingResources.map(r => [r.fullDomain || '', r])); for (const def of resourceDefs) { const fullDomain = def.subdomain ? `${def.subdomain}.${domain}` : domain; // Validate container before creating/updating resource const validation = await validateContainer(def); if (!validation.valid) { if (validation.shouldSkip) { warnings.push(`${fullDomain}: ${validation.warning}`); logger_1.logger.warn(validation.warning); continue; } else { errors.push(`${fullDomain}: ${validation.warning}`); logger_1.logger.error(validation.warning); continue; } } const existingResource = existingByDomain.get(fullDomain); if (existingResource) { // Check if resource needs update const desired = { name: def.name, ssl: true, proxyPort: 80, protocol: 'http', fullDomain, }; if (resourceNeedsUpdate(existingResource, desired)) { try { await pangolin_client_1.pangolinClient.updateResource(existingResource.resourceId, { name: desired.name, ssl: desired.ssl, proxyPort: desired.proxyPort, protocol: desired.protocol, fullDomain: desired.fullDomain, }); updated.push(fullDomain); logger_1.logger.info(`Updated resource: ${fullDomain}`); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; errors.push(`${fullDomain} (update): ${msg}`); logger_1.logger.error(`Failed to update resource ${fullDomain}:`, err); } } else { skipped.push(fullDomain); } } else { // Create new HTTP resource try { const resource = await pangolin_client_1.pangolinClient.createResource({ name: def.name, type: 'http', domainId: matchingDomain.domainId, subdomain: def.subdomain, http: true, ssl: true, enabled: true, }); // Add target after resource creation try { await pangolin_client_1.pangolinClient.createTarget(resource.resourceId, { method: def.protocol === 'https' ? 'https' : 'http', host: 'nginx', // All resources proxy through nginx port: def.port, }); created.push(fullDomain); logger_1.logger.info(`Created resource and target: ${fullDomain} -> nginx:${def.port}`); } catch (targetErr) { const msg = targetErr instanceof Error ? targetErr.message : 'Unknown error'; warnings.push(`${fullDomain}: Resource created but target failed: ${msg}`); logger_1.logger.warn(`Failed to add target for ${fullDomain}:`, targetErr); } } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; errors.push(`${fullDomain}: ${msg}`); logger_1.logger.error(`Failed to create resource ${fullDomain}:`, err); } } } // --- Step 3: Update .env file atomically (only if new site with credentials) --- let envUpdated = false; if (siteCreated && newtId && newtSecret) { try { const { readFileSync, writeFileSync } = await Promise.resolve().then(() => __importStar(require('fs'))); const { join } = await Promise.resolve().then(() => __importStar(require('path'))); const envPath = join(process.cwd(), '.env'); const backupPath = join(process.cwd(), `.env.backup.${Date.now()}`); // Read current .env let envContent = readFileSync(envPath, 'utf8'); // Create backup writeFileSync(backupPath, envContent, 'utf8'); logger_1.logger.info(`Created .env backup: ${backupPath}`); // Helper to update or add env var const updateEnvVar = (content, key, value) => { const regex = new RegExp(`^${key}=.*$`, 'm'); if (regex.test(content)) { return content.replace(regex, `${key}=${value}`); } else { return content + `\n${key}=${value}`; } }; // Update all Pangolin credentials envContent = updateEnvVar(envContent, 'PANGOLIN_SITE_ID', siteId); envContent = updateEnvVar(envContent, 'PANGOLIN_NEWT_ID', newtId); envContent = updateEnvVar(envContent, 'PANGOLIN_NEWT_SECRET', newtSecret); envContent = updateEnvVar(envContent, 'PANGOLIN_ENDPOINT', env_1.env.PANGOLIN_ENDPOINT || 'https://api.bnkserve.org'); envContent = updateEnvVar(envContent, 'DOMAIN', domain); // Atomic write writeFileSync(envPath, envContent, 'utf8'); envUpdated = true; logger_1.logger.info('.env file updated with Newt credentials'); } catch (err) { logger_1.logger.error('Failed to update .env file:', err); errors.push('Failed to update .env file - manual update required'); } } // --- Step 4: Restart Newt container (only if credentials updated) --- let containerRestarted = false; if (envUpdated) { try { const result = await docker_service_1.dockerService.restartContainer('newt'); if (result.success) { containerRestarted = true; logger_1.logger.info('Newt container restarted successfully'); } else { logger_1.logger.error('Failed to restart Newt container:', result.output); errors.push('Failed to restart Newt container - manual restart required'); } } catch (err) { logger_1.logger.error('Failed to restart Newt container:', err); errors.push('Failed to restart Newt container - manual restart required'); } } // --- Step 5: Verify tunnel operational (only if container restarted) --- let tunnelReady = false; if (containerRestarted) { // Wait a few seconds for container to start await new Promise(resolve => setTimeout(resolve, 5000)); try { const testUrl = `https://app.${domain}/api/health`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout const response = await fetch(testUrl, { method: 'GET', signal: controller.signal, }); clearTimeout(timeoutId); tunnelReady = response.ok; if (tunnelReady) { logger_1.logger.info('Tunnel verification successful'); } else { logger_1.logger.warn('Tunnel verification failed - may still be initializing'); } } catch (err) { logger_1.logger.warn('Tunnel verification failed - may still be initializing:', err); } } // --- Return full status --- res.json({ success: true, site: { siteId, name: siteName, created: siteCreated, credentials: newtId && newtSecret ? { newtId: `${newtId.substring(0, 8)}...`, // Masked for security secretMasked: `${newtSecret.substring(0, 8)}...`, } : null, }, resources: { created: created.length, updated: updated.length, skipped: skipped.length, total: resourceDefs.length, warnings: warnings.length, errors: errors.length, details: { created, updated, skipped, warnings, errors }, }, automation: { envUpdated, containerRestarted, tunnelReady, }, instructions: !envUpdated ? [ 'Site already exists. To update credentials:', '1. Check .env file for existing PANGOLIN_NEWT_ID and PANGOLIN_NEWT_SECRET', '2. If missing, retrieve from Pangolin dashboard', '3. Restart Newt container: docker compose up -d newt', ] : [ '✓ Site created', '✓ Resources synced', '✓ .env updated automatically', '✓ Newt container restarted', tunnelReady ? '✓ Tunnel verified operational' : '⚠ Tunnel initializing (check status in a few minutes)', ], }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; logger_1.logger.error('Automated Pangolin setup failed:', err); res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); } }); // POST /api/pangolin/sync — Sync resources (create missing, update changed, report status) router.post('/sync', pangolinSetupLimiter, async (_req, res) => { try { if (!pangolin_client_1.pangolinClient.configured) { res.status(400).json({ error: { message: 'Pangolin not configured', code: 'NOT_CONFIGURED' } }); return; } const siteId = env_1.env.PANGOLIN_SITE_ID; if (!siteId) { res.status(400).json({ error: { message: 'PANGOLIN_SITE_ID not set. Run setup first.', code: 'NO_SITE' } }); return; } const domain = env_1.env.DOMAIN; // Load resource definitions from YAML const resourceDefs = loadResourceDefinitions(); if (resourceDefs.length === 0) { res.status(500).json({ error: { message: 'No resource definitions found. Check configs/pangolin/resources.yml', code: 'NO_CONFIG' } }); return; } // Get existing resources const existing = await pangolin_client_1.pangolinClient.listResources(); const existingByDomain = new Map(existing.map(r => [r.fullDomain || '', r])); const created = []; const updated = []; const skipped = []; const warnings = []; const errors = []; for (const def of resourceDefs) { const fullDomain = def.subdomain ? `${def.subdomain}.${domain}` : domain; // Validate container before creating/updating resource const validation = await validateContainer(def); if (!validation.valid) { if (validation.shouldSkip) { warnings.push(`${fullDomain}: ${validation.warning}`); logger_1.logger.warn(validation.warning); continue; } else { errors.push(`${fullDomain}: ${validation.warning}`); logger_1.logger.error(validation.warning); continue; } } const existingResource = existingByDomain.get(fullDomain); if (existingResource) { // Check if resource needs update const desired = { name: def.name, ssl: true, proxyPort: 80, protocol: 'http', fullDomain, }; if (resourceNeedsUpdate(existingResource, desired)) { try { await pangolin_client_1.pangolinClient.updateResource(existingResource.resourceId, { name: desired.name, ssl: desired.ssl, proxyPort: desired.proxyPort, protocol: desired.protocol, fullDomain: desired.fullDomain, }); updated.push(fullDomain); logger_1.logger.info(`Updated resource: ${fullDomain}`); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; errors.push(`${fullDomain} (update): ${msg}`); logger_1.logger.error(`Failed to update resource ${fullDomain}:`, err); } } else { skipped.push(fullDomain); } } else { // Create new resource try { await pangolin_client_1.pangolinClient.createResource({ name: def.name, siteId, subdomain: def.subdomain || undefined, fullDomain, ssl: true, http: true, protocol: 'http', proxyPort: 80, }); created.push(fullDomain); logger_1.logger.info(`Created resource: ${fullDomain}`); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; errors.push(`${fullDomain}: ${msg}`); logger_1.logger.error(`Failed to create resource ${fullDomain}:`, err); } } } res.json({ success: true, created: created.length, updated: updated.length, skipped: skipped.length, warnings: warnings.length, errors: errors.length, details: { created, updated, skipped, warnings, errors }, }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; logger_1.logger.error('Pangolin sync failed:', err); res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); } }); // DELETE /api/pangolin/resource/:id — Delete a resource router.delete('/resource/:id', async (req, res) => { try { const resourceId = req.params.id; await pangolin_client_1.pangolinClient.deleteResource(resourceId); res.json({ success: true }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); } }); // PUT /api/pangolin/resource/:id — Update a resource router.put('/resource/:id', async (req, res) => { try { const resourceId = req.params.id; const resource = await pangolin_client_1.pangolinClient.updateResource(resourceId, req.body); res.json({ success: true, resource }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; logger_1.logger.error('Pangolin resource update failed:', err); res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); } }); // GET /api/pangolin/certificate/:domainId/:domain — Get certificate info router.get('/certificate/:domainId/:domain', async (req, res) => { try { const { domainId, domain } = req.params; const certificate = await pangolin_client_1.pangolinClient.getCertificate(domainId, domain); res.json({ certificate }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); } }); // POST /api/pangolin/certificate/:certId — Update certificate router.post('/certificate/:certId', async (req, res) => { try { const certId = req.params.certId; const certificate = await pangolin_client_1.pangolinClient.updateCertificate(certId, req.body); res.json({ success: true, certificate }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); } }); // GET /api/pangolin/resource/:id/clients — List connected clients for a resource router.get('/resource/:id/clients', async (req, res) => { try { const resourceId = req.params.id; const clients = await pangolin_client_1.pangolinClient.listClients(resourceId); res.json({ clients }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); } }); exports.pangolinRouter = router; //# sourceMappingURL=pangolin.routes.js.map