Files
dashcaddy/dashcaddy-api/routes/discover-adopt.js
T

173 lines
5.7 KiB
JavaScript

/**
* 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;
};