Files
Void-Homelab/lib/api/routes/control.js

121 lines
4.8 KiB
JavaScript

// 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}<path-after-/api/control>, 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);
}));