diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index 36058032..098e9005 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -9,6 +9,8 @@ import { EXIT_CODE, TunnelCliError } from './lib/cli-errors.js'; import { resolveServeHost, hasUiPasswordConfigured, + generateUiPassword, + resolveServeUiPassword, assertAuthenticatedNetworkExposure, } from './lib/cli-network.js'; import { @@ -428,6 +430,8 @@ export { assertAuthenticatedNetworkExposure, resolveServeHost, hasUiPasswordConfigured, + generateUiPassword, + resolveServeUiPassword, shouldDisplayTunnelQr, isValidTunnelDoctorResponse, readDesktopLocalPortFromSettings, diff --git a/packages/web/bin/cli.test.js b/packages/web/bin/cli.test.js index 7cc00660..5c2adbf4 100644 --- a/packages/web/bin/cli.test.js +++ b/packages/web/bin/cli.test.js @@ -34,12 +34,14 @@ import { discoverRunningInstances, discoverUnconfirmedRegistryInstanceOnPort, ensureTunnelProfilesMigrated, + generateUiPassword, getInstanceFilePath, getPidFilePath, isOpenchamberCmdline, isOpenchamberProcessRunning, parseArgs, resolveServeHost, + resolveServeUiPassword, } from './cli.js'; async function withTempOpenChamberDataDir(fn) { @@ -692,6 +694,43 @@ describe('network-exposed auth validation', () => { }); }); +describe('serve UI password resolution', () => { + it('keeps a configured password untouched', () => { + expect(resolveServeUiPassword({ uiPassword: 'secret', explicitUiPassword: true })) + .toEqual({ password: 'secret', generated: false }); + }); + + it('generates a password for an explicit --ui-password flag without a value', () => { + const resolved = resolveServeUiPassword({ uiPassword: '', explicitUiPassword: true }); + expect(resolved.generated).toBe(true); + expect(typeof resolved.password).toBe('string'); + expect(resolved.password.length).toBe(16); + }); + + it('does not generate a password when the flag is absent', () => { + expect(resolveServeUiPassword({ uiPassword: undefined, explicitUiPassword: false })) + .toEqual({ password: undefined, generated: false }); + }); + + it('generates passwords from an ambiguity-free charset', () => { + const resolved = resolveServeUiPassword({ uiPassword: '', explicitUiPassword: true }); + expect(resolved.password).toMatch(/^[A-HJ-NP-Za-km-z2-9]{16}$/); + expect(resolved.password).not.toMatch(/[0O1Il]/); + }); + + it('generates distinct passwords on repeated calls', () => { + const a = generateUiPassword(); + const b = generateUiPassword(); + expect(a).not.toBe(b); + }); + + it('parses --ui-password without a value as explicit but empty', () => { + const parsed = parseArgs(['serve', '--ui-password']); + expect(parsed.options.explicitUiPassword).toBe(true); + expect(parsed.options.uiPassword).toBe(''); + }); +}); + describe('serve host resolution', () => { it('uses OPENCHAMBER_HOST when --host is not provided', () => { const previous = process.env.OPENCHAMBER_HOST; diff --git a/packages/web/bin/lib/cli-args.js b/packages/web/bin/lib/cli-args.js index deeccb94..1bc9c387 100644 --- a/packages/web/bin/lib/cli-args.js +++ b/packages/web/bin/lib/cli-args.js @@ -593,7 +593,7 @@ OPTIONS: --lan Bind to 0.0.0.0 for LAN access --server Public/server URL for connect-url links --relay connect-url: also include the end-to-end-encrypted relay transport - --ui-password Protect browser UI with single password + --ui-password [password] Protect browser UI with a password (generates one when omitted) --api-only Start API routes only, without serving browser UI assets --foreground Run server in foreground (use with systemd/process managers) --no-daemon Alias for --foreground @@ -752,7 +752,7 @@ COMMON OPTIONS: -p, --port Target OpenChamber instance port --host Bind address when auto-starting an instance --lan Bind to 0.0.0.0 when auto-starting an instance - --ui-password Protect browser UI when auto-starting an instance + --ui-password [password] Protect browser UI when auto-starting an instance (generates one when omitted) --api-only Start API routes only when auto-starting an instance --json Output machine-readable JSON --all Apply to all running instances (doctor default, stop) diff --git a/packages/web/bin/lib/cli-network.js b/packages/web/bin/lib/cli-network.js index 7e0e666a..992a9e94 100644 --- a/packages/web/bin/lib/cli-network.js +++ b/packages/web/bin/lib/cli-network.js @@ -1,5 +1,6 @@ import dgram from 'dgram'; import os from 'os'; +import { randomInt } from 'node:crypto'; import { EXIT_CODE, TunnelCliError } from './cli-errors.js'; import { getUnauthenticatedLanErrorMessage, @@ -125,6 +126,33 @@ function hasUiPasswordConfigured(password) { return typeof password === 'string' && password.trim().length > 0; } +// Ambiguous-character-free alphabet so the printed password is easy to type +// from a phone or another machine. Mirrors the pre-refactor CLI alphabet. +const UI_PASSWORD_CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'; + +function generateUiPassword(length = 16) { + let password = ''; + for (let i = 0; i < length; i++) { + password += UI_PASSWORD_CHARSET[randomInt(UI_PASSWORD_CHARSET.length)]; + } + return password; +} + +// Resolves the effective UI password for a serve: a configured password wins; +// an explicit `--ui-password` flag without a value gets a freshly generated +// password so daemon/foreground serves never silently drop the requested +// protection. The caller must surface `generated` passwords to the user once +// and persist them in the instance state file the server-side reads. +function resolveServeUiPassword({ uiPassword, explicitUiPassword }) { + if (hasUiPasswordConfigured(uiPassword)) { + return { password: uiPassword, generated: false }; + } + if (explicitUiPassword === true) { + return { password: generateUiPassword(), generated: true }; + } + return { password: undefined, generated: false }; +} + function assertAuthenticatedNetworkExposure({ host, uiPassword }) { const bindHost = resolveConfiguredBindHost(host); if (hasUiPasswordConfigured(uiPassword)) { @@ -150,5 +178,7 @@ export { detectLanIPv4Address, assertSafeBrowserPort, hasUiPasswordConfigured, + generateUiPassword, + resolveServeUiPassword, assertAuthenticatedNetworkExposure, }; diff --git a/packages/web/bin/lib/commands-serve.js b/packages/web/bin/lib/commands-serve.js index 1475018a..48e0a6bc 100644 --- a/packages/web/bin/lib/commands-serve.js +++ b/packages/web/bin/lib/commands-serve.js @@ -2,7 +2,7 @@ import fs from 'fs'; import { pathToFileURL } from 'url'; import { spawn } from 'child_process'; import { EXIT_CODE, TunnelCliError } from './cli-errors.js'; -import { buildLocalUrl, resolveServeHost, assertSafeBrowserPort, hasUiPasswordConfigured, assertAuthenticatedNetworkExposure } from './cli-network.js'; +import { buildLocalUrl, resolveServeHost, assertSafeBrowserPort, resolveServeUiPassword, assertAuthenticatedNetworkExposure } from './cli-network.js'; import { fetchSystemInfoFromPort } from './cli-http.js'; import { isPortAvailable, resolveAvailablePort } from './cli-ports.js'; import { ensureLogsDir, getLogFilePath } from './cli-paths.js'; @@ -110,7 +110,13 @@ async function serveCommand(options) { rotateLogFile(initialLogPath); const logFd = fs.openSync(initialLogPath, 'a'); - const effectiveUiPassword = hasUiPasswordConfigured(options.uiPassword) ? options.uiPassword : undefined; + // Resolve the effective UI password before either launch path so a + // password generated for `--ui-password` (no value) is set in the + // daemon/foreground environment before spawning and persisted in the + // instance state file the server and restart/status flows read. + const resolvedUiPassword = resolveServeUiPassword(options); + const effectiveUiPassword = resolvedUiPassword.password; + const autoGeneratedUiPassword = resolvedUiPassword.generated === true; assertAuthenticatedNetworkExposure({ host: effectiveHost, uiPassword: effectiveUiPassword, @@ -214,8 +220,15 @@ async function serveCommand(options) { if (isQuietMode(options)) { if (!options.suppressQuietOutput) { - realStdoutWrite(`${resolvedPort}\n`); + realStdoutWrite( + autoGeneratedUiPassword + ? `${resolvedPort} pass:${effectiveUiPassword}\n` + : `${resolvedPort}\n` + ); } + } else if (autoGeneratedUiPassword && showOutput && !options.suppressStartupSummary) { + console.log(`Generated UI password: ${effectiveUiPassword}`); + console.log('Save this password — it is not shown again.'); } // Clean up PID / instance files. @@ -365,7 +378,11 @@ async function serveCommand(options) { }; if (isJsonMode(options)) { - printJson({ ...serveResult, messages: jsonMessages }); + printJson({ + ...serveResult, + messages: jsonMessages, + ...(autoGeneratedUiPassword ? { password: effectiveUiPassword } : {}), + }); return resolvedPort; } @@ -373,7 +390,14 @@ async function serveCommand(options) { if (options.suppressQuietOutput) { return resolvedPort; } - process.stdout.write(`${resolvedPort}\n`); + // A generated password is essential result data for scripts: include it + // in the same compact `pass:` token form `openchamber status --quiet` + // already emits. Configured passwords are never echoed. + process.stdout.write( + autoGeneratedUiPassword + ? `${resolvedPort} pass:${effectiveUiPassword}\n` + : `${resolvedPort}\n` + ); return resolvedPort; } @@ -382,6 +406,10 @@ async function serveCommand(options) { if (!options.suppressStartupSummary && showOutput) { clackIntro('OpenChamber Started'); logStatus('success', `port ${serveResult.port} (PID: ${serveResult.pid})`); + if (autoGeneratedUiPassword) { + logStatus('success', 'UI password', effectiveUiPassword); + logStatus('warning', 'save this password', 'it is not shown again'); + } logStatus('info', `visit: ${serveResult.url}`); logStatus('info', `logs: ${serveResult.logs}`); clackOutro('daemon running');