1002 lines
42 KiB
JavaScript

"use strict";
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 zod_1 = require("zod");
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 env_writer_service_1 = require("../../services/env-writer.service");
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 [];
}
// Apply defaults for target_ip/target_port
const resources = config.resources.map(r => ({
...r,
target_ip: r.target_ip || 'nginx',
target_port: r.target_port || 80,
}));
logger_1.logger.info(`Loaded ${resources.length} resource definitions from YAML`);
return resources;
}
catch (err) {
logger_1.logger.error('Failed to load resource definitions:', err);
return [];
}
}
/**
* Validate container is running before creating resource
*/
async function validateContainer(def) {
try {
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,
};
}
}
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,
};
}
}
/**
* Suggest next available subnet based on existing sites
*/
function suggestNextSubnet(sites) {
if (!sites || sites.length === 0) {
return '100.89.128.4/30';
}
const subnets = sites
.map(s => s.subnet)
.filter((s) => Boolean(s))
.map(s => {
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);
if (subnets.length === 0) {
return '100.89.128.4/30';
}
const lastSubnet = subnets[subnets.length - 1].subnet;
if (!lastSubnet) {
return '100.89.128.4/30';
}
const match = lastSubnet.match(/^100\.89\.128\.(\d+)\/30$/);
if (match && match[1]) {
const lastOctet = parseInt(match[1], 10);
const nextOctet = lastOctet + 4;
if (nextOctet <= 252) {
return `100.89.128.${nextOctet}/30`;
}
}
return '100.89.128.4/30';
}
const router = (0, express_1.Router)();
// --- All endpoints require SUPER_ADMIN authentication ---
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/resource-definitions — List resource definitions from YAML config
router.get('/resource-definitions', (_req, res) => {
try {
const resourceDefs = loadResourceDefinitions();
res.json({
domain: env_1.env.DOMAIN,
resources: resourceDefs.map(def => ({
name: def.name,
subdomain: def.subdomain,
fullDomain: def.subdomain ? `${def.subdomain}.${env_1.env.DOMAIN}` : env_1.env.DOMAIN,
port: def.port,
container: def.container,
target_ip: def.target_ip,
target_port: def.target_port,
required: def.required,
})),
});
}
catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
logger_1.logger.error('Failed to load resource definitions:', err);
res.status(500).json({ error: { message: msg, code: 'CONFIG_ERROR' } });
}
});
// GET /api/pangolin/resource-status — Cross-referenced live status of all resources
router.get('/resource-status', async (_req, res) => {
try {
if (!pangolin_client_1.pangolinClient.configured) {
res.status(400).json({ error: { message: 'Pangolin not configured', code: 'NOT_CONFIGURED' } });
return;
}
const domain = env_1.env.DOMAIN;
const siteId = env_1.env.PANGOLIN_SITE_ID || '';
const resourceDefs = loadResourceDefinitions();
const liveResources = await pangolin_client_1.pangolinClient.listResources();
// Build lookup by fullDomain
const liveByDomain = new Map(liveResources.map(r => [r.fullDomain || '', r]));
// Cross-reference YAML definitions with live state
const resources = resourceDefs.map(def => {
const fullDomain = def.subdomain ? `${def.subdomain}.${domain}` : domain;
const live = liveByDomain.get(fullDomain);
// Find matching target (from inline targets array)
const targets = live?.targets || [];
const target = targets[0]; // Primary target
const expectedIp = def.target_ip;
const expectedPort = def.target_port;
const targetCorrect = target
? target.ip === expectedIp && target.port === expectedPort
: false;
// Remove from map so we can find "extras" later
if (live)
liveByDomain.delete(fullDomain);
return {
name: def.name,
subdomain: def.subdomain,
fullDomain,
required: def.required,
container: def.container,
profile: def.profile || null,
expectedTargetIp: expectedIp,
expectedTargetPort: expectedPort,
exists: !!live,
resourceId: live?.resourceId || null,
hasTarget: !!target,
targetCorrect,
actualTargetIp: target?.ip || null,
actualTargetPort: target?.port || null,
targetId: target?.targetId || null,
targetEnabled: target?.enabled ?? null,
ssl: live?.ssl ?? null,
sso: live?.blockAccess ?? null,
blockAccess: live?.blockAccess ?? null,
enabled: live?.active ?? null,
};
});
// Remaining entries in liveByDomain are "extras" — in Pangolin but not in YAML
const extras = Array.from(liveByDomain.values()).map(r => {
const target = r.targets?.[0];
return {
name: r.name,
subdomain: r.subdomain || null,
fullDomain: r.fullDomain || '',
required: false,
container: null,
profile: null,
expectedTargetIp: null,
expectedTargetPort: null,
exists: true,
resourceId: r.resourceId,
hasTarget: !!target,
targetCorrect: true, // No expected value to compare against
actualTargetIp: target?.ip || null,
actualTargetPort: target?.port || null,
targetId: target?.targetId || null,
targetEnabled: target?.enabled ?? null,
ssl: r.ssl ?? null,
sso: r.blockAccess ?? null,
blockAccess: r.blockAccess ?? null,
enabled: r.active ?? null,
};
});
// Summary counts
const healthy = resources.filter(r => r.exists && r.hasTarget && r.targetCorrect).length;
const misconfigured = resources.filter(r => r.exists && (!r.hasTarget || !r.targetCorrect)).length;
const missing = resources.filter(r => !r.exists).length;
res.json({
resources,
extras,
summary: {
total: resources.length,
healthy,
misconfigured,
missing,
extras: extras.length,
},
siteId,
domain,
});
}
catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
logger_1.logger.error('Resource status check failed:', err);
res.status(500).json({ error: { message: msg, 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 — Full automated setup: site + resources + targets + .env update
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 exitNodeId = req.body?.exitNodeId;
const autoWriteEnv = req.body?.autoWriteEnv !== false; // Default true
// Validate exit node ID if provided
if (exitNodeId !== undefined && exitNodeId !== null && exitNodeId !== '') {
if (typeof exitNodeId !== 'string' || exitNodeId.length < 3 || exitNodeId.length > 100) {
res.status(400).json({
error: { message: 'Invalid exit node ID format', code: 'INVALID_EXIT_NODE' },
});
return;
}
try {
const availableNodes = await pangolin_client_1.pangolinClient.listExitNodes();
if (availableNodes.length > 0) {
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}`);
}
}
}
catch {
logger_1.logger.warn('Could not verify exit node (endpoint unavailable)');
}
}
// === STEP 1: Get Newt credentials from Pangolin ===
logger_1.logger.info('Setup Step 1: Fetching Newt credentials via pickSiteDefaults...');
let newtId;
let newtSecret;
let address;
let usedPickDefaults = false;
try {
const defaults = await pangolin_client_1.pangolinClient.pickSiteDefaults();
newtId = defaults.newtId;
newtSecret = defaults.newtSecret;
address = defaults.address;
usedPickDefaults = true;
logger_1.logger.info(`pickSiteDefaults succeeded: newtId=${newtId}`);
}
catch (err) {
// Fallback: older Pangolin versions may not have this endpoint
logger_1.logger.warn('pickSiteDefaults not available, will parse Newt creds from site creation response');
newtId = '';
newtSecret = '';
address = '';
}
// === STEP 2: Create site ===
logger_1.logger.info(`Setup Step 2: Creating site "${siteName}"...`);
const sitePayload = {
name: siteName,
type: 'newt',
...(exitNodeId && { exitNodeId }),
};
// Pass Newt credentials from pickSiteDefaults if available
if (usedPickDefaults) {
sitePayload.newtId = newtId;
sitePayload.secret = newtSecret;
if (address)
sitePayload.address = address;
}
const site = await pangolin_client_1.pangolinClient.createSite(sitePayload);
const siteId = site.siteId;
logger_1.logger.info(`Site created: ${siteId}`);
// If we didn't use pickSiteDefaults, try to get creds from site response
if (!usedPickDefaults && site.newt) {
newtId = site.newt.newtId;
newtSecret = site.newt.secret;
logger_1.logger.info(`Got Newt credentials from site creation response: newtId=${newtId}`);
}
// === STEP 3: Get domain ID (once, for all resources) ===
logger_1.logger.info('Setup Step 3: Looking up domain...');
const domains = await pangolin_client_1.pangolinClient.listDomains();
const matchingDomain = domains.find(d => domain.endsWith(d.baseDomain));
if (!matchingDomain) {
res.status(400).json({
error: { message: `Domain "${domain}" not registered in Pangolin. Register it first.`, code: 'DOMAIN_NOT_FOUND' },
});
return;
}
logger_1.logger.info(`Using domain: ${matchingDomain.baseDomain} (ID: ${matchingDomain.domainId})`);
// === STEP 4: Create resources + targets ===
logger_1.logger.info('Setup Step 4: Creating resources and targets...');
const resourceDefs = loadResourceDefinitions();
if (resourceDefs.length === 0) {
logger_1.logger.error('No resource definitions found - check configs/pangolin/resources.yml');
}
const created = [];
const errors = [];
const warnings = [];
for (const def of resourceDefs) {
const fullDomain = def.subdomain ? `${def.subdomain}.${domain}` : domain;
// Validate container
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 {
// Step 4a: Create HTTP resource
const resourcePayload = {
name: def.name,
domainId: matchingDomain.domainId,
subdomain: def.subdomain || '',
http: true,
protocol: 'tcp',
};
const resource = await pangolin_client_1.pangolinClient.createResource(resourcePayload);
const resourceId = resource.resourceId;
logger_1.logger.info(`Created resource: ${fullDomain} (ID: ${resourceId})`);
// Make resource publicly accessible
try {
await pangolin_client_1.pangolinClient.updateResource(resourceId, { blockAccess: false });
logger_1.logger.info(`Set ${fullDomain} as publicly accessible`);
}
catch (updateErr) {
logger_1.logger.warn(`Created ${fullDomain} but failed to set public access:`, updateErr);
}
// Step 4b: Create target (THE MISSING STEP!)
let targetCreated = false;
try {
const targetPayload = {
siteId,
ip: def.target_ip,
port: def.target_port,
method: 'http',
enabled: true,
};
await pangolin_client_1.pangolinClient.createTarget(resourceId, targetPayload);
targetCreated = true;
logger_1.logger.info(`Created target for ${fullDomain}: ${def.target_ip}:${def.target_port}`);
}
catch (targetErr) {
const msg = targetErr instanceof Error ? targetErr.message : 'Unknown error';
errors.push(`${fullDomain} (target): ${msg}`);
logger_1.logger.error(`Failed to create target for ${fullDomain}: ${msg}`);
}
created.push({ subdomain: def.subdomain || '(root)', name: def.name, resourceId, targetCreated });
}
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}`);
}
}
// === STEP 5: Auto-write .env file ===
let envWriteResult = null;
if (autoWriteEnv && newtId && newtSecret) {
logger_1.logger.info('Setup Step 5: Writing credentials to .env...');
const pangolinEndpoint = env_1.env.PANGOLIN_ENDPOINT || env_1.env.PANGOLIN_API_URL.replace('/v1', '');
envWriteResult = (0, env_writer_service_1.updateEnvFile)({
PANGOLIN_SITE_ID: siteId,
PANGOLIN_NEWT_ID: newtId,
PANGOLIN_NEWT_SECRET: newtSecret,
PANGOLIN_ENDPOINT: pangolinEndpoint,
});
if (envWriteResult.success) {
logger_1.logger.info('Credentials written to .env successfully');
}
else {
logger_1.logger.error(`Failed to write .env: ${envWriteResult.error}`);
warnings.push(`.env auto-write failed: ${envWriteResult.error}`);
}
}
// === STEP 6: Restart Newt container ===
let newtRestarted = false;
if (autoWriteEnv && envWriteResult?.success) {
logger_1.logger.info('Setup Step 6: Restarting Newt container...');
try {
const result = await docker_service_1.dockerService.restartContainer('newt');
newtRestarted = result.success;
if (!result.success) {
warnings.push(`Newt restart failed: ${result.output}`);
}
}
catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
warnings.push(`Newt restart error: ${msg}`);
}
}
res.json({
success: true,
site: {
siteId,
name: siteName,
},
newt: {
newtId: newtId || null,
newtSecret: newtSecret ? '***' : null, // Don't expose in response when auto-written
credentialsAutoWritten: envWriteResult?.success || false,
containerRestarted: newtRestarted,
},
resources: {
created: created.length,
total: resourceDefs.length,
items: created,
errors,
warnings,
},
envUpdate: envWriteResult ? {
success: envWriteResult.success,
updated: envWriteResult.updated,
added: envWriteResult.added,
error: envWriteResult.error,
} : null,
// Fallback instructions if auto-write failed
...(!envWriteResult?.success && newtId && {
manualInstructions: [
'Auto-write to .env failed. Add these manually:',
`PANGOLIN_SITE_ID=${siteId}`,
`PANGOLIN_NEWT_ID=${newtId}`,
`PANGOLIN_NEWT_SECRET=${newtSecret}`,
`PANGOLIN_ENDPOINT=${env_1.env.PANGOLIN_ENDPOINT || env_1.env.PANGOLIN_API_URL.replace('/v1', '')}`,
'Then restart the newt container: docker compose up -d newt',
],
}),
});
}
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/sync — Sync resources (create missing, verify targets)
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 siteSlug = env_1.env.PANGOLIN_SITE_ID;
if (!siteSlug) {
res.status(400).json({ error: { message: 'PANGOLIN_SITE_ID not set. Run setup first.', code: 'NO_SITE' } });
return;
}
// Resolve numeric siteId from slug (Pangolin targets require numeric siteId)
let siteId = siteSlug;
if (isNaN(Number(siteSlug))) {
const sites = await pangolin_client_1.pangolinClient.listSites();
const match = sites.find(s => s.niceId === siteSlug || s.name === siteSlug);
if (match) {
siteId = match.siteId;
logger_1.logger.info(`Resolved site slug "${siteSlug}" to numeric siteId ${siteId}`);
}
else {
logger_1.logger.warn(`Could not resolve site slug "${siteSlug}" to numeric ID, using as-is`);
}
}
const domain = env_1.env.DOMAIN;
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 and domain info
const [existing, domains] = await Promise.all([
pangolin_client_1.pangolinClient.listResources(),
pangolin_client_1.pangolinClient.listDomains(),
]);
const matchingDomain = domains.find(d => domain.endsWith(d.baseDomain));
if (!matchingDomain) {
res.status(400).json({
error: { message: `Domain "${domain}" not registered in Pangolin`, code: 'DOMAIN_NOT_FOUND' }
});
return;
}
// Build lookup by full domain
const existingByDomain = new Map(existing.map(r => [r.fullDomain || '', r]));
const created = [];
const targetFixed = [];
const skipped = [];
const warnings = [];
const errors = [];
for (const def of resourceDefs) {
const fullDomain = def.subdomain ? `${def.subdomain}.${domain}` : domain;
// Validate container
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) {
// Resource exists — verify it has a target
try {
const targets = await pangolin_client_1.pangolinClient.listTargets(existingResource.resourceId);
if (targets.length === 0) {
// Missing target — create one
logger_1.logger.info(`Resource ${fullDomain} has no target, creating one...`);
await pangolin_client_1.pangolinClient.createTarget(existingResource.resourceId, {
siteId,
ip: def.target_ip,
port: def.target_port,
method: 'http',
enabled: true,
});
targetFixed.push(fullDomain);
logger_1.logger.info(`Created missing target for ${fullDomain}`);
}
else {
skipped.push(fullDomain);
}
}
catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
// If listing targets fails, try to create one anyway
try {
await pangolin_client_1.pangolinClient.createTarget(existingResource.resourceId, {
siteId,
ip: def.target_ip,
port: def.target_port,
method: 'http',
enabled: true,
});
targetFixed.push(fullDomain);
}
catch (targetErr) {
errors.push(`${fullDomain} (target check): ${msg}`);
}
}
}
else {
// Create new resource + target
try {
const resource = await pangolin_client_1.pangolinClient.createResource({
name: def.name,
domainId: matchingDomain.domainId,
subdomain: def.subdomain || '',
http: true,
protocol: 'tcp',
});
// Make publicly accessible (disable SSO auth + blockAccess)
try {
await pangolin_client_1.pangolinClient.updateResource(resource.resourceId, { sso: false, blockAccess: false });
}
catch {
logger_1.logger.warn(`Created ${fullDomain} but failed to set public access`);
}
// Create target
try {
await pangolin_client_1.pangolinClient.createTarget(resource.resourceId, {
siteId,
ip: def.target_ip,
port: def.target_port,
method: 'http',
enabled: true,
});
}
catch (targetErr) {
const msg = targetErr instanceof Error ? targetErr.message : 'Unknown error';
errors.push(`${fullDomain} (target): ${msg}`);
}
created.push(fullDomain);
logger_1.logger.info(`Created resource + target: ${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,
targetFixed: targetFixed.length,
skipped: skipped.length,
warnings: warnings.length,
errors: errors.length,
details: { created, targetFixed, 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' } });
}
});
// POST /api/pangolin/test-2step — Test 2-step resource + target creation (diagnostic endpoint)
router.post('/test-2step', 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 domain = env_1.env.DOMAIN;
const testSubdomain = 'test-2step-' + Math.random().toString(36).substring(7);
logger_1.logger.info(`Starting 2-step test for ${testSubdomain}.${domain}`);
// Get domain info
const domains = await pangolin_client_1.pangolinClient.listDomains();
const matchingDomain = domains.find(d => domain.endsWith(d.baseDomain));
if (!matchingDomain) {
res.status(400).json({
error: { message: `Domain not found: ${domain}`, code: 'DOMAIN_NOT_FOUND' },
});
return;
}
// Get siteId
let siteId = env_1.env.PANGOLIN_SITE_ID;
if (!siteId) {
const sites = await pangolin_client_1.pangolinClient.listSites();
if (sites.length === 0) {
res.status(400).json({
error: { message: 'No sites available. Run setup first.', code: 'NO_SITE' },
});
return;
}
siteId = sites[0].siteId;
}
const results = {
testName: `2-Step Resource + Target (${testSubdomain})`,
domainId: matchingDomain.domainId,
siteId,
steps: [],
};
// --- Step 1: Create resource ---
let resourceId;
try {
const createdResource = await pangolin_client_1.pangolinClient.createResource({
name: `Test 2-Step - ${testSubdomain}`,
domainId: matchingDomain.domainId,
subdomain: testSubdomain,
http: true,
protocol: 'tcp',
});
resourceId = createdResource.resourceId;
results.steps.push({
step: 1, name: 'Create HTTP resource', status: 'success', resourceId,
});
}
catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
results.steps.push({
step: 1, name: 'Create HTTP resource', status: 'failed', error: msg,
});
res.status(500).json({ success: false, results });
return;
}
// --- Step 2: Create target (PUT, correct payload) ---
try {
const targetPayload = {
siteId,
ip: 'nginx',
port: 80,
method: 'http',
enabled: true,
};
const createdTarget = await pangolin_client_1.pangolinClient.createTarget(resourceId, targetPayload);
results.steps.push({
step: 2, name: 'Create target', status: 'success',
payload: targetPayload, response: createdTarget,
});
}
catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
results.steps.push({
step: 2, name: 'Create target', status: 'failed',
error: msg,
});
}
// --- Step 3: Verify resource ---
try {
const verified = await pangolin_client_1.pangolinClient.getResource(resourceId);
results.steps.push({
step: 3, name: 'Verify resource', status: 'success', resource: verified,
});
}
catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
results.steps.push({
step: 3, name: 'Verify resource', status: 'failed', error: msg,
});
}
// --- Step 4: Cleanup ---
try {
await pangolin_client_1.pangolinClient.deleteResource(resourceId);
results.steps.push({
step: 4, name: 'Cleanup', status: 'success',
});
}
catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
results.steps.push({
step: 4, name: 'Cleanup', status: 'failed', error: msg, resourceId,
});
}
res.json({ success: true, message: '2-step test completed', results });
}
catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
logger_1.logger.error('2-step test failed:', err);
res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } });
}
});
// PUT /api/pangolin/resource/:id — Update a resource
const updateResourceSchema = zod_1.z.object({
name: zod_1.z.string().max(200).optional(),
subdomain: zod_1.z.string().max(200).optional(),
ssl: zod_1.z.boolean().optional(),
blockAccess: zod_1.z.boolean().optional(),
proxyPort: zod_1.z.number().int().optional(),
protocol: zod_1.z.string().max(20).optional(),
domainId: zod_1.z.string().max(200).optional(),
isBaseDomain: zod_1.z.boolean().optional(),
http: zod_1.z.boolean().optional(),
https: zod_1.z.boolean().optional(),
}).passthrough();
router.put('/resource/:id', async (req, res) => {
try {
const resourceId = req.params.id;
const body = updateResourceSchema.parse(req.body);
const resource = await pangolin_client_1.pangolinClient.updateResource(resourceId, 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
const updateCertificateSchema = zod_1.z.object({
autoRenew: zod_1.z.boolean().optional(),
isWildcard: zod_1.z.boolean().optional(),
}).passthrough();
router.post('/certificate/:certId', async (req, res) => {
try {
const certId = req.params.certId;
const body = updateCertificateSchema.parse(req.body);
const certificate = await pangolin_client_1.pangolinClient.updateCertificate(certId, 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