121 lines
4.8 KiB
JavaScript
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);
|
|
}));
|