Files
Serhii Dziupin 41a2e3781d 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
2026-08-05 11:24:10 +03:00

185 lines
5.6 KiB
JavaScript

import dgram from 'dgram';
import os from 'os';
import { randomInt } from 'node:crypto';
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import {
getUnauthenticatedLanErrorMessage,
isNetworkExposedBindHost,
isUnsafeUnauthenticatedLanAllowed,
} from '../../server/lib/security/bind-host.js';
// Browser-unsafe ports (Fetch/Chromium restricted ports).
const UNSAFE_BROWSER_PORTS = new Set([
0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69,
77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119,
123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515,
526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990,
993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061, 6000, 6566,
6665, 6666, 6667, 6668, 6669, 6697, 10080,
]);
function isUnsafeBrowserPort(port) {
return Number.isFinite(port) && UNSAFE_BROWSER_PORTS.has(Math.trunc(port));
}
function resolveConfiguredBindHost(hostOverride) {
const configured = typeof hostOverride === 'string' && hostOverride.trim()
? hostOverride.trim()
: typeof process.env.OPENCHAMBER_HOST === 'string'
? process.env.OPENCHAMBER_HOST.trim()
: '';
return configured || '127.0.0.1';
}
function resolveServeHost(hostOverride) {
return resolveConfiguredBindHost(hostOverride);
}
function resolveApiHost(hostOverride) {
const configured = resolveConfiguredBindHost(hostOverride);
if (!configured) {
return '127.0.0.1';
}
// Wildcard bind hosts are not valid destination hosts.
if (configured === '0.0.0.0') {
return '127.0.0.1';
}
if (configured === '::' || configured === '[::]') {
return '::1';
}
// Strip brackets if user provided [::1]
if (configured.startsWith('[') && configured.endsWith(']')) {
return configured.slice(1, -1);
}
return configured;
}
function formatHostForUrl(host) {
if (typeof host !== 'string') return '127.0.0.1';
// Bracket IPv6 for URL usage.
return host.includes(':') ? `[${host}]` : host;
}
function buildLocalUrl(port, endpoint = '', hostOverride) {
const host = formatHostForUrl(resolveApiHost(hostOverride));
const pathPart = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `http://${host}:${port}${pathPart}`;
}
async function detectLanIPv4Address() {
const ip = await new Promise((resolve) => {
const socket = dgram.createSocket('udp4');
const finish = (value) => {
try { socket.close(); } catch {}
resolve(value);
};
socket.once('error', () => finish(null));
try {
socket.connect(80, '8.8.8.8', (error) => {
if (error) return finish(null);
try {
const addr = socket.address();
finish(addr && typeof addr.address === 'string' ? addr.address : null);
} catch {
finish(null);
}
});
} catch {
finish(null);
}
});
if (ip && ip !== '0.0.0.0' && !ip.startsWith('127.')) return ip;
for (const entries of Object.values(os.networkInterfaces() || {})) {
for (const entry of entries || []) {
if (entry.family === 'IPv4' && !entry.internal && entry.address) {
return entry.address;
}
}
}
return null;
}
function formatUnsafePortWarning(port) {
return `Port ${port} is browser-unsafe (ERR_UNSAFE_PORT) and is not supported for OpenChamber UI at ${buildLocalUrl(port, '/')}.`;
}
function assertSafeBrowserPort(port, { context = 'This action' } = {}) {
if (!isUnsafeBrowserPort(port)) {
return;
}
throw new TunnelCliError(
`${context} cannot use port ${port}. ${formatUnsafePortWarning(port)} Use a safe port such as 3000, 5173, 8080, or a high ephemeral port.`,
EXIT_CODE.USAGE_ERROR,
);
}
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)) {
return;
}
if (!isNetworkExposedBindHost(bindHost)) {
return;
}
if (isUnsafeUnauthenticatedLanAllowed(process.env)) {
return;
}
throw new TunnelCliError(getUnauthenticatedLanErrorMessage(bindHost), EXIT_CODE.AUTH_CONFIG_ERROR);
}
export {
resolveConfiguredBindHost,
resolveServeHost,
resolveApiHost,
formatHostForUrl,
isUnsafeBrowserPort,
buildLocalUrl,
detectLanIPv4Address,
assertSafeBrowserPort,
hasUiPasswordConfigured,
generateUiPassword,
resolveServeUiPassword,
assertAuthenticatedNetworkExposure,
};