fix(cli): generate a UI password for bare --ui-password in daemon/serve mode

The grand tunnel restructuring removed the CLI's auto-generated UI
password, so `openchamber -d --ui-password` (no value) silently started
an unauthenticated server instead of creating a password as in 1.8.1.

Restore generation for an explicit --ui-password flag without a value:
the password is generated before either launch path, passed to the
daemon/foreground process via OPENCHAMBER_UI_PASSWORD, persisted in the
instance state file, and surfaced once in human/quiet/json output.

Refs OPE-216
This commit is contained in:
Serhii Dziupin
2026-08-05 11:24:10 +03:00
parent 34c221b07f
commit 41a2e3781d
5 changed files with 108 additions and 7 deletions
+4
View File
@@ -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,
+39
View File
@@ -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;
+2 -2
View File
@@ -593,7 +593,7 @@ OPTIONS:
--lan Bind to 0.0.0.0 for LAN access
--server <url> 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)
+30
View File
@@ -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,
};
+33 -5
View File
@@ -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');