Files
openchamber/packages/web/bin/lib/commands-connect-url.js
T
quiz152 7a165fd0bb fix(cli): atomic settings writes and gate relay key regeneration in connect-url
The CLI's settings accessors wrote settings.json directly with writeFile and
read it leniently, with no strict-reader gate on relay identity. Running
'openchamber connect-url' while the desktop app is up could:
  - tear the file for a concurrent reader in the app, tripping the relay
    service's read and mapping it to {} (first-run);
  - then regenerate the relay signing/encryption keys, changing serverId and
    orphaning every paired device and push binding.

Move the accessors into a dedicated module that mirrors the settings
runtime's guarantees: atomic tmp+rename writes (with the Windows fallback)
so no reader can observe a partial file, and a strict reader that throws on
corrupt/unreadable payloads so identity regeneration is gated exactly like
the server runtime. Wire the strict reader into the CLI relay identity path.

Adds unit tests covering atomic writes under concurrent readers, strict-read
behavior, and that a corrupt settings file makes getRelayIdentity fail
instead of minting a replacement keypair.
2026-08-09 11:16:30 +08:00

329 lines
13 KiB
JavaScript

import fs from 'fs';
import os from 'os';
import path from 'path';
import crypto from 'crypto';
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import {
assertSafeBrowserPort,
resolveConfiguredBindHost,
buildLocalUrl,
detectLanIPv4Address,
formatHostForUrl,
} from './cli-network.js';
import { discoverRunningInstances } from './cli-lifecycle.js';
import { getInstanceFilePath, readInstanceOptions } from './cli-process.js';
import { createRemoteClientAuthRuntime } from '../../server/lib/client-auth/remote-clients.js';
import { createClientPairingRuntime } from '../../server/lib/client-auth/pairing.js';
import { createRelayIdentityRuntime } from '../../server/lib/relay/identity.js';
import { DEFAULT_RELAY_URL } from '../../server/lib/relay/service.js';
import { bytesToBase64Url } from '../../server/lib/relay/e2ee.js';
import { createSettingsAccessors as createSettingsAccessorsModule } from './cli-settings-accessors.js';
import {
intro as clackIntro,
outro as clackOutro,
log as clackLog,
isJsonMode,
isQuietMode,
printJson,
logStatus,
} from '../cli-output.js';
const REMOTE_CLIENTS_FILE_NAME = 'remote-clients.json';
const PAIRING_SESSIONS_FILE_NAME = 'client-pairing-sessions.json';
function isValidRelayUrl(value) {
if (typeof value !== 'string') return false;
try {
const url = new URL(value.trim());
return url.protocol === 'ws:' || url.protocol === 'wss:';
} catch {
return false;
}
}
// Resolve the relay endpoint the same way the running host does (service.js):
// OPENCHAMBER_RELAY_URL env override, then the stored setting, then the default —
// so the pairing link points at the same relay the host connects out to.
function resolveRelayUrl(settings) {
const envUrl = process.env.OPENCHAMBER_RELAY_URL;
if (isValidRelayUrl(envUrl)) return envUrl.trim();
const stored = settings?.privateRelay?.relayUrl;
if (isValidRelayUrl(stored)) return stored.trim();
return DEFAULT_RELAY_URL;
}
// Minimal settings.json read/write for the relay identity runtime. It reads the
// whole object and writes it back with the relay keys added, so other settings
// are preserved. Enough for the CLI without wiring the full settings runtime.
//
// Mirrors the settings runtime's guarantees: atomic writes (tmp + rename) so
// concurrent readers in the running app never observe a half-written file, and
// a STRICT reader gating relay identity regeneration so a swallowed read
// failure can never mint a new serverId and orphan paired devices.
function createSettingsAccessors() {
return createSettingsAccessorsModule({
fsPromises: fs.promises,
path,
dataDir: getOpenChamberDataDir(),
settingsFileName: 'settings.json',
});
}
// Resolves the instance's relay identity (serverId + encryption public key,
// generating it if the relay was never enabled) into a pairing-v2 relay
// candidate. Relay is a transport, not a separate link format: the candidate
// carries no token — the client redeems the one-time pairing secret over the
// E2EE tunnel like any other candidate. `enabled` reports whether the host relay
// is actually on (a relay candidate only connects when the host is relaying).
async function buildRelayPairingCandidate() {
const accessors = createSettingsAccessors();
const settings = await accessors.readSettingsFromDiskMigrated();
const relayUrl = resolveRelayUrl(settings);
const identityRuntime = createRelayIdentityRuntime({ crypto, ...accessors });
const identity = await identityRuntime.getRelayIdentity();
return {
enabled: settings?.privateRelay?.enabled === true,
relayUrl,
serverId: identity.serverId,
candidate: {
type: 'relay',
relayUrl,
serverId: identity.serverId,
hostEncPubJwk: identity.hostEncPubJwk,
priority: 30,
},
};
}
// Pairing runtime backed by the same on-disk store the running host reads, so a
// session created here is redeemable by the live server. createPairingSession
// only writes the store (no server needed to mint); redeem is served by the host.
function createCliPairingRuntime() {
const dataDir = getOpenChamberDataDir();
const remoteClientAuthRuntime = createRemoteClientAuthRuntime({
fsPromises: fs.promises,
path,
crypto,
storePath: path.join(dataDir, REMOTE_CLIENTS_FILE_NAME),
});
return createClientPairingRuntime({
fsPromises: fs.promises,
path,
crypto,
storePath: path.join(dataDir, PAIRING_SESSIONS_FILE_NAME),
remoteClientAuthRuntime,
});
}
// Mirror of encodePairingConnectionPayload in @openchamber/ui (the bin cannot
// import the UI package). Keep in sync: v2 payload → base64url(JSON) in the URL
// query, so the one-time secret rides the link, never the network.
function encodePairingConnectUrl(payload) {
const encoded = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(payload)));
return `openchamber://connect?v=2&p=${encoded}`;
}
function buildPairingPayload({ pairing, label, candidates }) {
return {
v: 2,
pairingId: pairing.id,
secret: pairing.secret,
...(label ? { label } : {}),
...(pairing.fingerprint ? { fingerprint: pairing.fingerprint } : {}),
...(pairing.expiresAt ? { expiresAt: pairing.expiresAt } : {}),
candidates,
};
}
async function resolveConnectUrlServerUrl(options) {
let hostOverride = options.host;
if (typeof hostOverride !== 'string' && !process.env.OPENCHAMBER_HOST) {
const storedOptions = readInstanceOptions(await getInstanceFilePath(options.port));
if (typeof storedOptions?.host === 'string' && storedOptions.host.trim()) {
hostOverride = storedOptions.host.trim();
}
}
const bindHost = resolveConfiguredBindHost(hostOverride);
// A host that's already a full http(s) URL is a public/server URL, not a bind
// address (e.g. `--host https://devchamber.example.com` for a remote deploy
// behind a reverse proxy). Use it directly instead of feeding it to
// buildLocalUrl, which would produce `http://https://...:port`.
const hostAsServerUrl = normalizeServerUrlForConnection(bindHost);
if (hostAsServerUrl) {
return { serverUrl: hostAsServerUrl, source: 'configured-host' };
}
if (!isWildcardBindHost(bindHost)) {
return {
serverUrl: buildLocalUrl(options.port, '/', hostOverride).replace(/\/+$/, ''),
source: 'configured-host',
};
}
const lanAddress = await detectLanIPv4Address();
if (!lanAddress) {
return {
serverUrl: buildLocalUrl(options.port, '/').replace(/\/+$/, ''),
source: 'loopback-fallback',
};
}
return {
serverUrl: `http://${formatHostForUrl(lanAddress)}:${options.port}`,
source: 'lan-detected',
};
}
function isWildcardBindHost(host) {
return host === '0.0.0.0' || host === '::' || host === '[::]';
}
function isLoopbackServerUrl(serverUrl) {
try {
const hostname = new URL(serverUrl).hostname.replace(/^\[|\]$/g, '');
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1';
} catch {
return false;
}
}
function normalizeServerUrlForConnection(value) {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
parsed.hash = '';
return parsed.toString().replace(/\/+$/, '');
} catch {
return null;
}
}
function getOpenChamberDataDir() {
return process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
}
async function displayTunnelQrCode(url) {
try {
const qrcode = await import('qrcode-terminal');
console.log('\n📱 Scan this QR code to access the tunnel:\n');
qrcode.default.generate(url, { small: true });
console.log('');
} catch (error) {
console.warn(`Warning: Could not generate QR code: ${error.message}`);
}
}
function createConnectUrlCommand({ serveCommand }) {
return async function connectUrlCommand(options = {}) {
assertSafeBrowserPort(options.port, { context: 'OpenChamber connect-url' });
const explicitServerUrl = options.server ? normalizeServerUrlForConnection(options.server) : null;
if (options.server && !explicitServerUrl) {
throw new TunnelCliError('Invalid --server URL. Use an http:// or https:// URL.', EXIT_CODE.USAGE_ERROR);
}
const running = await discoverRunningInstances();
const serverState = running.some((entry) => entry.port === options.port)
? { port: options.port, autoStarted: false }
: await (async () => {
await serveCommand({
port: options.port,
explicitPort: true,
host: options.host,
uiPassword: options.uiPassword,
apiOnly: options.apiOnly,
suppressUnsafePortWarning: true,
suppressUiPasswordWarning: true,
suppressStartupSummary: true,
suppressQuietOutput: true,
});
return { port: options.port, autoStarted: true };
})();
const resolvedServerUrl = explicitServerUrl
? { serverUrl: explicitServerUrl, source: 'explicit' }
: await resolveConnectUrlServerUrl(options);
const serverUrl = resolvedServerUrl.serverUrl;
const label = options.name || os.hostname();
// Direct candidate for the reachable server URL, plus the relay transport as
// a fallback candidate — one link that works both on the LAN and off-network.
// Candidate priorities make the client prefer the direct route and try the
// relay last, mirroring the UI's "Anywhere" pairing. `--relay` opts in even
// when the host relay is not up yet (the demand-driven lifecycle starts it);
// otherwise the relay rides along only when it is already enabled.
const candidates = [{ type: serverUrl.startsWith('https://') ? 'tunnel' : 'lan', url: serverUrl, priority: 10 }];
const relay = await buildRelayPairingCandidate();
if (options.relay || relay.enabled) candidates.push(relay.candidate);
const pairingRuntime = createCliPairingRuntime();
// Mark relay-carrying sessions like the server route does, so the host's
// demand-driven relay lifecycle keeps the relay up while the link is pending.
const usesRelay = candidates.some((candidate) => candidate.type === 'relay');
const { pairing } = await pairingRuntime.createPairingSession({ label, usesRelay });
const connectUrl = encodePairingConnectUrl(buildPairingPayload({ pairing, label, candidates }));
if (isJsonMode(options)) {
printJson({
serverUrl,
connectUrl,
pairingId: pairing.id,
fingerprint: pairing.fingerprint,
expiresAt: pairing.expiresAt,
candidates,
autoStarted: serverState.autoStarted,
});
return;
}
if (isQuietMode(options)) {
process.stdout.write(`${connectUrl}\n`);
return;
}
clackIntro('OpenChamber pairing link');
if (serverState.autoStarted) {
logStatus('success', `started OpenChamber on port ${options.port}`);
}
logStatus('success', connectUrl);
clackLog.info(`Server URL: ${serverUrl}`);
if (options.relay || relay.enabled) {
clackLog.info(`Relay fallback: ${relay.relayUrl}`);
}
if (options.relay && !relay.enabled) {
logStatus('info', '[RELAY_STARTING]', 'Relay is not up yet. A running instance starts it within a minute; a stopped instance starts it on next launch.');
}
if (pairing.fingerprint) {
clackLog.info(`Fingerprint: ${pairing.fingerprint}`);
}
if (resolvedServerUrl.source === 'lan-detected') {
clackLog.info('Detected a LAN address because OpenChamber is bound to all interfaces. Use --server to override it.');
} else if (resolvedServerUrl.source === 'loopback-fallback') {
clackLog.warn('OpenChamber is bound to all interfaces, but no LAN address was detected. Use --server to provide a reachable URL.');
} else if (isLoopbackServerUrl(serverUrl)) {
// The direct candidate points at this machine only — other devices cannot
// use it. Say so instead of letting a "LAN" link silently not work (or a
// --relay link silently go relay-only).
if (options.relay) {
logStatus('warn', '[LAN_UNREACHABLE]', 'OpenChamber only listens on this machine, so devices will always connect through the relay. Restart with --lan to allow direct home-network connections.');
} else {
logStatus('warn', '[LAN_UNREACHABLE]', 'OpenChamber only listens on this machine, so other devices cannot use this link. Restart with --lan, or use --server to provide a reachable URL.');
}
}
clackLog.info('Scan or paste this link into another OpenChamber client. It is single-use and expires.');
if (options.qr === true) {
await displayTunnelQrCode(connectUrl);
}
clackOutro('pairing link generated');
};
}
export { createConnectUrlCommand };