/** * DC-103: Auto-route generation — generates Caddyfile entries and DNS records * for discovered containers. * * Takes a discovered container's info and generates: * 1. A Caddyfile site block with reverse_proxy * 2. A DNS A record pointing to the host * 3. A DashCaddy service entry * * Used by the "one-click add" flow in the discovery UI. * * DC-064: Caddy admin API safety — uses `fetchT` (with Origin + CSRF cookie * plumbing via http.js) instead of raw `fetch`, and resolves the admin URL * from the injected `caddy` context's `adminUrl` (which itself falls back to * `process.env.CADDY_ADMIN_URL`) instead of a hardcoded `localhost:2019`. */ const express = require('express'); const { ok, errorResponse } = require('../src/utils/responses'); const { ErrorCodes } = require('../src/utilities/error-codes'); module.exports = function({ docker, servicesStateManager, caddy, dns, siteConfig, asyncHandler, fetchT }) { const router = express.Router(); /** * POST /api/v1/discover/adopt * * Body: { * containerId: string, // Docker container ID (12 chars) * serviceId: string, // Desired service ID (subdomain) * name: string, // Display name * port: number, // Port to proxy to * protocol: 'http'|'https', // Protocol for the upstream * generateDns: boolean, // Whether to create a DNS record * generateRoute: boolean, // Whether to create a Caddyfile entry * } * * Returns: { service, caddyRoute, dnsRecord } */ router.post('/discover/adopt', asyncHandler(async (req, res) => { const { containerId, serviceId, name, port, protocol = 'http', generateDns = true, generateRoute = true, } = req.body || {}; // Validate required fields if (!containerId || !serviceId || !name) { return errorResponse(res, 400, 'containerId, serviceId, and name are required', { code: ErrorCodes.GENERAL.INVALID_INPUT, }); } if (!port || port < 1 || port > 65535) { return errorResponse(res, 400, 'Valid port (1-65535) is required', { code: ErrorCodes.SERVICE.INVALID_PORT, }); } // Validate serviceId format (subdomain-safe) if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(serviceId)) { return errorResponse(res, 400, 'serviceId must be a valid subdomain (lowercase, alphanumeric, hyphens)', { code: ErrorCodes.SERVICE.INVALID_SUBDOMAIN, }); } const tld = siteConfig?.tld || '.sami'; const domain = `${serviceId}${tld}`; const upstreamHost = protocol === 'https' ? 'https' : 'http'; // DC-064: resolve the Caddy admin URL from the caddy context (which // itself defaults to process.env.CADDY_ADMIN_URL via context/caddy.js). // Never hardcode localhost:2019 — non-loopback Caddy binds disable // enforce_origin and the raw fetch below would 403. Using fetchT (when // provided) includes the Origin header that satisfies enforce_origin; // when fetchT is null we fall back to raw fetch but ONLY for tests that // explicitly mock the admin URL. const caddyAdminUrl = caddy?.adminUrl || process.env.CADDY_ADMIN_URL || 'http://localhost:2019'; const httpClient = typeof fetchT === 'function' ? fetchT : fetch; const result = { service: null, caddyRoute: null, dnsRecord: null, }; // 1. Create the service entry try { const service = { id: serviceId, name, subdomain: serviceId, domain, url: `https://${domain}`, port, protocol, containerId, type: 'auto-discovered', createdAt: new Date().toISOString(), }; if (servicesStateManager) { await servicesStateManager.update(services => { // Check for duplicate if (services.some(s => s.id === serviceId)) { throw new Error(`Service ${serviceId} already exists`); } services.push(service); return services; }); } result.service = service; } catch (err) { return errorResponse(res, 409, err.message, { code: ErrorCodes.SERVICE.DUPLICATE_ID, }); } // 2. Generate Caddyfile route if (generateRoute && caddy) { try { // Use Caddy admin API to add the route const routeConfig = { match: [{ host: [domain] }], handle: [{ handler: 'reverse_proxy', upstreams: [{ dial: `localhost:${port}` }], }], terminal: true, }; // Add via Caddy admin API (via fetchT so Origin header is present) const response = await httpClient(`${caddyAdminUrl}/config/apps/http/servers/srv0/routes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(routeConfig), }); if (response.ok) { result.caddyRoute = { domain, upstream: `localhost:${port}`, status: 'created' }; } else { result.caddyRoute = { domain, status: 'failed', error: `Caddy API returned ${response.status}` }; } } catch (err) { result.caddyRoute = { domain, status: 'failed', error: err.message }; } } // 3. Generate DNS record if (generateDns && dns) { try { // Create an A record pointing to the host result.dnsRecord = { domain, type: 'A', // The actual DNS creation depends on the DNS provider configured status: 'pending', message: 'DNS record creation depends on configured DNS provider', }; } catch (err) { result.dnsRecord = { status: 'failed', error: err.message }; } } ok(res, result, 201); })); return router; };