/** * Shipdeck fleet module. All privileged values are fail-closed here before * crossing the token-gated host bridge. Tokens are forwarded in-memory only: * never logged, audited, persisted, or returned. */ const express = require('express'); const { ok, errorResponse } = require('../src/utils/responses'); const BRIDGE_URL = process.env.SHIPDECK_BRIDGE_URL || ''; const BRIDGE_TOKEN_FILE = process.env.SHIPDECK_BRIDGE_TOKEN_FILE || ''; const PROBE_TIMEOUT = Number(process.env.SHIPDECK_PROBE_TIMEOUT || 15000); const DEPLOY_TIMEOUT = Number(process.env.SHIPDECK_DEPLOY_TIMEOUT || 920000); const reServiceName = /^[a-z0-9][a-z0-9-]{0,62}$/; const reBinaryPath = /^\/?[A-Za-z0-9][A-Za-z0-9._\-/]{0,255}$/; const reSHA256 = /^[a-f0-9]{64}$/; const reRegistryRef = /^(?:[A-Za-z0-9][A-Za-z0-9.-]*(?::[0-9]{1,5})?\/)?[A-Za-z0-9][A-Za-z0-9._/-]*(?::[A-Za-z0-9][A-Za-z0-9._-]{0,127}|@sha256:[a-f0-9]{64})$/; const reGitURL = /^https:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?\/?$/; const reEnvName = /^[A-Z_][A-Z0-9_]*$/; const reToken = /^[A-Za-z0-9_.=~-]{0,512}$/; const reUser = /^[A-Za-z0-9_][A-Za-z0-9_.-]*(?::[A-Za-z0-9_][A-Za-z0-9_.-]*)?$/; const reMountPath = new RegExp('^/[A-Za-z0-9._/-]+$'); function hasControl(value) { return Array.from(value).some((ch) => ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127); } function cleanEnv(value) { if (value === undefined) return undefined; if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('env must be an object'); const out = {}; for (const [key, val] of Object.entries(value)) { if (!reEnvName.test(key) || typeof val !== 'string' || val.length > 300 || val.includes('"') || val.includes('\\') || hasControl(val)) { throw new Error('env must map uppercase names to strings <=300 chars without quotes, backslashes, or control characters'); } out[key] = val; } return out; } function cleanMounts(value) { if (value === undefined) return []; if (!Array.isArray(value) || value.length > 32) throw new Error('mounts must be an array of at most 32 entries'); return value.map((mount) => { if (!mount || typeof mount !== 'object' || Array.isArray(mount)) throw new Error('invalid mount'); const source = String(mount.source || ''); const target = String(mount.target || ''); if (!reMountPath.test(source) || !reMountPath.test(target) || source.includes('..') || target.includes('..')) throw new Error('mount paths must be safe absolute paths'); if (mount.read_only !== undefined && typeof mount.read_only !== 'boolean') throw new Error('mount read_only must be boolean'); return { source, target, read_only: mount.read_only === true }; }); } function cleanCommand(value) { if (value === undefined) return []; if (!Array.isArray(value) || value.length > 64 || value.some((v) => typeof v !== 'string' || !v || v.length > 1024 || hasControl(v))) throw new Error('cmd must be an array of safe argument strings'); if (!reBinaryPath.test(value[0])) throw new Error('cmd executable is invalid'); return value.slice(); } function readToken() { if (!BRIDGE_TOKEN_FILE) return ''; try { return require('fs').readFileSync(BRIDGE_TOKEN_FILE, 'utf8').trim(); } catch (_) { return ''; } } module.exports = function fleetRoutes({ asyncHandler, log, auditLogger, fetchT, servicesStateManager }) { const router = express.Router(); async function bridge(method, path, body, timeout = PROBE_TIMEOUT) { const headers = { 'X-Shipdeck-Token': readToken() }; const options = { method, headers }; if (body !== undefined) { headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(body); } const response = await fetchT(BRIDGE_URL + path, options, timeout); let parsed; try { parsed = await response.json(); } catch (_) { parsed = { ok: false, error: 'bridge returned non-JSON response' }; } return { status: response.status, body: parsed }; } function bridgeError(res, status, body, fallback) { const outward = status === 400 || status === 409 ? status : 502; return errorResponse(res, outward, body.error || fallback, body.output ? { output: String(body.output).slice(-4000) } : undefined); } router.use((req, res, next) => { if (!BRIDGE_URL) return errorResponse(res, 501, 'Fleet feature not configured: set SHIPDECK_BRIDGE_URL'); next(); }); router.post('/from-git', asyncHandler(async (req, res) => { const { repo_url: repoUrl, name, subdomain, port, token, sha256 } = req.body || {}; if (typeof repoUrl !== 'string' || !reGitURL.test(repoUrl)) return errorResponse(res, 400, 'repo_url must be https://host/owner/repo'); if (typeof name !== 'string' || !reServiceName.test(name)) return errorResponse(res, 400, 'invalid service name'); if (typeof subdomain !== 'string' || !reServiceName.test(subdomain)) return errorResponse(res, 400, 'invalid subdomain'); if (!Number.isInteger(port) || port < 1 || port > 65535) return errorResponse(res, 400, 'port must be 1-65535'); if (sha256 !== undefined && (typeof sha256 !== 'string' || !reSHA256.test(sha256))) return errorResponse(res, 400, 'sha256 must be 64 lowercase hex characters'); if (token !== undefined && (typeof token !== 'string' || !reToken.test(token))) return errorResponse(res, 400, 'invalid token'); let env; try { env = cleanEnv(req.body.env); } catch (e) { return errorResponse(res, 400, e.message); } try { const payload = { repo_url: repoUrl, service: name, subdomain, port, env, sha256 }; if (token !== undefined) payload.token = token; const { status, body } = await bridge('POST', '/api/install', payload, DEPLOY_TIMEOUT); if (status !== 200 || !body.ok) return bridgeError(res, status, body, 'Git deployment failed'); const deployed = body.service || {}; const card = { id: name, name, url: `https://${subdomain}.sami`, ip: deployed.host || 'localhost', port, logo: deployed.logo || `/assets/${name}.png`, tailscaleOnly: true, isCustom: true, managedBy: 'shipdeck', repoUrl, shipdeckfile: deployed.shipdeckfile, journalRowId: deployed.journal_row_id, }; await servicesStateManager.update((services) => { const i = services.findIndex((s) => s.id === name); if (i >= 0) services[i] = { ...services[i], ...card }; else services.push(card); return services; }); if (auditLogger) auditLogger.log({ action: 'fleet.from-git', resource: name, details: { repo_url: repoUrl, port }, outcome: 'success' }).catch(() => {}); log.info('fleet', 'Shipdeck Git deployment completed', { service: name, port }); return ok(res, { service: card, journal_row_id: deployed.journal_row_id, phase: 'live' }); } catch (e) { log.error('fleet', 'bridge unreachable during Git deployment', { service: name, error: e.message }); return errorResponse(res, 502, 'shipdeck bridge unreachable'); } })); router.post('/from-image', asyncHandler(async (req, res) => { const { image, name, subdomain, port, user, restart, sha256 } = req.body || {}; if (typeof image !== 'string' || !reRegistryRef.test(image)) return errorResponse(res, 400, 'invalid registry image reference'); if (typeof name !== 'string' || !reServiceName.test(name)) return errorResponse(res, 400, 'invalid service name'); if (typeof subdomain !== 'string' || !reServiceName.test(subdomain)) return errorResponse(res, 400, 'invalid subdomain'); if (!Number.isInteger(port) || port < 1 || port > 65535) return errorResponse(res, 400, 'port must be 1-65535'); if (sha256 !== undefined && (typeof sha256 !== 'string' || !reSHA256.test(sha256))) return errorResponse(res, 400, 'sha256 must be 64 lowercase hex characters'); if (user !== undefined && (typeof user !== 'string' || !reUser.test(user))) return errorResponse(res, 400, 'invalid user'); if (restart !== undefined && !['no', 'always', 'unless-stopped', 'on-failure'].includes(restart)) return errorResponse(res, 400, 'invalid restart policy'); let env, mounts, cmd; try { env = cleanEnv(req.body.env); mounts = cleanMounts(req.body.mounts); cmd = cleanCommand(req.body.cmd); } catch (e) { return errorResponse(res, 400, e.message); } try { const { status, body } = await bridge('POST', '/api/image/install', { image, name, subdomain, port, env, mounts, user, restart, cmd, sha256 }, DEPLOY_TIMEOUT); if (status !== 200 || !body.ok) return bridgeError(res, status, body, 'Image deployment failed'); const deployed = body.service || {}; const card = { id: name, name, url: `https://${subdomain}.sami`, ip: 'localhost', port, logo: `/assets/${name}.png`, tailscaleOnly: true, isCustom: true, managedBy: 'shipdeck', image: deployed.image || image, shipdeckfile: deployed.shipdeckfile, journalRowId: deployed.journal_row_id }; await servicesStateManager.update((services) => { const i = services.findIndex((s) => s.id === name); if (i >= 0) services[i] = { ...services[i], ...card }; else services.push(card); return services; }); if (auditLogger) auditLogger.log({ action: 'fleet.from-image', resource: name, details: { image, port }, outcome: 'success' }).catch(() => {}); return ok(res, { service: card, journal_row_id: deployed.journal_row_id, phase: 'live' }); } catch (e) { log.error('fleet', 'bridge unreachable during image deployment', { service: name, error: e.message }); return errorResponse(res, 502, 'shipdeck bridge unreachable'); } })); router.get('/list', asyncHandler(async (req, res) => { const { status, body } = await bridge('GET', '/api/managed'); if (status !== 200 || !body.ok) return bridgeError(res, status, body, 'fleet listing failed'); return ok(res, { services: body.services || [] }); })); for (const action of ['start', 'stop', 'restart', 'rm']) { router.post('/' + action, asyncHandler(async (req, res) => { const name = req.body && req.body.name; if (typeof name !== 'string' || !reServiceName.test(name)) return errorResponse(res, 400, 'invalid service name'); const { status, body } = await bridge('POST', '/api/' + action, { name }, DEPLOY_TIMEOUT); if (status !== 200 || !body.ok) return bridgeError(res, status, body, action + ' failed'); if (action === 'rm') await servicesStateManager.update((services) => services.filter((s) => s.id !== name)); return ok(res, { name, action, output: String(body.output || '').slice(-4000) }); })); } router.get('/logs', asyncHandler(async (req, res) => { const name = String(req.query.name || ''); if (!reServiceName.test(name)) return errorResponse(res, 400, 'invalid service name'); const { status, body } = await bridge('GET', '/api/logs?name=' + encodeURIComponent(name)); if (status !== 200 || !body.ok) return bridgeError(res, status, body, 'logs failed'); return ok(res, { name, logs: String(body.output || '').slice(-64000) }); })); router.get('/shipdeckfile', asyncHandler(async (req, res) => { const id = String(req.query.id || ''); if (!reServiceName.test(id)) return errorResponse(res, 400, 'invalid service id'); const services = await servicesStateManager.read(); const service = services.find((s) => s.id === id && s.managedBy === 'shipdeck'); if (!service || typeof service.shipdeckfile !== 'string' || !/^\/var\/lib\/shipdeck\/services\/[a-z0-9-]+\/Shipdeckfile$/.test(service.shipdeckfile)) return errorResponse(res, 404, 'Shipdeckfile not registered for this service'); const { status, body } = await bridge('GET', '/api/shipdeckfile?name=' + encodeURIComponent(id)); if (status !== 200 || !body.ok) return bridgeError(res, status, body, 'Shipdeckfile read failed'); return ok(res, { id, shipdeckfile: String(body.shipdeckfile || '') }); })); return router; }; module.exports._validation = { reServiceName, reBinaryPath, reSHA256, reRegistryRef, cleanEnv, cleanMounts, cleanCommand };