// 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 { request as httpRequest } from 'node:http'; import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs'; import { basename, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { listOrgs, resolveOrg } from '../../organization/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 }); }; } // The exit code a supervised Console uses to ask for a relaunch (EX_TEMPFAIL). Only the Console's // own restart route (POST /api/console/restart, a human action) exits with it. export const RESTART_EXIT = 75; const sleep = ms => new Promise(r => setTimeout(r, ms)); let lastStamp = 0; // One Console process: spawn it, wait for the URL it prints, check the address and port. async function launch(ctx, args, lan, detach) { lastStamp = Math.max(Date.now(), lastStamp + 1); const log = join(directory(ctx), `console-${process.pid}-${lastStamp}.log`); const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); let child; try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); } finally { closeSync(fd); } const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); }); let exited = false; done.then(() => { exited = true; }); try { 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'); return { child, done, url: match[1], port: match[3], startTime }; } await sleep(50); } // The log is named under the data root, never by host path (PR #1916). fail(`console failed to start; see console/${basename(log)} under the data root`); } catch (e) { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; } throw e; } } const record = (run, lan) => JSON.stringify({ pid: run.child.pid, startTime: run.startTime, url: run.url, ...(lan ?? {}) }) + '\n'; // F3: a relaunch keeps the arguments and the address. The state file must still name the process // that asked for the restart; a stop that ran in between wins, and nothing is relaunched. async function relaunch(ctx, old, args, lan) { const next = args.map((a, i) => (args[i - 1] === '--port' ? old.port : a)); let release; for (let n = 0; !release; n++) { try { release = takeLock(ctx); } catch (e) { if (n >= 50) throw e; await sleep(100); } } try { const s = state(ctx); if (!s || s.pid !== old.child.pid || s.startTime !== old.startTime || s.url !== old.url) fail('console state changed during restart'); let fresh; // The old listener has closed; a refused bind is retried briefly. for (let n = 0; !fresh; n++) { try { fresh = await launch(ctx, next, lan, false); } catch (e) { if (n >= 4) throw e; await sleep(500); } } try { if (fresh.url !== old.url) fail('console restarted on another address'); const tmp = `${stateFile(ctx)}.tmp-${randomBytes(4).toString('hex')}`; writeFileSync(tmp, record(fresh, lan), { mode: 0o600, flag: 'wx' }); renameSync(tmp, stateFile(ctx)); } catch (e) { fresh.child.kill('SIGTERM'); await fresh.done; throw e; } return fresh; } finally { release(); } } // Foreground supervision: relaunch on RESTART_EXIT, end on any other exit. `stop` forwards a // stop signal (the unit's SIGTERM, a terminal's Ctrl-C) to the Console; a stop is a clean end. function supervise(ctx, first, args, lan, warn) { let current = first, stopping = false; const stop = () => { if (stopping) return; stopping = true; const child = current.child; if (child.exitCode !== null || child.signalCode !== null) return; child.kill('SIGTERM'); setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 15000).unref(); }; const done = (async () => { for (;;) { const code = await current.done; if (stopping) return 0; if (code !== RESTART_EXIT) return code; warn('mosaic: console asked for a restart; relaunching on the same arguments\n'); try { current = await relaunch(ctx, current, args, lan); } catch (e) { warn(`mosaic: console restart failed: ${e.message}\n`); return 1; } if (stopping) { stopping = false; stop(); await current.done; return 0; } } })(); return { done, stop }; } // `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. // In the foreground the Console runs with --supervised: its restart route exits RESTART_EXIT and // this process relaunches it in place (F3). A detached Console has no supervisor. 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 run, args; 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. 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(',')] : []), ...(detach ? [] : ['--supervised'])]; run = await launch(ctx, args, lan, detach); try { writeFileSync(stateFile(ctx), record(run, lan), { mode: 0o600, flag: 'wx' }); } catch (e) { run.child.kill('SIGTERM'); await run.done; throw e; } } finally { release(); } if (detach) run.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, run.port)); const result = { url: run.url, pid: run.child.pid, ...(lan ?? {}) }; const supervision = detach ? { done: run.done, stop: () => run.child.kill('SIGTERM') } : supervise(ctx, run, args, lan, warn); Object.defineProperty(result, 'done', { value: supervision.done }); Object.defineProperty(result, 'stop', { value: supervision.stop }); return result; } 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); // The other entry script is a host path, so the refusal never names it (PR #1916). if (i < 0 || argv[i + 1] !== 'serve') fail('console was started from another checkout; 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 } : {}) }; } // U1 (#1873): `mosaic console restart` and `mosaic org reload ID` are the F3 page controls from a // terminal. Each one is a request to the running Console named by console.json, sent as the page // sends it: the Console's own Host and Origin, Sec-Fetch-Site: same-origin and a JSON `{}` body. // The Console's route applies its owner, --send and origin checks and writes the action trail; // nothing here writes the trail, signals a process or touches a bus. The CLI verbs refuse inside // an agent run before they get here (transport.mjs refuseInsideAgent). const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/; function consoleTarget(ctx) { const s = state(ctx); if (!s) fail('no Console runs for this instance; start one with mosaic console start'); if (!liveConsole(s)) fail('the Console in console state is not running; see mosaic console status'); const url = /^http:\/\/([0-9.]+):(\d+)\/$/.exec(s.url); if (!url) fail('console state is invalid'); const port = Number(url[2]); if (forbidden.has(port)) fail('console state names a forbidden port'); // A LAN Console on 0.0.0.0 answers on loopback too; one bound to an address answers only there. const address = s.bind && s.bind !== '0.0.0.0' ? s.bind : '127.0.0.1'; return { address, port, host: `${address}:${port}` }; } function consoleCall(target, { method = 'GET', path, timeoutMs }) { return new Promise((resolve, reject) => { const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } : {}) }; let sent = false; const req = httpRequest({ host: target.address, port: target.port, method, path, headers, timeout: timeoutMs }, res => { let text = ''; res.setEncoding('utf8'); res.on('data', chunk => { text += chunk; if (text.length > 65536) req.destroy(new Error('the Console answer is too large')); }); res.on('end', () => { let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ } resolve({ status: res.statusCode, body }); }); res.on('error', e => reject(Object.assign(e, { sent: true }))); }); req.on('timeout', () => req.destroy(Object.assign(new Error('timed out'), { code: 'timeout' }))); req.on('finish', () => { sent = true; }); req.on('error', e => reject(Object.assign(e, { sent }))); req.end(method === 'POST' ? '{}' : undefined); }); } // A refused connection never reached the Console. Anything else after the request left may have // changed something, so it is outcome-unknown and nothing is retried. async function consolePost(target, path, { timeoutMs, unknown }) { let answer; try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs }); } catch (e) { if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status'); throw new CliError(`outcome unknown: ${unknown}`, 1); } return answer; } function refusal(answer, what) { const code = typeof answer.body?.error === 'string' && /^[a-z-]{1,64}$/.test(answer.body.error) ? answer.body.error : `http ${answer.status}`; const message = typeof answer.body?.message === 'string' ? `: ${answer.body.message.slice(0, 500)}` : ''; return new CliError(`${what} refused (${code})${message}`, answer.status >= 500 ? 1 : 3); } // POST /api/console/restart, then wait for the relaunched Console (a new boot id on /api/config). export async function consoleRestart(ctx, { timeoutMs = 10000, waitMs = 30000 } = {}) { const target = consoleTarget(ctx); const answer = await consolePost(target, '/api/console/restart', { timeoutMs, unknown: 'the Console may be restarting; check mosaic console status and the action trail' }); if (answer.status !== 202 || answer.body?.outcome !== 'restarting' || typeof answer.body.boot !== 'string') throw refusal(answer, 'console restart'); const from = answer.body.boot, deadline = Date.now() + waitMs; while (Date.now() < deadline) { await sleep(250); try { const config = await consoleCall(target, { path: '/api/config', timeoutMs: 2000 }); if (config.status === 200 && typeof config.body?.boot === 'string' && config.body.boot !== from) return { outcome: 'restarted', from, boot: config.body.boot }; } catch { /* closed while it restarts */ } } throw new CliError('the Console accepted the restart but has not answered since; check mosaic console status', 1); } // POST /api/orgs/ID/restart/bus: a running bus restarts, a stopped one starts (org-admin.mjs restartBus). export async function consoleBusRestart(ctx, id, { timeoutMs = 120000 } = {}) { if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4); const target = consoleTarget(ctx); const answer = await consolePost(target, `/api/orgs/${id}/restart/bus`, { timeoutMs, unknown: `the bus may be restarting; check mosaic bus status ${id} and the action trail` }); if (answer.status !== 200 || answer.body?.outcome !== 'done' || answer.body.step !== 'bus-restart') throw refusal(answer, 'org reload'); return answer.body; }