feat(control): IV Control admin app — owner-gated /api/control proxy to ivctl + Control view (applicants/instances/releases/tickets/groups) + sidebar
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ import { router as themeRouter } from './routes/theme.js';
|
||||
import { router as drossRouter } from './routes/dross.js';
|
||||
import { router as voiceRouter } from './routes/voice.js';
|
||||
import { router as improvementsRouter, cssHandler } from './routes/improvements.js';
|
||||
import { router as controlRouter } from './routes/control.js';
|
||||
|
||||
export function mountApi(app) {
|
||||
const api = Router();
|
||||
@@ -59,6 +60,7 @@ export function mountApi(app) {
|
||||
api.use('/storage', storageRouter);
|
||||
api.use('/backups', backupsRouter);
|
||||
api.use('/little-blue', littleblueRouter);
|
||||
api.use('/control', controlRouter);
|
||||
api.use('/ai-usage', aiUsageRouter);
|
||||
api.use('/projects', projectsRouter);
|
||||
api.use('/projects/:project_id/tasks', tasksByProjectRouter);
|
||||
|
||||
120
lib/api/routes/control.js
Normal file
120
lib/api/routes/control.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// 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);
|
||||
}));
|
||||
Reference in New Issue
Block a user