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:
Iuliia Ivashko
2026-07-10 00:12:33 +03:00
committed by GitHub
parent a1aae30e66
commit 91a95bfdaa
53 changed files with 4589 additions and 1369 deletions
@@ -27,9 +27,11 @@ import {
redactSensitiveUrl,
resolveDesktopHostUrl,
type DesktopHost,
type DesktopHostRelay,
type HostProbeResult,
} from '@/lib/desktopHosts';
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import {
desktopSshConnect,
desktopSshDisconnect,
@@ -47,6 +49,26 @@ const runtimeKeyForHost = (host: DesktopHost): string => {
return `host:${host.id}`;
};
// Quick reachability check for a relay host: open a throwaway E2EE tunnel and
// hit /health. Confirms the relay routes to the (still-online) host before we
// commit the runtime switch, so an offline host surfaces as an error instead of
// a broken runtime. The steady-state tunnel is opened by switchRuntimeEndpoint.
const probeRelayHost = async (relay: DesktopHostRelay): Promise<boolean> => {
const tunnel = createRelayTunnelClient({
relayUrl: relay.relayUrl,
serverId: relay.serverId,
hostEncPubJwk: relay.hostEncPubJwk,
});
try {
const response = await tunnel.fetch('/health');
return response.ok;
} catch {
return false;
} finally {
tunnel.close();
}
};
type HostStatus = {
status: HostProbeResult['status'];
latencyMs: number;
@@ -240,6 +262,15 @@ const resolveCurrentHost = (hosts: DesktopHost[]) => {
const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin;
const normalizedCurrent = normalizeHostUrl(currentHref) || currentHref;
// Relay hosts share the window origin as their (virtual) API base, so URL
// matching can't distinguish them — identify the active relay host by its
// stable runtime key instead.
const activeRuntimeKey = getRuntimeKey();
const relayMatch = hosts.find((h) => h.relay && runtimeKeyForHost(h) === activeRuntimeKey);
if (relayMatch) {
return { id: relayMatch.id, label: relayMatch.label, url: relayMatch.url };
}
if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) {
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
}
@@ -484,6 +515,32 @@ export function DesktopHostSwitcherDialog({
}, [open]);
const handleSwitch = React.useCallback(async (host: DesktopHost) => {
// Relay hosts have no reachable HTTP origin — they ride the E2EE tunnel.
// Activate it in-renderer via switchRuntimeEndpoint({ relay }); the runtime
// fetch/socket layers route through the tunnel from the singleton registry.
if (host.relay) {
setSwitchingHostId(host.id);
const reachable = await probeRelayHost(host.relay).catch(() => false);
setStatusById((prev) => ({
...prev,
[host.id]: { status: reachable ? 'ok' : 'unreachable', latencyMs: 0 },
}));
if (!reachable) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
setSwitchingHostId(null);
return;
}
switchRuntimeEndpoint({
apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '',
clientToken: host.clientToken || null,
runtimeKey: runtimeKeyForHost(host),
relay: host.relay,
});
onHostSwitched?.();
setSwitchingHostId(null);
return;
}
const origin = host.id === LOCAL_HOST_ID ? localOrigin : (normalizeHostUrl(host.url) || '');
const apiOrigin = host.id === LOCAL_HOST_ID ? localOrigin : (normalizeHostUrl(getDesktopHostApiUrl(host)) || '');
if (!origin) return;