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
+87 -15
View File
@@ -1,3 +1,5 @@
import { getActiveRelayTunnel } from './relay/runtime-tunnel';
import { TUNNEL_PARSE_BASE } from './relay/tunnel-payloads';
import { buildRuntimeAuthHeaders } from './runtime-auth';
import { getRuntimeUrlResolver, type RuntimeUrlQuery } from './runtime-url';
@@ -150,6 +152,56 @@ const mergeHeaders = async (inputHeaders?: HeadersInit, initHeaders?: HeadersIni
return buildRuntimeAuthHeaders(headers);
};
// ── Relay-mode routing ─────────────────────────────────────────────────────
// When the active runtime is a private relay, runtime HTTP does not go to the
// network: it rides the E2EE tunnel. We route exactly the same paths we would
// resolve for a network runtime (/api, /auth, /health) and attach identical
// auth headers; the bearer/url-token semantics are unchanged, only the
// transport differs. Non-runtime requests (external URLs) fall through to the
// real network fetch.
const appendPathQuery = (path: string, query?: RuntimeUrlQuery): string => {
if (!query) return path;
const url = new URL(path, TUNNEL_PARSE_BASE);
appendRuntimeQuery(url, query);
return `${url.pathname}${url.search}`;
};
const extractRelayPath = (input: string | URL | Request, query?: RuntimeUrlQuery): string | null => {
const raw = input instanceof Request ? input.url : input.toString();
if (!isAbsoluteUrl(raw)) {
if (!shouldResolveApiPath(raw)) return null;
return appendPathQuery(raw, query);
}
try {
const url = new URL(raw);
if (!isCurrentWindowUrl(url) || !shouldResolveApiPath(url.pathname)) return null;
appendRuntimeQuery(url, query);
return `${url.pathname}${url.search}`;
} catch {
return null;
}
};
const tryRelayFetch = async (
input: string | URL | Request,
requestInit: RequestInit,
query?: RuntimeUrlQuery,
): Promise<Response | null> => {
const relay = getActiveRelayTunnel();
if (!relay) return null;
const path = extractRelayPath(input, query);
if (path === null) return null;
const inputHeaders = input instanceof Request ? input.headers : undefined;
const headers = await mergeHeaders(inputHeaders, requestInit.headers, true);
if (input instanceof Request) {
// Forward the Request itself — the tunnel reads its method/body/signal
// natively (incl. stream bodies). Re-wrapping as `new Request(path, input)`
// throws on a stream body without duplex:'half'.
return relay.fetch(input, { ...requestInit, headers });
}
return relay.fetch(path, { ...requestInit, headers });
};
const resolveRuntimeFetchInput = (input: string | URL | Request, query?: RuntimeUrlQuery): string | URL | Request => {
if (typeof input === 'string') {
return buildRuntimeFetchUrl(input, query);
@@ -192,25 +244,43 @@ const coalesceReadKey = (method: string, url: string, hasSignal: boolean): strin
export const runtimeFetch = async (input: string | URL | Request, init: RuntimeFetchOptions = {}): Promise<Response> => {
const { query, ...requestInit } = init;
const resolvedInput = resolveRuntimeFetchInput(input, query);
const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined;
const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput));
const doFetch = (): Promise<Response> =>
resolvedInput instanceof Request
? fetch(new Request(resolvedInput, { ...requestInit, headers }))
: fetch(resolvedInput, { ...requestInit, headers });
// Resolve the transport once — relay tunnel or network — then apply the SAME
// read-coalescing to both. On a relay the tunnel is bandwidth/latency-bound, so
// deduping concurrent identical GETs matters there most.
const relay = getActiveRelayTunnel();
const relayPath = relay ? extractRelayPath(input, query) : null;
let doFetch: () => Promise<Response>;
let url: string;
let method: string;
if (relay && relayPath !== null) {
const inputHeaders = input instanceof Request ? input.headers : undefined;
const headers = await mergeHeaders(inputHeaders, requestInit.headers, true);
doFetch = input instanceof Request
? () => relay.fetch(input, { ...requestInit, headers })
: () => relay.fetch(relayPath, { ...requestInit, headers });
url = relayPath;
method = String(requestInit.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
} else {
const resolvedInput = resolveRuntimeFetchInput(input, query);
const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined;
const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput));
doFetch = resolvedInput instanceof Request
? () => fetch(new Request(resolvedInput, { ...requestInit, headers }))
: () => fetch(resolvedInput, { ...requestInit, headers });
url =
resolvedInput instanceof Request ? resolvedInput.url
: resolvedInput instanceof URL ? resolvedInput.toString()
: String(resolvedInput);
method = String(
requestInit.method ?? (resolvedInput instanceof Request ? resolvedInput.method : 'GET'),
).toUpperCase();
}
const url =
resolvedInput instanceof Request ? resolvedInput.url
: resolvedInput instanceof URL ? resolvedInput.toString()
: String(resolvedInput);
const method = String(
requestInit.method ?? (resolvedInput instanceof Request ? resolvedInput.method : 'GET'),
).toUpperCase();
// A Request always carries a (possibly default) signal; treat any Request, or
// an explicit init.signal, as "has signal" and skip coalescing for safety.
const hasSignal = requestInit.signal != null || resolvedInput instanceof Request;
const hasSignal = requestInit.signal != null || input instanceof Request;
const key = coalesceReadKey(method, url, hasSignal);
if (!key) return doFetch();
@@ -235,6 +305,8 @@ export const installRuntimeFetchBridge = (): void => {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const relayResponse = await tryRelayFetch(input, init ?? {});
if (relayResponse) return relayResponse;
if (typeof input === 'string') {
if (!shouldResolveFetchInput(input)) {
try {