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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user