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 {
|
import {
|
||||||
resolveServeHost,
|
resolveServeHost,
|
||||||
hasUiPasswordConfigured,
|
hasUiPasswordConfigured,
|
||||||
|
generateUiPassword,
|
||||||
|
resolveServeUiPassword,
|
||||||
assertAuthenticatedNetworkExposure,
|
assertAuthenticatedNetworkExposure,
|
||||||
} from './lib/cli-network.js';
|
} from './lib/cli-network.js';
|
||||||
import {
|
import {
|
||||||
@@ -428,6 +430,8 @@ export {
|
|||||||
assertAuthenticatedNetworkExposure,
|
assertAuthenticatedNetworkExposure,
|
||||||
resolveServeHost,
|
resolveServeHost,
|
||||||
hasUiPasswordConfigured,
|
hasUiPasswordConfigured,
|
||||||
|
generateUiPassword,
|
||||||
|
resolveServeUiPassword,
|
||||||
shouldDisplayTunnelQr,
|
shouldDisplayTunnelQr,
|
||||||
isValidTunnelDoctorResponse,
|
isValidTunnelDoctorResponse,
|
||||||
readDesktopLocalPortFromSettings,
|
readDesktopLocalPortFromSettings,
|
||||||
|
|||||||
@@ -34,12 +34,14 @@ import {
|
|||||||
discoverRunningInstances,
|
discoverRunningInstances,
|
||||||
discoverUnconfirmedRegistryInstanceOnPort,
|
discoverUnconfirmedRegistryInstanceOnPort,
|
||||||
ensureTunnelProfilesMigrated,
|
ensureTunnelProfilesMigrated,
|
||||||
|
generateUiPassword,
|
||||||
getInstanceFilePath,
|
getInstanceFilePath,
|
||||||
getPidFilePath,
|
getPidFilePath,
|
||||||
isOpenchamberCmdline,
|
isOpenchamberCmdline,
|
||||||
isOpenchamberProcessRunning,
|
isOpenchamberProcessRunning,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
resolveServeHost,
|
resolveServeHost,
|
||||||
|
resolveServeUiPassword,
|
||||||
} from './cli.js';
|
} from './cli.js';
|
||||||
|
|
||||||
async function withTempOpenChamberDataDir(fn) {
|
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', () => {
|
describe('serve host resolution', () => {
|
||||||
it('uses OPENCHAMBER_HOST when --host is not provided', () => {
|
it('uses OPENCHAMBER_HOST when --host is not provided', () => {
|
||||||
const previous = process.env.OPENCHAMBER_HOST;
|
const previous = process.env.OPENCHAMBER_HOST;
|
||||||
|
|||||||
@@ -593,7 +593,7 @@ OPTIONS:
|
|||||||
--lan Bind to 0.0.0.0 for LAN access
|
--lan Bind to 0.0.0.0 for LAN access
|
||||||
--server <url> Public/server URL for connect-url links
|
--server <url> Public/server URL for connect-url links
|
||||||
--relay connect-url: also include the end-to-end-encrypted relay transport
|
--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
|
--api-only Start API routes only, without serving browser UI assets
|
||||||
--foreground Run server in foreground (use with systemd/process managers)
|
--foreground Run server in foreground (use with systemd/process managers)
|
||||||
--no-daemon Alias for --foreground
|
--no-daemon Alias for --foreground
|
||||||
@@ -752,7 +752,7 @@ COMMON OPTIONS:
|
|||||||
-p, --port Target OpenChamber instance port
|
-p, --port Target OpenChamber instance port
|
||||||
--host Bind address when auto-starting an instance
|
--host Bind address when auto-starting an instance
|
||||||
--lan Bind to 0.0.0.0 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
|
--api-only Start API routes only when auto-starting an instance
|
||||||
--json Output machine-readable JSON
|
--json Output machine-readable JSON
|
||||||
--all Apply to all running instances (doctor default, stop)
|
--all Apply to all running instances (doctor default, stop)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import dgram from 'dgram';
|
import dgram from 'dgram';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
import { randomInt } from 'node:crypto';
|
||||||
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
|
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
|
||||||
import {
|
import {
|
||||||
getUnauthenticatedLanErrorMessage,
|
getUnauthenticatedLanErrorMessage,
|
||||||
@@ -125,6 +126,33 @@ function hasUiPasswordConfigured(password) {
|
|||||||
return typeof password === 'string' && password.trim().length > 0;
|
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 }) {
|
function assertAuthenticatedNetworkExposure({ host, uiPassword }) {
|
||||||
const bindHost = resolveConfiguredBindHost(host);
|
const bindHost = resolveConfiguredBindHost(host);
|
||||||
if (hasUiPasswordConfigured(uiPassword)) {
|
if (hasUiPasswordConfigured(uiPassword)) {
|
||||||
@@ -150,5 +178,7 @@ export {
|
|||||||
detectLanIPv4Address,
|
detectLanIPv4Address,
|
||||||
assertSafeBrowserPort,
|
assertSafeBrowserPort,
|
||||||
hasUiPasswordConfigured,
|
hasUiPasswordConfigured,
|
||||||
|
generateUiPassword,
|
||||||
|
resolveServeUiPassword,
|
||||||
assertAuthenticatedNetworkExposure,
|
assertAuthenticatedNetworkExposure,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|||||||
import { pathToFileURL } from 'url';
|
import { pathToFileURL } from 'url';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
|
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 { fetchSystemInfoFromPort } from './cli-http.js';
|
||||||
import { isPortAvailable, resolveAvailablePort } from './cli-ports.js';
|
import { isPortAvailable, resolveAvailablePort } from './cli-ports.js';
|
||||||
import { ensureLogsDir, getLogFilePath } from './cli-paths.js';
|
import { ensureLogsDir, getLogFilePath } from './cli-paths.js';
|
||||||
@@ -110,7 +110,13 @@ async function serveCommand(options) {
|
|||||||
rotateLogFile(initialLogPath);
|
rotateLogFile(initialLogPath);
|
||||||
const logFd = fs.openSync(initialLogPath, 'a');
|
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({
|
assertAuthenticatedNetworkExposure({
|
||||||
host: effectiveHost,
|
host: effectiveHost,
|
||||||
uiPassword: effectiveUiPassword,
|
uiPassword: effectiveUiPassword,
|
||||||
@@ -214,8 +220,15 @@ async function serveCommand(options) {
|
|||||||
|
|
||||||
if (isQuietMode(options)) {
|
if (isQuietMode(options)) {
|
||||||
if (!options.suppressQuietOutput) {
|
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.
|
// Clean up PID / instance files.
|
||||||
@@ -365,7 +378,11 @@ async function serveCommand(options) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isJsonMode(options)) {
|
if (isJsonMode(options)) {
|
||||||
printJson({ ...serveResult, messages: jsonMessages });
|
printJson({
|
||||||
|
...serveResult,
|
||||||
|
messages: jsonMessages,
|
||||||
|
...(autoGeneratedUiPassword ? { password: effectiveUiPassword } : {}),
|
||||||
|
});
|
||||||
return resolvedPort;
|
return resolvedPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +390,14 @@ async function serveCommand(options) {
|
|||||||
if (options.suppressQuietOutput) {
|
if (options.suppressQuietOutput) {
|
||||||
return resolvedPort;
|
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;
|
return resolvedPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +406,10 @@ async function serveCommand(options) {
|
|||||||
if (!options.suppressStartupSummary && showOutput) {
|
if (!options.suppressStartupSummary && showOutput) {
|
||||||
clackIntro('OpenChamber Started');
|
clackIntro('OpenChamber Started');
|
||||||
logStatus('success', `port ${serveResult.port} (PID: ${serveResult.pid})`);
|
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', `visit: ${serveResult.url}`);
|
||||||
logStatus('info', `logs: ${serveResult.logs}`);
|
logStatus('info', `logs: ${serveResult.logs}`);
|
||||||
clackOutro('daemon running');
|
clackOutro('daemon running');
|
||||||
|
|||||||
Reference in New Issue
Block a user