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:
@@ -12,6 +12,14 @@ import { createTunnelHost } from './tunnel-host.js';
|
||||
const BACKOFF_BASE_MS = 1000;
|
||||
const BACKOFF_CAP_MS = 30000;
|
||||
const DATA_SOCKET_OPEN_TIMEOUT_MS = 15000;
|
||||
// Clients send a tunnel Ping at least every ~30s when idle, so a data socket
|
||||
// with no inbound traffic for 3 ping intervals belongs to a client that died
|
||||
// without a WebSocket close (network loss, battery kill). The relay worker may
|
||||
// not notice the dead client leg for a long time, so the host must reap these
|
||||
// itself — both to free resources and to keep the "N devices connected" status
|
||||
// honest instead of counting ghosts.
|
||||
const DATA_SOCKET_IDLE_TIMEOUT_MS = 90_000;
|
||||
const DATA_SOCKET_IDLE_SWEEP_INTERVAL_MS = 30_000;
|
||||
const DEFAULT_BATCH_WINDOW_MS = 150;
|
||||
|
||||
// Resolve the frame-batching flush window: explicit option wins, then env, then
|
||||
@@ -103,7 +111,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = { socket, tunnel: null, openTimer: null, batcher: null };
|
||||
const entry = { socket, tunnel: null, openTimer: null, batcher: null, lastActivityAt: Date.now() };
|
||||
dataSockets.set(connectionId, entry);
|
||||
entry.openTimer = setTimeout(() => {
|
||||
logger.warn('[Relay] host-data socket open timeout');
|
||||
@@ -141,6 +149,9 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
const handleMessage = async (data, isBinary) => {
|
||||
const current = dataSockets.get(connectionId);
|
||||
if (current !== entry) return;
|
||||
// Any inbound message (including the client's keepalive Ping) proves the
|
||||
// client is alive; the idle sweeper reaps sockets this stops updating.
|
||||
entry.lastActivityAt = Date.now();
|
||||
|
||||
if (!isBinary) {
|
||||
const action = await handshake.handleText(data.toString('utf8'));
|
||||
@@ -298,9 +309,22 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
});
|
||||
};
|
||||
|
||||
// Reap data sockets whose client went silent (no frames, no keepalive pings)
|
||||
// — a dead phone leg the relay worker hasn't noticed yet.
|
||||
const idleSweepTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [connectionId, entry] of [...dataSockets.entries()]) {
|
||||
if (now - entry.lastActivityAt <= DATA_SOCKET_IDLE_TIMEOUT_MS) continue;
|
||||
logger.info(`[Relay] reaping idle data socket connectionId=${connectionId}`);
|
||||
teardownDataSocket(connectionId, 1001, 'client idle timeout');
|
||||
}
|
||||
}, DATA_SOCKET_IDLE_SWEEP_INTERVAL_MS);
|
||||
if (typeof idleSweepTimer.unref === 'function') idleSweepTimer.unref();
|
||||
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearInterval(idleSweepTimer);
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
|
||||
Reference in New Issue
Block a user