Security review: - Release verification cache keys on ctime as well as mtime, in nanoseconds. A same-size rewrite with mtime set back now re-hashes and reports proxy-install-invalid. Build review: - The setup page hides the account panels on any failed accounts read, not only proxy-down, and brings them back once the proxy answers. - The organizations table shows a dash, not a made-up 0, when a bus does not answer or sends no model list (orgCatalogue in proxy-admin.mjs). - A fence clear the bus did not answer keeps the confirmed restart for a retry. - A fence with no readable time names `mosaic proxy fence clear --proxy-restarted` on the page, in the refusal table and in the README. - CLI `init --generate-key` goes through the setup controller and writes the same init receipt as the page. - Status carries the last ten receipts, so the page shows them after a reload. An initialized proxy keeps the init form folded. - README: proxy-admin-busy is per process; the cross-process locks that serialize the writes are named.
115 lines
7.1 KiB
JavaScript
115 lines
7.1 KiB
JavaScript
// #1873 model proxy setup from the Console: the owner-only HTTP routes. Same checks as
|
|
// proxy-http.mjs. The controller (packages/model-proxy/src/admin-control.mjs) runs host
|
|
// actions; init and the fence clear go to the bus, which decides them. No key, token, path or
|
|
// upstream text reaches a response, receipt or log line.
|
|
import { isIP } from 'node:net';
|
|
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
|
|
import { createProxyAdmin, proxyCode } from '../../model-proxy/src/admin-control.mjs';
|
|
|
|
export { createProxyAdmin };
|
|
|
|
// #1873: one organization's catalogue row for the setup page. A count is shown only when the bus
|
|
// answered with a model list; otherwise models is null and the page shows a dash, never a made-up 0.
|
|
export async function orgCatalogue(call) {
|
|
try {
|
|
const value = await call('routes');
|
|
return { status: value?.catalogue?.status ?? 'unknown', models: Array.isArray(value?.catalogue?.models) ? value.catalogue.models.length : null };
|
|
} catch { return { status: 'bus-unavailable', models: null }; }
|
|
}
|
|
|
|
const ROUTES = Object.freeze({
|
|
'/api/proxy/admin/status': ['GET', []],
|
|
'/api/proxy/admin/unit/render': ['GET', []],
|
|
'/api/proxy/admin/fetch': ['POST', []],
|
|
'/api/proxy/admin/init': ['POST', null],
|
|
'/api/proxy/admin/unit/install': ['POST', []],
|
|
'/api/proxy/admin/unit/start': ['POST', []],
|
|
'/api/proxy/admin/unit/stop': ['POST', []],
|
|
'/api/proxy/admin/unit/restart': ['POST', []],
|
|
'/api/proxy/admin/fence/clear': ['POST', []],
|
|
'/api/proxy/admin/init-lock/clear': ['POST', []],
|
|
});
|
|
export const PROXY_ADMIN_PATHS = Object.freeze(Object.keys(ROUTES));
|
|
const INIT_KEYS = ['generate', 'key', 'confirm', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'];
|
|
const json = (res, status, value) => {
|
|
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value));
|
|
};
|
|
const loopbackName = name => name === 'localhost' || name === '[::1]' || name === '::1' || (isIP(name) === 4 && name.startsWith('127.'));
|
|
const loopbackAddress = address => typeof address === 'string' && (address === '::1' || /^(::ffff:)?127\./.test(address));
|
|
// A pasted management key crosses only HTTPS or loopback. A plain-HTTP LAN page gets the generated key.
|
|
export function keyTransportOk(origin, remoteAddress) {
|
|
let url; try { url = new URL(origin); } catch { return false; }
|
|
if (url.protocol === 'https:') return true;
|
|
return url.protocol === 'http:' && loopbackName(url.hostname) && loopbackAddress(remoteAddress);
|
|
}
|
|
const STATUS = { 'human-required': 403, 'proxy-key-transport': 403, 'not-configured': 503, 'proxy-down': 503, 'outcome-unknown': 503, 'proxy-admin-busy': 409 };
|
|
|
|
// `owner` is true only for the instance owner's session (the dev local owner, or the root owner
|
|
// signed in under --sso). `call` is the owner's transport to the selected bus, or null.
|
|
export async function proxyAdminHTTP(req, res, { path, admin, call, named, tester, granted, owner, send, origin, user, log }) {
|
|
const route = ROUTES[path];
|
|
const done = (status, value) => {
|
|
// Path, method and status only: never a body, key, evidence or exception text.
|
|
log(`${new Date().toISOString()} ${route?.[0] ?? req.method} ${route ? path : '/api/proxy/admin/unknown'} ${status} user=${JSON.stringify(user ?? '')}`);
|
|
return json(res, status, value);
|
|
};
|
|
const refuse = code => done(STATUS[code] ?? 400, { error: code });
|
|
if (tester || granted || !named || owner !== true) return refuse('human-required');
|
|
if (!route) return done(404, { error: 'not found' });
|
|
if (req.method !== route[0]) return done(405, { error: 'method not allowed' });
|
|
if (req.method === 'POST') {
|
|
if (!send) return done(403, { error: 'read-only' });
|
|
if (req.headersDistinct.origin?.length !== 1 || req.headers.origin !== origin) return done(403, { error: 'cross-origin request refused' });
|
|
if (req.headersDistinct['sec-fetch-site']?.length !== 1 || req.headers['sec-fetch-site'] !== 'same-origin') return done(403, { error: 'cross-site request refused' });
|
|
}
|
|
if (!admin) return refuse('not-configured');
|
|
let args = {};
|
|
if (req.method === 'POST') {
|
|
try {
|
|
if ((req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') throw Error();
|
|
const chunks = []; let size = 0;
|
|
for await (const chunk of req) { size += chunk.length; if (size > 16384) throw Error(); chunks.push(chunk); }
|
|
args = JSON.parse(Buffer.concat(chunks));
|
|
if (!args || typeof args !== 'object' || Array.isArray(args)) throw Error();
|
|
const allowed = route[1] ?? INIT_KEYS;
|
|
if (Object.keys(args).some(k => !allowed.includes(k))) throw Error();
|
|
} catch { return refuse('invalid-request'); }
|
|
}
|
|
try {
|
|
switch (path) {
|
|
case '/api/proxy/admin/status': return done(200, await admin.status());
|
|
case '/api/proxy/admin/unit/render': return done(200, await admin.render());
|
|
case '/api/proxy/admin/fetch': return done(202, admin.fetchStart());
|
|
case '/api/proxy/admin/init': {
|
|
const timing = {};
|
|
for (const field of ['port', 'rotateGraceMinutes', 'firstOutputSeconds']) if (Object.hasOwn(args, field)) timing[field] = args[field];
|
|
if (Object.hasOwn(timing, 'port') && (!Number.isInteger(timing.port) || timing.port < 1024 || timing.port > 65535 || timing.port === 8317)) return refuse('proxy-config-invalid');
|
|
try { proxyTiming(timing); } catch (error) { return refuse(proxyCode(error, 'proxy-config-invalid')); }
|
|
let initArgs;
|
|
if (args.generate === true && !Object.hasOwn(args, 'key') && !Object.hasOwn(args, 'confirm')) initArgs = { generate: true, ...timing };
|
|
else if (!Object.hasOwn(args, 'generate') && Object.hasOwn(args, 'key')) {
|
|
// The transport check comes before the key is examined, so a refused key is never judged.
|
|
if (!keyTransportOk(origin, req.socket?.remoteAddress)) return refuse('proxy-key-transport');
|
|
if (typeof args.key !== 'string' || args.key !== args.confirm) return refuse('proxy-key-mismatch');
|
|
if (!validManagementKey(args.key)) return refuse('proxy-key-invalid');
|
|
initArgs = { key: args.key, ...timing };
|
|
} else return refuse('invalid-request');
|
|
if (!call) return refuse('not-configured');
|
|
return done(200, await admin.init(call, initArgs));
|
|
}
|
|
case '/api/proxy/admin/unit/install': return done(200, await admin.unitAction('install'));
|
|
case '/api/proxy/admin/unit/start': return done(200, await admin.unitAction('start'));
|
|
case '/api/proxy/admin/unit/stop': return done(200, await admin.unitAction('stop'));
|
|
case '/api/proxy/admin/unit/restart': return done(200, await admin.unitAction('restart'));
|
|
case '/api/proxy/admin/fence/clear':
|
|
if (!call) return refuse('not-configured');
|
|
return done(200, await admin.fenceClear(call));
|
|
case '/api/proxy/admin/init-lock/clear': return done(200, await admin.initLockClear());
|
|
}
|
|
} catch (error) {
|
|
const code = ['human-required', 'not-configured', 'invalid-request'].includes(error?.code) ? error.code : proxyCode(error, 'outcome-unknown');
|
|
return refuse(code);
|
|
}
|
|
return done(404, { error: 'not found' });
|
|
}
|