// lib/api/routes/control.js // // Control — owner-only proxy to the **ivctl** admin API (the licensed-distribution // control plane for "IV Control"). Every /api/control/* request is forwarded to // ${IVCTL_URL}, injecting the admin token server-side so // it never reaches the browser. // // browser POST /api/control/admin/releases (multipart) // -> ivctl POST ${IVCTL_URL}/admin/releases (X-Admin-Token injected) // // Method, query string, JSON bodies AND multipart bodies are passed through, and // the upstream response (including image/log file downloads) is streamed back // verbatim (status, content-type, content-disposition, body). // // Auth: mounted inside mountApi() AFTER agentOrOwner, and every route is gated by // requireOwner — same owner gate the other admin routes use. Agents get 403. // // Required environment variables (read from Void 2's server env): // IVCTL_URL base URL of the ivctl admin service, e.g. http://192.168.1.230:8080 // (no trailing slash). If unset -> 503 { error: 'ivctl_not_configured' }. // IVCTL_ADMIN_TOKEN the shared admin token sent upstream as `X-Admin-Token`. import { Router } from 'express'; import { requireOwner } from '../cap.js'; import { asyncWrap } from '../errors.js'; import { Readable } from 'node:stream'; export const router = Router(); // Owner-only for the whole surface (defence in depth on top of mountApi's // agentOrOwner — requireOwner additionally rejects agent tokens with 403). router.use(requireOwner); const ivctlBase = () => (process.env.IVCTL_URL || '').replace(/\/+$/, ''); // Headers we must NOT copy from the browser request to the upstream (hop-by-hop // or auth that would leak / confuse ivctl). The admin token is injected fresh. const REQ_STRIP = new Set([ 'host', 'connection', 'content-length', 'authorization', 'x-admin-token', 'cookie', 'accept-encoding', 'transfer-encoding' ]); // Headers we must NOT copy back from upstream to the browser (let Express manage // framing / encoding). Everything else (content-type, content-disposition, // cache-control, etc.) is forwarded so file downloads behave correctly. const RES_STRIP = new Set([ 'connection', 'transfer-encoding', 'content-encoding', 'content-length', 'keep-alive' ]); // Build the upstream body. express.json() has already run globally: // - application/json -> req.body is the parsed object; re-serialize it. // - everything else (multipart, octet-stream, empty) -> express.json skipped // it, so the raw request stream is still intact; buffer it through. // Release tarballs are owner uploads (bounded), so buffering is acceptable and // far more robust than half-duplex stream forwarding through native fetch. async function buildBody(req) { const method = req.method.toUpperCase(); if (method === 'GET' || method === 'HEAD') return undefined; const ctype = (req.headers['content-type'] || '').toLowerCase(); if (ctype.includes('application/json')) { // req.body may be {} for an empty JSON body; only send when there's content. if (req.body && Object.keys(req.body).length) return JSON.stringify(req.body); return undefined; } // Raw / multipart: collect the untouched stream into a Buffer. const chunks = []; for await (const chunk of req) chunks.push(chunk); if (!chunks.length) return undefined; return Buffer.concat(chunks); } router.all(/.*/, asyncWrap(async (req, res) => { const base = ivctlBase(); if (!base) { return res.status(503).json({ error: 'ivctl_not_configured', message: 'IVCTL_URL is not set on the Void server; the Control admin proxy is unavailable.' }); } // req.path here is the path AFTER the /api/control mount point (e.g. // "/admin/releases"). req.originalUrl carries the query string; reuse it. const qIndex = req.originalUrl.indexOf('?'); const query = qIndex === -1 ? '' : req.originalUrl.slice(qIndex); const target = base + req.path + query; const headers = {}; for (const [k, v] of Object.entries(req.headers)) { if (!REQ_STRIP.has(k.toLowerCase())) headers[k] = v; } headers['X-Admin-Token'] = process.env.IVCTL_ADMIN_TOKEN || ''; const body = await buildBody(req); let upstream; try { upstream = await fetch(target, { method: req.method, headers, body, redirect: 'manual' }); } catch (err) { return res.status(502).json({ error: 'ivctl_unreachable', message: `Failed to reach ivctl at ${base}: ${err.message}` }); } res.status(upstream.status); for (const [k, v] of upstream.headers.entries()) { if (!RES_STRIP.has(k.toLowerCase())) res.setHeader(k, v); } if (!upstream.body) return res.end(); // Stream the upstream body straight through (handles JSON, images, log files). Readable.fromWeb(upstream.body).pipe(res); }));