Files
stack/packages/cli/src/org-console.mjs
T
jason.woltje 665a5148d4 Open the dev owner Console to allowed LAN names
mosaic console start --dev-local-owner --dev-insecure-bind ADDR
--dev-allow-host NAME[,NAME...] binds 0.0.0.0 or an IPv4 address and
answers only NAME:<bound port> plus the loopback names. Both flags are
refused without the dev owner, the bind without an allowed name, and a
specific ADDR that is not itself a name. Start and console status print
the no-sign-in warning; the state file records bind and allowHosts.

serve.mjs takes devAllowHosts only with the dev owner; SSO, preview and
tester listeners stay loopback. Writes need Origin http://<same Host>.
Chromium sends no Fetch Metadata from a LAN http origin, so for an
allowed name a write with that exact Origin and no Sec-Fetch-Site counts
as same-origin; an explicit header is still judged by the routes.
chat.js builds its operation keys without crypto.randomUUID, which an
insecure context lacks.
2026-11-08 21:13:53 -06:00

152 lines
8.8 KiB
JavaScript

// Instance-wide Console process; organization routing is owned by S5.
import { randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs';
import { spawn } from 'node:child_process';
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { listOrgs, resolveOrg } from '../../business/src/org-registry.mjs';
import { cmdlineOf, startTimeOf } from './host.mjs';
import { CliError } from './errors.mjs';
import { devLanOptions } from './dev-local-owner.mjs';
const WEBUI = fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url));
const forbidden = new Set([7332, 7340, 7341, 33223, 17450, 17443, 44307]);
const fail = message => { throw new CliError(message, 3); };
const directory = ctx => join(ctx.dataRoot, 'console');
const stateFile = ctx => join(directory(ctx), 'console.json');
function state(ctx) {
let fd;
try { fd = openSync(stateFile(ctx), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (e) { if (e.code === 'ENOENT') return null; fail('console state is unreadable'); }
let data;
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.nlink !== 1) fail('unsafe console state');
try { data = JSON.parse(readFileSync(fd, 'utf8')); } catch { fail('console state is unreadable'); }
} finally { closeSync(fd); }
if (!Number.isSafeInteger(data?.pid) || data.pid <= 0 || typeof data.startTime !== 'string' || !data.startTime || typeof data.url !== 'string') fail('console state is invalid');
// org-lan-bind: a LAN-exposed Console records where it listens and the names it answers.
if (data.bind !== undefined || data.allowHosts !== undefined) {
try { if (!Array.isArray(data.allowHosts) || data.allowHosts.some(name => typeof name !== 'string' || name.includes(',')) || !devLanOptions({ devOwner: true, bind: data.bind, names: data.allowHosts })) throw new Error(); }
catch { fail('console state is invalid'); }
}
return data;
}
const lanWarning = (bind, port) => `WARNING: dev owner Console listens on ${bind}:${port} with no sign-in; anyone who can reach it acts as the owner.\n`;
function checkDir(ctx) {
mkdirSync(directory(ctx), { recursive: true, mode: 0o700 });
const st = lstatSync(directory(ctx));
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o700) fail('unsafe console directory');
}
function liveConsole(s) {
let p;
try { p = processState(s.pid); } catch { fail('console process identity is uncertain'); }
return p.alive && p.startTime === s.startTime;
}
function removeStale(ctx, s, warn) {
if (liveConsole(s)) return false;
const file = stateFile(ctx), st = lstatSync(file);
const current = state(ctx);
if (!current || current.pid !== s.pid || current.startTime !== s.startTime || current.url !== s.url) fail('console state changed during recovery');
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('unsafe console state');
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const aside = `${file}.stale-${stamp}-${randomBytes(4).toString('hex')}`;
const currentStat = lstatSync(file);
if (currentStat.dev !== st.dev || currentStat.ino !== st.ino) fail('console state changed during recovery');
renameSync(file, aside);
warn(`mosaic: stale console state moved to ${aside}\n`);
return true;
}
function takeLock(ctx) {
checkDir(ctx);
const path = join(directory(ctx), 'start.lock');
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
return () => { closeSync(fd); rmSync(path, { force: true }); };
}
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
const release = takeLock(ctx);
let child = null, done = null;
try {
const prior = state(ctx);
if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first');
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
if (org) await resolveOrg(ctx, org.id);
// The pre-S5 Console still accepts a business preselection.
const args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : [])];
const log = join(directory(ctx), `console-${process.pid}-${Date.now()}.log`);
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
finally { closeSync(fd); }
done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
let exited = false; done.then(() => { exited = true; });
const deadline = Date.now() + 30000;
while (Date.now() < deadline && !exited) {
const text = readFileSync(log, 'utf8');
// The URL is loopback, or the specific address a LAN Console binds.
const match = /Mosaic Console: (http:\/\/([0-9.]+):(\d+)\/)/.exec(text);
if (match) {
if (match[2] !== '127.0.0.1' && match[2] !== lan?.bind) fail('console printed an unexpected address');
if (forbidden.has(Number(match[3]))) fail('console selected a forbidden port');
const startTime = startTimeOf(child.pid);
if (!startTime) fail('console exited during startup');
writeFileSync(stateFile(ctx), JSON.stringify({ pid: child.pid, startTime, url: match[1], ...(lan ?? {}) }) + '\n', { mode: 0o600, flag: 'wx' });
if (detach) child.unref();
// The child's stderr goes to the log; the person who started it sees the warning here.
if (lan) warn(lanWarning(lan.bind, match[3]));
const result = { url: match[1], pid: child.pid, ...(lan ?? {}) };
Object.defineProperty(result, 'done', { value: done });
return result;
}
await new Promise(r => setTimeout(r, 50));
}
fail(`console failed to start; see ${log}`);
} catch (e) {
if (child && child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
throw e;
} finally { release(); }
}
export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } = {}) {
const release = takeLock(ctx);
try {
const s = state(ctx);
if (!s || removeStale(ctx, s, warn)) return { stopped: false };
if (!liveConsole(s)) fail('console process changed before stop');
const argv = cmdlineOf(s.pid);
if (!argv || !argv.length) fail('console process identity is uncertain');
const i = argv.indexOf(WEBUI);
if (i < 0 || argv[i + 1] !== 'serve') {
const serve = argv.indexOf('serve');
const entry = serve > 0 ? ` ${argv[serve - 1]}` : '';
fail(`console was started from another path${entry}; stop it there`);
}
process.kill(s.pid, 'SIGTERM');
const deadline = Date.now() + 20000;
while (Date.now() < deadline) {
let p; try { p = processState(s.pid); } catch { fail('console process identity is uncertain'); }
if (!p.alive || p.startTime !== s.startTime) { rmSync(stateFile(ctx)); return { stopped: true, pid: s.pid }; }
await new Promise(r => setTimeout(r, 100));
}
fail('console did not stop');
} finally { release(); }
}
// Read-only: what the state file says and whether that process still runs. It never moves a
// stale state file (start and stop do that under the start lock).
export function consoleStatus(ctx, { warn = s => process.stderr.write(s) } = {}) {
const s = state(ctx);
if (!s) return { running: false };
const running = liveConsole(s);
const bind = s.bind ?? '127.0.0.1';
const port = /:(\d+)\/$/.exec(s.url)?.[1] ?? 'unknown';
// The same warning start printed, for as long as a Console started with the LAN flags runs.
if (running && s.bind) warn(lanWarning(bind, port));
return { running, ...(running ? {} : { stale: true }), pid: s.pid, url: s.url, bind, ...(s.allowHosts ? { allowHosts: s.allowHosts } : {}) };
}