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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user