import { Router, type Request, type Response } from 'express'; import rateLimit from 'express-rate-limit'; import RedisStore from 'rate-limit-redis'; import { readFileSync } from 'fs'; import { join } from 'path'; import { parse as parseYAML } from 'yaml'; import { authenticate } from '../../middleware/auth.middleware'; import { requireRole } from '../../middleware/rbac.middleware'; import { redis } from '../../config/redis'; import { pangolinClient, type CreateHttpResourcePayload, type CreateTargetPayload } from '../../services/pangolin.client'; import { dockerService } from '../../services/docker.service'; import { env } from '../../config/env'; import { logger } from '../../utils/logger'; // Rate limiter for expensive Pangolin operations const pangolinSetupLimiter = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes max: 3, // Max 3 operations per 5 minutes per user keyGenerator: (req: Request) => `pangolin-setup:${req.user?.id || req.ip}`, standardHeaders: true, legacyHeaders: false, store: new RedisStore({ sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise, prefix: 'rl:pangolin-setup:', }), message: { error: { message: 'Too many Pangolin setup operations. Please wait 5 minutes before trying again.', code: 'PANGOLIN_RATE_LIMIT_EXCEEDED', }, }, }); // --- Resource Definitions from YAML --- interface ResourceDefinition { subdomain: string; name: string; container: string; port: number; required: boolean; profile?: string; } interface ResourceConfig { resources: ResourceDefinition[]; } /** * Load resource definitions from YAML config file */ function loadResourceDefinitions(): ResourceDefinition[] { try { const configPath = join(__dirname, '../../../configs/pangolin/resources.yml'); const fileContent = readFileSync(configPath, 'utf8'); const config = parseYAML(fileContent) as ResourceConfig; if (!config || !Array.isArray(config.resources)) { logger.error('Invalid resources.yml format'); return []; } logger.info(`Loaded ${config.resources.length} resource definitions from YAML`); return config.resources; } catch (err) { 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: ResourceDefinition ): Promise<{ valid: boolean; warning?: string; shouldSkip: boolean }> { try { // Check monitoring profile if (def.profile === 'monitoring') { const grafanaStatus = await 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 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.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: { name?: string; ssl?: boolean; proxyPort?: number; protocol?: string; fullDomain?: string }, desired: { name: string; ssl: boolean; proxyPort: number; protocol: string; fullDomain: string } ): boolean { 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: Array<{ address?: string; subnet?: string }>): string { 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): s is string => 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 = Router(); // --- All endpoints require SUPER_ADMIN authentication --- router.use(authenticate); router.use(requireRole('SUPER_ADMIN')); // GET /api/pangolin/status — Health + connection info router.get('/status', async (_req: Request, res: Response) => { try { const configured = pangolinClient.configured; let healthy = false; if (configured) { healthy = await pangolinClient.healthCheck(); } res.json({ configured, healthy, pangolinUrl: env.PANGOLIN_API_URL || null, orgId: env.PANGOLIN_ORG_ID || null, siteId: env.PANGOLIN_SITE_ID || null, newtConfigured: !!(env.PANGOLIN_NEWT_ID && env.PANGOLIN_NEWT_SECRET), }); } catch (err) { 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: Request, res: Response) => { res.json({ pangolinApiUrl: env.PANGOLIN_API_URL || null, pangolinEndpoint: env.PANGOLIN_ENDPOINT || null, orgId: env.PANGOLIN_ORG_ID || null, siteId: env.PANGOLIN_SITE_ID || null, newtId: env.PANGOLIN_NEWT_ID || null, newtConfigured: !!(env.PANGOLIN_NEWT_ID && env.PANGOLIN_NEWT_SECRET), domain: env.DOMAIN, }); }); // GET /api/pangolin/newt-status — Check newt container status router.get('/newt-status', async (_req: Request, res: Response) => { try { const containerStatus = await dockerService.getContainerStatus('newt-changemaker'); res.json({ newtConfigured: !!(env.PANGOLIN_NEWT_ID && env.PANGOLIN_NEWT_SECRET), containerRunning: containerStatus.running, containerStatus: containerStatus.status, ready: containerStatus.running && !!(env.PANGOLIN_NEWT_ID && env.PANGOLIN_NEWT_SECRET), error: containerStatus.error, }); } catch (err) { 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: Request, res: Response) => { try { if (!env.PANGOLIN_NEWT_ID || !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 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.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: Request, res: Response) => { try { const sites = await 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: Request, res: Response) => { try { const exitNodes = await pangolinClient.listExitNodes(); res.json({ exitNodes }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; 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: Request, res: Response) => { try { const resourceDefs = loadResourceDefinitions(); res.json({ domain: env.DOMAIN, resources: resourceDefs.map(def => ({ name: def.name, subdomain: def.subdomain, fullDomain: def.subdomain ? `${def.subdomain}.${env.DOMAIN}` : env.DOMAIN, port: def.port, container: def.container, required: def.required, })), }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; logger.error('Failed to load resource definitions:', err); res.status(500).json({ error: { message: msg, code: 'CONFIG_ERROR' } }); } }); // GET /api/pangolin/resources — List resources router.get('/resources', async (_req: Request, res: Response) => { try { const resources = await 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: Request, res: Response) => { try { if (!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.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: number) => 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 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.warn(`Creating site with offline exit node: ${exitNodeId} (${node.name})`); } } } catch (err) { logger.warn('Could not verify exit node (exit-nodes endpoint unavailable)'); // Don't fail - proceed with site creation } } else { logger.info('Creating site without exit node (self-hosted mode)'); } // Create site (returns newt credentials) logger.info(`Creating Pangolin site: ${siteName}${subnet ? ` with subnet ${subnet}` : ''}${exitNodeId ? ` via exit node ${exitNodeId}` : ''}`); const site = await pangolinClient.createSite({ name: siteName, type: 'newt', ...(subnet && { subnet }), ...(exitNodeId && { exitNodeId }) }); const siteId = site.siteId; logger.info(`Site created: ${siteId}`); // Load resource definitions from YAML const resourceDefs = loadResourceDefinitions(); if (resourceDefs.length === 0) { logger.error('No resource definitions found - check configs/pangolin/resources.yml'); } // Create resources for all subdomains const created: Array<{ subdomain: string; name: string; siteResourceId: string }> = []; const errors: string[] = []; const warnings: string[] = []; 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.warn(validation.warning); continue; } else { errors.push(`${fullDomain}: ${validation.warning}`); logger.error(validation.warning); continue; } } try { // Get domain ID for the domain const domains = await pangolinClient.listDomains(); const matchingDomain = domains.find(d => domain.endsWith(d.baseDomain)); if (!matchingDomain) { errors.push(`${fullDomain}: Domain not registered in Pangolin`); logger.error(`Domain not found: ${domain}`); continue; } const resource = await pangolinClient.createResource(siteId, { name: def.name, type: 'http', domainId: matchingDomain.domainId, subdomain: def.subdomain || '', http: true, ssl: true, enabled: true, }); // Make resource publicly accessible (not protected) try { await pangolinClient.updateResource(resource.resourceId || resource.siteResourceId, { blockAccess: false, }); logger.info(`Set ${fullDomain} as publicly accessible (blockAccess: false)`); } catch (updateErr) { logger.warn(`Created ${fullDomain} but failed to set as publicly accessible:`, updateErr); } created.push({ subdomain: def.subdomain || '(root)', name: def.name, siteResourceId: resource.resourceId || resource.siteResourceId }); logger.info(`Created HTTP proxy resource: ${fullDomain}`); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; errors.push(`${fullDomain}: ${msg}`); 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.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.error('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: Request, res: Response) => { try { if (!pangolinClient.configured) { res.status(400).json({ error: { message: 'Pangolin not configured', code: 'NOT_CONFIGURED' } }); return; } const siteId = 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.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 pangolinClient.listResources(); const existingByDomain = new Map(existing.map(r => [r.fullDomain || '', r])); const created: string[] = []; const updated: string[] = []; const skipped: string[] = []; const warnings: string[] = []; const errors: string[] = []; 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.warn(validation.warning); continue; } else { errors.push(`${fullDomain}: ${validation.warning}`); 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 pangolinClient.updateResource(existingResource.resourceId!, { name: desired.name, ssl: desired.ssl, proxyPort: desired.proxyPort, protocol: desired.protocol, fullDomain: desired.fullDomain, }); updated.push(fullDomain); logger.info(`Updated resource: ${fullDomain}`); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; errors.push(`${fullDomain} (update): ${msg}`); logger.error(`Failed to update resource ${fullDomain}:`, err); } } else { skipped.push(fullDomain); } } else { // Create new resource try { // Get domain ID for the domain const domains = await pangolinClient.listDomains(); const matchingDomain = domains.find(d => domain.endsWith(d.baseDomain)); if (!matchingDomain) { errors.push(`${fullDomain}: Domain not registered in Pangolin`); logger.error(`Domain not found: ${domain}`); continue; } const resource = await pangolinClient.createResource(siteId, { name: def.name, type: 'http', domainId: matchingDomain.domainId, subdomain: def.subdomain || '', http: true, ssl: true, enabled: true, }); // Make resource publicly accessible (not protected) try { await pangolinClient.updateResource(resource.resourceId || resource.siteResourceId, { blockAccess: false, }); logger.info(`Set ${fullDomain} as publicly accessible (blockAccess: false)`); } catch (updateErr) { logger.warn(`Created ${fullDomain} but failed to set as publicly accessible:`, updateErr); } created.push(fullDomain); logger.info(`Created HTTP proxy resource: ${fullDomain}`); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; errors.push(`${fullDomain}: ${msg}`); 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.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: Request, res: Response) => { try { const resourceId = req.params.id as string; await 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: Request, res: Response) => { try { if (!pangolinClient.configured) { res.status(400).json({ error: { message: 'Pangolin not configured', code: 'NOT_CONFIGURED' }, }); return; } const domain = env.DOMAIN; const testSubdomain = 'test-2step-' + Math.random().toString(36).substring(7); logger.info(`Starting 2-step test for ${testSubdomain}.${domain}`); // Get domain info const domains = await 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; } logger.info(`Using domain: ${matchingDomain.baseDomain} (ID: ${matchingDomain.domainId})`); // Get siteId from env or use first available site let siteId = env.PANGOLIN_SITE_ID; if (!siteId) { const sites = await 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; logger.info(`Using first available site: ${siteId}`); } const results: Record = { testName: `2-Step Resource + Target (${testSubdomain})`, domainId: matchingDomain.domainId, siteId, steps: [], }; // --- Step 1: Create resource WITHOUT target --- logger.info('Step 1: Creating HTTP resource (minimal payload, no target)'); const createResourcePayload: CreateHttpResourcePayload = { name: `Test 2-Step - ${testSubdomain}`, type: 'http', domainId: matchingDomain.domainId, subdomain: testSubdomain, http: true, ssl: true, enabled: true, }; let resourceId: string | undefined; try { logger.info(`Resource payload: ${JSON.stringify(createResourcePayload)}`); const createdResource = await pangolinClient.createResource(siteId, createResourcePayload); resourceId = createdResource.resourceId; logger.info(`✅ Step 1 Success: Resource created (${resourceId})`); (results.steps as any).push({ step: 1, name: 'Create HTTP resource', status: 'success', resourceId, resource: createdResource, }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; logger.error('❌ Step 1 Failed:', err); (results.steps as any).push({ step: 1, name: 'Create HTTP resource', status: 'failed', error: msg, }); res.status(500).json({ success: false, error: { message: msg, code: 'RESOURCE_CREATION_FAILED' }, results, }); return; } // --- Step 2: Create target --- logger.info('Step 2: Creating target for resource'); const createTargetPayload: CreateTargetPayload = { method: 'http', host: 'nginx', port: 4000, }; const targetAttempts: any[] = []; // Try standard endpoint logger.info(`Target payload: ${JSON.stringify(createTargetPayload)}`); try { logger.info('Attempting target creation with standard endpoint...'); const createdTarget = await pangolinClient.createTarget(resourceId!, createTargetPayload); logger.info(`✅ Step 2 Success: Target created (standard endpoint)`); (results.steps as any).push({ step: 2, name: 'Create target (standard endpoint)', status: 'success', payload: createTargetPayload, response: createdTarget, }); targetAttempts.push({ endpoint: 'standard', status: 'success', response: createdTarget, }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; logger.warn('⚠️ Standard endpoint failed, trying alternatives:', err); targetAttempts.push({ endpoint: 'standard', status: 'failed', error: msg, }); // Try alternative endpoint format logger.info('Attempting target creation with alternative endpoint format (site-resource)...'); try { const altResult = await (pangolinClient as any).createTargetAlt?.(resourceId, createTargetPayload, 'site-resource'); if (altResult?.success) { logger.info(`✅ Step 2 Success: Target created (alternative endpoint)`); (results.steps as any).push({ step: 2, name: 'Create target (alternative endpoint)', status: 'success', payload: createTargetPayload, response: altResult.response, }); targetAttempts.push(altResult); } else { logger.warn('⚠️ Alternative endpoint also failed'); targetAttempts.push(altResult || { endpoint: 'site-resource', status: 'failed', error: 'createTargetAlt not available', }); (results.steps as any).push({ step: 2, name: 'Create target', status: 'failed', payload: createTargetPayload, attempts: targetAttempts, error: 'Both target endpoints failed', }); } } catch (altErr) { const altMsg = altErr instanceof Error ? altErr.message : 'Unknown error'; logger.warn('⚠️ Alternative endpoint attempt failed:', altErr); targetAttempts.push({ endpoint: 'site-resource', status: 'failed', error: altMsg, }); (results.steps as any).push({ step: 2, name: 'Create target', status: 'partial_failure', payload: createTargetPayload, attempts: targetAttempts, note: 'Resource created but target creation failed - may still be functional', }); } } // --- Step 3: Verify resource was created --- logger.info('Step 3: Verifying resource exists'); try { const verifiedResource = await pangolinClient.getResource(resourceId!); logger.info(`✅ Step 3 Success: Resource verified`); (results.steps as any).push({ step: 3, name: 'Verify resource', status: 'success', resource: verifiedResource, }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; logger.error('❌ Step 3 Failed:', err); (results.steps as any).push({ step: 3, name: 'Verify resource', status: 'failed', error: msg, }); } // --- Step 4: Cleanup --- logger.info('Step 4: Cleanup (deleting test resource)'); try { await pangolinClient.deleteResource(resourceId!); logger.info(`✅ Step 4 Success: Resource deleted`); (results.steps as any).push({ step: 4, name: 'Cleanup (delete resource)', status: 'success', }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; logger.error('❌ Step 4 Failed:', err); (results.steps as any).push({ step: 4, name: 'Cleanup (delete resource)', 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.error('2-step test failed:', err); res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); } }); // PUT /api/pangolin/resource/:id — Update a resource router.put('/resource/:id', async (req: Request, res: Response) => { try { const resourceId = req.params.id as string; const resource = await pangolinClient.updateResource(resourceId, req.body); res.json({ success: true, resource }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; 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: Request, res: Response) => { try { const { domainId, domain } = req.params; const certificate = await pangolinClient.getCertificate(domainId as string, domain as string); 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: Request, res: Response) => { try { const certId = req.params.certId as string; const certificate = await 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: Request, res: Response) => { try { const resourceId = req.params.id as string; const clients = await 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' } }); } }); export const pangolinRouter = router;