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.
196 lines
12 KiB
JavaScript
196 lines
12 KiB
JavaScript
import { join } from 'node:path';
|
|
import { configDir } from '../../organization/src/organization-config.mjs';
|
|
import { createProxyLock } from '../../bus/src/proxy-lock.mjs';
|
|
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
|
|
import { CliError } from './errors.mjs';
|
|
import { orgSelector } from './org-selector.mjs';
|
|
import { refuseInsideAgent } from './transport.mjs';
|
|
import { fetchPinned } from '../../model-proxy/src/fetch.mjs';
|
|
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
|
|
import { createProxyAdmin } from '../../model-proxy/src/admin-control.mjs';
|
|
import { listOrgs } from '../../organization/src/org-registry.mjs';
|
|
import { busesStopped, instanceFromConfig } from './proxy-admin-host.mjs';
|
|
const failure = (code, exitCode = 2) => Object.assign(new CliError(code, exitCode), { code });
|
|
// A raw terminal suppresses echo; input is never argv, env, readline history or stdout.
|
|
export function promptProxyKey(io, label) {
|
|
if (!io.stdin.isTTY || !io.stdout.isTTY || typeof io.stdin.setRawMode !== 'function') throw failure('proxy-terminal-required', 3);
|
|
io.stderr.write(label);
|
|
const wasRaw = io.stdin.isRaw;
|
|
return new Promise((resolve, reject) => {
|
|
let value = '';
|
|
const finish = (error) => {
|
|
io.stdin.off('data', data); io.stdin.off('end', end); io.stdin.off('error', failed);
|
|
io.stdin.setRawMode(wasRaw); io.stdin.pause(); io.stderr.write('\n');
|
|
error ? reject(error) : resolve(value);
|
|
};
|
|
const end = () => finish(failure('proxy-key-invalid'));
|
|
const failed = () => finish(failure('proxy-key-invalid'));
|
|
const data = chunk => {
|
|
for (const char of chunk.toString()) {
|
|
if (char === '\u0003' || char === '\u0004') return finish(failure('proxy-key-invalid'));
|
|
if (char === '\r' || char === '\n') return finish();
|
|
if (char === '\u007f' || char === '\b') value = value.slice(0, -1);
|
|
else value += char;
|
|
if (value.length > 4096) return finish(failure('proxy-key-invalid'));
|
|
}
|
|
};
|
|
io.stdin.setRawMode(true); io.stdin.on('data', data); io.stdin.once('end', end); io.stdin.once('error', failed); io.stdin.resume();
|
|
});
|
|
}
|
|
// #1873: the setup controller the Console's Proxy page uses, for this instance. `admin` is a
|
|
// test seam; the default runs proxy-service.sh, systemctl and the pinned fetch for real.
|
|
function adminFor(io, system, deps) {
|
|
if (deps.admin) return deps.admin;
|
|
const config = typeof system === 'function' ? system() : system;
|
|
const dir = configDir(io.env);
|
|
return createProxyAdmin({
|
|
dataRoot: config.dataRoot, configDir: dir, instance: instanceFromConfig(io.env), env: io.env, fetch: deps.fetch ?? fetchPinned,
|
|
unitDir: deps.unitDir ?? null,
|
|
buses: () => busesStopped({ dataRoot: config.dataRoot, configDir: dir }),
|
|
orgs: async () => (await listOrgs({ configDir: dir, dataRoot: config.dataRoot })).filter(org => org.state === 'active').map(org => ({ id: org.id, proxy: 'shared', catalogue: null })),
|
|
});
|
|
}
|
|
const print = (io, value) => io.stdout.write(JSON.stringify(value) + '\n');
|
|
const hostCode = (error, fallback) => (/^proxy-[a-z-]+$/.test(error?.code ?? '') ? error.code : fallback);
|
|
|
|
// `mosaic proxy status | unit ... | init-lock ...`: host facts and actions, labels only.
|
|
async function setupCommand(verb, rest, io, { system, deps }) {
|
|
const admin = adminFor(io, system, deps);
|
|
try {
|
|
if (verb === 'status') {
|
|
if (!(rest.length === 0 || (rest.length === 1 && rest[0] === '--json'))) throw failure('proxy-usage', 4);
|
|
const status = await admin.status();
|
|
if (rest[0] === '--json') return print(io, status);
|
|
const { release, init, unit, fence, initLock } = status;
|
|
io.stdout.write([
|
|
`release: ${release.fetched ? `${release.version} (panel ${release.panelVersion}) ${release.verified ? 'hashes verified' : `not verified: ${release.problem}`}` : `not fetched (pinned ${release.version})`}`,
|
|
`initialized: ${init.initialized ? `yes, port ${init.settings?.port}` : init.partial ? `partly (${init.files.filter(f => f.present).map(f => f.label).join(', ')})` : 'no'}`,
|
|
`unit: ${unit.unit} ${unit.installed ? 'installed' : 'not installed'}, ${unit.active}${unit.pid ? ` pid ${unit.pid}` : ''}${unit.port ? `, port ${unit.port} ${unit.listening ? 'answering' : 'not answering'}` : ''}`,
|
|
`fence: ${fence.set ? `set${fence.op ? ` after ${fence.op}` : ''}${fence.time ? ` at ${fence.time}` : ''}` : 'clear'}`,
|
|
`init lock: ${initLock.present ? (initLock.clearable ? 'stale, clearable' : `held: ${initLock.blockers.join('; ')}`) : 'none'}`,
|
|
`own proxy: ${status.ownProxy.state}`,
|
|
`next: ${status.next.join(', ')}`,
|
|
].join('\n') + '\n');
|
|
return;
|
|
}
|
|
if (verb === 'unit') {
|
|
const [op, ...flags] = rest;
|
|
if (op === 'render' && flags.length === 0) { io.stdout.write((await admin.render()).text); return; }
|
|
if (op === 'status' && (flags.length === 0 || (flags.length === 1 && flags[0] === '--json'))) { print(io, (await admin.status()).unit); return; }
|
|
if (['install', 'start', 'stop', 'restart'].includes(op) && flags.length === 0) { print(io, await admin.unitAction(op)); return; }
|
|
throw failure('proxy-usage', 4);
|
|
}
|
|
if (verb === 'init-lock') {
|
|
const [op, ...flags] = rest;
|
|
if (flags.length !== 0) throw failure('proxy-usage', 4);
|
|
if (op === 'status') { print(io, (await admin.status()).initLock); return; }
|
|
if (op === 'clear') { print(io, await admin.initLockClear()); return; }
|
|
throw failure('proxy-usage', 4);
|
|
}
|
|
} catch (error) { if (error instanceof CliError) throw error; throw failure(hostCode(error, 'proxy-unit-failed')); }
|
|
throw failure('proxy-usage', 4);
|
|
}
|
|
|
|
export async function proxyCommand(args, io, { system, callFor, prompt = promptProxyKey, fetch = fetchPinned, ...deps }) {
|
|
const [verb, ...rest] = args;
|
|
if (args.includes('--tester')) throw failure('human-required', 3);
|
|
// #1873: every proxy verb, read or write, runs only from a human shell.
|
|
if (['init', 'fetch', 'fence', 'status', 'unit', 'init-lock'].includes(verb)) {
|
|
try { refuseInsideAgent(io.env); } catch { throw failure('human-required', 3); }
|
|
}
|
|
if (['status', 'unit', 'init-lock'].includes(verb)) return setupCommand(verb, rest, io, { system, deps: { ...deps, fetch } });
|
|
if (verb === 'fence' && rest[0] === 'clear' && rest.includes('--restart')) {
|
|
// #1873: restart the proxy unit, then send the restart evidence to the bus, which re-proves it
|
|
// on the host before it clears the fence. --business/--org names the bus.
|
|
const parsed = { flags: new Set(), values: {} };
|
|
const selector = orgSelector(() => failure('proxy-usage', 4));
|
|
const flags = rest.slice(1);
|
|
let restart = false;
|
|
for (let i = 0; i < flags.length; i++) {
|
|
if (flags[i] === '--restart' && !restart) { restart = true; continue; }
|
|
if (selector.owns(flags[i])) { selector.take(flags[i], flags[i + 1]); i++; continue; }
|
|
throw failure('proxy-usage', 4);
|
|
}
|
|
if (selector.given) parsed.values['--business'] = selector.value;
|
|
const call = callFor(parsed);
|
|
const admin = adminFor(io, system, { ...deps, fetch });
|
|
try {
|
|
const restarted = await admin.unitAction('restart');
|
|
if (restarted.evidence?.confirmed !== true) throw failure('proxy-restart-unconfirmed');
|
|
print(io, { restart: restarted.receipt, ...(await admin.fenceClear(call)) });
|
|
return;
|
|
} catch (error) { if (error instanceof CliError) throw error; throw failure(hostCode(error, 'proxy-down')); }
|
|
}
|
|
if (verb === 'fence') {
|
|
const [action, ...flags] = rest;
|
|
if (action === 'clear' && flags.length === 0) throw failure('proxy-restart-required');
|
|
if (!(action === 'status' && (flags.length === 0 || (flags.length === 1 && flags[0] === '--json')))
|
|
&& !(action === 'clear' && flags.length === 1 && flags[0] === '--proxy-restarted')) throw failure('proxy-usage', 4);
|
|
try {
|
|
const config = typeof system === 'function' ? system() : system;
|
|
if (!config.proxy) throw failure('proxy-config-invalid');
|
|
const dir = configDir(io.env);
|
|
const lock = createProxyLock({ stateDir: join(config.dataRoot, 'proxy'), identity: `${dir}:${config.proxy.port}` });
|
|
let result;
|
|
if (action === 'status') result = { uncertain: lock.status() };
|
|
else {
|
|
const client = await loadManagementClient({ configDir: dir, proxy: config.proxy });
|
|
result = await lock.clear({ restarted: true, read: () => client.getKeys() });
|
|
}
|
|
io.stdout.write(JSON.stringify(result) + '\n'); return;
|
|
} catch (error) { if (error instanceof CliError) throw error; throw failure(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-down'); }
|
|
}
|
|
if (!['fetch', 'init'].includes(verb)) throw failure('proxy-usage', 4);
|
|
const parsed = { flags: new Set(), values: {} }; let port;
|
|
// --org and --business are one selector in this parser's scope (rename design r2).
|
|
const selector = orgSelector(() => failure('proxy-usage', 4));
|
|
for (let i = 0; i < rest.length; i++) {
|
|
const flag = rest[i];
|
|
if (verb === 'init' && selector.owns(flag)) { selector.take(flag, rest[i + 1]); i++; continue; }
|
|
if (verb === 'init' && flag === '--generate-key' && !parsed.flags.has(flag)) { parsed.flags.add(flag); continue; }
|
|
if (verb !== 'init' || !['--port', '--rotate-grace-minutes', '--first-output-seconds'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
|
|
parsed.values[flag] = rest[++i];
|
|
}
|
|
if (selector.given) parsed.values['--business'] = selector.value;
|
|
if (parsed.values['--port'] !== undefined) {
|
|
if (!/^[0-9]+$/.test(parsed.values['--port'])) throw failure('proxy-config-invalid');
|
|
port = Number(parsed.values['--port']);
|
|
if (port < 1024 || port > 65535 || port === 8317) throw failure('proxy-config-invalid');
|
|
}
|
|
const timing = {};
|
|
for (const [flag, field] of [['--rotate-grace-minutes', 'rotateGraceMinutes'], ['--first-output-seconds', 'firstOutputSeconds']])
|
|
if (parsed.values[flag] !== undefined) timing[field] = /^[0-9]+$/.test(parsed.values[flag]) ? Number(parsed.values[flag]) : NaN;
|
|
try { proxyTiming(timing); } catch (error) { throw failure(error.code); }
|
|
try {
|
|
const config = typeof system === 'function' ? system() : system;
|
|
if (verb === 'fetch') {
|
|
// #1873: the page's fetch, with its progress on stderr and its receipt in proxy-admin.jsonl;
|
|
// stdout keeps its existing line.
|
|
const { result, receipt } = await adminFor(io, system, { ...deps, fetch }).fetchRun({ progress: step => io.stderr.write(`fetch: ${step}\n`) });
|
|
io.stderr.write(`receipt: ${JSON.stringify(receipt)}\n`);
|
|
io.stdout.write(JSON.stringify({ binary: result.binary, panel: result.panel }) + '\n'); return;
|
|
}
|
|
// #1873: the bus generates the key and stores it where init stores a typed key; no prompt,
|
|
// no terminal needed, and the key is never printed or returned.
|
|
if (parsed.flags.has('--generate-key')) {
|
|
// Through the page's controller, so the same init receipt lands in proxy-admin.jsonl.
|
|
const call = callFor(parsed);
|
|
const { settings, receipt } = await adminFor(io, system, deps).init(call, { generate: true, ...(port === undefined ? {} : { port }), ...timing });
|
|
io.stderr.write(`receipt: ${JSON.stringify(receipt)}\n`);
|
|
print(io, { ...settings, generated: true });
|
|
return;
|
|
}
|
|
if (!io.stdin.isTTY || !io.stdout.isTTY) throw failure('proxy-terminal-required', 3);
|
|
// Establish that the CLI is eligible before reading sensitive input. The bus verifies the proof.
|
|
const call = callFor(parsed);
|
|
const first = await prompt(io, 'Management key: '), second = await prompt(io, 'Repeat management key: ');
|
|
if (first !== second) throw failure('proxy-key-mismatch');
|
|
if (!validManagementKey(first)) throw failure('proxy-key-invalid');
|
|
const result = await call('proxy.init', { key: first, ...(port === undefined ? {} : { port }), ...timing });
|
|
io.stdout.write(JSON.stringify(result) + '\n');
|
|
} catch (e) {
|
|
if (e instanceof CliError) throw e;
|
|
throw failure(/^proxy-[a-z-]+$/.test(e.code ?? '') ? e.code : verb === 'fetch' ? 'proxy-fetch-failed' : 'proxy-init-failed');
|
|
}
|
|
}
|