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'); } }