diff --git a/dashcaddy-api/routes/discover-adopt.js b/dashcaddy-api/routes/discover-adopt.js new file mode 100644 index 0000000..c6dcd54 --- /dev/null +++ b/dashcaddy-api/routes/discover-adopt.js @@ -0,0 +1,159 @@ +/** + * 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. + */ +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 }) { + 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'; + const caddyAdminUrl = 'http://localhost:2019'; + + 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 + const response = await fetch(`${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; +}; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index f985344..a0ea76c 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -62,6 +62,7 @@ const authRoutes = require('../routes/auth'); const shareRoutes = require('../routes/share'); const i18nRoutes = require('../routes/i18n'); const discoverRoutes = require('../routes/discover'); +const discoverAdoptRoutes = require('../routes/discover-adopt'); const configRoutes = require('../routes/config'); const dnsRoutes = require('../routes/dns'); const notificationRoutes = require('../routes/notifications'); @@ -607,6 +608,16 @@ async function createApp() { servicesStateManager: ctx.servicesStateManager, asyncHandler: ctx.asyncHandler, })); + + // DC-103: One-click adopt — auto-generate routes + DNS + service entry + apiRouter.use(discoverAdoptRoutes({ + docker: ctx.docker, + servicesStateManager: ctx.servicesStateManager, + caddy: ctx.caddy, + dns: ctx.dns, + siteConfig: ctx.config, + asyncHandler: ctx.asyncHandler, + })); apiRouter.use(updatesRoutes({ updateManager: ctx.updateManager, selfUpdater: ctx.selfUpdater,