1142 lines
50 KiB
JavaScript

"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