feat: add private relay for end-to-end-encrypted remote access (#2087)

Adds OpenChamber Relay — an opt-in way to reach an instance from a phone,
browser, or another desktop from anywhere, with no open inbound ports, no
tunnel, and no shared LAN. The instance dials outbound to a relay; all app
traffic (HTTP, the event stream, terminal, dictation) is multiplexed and
encrypted through a single connection per client, so the relay only ever
forwards opaque ciphertext.

Transport
- End-to-end-encrypted channel over WebCrypto (ECDH P-256 -> HKDF ->
  AES-256-GCM) with a capability-negotiated handshake and a small
  HTTP/SSE/WebSocket multiplexing protocol. A byte-compatible JS host mirror
  is cross-checked by tests.
- Host: outbound connection manager, per-client tunnel dispatcher to the local
  server over loopback, reuse of the existing instance identity key, and
  management routes. Disabled by default; explicit opt-in.
- Client: plugs into the existing runtime layer (runtime-fetch/-url/-switch/
  -auth, event pipeline, terminal, dictation) so features work over the relay
  unchanged; direct-URL and Electron realtime-proxy paths are untouched.

Pairing & UX
- Relay section in Settings -> Remote Instances (live status, QR/link pairing,
  revocation via the existing client-token list) and the mobile connect flow.
- Frame batching and idle-gated keepalive keep tunnel message volume low
  without affecting streaming smoothness.

Security
- The tunnel is transport only; the server authenticates every tunneled
  request exactly as for a direct remote client.
  fragments only. The relay stores no keys, tokens, or payloads.

Operability
- The endpoint can be pinned to a self-hosted rel
  paired clients inherit it from the offer automatically.
- Relay module DOCUMENTATION.md and a relay-trans
  invariants that future WebSocket/streaming changes must follow.

The relay transport is complete and tested; the UI for enabling and pairing
is gated behind openchamber_relay_gate and stays
This commit is contained in:
Bohdan Triapitsyn
2026-07-08 03:44:02 +03:00
committed by GitHub
parent 42e470cefa
commit 859b4529da
74 changed files with 7768 additions and 99 deletions
@@ -8,6 +8,8 @@
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { openRuntimeWebSocket } from '@/lib/relay/runtime-socket';
import { type RelayTunnelWebSocket } from '@/lib/relay/tunnel-client';
export interface DictationStartOptions {
provider?: 'local' | 'openai-compatible';
@@ -67,7 +69,7 @@ interface PendingFinish {
}
export class DictationClient {
private socket: WebSocket | null = null;
private socket: RelayTunnelWebSocket | null = null;
private connectPromise: Promise<void> | null = null;
private idleCloseTimer: ReturnType<typeof setTimeout> | null = null;
private readonly pendingStarts = new Map<string, PendingStart>();
@@ -116,10 +118,10 @@ export class DictationClient {
this.connectPromise = new Promise<void>((resolve, reject) => {
let settled = false;
let socket: WebSocket;
let socket: RelayTunnelWebSocket;
try {
const url = getRuntimeUrlResolver().websocket('/api/dictation/ws');
socket = new WebSocket(url);
socket = openRuntimeWebSocket(url);
} catch (error) {
this.connectPromise = null;
reject(error instanceof Error ? error : new Error(String(error)));
@@ -163,20 +165,26 @@ export class DictationClient {
};
socket.onerror = () => {
if (!settled) {
// Prefer onclose, which follows with the real reason (e.g.
// "Unexpected server response: 403"). But if a socket ever errors
// without a prompt onclose, fail fast here rather than hanging for
// the full connect timeout. onclose still wins if it arrives first.
window.setTimeout(() => {
if (settled) return;
settled = true;
clearTimeout(timeout);
this.connectPromise = null;
reject(new Error('Dictation connection failed'));
}
}, 250);
};
socket.onclose = () => {
socket.onclose = (event) => {
if (!settled) {
settled = true;
clearTimeout(timeout);
this.connectPromise = null;
reject(new Error('Dictation connection closed'));
const detail = event?.reason ? `: ${event.reason}` : '';
reject(new Error(`Dictation connection failed${detail}`));
return;
}
if (this.socket === socket) {