changemaker.lite/api/src/modules/pangolin/pangolin.routes.ts
bunker-admin 99a6abab06 Add video card insert feature + MkDocs video hydration + fixes
- New video card block for GrapesJS landing pages, email templates,
  MkDocs export, and documentation editor Insert dropdown
- Shared HTML generators in admin/src/utils/videoCardHtml.ts
- MkDocs video-player.js hydrates .video-card-block elements:
  thumbnail fix via MEDIA_API_URL, click-to-play inline, Gallery link
- Media API CORS: auto-add MkDocs + docs subdomain origins
- env_config_hook.py: smart Docker hostname detection, ADMIN_PORT
  resolution, pass env vars to MkDocs container
- Gallery URL uses /gallery?expanded=ID format
- VideoPickerModal: fix double /api prefix and Docker hostname thumbs
- Seed: default-video-card PageBlock
- Remove V1 legacy code (influence/, map/)

Bunker Admin
2026-02-17 15:42:32 -07:00

1006 lines
34 KiB
TypeScript

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<any>,
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<string, unknown> = {
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;