webui serve takes --proof-header NAME --proof-file PATH, only with the --public-host preview flags. Before listen it reads the file once through O_NOFOLLOW and fstat: a regular file owned by the Console uid, mode 0400 or 0600, at least 32 base64url bytes with one optional final LF. Any file problem gives one fixed refusal that names neither the path nor the contents. The header name can't collide with Host, Origin, auth or tester names. On a public Host the guard runs before any auth header is read. It needs exactly one proof header and compares equal-length buffers with timingSafeEqual. Missing, wrong and repeated proofs get the same fixed 401. A proof header on another Host or on the tester listener gets 403. The existing auth, Origin, Fetch Metadata and human write checks are unchanged. webui-service.sh renders both flags and checks the file's metadata and canonical location without opening it, and the send-status regex accepts the proof pair. stack-release.sh only gains a comment: it already carries Console flags through re-pin and rollback, and new tests pin that. This is a partial fix. Same-uid seats can still read the proof file. Activation (middleware, unit flags, restart, smoke) is a separate Mos-checked window and isn't part of this commit. Dewey approved round 2 in #1676 comment 29386, candidate 9300a670. Round 1 changes were in 29374.
109 lines
7.9 KiB
JavaScript
109 lines
7.9 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createChat } from './chat.mjs';
|
|
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
|
|
import { busCaller } from './bus.mjs';
|
|
import { TESTER_CLI } from '../../cli/src/transport.mjs';
|
|
import { views } from '../../bus/src/views.mjs';
|
|
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
|
|
import { configDir } from '../../business/src/business.mjs';
|
|
import { consoleReads, redactor } from './reads.mjs';
|
|
import { statSync } from 'node:fs';
|
|
import { isAbsolute } from 'node:path';
|
|
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
|
const args = process.argv.slice(2);
|
|
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
|
|
try {
|
|
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
|
|
if (args.shift() !== 'serve') throw new Error(usage);
|
|
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
|
|
let sendOn = false, testerPort = null;
|
|
while (args.length) {
|
|
const flag = args.shift();
|
|
// Row 76 (#1570): the one flag without a value.
|
|
if (flag === '--send') { sendOn = true; continue; }
|
|
const value = args.shift();
|
|
if (!value) throw new Error(`missing value for ${flag}`);
|
|
if (flag === '--port') {
|
|
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
|
|
options.port = Number(value);
|
|
} else if (flag === '--tester-port') {
|
|
if (!/^\d{1,5}$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1024 || Number(value) > 65535) throw new Error('--tester-port must be a whole number in 1024..65535');
|
|
testerPort = Number(value);
|
|
} else if (flag === '--board') options.board = value;
|
|
// Row 77 (#1571): the canonical checkout the queue read runs from, when
|
|
// serve runs from a pinned copy; everything else loads from the copy.
|
|
else if (flag === '--checkout') {
|
|
let dir = false;
|
|
try { dir = isAbsolute(value) && statSync(value).isDirectory(); } catch { /* refused below */ }
|
|
if (!dir) throw new Error('--checkout must be the absolute path of a directory');
|
|
options.checkout = value;
|
|
}
|
|
else if (flag === '--business') {
|
|
if (!ID_PATTERN.test(value)) throw new Error('business must be a business id');
|
|
options.business = value;
|
|
}
|
|
// Row 74 (#1568): the preview mode, all three or none, checked before any config is read.
|
|
else if (flag === '--public-host') preview.publicHost = value;
|
|
else if (flag === '--auth-header') preview.authHeader = value;
|
|
else if (flag === '--allow-user') preview.allowUser = value;
|
|
else if (flag === '--proof-header') proofFlags.proofHeader = value;
|
|
else if (flag === '--proof-file') proofFlags.proofFile = value;
|
|
else throw new Error(`unknown option: ${flag}`);
|
|
}
|
|
if (testerPort !== null && (testerPort === options.port || testerPort === 7332)) throw new Error('--tester-port must differ from --port and cannot be the forwarded port 7332');
|
|
options.preview = previewOptions(preview);
|
|
const proof = proofOptions(proofFlags, options.preview);
|
|
if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
|
|
// The bus views (slice 1 S5) need the system config. Without it the
|
|
// board still serves and the bus routes answer not-configured.
|
|
// The Queue, Business and Settings reads (row 54) need no bus; without
|
|
// the system config, Business can't resolve authority and Settings has
|
|
// no notifier. A refusal printed here names no path (redactor).
|
|
// --send without a bus refuses to start rather than serve a composer that can't send.
|
|
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, busLine, system = null;
|
|
try {
|
|
system = loadSystem();
|
|
const { dataRoot } = system;
|
|
const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
|
chat = createChat({ call });
|
|
// Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. Row 165
|
|
// (#1662): so is `instances`, and its write `instances.set` is only under --send. Row 65
|
|
// (mos-0840): so is `routes`, and its write `routes.set`.
|
|
bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
|
|
// Row 148: the spec and docs editor reads like the other reads and saves only under --send.
|
|
projectFiles = args => call('project.files', args);
|
|
projectFile = args => call('project.file', args);
|
|
if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);}
|
|
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
|
} catch (err) {
|
|
const why = redactor({ configDir: configDir() })(err.message);
|
|
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
|
|
if (testerPort !== null) throw new Error(`--tester-port needs the bus configured (${why})`);
|
|
busLine = `Bus: not configured (${why})`;
|
|
}
|
|
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
|
|
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
|
|
let testerServer = null;
|
|
if (testerPort !== null) {
|
|
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
|
|
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
|
|
try {
|
|
testerServer = await startServer({ host: '127.0.0.1', port: testerPort, board: options.board,
|
|
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
|
|
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
|
|
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
|
|
// Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's.
|
|
chat: createChat({ call }),
|
|
});
|
|
} catch (err) { await new Promise(resolve => server.close(resolve)); throw err; }
|
|
}
|
|
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
|
|
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
|
|
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
|
|
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
|
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { server.close(); testerServer?.close(); });
|
|
} catch (err) {
|
|
console.error(`refused: ${err.message}`);
|
|
process.exitCode = 2;
|
|
}
|