Files
dashcaddy/dashcaddy-api/routes/deploys.js
T
Hermes Agent 5cf5dac281 [pending-judge] fix(r4): audit contract, subdomain forwarding, timeout kill (judge r3 blockers)
BLOCK0: catalog deploy audit now uses the REAL AuditLogger contract
(auditLogger.log({action,resource,details,outcome,ip})) fire-and-forget
via Promise.resolve().catch — async audit failures can never fail the
deploy response (tested with a throwing audit backend).
BLOCK1: verify.sh targeted-jest block captures jest exit code explicitly
(set +e / rc / loud FAIL) — no silent termination under set -e.
BLOCK2: subdomain advertised by the App Selector modal is now FORWARDED
end-to-end (selector -> /deploys/catalog/deploy validated charset ->
bridge --subdomain -> CLI CatalogInstallOptions, implemented in
dc-catalog-2 r3); invalid subdomains 400 before the bridge (4 cases).
POLISH: boot-timeout SIGKILLs the child (no leaked process).
New r4 suite: 7 tests (audit shape success/failure, subdomain forward +
4 rejections). Full suite 157 suites / 3519 tests green.
2026-10-07 03:59:30 -07:00

375 lines
16 KiB
JavaScript

/**
* Deploys route factory — shipdeck-backed source deploys (DC-130).
*
* Bridge architecture: the shipdeck CLI (SSH keys, fleet-dns creds, root)
* lives on the DNS2 HOST. A token-gated shipdeck-bridge daemon
* (/opt/shipdeck-bridge, systemd shipdeck-bridge.service, 127.0.0.1:8977 +
* docker bridge 172.17.0.1:8977) wraps the CLI. This route proxies to it —
* the container never touches SSH or DNS credentials.
*
* Endpoints (all under /api/v1/deploys, standard dashboard auth):
* GET /repos -> deployable repos (dirs with Shipdeckfile)
* GET /services -> deployed services (journal-derived)
* GET /journal?service=N -> journal rows
* GET /status?service=N -> live re-probe
* POST /deploy {dir} -> run a deploy (long; up to SHIPDECK_DEPLOY_TIMEOUT)
* POST /rollback {service} -> roll back to the previous release
* GET /catalog/apps -> shipdeck catalog list (73 embedded apps) [DC-CATALOG-3]
* POST /catalog/deploy -> deploy a catalog app via shipdeck (no Docker) [DC-CATALOG-3]
*
* Opt-in: when SHIPDECK_BRIDGE_URL is unset every endpoint returns 501 with a
* clear message (the DC-048 opt-in pattern: the feature does not exist until
* the operator configures it).
*/
const express = require('express');
const { ok, errorResponse } = require('../src/utils/responses');
const SHIPDECK_BRIDGE_URL = process.env.SHIPDECK_BRIDGE_URL || '';
const SHIPDECK_BRIDGE_TOKEN_FILE = process.env.SHIPDECK_BRIDGE_TOKEN_FILE || '';
const SHIPDECK_PROBE_TIMEOUT = Number(process.env.SHIPDECK_PROBE_TIMEOUT || 15000);
const SHIPDECK_DEPLOY_TIMEOUT = Number(process.env.SHIPDECK_DEPLOY_TIMEOUT || 620000);
const SERVICE_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
function readBridgeToken() {
if (!SHIPDECK_BRIDGE_TOKEN_FILE) return '';
const fs = require('fs');
try {
return fs.readFileSync(SHIPDECK_BRIDGE_TOKEN_FILE, 'utf8').trim();
} catch (e) {
return '';
}
}
module.exports = function ({ asyncHandler, log, auditLogger, fetchT }) {
const router = express.Router();
function featureEnabled() {
return SHIPDECK_BRIDGE_URL !== '';
}
function notConfigured(res) {
return errorResponse(res, 501, 'Deploys feature not configured: set SHIPDECK_BRIDGE_URL (and SHIPDECK_BRIDGE_TOKEN_FILE) to enable');
}
/**
* Proxy to the bridge. Returns {status, body}.
* bodyStream: pass a longer timeout for deploy/rollback.
*/
async function bridge(method, path, body, timeoutMs) {
const token = readBridgeToken();
const headers = { 'X-Shipdeck-Token': token };
let payload;
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
payload = JSON.stringify(body);
}
const res = await fetchT(SHIPDECK_BRIDGE_URL + path, {
method,
headers,
body: payload,
}, timeoutMs || SHIPDECK_PROBE_TIMEOUT);
let parsed;
try {
parsed = await res.json();
} catch (e) {
parsed = { ok: false, error: 'bridge returned non-JSON response' };
}
return { status: res.status, body: parsed };
}
// ---- feature gate for every endpoint ----
router.use((req, res, next) => {
if (!featureEnabled()) return notConfigured(res);
next();
});
// ---- DC-CATALOG-3: shipdeck catalog (App Selector integration) ----
// Catalog list: 73 embedded apps from the shipdeck binary (single source
// of truth; DashCaddy templates and the shipdeck catalog both derive from
// app-templates.js).
router.get('/catalog/apps', asyncHandler(async (req, res) => {
const { status, body } = await bridge('GET', '/api/catalog/apps');
if (status !== 200) {
log.error('deploys', 'catalog list: bridge error', { status });
return errorResponse(res, status === 401 ? 502 : status, (body && body.error) || 'bridge error');
}
return ok(res, { apps: (body && body.apps) || [] });
}));
// Catalog deploy: validated server-side by the bridge, executed by the
// shipdeck CLI (OCI image -> hardened systemd service; no Docker daemon).
// Body: { app, port, media_path?, host_ip?, env? {K:V}, subdomain? }
router.post('/catalog/deploy', asyncHandler(async (req, res) => {
const app = req.body && req.body.app;
const port = req.body && Number(req.body.port);
if (!SERVICE_RE.test(String(app || ''))) {
return errorResponse(res, 400, 'invalid app id');
}
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
return errorResponse(res, 400, 'port must be an integer in 1024-65535');
}
const envIn = (req.body && req.body.env) || {};
const env = {};
for (const [k, v] of Object.entries(envIn)) {
if (!/^[A-Z_][A-Z0-9_]*$/.test(k) || typeof v !== 'string' || v.length > 300) {
return errorResponse(res, 400, 'invalid env entry');
}
env[k] = v;
}
const subdomain = String((req.body && req.body.subdomain) || '');
if (subdomain !== '' && !/^[a-z0-9][a-z0-9-]{0,62}$/.test(subdomain)) {
return errorResponse(res, 400, 'invalid subdomain');
}
const payload = {
app,
port,
subdomain,
media_path: String((req.body && req.body.media_path) || ''),
host_ip: String((req.body && req.body.host_ip) || ''),
env,
};
const t0 = Date.now();
const { status, body } = await bridge('POST', '/api/catalog/deploy', payload, SHIPDECK_DEPLOY_TIMEOUT);
const seconds = ((Date.now() - t0) / 1000).toFixed(1);
// Audit via the real AuditLogger contract (this.log({action, resource,
// details, outcome, ip}) — see src/security/audit-logger.js); non-fatal.
if (auditLogger && typeof auditLogger.log === 'function') {
// Fire-and-forget: async rejections escape sync try/catch, so bind a
// .catch() instead (audit must never fail the deploy response).
Promise.resolve(auditLogger.log({
action: 'deploy.catalog',
resource: `catalog-app:${app}`,
details: { app, port, bridgeStatus: status, ok: !!(body && body.ok), seconds },
outcome: status === 200 && !!(body && body.ok) ? 'success' : 'failure',
ip: req.ip,
})).catch(() => { /* non-fatal by contract */ });
}
if (status !== 200 || !body.ok) {
log.error('deploys', 'catalog deploy failed', { app, port, status, error: body && body.error });
return errorResponse(res, 502, (body && (body.error || 'catalog deploy failed')) || 'catalog deploy failed', {
output: ((body && body.output) || '').slice(-4000),
});
}
return ok(res, {
app,
port,
subdomain: (body && body.subdomain) || app,
url: `http://127.0.0.1:${port}`,
output: ((body && body.output) || '').slice(-4000),
seconds,
});
}));
router.get('/repos', asyncHandler(async (req, res) => {
const { status, body } = await bridge('GET', '/api/repos');
if (status !== 200 || !body.ok) {
return errorResponse(res, status === 401 ? 502 : status, body.error || 'bridge error');
}
return ok(res, { repos: body.repos });
}));
router.get('/services', asyncHandler(async (req, res) => {
const { status, body } = await bridge('GET', '/api/services');
if (status !== 200 || !body.ok) {
return errorResponse(res, status === 401 ? 502 : status, body.error || 'bridge error');
}
return ok(res, { services: body.services });
}));
router.get('/journal', asyncHandler(async (req, res) => {
const service = String(req.query.service || '');
if (service && !SERVICE_RE.test(service)) {
return errorResponse(res, 400, 'invalid service name');
}
const qs = service ? `?service=${encodeURIComponent(service)}` : '';
const { status, body } = await bridge('GET', '/api/journal' + qs);
if (status !== 200 && status !== 500) {
return errorResponse(res, status === 401 ? 502 : status, body.error || 'bridge error');
}
return ok(res, { rows: body.rows || [], ok: body.ok });
}));
router.get('/status', asyncHandler(async (req, res) => {
const service = String(req.query.service || '');
if (!SERVICE_RE.test(service)) {
return errorResponse(res, 400, 'invalid service name');
}
let status, body;
try {
({ status, body } = await bridge('GET', `/api/status?service=${encodeURIComponent(service)}`));
} catch (e) {
log.error('deploys', 'status probe: bridge unreachable', { service, error: e.message });
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
}
if (status === 401 || status === 403) {
// bridge auth/protocol failure = infrastructure problem, NOT a probe result
log.error('deploys', 'status probe: bridge auth failed', { service, status });
return errorResponse(res, 502, 'shipdeck bridge rejected the request (auth/config error)');
}
if (status >= 500 && body && body.ok === false && body.output) {
// shipdeck status exits non-zero when checks fail — that's an EXPECTED
// probe result (failing checks), surface as ok:false with the output.
return ok(res, { ok: false, output: body.output || '' });
}
if (status !== 200) {
// malformed upstream route or other unexpected bridge failure
log.error('deploys', 'status probe: unexpected bridge response', {
service,
status,
bridgeError: body && body.error ? String(body.error).slice(0, 200) : 'none',
});
return errorResponse(res, 502, 'shipdeck bridge protocol error (unexpected response, status ' + status + ')');
}
return ok(res, { ok: body.ok === true, output: body.output || '' });
}));
router.post('/deploy', asyncHandler(async (req, res) => {
const dir = req.body && req.body.dir;
if (typeof dir !== 'string' || !dir.trim()) {
return errorResponse(res, 400, 'dir is required');
}
try {
const { status, body } = await bridge('POST', '/api/deploy', { dir }, SHIPDECK_DEPLOY_TIMEOUT);
if (auditLogger) {
auditLogger.log({
action: 'deploy.shipdeck',
resource: dir,
details: { dir, exit: body.exit },
outcome: body.ok ? 'success' : 'failure',
}).catch(() => {});
}
if (status !== 200 || !body.ok) {
log.warn('deploys', 'shipdeck deploy failed', { dir, exit: body.exit });
return errorResponse(res, status === 401 ? 502 : status === 500 ? 502 : status, body.error || 'deploy failed', { output: (body.output || '').slice(-4000) });
}
log.info('deploys', 'shipdeck deploy completed', { dir });
return ok(res, { exit: body.exit, output: body.output });
} catch (e) {
log.error('deploys', 'bridge unreachable', { error: e.message });
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
}
}));
router.post('/rollback', asyncHandler(async (req, res) => {
const service = req.body && req.body.service;
if (typeof service !== 'string' || !SERVICE_RE.test(service)) {
return errorResponse(res, 400, 'invalid service name');
}
try {
const { status, body } = await bridge('POST', '/api/rollback', { service }, SHIPDECK_DEPLOY_TIMEOUT);
if (auditLogger) {
auditLogger.log({
action: 'deploy.rollback',
resource: service,
details: { service, exit: body.exit },
outcome: body.ok ? 'success' : 'failure',
}).catch(() => {});
}
if (status !== 200 || !body.ok) {
return errorResponse(res, 502, body.error || 'rollback failed', { output: (body.output || '').slice(-4000) });
}
return ok(res, { exit: body.exit, output: body.output });
} catch (e) {
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
}
}));
// DC-131/133: install from ANY git host — clone+detect+deploy on the bridge
// host, then the client registers the card via POST /api/v1/services.
// Same URL grammar the bridge enforces: any https host/owner/repo. The
// optional per-request token is forwarded to the bridge (validated, never
// stored by either layer).
router.post('/install', asyncHandler(async (req, res) => {
const { repo_url: repoUrl, service, subdomain, args, token, env } = req.body || {};
if (typeof repoUrl !== 'string' || !/^https:\/\/[A-Za-z0-9.-]+(?::\d+)?\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(\.git)?\/?$/.test(repoUrl)) {
return errorResponse(res, 400, 'repo_url must be a https://host/owner/repo URL');
}
if (typeof service !== 'string' || !SERVICE_RE.test(service)) {
return errorResponse(res, 400, 'invalid service name');
}
// Uniform token contract (same as /gitea-repos): supplied token must be
// a string <=512 chars. Empty string is deliberately PRESERVED on the
// wire: the bridge distinguishes "explicit anonymous" from omitted
// (omitted may use the fleet fallback credential).
let cleanToken;
if (token !== undefined) {
if (typeof token !== 'string' || token.length > 512) {
return errorResponse(res, 400, 'invalid token');
}
cleanToken = token;
}
// Mirror the bridge's fail-closed environment contract so invalid
// values never cross the proxy boundary. Valid values are forwarded
// unchanged; omitted env stays omitted.
let cleanEnv;
if (env !== undefined) {
const validEnv = env && typeof env === 'object' && !Array.isArray(env) &&
Object.entries(env).every(([k, v]) =>
/^[A-Z_][A-Z0-9_]*$/.test(k) && typeof v === 'string' &&
v.length <= 300 && !/["\\\x00-\x1f\x7f]/.test(v));
if (!validEnv) {
return errorResponse(res, 400, 'env must map valid uppercase names to strings <=300 chars without quotes, backslashes, or control characters');
}
cleanEnv = env;
}
try {
const { status, body } = await bridge('POST', '/api/install', { repo_url: repoUrl, service, subdomain, args, token: cleanToken, env: cleanEnv }, SHIPDECK_DEPLOY_TIMEOUT);
if (auditLogger) {
auditLogger.log({
action: 'deploy.install',
resource: service,
details: { repo_url: repoUrl, subdomain: subdomain || service },
outcome: body.ok ? 'success' : 'failure',
}).catch(() => {});
}
if (status !== 200 || !body.ok) {
return errorResponse(res, status === 401 ? 502 : status === 500 ? 502 : status, body.error || 'install failed', { output: (body.output || '').slice(-4000) });
}
log.info('deploys', 'Install completed from ' + (repoUrl.split('/')[2] || 'git host'), { service });
return ok(res, { service: body.service, output: body.output });
} catch (e) {
log.error('deploys', 'bridge unreachable during install', { error: e.message });
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
}
}));
// DC-132/133: list Gitea repos installable via the bridge — from any
// instance. POST with a JSON body so host/token ride in the body (a GET
// has no body; the earlier GET handler read req.body and always saw
// undefined). The bridge never persists the token.
router.post('/gitea-repos', asyncHandler(async (req, res) => {
const payload = {};
if (req.body && typeof req.body.gitea_url === 'string' && req.body.gitea_url.trim()) {
payload.gitea_url = req.body.gitea_url.trim();
}
if (req.body && req.body.token !== undefined) {
// Uniform token contract. Empty string is DELIBERATELY preserved on
// the wire: the bridge distinguishes explicit-anonymous from omitted
// (omitted may use the fleet credential).
const t = req.body.token;
if (typeof t !== 'string' || t.length > 512) {
return errorResponse(res, 400, 'invalid token');
}
payload.token = t;
}
try {
const { status, body } = await bridge('POST', '/api/gitea/repos', payload, 20000);
if (status !== 200 || !body.ok) {
return errorResponse(res, status === 502 ? 502 : status, body.error || 'gitea listing failed');
}
return ok(res, { repos: body.repos });
} catch (e) {
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
}
}));
return router;
};