1002 lines
42 KiB
JavaScript
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
|