Files
stack/agents/rocko/work/queue-178/build.patch
jason.woltje 068d868a99 lead: commit rocko's untracked evidence (4471 files: queue-124 queue-129 queue-171 queue-178 queue-280 queue-31 queue-84 queue-85 queue-94)
Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2026-11-02 15:05:15 -06:00

582 lines
41 KiB
Diff

diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index 49fa5d1e7..9f5feb7e4 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -851,3 +851,23 @@ The plan prints the final Console flags and any dropped `--tester-port N`;
the steps log records a drop too. `pm-on` keeps the pointer's Console flags.
These operations retain their existing Mos/Jason window authority. Nothing in
this row forwards the tester port through the tunnel or changes live units.
+
+Console proxy proof (row 178, #1676): `serve`, `webui-service.sh render` and
+`install` accept `--proof-header NAME --proof-file PATH` together, under preview
+mode only. The header must differ from Host, Origin, the auth header and
+x-mosaic-tester. Serve exits 2 unless PATH is a non-symlink regular file owned by
+its uid, mode 0600 or 0400, containing at least 32 base64url characters in one
+line (an optional final LF is allowed). Render checks metadata only and refuses
+paths inside the checkout or copies; it never opens the file. Keep the value
+out of argv, logs and messages. Serve reads it once, and prints only `proxy
+proof on` or `proxy proof off`. The public Host needs exactly one matching proof
+before sign-in is checked; bad or absent proof gets 401 and no bus or board
+call. The proof header on another Host or the tester listener gets 403.
+`stack-release.sh` preserves both flags unchanged on re-pin and rollback.
+
+This row installs no proof file, middleware or live flags. Those belong to a
+separate Mos plan-checked window: middleware first, Console flags second, then a
+public sign-in smoke and a forged-loopback 401 check; rollback drops flags
+before middleware. Same-uid callers can still read the file; separate service
+uid is Jason's question on #385. The Authentik allowed-uid file remains an
+identity input and is never reused for proof.
diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs
index c7d9478c9..49a9659f5 100644
--- a/packages/cli/tests/stack-release.test.mjs
+++ b/packages/cli/tests/stack-release.test.mjs
@@ -1197,3 +1197,26 @@ test('T44 row 180 item 2: --plan lists tester-port-invalid and tester-port-targe
await refused(e,['activate',A,'acme','--tester-port','7333'],'tester-port-target',`${A}'s Console does not support --tester-port`);
});
});
+
+// Row 178: proof metadata uses temp fixture files, never the installed slot.
+async function seedProof(e) {
+ await seed(e);
+ const file=join(e.root,'proof.fixture');writeFileSync(file,'fixture_proxy_proof_012345678901234567890123456789',{mode:0o600});
+ const extra=['--public-host','console.example','--auth-header','x-user','--allow-user','jason','--proof-header','x-console-proof','--proof-file',file];
+ const p=pointer(e);p.console.push(...extra);writeFileSync(e.pointer,JSON.stringify(p)+'\n');
+ const unit=join(e.units,CON);edit(unit,` --business acme\n`,` --business acme ${extra.join(' ')}\n`);
+ return p.console;
+}
+test('T178 proxy proof header and path survive re-pin and rollback unchanged in the pointer and unit',async t=>{
+ const e=await mk(t),expected=await seedProof(e);
+ let r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
+ assert.deepEqual(pointer(e).console,expected);assert.ok(consoleText(e).includes(expected.join(' ')));
+ r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
+ assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,expected);assert.ok(consoleText(e).includes(expected.join(' ')));
+});
+test('T178 proxy proof pair survives a tester-port replacement as well',async t=>{
+ const e=await mk(t),expected=await seedProof(e);
+ const r=await run(e,'activate',C1,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
+ assert.deepEqual(pointer(e).console,[...expected,'--tester-port','7333']);
+ assert.ok(consoleText(e).includes([...expected,'--tester-port','7333'].join(' ')));
+});
diff --git a/packages/webui/README.md b/packages/webui/README.md
index e3448f2a2..634ceaead 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -971,3 +971,31 @@ and pointer carry it. A target that lacks support refuses an explicit flag befor
stopping anything. Re-pins/rollbacks preserve it for supporting targets and drop
it, visibly in the plan and steps log, for older ones. See docs/TOOLS.md. No live
listener, unit or tunnel changes are part of a build or test.
+
+### Proxy proof (row 178)
+
+Preview mode can additionally require `--proof-header NAME --proof-file PATH`.
+Both flags go together and require the three preview flags. The file must be a
+regular, non-symlink file owned by the service uid, mode 0600 or 0400, containing
+one line of at least 32 base64url characters (`A-Z`, `a-z`, `0-9`, `_`, `-`),
+with at most one final LF. Serve reads it once before listening. Only the header
+name and file path go on argv; the value is never printed, returned in config,
+or logged. Restarting with the new file is how an operator rotates the proof.
+
+For the public Host, exactly one proof header must match before the auth header
+is read. Missing, repeated or incorrect proof gets the same 401 `proxy proof
+required` and reaches neither bus nor board. The proof header with any other
+Host, including the tester listener, gets 403. Without the flags startup says
+`proxy proof off`; with them it says `proxy proof on`.
+
+`webui-service.sh render` accepts the pair, checks file metadata and refuses
+paths inside the checkout or configured copies directory. It never opens the
+file. `stack-release.sh` carries the pair unchanged across re-pin and rollback.
+
+This is a partial boundary. It blocks a caller that cannot read the file; it
+cannot block another process running as the Console uid. Separate service uid
+is Jason's remaining infrastructure decision on #385. No live proof, middleware
+or unit flags are installed by this code change. Activation is a separately
+plan-checked Mos window: proxy middleware first, then Console flags and restart;
+rollback removes flags before middleware. Do not reuse console-allowed-uid,
+which is an identity rather than a proxy secret.
diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs
index bb8775137..774024bc6 100644
--- a/packages/webui/src/cli.mjs
+++ b/packages/webui/src/cli.mjs
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { createChat } from './chat.mjs';
-import { startServer, previewOptions, DEFAULT_BOARD } from './serve.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';
@@ -11,11 +11,11 @@ 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]]';
+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 = {};
+ const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, testerPort = null;
while (args.length) {
const flag = args.shift();
@@ -46,10 +46,13 @@ try {
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.
@@ -79,14 +82,14 @@ try {
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, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
+ 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, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
+ 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.
@@ -95,8 +98,9 @@ try {
} 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}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
+ 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}`);
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index 8846e7341..47883b5c9 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -1,5 +1,6 @@
import { createServer } from 'node:http';
-import { readFileSync } from 'node:fs';
+import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
+import { timingSafeEqual } from 'node:crypto';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from './bus.mjs';
@@ -211,6 +212,32 @@ export function previewOptions({ publicHost, authHeader, allowUser } = {}) {
if (typeof allowUser !== 'string' || !USER.test(allowUser)) throw new Error('allowed user must be 1..256 visible ASCII characters without a comma');
return { host, header, user: allowUser };
}
+// Row 178: read the proof once, without following a final symlink. Refusals never
+// include the path, bytes, or an underlying fs error. The tester gets only its header name.
+export function proofOptions({ proofHeader, proofFile } = {}, preview = null) {
+ if (proofHeader === undefined && proofFile === undefined) return null;
+ if (!proofHeader || !proofFile || !preview) throw new Error('proxy proof needs both flags and preview mode');
+ const header = String(proofHeader).toLowerCase();
+ if (!HEADER.test(header) || ['host', 'origin', 'x-mosaic-tester', preview.header].includes(header)) {
+ throw new Error('proxy proof header must be distinct from Host, Origin, tester and auth headers');
+ }
+ let fd;
+ try {
+ fd = openSync(proofFile, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
+ const st = fstatSync(fd);
+ if (!st.isFile() || st.uid !== process.getuid() || ![0o600, 0o400].includes(st.mode & 0o7777)) {
+ throw new Error('invalid proof metadata');
+ }
+ const text = readFileSync(fd, 'utf8');
+ const line = text.endsWith('\n') ? text.slice(0, -1) : text;
+ if (line.length < 32 || /[^A-Za-z0-9_-]/.test(line)) throw new Error('invalid proof bytes');
+ return { header, value: Buffer.from(line, 'ascii') };
+ } catch {
+ throw new Error('proxy proof file must be a regular owned 0400 or 0600 file with one base64url line of at least 32 bytes');
+ } finally {
+ if (fd !== undefined) closeSync(fd);
+ }
+}
// REQ-WEB-1: same signed-in send boundary, a distinct strict settings payload.
async function settingsSend(req, res, { settingsWrite, preview, log }) {
const refuse = (status, error, message) => json(res, status, { error, message, sent: false });
@@ -436,7 +463,7 @@ async function projectFileRead(res, read, path, search) {
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
-export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, testerAuthHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
+export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
@@ -448,6 +475,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
}
+ if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
if (send !== null && (preview === null || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
const server = createServer(async (req, res) => {
res.setHeader('x-content-type-options', 'nosniff');
@@ -456,12 +484,24 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// Presence, even with an empty value: never turn the public listener into a tester ingress.
if (!tester && Object.hasOwn(req.headers, 'x-mosaic-tester')) return json(res, 403, { error: 'tester listener required' });
if (tester && testerAuthHeader && Object.hasOwn(req.headers, testerAuthHeader.toLowerCase())) return json(res, 403, { error: 'proxy header refused' });
+ if (tester && testerProofHeader && Object.hasOwn(req.headers, testerProofHeader.toLowerCase())) return json(res, 403, { error: 'proxy proof header refused' });
let path, search;
// Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is
// never legitimate, so it is refused before any check reads the Host.
if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, 400, { error: 'repeated Host refused' });
// Preview mode: a request for the public name, exactly, as the proxy forwards it.
const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host;
+ // The proof precedes every auth-header read, including the wrong-Host fence.
+ if (proof !== null) {
+ const values = req.headersDistinct[proof.header];
+ if (!named && values) return json(res, 403, { error: 'proxy proof Host refused' });
+ if (named) {
+ const value = values?.length === 1 ? Buffer.from(values[0], 'utf8') : null;
+ if (!value || value.length !== proof.value.length || !timingSafeEqual(value, proof.value)) {
+ return json(res, 401, { error: 'proxy proof required' });
+ }
+ }
+ }
// HEADER with any other Host comes from a proxy that rewrote Host (nginx's default) and would
// otherwise land on the loopback path unchecked. The operator's browser never sends it.
if (preview !== null && !named && req.headersDistinct[preview.header]) return json(res, 403, { error: 'proxy Host refused', message: 'the proxy must forward Host unchanged' });
diff --git a/packages/webui/systemd/mosaic-webui.service.in b/packages/webui/systemd/mosaic-webui.service.in
index 6049de0b7..53c357718 100644
--- a/packages/webui/systemd/mosaic-webui.service.in
+++ b/packages/webui/systemd/mosaic-webui.service.in
@@ -3,6 +3,8 @@
# @PATH@, @NODE@ and the serve flags below are filled in at install time.
# @REPO@ is the read-only copy `install --copy ID` names, and the flags
# pass the canonical checkout as --checkout for the queue read (row 77).
+# Row 178: optional --proof-header NAME --proof-file PATH checks proxy proof;
+# the value is read once by serve and never carried in this unit.
# Edit the template and reinstall; do not edit the installed unit.
#
# The Console reads the bus through the human transport, so whoever starts
diff --git a/packages/webui/tests/proof-cli.test.mjs b/packages/webui/tests/proof-cli.test.mjs
new file mode 100644
index 000000000..1f243b0aa
--- /dev/null
+++ b/packages/webui/tests/proof-cli.test.mjs
@@ -0,0 +1,85 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { spawn, spawnSync } from 'node:child_process';
+import { once } from 'node:events';
+import { createServer } from 'node:net';
+import { request } from 'node:http';
+import { testerFixture } from './tester-fixture.mjs';
+import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+const CLI = new URL('../src/cli.mjs', import.meta.url).pathname;
+const VALUE = 'fixture_proxy_proof_0123456789abcdef_0123456789';
+const PREVIEW = ['--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'];
+function fixture(t) {
+ const root = mkdtempSync(join(tmpdir(), 'proof-cli-')); t.after(() => rmSync(root, { recursive: true, force: true }));
+ const file = join(root, 'fixture.token'); writeFileSync(file, VALUE + '\n', { mode: 0o600 });
+ return { root, file, env: { ...process.env, MOSAIC_CONFIG: join(root, 'missing-config.json') } };
+}
+test('proof startup refuses partial flags non-preview and invalid fixture file metadata or bytes', t => {
+ const f = fixture(t), proof = ['--proof-header', 'x-console-proof', '--proof-file', f.file];
+ const sets = [[...PREVIEW, '--proof-header', 'x-console-proof'], [...PREVIEW, '--proof-file', f.file], proof];
+ const missing = join(f.root, 'missing'), dir = join(f.root, 'directory'), link = join(f.root, 'link');
+ mkdirSync(dir); symlinkSync(f.file, link);
+ for (const file of [missing, dir, link]) sets.push([...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file]);
+ const cases = [
+ ...sets.map(flags => ({ flags })),
+ ...[['mode', VALUE, 0o644], ['short', 'short_fixture', 0o600], ['newline', VALUE.slice(0, 20) + '\n' + VALUE.slice(20), 0o600], ['bad', VALUE + '!', 0o600], ['two-final-lines', VALUE + '\n\n', 0o600]].map(([name, value, mode]) => {
+ const file = join(f.root, name); writeFileSync(file, value, { mode });
+ return { flags: [...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], value };
+ }),
+ ];
+ // Simulate another uid via the fixture child's fstat; never chown a host file.
+ const hook = join(f.root, 'other-owner.mjs');
+ writeFileSync(hook, `import fs from 'node:fs';import {syncBuiltinESMExports} from 'node:module';const original=fs.fstatSync;fs.fstatSync=(...args)=>{const s=original(...args);s.uid=process.getuid()+1;return s;};syncBuiltinESMExports();`);
+ cases.push({ flags: [...PREVIEW, ...proof], env: { ...f.env, NODE_OPTIONS: `--import=${hook}` } });
+ for (const c of cases) {
+ const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...c.flags], { env: c.env ?? f.env, encoding: 'utf8', timeout: 5000 });
+ assert.equal(r.status, 2, 'invalid proof startup must refuse'); assert.match(r.stderr, /proof/i);
+ assert.ok(!r.stderr.includes(VALUE)); assert.ok(!r.stdout.includes(VALUE));
+ if (c.value) assert.ok(!r.stderr.includes(c.value));
+ }
+});
+test('serve argv carries only fixture proof path and header; startup explicitly reports proof on or off', async t => {
+ const f = fixture(t);
+ for (const on of [true, false]) {
+ const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...PREVIEW, ...(on ? ['--proof-header', 'x-console-proof', '--proof-file', f.file] : [])], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
+ let out = '', err = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => err += c);
+ t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(Error('fixture CLI startup timeout')), 5000);
+ const check = () => { if (out.includes('Ctrl-C')) { clearTimeout(timer); resolve(); } };
+ child.stdout.on('data', check); child.once('exit', () => { clearTimeout(timer); reject(Error('fixture CLI exited before startup')); }); check();
+ });
+ assert.match(out, new RegExp(`proxy proof ${on ? 'on' : 'off'}`));
+ const cmdline = readFileSync(`/proc/${child.pid}/cmdline`, 'utf8');
+ assert.ok(!cmdline.includes(VALUE)); assert.ok(!out.includes(VALUE)); assert.ok(!err.includes(VALUE));
+ if (on) { assert.ok(cmdline.includes(f.file)); assert.ok(cmdline.includes('--proof-header')); }
+ child.kill('SIGTERM'); await once(child, 'close');
+ }
+});
+
+test('CLI applies proof on the main listener and rejects that header on its tester listener', async t => {
+ const f = await testerFixture(t), file = join(f.root, 'proof.fixture');
+ writeFileSync(file, VALUE, { mode: 0o600 });
+ const free = createServer(); free.listen(0, '127.0.0.1'); await once(free, 'listening');
+ const testerPort = free.address().port; await new Promise(r => free.close(r));
+ const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--tester-port', String(testerPort), '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
+ let output = ''; child.stdout.on('data', c => output += c); child.stderr.resume();
+ t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(Error('fixture tester startup timeout')), 5000);
+ const check = () => { if (output.includes('Ctrl-C')) { clearTimeout(timer); resolve(); } };
+ child.stdout.on('data', check); child.once('exit', () => { clearTimeout(timer); reject(Error('fixture tester startup refused')); }); check();
+ });
+ const port = Number(output.match(/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)/)[1]);
+ const get = (port, headers) => new Promise((resolve, reject) => {
+ const q = request({ host: '127.0.0.1', port, path: '/api/config', headers }, r => {
+ let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, text }));
+ }); q.on('error', reject); q.end();
+ });
+ assert.equal((await get(port, { host: 'console.example', 'x-user': 'jason' })).status, 401);
+ const good = await get(port, { host: 'console.example', 'x-user': 'jason', 'x-console-proof': VALUE });
+ assert.equal(good.status, 200); assert.ok(!good.text.includes(VALUE));
+ assert.equal((await get(testerPort, { host: `127.0.0.1:${testerPort}`, 'x-console-proof': VALUE })).status, 403);
+});
diff --git a/packages/webui/tests/proof.test.mjs b/packages/webui/tests/proof.test.mjs
new file mode 100644
index 000000000..26e45fd8a
--- /dev/null
+++ b/packages/webui/tests/proof.test.mjs
@@ -0,0 +1,81 @@
+// Row 178: fixture proof values only. No installed proof, proxy or unit is read.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createServer, request } from 'node:http';
+import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import * as serve from '../src/serve.mjs';
+import { close } from './fixture.mjs';
+const HOST = 'console.example', HEADER = 'x-console-proof', VALUE = 'fixture_proxy_proof_0123456789abcdef_0123456789';
+const preview = { host: HOST, header: 'x-user', user: 'jason' };
+function ask(port, { host = HOST, headers = {}, path = '/api/bus/tasks', method = 'GET', body } = {}) {
+ return new Promise((resolve, reject) => {
+ const q = request({ host: '127.0.0.1', port, path, method, headers: Object.fromEntries(Object.entries({ host, 'x-user': 'jason', ...headers }).filter(([, value]) => value !== undefined)) }, r => {
+ let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, text, body: JSON.parse(text) }));
+ }); q.on('error', reject); q.end(body);
+ });
+}
+async function setup(t) {
+ const root = mkdtempSync(join(tmpdir(), 'proof-http-')), file = join(root, 'fixture.token');
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ writeFileSync(file, VALUE + '\n', { mode: 0o600 });
+ const proof = serve.proofOptions({ proofHeader: HEADER, proofFile: file }, preview);
+ const calls = [], logs = [];
+ const board = createServer((q, r) => { calls.push('board'); r.setHeader('content-type', 'application/json'); r.end('{"sessions":[]}'); });
+ await new Promise(r => board.listen(0, '127.0.0.1', r)); t.after(() => close(board));
+ const server = await serve.startServer({ port: 0, preview, proof, board: `http://127.0.0.1:${board.address().port}`,
+ bus: { tasks: async () => { calls.push('tasks'); return []; } }, send: async () => { calls.push('send'); return { id: 'receipt-1' }; }, log: line => logs.push(line) });
+ t.after(() => close(server));
+ return { port: server.address().port, calls, logs, proof, file };
+}
+test('proxy proof admits named reads and sends, reads its fixture once, and never exposes the value', async t => {
+ const f = await setup(t), headers = { [HEADER]: VALUE };
+ writeFileSync(f.file, 'changed_fixture_value_012345678901234567890');
+ assert.equal((await ask(f.port, { headers })).status, 200);
+ assert.equal((await ask(f.port, { headers, path: '/api/board' })).status, 200);
+ const sent = await ask(f.port, { headers: { ...headers, origin: `https://${HOST}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, method: 'POST', path: '/api/bus/message', body: JSON.stringify({ to: 'coder', body: 'fixture' }) });
+ assert.equal(sent.status, 200); assert.equal(sent.body.id, 'receipt-1');
+ assert.deepEqual(f.calls, ['tasks', 'board', 'send']);
+ const config = await ask(f.port, { headers, path: '/api/config' });
+ assert.equal(config.status, 200); assert.ok(!config.text.includes(VALUE));
+ assert.ok(!f.logs.join('\n').includes(VALUE));
+});
+test('proxy proof refuses missing wrong short or repeated proof before auth and before any read', async t => {
+ const f = await setup(t);
+ for (const headers of [{}, { [HEADER]: 'x'.repeat(VALUE.length) }, { [HEADER]: VALUE.slice(1) }, { [HEADER]: [VALUE, VALUE] }]) {
+ for (const path of ['/api/bus/tasks', '/api/board']) {
+ const result = await ask(f.port, { headers, path });
+ assert.equal(result.status, 401); assert.deepEqual(result.body, { error: 'proxy proof required' });
+ assert.ok(!result.text.includes(VALUE));
+ }
+ }
+ // Writes are fenced by the same proof check, before the human send callback.
+ for (const headers of [{}, { [HEADER]: 'x'.repeat(VALUE.length) }, { [HEADER]: VALUE.slice(1) }, { [HEADER]: [VALUE, VALUE] }]) {
+ const result = await ask(f.port, { headers: { ...headers, origin: `https://${HOST}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, path: '/api/bus/message', method: 'POST', body: JSON.stringify({ to: 'coder', body: 'fixture' }) });
+ assert.equal(result.status, 401); assert.deepEqual(result.body, { error: 'proxy proof required' });
+ assert.ok(!result.text.includes(VALUE));
+ }
+ const noIdentity = await ask(f.port, { headers: { 'x-user': 'wrong' } });
+ assert.equal(noIdentity.status, 401); assert.deepEqual(noIdentity.body, { error: 'proxy proof required' });
+ assert.deepEqual(f.calls, []); assert.ok(!f.logs.join('\n').includes(VALUE));
+});
+test('proxy proof header is refused on other Hosts and on the tester listener', async t => {
+ const f = await setup(t);
+ for (const host of [`127.0.0.1:${f.port}`, 'other.example']) {
+ assert.equal((await ask(f.port, { host, headers: { 'x-user': undefined, [HEADER]: VALUE } })).status, 403);
+ }
+ const server = await serve.startServer({ port: 0, tester: true, testerProofHeader: HEADER, board: 'http://127.0.0.1:9' });
+ t.after(() => close(server));
+ const result = await ask(server.address().port, { host: `127.0.0.1:${server.address().port}`, headers: { 'x-user': undefined, [HEADER]: VALUE }, path: '/api/config' });
+ assert.equal(result.status, 403); assert.deepEqual(f.calls, []);
+});
+test('proof options reject unsafe or colliding header names without disclosing file contents', t => {
+ const f = mkdtempSync(join(tmpdir(), 'proof-options-')); t.after(() => rmSync(f, { recursive: true, force: true }));
+ const file = join(f, 'fixture.token'); writeFileSync(file, VALUE, { mode: 0o600 });
+ for (const proofHeader of ['host', 'origin', 'x-user', 'x-mosaic-tester', 'bad header', '']) {
+ assert.throws(() => serve.proofOptions({ proofHeader, proofFile: file }, preview), /proof/);
+ }
+ chmodSync(file, 0o400);
+ assert.equal(serve.proofOptions({ proofHeader: HEADER, proofFile: file }, preview).header, HEADER);
+});
diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs
index 8811a5b9e..5adfcecb4 100644
--- a/packages/webui/tests/service.test.mjs
+++ b/packages/webui/tests/service.test.mjs
@@ -756,3 +756,41 @@ test('status names the copy and whether refactor has moved past it, and says whe
// status reads; it calls nothing that changes the unit.
assert.deepEqual(readFileSync(join(dir, readdirSync(dir).find((d) => d.startsWith('si-')), 'calls.txt'), 'utf8').split('\n').filter(Boolean).filter((c) => !/^--user (is-enabled|is-active|show) mosaic-webui\.service( |$)/.test(c)), []);
});
+
+// Row 178: only fixture paths and stand-in systemctl; render reads metadata, not content.
+test('proxy proof render carries both flags but never opens the fixture file', async t => {
+ const dir = tmp(t), file = join(dir, 'proof.fixture');
+ // Invalid proof bytes deliberately prove render does not validate/read contents.
+ writeFileSync(file, 'not-a-valid-proof', { mode: 0o600 });
+ const r = await run(dir, ['render', ...PREVIEW, '--proof-header', 'X-Console-Proof', '--proof-file', file]);
+ assert.equal(r.status, 0, r.stderr);
+ assert.ok(execStart(r.stdout).includes(` --proof-header X-Console-Proof --proof-file ${file}`));
+ assert.ok(!r.stdout.includes('not-a-valid-proof')); assert.equal(r.stderr, '');
+});
+test('proxy proof render refuses incomplete non-preview missing unsafe-mode and repo/copy paths', async t => {
+ const dir = tmp(t), file = join(dir, 'proof.fixture'); writeFileSync(file, 'fixture', { mode: 0o600 });
+ const insideDir = mkdtempSync(join(REPO, 'packages/webui/tests/proof-render-'));
+ t.after(() => rmSync(insideDir, { recursive: true, force: true }));
+ const inside = join(insideDir, 'proof.fixture'); writeFileSync(inside, 'fixture', { mode: 0o600 });
+ const copy = join(copyRoot(dir), 'proof.fixture'); writeFileSync(copy, 'fixture', { mode: 0o600 });
+ const flags = ['--proof-header', 'x-console-proof', '--proof-file', file];
+ const sets = [[...PREVIEW, '--proof-header', 'x-console-proof'], [...PREVIEW, '--proof-file', file], flags,
+ ...[join(dir, 'missing'), inside, copy].map(f => [...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', f])];
+ for (const args of sets) {
+ const r = await run(dir, ['render', ...args]); assert.equal(r.status, 1); assert.equal(r.stdout, ''); assert.match(r.stderr, /proof/);
+ }
+ chmodSync(file, 0o644);
+ const bad = await run(dir, ['render', ...PREVIEW, ...flags]); assert.equal(bad.status, 1); assert.match(bad.stderr, /proof/);
+});
+
+test('proxy proof status retains send and tester reporting with both proof flags', async t => {
+ const dir = tmp(t), unitDir = join(dir, 'units'), file = join(dir, 'proof.fixture');
+ writeFileSync(file, 'fixture', { mode: 0o600 });
+ const port = await closedPort(), bin = standIn(dir);
+ const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW, '--send', '--tester-port', '1024', '--proof-header', 'x-console-proof', '--proof-file', file], bin);
+ assert.equal(r.status, 0, r.stderr);
+ const status = await run(dir, ['status', '--dir', unitDir], bin);
+ assert.equal(status.status, 0, status.stderr);
+ assert.match(status.stdout, /send: on \(--send; human messages to roles\)/);
+ assert.match(status.stdout, /tester: http:\/\/127\.0\.0\.1:1024/);
+});
diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh
index f70295e99..87cb77522 100755
--- a/scripts/stack-release.sh
+++ b/scripts/stack-release.sh
@@ -617,6 +617,8 @@ flags_drift() {
[ -z "$d" ] || refuse unit-flags-drift "the units differ from the pointer; settle it first:$d"
}
+# Row 178: --proof-header and --proof-file are an opaque pair in P_CONSOLE.
+# Carry them unchanged on re-pin and rollback; never read the proof value.
# Resolve desired flags independently of the pointer and effective units, so a hand-edited
# unit still refuses drift and a failed attempt can restore exactly the captured stack.
# Read the target's own Console, because old copies cannot start with the new flag.
diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh
index 71db7e5c1..d34af09dc 100755
--- a/scripts/webui-service.sh
+++ b/scripts/webui-service.sh
@@ -46,7 +46,9 @@
# (row 83), taken only with the preview set: the unit's Console then sends
# human messages to roles and needs the system config at start. systemd
# expands % and $ and splits on spaces, so each value is held to a plain
-# character set and refused otherwise. None is a secret.
+# character set and refused otherwise. --proof-header NAME --proof-file PATH
+# go together under preview; only the path is carried here, never the secret.
+# render checks ownership, mode and location without opening the proof file.
#
# There is no start, stop, enable or restart verb. The Console reads the
# main listener's bus through the human transport; the optional loopback
@@ -66,7 +68,7 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
UNIT_NAME="mosaic-webui.service"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
- FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N]
+ FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
@@ -89,6 +91,9 @@ PUBLIC_HOST=""
AUTH_HEADER=""
ALLOW_USER=""
SEND=0
+PROOF_HEADER=""
+PROOF_FILE=""
+PROOF_SET=0
SERVE_SET=0
parse_flags() {
@@ -106,6 +111,8 @@ parse_flags() {
--public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;;
--auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;;
--allow-user) [ $# -ge 2 ] || usage; ALLOW_USER="$2"; SERVE_SET=1; shift 2 ;;
+ --proof-header) [ $# -ge 2 ] || usage; PROOF_HEADER="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
+ --proof-file) [ $# -ge 2 ] || usage; PROOF_FILE="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--send) SEND=1; SERVE_SET=1; shift ;;
*) usage ;;
esac
@@ -177,6 +184,23 @@ serve_flags() {
fi
flags="$flags --public-host $PUBLIC_HOST --auth-header $AUTH_HEADER --allow-user $ALLOW_USER"
fi
+ if [ "$PROOF_SET" = 1 ]; then
+ [ -n "$PROOF_HEADER" ] && [ -n "$PROOF_FILE" ] && [ "$preview" -eq 3 ] || die "proxy proof needs both flags and preview mode"
+ [[ "$PROOF_HEADER" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,63}$ ]] || die "invalid proxy proof header"
+ case "${PROOF_HEADER,,}" in host|origin|x-mosaic-tester|"${AUTH_HEADER,,}") die "proxy proof header must differ from Host, Origin, tester and auth headers" ;; esac
+ case "$PROOF_FILE" in /*) ;; *) die "proxy proof file must be an absolute path" ;; esac
+ case "$PROOF_FILE" in *[!A-Za-z0-9/._+-]*) die "proxy proof path cannot be carried by the unit" ;; esac
+ # Metadata only: render never opens the proof, even to validate its contents.
+ [ ! -L "$PROOF_FILE" ] && [ -f "$PROOF_FILE" ] || die "invalid proxy proof file metadata"
+ local proof_path copies_path proof_mode proof_uid
+ proof_path="$(realpath -e -- "$PROOF_FILE")" || die "cannot stat proxy proof file"
+ copies_path="$(realpath -m -- "$COPIES")" || die "cannot resolve copies directory"
+ case "$proof_path" in "$REPO"|"$REPO"/*|"$copies_path"|"$copies_path"/*) die "proxy proof file must be outside the repository and copies" ;; esac
+ proof_mode="$(stat -c '%a' -- "$PROOF_FILE")" || die "cannot stat proxy proof file"
+ proof_uid="$(stat -c '%u' -- "$PROOF_FILE")" || die "cannot stat proxy proof file"
+ { [ "$proof_mode" = 600 ] || [ "$proof_mode" = 400 ]; } && [ "$proof_uid" = "$(id -u)" ] || die "invalid proxy proof file metadata"
+ flags="$flags --proof-header $PROOF_HEADER --proof-file $PROOF_FILE"
+ fi
[ "$SEND" = 0 ] || flags="$flags --send"
[ "$TESTER_SET" = 0 ] || flags="$flags --tester-port $TESTER_PORT"
echo "$flags"
@@ -361,10 +385,10 @@ status_unit() {
else
echo "copy: none; the unit runs from $wd"
fi
- # render puts --send right after the allowed user, before the optional
- # tester port. The allowed user
+ # render puts --send after the allowed user and optional proof pair,
+ # before the tester port. The allowed user
# starts with a letter or digit (N5), so it is never the value --send.
- if grep -Eq '^ExecStart=.* --allow-user [^ ][^ ]* --send( --tester-port [0-9]+)?$' "$target"; then
+ if grep -Eq '^ExecStart=.* --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)? --send( --tester-port [0-9]+)?$' "$target"; then
echo "send: on (--send; human messages to roles)"
else
echo "send: off (read-only)"