feat: pairing v2 — one-tap trusted devices over LAN and private relay (#2103)
Reworks how devices connect to an OpenChamber server, end to end. Pairing v2: - One-time pairing links/QR codes (openchamber://connect?v=2) carrying a set of transport candidates (LAN/tunnel/relay) and a single-use secret redeemed server-side; no tokens embedded in links - Add-a-device dialog written for first-time users: intent-based transport choice (Anywhere / Home network only / This computer only) with plain-language descriptions, transparent fallback checkboxes, server-authoritative LAN detection, high-res QR dialog - Private relay folded into pairing as a transport candidate with a demand-driven lifecycle (enables when a relay device is paired, disables when none remain) Multi-transport devices: - A saved device holds all its transports and one token; mobile re-probes on connect, resume, and network change and hot-switches LAN<->relay seamlessly (no re-pairing, no remount, session preserved) - Desktop can import relay pairing links, switch to relay hosts through the E2EE tunnel, and restore a relay default host after relaunch Device management: - Device list (web + desktop) shows live per-device connectivity with the active transport (Connected - Local network / Relay) and platform badges (iOS/Android/macOS/Windows/Linux) - One physical device = one record: stable per-install dedupe keys across pairing and password re-login; typed pairing label names the device, paired devices name the connection by the issuing server hostname - Trusted desktop-local client manages all devices (list, revoke, clear revoked); relay host reaps dead client sockets after 3 missed keepalives Android: - LAN transport unblocked (cleartext + mixed content, mirroring iOS ATS exceptions); resume re-probe retries through network flux and silently auto-reconnects from a disconnected state
This commit is contained in:
@@ -87,6 +87,7 @@ import { createNotificationTemplateRuntime } from './lib/notifications/template-
|
||||
import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
|
||||
import { createProjectConfigRuntime } from './lib/projects/project-config.js';
|
||||
import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js';
|
||||
import { createClientPairingRuntime } from './lib/client-auth/pairing.js';
|
||||
import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js';
|
||||
import { attachRealtimeProxy } from './lib/realtime-proxy.js';
|
||||
import { createRelayService } from './lib/relay/service.js';
|
||||
@@ -282,6 +283,7 @@ const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
||||
const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json');
|
||||
const APNS_TOKENS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'apns-tokens.json');
|
||||
const REMOTE_CLIENTS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'remote-clients.json');
|
||||
const CLIENT_PAIRING_SESSIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'client-pairing-sessions.json');
|
||||
const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-managed-remote-tunnels.json');
|
||||
const CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json');
|
||||
const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION = 1;
|
||||
@@ -873,6 +875,13 @@ const remoteClientAuthRuntime = createRemoteClientAuthRuntime({
|
||||
crypto,
|
||||
storePath: REMOTE_CLIENTS_FILE_PATH,
|
||||
});
|
||||
const clientPairingRuntime = createClientPairingRuntime({
|
||||
fsPromises,
|
||||
path,
|
||||
crypto,
|
||||
storePath: CLIENT_PAIRING_SESSIONS_FILE_PATH,
|
||||
remoteClientAuthRuntime,
|
||||
});
|
||||
const featureRoutesRuntime = createFeatureRoutesRuntime({
|
||||
clientReloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
});
|
||||
@@ -1102,6 +1111,34 @@ async function main(options = {}) {
|
||||
|| (typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0
|
||||
? process.env.OPENCHAMBER_HOST.trim()
|
||||
: '127.0.0.1');
|
||||
|
||||
// Pairing transports advertised to the create-device dialog. LAN reachability is
|
||||
// derived from the SERVER's actual bind (a wildcard bind → the machine's LAN IP;
|
||||
// a specific non-loopback host → that host), NOT from how the UI was opened — so
|
||||
// "Local network" works even when the UI is opened on localhost, and is absent
|
||||
// when the server is only bound to loopback (a LAN link would not connect).
|
||||
const resolvePairingTransports = () => {
|
||||
const activePort = tunnelRuntimeContext.getActivePort() || port;
|
||||
const local = `http://127.0.0.1:${activePort}`;
|
||||
let lanHost = null;
|
||||
if (isNetworkExposedBindHost(effectiveBindHost)) {
|
||||
try {
|
||||
for (const list of Object.values(os.networkInterfaces())) {
|
||||
for (const entry of (list || [])) {
|
||||
if (entry.family === 'IPv4' && !entry.internal) { lanHost = entry.address; break; }
|
||||
}
|
||||
if (lanHost) break;
|
||||
}
|
||||
} catch {
|
||||
lanHost = null;
|
||||
}
|
||||
} else {
|
||||
const h = String(effectiveBindHost || '').toLowerCase();
|
||||
if (h && h !== '127.0.0.1' && h !== 'localhost' && h !== '::1') lanHost = effectiveBindHost;
|
||||
}
|
||||
const lan = lanHost ? `http://${lanHost.includes(':') ? `[${lanHost}]` : lanHost}:${activePort}` : null;
|
||||
return { local, lan, relayAvailable: true };
|
||||
};
|
||||
const uiPassword = typeof options.uiPassword === 'string'
|
||||
? options.uiPassword
|
||||
: (typeof process.env.OPENCHAMBER_UI_PASSWORD === 'string' ? process.env.OPENCHAMBER_UI_PASSWORD : null);
|
||||
@@ -1204,6 +1241,11 @@ async function main(options = {}) {
|
||||
server = http.createServer(app);
|
||||
let realtimeProxyRuntime = { stop: () => {} };
|
||||
|
||||
// The relay service is constructed further below (it depends on the tunnel
|
||||
// runtime's active port). The pairing routes registered here only read the
|
||||
// relay candidate lazily at request time, so a late-bound holder is enough.
|
||||
let relayServiceInstance = null;
|
||||
|
||||
const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, {
|
||||
process,
|
||||
openchamberVersion: OPENCHAMBER_VERSION,
|
||||
@@ -1244,6 +1286,30 @@ async function main(options = {}) {
|
||||
uiPassword,
|
||||
tunnelAuthController,
|
||||
remoteClientAuthRuntime,
|
||||
clientPairingRuntime,
|
||||
getRelayPairingCandidate: (options) => {
|
||||
if (!relayServiceInstance) return null;
|
||||
// A relay pairing link enables the relay on demand; a plain link only
|
||||
// advertises relay when it is already on.
|
||||
return options?.ensureEnabled
|
||||
? relayServiceInstance.ensureEnabledForPairing()
|
||||
: relayServiceInstance.getPairingCandidate();
|
||||
},
|
||||
// Re-evaluate the relay lifecycle after pairing/device changes (a revoked or
|
||||
// redeemed device can flip relay demand on or off).
|
||||
reconcileRelay: () => (relayServiceInstance ? relayServiceInstance.reconcile() : Promise.resolve()),
|
||||
getPairingTransports: resolvePairingTransports,
|
||||
// The display name a paired device shows for THIS server. Devices name the
|
||||
// connection by the issuing machine's hostname, not the per-device pairing
|
||||
// label typed by the operator.
|
||||
getServerLabel: () => {
|
||||
try {
|
||||
const name = os.hostname();
|
||||
return typeof name === 'string' && name.trim().length > 0 ? name.trim() : 'OpenChamber';
|
||||
} catch {
|
||||
return 'OpenChamber';
|
||||
}
|
||||
},
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
sayTTSCapability,
|
||||
@@ -1297,7 +1363,17 @@ async function main(options = {}) {
|
||||
writeSettingsToDisk,
|
||||
remoteClientAuthRuntime,
|
||||
getLocalPort: () => tunnelRuntimeContext.getActivePort(),
|
||||
// Relay demand = any paired device or pending pairing session that uses the
|
||||
// relay transport. Drives the auto on/off lifecycle.
|
||||
hasRelayDemand: async () => {
|
||||
const [pendingRelay, deviceRelay] = await Promise.all([
|
||||
clientPairingRuntime.hasActiveRelaySession().catch(() => false),
|
||||
remoteClientAuthRuntime.hasActiveRelayClients().catch(() => false),
|
||||
]);
|
||||
return pendingRelay || deviceRelay;
|
||||
},
|
||||
});
|
||||
relayServiceInstance = relayService;
|
||||
relayService.registerRoutes(app);
|
||||
|
||||
await featureRoutesRuntime.registerRoutes(app, {
|
||||
@@ -1410,7 +1486,9 @@ async function main(options = {}) {
|
||||
}
|
||||
|
||||
// Only opens a relay control socket when the user opted in (config enabled).
|
||||
void relayService.startIfEnabled();
|
||||
// Reconcile the relay lifecycle from demand on startup: run it if any relay
|
||||
// device/session exists, stop it (and clear a stale enabled flag) otherwise.
|
||||
void relayService.reconcile();
|
||||
|
||||
return {
|
||||
expressApp: app,
|
||||
|
||||
Reference in New Issue
Block a user