/** * DC-108: Multi-host fleet management — deploy across multiple servers * * Foundation API for registering remote DashCaddy instances and coordinating * deployments across them. Each host runs its own DashCaddy container; this * module tracks the fleet state and can forward commands. * * GET /api/v1/fleet/hosts — list all registered hosts * POST /api/v1/fleet/hosts — register a new host * DELETE /api/v1/fleet/hosts/:hostId — deregister a host * GET /api/v1/fleet/status — fleet-wide status overview * POST /api/v1/fleet/deploy — deploy to multiple hosts * * Host state is persisted in {dataDir}/fleet-hosts.json * * Security (SSRF hardening, DC-068): * `POST /fleet/hosts` previously accepted any string as `hostname`, which * the subsequent `GET /fleet/status` flow composed verbatim into * `http://${hostname}:${port}/api/v1/system/health`. An authenticated * dashboard operator could register `hostname: "127.0.0.1"` or * `hostname: "169.254.169.254"` (cloud metadata service) and have the * container reach that internal endpoint on their behalf. The * `validateFleetHost()` + `resolveAndCheckAddress()` helpers in * `src/utilities/fleet-validation.js` close that hole: * - hostname syntax + port bounds + tag bounds (cheap, sync) * - literal IPv4/IPv6 private-range check (sync) * - DNS resolution + resolved-IP private-range check (async) * - Probe URL built from the RESOLVED IP, not the user-supplied * hostname, defeating DNS-rebinding attacks * - Probe concurrency capped at MAX_PROBE_CONCURRENCY so a malicious or * hung fleet can't stall the dashboard * - `FLEET_ALLOW_PRIVATE_HOSTS=true` opt-in for Tailscale / RFC1918 * deployments where private hosts are intentional * * Hosts that violate validation are still surfaced in `GET /fleet/hosts` * (operator visibility), but `GET /fleet/status` skips them and tags them * `validation_failed` instead of probing. */ const express = require('express'); const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const crypto = require('crypto'); const { ok, errorResponse } = require('../src/utils/responses'); const { ErrorCodes } = require('../src/utilities/error-codes'); const { validateFleetHost, resolveAndCheckAddress, } = require('../src/utilities/fleet-validation'); const HOSTS_FILE = process.env.FLEET_HOSTS_FILE || path.join(process.cwd(), 'data', 'fleet-hosts.json'); // Read lazily (per-request) so a test or operator script can flip the // opt-in at runtime without re-requiring the module. const ALLOW_PRIVATE_HOSTS = () => process.env.FLEET_ALLOW_PRIVATE_HOSTS === 'true'; // Cap concurrent probes in /fleet/status — a malicious fleet with N hosts // would otherwise stall the dashboard with up to N parallel 3s timeouts. const MAX_PROBE_CONCURRENCY = 5; // Per-host probe timeout for /fleet/status. const PROBE_TIMEOUT_MS = 3000; module.exports = function({ log, asyncHandler }) { const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)); const router = express.Router(); /** * Re-validate every stored host's hostname+port (defense-in-depth against * a hand-edited fleet-hosts.json or an environment where validation * loosened since the entry was written). Returns the host with a * `validation` field describing current policy compliance. */ async function revalidateStoredHost(host, opts = {}) { const allowPrivate = !!opts.allowPrivate; const v = validateFleetHost({ name: host.name, hostname: host.hostname, port: host.port, tags: host.tags, }); if (!v.ok) { return { host, validation: { valid: false, code: v.code, message: v.message } }; } // For DNS names, also resolve + check the resolved IP. Literal IPs are // already validated inside validateFleetHost(). Use `net.isIP` rather // than colon-presence heuristics so a real IPv6 with no dot is treated // as a literal (not as a DNS name), while URL-shaped strings like // `http://evil.com` (which contain both `:` and `/`) fall through to // the DNS-name path and get rejected by validateFleetHost()'s hostname // syntax check. const net = require('net'); if (net.isIP(host.hostname) === 0) { const r = await resolveAndCheckAddress(host.hostname, { allowPrivate }); if (!r.ok) { return { host, validation: { valid: false, code: r.code, message: r.message } }; } return { host, validation: { valid: true, resolvedIp: r.ip, family: r.family } }; } return { host, validation: { valid: true } }; } /** * Run `worker(host)` over `hosts` with at most `MAX_PROBE_CONCURRENCY` * concurrent workers. Preserves order in the returned array so the * operator sees hosts in the same order they registered them. */ async function runWithConcurrency(hosts, worker, limit = MAX_PROBE_CONCURRENCY) { const out = new Array(hosts.length); let next = 0; const runners = Array.from({ length: Math.min(limit, hosts.length) }, () => (async () => { while (true) { const i = next++; if (i >= hosts.length) return; out[i] = await worker(hosts[i], i); } })()); await Promise.all(runners); return out; } async function loadHosts() { const hostsFile = process.env.FLEET_HOSTS_FILE || HOSTS_FILE; try { const data = await fsp.readFile(hostsFile, 'utf8'); return JSON.parse(data); } catch { return []; } } async function saveHosts(hosts) { const hostsFile = process.env.FLEET_HOSTS_FILE || HOSTS_FILE; await fsp.mkdir(path.dirname(hostsFile), { recursive: true }); await fsp.writeFile(hostsFile, JSON.stringify(hosts, null, 2)); } // GET /api/v1/fleet/hosts router.get('/fleet/hosts', wrap(async (req, res) => { const hosts = await loadHosts(); ok(res, { total: hosts.length, hosts }); })); // POST /api/v1/fleet/hosts — register a new host router.post('/fleet/hosts', wrap(async (req, res) => { const body = req.body || {}; const { apiKey, ...rest } = body; // DC-068 SSRF hardening: synchronous structural validation first // (hostname syntax, port bounds, tag bounds, literal-IPv4 private range). // DNS rebinding protection runs after this via resolveAndCheckAddress(). const v = validateFleetHost(rest); if (!v.ok) { const logDetail = { code: v.code, message: v.message }; // Redact any user-supplied hostname in the audit log; only keep the // error code + length, never the raw value (it may be attacker-supplied // junk that has nothing to do with the real fleet). if (typeof body.hostname === 'string') logDetail.hostnameLen = body.hostname.length; if (log) log.warn('fleet', 'Host registration rejected by validation', logDetail); return errorResponse(res, 400, v.message, { code: v.code }); } const { name, hostname, port, tags } = v.normalized; // DC-068 DNS rebinding protection: if `hostname` is a DNS name (not a // literal IP), resolve it now and reject the registration if the resolved // address is private/reserved. The resolved IP is stored alongside the // hostname so /fleet/status probes it by IP, not by re-resolving the // name (closing the rebinding window). `net.isIP` distinguishes a real // IPv4 dotted-quad OR IPv6 from URL-shaped junk like `http://evil.com` // (which would otherwise be misclassified as IPv6 by a naive // colon-presence check). let resolvedIp = hostname; let dnsFamily = null; if (require('net').isIP(hostname) === 0) { const r = await resolveAndCheckAddress(hostname, { allowPrivate: ALLOW_PRIVATE_HOSTS() }); if (!r.ok) { if (log) log.warn('fleet', 'Host registration rejected by DNS resolution', { code: r.code, message: r.message }); return errorResponse(res, 400, r.message, { code: r.code }); } resolvedIp = r.ip; dnsFamily = r.family; } else { // Literal IP — capture the IP family so /fleet/status and // /fleet/deploy can bracket-wrap IPv6 correctly when probes/URLs // are built from the resolved IP. resolvedIp stays equal to the // literal hostname so the existing test invariant still holds. dnsFamily = require('net').isIP(hostname); } const hosts = await loadHosts(); // Check for duplicate (compare on the original hostname string, not the // resolved IP — operators know their hosts by name). if (hosts.some(h => h.hostname === hostname)) { return errorResponse(res, 409, `Host ${hostname} already registered`, { code: ErrorCodes.GENERAL.CONFLICT, }); } const host = { id: crypto.randomUUID(), name, hostname, port, tags, status: 'unknown', registeredAt: new Date().toISOString(), lastSeen: null, containerCount: null, // DNS rebinding protection — probe by this IP, not by re-resolving. resolvedIp, dnsFamily, apiKey: apiKey ? '***' : null, // Never store the actual key apiKeyHash: apiKey ? crypto.createHash('sha256').update(apiKey).digest('hex') : null, }; hosts.push(host); await saveHosts(hosts); if (log) log.info('fleet', 'Host registered', { name, hostname, resolvedIp, dnsFamily }); ok(res, { host }, 201); })); // DELETE /api/v1/fleet/hosts/:hostId router.delete('/fleet/hosts/:hostId', wrap(async (req, res) => { const { hostId } = req.params; const hosts = await loadHosts(); const filtered = hosts.filter(h => h.id !== hostId); if (filtered.length === hosts.length) { return errorResponse(res, 404, `Host ${hostId} not found`); } await saveHosts(filtered); ok(res, { message: 'Host deregistered' }); })); // GET /api/v1/fleet/status — aggregate fleet status // // DC-068 SSRF hardening: every stored host is re-validated before probing // (defense-in-depth against a hand-edited fleet-hosts.json or a config // file written before this policy was enabled). Probes use the // `resolvedIp` captured at registration time — never re-resolve the // hostname, since DNS-rebinding attackers could flip the A record // between registration and probe. Probe concurrency is capped at // MAX_PROBE_CONCURRENCY so a malicious fleet with N hung hosts can't // stall the dashboard with up to N parallel timeouts. router.get('/fleet/status', wrap(async (req, res) => { const hosts = await loadHosts(); // Validate all hosts (in parallel) and split into "probeable" vs // "validation_failed". Both lists are returned for operator visibility. const validated = await runWithConcurrency( hosts, (host) => revalidateStoredHost(host, { allowPrivate: ALLOW_PRIVATE_HOSTS() }), Math.max(MAX_PROBE_CONCURRENCY, hosts.length || 1) ); const probeTargets = validated.filter((v) => v.validation.valid); const skipped = validated .filter((v) => !v.validation.valid) .map((v) => ({ ...v.host, status: 'validation_failed', validationError: v.validation.message })); const probeResults = await runWithConcurrency(probeTargets, async ({ host, validation }) => { const probeIp = validation.resolvedIp || host.hostname; const probeHost = require('net').isIP(probeIp) === 6 ? `[${probeIp}]` : probeIp; const url = `http://${probeHost}:${host.port}/api/v1/system/health`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); try { const response = await fetch(url, { signal: controller.signal, headers: host.apiKeyHash ? { 'x-api-key': host.apiKeyHash } : {}, }); if (response.ok) { const data = await response.json(); host.status = data.status || 'healthy'; host.lastSeen = new Date().toISOString(); host.containerCount = data.checks?.services?.total || null; } else { host.status = 'unreachable'; } } catch { host.status = 'offline'; } finally { clearTimeout(timeout); } return host; }, MAX_PROBE_CONCURRENCY); const updatedHosts = [...probeResults, ...skipped]; await saveHosts(updatedHosts); const summary = { total: updatedHosts.length, healthy: updatedHosts.filter((h) => h.status === 'healthy').length, degraded: updatedHosts.filter((h) => h.status === 'degraded').length, unhealthy: updatedHosts.filter((h) => h.status === 'unhealthy').length, offline: updatedHosts.filter((h) => h.status === 'offline' || h.status === 'unreachable').length, validation_failed: updatedHosts.filter((h) => h.status === 'validation_failed').length, }; ok(res, { summary, hosts: updatedHosts }); })); // POST /api/v1/fleet/deploy — deploy a template to multiple hosts // // DC-068 SSRF hardening: returns plan entries whose `deployUrl` is built // from `resolvedIp` (the address captured at registration time) — never // from the raw hostname. Operators copy-and-paste these URLs into the // forwarding tool of their choice; routing them through a literal IP // prevents a DNS-rebinding rename from pivoting the deploy call. router.post('/fleet/deploy', wrap(async (req, res) => { const { templateId, hostIds = [], config = {} } = req.body || {}; if (!templateId) { return errorResponse(res, 400, 'templateId is required'); } const hosts = await loadHosts(); const targetHosts = hostIds.length > 0 ? hosts.filter(h => hostIds.includes(h.id)) : hosts; if (targetHosts.length === 0) { return errorResponse(res, 400, 'No valid hosts to deploy to'); } // Build the plan. Each entry's `deployUrl` is built from the host's // resolved IP (or the literal hostname for literal-IP hosts) — never // from a re-resolution of the raw hostname. IPv6 literals must be // wrapped in `[...]` so the URL parser preserves them as a single // authority. Use `net.isIP` against the resolved IP rather than the // stored `dnsFamily` so legacy entries (those registered before // dnsFamily was captured) still get correct bracket wrapping. const plan = targetHosts.map(host => { const probeIp = host.resolvedIp || host.hostname; const probeHost = require('net').isIP(probeIp) === 6 ? `[${probeIp}]` : probeIp; return { hostId: host.id, hostname: host.hostname, templateId, config, status: 'pending', deployUrl: `http://${probeHost}:${host.port}/api/v1/apps/deploy`, }; }); ok(res, { templateId, totalHosts: plan.length, plan, message: 'Deployment plan generated. Forward each step to the host API.', }); })); return router; };