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:
committed by
GitHub
parent
42e470cefa
commit
859b4529da
@@ -32,7 +32,7 @@ import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@
|
||||
import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
|
||||
import { getDisplayModelName } from '@/lib/quota/model-families';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
@@ -58,7 +58,7 @@ import { MobileFilesSurface } from './MobileFilesSurface';
|
||||
import { MobileSessionsSheet } from './MobileSessionsSheet';
|
||||
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
||||
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
||||
import { autoConnectLastInstance, isSameConnectionUrl, useMobileConnection, validateMobileConnectionSession } from './mobileConnections';
|
||||
import { autoConnectLastInstance, isSameConnectionUrl, relayConnectionRuntimeKey, useMobileConnection, validateActiveRuntimeSession } from './mobileConnections';
|
||||
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
||||
import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
||||
import { useAppFontEffects } from './useAppFontEffects';
|
||||
@@ -644,7 +644,9 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
||||
</span>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="truncate typography-ui-label text-foreground">{pendingConnection.label}</p>
|
||||
<p className="truncate typography-small text-muted-foreground">{pendingConnection.url}</p>
|
||||
<p className="truncate typography-small text-muted-foreground">
|
||||
{pendingConnection.relay ? t('mobile.connect.relay.badge') : pendingConnection.url}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
@@ -768,14 +770,16 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
||||
key={connection.id}
|
||||
type="button"
|
||||
className="flex min-h-14 w-full items-center gap-3 border-b border-border/60 px-3.5 py-2.5 text-left last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
onClick={() => void conn.connect({ url: connection.url, clientToken: connection.clientToken, label: connection.label })}
|
||||
onClick={() => void conn.connect({ url: connection.url, clientToken: connection.clientToken, label: connection.label, relay: connection.relay })}
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
<Icon name="server" className="size-[18px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
||||
<span className="block truncate typography-small text-muted-foreground">{connection.url}</span>
|
||||
<span className="block truncate typography-small text-muted-foreground">
|
||||
{connection.mode === 'relay' ? t('mobile.connect.relay.badge') : connection.url}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="arrow-right-s" className="size-5 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -882,7 +886,12 @@ const MobileInstancesSurface: React.FC<{
|
||||
setConfirmingDeleteId(null);
|
||||
if (editingId === id) resetForm();
|
||||
void removeConnection(id).then((removed) => {
|
||||
if (removed && isSameConnectionUrl(removed.url, getRuntimeApiBaseUrl())) {
|
||||
if (!removed) return;
|
||||
// Relay entries have no reachable URL — the runtime key is their identity.
|
||||
const isActive = removed.relay
|
||||
? getRuntimeKey() === relayConnectionRuntimeKey(removed.relay)
|
||||
: isSameConnectionUrl(removed.url, getRuntimeApiBaseUrl());
|
||||
if (isActive) {
|
||||
onActiveConnectionDeleted();
|
||||
}
|
||||
});
|
||||
@@ -901,7 +910,9 @@ const MobileInstancesSurface: React.FC<{
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate typography-ui-label text-foreground">{pendingConnection.label}</p>
|
||||
<p className="truncate typography-small text-muted-foreground">{pendingConnection.url}</p>
|
||||
<p className="truncate typography-small text-muted-foreground">
|
||||
{pendingConnection.relay ? t('mobile.connect.relay.badge') : pendingConnection.url}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
@@ -946,7 +957,7 @@ const MobileInstancesSurface: React.FC<{
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3.5 py-3 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary disabled:opacity-60"
|
||||
onClick={() => void connect({ url: connection.url, clientToken: connection.clientToken, label: connection.label })}
|
||||
onClick={() => void connect({ url: connection.url, clientToken: connection.clientToken, label: connection.label, relay: connection.relay })}
|
||||
disabled={isBusy || confirming}
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
@@ -954,7 +965,9 @@ const MobileInstancesSurface: React.FC<{
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
||||
<span className="block truncate typography-small text-muted-foreground">{connection.url}</span>
|
||||
<span className="block truncate typography-small text-muted-foreground">
|
||||
{connection.mode === 'relay' ? t('mobile.connect.relay.badge') : connection.url}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5 pr-2">
|
||||
@@ -969,7 +982,7 @@ const MobileInstancesSurface: React.FC<{
|
||||
<Icon name="delete-bin" className="size-[18px]" />
|
||||
<span className="typography-ui-label">{t('mobile.instances.delete')}</span>
|
||||
</button>
|
||||
) : (
|
||||
) : connection.mode === 'relay' ? null : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('mobile.instances.edit')}
|
||||
@@ -2199,7 +2212,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
const validationSeq = nativeResumeValidationSeqRef.current + 1;
|
||||
nativeResumeValidationSeqRef.current = validationSeq;
|
||||
|
||||
void validateMobileConnectionSession({ url: apiBaseUrl, clientToken: getRuntimeClientToken() }).then((isValid) => {
|
||||
void validateActiveRuntimeSession({ url: apiBaseUrl, clientToken: getRuntimeClientToken() }).then((isValid) => {
|
||||
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
||||
if (!isValid) {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { validateMobileConnectionSession } from './mobileConnections';
|
||||
import { loadMobileConnections, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
const createLocalStorageStub = () => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { store.set(key, value); },
|
||||
removeItem: (key: string) => { store.delete(key); },
|
||||
};
|
||||
};
|
||||
|
||||
const installTestWindow = () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
@@ -12,6 +21,7 @@ const installTestWindow = () => {
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
location: { protocol: 'https:' },
|
||||
localStorage: createLocalStorageStub(),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -21,6 +31,95 @@ const restoreGlobals = () => {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'openchamber.mobile.connections.v1';
|
||||
|
||||
const testRelay: MobileRelayConfig = {
|
||||
relayUrl: 'wss://relay.example/tunnel',
|
||||
serverId: 'srv_test123',
|
||||
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' },
|
||||
};
|
||||
|
||||
describe('mobile connection storage', () => {
|
||||
test('entries persisted before relay support normalize to direct mode on read', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([
|
||||
{ id: 'a', label: 'Home', url: 'http://192.168.1.10:2606', lastUsedAt: 10, clientToken: 'tok-a' },
|
||||
{ id: 'b', label: 'Work', url: 'http://work.example', lastUsedAt: 5 },
|
||||
]));
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections).toHaveLength(2);
|
||||
expect(connections.every((connection) => connection.mode === 'direct')).toBe(true);
|
||||
expect(connections[0]?.relay).toBe(undefined);
|
||||
expect(connections[0]?.clientToken).toBe('tok-a');
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('relay connections round-trip mode and transport config', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
|
||||
await upsertMobileConnection({
|
||||
label: 'My Desktop',
|
||||
url: 'openchamber://connect?v=1&mode=relay',
|
||||
clientToken: 'oc_client_secret',
|
||||
relay: testRelay,
|
||||
});
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections).toHaveLength(1);
|
||||
const saved = connections[0]!;
|
||||
expect(saved.mode).toBe('relay');
|
||||
expect(saved.relay).toEqual(testRelay);
|
||||
// Web surface: token stays inline like direct connections.
|
||||
expect(saved.clientToken).toBe('oc_client_secret');
|
||||
|
||||
// Persisted metadata carries only the three transport fields — no grant.
|
||||
const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]') as Array<Record<string, unknown>>;
|
||||
expect(raw[0]?.mode).toBe('relay');
|
||||
expect(Object.keys(raw[0]?.relay as object).sort()).toEqual(['hostEncPubJwk', 'relayUrl', 'serverId']);
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('relay entries with malformed transport config are dropped, direct entries survive', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([
|
||||
{ id: 'bad', label: 'Broken', url: 'openchamber://connect', lastUsedAt: 20, mode: 'relay', relay: { relayUrl: 'wss://relay.example' } },
|
||||
{ id: 'ok', label: 'Home', url: 'http://192.168.1.10:2606', lastUsedAt: 10 },
|
||||
]));
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections).toHaveLength(1);
|
||||
expect(connections[0]?.id).toBe('ok');
|
||||
expect(connections[0]?.mode).toBe('direct');
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('relay and direct connections dedupe independently', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
await upsertMobileConnection({ label: 'Direct', url: 'http://host.example' });
|
||||
await upsertMobileConnection({ label: 'Relay', url: 'openchamber://connect?v=1&mode=relay', relay: testRelay });
|
||||
await upsertMobileConnection({ label: 'Relay renamed', url: 'openchamber://connect?v=1&mode=relay', relay: testRelay });
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections).toHaveLength(2);
|
||||
expect(connections.filter((connection) => connection.mode === 'relay')).toHaveLength(1);
|
||||
expect(connections.find((connection) => connection.mode === 'relay')?.label).toBe('Relay renamed');
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMobileConnectionSession', () => {
|
||||
test('accepts a reachable authenticated runtime', async () => {
|
||||
const fetchMock = mock(async (input: RequestInfo | URL) => {
|
||||
|
||||
@@ -19,6 +19,10 @@ import React from 'react';
|
||||
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
import { buildRelayOfferUrl, parseRelayOfferUrl } from '@/lib/relay/offer';
|
||||
import { isRelayModeActive } from '@/lib/relay/runtime-tunnel';
|
||||
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
|
||||
const MOBILE_CONNECTIONS_STORAGE_KEY = 'openchamber.mobile.connections.v1';
|
||||
@@ -28,11 +32,28 @@ const MOBILE_CONNECT_TIMEOUT_MS = 8000;
|
||||
const MOBILE_NATIVE_HTTP_TIMEOUT_MS = 2500;
|
||||
const MOBILE_SECURE_TIMEOUT_MS = 3000;
|
||||
|
||||
export type MobileConnectionMode = 'direct' | 'relay';
|
||||
|
||||
// Persisted relay transport config. This is connection metadata, not a secret
|
||||
// (the host public key is public by construction) — but never log it raw; use
|
||||
// redactOffer-style masking for any debug output.
|
||||
export type MobileRelayConfig = {
|
||||
relayUrl: string;
|
||||
serverId: string;
|
||||
hostEncPubJwk: JsonWebKey;
|
||||
};
|
||||
|
||||
export type MobileSavedConnection = {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
lastUsedAt: number;
|
||||
// 'direct' talks HTTP to `url`; 'relay' rides the E2EE tunnel described by `relay`.
|
||||
// Entries persisted before relay support existed normalize to 'direct' on read.
|
||||
mode: MobileConnectionMode;
|
||||
// Present iff mode === 'relay'. For relay entries `url` holds the canonical
|
||||
// token-free offer link (display/dedupe only — never fetched).
|
||||
relay?: MobileRelayConfig;
|
||||
// Native: indicates a token exists in the secure store. Web: unused.
|
||||
hasToken?: boolean;
|
||||
// Web only: the token stored inline. On native this stays undefined in the list.
|
||||
@@ -42,12 +63,17 @@ export type MobileSavedConnection = {
|
||||
export type MobilePendingConnection = {
|
||||
label: string;
|
||||
url: string;
|
||||
// Present when the password unlock must ride the relay tunnel.
|
||||
relay?: MobileRelayConfig;
|
||||
relayGrant?: string;
|
||||
};
|
||||
|
||||
export type MobileConnectInput = {
|
||||
url: string;
|
||||
clientToken?: string;
|
||||
label?: string;
|
||||
relay?: MobileRelayConfig;
|
||||
relayGrant?: string;
|
||||
};
|
||||
|
||||
type MobileFetchResponse = {
|
||||
@@ -97,6 +123,79 @@ const getConnectionStorageKey = (url: string): string => {
|
||||
export const isSameConnectionUrl = (left: string, right: string): boolean =>
|
||||
getConnectionStorageKey(left) === getConnectionStorageKey(right);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relay helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Stable identity for a relay connection. Also used as the runtime key passed
|
||||
// to switchRuntimeEndpoint so "is this saved entry the active runtime?" checks
|
||||
// can compare against getRuntimeKey().
|
||||
export const relayConnectionRuntimeKey = (relay: MobileRelayConfig): string =>
|
||||
`relay:${relay.serverId}@${relay.relayUrl.trim()}`;
|
||||
|
||||
// Dedupe/secure-store key for a connection of either mode. Direct connections
|
||||
// keep the historical normalized-URL key so existing saved tokens stay valid.
|
||||
const connectionKeyOf = (connection: { url: string; relay?: MobileRelayConfig }): string =>
|
||||
connection.relay ? relayConnectionRuntimeKey(connection.relay) : getConnectionStorageKey(connection.url);
|
||||
|
||||
// Canonical token-free offer link stored as the relay entry's `url`. Secret-free
|
||||
// by construction (no token/grant), safe for localStorage and display.
|
||||
const canonicalRelayUrl = (relay: MobileRelayConfig): string =>
|
||||
buildRelayOfferUrl({
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
relayUrl: relay.relayUrl,
|
||||
serverId: relay.serverId,
|
||||
hostEncPubJwk: relay.hostEncPubJwk,
|
||||
});
|
||||
|
||||
const parseRelayConfig = (value: unknown): MobileRelayConfig | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.relayUrl !== 'string' || !record.relayUrl.trim()) return null;
|
||||
if (typeof record.serverId !== 'string' || !record.serverId.trim()) return null;
|
||||
const jwk = record.hostEncPubJwk;
|
||||
if (!jwk || typeof jwk !== 'object' || Array.isArray(jwk)) return null;
|
||||
const key = jwk as Record<string, unknown>;
|
||||
if (key.kty !== 'EC' || key.crv !== 'P-256') return null;
|
||||
if (typeof key.x !== 'string' || !key.x || typeof key.y !== 'string' || !key.y) return null;
|
||||
return {
|
||||
relayUrl: record.relayUrl,
|
||||
serverId: record.serverId,
|
||||
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: key.x, y: key.y },
|
||||
};
|
||||
};
|
||||
|
||||
type ResolvedRelayInput = {
|
||||
relay: MobileRelayConfig;
|
||||
token?: string;
|
||||
label?: string;
|
||||
grant?: string;
|
||||
};
|
||||
|
||||
// Accepts relay input either as an explicit descriptor (saved connections) or as
|
||||
// a raw pairing link typed/pasted/scanned into the URL field.
|
||||
const resolveRelayInput = (input: MobileConnectInput): ResolvedRelayInput | null => {
|
||||
if (input.relay) {
|
||||
return {
|
||||
relay: input.relay,
|
||||
token: input.clientToken?.trim() || undefined,
|
||||
label: input.label?.trim() || undefined,
|
||||
grant: input.relayGrant,
|
||||
};
|
||||
}
|
||||
const trimmed = input.url.trim();
|
||||
if (!/^openchamber:\/\//i.test(trimmed)) return null;
|
||||
const offer = parseRelayOfferUrl(trimmed);
|
||||
if (!offer) return null;
|
||||
return {
|
||||
relay: { relayUrl: offer.relayUrl, serverId: offer.serverId, hostEncPubJwk: offer.hostEncPubJwk },
|
||||
token: input.clientToken?.trim() || offer.token,
|
||||
label: input.label?.trim() || offer.label,
|
||||
grant: offer.grant,
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request helpers (native CapacitorHttp first — needed to reach plain-http LAN
|
||||
// servers the secure webview cannot fetch — then a browser-fetch fallback).
|
||||
@@ -194,7 +293,7 @@ const requestWithTimeout = async (url: string, init?: RequestInit): Promise<Mobi
|
||||
);
|
||||
};
|
||||
|
||||
const readSessionStatus = async (response: MobileFetchResponse | null): Promise<MobileSessionStatus | null> => {
|
||||
const readSessionStatus = async (response: { json: () => Promise<unknown> } | null): Promise<MobileSessionStatus | null> => {
|
||||
if (!response) return null;
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
@@ -206,6 +305,68 @@ const readSessionStatus = async (response: MobileFetchResponse | null): Promise<
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relay connect helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RELAY_CONNECT_TIMEOUT_MS = 15_000;
|
||||
|
||||
type RelayProbeOutcome = 'ok' | 'needs-login' | 'auth-failed' | 'unreachable';
|
||||
|
||||
// Probe /health + /auth/session through a short-lived tunnel — the relay
|
||||
// counterpart of the direct flow's pre-switch reachability/auth probe. The
|
||||
// throwaway client is always closed; the long-lived runtime tunnel is created
|
||||
// by switchRuntimeEndpoint afterwards. Cookies never ride the tunnel, so the
|
||||
// cookie-only-session special case from the direct flow does not apply here.
|
||||
const probeRelaySession = async (
|
||||
relay: MobileRelayConfig,
|
||||
token?: string,
|
||||
grant?: string,
|
||||
): Promise<RelayProbeOutcome> => {
|
||||
const tunnel = createRelayTunnelClient({
|
||||
relayUrl: relay.relayUrl,
|
||||
serverId: relay.serverId,
|
||||
hostEncPubJwk: relay.hostEncPubJwk,
|
||||
...(grant ? { grant } : {}),
|
||||
});
|
||||
try {
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
|
||||
const health = await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, tunnel.fetch('/health', { headers }).catch(() => null));
|
||||
logConnect('relay:health', { ok: health?.ok === true, status: health?.status ?? null });
|
||||
if (!health?.ok) return 'unreachable';
|
||||
const session = await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, tunnel.fetch('/auth/session', { headers }).catch(() => null));
|
||||
logConnect('relay:session', { ok: session?.ok === true, status: session?.status ?? null, hasToken: Boolean(token) });
|
||||
if (!session) return 'unreachable';
|
||||
if (session.status === 401) return token ? 'auth-failed' : 'needs-login';
|
||||
if (!session.ok && session.status !== 404) return 'auth-failed';
|
||||
const status = await readSessionStatus(session);
|
||||
if (status && status.disabled !== true && status.authenticated === false) {
|
||||
return token ? 'auth-failed' : 'needs-login';
|
||||
}
|
||||
return 'ok';
|
||||
} finally {
|
||||
tunnel.close();
|
||||
}
|
||||
};
|
||||
|
||||
const switchToRelayRuntime = (relay: MobileRelayConfig, clientToken: string | null, grant?: string): void => {
|
||||
// Relay mode has no network base URL: runtimeFetch intercepts runtime paths on
|
||||
// the current window origin and rides the E2EE tunnel, so the window origin is
|
||||
// the correct virtual API base. The runtime key carries the real identity.
|
||||
const apiBaseUrl = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
switchRuntimeEndpoint({
|
||||
apiBaseUrl,
|
||||
clientToken,
|
||||
runtimeKey: relayConnectionRuntimeKey(relay),
|
||||
relay: {
|
||||
relayUrl: relay.relayUrl,
|
||||
serverId: relay.serverId,
|
||||
hostEncPubJwk: relay.hostEncPubJwk,
|
||||
...(grant ? { grant } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata storage (localStorage) — never holds the token on native.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -225,12 +386,19 @@ const readConnections = (): MobileSavedConnection[] => {
|
||||
if (!item || typeof item !== 'object') return [];
|
||||
const c = item as Partial<MobileSavedConnection>;
|
||||
if (typeof c.id !== 'string' || typeof c.url !== 'string') return [];
|
||||
// Explicit normalization: entries persisted before relay support carry no
|
||||
// `mode` and default to 'direct'. A relay entry with malformed transport
|
||||
// config is unusable — drop it rather than misrepresent it as direct.
|
||||
const relay = c.mode === 'relay' ? parseRelayConfig(c.relay) : null;
|
||||
if (c.mode === 'relay' && !relay) return [];
|
||||
const inlineToken = typeof c.clientToken === 'string' && c.clientToken.trim() ? c.clientToken : undefined;
|
||||
const base: MobileSavedConnection = {
|
||||
id: c.id,
|
||||
label: typeof c.label === 'string' && c.label.trim() ? c.label : getConnectionLabel(c.url),
|
||||
url: c.url,
|
||||
lastUsedAt: typeof c.lastUsedAt === 'number' ? c.lastUsedAt : 0,
|
||||
mode: relay ? 'relay' : 'direct',
|
||||
...(relay ? { relay } : {}),
|
||||
};
|
||||
if (native) return [{ ...base, hasToken: Boolean(c.hasToken) || Boolean(inlineToken) }];
|
||||
return [{ ...base, clientToken: inlineToken, hasToken: Boolean(inlineToken) }];
|
||||
@@ -241,11 +409,20 @@ const readConnections = (): MobileSavedConnection[] => {
|
||||
const writeConnections = (connections: MobileSavedConnection[]): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const native = isCapacitorApp();
|
||||
const serialized = connections.slice(0, MOBILE_CONNECTIONS_LIMIT).map((c) => (
|
||||
native
|
||||
? { id: c.id, label: c.label, url: c.url, lastUsedAt: c.lastUsedAt, hasToken: Boolean(c.hasToken || c.clientToken) }
|
||||
: { id: c.id, label: c.label, url: c.url, lastUsedAt: c.lastUsedAt, clientToken: c.clientToken }
|
||||
));
|
||||
const serialized = connections.slice(0, MOBILE_CONNECTIONS_LIMIT).map((c) => {
|
||||
// Persist only the three relay transport fields — grant/token never land here.
|
||||
const shared = {
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
url: c.url,
|
||||
lastUsedAt: c.lastUsedAt,
|
||||
mode: c.mode,
|
||||
...(c.relay ? { relay: { relayUrl: c.relay.relayUrl, serverId: c.relay.serverId, hostEncPubJwk: c.relay.hostEncPubJwk } } : {}),
|
||||
};
|
||||
return native
|
||||
? { ...shared, hasToken: Boolean(c.hasToken || c.clientToken) }
|
||||
: { ...shared, clientToken: c.clientToken };
|
||||
});
|
||||
try {
|
||||
window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(serialized));
|
||||
} catch (error) {
|
||||
@@ -255,23 +432,25 @@ const writeConnections = (connections: MobileSavedConnection[]): void => {
|
||||
|
||||
const upsertConnectionInList = (
|
||||
connections: MobileSavedConnection[],
|
||||
draft: { label: string; url: string; clientToken?: string; hasToken?: boolean },
|
||||
draft: { label: string; url: string; clientToken?: string; hasToken?: boolean; relay?: MobileRelayConfig },
|
||||
): MobileSavedConnection[] => {
|
||||
const key = getConnectionStorageKey(draft.url);
|
||||
const existing = connections.find((item) => getConnectionStorageKey(item.url) === key);
|
||||
const key = connectionKeyOf(draft);
|
||||
const existing = connections.find((item) => connectionKeyOf(item) === key);
|
||||
const native = isCapacitorApp();
|
||||
const next: MobileSavedConnection = {
|
||||
id: existing?.id || crypto.randomUUID(),
|
||||
label: draft.label,
|
||||
url: draft.url,
|
||||
lastUsedAt: Date.now(),
|
||||
mode: draft.relay ? 'relay' : 'direct',
|
||||
...(draft.relay ? { relay: draft.relay } : {}),
|
||||
...(native
|
||||
? { hasToken: draft.hasToken ?? (Boolean(draft.clientToken) || existing?.hasToken || false) }
|
||||
: { clientToken: draft.clientToken ?? existing?.clientToken, hasToken: Boolean(draft.clientToken ?? existing?.clientToken) }),
|
||||
};
|
||||
return [
|
||||
next,
|
||||
...connections.filter((item) => item.id !== next.id && getConnectionStorageKey(item.url) !== key),
|
||||
...connections.filter((item) => item.id !== next.id && connectionKeyOf(item) !== key),
|
||||
].slice(0, MOBILE_CONNECTIONS_LIMIT);
|
||||
};
|
||||
|
||||
@@ -295,8 +474,11 @@ type NativeSecureStorage = {
|
||||
const nativeSecure = SecureStorage as unknown as NativeSecureStorage;
|
||||
const KEYCHAIN_ACCESS_WHEN_UNLOCKED = 0; // KeychainAccess.whenUnlocked
|
||||
|
||||
const prefixedTokenKey = (url: string): string =>
|
||||
`${MOBILE_SECURE_STORAGE_PREFIX}token.${encodeURIComponent(getConnectionStorageKey(url))}`;
|
||||
// `key` is a connection storage key (connectionKeyOf): the normalized URL for
|
||||
// direct connections (unchanged historical format, existing tokens stay valid)
|
||||
// or the relay identity key for relay connections.
|
||||
const prefixedTokenKey = (key: string): string =>
|
||||
`${MOBILE_SECURE_STORAGE_PREFIX}token.${encodeURIComponent(key)}`;
|
||||
|
||||
const withTimeout = async <T,>(operation: Promise<T>, fallback: T): Promise<T> => {
|
||||
let timeoutId: number | undefined;
|
||||
@@ -322,36 +504,36 @@ const boundedSecure = async <T,>(label: string, run: () => Promise<T>, fallback:
|
||||
);
|
||||
};
|
||||
|
||||
const readSecureToken = async (url: string): Promise<string | undefined> => {
|
||||
logStorage('secure:read-start', { url });
|
||||
const readSecureToken = async (key: string): Promise<string | undefined> => {
|
||||
logStorage('secure:read-start', { key });
|
||||
const value = await boundedSecure(
|
||||
'secure:read',
|
||||
async () => (await nativeSecure.internalGetItem({ prefixedKey: prefixedTokenKey(url), sync: false })).data,
|
||||
async () => (await nativeSecure.internalGetItem({ prefixedKey: prefixedTokenKey(key), sync: false })).data,
|
||||
null,
|
||||
);
|
||||
const token = typeof value === 'string' && value.trim() ? value : undefined;
|
||||
logStorage('secure:read', { url, hasToken: Boolean(token) });
|
||||
logStorage('secure:read', { key, hasToken: Boolean(token) });
|
||||
return token;
|
||||
};
|
||||
|
||||
const writeSecureToken = async (url: string, token: string): Promise<boolean> => {
|
||||
logStorage('secure:write-start', { url });
|
||||
const writeSecureToken = async (key: string, token: string): Promise<boolean> => {
|
||||
logStorage('secure:write-start', { key });
|
||||
const ok = await boundedSecure('secure:write', async () => {
|
||||
await nativeSecure.internalSetItem({
|
||||
prefixedKey: prefixedTokenKey(url),
|
||||
prefixedKey: prefixedTokenKey(key),
|
||||
data: token,
|
||||
sync: false,
|
||||
access: KEYCHAIN_ACCESS_WHEN_UNLOCKED,
|
||||
});
|
||||
return true;
|
||||
}, false);
|
||||
logStorage('secure:write', { url, ok });
|
||||
logStorage('secure:write', { key, ok });
|
||||
return ok;
|
||||
};
|
||||
|
||||
const deleteSecureToken = async (url: string): Promise<void> => {
|
||||
const deleteSecureToken = async (key: string): Promise<void> => {
|
||||
await boundedSecure('secure:delete', async () => {
|
||||
await nativeSecure.internalRemoveItem({ prefixedKey: prefixedTokenKey(url), sync: false });
|
||||
await nativeSecure.internalRemoveItem({ prefixedKey: prefixedTokenKey(key), sync: false });
|
||||
return true;
|
||||
}, false);
|
||||
};
|
||||
@@ -379,7 +561,7 @@ const migrateLegacyInlineTokens = async (): Promise<void> => {
|
||||
if (legacy.length === 0) return;
|
||||
logStorage('secure:migrate-start', { count: legacy.length });
|
||||
for (const { url, clientToken } of legacy) {
|
||||
await writeSecureToken(url, clientToken);
|
||||
await writeSecureToken(getConnectionStorageKey(url), clientToken);
|
||||
}
|
||||
writeConnections(readConnections());
|
||||
logStorage('secure:migrate-done', { count: legacy.length });
|
||||
@@ -391,12 +573,12 @@ export const loadMobileConnections = async (): Promise<MobileSavedConnection[]>
|
||||
};
|
||||
|
||||
export const upsertMobileConnection = async (
|
||||
connection: { label: string; url: string; clientToken?: string },
|
||||
connection: { label: string; url: string; clientToken?: string; relay?: MobileRelayConfig },
|
||||
): Promise<MobileSavedConnection[]> => {
|
||||
const next = upsertConnectionInList(readConnections(), connection);
|
||||
writeConnections(next);
|
||||
if (isCapacitorApp() && connection.clientToken) {
|
||||
await writeSecureToken(connection.url, connection.clientToken);
|
||||
await writeSecureToken(connectionKeyOf(connection), connection.clientToken);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
@@ -406,7 +588,7 @@ export const deleteMobileConnection = async (id: string): Promise<MobileSavedCon
|
||||
const removed = connections.find((connection) => connection.id === id) ?? null;
|
||||
const next = connections.filter((connection) => connection.id !== id);
|
||||
writeConnections(next);
|
||||
if (removed && isCapacitorApp()) await deleteSecureToken(removed.url);
|
||||
if (removed && isCapacitorApp()) await deleteSecureToken(connectionKeyOf(removed));
|
||||
return next;
|
||||
};
|
||||
|
||||
@@ -422,6 +604,25 @@ export const autoConnectLastInstance = async (): Promise<boolean> => {
|
||||
const candidate = readConnections()[0]; // sorted most-recent-first
|
||||
if (!candidate) return false;
|
||||
|
||||
// Relay connections auto-connect through the tunnel: no URL to probe, the
|
||||
// health/session check rides a throwaway tunnel client instead.
|
||||
if (candidate.mode === 'relay' && candidate.relay) {
|
||||
let relayToken: string | undefined;
|
||||
if (isCapacitorApp()) {
|
||||
if (!candidate.hasToken) return false;
|
||||
relayToken = await readSecureToken(connectionKeyOf(candidate));
|
||||
if (!relayToken) return false;
|
||||
} else {
|
||||
relayToken = candidate.clientToken;
|
||||
if (!relayToken) return false;
|
||||
}
|
||||
const outcome = await probeRelaySession(candidate.relay, relayToken);
|
||||
if (outcome !== 'ok') return false;
|
||||
await upsertMobileConnection({ label: candidate.label, url: candidate.url, relay: candidate.relay }); // bump lastUsedAt
|
||||
switchToRelayRuntime(candidate.relay, relayToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
const url = normalizeConnectionUrl(candidate.url);
|
||||
if (!url) return false;
|
||||
|
||||
@@ -430,7 +631,7 @@ export const autoConnectLastInstance = async (): Promise<boolean> => {
|
||||
let token: string | undefined;
|
||||
if (isCapacitorApp()) {
|
||||
if (!candidate.hasToken) return false;
|
||||
token = await readSecureToken(url);
|
||||
token = await readSecureToken(getConnectionStorageKey(url));
|
||||
if (!token) return false;
|
||||
} else {
|
||||
token = candidate.clientToken;
|
||||
@@ -477,6 +678,27 @@ export const validateMobileConnectionSession = async (input: {
|
||||
return !(status && status.disabled !== true && status.authenticated === false);
|
||||
};
|
||||
|
||||
// Relay-aware session validation for the ACTIVE runtime (native resume path).
|
||||
// In relay mode there is no reachable URL to probe — validate through the live
|
||||
// tunnel via runtimeFetch. A transport failure/timeout is transient (the tunnel
|
||||
// reconnects on its own) and must not masquerade as a revoked session, so only
|
||||
// an explicit auth rejection reports invalid.
|
||||
export const validateActiveRuntimeSession = async (input: {
|
||||
url: string;
|
||||
clientToken?: string | null;
|
||||
}): Promise<boolean> => {
|
||||
if (!isRelayModeActive()) return validateMobileConnectionSession(input);
|
||||
const session = await raceWithTimeout(
|
||||
RELAY_CONNECT_TIMEOUT_MS,
|
||||
runtimeFetch('/auth/session').then((response): Response | null => response).catch(() => null),
|
||||
);
|
||||
if (!session) return true;
|
||||
if (session.status === 401) return false;
|
||||
if (!session.ok && session.status !== 404) return true;
|
||||
const status = await readSessionStatus(session);
|
||||
return !(status && status.disabled !== true && status.authenticated === false);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared connection controller
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -532,7 +754,7 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
}, [applyConnections]);
|
||||
|
||||
// Persist metadata for a connection and reflect it in state immediately.
|
||||
const persistMetadata = React.useCallback((draft: { label: string; url: string; clientToken?: string }) => {
|
||||
const persistMetadata = React.useCallback((draft: { label: string; url: string; clientToken?: string; relay?: MobileRelayConfig }) => {
|
||||
const next = upsertConnectionInList(connectionsRef.current, draft);
|
||||
applyConnections(next);
|
||||
writeConnections(next);
|
||||
@@ -543,6 +765,49 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
setError(null);
|
||||
beginBusy('connect');
|
||||
try {
|
||||
// Relay connections (saved entries or pasted/scanned pairing offers) ride
|
||||
// the E2EE tunnel; there is no URL to reach, so the probe + login flow
|
||||
// runs through a throwaway tunnel client instead of network requests.
|
||||
const relayInput = resolveRelayInput(input);
|
||||
if (relayInput) {
|
||||
const { relay, grant } = relayInput;
|
||||
const key = relayConnectionRuntimeKey(relay);
|
||||
const saved = connectionsRef.current.find((c) => c.relay && relayConnectionRuntimeKey(c.relay) === key);
|
||||
const label = relayInput.label || saved?.label || getConnectionLabel(relay.relayUrl);
|
||||
let token = relayInput.token;
|
||||
const tokenIsNew = Boolean(token);
|
||||
if (!token) {
|
||||
if (isCapacitorApp()) {
|
||||
if (saved?.hasToken) token = await readSecureToken(key);
|
||||
} else {
|
||||
token = saved?.clientToken;
|
||||
}
|
||||
}
|
||||
logConnect('relay:connect:start', { serverId: relay.serverId, hasToken: Boolean(token) });
|
||||
const outcome = await probeRelaySession(relay, token, grant);
|
||||
const url = canonicalRelayUrl(relay);
|
||||
if (outcome === 'unreachable') {
|
||||
setError(t('mobile.connect.error.unreachable'));
|
||||
return;
|
||||
}
|
||||
if (outcome === 'needs-login') {
|
||||
persistMetadata({ label, url, relay });
|
||||
setPendingConnection({ label, url, relay, relayGrant: grant });
|
||||
return;
|
||||
}
|
||||
if (outcome === 'auth-failed') {
|
||||
setError(t('mobile.connect.error.authRequired'));
|
||||
return;
|
||||
}
|
||||
if (token && tokenIsNew && isCapacitorApp()) {
|
||||
await writeSecureToken(key, token);
|
||||
}
|
||||
persistMetadata({ label, url, relay, clientToken: token });
|
||||
switchToRelayRuntime(relay, token ?? null, grant);
|
||||
onConnected();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = normalizeConnectionUrl(input.url);
|
||||
if (!url) {
|
||||
setError(t('mobile.connect.error.urlRequired'));
|
||||
@@ -558,8 +823,8 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
let token = input.clientToken?.trim() || undefined;
|
||||
const tokenIsNew = Boolean(token);
|
||||
if (!token && isCapacitorApp()) {
|
||||
const saved = connectionsRef.current.find((c) => isSameConnectionUrl(c.url, url));
|
||||
if (saved?.hasToken) token = await readSecureToken(url);
|
||||
const saved = connectionsRef.current.find((c) => c.mode !== 'relay' && isSameConnectionUrl(c.url, url));
|
||||
if (saved?.hasToken) token = await readSecureToken(getConnectionStorageKey(url));
|
||||
}
|
||||
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
|
||||
@@ -601,7 +866,7 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
// Connected. If the token came from the user (not the secure store), persist
|
||||
// it first so a cold restart won't re-prompt.
|
||||
if (token && tokenIsNew && isCapacitorApp()) {
|
||||
await writeSecureToken(url, token);
|
||||
await writeSecureToken(getConnectionStorageKey(url), token);
|
||||
}
|
||||
persistMetadata({ label, url, clientToken: token });
|
||||
switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: token ?? null });
|
||||
@@ -620,6 +885,50 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
beginBusy('password');
|
||||
const { url, label } = pendingConnection;
|
||||
try {
|
||||
// Relay login rides the tunnel: POST /auth/session through a throwaway
|
||||
// tunnel client. Cookies never cross the tunnel, so an issued bearer
|
||||
// token is mandatory on every platform (not just native).
|
||||
if (pendingConnection.relay) {
|
||||
const relay = pendingConnection.relay;
|
||||
const grant = pendingConnection.relayGrant;
|
||||
const tunnel = createRelayTunnelClient({
|
||||
relayUrl: relay.relayUrl,
|
||||
serverId: relay.serverId,
|
||||
hostEncPubJwk: relay.hostEncPubJwk,
|
||||
...(grant ? { grant } : {}),
|
||||
});
|
||||
try {
|
||||
logConnect('relay:password:start', { serverId: relay.serverId });
|
||||
const response = await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, tunnel.fetch('/auth/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ password, trustDevice: true, issueClientToken: true, clientLabel: 'OpenChamber Mobile' }),
|
||||
}).catch(() => null));
|
||||
logConnect('relay:password:done', { ok: response?.ok === true, status: response?.status ?? null });
|
||||
if (!response?.ok) {
|
||||
setError(t('mobile.connect.error.passwordFailed'));
|
||||
return;
|
||||
}
|
||||
const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null;
|
||||
const issuedToken = typeof payload?.clientToken === 'string' ? payload.clientToken.trim() : '';
|
||||
logConnect('relay:password:token', { issued: Boolean(issuedToken) });
|
||||
if (!issuedToken) {
|
||||
setError(t('mobile.connect.error.authRequired'));
|
||||
return;
|
||||
}
|
||||
if (isCapacitorApp()) {
|
||||
await writeSecureToken(relayConnectionRuntimeKey(relay), issuedToken);
|
||||
}
|
||||
persistMetadata({ label, url: canonicalRelayUrl(relay), relay, clientToken: issuedToken });
|
||||
setPendingConnection(null);
|
||||
switchToRelayRuntime(relay, issuedToken, grant);
|
||||
onConnected();
|
||||
} finally {
|
||||
tunnel.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logConnect('password:start', { url });
|
||||
const response = await requestWithTimeout(`${url}/auth/session`, {
|
||||
method: 'POST',
|
||||
@@ -646,7 +955,7 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
|
||||
// Guarantee the token is persisted BEFORE switching (no fire-and-forget).
|
||||
if (isCapacitorApp() && issuedToken) {
|
||||
await writeSecureToken(url, issuedToken);
|
||||
await writeSecureToken(getConnectionStorageKey(url), issuedToken);
|
||||
}
|
||||
persistMetadata({ label, url, clientToken: issuedToken || undefined });
|
||||
setPendingConnection(null);
|
||||
@@ -667,6 +976,21 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
|
||||
const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise<MobileSavedConnection | null> => {
|
||||
setError(null);
|
||||
// Relay pairing links save as relay-mode entries (token → secure storage,
|
||||
// metadata holds only the transport descriptor).
|
||||
const relayInput = resolveRelayInput(input);
|
||||
if (relayInput) {
|
||||
const { relay } = relayInput;
|
||||
const key = relayConnectionRuntimeKey(relay);
|
||||
const label = relayInput.label || getConnectionLabel(relay.relayUrl);
|
||||
// Awaited token write so "Save" truly persisted the secret before returning.
|
||||
if (isCapacitorApp() && relayInput.token) {
|
||||
await writeSecureToken(key, relayInput.token);
|
||||
}
|
||||
const next = persistMetadata({ label, url: canonicalRelayUrl(relay), relay, clientToken: relayInput.token });
|
||||
return next.find((connection) => connection.relay && relayConnectionRuntimeKey(connection.relay) === key) ?? null;
|
||||
}
|
||||
|
||||
const url = normalizeConnectionUrl(input.url);
|
||||
if (!url) {
|
||||
setError(t('mobile.connect.error.urlRequired'));
|
||||
@@ -676,10 +1000,10 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
const label = input.label?.trim() || getConnectionLabel(url);
|
||||
// Awaited token write so "Save" truly persisted the secret before returning.
|
||||
if (isCapacitorApp() && clientToken) {
|
||||
await writeSecureToken(url, clientToken);
|
||||
await writeSecureToken(getConnectionStorageKey(url), clientToken);
|
||||
}
|
||||
const next = persistMetadata({ label, url, clientToken });
|
||||
return next.find((connection) => isSameConnectionUrl(connection.url, url)) ?? null;
|
||||
return next.find((connection) => connection.mode !== 'relay' && isSameConnectionUrl(connection.url, url)) ?? null;
|
||||
}, [persistMetadata, t]);
|
||||
|
||||
const removeConnection = React.useCallback(async (id: string): Promise<MobileSavedConnection | null> => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { buildRelayOfferUrl } from '@/lib/relay/offer';
|
||||
import type { RelayOfferV1 } from '@/lib/relay/protocol';
|
||||
|
||||
import { parseConnectionPayload } from './mobileQrScan';
|
||||
|
||||
const baseOffer: RelayOfferV1 = {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
relayUrl: 'wss://relay.example/tunnel',
|
||||
serverId: 'srv_test123',
|
||||
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' },
|
||||
};
|
||||
|
||||
describe('parseConnectionPayload', () => {
|
||||
test('parses direct pairing links unchanged', () => {
|
||||
const payload = parseConnectionPayload('openchamber://connect?v=1&server=http%3A%2F%2F192.168.1.10%3A2606&token=tok&label=Home');
|
||||
expect(payload).toEqual({ url: 'http://192.168.1.10:2606', clientToken: 'tok', label: 'Home' });
|
||||
});
|
||||
|
||||
test('parses bare http(s) URLs unchanged', () => {
|
||||
expect(parseConnectionPayload('https://oc.example')).toEqual({ url: 'https://oc.example' });
|
||||
expect(parseConnectionPayload(' http://192.168.1.10:2606 ')).toEqual({ url: 'http://192.168.1.10:2606' });
|
||||
});
|
||||
|
||||
test('rejects non-connection payloads', () => {
|
||||
expect(parseConnectionPayload('')).toBeNull();
|
||||
expect(parseConnectionPayload('hello world')).toBeNull();
|
||||
expect(parseConnectionPayload('openchamber://connect')).toBeNull();
|
||||
expect(parseConnectionPayload('openchamber://session/abc')).toBeNull();
|
||||
});
|
||||
|
||||
test('recognizes relay offers with embedded token and grant', () => {
|
||||
const url = buildRelayOfferUrl({ ...baseOffer, label: 'My Desktop', token: 'oc_client_secret', grant: 'grant123' });
|
||||
const payload = parseConnectionPayload(url);
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload?.url).toBe(url);
|
||||
expect(payload?.label).toBe('My Desktop');
|
||||
expect(payload?.clientToken).toBe('oc_client_secret');
|
||||
expect(payload?.relay).toEqual({
|
||||
relayUrl: baseOffer.relayUrl,
|
||||
serverId: baseOffer.serverId,
|
||||
hostEncPubJwk: baseOffer.hostEncPubJwk,
|
||||
});
|
||||
expect(payload?.relayGrant).toBe('grant123');
|
||||
});
|
||||
|
||||
test('recognizes token-less relay offers (login-on-first-connect)', () => {
|
||||
const url = buildRelayOfferUrl(baseOffer);
|
||||
const payload = parseConnectionPayload(url);
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload?.clientToken).toBe(undefined);
|
||||
expect(payload?.relayGrant).toBe(undefined);
|
||||
expect(payload?.relay?.serverId).toBe(baseOffer.serverId);
|
||||
});
|
||||
|
||||
test('malformed relay offers fall through to direct parsing rules', () => {
|
||||
// mode=relay but no fragment payload → not a valid offer, and no `server`
|
||||
// param either → rejected entirely, exactly like before relay support.
|
||||
expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay')).toBeNull();
|
||||
// Direct link that also carries an unrelated mode param keeps direct parsing.
|
||||
const direct = parseConnectionPayload('openchamber://connect?v=1&mode=relay&server=http%3A%2F%2Fhost.example');
|
||||
expect(direct).toEqual({ url: 'http://host.example' });
|
||||
});
|
||||
});
|
||||
@@ -9,10 +9,19 @@
|
||||
// at runtime instead of importing the package so the web build stays dependency-free
|
||||
// and the browser-hosted mobile UI degrades to `unsupported` cleanly.
|
||||
|
||||
import { parseRelayOfferUrl } from '@/lib/relay/offer';
|
||||
|
||||
import type { MobileRelayConfig } from './mobileConnections';
|
||||
|
||||
export type MobileConnectionPayload = {
|
||||
url: string;
|
||||
clientToken?: string;
|
||||
label?: string;
|
||||
// Present when the payload is a relay pairing offer (openchamber://connect?v=1&mode=relay#offer=...).
|
||||
// `url` then holds the raw offer link so form fields and connect() can round-trip it.
|
||||
relay?: MobileRelayConfig;
|
||||
// One-time relay authorization grant from the offer. Never persisted.
|
||||
relayGrant?: string;
|
||||
};
|
||||
|
||||
export type QrScanResult =
|
||||
@@ -108,6 +117,23 @@ export const parseConnectionPayload = (raw: string): MobileConnectionPayload | n
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^openchamber:\/\//i.test(trimmed)) {
|
||||
// Relay pairing offers are a strict superset format (mode=relay + fragment
|
||||
// payload); try them first. Direct pairing links (?server=...) never match
|
||||
// the relay parser, so existing payloads are untouched.
|
||||
const offer = parseRelayOfferUrl(trimmed);
|
||||
if (offer) {
|
||||
return {
|
||||
url: trimmed,
|
||||
clientToken: offer.token,
|
||||
label: offer.label,
|
||||
relay: {
|
||||
relayUrl: offer.relayUrl,
|
||||
serverId: offer.serverId,
|
||||
hostEncPubJwk: offer.hostEncPubJwk,
|
||||
},
|
||||
relayGrant: offer.grant,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const server = parsed.searchParams.get('server')?.trim();
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import React from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n, type I18nKey } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
// OpenChamber-owned relay routes (registered before the generic OpenCode proxy).
|
||||
const RELAY_STATUS_ROUTE = '/api/openchamber/relay/status';
|
||||
const RELAY_ENABLE_ROUTE = '/api/openchamber/relay/enable';
|
||||
const RELAY_DISABLE_ROUTE = '/api/openchamber/relay/disable';
|
||||
const RELAY_OFFER_ROUTE = '/api/openchamber/relay/offer';
|
||||
|
||||
const STATUS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
type RelayState = 'disabled' | 'connecting' | 'connected' | 'reconnecting' | 'error';
|
||||
|
||||
interface RelayStatus {
|
||||
enabled: boolean;
|
||||
state: RelayState;
|
||||
serverId: string;
|
||||
connectedClients: number;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
const RELAY_STATES = new Set<string>(['disabled', 'connecting', 'connected', 'reconnecting', 'error']);
|
||||
|
||||
// Authoritative fetch: returns null strictly on fetch/shape failure so callers
|
||||
// keep the previous status instead of treating a blip as "relay disabled".
|
||||
const fetchRelayStatus = async (signal?: AbortSignal): Promise<RelayStatus | null> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await runtimeFetch(RELAY_STATUS_ROUTE, { method: 'GET', signal });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) return null;
|
||||
const body = (await response.json().catch(() => null)) as Partial<RelayStatus> | null;
|
||||
if (!body || typeof body.enabled !== 'boolean' || typeof body.state !== 'string' || !RELAY_STATES.has(body.state)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
enabled: body.enabled,
|
||||
state: body.state as RelayState,
|
||||
serverId: typeof body.serverId === 'string' ? body.serverId : '',
|
||||
connectedClients: typeof body.connectedClients === 'number' ? body.connectedClients : 0,
|
||||
...(typeof body.lastError === 'string' && body.lastError ? { lastError: body.lastError } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const stateLabelKey = (state: RelayState): I18nKey => {
|
||||
switch (state) {
|
||||
case 'connecting':
|
||||
return 'settings.remoteInstances.relay.state.connecting';
|
||||
case 'connected':
|
||||
return 'settings.remoteInstances.relay.state.connected';
|
||||
case 'reconnecting':
|
||||
return 'settings.remoteInstances.relay.state.reconnecting';
|
||||
case 'error':
|
||||
return 'settings.remoteInstances.relay.state.error';
|
||||
default:
|
||||
return 'settings.remoteInstances.relay.state.disabled';
|
||||
}
|
||||
};
|
||||
|
||||
const stateDotClass = (state: RelayState): string => {
|
||||
if (state === 'connected') {
|
||||
return 'bg-[var(--status-success)] animate-pulse';
|
||||
}
|
||||
if (state === 'error') {
|
||||
return 'bg-[var(--status-error)] animate-pulse';
|
||||
}
|
||||
if (state === 'connecting' || state === 'reconnecting') {
|
||||
return 'bg-[var(--status-warning)] animate-pulse';
|
||||
}
|
||||
return 'bg-muted-foreground/40';
|
||||
};
|
||||
|
||||
export const RelaySection: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = React.useState<RelayStatus | null>(null);
|
||||
const [statusLoaded, setStatusLoaded] = React.useState(false);
|
||||
const [isToggling, setIsToggling] = React.useState(false);
|
||||
const [pairLabel, setPairLabel] = React.useState('');
|
||||
const [includeToken, setIncludeToken] = React.useState(true);
|
||||
const [isPairing, setIsPairing] = React.useState(false);
|
||||
const [offerUrl, setOfferUrl] = React.useState<string | null>(null);
|
||||
const [offerQrDataUrl, setOfferQrDataUrl] = React.useState<string | null>(null);
|
||||
const [qrDialogOpen, setQrDialogOpen] = React.useState(false);
|
||||
|
||||
const refreshStatus = React.useCallback(async (signal?: AbortSignal) => {
|
||||
const next = await fetchRelayStatus(signal);
|
||||
if (signal?.aborted) return;
|
||||
setStatusLoaded(true);
|
||||
// Preserve the last known status on fetch failure; never downgrade to
|
||||
// "disabled" because of a transient network error.
|
||||
if (next) setStatus(next);
|
||||
}, []);
|
||||
|
||||
// Poll only while this section is mounted (page visible) and the document
|
||||
// is visible — no global polling.
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void refreshStatus(controller.signal);
|
||||
const interval = window.setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
void refreshStatus(controller.signal);
|
||||
}, STATUS_POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [refreshStatus]);
|
||||
|
||||
const handleEnable = React.useCallback(async () => {
|
||||
setIsToggling(true);
|
||||
try {
|
||||
const response = await runtimeFetch(RELAY_ENABLE_ROUTE, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
toast.error(t('settings.remoteInstances.relay.toast.enableFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
setIsToggling(false);
|
||||
}
|
||||
}, [refreshStatus, t]);
|
||||
|
||||
const handleDisable = React.useCallback(async () => {
|
||||
const confirmed = window.confirm(t('settings.remoteInstances.relay.confirm.disable'));
|
||||
if (!confirmed) return;
|
||||
setIsToggling(true);
|
||||
try {
|
||||
const response = await runtimeFetch(RELAY_DISABLE_ROUTE, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
setOfferUrl(null);
|
||||
setOfferQrDataUrl(null);
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
toast.error(t('settings.remoteInstances.relay.toast.disableFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
setIsToggling(false);
|
||||
}
|
||||
}, [refreshStatus, t]);
|
||||
|
||||
const handleCreateOffer = React.useCallback(async () => {
|
||||
setIsPairing(true);
|
||||
try {
|
||||
const response = await runtimeFetch(RELAY_OFFER_ROUTE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
includeToken,
|
||||
...(pairLabel.trim() ? { clientLabel: pairLabel.trim() } : {}),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const result = (await response.json()) as { url?: unknown };
|
||||
if (typeof result.url !== 'string' || !result.url) {
|
||||
throw new Error('Malformed offer response');
|
||||
}
|
||||
setOfferUrl(result.url);
|
||||
// Relay offers are ~500 chars (encryption key JWK + token) — far denser than
|
||||
// direct-pairing QRs. Render at high resolution with low ECC; the fullscreen
|
||||
// dialog then displays it large enough for a phone camera to lock on. A small
|
||||
// inline QR of this density is unscannable (learned the hard way).
|
||||
setOfferQrDataUrl(
|
||||
await QRCode.toDataURL(result.url, { width: 1024, margin: 2, errorCorrectionLevel: 'L' }),
|
||||
);
|
||||
setPairLabel('');
|
||||
} catch (err) {
|
||||
toast.error(t('settings.remoteInstances.relay.toast.offerFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
setIsPairing(false);
|
||||
}
|
||||
}, [includeToken, pairLabel, t]);
|
||||
|
||||
const handleCopyOffer = React.useCallback(() => {
|
||||
if (!offerUrl) return;
|
||||
void copyTextToClipboard(offerUrl).then((result) => {
|
||||
if (result.ok) {
|
||||
toast.success(t('settings.remoteInstances.relay.toast.linkCopied'));
|
||||
}
|
||||
});
|
||||
}, [offerUrl, t]);
|
||||
|
||||
const enabled = status?.enabled === true;
|
||||
const state: RelayState = status?.state ?? 'disabled';
|
||||
const isConnected = state === 'connected';
|
||||
|
||||
return (
|
||||
<div data-settings-item="remote-instances.relay" className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.relay.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.relay.description')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
{!statusLoaded ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.relay.state.loading')}</p>
|
||||
) : !enabled ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.remoteInstances.relay.enableHint')}</p>
|
||||
<Button type="button" size="xs" className="!font-normal shrink-0" onClick={() => void handleEnable()} disabled={isToggling}>
|
||||
{t('settings.remoteInstances.relay.actions.enable')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${stateDotClass(state)}`} />
|
||||
<p className="typography-ui-label text-foreground truncate">{t(stateLabelKey(state))}</p>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground truncate">
|
||||
{(status?.connectedClients ?? 0) === 1
|
||||
? t('settings.remoteInstances.relay.status.clientsOne', { count: 1 })
|
||||
: t('settings.remoteInstances.relay.status.clientsMany', { count: status?.connectedClients ?? 0 })}
|
||||
</p>
|
||||
{state === 'error' && status?.lastError ? (
|
||||
<p className="typography-micro text-[var(--status-error)] break-all">{status.lastError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal shrink-0" onClick={() => void handleDisable()} disabled={isToggling}>
|
||||
{t('settings.remoteInstances.relay.actions.disable')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.relay.pair.title')}</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
className="h-8"
|
||||
value={pairLabel}
|
||||
onChange={(event) => setPairLabel(event.target.value)}
|
||||
placeholder={t('settings.remoteInstances.relay.pair.labelPlaceholder')}
|
||||
disabled={isPairing}
|
||||
/>
|
||||
<Button type="button" size="xs" className="!font-normal shrink-0" onClick={() => void handleCreateOffer()} disabled={isPairing || !isConnected}>
|
||||
{t('settings.remoteInstances.relay.pair.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
<label className="flex w-fit cursor-pointer items-center gap-2 py-0.5">
|
||||
<Switch checked={includeToken} onCheckedChange={(checked) => setIncludeToken(Boolean(checked))} disabled={isPairing} />
|
||||
<span className="typography-ui-label font-normal text-foreground">{t('settings.remoteInstances.relay.pair.includeToken')}</span>
|
||||
</label>
|
||||
{!includeToken ? (
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.remoteInstances.relay.pair.noTokenHint')}</p>
|
||||
) : null}
|
||||
{!isConnected ? (
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.remoteInstances.relay.pair.requiresConnected')}</p>
|
||||
) : null}
|
||||
{offerUrl ? (
|
||||
<div className="min-w-0 space-y-2 rounded-md border border-[var(--interactive-border)] p-2">
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.relay.pair.linkLabel')}</p>
|
||||
<code className="block select-all break-all typography-code text-foreground">{offerUrl}</code>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleCopyOffer}>
|
||||
<Icon name="file-copy" className="h-3.5 w-3.5" />
|
||||
{t('settings.common.actions.copyAll')}
|
||||
</Button>
|
||||
{offerQrDataUrl ? (
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setQrDialogOpen(true)}>
|
||||
<Icon name="scan-2" className="h-3.5 w-3.5" />
|
||||
{t('settings.remoteInstances.relay.pair.showQr')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="typography-meta text-[var(--status-warning)]">{t('settings.remoteInstances.relay.pair.warning')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.remoteInstances.relay.pair.manageHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
<Dialog open={qrDialogOpen} onOpenChange={setQrDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.remoteInstances.relay.pair.qrDialogTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.remoteInstances.relay.pair.qrDialogDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{offerQrDataUrl ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<img
|
||||
src={offerQrDataUrl}
|
||||
alt={t('settings.remoteInstances.relay.pair.qrAlt')}
|
||||
className="w-full max-w-xs rounded-md bg-white p-3"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import { RelaySection } from '@/components/sections/remote-instances/RelaySection';
|
||||
import { RELAY_UI_ENABLED } from '@/lib/relay/gate';
|
||||
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
@@ -1115,6 +1117,8 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{clientAuth && RELAY_UI_ENABLED ? <RelaySection /> : null}
|
||||
|
||||
{showInstanceManagement ? <div data-settings-item="remote-instances.direct-hosts" className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.direct.title')}</h3>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -287,6 +287,37 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': 'This device',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': 'Last used {date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': 'Never used',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.description': 'Let your other devices connect from anywhere without opening ports. Traffic is end-to-end encrypted — the relay cannot read it.',
|
||||
'settings.remoteInstances.relay.enableHint': 'Nothing is shared until you enable the relay on this server.',
|
||||
'settings.remoteInstances.relay.actions.enable': 'Enable Relay',
|
||||
'settings.remoteInstances.relay.actions.disable': 'Disable',
|
||||
'settings.remoteInstances.relay.confirm.disable': 'Disable the relay? Devices connected through it will be disconnected immediately.',
|
||||
'settings.remoteInstances.relay.state.loading': 'Checking relay status...',
|
||||
'settings.remoteInstances.relay.state.disabled': 'Disabled',
|
||||
'settings.remoteInstances.relay.state.connecting': 'Connecting',
|
||||
'settings.remoteInstances.relay.state.connected': 'Connected',
|
||||
'settings.remoteInstances.relay.state.reconnecting': 'Reconnecting',
|
||||
'settings.remoteInstances.relay.state.error': 'Error',
|
||||
'settings.remoteInstances.relay.status.clientsOne': '{count} device connected',
|
||||
'settings.remoteInstances.relay.status.clientsMany': '{count} devices connected',
|
||||
'settings.remoteInstances.relay.pair.title': 'Pair a device',
|
||||
'settings.remoteInstances.relay.pair.labelPlaceholder': 'Device name (optional)',
|
||||
'settings.remoteInstances.relay.pair.includeToken': 'Include access token (one-scan pairing)',
|
||||
'settings.remoteInstances.relay.pair.noTokenHint': 'Without a token, the device signs in with this server’s UI password after connecting.',
|
||||
'settings.remoteInstances.relay.pair.generate': 'Create pairing link',
|
||||
'settings.remoteInstances.relay.pair.requiresConnected': 'Pairing becomes available once the relay is connected.',
|
||||
'settings.remoteInstances.relay.pair.linkLabel': 'Pairing link',
|
||||
'settings.remoteInstances.relay.pair.warning': 'This link grants access to this server. Do not share it.',
|
||||
'settings.remoteInstances.relay.pair.qrAlt': 'Relay pairing QR code',
|
||||
'settings.remoteInstances.relay.pair.showQr': 'Show QR code',
|
||||
'settings.remoteInstances.relay.pair.qrDialogTitle': 'Scan to pair',
|
||||
'settings.remoteInstances.relay.pair.qrDialogDescription': 'Scan this QR code with the OpenChamber app on your other device.',
|
||||
'settings.remoteInstances.relay.pair.manageHint': 'Manage or revoke paired devices in the “Connect to this server” list above.',
|
||||
'settings.remoteInstances.relay.toast.enableFailed': 'Failed to enable relay',
|
||||
'settings.remoteInstances.relay.toast.disableFailed': 'Failed to disable relay',
|
||||
'settings.remoteInstances.relay.toast.offerFailed': 'Failed to create pairing link',
|
||||
'settings.remoteInstances.relay.toast.linkCopied': 'Pairing link copied',
|
||||
'settings.remoteInstances.sidebar.phase.ready': 'Ready',
|
||||
'settings.remoteInstances.sidebar.phase.error': 'Error',
|
||||
'settings.remoteInstances.sidebar.phase.reconnect': 'Reconnect',
|
||||
|
||||
@@ -58,6 +58,7 @@ export const dict = {
|
||||
'mobile.connect.scan.unsupported': 'QR scanning is only available in the installed mobile app.',
|
||||
'mobile.connect.saved.title': 'Saved connections',
|
||||
'mobile.connect.saved.empty': 'No saved connections yet.',
|
||||
'mobile.connect.relay.badge': 'via OpenChamber Relay',
|
||||
'mobile.connect.error.urlRequired': 'Enter a server URL.',
|
||||
'mobile.connect.error.invalidUrl': 'That server URL is not valid.',
|
||||
'mobile.connect.error.unreachable': 'Could not reach that OpenChamber server.',
|
||||
|
||||
@@ -254,6 +254,37 @@ export const settingsDict = {
|
||||
"settings.remoteInstances.clientAuth.state.thisDevice": "Este dispositivo",
|
||||
"settings.remoteInstances.clientAuth.lastUsed": "Último uso {date}",
|
||||
"settings.remoteInstances.clientAuth.neverUsed": "Nunca usado",
|
||||
"settings.remoteInstances.relay.title": "OpenChamber Relay",
|
||||
"settings.remoteInstances.relay.description": "Permite que tus otros dispositivos se conecten desde cualquier lugar sin abrir puertos. El tráfico está cifrado de extremo a extremo: el relay no puede leerlo.",
|
||||
"settings.remoteInstances.relay.enableHint": "No se comparte nada hasta que actives el relay en este servidor.",
|
||||
"settings.remoteInstances.relay.actions.enable": "Activar Relay",
|
||||
"settings.remoteInstances.relay.actions.disable": "Desactivar",
|
||||
"settings.remoteInstances.relay.confirm.disable": "¿Desactivar el relay? Los dispositivos conectados a través de él se desconectarán de inmediato.",
|
||||
"settings.remoteInstances.relay.state.loading": "Comprobando el estado del relay...",
|
||||
"settings.remoteInstances.relay.state.disabled": "Desactivado",
|
||||
"settings.remoteInstances.relay.state.connecting": "Conectando",
|
||||
"settings.remoteInstances.relay.state.connected": "Conectado",
|
||||
"settings.remoteInstances.relay.state.reconnecting": "Reconectando",
|
||||
"settings.remoteInstances.relay.state.error": "Error",
|
||||
"settings.remoteInstances.relay.status.clientsOne": "{count} dispositivo conectado",
|
||||
"settings.remoteInstances.relay.status.clientsMany": "{count} dispositivos conectados",
|
||||
"settings.remoteInstances.relay.pair.title": "Emparejar un dispositivo",
|
||||
"settings.remoteInstances.relay.pair.labelPlaceholder": "Nombre del dispositivo (opcional)",
|
||||
"settings.remoteInstances.relay.pair.includeToken": "Incluir token de acceso (emparejamiento con un solo escaneo)",
|
||||
"settings.remoteInstances.relay.pair.noTokenHint": "Sin token, el dispositivo inicia sesión con la contraseña de la interfaz de este servidor tras conectarse.",
|
||||
"settings.remoteInstances.relay.pair.generate": "Crear enlace de emparejamiento",
|
||||
"settings.remoteInstances.relay.pair.requiresConnected": "El emparejamiento estará disponible cuando el relay esté conectado.",
|
||||
"settings.remoteInstances.relay.pair.linkLabel": "Enlace de emparejamiento",
|
||||
"settings.remoteInstances.relay.pair.warning": "Este enlace concede acceso a este servidor. No lo compartas.",
|
||||
"settings.remoteInstances.relay.pair.qrAlt": "Código QR de emparejamiento del relay",
|
||||
"settings.remoteInstances.relay.pair.showQr": "Mostrar código QR",
|
||||
"settings.remoteInstances.relay.pair.qrDialogTitle": "Escanear para emparejar",
|
||||
"settings.remoteInstances.relay.pair.qrDialogDescription": "Escanea este código QR con la app de OpenChamber en tu otro dispositivo.",
|
||||
"settings.remoteInstances.relay.pair.manageHint": "Gestiona o revoca los dispositivos emparejados en la lista «Conexión a este servidor» de arriba.",
|
||||
"settings.remoteInstances.relay.toast.enableFailed": "No se pudo activar el relay",
|
||||
"settings.remoteInstances.relay.toast.disableFailed": "No se pudo desactivar el relay",
|
||||
"settings.remoteInstances.relay.toast.offerFailed": "No se pudo crear el enlace de emparejamiento",
|
||||
"settings.remoteInstances.relay.toast.linkCopied": "Enlace de emparejamiento copiado",
|
||||
"settings.remoteInstances.sidebar.phase.ready": "Listo",
|
||||
"settings.remoteInstances.sidebar.phase.error": "Error",
|
||||
"settings.remoteInstances.sidebar.phase.reconnect": "Reconectar",
|
||||
|
||||
@@ -59,6 +59,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"mobile.connect.scan.unsupported": "El escaneo de QR solo está disponible en la app móvil instalada.",
|
||||
"mobile.connect.saved.title": "Conexiones guardadas",
|
||||
"mobile.connect.saved.empty": "Aún no hay conexiones guardadas.",
|
||||
"mobile.connect.relay.badge": "a través de OpenChamber Relay",
|
||||
"mobile.connect.error.urlRequired": "Introduce una URL de servidor.",
|
||||
"mobile.connect.error.invalidUrl": "Esa URL de servidor no es válida.",
|
||||
"mobile.connect.error.unreachable": "No se pudo conectar con ese servidor de OpenChamber.",
|
||||
|
||||
@@ -1789,6 +1789,37 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': 'Cet appareil',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': 'Dernière utilisation le {date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': 'Jamais utilisé',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.description': 'Permettez à vos autres appareils de se connecter depuis n’importe où sans ouvrir de ports. Le trafic est chiffré de bout en bout — le relais ne peut pas le lire.',
|
||||
'settings.remoteInstances.relay.enableHint': 'Rien n’est partagé tant que vous n’activez pas le relais sur ce serveur.',
|
||||
'settings.remoteInstances.relay.actions.enable': 'Activer le relais',
|
||||
'settings.remoteInstances.relay.actions.disable': 'Désactiver',
|
||||
'settings.remoteInstances.relay.confirm.disable': 'Désactiver le relais ? Les appareils connectés via celui-ci seront déconnectés immédiatement.',
|
||||
'settings.remoteInstances.relay.state.loading': 'Vérification de l’état du relais...',
|
||||
'settings.remoteInstances.relay.state.disabled': 'Désactivé',
|
||||
'settings.remoteInstances.relay.state.connecting': 'Connexion',
|
||||
'settings.remoteInstances.relay.state.connected': 'Connecté',
|
||||
'settings.remoteInstances.relay.state.reconnecting': 'Reconnexion',
|
||||
'settings.remoteInstances.relay.state.error': 'Erreur',
|
||||
'settings.remoteInstances.relay.status.clientsOne': '{count} appareil connecté',
|
||||
'settings.remoteInstances.relay.status.clientsMany': '{count} appareils connectés',
|
||||
'settings.remoteInstances.relay.pair.title': 'Associer un appareil',
|
||||
'settings.remoteInstances.relay.pair.labelPlaceholder': 'Nom de l’appareil (facultatif)',
|
||||
'settings.remoteInstances.relay.pair.includeToken': 'Inclure le jeton d’accès (association en un seul scan)',
|
||||
'settings.remoteInstances.relay.pair.noTokenHint': 'Sans jeton, l’appareil se connecte avec le mot de passe de l’interface de ce serveur.',
|
||||
'settings.remoteInstances.relay.pair.generate': 'Créer un lien d’association',
|
||||
'settings.remoteInstances.relay.pair.requiresConnected': 'L’association devient disponible une fois le relais connecté.',
|
||||
'settings.remoteInstances.relay.pair.linkLabel': 'Lien d’association',
|
||||
'settings.remoteInstances.relay.pair.warning': 'Ce lien donne accès à ce serveur. Ne le partagez pas.',
|
||||
'settings.remoteInstances.relay.pair.qrAlt': 'Code QR d’association du relais',
|
||||
'settings.remoteInstances.relay.pair.showQr': 'Afficher le code QR',
|
||||
'settings.remoteInstances.relay.pair.qrDialogTitle': 'Scanner pour associer',
|
||||
'settings.remoteInstances.relay.pair.qrDialogDescription': 'Scannez ce code QR avec l’application OpenChamber sur votre autre appareil.',
|
||||
'settings.remoteInstances.relay.pair.manageHint': 'Gérez ou révoquez les appareils associés dans la liste « Connexion à ce serveur » ci-dessus.',
|
||||
'settings.remoteInstances.relay.toast.enableFailed': 'Échec de l’activation du relais',
|
||||
'settings.remoteInstances.relay.toast.disableFailed': 'Échec de la désactivation du relais',
|
||||
'settings.remoteInstances.relay.toast.offerFailed': 'Échec de la création du lien d’association',
|
||||
'settings.remoteInstances.relay.toast.linkCopied': 'Lien d’association copié',
|
||||
'settings.openchamber.about.field.openCodeVersion': 'Version d’OpenCode',
|
||||
'settings.openchamber.about.state.unknown': 'inconnue',
|
||||
'settings.voice.page.field.ttsInputMode': 'Mode d’entrée TTS',
|
||||
|
||||
@@ -2503,6 +2503,7 @@ export const dict = {
|
||||
'mobile.connect.scan.unsupported': 'Le scan QR est disponible uniquement dans l\'app mobile installée.',
|
||||
'mobile.connect.saved.title': 'Connexions enregistrées',
|
||||
'mobile.connect.saved.empty': 'Aucune connexion enregistrée pour le moment.',
|
||||
'mobile.connect.relay.badge': 'via OpenChamber Relay',
|
||||
'mobile.connect.error.urlRequired': 'Saisissez une URL de serveur.',
|
||||
'mobile.connect.error.invalidUrl': 'Cette URL de serveur n\'est pas valide.',
|
||||
'mobile.connect.error.unreachable': 'Impossible de joindre ce serveur OpenChamber.',
|
||||
|
||||
@@ -287,6 +287,37 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': 'このデバイス',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': '最終使用 {date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': '未使用',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.description': 'ポートを開放せずに、他のデバイスからどこからでも接続できます。通信はエンドツーエンドで暗号化され、リレーは内容を読めません。',
|
||||
'settings.remoteInstances.relay.enableHint': 'このサーバーでリレーを有効にするまで、何も共有されません。',
|
||||
'settings.remoteInstances.relay.actions.enable': 'リレーを有効にする',
|
||||
'settings.remoteInstances.relay.actions.disable': '無効にする',
|
||||
'settings.remoteInstances.relay.confirm.disable': 'リレーを無効にしますか?リレー経由で接続中のデバイスは即座に切断されます。',
|
||||
'settings.remoteInstances.relay.state.loading': 'リレーの状態を確認中...',
|
||||
'settings.remoteInstances.relay.state.disabled': '無効',
|
||||
'settings.remoteInstances.relay.state.connecting': '接続中',
|
||||
'settings.remoteInstances.relay.state.connected': '接続済み',
|
||||
'settings.remoteInstances.relay.state.reconnecting': '再接続中',
|
||||
'settings.remoteInstances.relay.state.error': 'エラー',
|
||||
'settings.remoteInstances.relay.status.clientsOne': '{count} 台のデバイスが接続中',
|
||||
'settings.remoteInstances.relay.status.clientsMany': '{count} 台のデバイスが接続中',
|
||||
'settings.remoteInstances.relay.pair.title': 'デバイスをペアリング',
|
||||
'settings.remoteInstances.relay.pair.labelPlaceholder': 'デバイス名(任意)',
|
||||
'settings.remoteInstances.relay.pair.includeToken': 'アクセストークンを含める(1回のスキャンでペアリング)',
|
||||
'settings.remoteInstances.relay.pair.noTokenHint': 'トークンなしの場合、デバイスは接続後にこのサーバーのUIパスワードでサインインします。',
|
||||
'settings.remoteInstances.relay.pair.generate': 'ペアリングリンクを作成',
|
||||
'settings.remoteInstances.relay.pair.requiresConnected': 'ペアリングはリレーの接続後に利用できます。',
|
||||
'settings.remoteInstances.relay.pair.linkLabel': 'ペアリングリンク',
|
||||
'settings.remoteInstances.relay.pair.warning': 'このリンクはこのサーバーへのアクセスを許可します。共有しないでください。',
|
||||
'settings.remoteInstances.relay.pair.qrAlt': 'リレーペアリング用QRコード',
|
||||
'settings.remoteInstances.relay.pair.showQr': 'QRコードを表示',
|
||||
'settings.remoteInstances.relay.pair.qrDialogTitle': 'スキャンしてペアリング',
|
||||
'settings.remoteInstances.relay.pair.qrDialogDescription': '他のデバイスのOpenChamberアプリでこのQRコードをスキャンします。',
|
||||
'settings.remoteInstances.relay.pair.manageHint': 'ペアリング済みデバイスの管理や取り消しは、上の「このサーバーへの接続」一覧で行えます。',
|
||||
'settings.remoteInstances.relay.toast.enableFailed': 'リレーを有効にできませんでした',
|
||||
'settings.remoteInstances.relay.toast.disableFailed': 'リレーを無効にできませんでした',
|
||||
'settings.remoteInstances.relay.toast.offerFailed': 'ペアリングリンクを作成できませんでした',
|
||||
'settings.remoteInstances.relay.toast.linkCopied': 'ペアリングリンクをコピーしました',
|
||||
'settings.remoteInstances.sidebar.phase.ready': '準備完了',
|
||||
'settings.remoteInstances.sidebar.phase.error': 'エラー',
|
||||
'settings.remoteInstances.sidebar.phase.reconnect': '再接続',
|
||||
|
||||
@@ -58,6 +58,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mobile.connect.cancelPassword': '別のサーバーを使用',
|
||||
'mobile.connect.saved.title': '保存された接続',
|
||||
'mobile.connect.saved.empty': '保存された接続はまだありません。',
|
||||
'mobile.connect.relay.badge': 'OpenChamber Relay 経由',
|
||||
'mobile.connect.error.urlRequired': 'サーバー URL を入力してください。',
|
||||
'mobile.connect.error.invalidUrl': 'そのサーバー URL は無効です。',
|
||||
'mobile.connect.error.unreachable': 'その OpenChamber サーバーに接続できませんでした。',
|
||||
|
||||
@@ -254,6 +254,37 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': '이 기기',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': '마지막 사용 {date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': '사용한 적 없음',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.description': '포트를 열지 않고도 다른 기기가 어디서든 연결할 수 있습니다. 트래픽은 종단 간 암호화되어 릴레이는 내용을 읽을 수 없습니다.',
|
||||
'settings.remoteInstances.relay.enableHint': '이 서버에서 릴레이를 켜기 전까지는 아무것도 공유되지 않습니다.',
|
||||
'settings.remoteInstances.relay.actions.enable': '릴레이 켜기',
|
||||
'settings.remoteInstances.relay.actions.disable': '끄기',
|
||||
'settings.remoteInstances.relay.confirm.disable': '릴레이를 끄시겠습니까? 릴레이를 통해 연결된 기기는 즉시 연결이 끊어집니다.',
|
||||
'settings.remoteInstances.relay.state.loading': '릴레이 상태 확인 중...',
|
||||
'settings.remoteInstances.relay.state.disabled': '꺼짐',
|
||||
'settings.remoteInstances.relay.state.connecting': '연결 중',
|
||||
'settings.remoteInstances.relay.state.connected': '연결됨',
|
||||
'settings.remoteInstances.relay.state.reconnecting': '재연결 중',
|
||||
'settings.remoteInstances.relay.state.error': '오류',
|
||||
'settings.remoteInstances.relay.status.clientsOne': '기기 {count}대 연결됨',
|
||||
'settings.remoteInstances.relay.status.clientsMany': '기기 {count}대 연결됨',
|
||||
'settings.remoteInstances.relay.pair.title': '기기 페어링',
|
||||
'settings.remoteInstances.relay.pair.labelPlaceholder': '기기 이름 (선택 사항)',
|
||||
'settings.remoteInstances.relay.pair.includeToken': '액세스 토큰 포함 (한 번 스캔으로 페어링)',
|
||||
'settings.remoteInstances.relay.pair.noTokenHint': '토큰이 없으면 기기는 연결 후 이 서버의 UI 비밀번호로 로그인합니다.',
|
||||
'settings.remoteInstances.relay.pair.generate': '페어링 링크 만들기',
|
||||
'settings.remoteInstances.relay.pair.requiresConnected': '페어링은 릴레이가 연결된 후 사용할 수 있습니다.',
|
||||
'settings.remoteInstances.relay.pair.linkLabel': '페어링 링크',
|
||||
'settings.remoteInstances.relay.pair.warning': '이 링크는 이 서버에 대한 접근 권한을 부여합니다. 공유하지 마세요.',
|
||||
'settings.remoteInstances.relay.pair.qrAlt': '릴레이 페어링 QR 코드',
|
||||
'settings.remoteInstances.relay.pair.showQr': 'QR 코드 표시',
|
||||
'settings.remoteInstances.relay.pair.qrDialogTitle': '스캔하여 페어링',
|
||||
'settings.remoteInstances.relay.pair.qrDialogDescription': '다른 기기의 OpenChamber 앱으로 이 QR 코드를 스캔하세요.',
|
||||
'settings.remoteInstances.relay.pair.manageHint': '페어링된 기기는 위의 “이 서버에 연결” 목록에서 관리하거나 철회할 수 있습니다.',
|
||||
'settings.remoteInstances.relay.toast.enableFailed': '릴레이를 켤 수 없습니다',
|
||||
'settings.remoteInstances.relay.toast.disableFailed': '릴레이를 끔 수 없습니다',
|
||||
'settings.remoteInstances.relay.toast.offerFailed': '페어링 링크를 만들지 못했습니다',
|
||||
'settings.remoteInstances.relay.toast.linkCopied': '페어링 링크를 복사했습니다',
|
||||
'settings.remoteInstances.sidebar.phase.ready': '준비됨',
|
||||
'settings.remoteInstances.sidebar.phase.error': '오류',
|
||||
'settings.remoteInstances.sidebar.phase.reconnect': '재연결',
|
||||
|
||||
@@ -59,6 +59,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mobile.connect.scan.unsupported': 'QR 스캔은 설치된 모바일 앱에서만 사용할 수 있습니다.',
|
||||
'mobile.connect.saved.title': '저장된 연결',
|
||||
'mobile.connect.saved.empty': '아직 저장된 연결이 없습니다.',
|
||||
'mobile.connect.relay.badge': 'OpenChamber Relay 경유',
|
||||
'mobile.connect.error.urlRequired': '서버 URL을 입력하세요.',
|
||||
'mobile.connect.error.invalidUrl': '유효하지 않은 서버 URL입니다.',
|
||||
'mobile.connect.error.unreachable': '해당 OpenChamber 서버에 연결할 수 없습니다.',
|
||||
|
||||
@@ -1477,6 +1477,37 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': 'To urządzenie',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': 'Ostatnio użyto {date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': 'Nigdy nie użyto',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.description': 'Pozwól swoim innym urządzeniom łączyć się z dowolnego miejsca bez otwierania portów. Ruch jest szyfrowany od końca do końca — relay nie może go odczytać.',
|
||||
'settings.remoteInstances.relay.enableHint': 'Nic nie jest udostępniane, dopóki nie włączysz relay na tym serwerze.',
|
||||
'settings.remoteInstances.relay.actions.enable': 'Włącz Relay',
|
||||
'settings.remoteInstances.relay.actions.disable': 'Wyłącz',
|
||||
'settings.remoteInstances.relay.confirm.disable': 'Wyłączyć relay? Urządzenia połączone przez niego zostaną natychmiast rozłączone.',
|
||||
'settings.remoteInstances.relay.state.loading': 'Sprawdzanie stanu relay...',
|
||||
'settings.remoteInstances.relay.state.disabled': 'Wyłączony',
|
||||
'settings.remoteInstances.relay.state.connecting': 'Łączenie',
|
||||
'settings.remoteInstances.relay.state.connected': 'Połączono',
|
||||
'settings.remoteInstances.relay.state.reconnecting': 'Ponowne łączenie',
|
||||
'settings.remoteInstances.relay.state.error': 'Błąd',
|
||||
'settings.remoteInstances.relay.status.clientsOne': '{count} urządzenie połączone',
|
||||
'settings.remoteInstances.relay.status.clientsMany': 'Połączone urządzenia: {count}',
|
||||
'settings.remoteInstances.relay.pair.title': 'Sparuj urządzenie',
|
||||
'settings.remoteInstances.relay.pair.labelPlaceholder': 'Nazwa urządzenia (opcjonalnie)',
|
||||
'settings.remoteInstances.relay.pair.includeToken': 'Dołącz token dostępu (parowanie jednym skanem)',
|
||||
'settings.remoteInstances.relay.pair.noTokenHint': 'Bez tokenu urządzenie zaloguje się po połączeniu hasłem interfejsu tego serwera.',
|
||||
'settings.remoteInstances.relay.pair.generate': 'Utwórz link parowania',
|
||||
'settings.remoteInstances.relay.pair.requiresConnected': 'Parowanie będzie dostępne, gdy relay zostanie połączony.',
|
||||
'settings.remoteInstances.relay.pair.linkLabel': 'Link parowania',
|
||||
'settings.remoteInstances.relay.pair.warning': 'Ten link daje dostęp do tego serwera. Nie udostępniaj go.',
|
||||
'settings.remoteInstances.relay.pair.qrAlt': 'Kod QR parowania relay',
|
||||
'settings.remoteInstances.relay.pair.showQr': 'Pokaż kod QR',
|
||||
'settings.remoteInstances.relay.pair.qrDialogTitle': 'Zeskanuj, aby sparować',
|
||||
'settings.remoteInstances.relay.pair.qrDialogDescription': 'Zeskanuj ten kod QR aplikacją OpenChamber na drugim urządzeniu.',
|
||||
'settings.remoteInstances.relay.pair.manageHint': 'Zarządzaj sparowanymi urządzeniami lub odbieraj im dostęp na liście „Połączenie z tym serwerem” powyżej.',
|
||||
'settings.remoteInstances.relay.toast.enableFailed': 'Nie udało się włączyć relay',
|
||||
'settings.remoteInstances.relay.toast.disableFailed': 'Nie udało się wyłączyć relay',
|
||||
'settings.remoteInstances.relay.toast.offerFailed': 'Nie udało się utworzyć linku parowania',
|
||||
'settings.remoteInstances.relay.toast.linkCopied': 'Skopiowano link parowania',
|
||||
'settings.remoteInstances.sidebar.toast.retriedWithRandomPort': 'Ponowiono próbę z losowym lokalnym portem',
|
||||
'settings.remoteInstances.sidebar.toast.retryFailed': 'Nie udało się ponowić połączenia',
|
||||
'settings.remoteInstances.sidebar.total': 'Suma: {count}',
|
||||
|
||||
@@ -60,6 +60,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mobile.connect.scan.unsupported': 'Skanowanie QR jest dostępne tylko w zainstalowanej aplikacji mobilnej.',
|
||||
'mobile.connect.saved.title': 'Zapisane połączenia',
|
||||
'mobile.connect.saved.empty': 'Brak zapisanych połączeń.',
|
||||
'mobile.connect.relay.badge': 'przez OpenChamber Relay',
|
||||
'mobile.connect.error.urlRequired': 'Podaj adres URL serwera.',
|
||||
'mobile.connect.error.invalidUrl': 'Ten adres URL serwera jest nieprawidłowy.',
|
||||
'mobile.connect.error.unreachable': 'Nie udało się połączyć z tym serwerem OpenChamber.',
|
||||
|
||||
@@ -254,6 +254,37 @@ export const settingsDict = {
|
||||
"settings.remoteInstances.clientAuth.state.thisDevice": "Este dispositivo",
|
||||
"settings.remoteInstances.clientAuth.lastUsed": "Último uso em {date}",
|
||||
"settings.remoteInstances.clientAuth.neverUsed": "Nunca usado",
|
||||
"settings.remoteInstances.relay.title": "OpenChamber Relay",
|
||||
"settings.remoteInstances.relay.description": "Permita que seus outros dispositivos se conectem de qualquer lugar sem abrir portas. O tráfego é criptografado de ponta a ponta — o relay não consegue lê-lo.",
|
||||
"settings.remoteInstances.relay.enableHint": "Nada é compartilhado até você ativar o relay neste servidor.",
|
||||
"settings.remoteInstances.relay.actions.enable": "Ativar Relay",
|
||||
"settings.remoteInstances.relay.actions.disable": "Desativar",
|
||||
"settings.remoteInstances.relay.confirm.disable": "Desativar o relay? Os dispositivos conectados por ele serão desconectados imediatamente.",
|
||||
"settings.remoteInstances.relay.state.loading": "Verificando o status do relay...",
|
||||
"settings.remoteInstances.relay.state.disabled": "Desativado",
|
||||
"settings.remoteInstances.relay.state.connecting": "Conectando",
|
||||
"settings.remoteInstances.relay.state.connected": "Conectado",
|
||||
"settings.remoteInstances.relay.state.reconnecting": "Reconectando",
|
||||
"settings.remoteInstances.relay.state.error": "Erro",
|
||||
"settings.remoteInstances.relay.status.clientsOne": "{count} dispositivo conectado",
|
||||
"settings.remoteInstances.relay.status.clientsMany": "{count} dispositivos conectados",
|
||||
"settings.remoteInstances.relay.pair.title": "Parear um dispositivo",
|
||||
"settings.remoteInstances.relay.pair.labelPlaceholder": "Nome do dispositivo (opcional)",
|
||||
"settings.remoteInstances.relay.pair.includeToken": "Incluir token de acesso (pareamento com um único escaneamento)",
|
||||
"settings.remoteInstances.relay.pair.noTokenHint": "Sem token, o dispositivo faz login com a senha da interface deste servidor após conectar.",
|
||||
"settings.remoteInstances.relay.pair.generate": "Criar link de pareamento",
|
||||
"settings.remoteInstances.relay.pair.requiresConnected": "O pareamento fica disponível quando o relay estiver conectado.",
|
||||
"settings.remoteInstances.relay.pair.linkLabel": "Link de pareamento",
|
||||
"settings.remoteInstances.relay.pair.warning": "Este link concede acesso a este servidor. Não o compartilhe.",
|
||||
"settings.remoteInstances.relay.pair.qrAlt": "Código QR de pareamento do relay",
|
||||
"settings.remoteInstances.relay.pair.showQr": "Mostrar código QR",
|
||||
"settings.remoteInstances.relay.pair.qrDialogTitle": "Escanear para parear",
|
||||
"settings.remoteInstances.relay.pair.qrDialogDescription": "Escaneie este código QR com o app OpenChamber no seu outro dispositivo.",
|
||||
"settings.remoteInstances.relay.pair.manageHint": "Gerencie ou revogue dispositivos pareados na lista “Conectar a este servidor” acima.",
|
||||
"settings.remoteInstances.relay.toast.enableFailed": "Falha ao ativar o relay",
|
||||
"settings.remoteInstances.relay.toast.disableFailed": "Falha ao desativar o relay",
|
||||
"settings.remoteInstances.relay.toast.offerFailed": "Falha ao criar o link de pareamento",
|
||||
"settings.remoteInstances.relay.toast.linkCopied": "Link de pareamento copiado",
|
||||
"settings.remoteInstances.sidebar.phase.ready": "Pronto",
|
||||
"settings.remoteInstances.sidebar.phase.error": "Erro",
|
||||
"settings.remoteInstances.sidebar.phase.reconnect": "Reconectar",
|
||||
|
||||
@@ -59,6 +59,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"mobile.connect.scan.unsupported": "A leitura de QR só está disponível no app móvel instalado.",
|
||||
"mobile.connect.saved.title": "Conexões salvas",
|
||||
"mobile.connect.saved.empty": "Nenhuma conexão salva ainda.",
|
||||
"mobile.connect.relay.badge": "via OpenChamber Relay",
|
||||
"mobile.connect.error.urlRequired": "Informe a URL de um servidor.",
|
||||
"mobile.connect.error.invalidUrl": "Essa URL de servidor não é válida.",
|
||||
"mobile.connect.error.unreachable": "Não foi possível acessar esse servidor OpenChamber.",
|
||||
|
||||
@@ -254,6 +254,37 @@ export const settingsDict = {
|
||||
"settings.remoteInstances.clientAuth.state.thisDevice": "Цей пристрій",
|
||||
"settings.remoteInstances.clientAuth.lastUsed": "Останнє використання {date}",
|
||||
"settings.remoteInstances.clientAuth.neverUsed": "Ще не використовувався",
|
||||
"settings.remoteInstances.relay.title": "OpenChamber Relay",
|
||||
"settings.remoteInstances.relay.description": "Дозволяє вашим іншим пристроям підключатися звідки завгодно без відкриття портів. Трафік шифрується наскрізно — релей не може його прочитати.",
|
||||
"settings.remoteInstances.relay.enableHint": "Нічого не передається, доки ви не увімкнете релей на цьому сервері.",
|
||||
"settings.remoteInstances.relay.actions.enable": "Увімкнути Relay",
|
||||
"settings.remoteInstances.relay.actions.disable": "Вимкнути",
|
||||
"settings.remoteInstances.relay.confirm.disable": "Вимкнути релей? Пристрої, підключені через нього, будуть негайно відключені.",
|
||||
"settings.remoteInstances.relay.state.loading": "Перевірка стану релею...",
|
||||
"settings.remoteInstances.relay.state.disabled": "Вимкнено",
|
||||
"settings.remoteInstances.relay.state.connecting": "Підключення",
|
||||
"settings.remoteInstances.relay.state.connected": "Підключено",
|
||||
"settings.remoteInstances.relay.state.reconnecting": "Повторне підключення",
|
||||
"settings.remoteInstances.relay.state.error": "Помилка",
|
||||
"settings.remoteInstances.relay.status.clientsOne": "Підключено пристроїв: {count}",
|
||||
"settings.remoteInstances.relay.status.clientsMany": "Підключено пристроїв: {count}",
|
||||
"settings.remoteInstances.relay.pair.title": "Спарувати пристрій",
|
||||
"settings.remoteInstances.relay.pair.labelPlaceholder": "Назва пристрою (необов'язково)",
|
||||
"settings.remoteInstances.relay.pair.includeToken": "Додати токен доступу (спарювання одним скануванням)",
|
||||
"settings.remoteInstances.relay.pair.noTokenHint": "Без токена пристрій після підключення входить за паролем інтерфейсу цього сервера.",
|
||||
"settings.remoteInstances.relay.pair.generate": "Створити посилання для спарювання",
|
||||
"settings.remoteInstances.relay.pair.requiresConnected": "Спарювання стане доступним після підключення релею.",
|
||||
"settings.remoteInstances.relay.pair.linkLabel": "Посилання для спарювання",
|
||||
"settings.remoteInstances.relay.pair.warning": "Це посилання надає доступ до цього сервера. Не діліться ним.",
|
||||
"settings.remoteInstances.relay.pair.qrAlt": "QR-код спарювання релею",
|
||||
"settings.remoteInstances.relay.pair.showQr": "Показати QR-код",
|
||||
"settings.remoteInstances.relay.pair.qrDialogTitle": "Скануйте для спарювання",
|
||||
"settings.remoteInstances.relay.pair.qrDialogDescription": "Відскануйте цей QR-код застосунком OpenChamber на іншому пристрої.",
|
||||
"settings.remoteInstances.relay.pair.manageHint": "Керуйте спареними пристроями або відкликайте їх у списку «Підключення до цього сервера» вище.",
|
||||
"settings.remoteInstances.relay.toast.enableFailed": "Не вдалося увімкнути релей",
|
||||
"settings.remoteInstances.relay.toast.disableFailed": "Не вдалося вимкнути релей",
|
||||
"settings.remoteInstances.relay.toast.offerFailed": "Не вдалося створити посилання для спарювання",
|
||||
"settings.remoteInstances.relay.toast.linkCopied": "Посилання для спарювання скопійовано",
|
||||
"settings.remoteInstances.sidebar.phase.ready": "Готово",
|
||||
"settings.remoteInstances.sidebar.phase.error": "Помилка",
|
||||
"settings.remoteInstances.sidebar.phase.reconnect": "Повторне підключення",
|
||||
|
||||
@@ -59,6 +59,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"mobile.connect.scan.unsupported": "Сканування QR доступне лише у встановленій мобільній апці.",
|
||||
"mobile.connect.saved.title": "Збережені підключення",
|
||||
"mobile.connect.saved.empty": "Збережених підключень ще немає.",
|
||||
"mobile.connect.relay.badge": "через OpenChamber Relay",
|
||||
"mobile.connect.error.urlRequired": "Введи адресу сервера.",
|
||||
"mobile.connect.error.invalidUrl": "Ця адреса сервера некоректна.",
|
||||
"mobile.connect.error.unreachable": "Не вдалося достукатись до цього OpenChamber сервера.",
|
||||
|
||||
@@ -254,6 +254,37 @@ export const settingsDict = {
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': '此设备',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': '上次使用 {date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': '从未使用',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.description': '无需开放端口,即可让你的其他设备从任何地方连接。流量端到端加密,中继无法读取内容。',
|
||||
'settings.remoteInstances.relay.enableHint': '在此服务器上启用中继之前,不会共享任何内容。',
|
||||
'settings.remoteInstances.relay.actions.enable': '启用中继',
|
||||
'settings.remoteInstances.relay.actions.disable': '停用',
|
||||
'settings.remoteInstances.relay.confirm.disable': '停用中继?通过它连接的设备将立即断开。',
|
||||
'settings.remoteInstances.relay.state.loading': '正在检查中继状态...',
|
||||
'settings.remoteInstances.relay.state.disabled': '已停用',
|
||||
'settings.remoteInstances.relay.state.connecting': '连接中',
|
||||
'settings.remoteInstances.relay.state.connected': '已连接',
|
||||
'settings.remoteInstances.relay.state.reconnecting': '重新连接中',
|
||||
'settings.remoteInstances.relay.state.error': '错误',
|
||||
'settings.remoteInstances.relay.status.clientsOne': '已连接 {count} 台设备',
|
||||
'settings.remoteInstances.relay.status.clientsMany': '已连接 {count} 台设备',
|
||||
'settings.remoteInstances.relay.pair.title': '配对设备',
|
||||
'settings.remoteInstances.relay.pair.labelPlaceholder': '设备名称(可选)',
|
||||
'settings.remoteInstances.relay.pair.includeToken': '包含访问令牌(扫一次即完成配对)',
|
||||
'settings.remoteInstances.relay.pair.noTokenHint': '不包含令牌时,设备连接后需使用此服务器的界面密码登录。',
|
||||
'settings.remoteInstances.relay.pair.generate': '创建配对链接',
|
||||
'settings.remoteInstances.relay.pair.requiresConnected': '中继连接后即可配对。',
|
||||
'settings.remoteInstances.relay.pair.linkLabel': '配对链接',
|
||||
'settings.remoteInstances.relay.pair.warning': '此链接可访问此服务器,请勿分享。',
|
||||
'settings.remoteInstances.relay.pair.qrAlt': '中继配对二维码',
|
||||
'settings.remoteInstances.relay.pair.showQr': '显示二维码',
|
||||
'settings.remoteInstances.relay.pair.qrDialogTitle': '扫描配对',
|
||||
'settings.remoteInstances.relay.pair.qrDialogDescription': '用其他设备上的 OpenChamber 应用扫描此二维码。',
|
||||
'settings.remoteInstances.relay.pair.manageHint': '可在上方“连接到此服务器”列表中管理或吊销已配对的设备。',
|
||||
'settings.remoteInstances.relay.toast.enableFailed': '无法启用中继',
|
||||
'settings.remoteInstances.relay.toast.disableFailed': '无法停用中继',
|
||||
'settings.remoteInstances.relay.toast.offerFailed': '无法创建配对链接',
|
||||
'settings.remoteInstances.relay.toast.linkCopied': '已复制配对链接',
|
||||
'settings.remoteInstances.sidebar.phase.ready': '就绪',
|
||||
'settings.remoteInstances.sidebar.phase.error': '错误',
|
||||
'settings.remoteInstances.sidebar.phase.reconnect': '重连',
|
||||
|
||||
@@ -59,6 +59,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mobile.connect.scan.unsupported': '二维码扫描仅在已安装的移动应用中可用。',
|
||||
'mobile.connect.saved.title': '已保存的连接',
|
||||
'mobile.connect.saved.empty': '暂无已保存的连接。',
|
||||
'mobile.connect.relay.badge': '通过 OpenChamber Relay 连接',
|
||||
'mobile.connect.error.urlRequired': '请输入服务器 URL。',
|
||||
'mobile.connect.error.invalidUrl': '该服务器 URL 无效。',
|
||||
'mobile.connect.error.unreachable': '无法连接到该 OpenChamber 服务器。',
|
||||
|
||||
@@ -260,6 +260,37 @@
|
||||
'settings.remoteInstances.clientAuth.state.thisDevice': '此裝置',
|
||||
'settings.remoteInstances.clientAuth.lastUsed': '上次使用:{date}',
|
||||
'settings.remoteInstances.clientAuth.neverUsed': '從未使用',
|
||||
'settings.remoteInstances.relay.title': 'OpenChamber Relay',
|
||||
'settings.remoteInstances.relay.description': '無需開放連接埠,即可讓你的其他裝置從任何地方連線。流量端對端加密,中繼無法讀取內容。',
|
||||
'settings.remoteInstances.relay.enableHint': '在此伺服器上啟用中繼之前,不會共享任何內容。',
|
||||
'settings.remoteInstances.relay.actions.enable': '啟用中繼',
|
||||
'settings.remoteInstances.relay.actions.disable': '停用',
|
||||
'settings.remoteInstances.relay.confirm.disable': '停用中繼?透過它連線的裝置將立即中斷連線。',
|
||||
'settings.remoteInstances.relay.state.loading': '正在檢查中繼狀態...',
|
||||
'settings.remoteInstances.relay.state.disabled': '已停用',
|
||||
'settings.remoteInstances.relay.state.connecting': '連線中',
|
||||
'settings.remoteInstances.relay.state.connected': '已連線',
|
||||
'settings.remoteInstances.relay.state.reconnecting': '重新連線中',
|
||||
'settings.remoteInstances.relay.state.error': '錯誤',
|
||||
'settings.remoteInstances.relay.status.clientsOne': '已連線 {count} 台裝置',
|
||||
'settings.remoteInstances.relay.status.clientsMany': '已連線 {count} 台裝置',
|
||||
'settings.remoteInstances.relay.pair.title': '配對裝置',
|
||||
'settings.remoteInstances.relay.pair.labelPlaceholder': '裝置名稱(選填)',
|
||||
'settings.remoteInstances.relay.pair.includeToken': '包含存取權杖(掃一次即完成配對)',
|
||||
'settings.remoteInstances.relay.pair.noTokenHint': '不包含權杖時,裝置連線後需使用此伺服器的介面密碼登入。',
|
||||
'settings.remoteInstances.relay.pair.generate': '建立配對連結',
|
||||
'settings.remoteInstances.relay.pair.requiresConnected': '中繼連線後即可配對。',
|
||||
'settings.remoteInstances.relay.pair.linkLabel': '配對連結',
|
||||
'settings.remoteInstances.relay.pair.warning': '此連結可存取此伺服器,請勿分享。',
|
||||
'settings.remoteInstances.relay.pair.qrAlt': '中繼配對 QR 碼',
|
||||
'settings.remoteInstances.relay.pair.showQr': '顯示 QR 碼',
|
||||
'settings.remoteInstances.relay.pair.qrDialogTitle': '掃描配對',
|
||||
'settings.remoteInstances.relay.pair.qrDialogDescription': '用另一台裝置上的 OpenChamber 應用程式掃描此 QR 碼。',
|
||||
'settings.remoteInstances.relay.pair.manageHint': '可在上方「連線到此伺服器」清單中管理或撤銷已配對的裝置。',
|
||||
'settings.remoteInstances.relay.toast.enableFailed': '無法啟用中繼',
|
||||
'settings.remoteInstances.relay.toast.disableFailed': '無法停用中繼',
|
||||
'settings.remoteInstances.relay.toast.offerFailed': '無法建立配對連結',
|
||||
'settings.remoteInstances.relay.toast.linkCopied': '已複製配對連結',
|
||||
'settings.remoteInstances.page.section.instance': '執行個體',
|
||||
'settings.remoteInstances.page.section.instanceDescription': '核心 SSH 設定。',
|
||||
'settings.remoteInstances.page.field.mode': '模式',
|
||||
|
||||
@@ -59,6 +59,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mobile.connect.scan.unsupported': 'QR code 掃描僅在已安裝的行動應用程式中可用。',
|
||||
'mobile.connect.saved.title': '已儲存的連線',
|
||||
'mobile.connect.saved.empty': '尚未儲存任何連線。',
|
||||
'mobile.connect.relay.badge': '透過 OpenChamber Relay 連線',
|
||||
'mobile.connect.error.urlRequired': '請輸入伺服器網址。',
|
||||
'mobile.connect.error.invalidUrl': '該伺服器網址無效。',
|
||||
'mobile.connect.error.unreachable': '無法連線至該 OpenChamber 伺服器。',
|
||||
|
||||
@@ -126,6 +126,27 @@ describe('opencodeClient prompt retry behavior', () => {
|
||||
expect(error instanceof Error ? error.message : String(error)).toContain('Failed to fetch');
|
||||
});
|
||||
|
||||
test('does not fabricate an HTTP 500 when the SDK swallows a transport failure into result.error', async () => {
|
||||
// The SDK catches thrown fetch errors and returns { error, response: undefined }.
|
||||
// That is a transport failure, not a server 500 — it must surface as a
|
||||
// descriptive transport error, never as "Failed to send message (500): {}".
|
||||
promptAsyncResults.push({ error: new TypeError('relay tunnel reset: plaintext frame on established channel'), response: undefined });
|
||||
|
||||
let error: unknown = null;
|
||||
try {
|
||||
await sendPrompt('anthropic-transport');
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(promptAsyncCalls.length).toBe(1);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
expect(message).not.toContain('Failed to send message (500)');
|
||||
expect(message).toContain('transport failure');
|
||||
expect(message).toContain('relay tunnel reset');
|
||||
expect((error as Error & { status?: number }).status).toBe(undefined);
|
||||
});
|
||||
|
||||
test('does not retry 503 prompt responses because proxy errors can be ambiguous too', async () => {
|
||||
promptAsyncResults.push({ response: new Response('starting', { status: 503 }) });
|
||||
|
||||
|
||||
@@ -860,7 +860,14 @@ class OpencodeService {
|
||||
if (result.response instanceof Response) {
|
||||
response = result.response;
|
||||
} else if (result.error) {
|
||||
const status = (result as SdkResult<unknown>).response?.status || 500;
|
||||
const status = (result as SdkResult<unknown>).response?.status;
|
||||
if (!status) {
|
||||
// The SDK caught a thrown fetch error (network/tunnel transport
|
||||
// failure) — there is no HTTP response to report. Never fabricate a
|
||||
// status: surface it as a transport error so callers treat it like
|
||||
// any other network failure instead of a server 500.
|
||||
throw new Error(`Message send transport failure: ${formatSdkError(result.error)}`);
|
||||
}
|
||||
response = new Response(JSON.stringify(result.error), { status });
|
||||
} else {
|
||||
response = new Response(JSON.stringify(result.data ?? true), { status: 200 });
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
base64UrlToBytes,
|
||||
bytesToBase64Url,
|
||||
createFrameDecryptor,
|
||||
createFrameEncryptor,
|
||||
deriveSessionKeys,
|
||||
exportPublicKeyJwk,
|
||||
generateEcdhKeyPair,
|
||||
generateHandshakeNonce,
|
||||
importEcdhPublicKey,
|
||||
publicKeyJwkFingerprint,
|
||||
RelayCryptoError,
|
||||
} from './crypto';
|
||||
import { ENCRYPTED_FRAME_HEADER_BYTES, MAX_PLAINTEXT_FRAME_BYTES } from './protocol';
|
||||
|
||||
const setupSession = async () => {
|
||||
const host = await generateEcdhKeyPair();
|
||||
const client = await generateEcdhKeyPair();
|
||||
const nonce = generateHandshakeNonce();
|
||||
const hostPub = await importEcdhPublicKey(await exportPublicKeyJwk(host.publicKey));
|
||||
const clientPub = await importEcdhPublicKey(await exportPublicKeyJwk(client.publicKey));
|
||||
const clientKeys = await deriveSessionKeys(client.privateKey, hostPub, nonce);
|
||||
const hostKeys = await deriveSessionKeys(host.privateKey, clientPub, nonce);
|
||||
return { clientKeys, hostKeys };
|
||||
};
|
||||
|
||||
describe('relay crypto', () => {
|
||||
test('both sides derive matching directional keys (round trip both ways)', async () => {
|
||||
const { clientKeys, hostKeys } = await setupSession();
|
||||
|
||||
const clientToHost = createFrameEncryptor(clientKeys.clientToHost);
|
||||
const hostReceives = createFrameDecryptor(hostKeys.clientToHost);
|
||||
const message = new TextEncoder().encode('hello from client');
|
||||
const decrypted = await hostReceives.decrypt(await clientToHost.encrypt(message));
|
||||
expect(new TextDecoder().decode(decrypted)).toBe('hello from client');
|
||||
|
||||
const hostToClient = createFrameEncryptor(hostKeys.hostToClient);
|
||||
const clientReceives = createFrameDecryptor(clientKeys.hostToClient);
|
||||
const reply = new TextEncoder().encode('hello from host');
|
||||
const decryptedReply = await clientReceives.decrypt(await hostToClient.encrypt(reply));
|
||||
expect(new TextDecoder().decode(decryptedReply)).toBe('hello from host');
|
||||
});
|
||||
|
||||
test('different nonce yields incompatible keys', async () => {
|
||||
const host = await generateEcdhKeyPair();
|
||||
const client = await generateEcdhKeyPair();
|
||||
const hostPub = await importEcdhPublicKey(await exportPublicKeyJwk(host.publicKey));
|
||||
const clientPub = await importEcdhPublicKey(await exportPublicKeyJwk(client.publicKey));
|
||||
const clientKeys = await deriveSessionKeys(client.privateKey, hostPub, generateHandshakeNonce());
|
||||
const hostKeys = await deriveSessionKeys(host.privateKey, clientPub, generateHandshakeNonce());
|
||||
const frame = await createFrameEncryptor(clientKeys.clientToHost).encrypt(new Uint8Array([1, 2, 3]));
|
||||
await expect(createFrameDecryptor(hostKeys.clientToHost).decrypt(frame)).rejects.toThrow(RelayCryptoError);
|
||||
});
|
||||
|
||||
test('tampered ciphertext is rejected', async () => {
|
||||
const { clientKeys, hostKeys } = await setupSession();
|
||||
const frame = await createFrameEncryptor(clientKeys.clientToHost).encrypt(new Uint8Array([9, 9, 9]));
|
||||
frame[frame.length - 1] ^= 0x01;
|
||||
await expect(createFrameDecryptor(hostKeys.clientToHost).decrypt(frame)).rejects.toThrow(
|
||||
'frame decryption failed',
|
||||
);
|
||||
});
|
||||
|
||||
test('replayed and reordered frames are rejected (counter regression)', async () => {
|
||||
const { clientKeys, hostKeys } = await setupSession();
|
||||
const encryptor = createFrameEncryptor(clientKeys.clientToHost);
|
||||
const decryptor = createFrameDecryptor(hostKeys.clientToHost);
|
||||
const first = await encryptor.encrypt(new Uint8Array([1]));
|
||||
const second = await encryptor.encrypt(new Uint8Array([2]));
|
||||
await decryptor.decrypt(first);
|
||||
await decryptor.decrypt(second);
|
||||
await expect(decryptor.decrypt(first)).rejects.toThrow('frame counter regression');
|
||||
});
|
||||
|
||||
test('skipped counters are tolerated but never regress', async () => {
|
||||
const { clientKeys, hostKeys } = await setupSession();
|
||||
const encryptor = createFrameEncryptor(clientKeys.clientToHost);
|
||||
const decryptor = createFrameDecryptor(hostKeys.clientToHost);
|
||||
const first = await encryptor.encrypt(new Uint8Array([1]));
|
||||
const second = await encryptor.encrypt(new Uint8Array([2]));
|
||||
const third = await encryptor.encrypt(new Uint8Array([3]));
|
||||
await decryptor.decrypt(first);
|
||||
await decryptor.decrypt(third);
|
||||
await expect(decryptor.decrypt(second)).rejects.toThrow('frame counter regression');
|
||||
});
|
||||
|
||||
test('oversized plaintext is rejected before encryption', async () => {
|
||||
const { clientKeys } = await setupSession();
|
||||
const encryptor = createFrameEncryptor(clientKeys.clientToHost);
|
||||
await expect(encryptor.encrypt(new Uint8Array(MAX_PLAINTEXT_FRAME_BYTES + 1))).rejects.toThrow(
|
||||
'plaintext frame exceeds maximum size',
|
||||
);
|
||||
});
|
||||
|
||||
test('truncated and wrong-version frames are rejected', async () => {
|
||||
const { hostKeys } = await setupSession();
|
||||
const decryptor = createFrameDecryptor(hostKeys.clientToHost);
|
||||
await expect(decryptor.decrypt(new Uint8Array(ENCRYPTED_FRAME_HEADER_BYTES))).rejects.toThrow(
|
||||
'encrypted frame too short',
|
||||
);
|
||||
const bogus = new Uint8Array(ENCRYPTED_FRAME_HEADER_BYTES + 20);
|
||||
bogus[0] = 42;
|
||||
await expect(decryptor.decrypt(bogus)).rejects.toThrow('unsupported encrypted frame version');
|
||||
});
|
||||
|
||||
test('importEcdhPublicKey rejects malformed JWKs', async () => {
|
||||
await expect(importEcdhPublicKey({ kty: 'RSA' })).rejects.toThrow(RelayCryptoError);
|
||||
await expect(importEcdhPublicKey({ kty: 'EC', crv: 'P-384', x: 'a', y: 'b' })).rejects.toThrow(
|
||||
RelayCryptoError,
|
||||
);
|
||||
await expect(importEcdhPublicKey({ kty: 'EC', crv: 'P-256', x: '!!', y: '!!' })).rejects.toThrow(
|
||||
RelayCryptoError,
|
||||
);
|
||||
});
|
||||
|
||||
test('fingerprint is stable across key-order differences and distinct per key', async () => {
|
||||
const pair = await generateEcdhKeyPair();
|
||||
const jwk = await exportPublicKeyJwk(pair.publicKey);
|
||||
const shuffled: JsonWebKey = { y: jwk.y, x: jwk.x, crv: jwk.crv, kty: jwk.kty };
|
||||
expect(publicKeyJwkFingerprint(jwk)).toBe(publicKeyJwkFingerprint(shuffled));
|
||||
const other = await exportPublicKeyJwk((await generateEcdhKeyPair()).publicKey);
|
||||
expect(publicKeyJwkFingerprint(jwk)).not.toBe(publicKeyJwkFingerprint(other));
|
||||
});
|
||||
|
||||
test('base64url round trip and rejection of invalid input', () => {
|
||||
for (const length of [0, 1, 2, 3, 16, 31, 32]) {
|
||||
const bytes = new Uint8Array(length);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
expect(base64UrlToBytes(bytesToBase64Url(bytes))).toEqual(bytes);
|
||||
}
|
||||
expect(() => base64UrlToBytes('a+b/c=')).toThrow(RelayCryptoError);
|
||||
expect(() => base64UrlToBytes('abcde')).toThrow(RelayCryptoError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
// E2EE primitives for the private relay (Layer 2 of the protocol spec).
|
||||
// WebCrypto only — isomorphic across browser, Node >= 20, WKWebView, and Workers.
|
||||
// Key agreement: ECDH P-256 -> HKDF-SHA-256 -> two AES-256-GCM keys (one per direction).
|
||||
// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 2).
|
||||
|
||||
import {
|
||||
ENCRYPTED_FRAME_HEADER_BYTES,
|
||||
ENCRYPTED_FRAME_IV_BYTES,
|
||||
ENCRYPTED_FRAME_VERSION,
|
||||
MAX_PLAINTEXT_FRAME_BYTES,
|
||||
RELAY_HKDF_INFO,
|
||||
} from './protocol';
|
||||
|
||||
const subtle = globalThis.crypto.subtle;
|
||||
|
||||
const ECDH_PARAMS: EcKeyGenParams = { name: 'ECDH', namedCurve: 'P-256' };
|
||||
const HANDSHAKE_NONCE_BYTES = 16;
|
||||
const SESSION_KEY_BYTES = 32;
|
||||
const GCM_TAG_BYTES = 16;
|
||||
// IV = 4-byte random per-direction prefix || 8-byte big-endian frame counter.
|
||||
const IV_PREFIX_BYTES = 4;
|
||||
const IV_COUNTER_BYTES = 8;
|
||||
|
||||
export class RelayCryptoError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RelayCryptoError';
|
||||
}
|
||||
}
|
||||
|
||||
export const generateEcdhKeyPair = (): Promise<CryptoKeyPair> =>
|
||||
subtle.generateKey(ECDH_PARAMS, true, ['deriveBits']);
|
||||
|
||||
export const exportPublicKeyJwk = async (key: CryptoKey): Promise<JsonWebKey> => {
|
||||
const jwk = await subtle.exportKey('jwk', key);
|
||||
// Keep only the fields that define the public point so serialized forms compare stably.
|
||||
return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
|
||||
};
|
||||
|
||||
export const importEcdhPublicKey = async (jwk: JsonWebKey): Promise<CryptoKey> => {
|
||||
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || typeof jwk.x !== 'string' || typeof jwk.y !== 'string') {
|
||||
throw new RelayCryptoError('invalid ECDH public key JWK');
|
||||
}
|
||||
try {
|
||||
return await subtle.importKey(
|
||||
'jwk',
|
||||
{ kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true },
|
||||
ECDH_PARAMS,
|
||||
true,
|
||||
[],
|
||||
);
|
||||
} catch {
|
||||
throw new RelayCryptoError('invalid ECDH public key JWK');
|
||||
}
|
||||
};
|
||||
|
||||
// Stable fingerprint of a public key, used to detect rekey attempts on re-hello.
|
||||
export const publicKeyJwkFingerprint = (jwk: JsonWebKey): string =>
|
||||
JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
|
||||
|
||||
export const generateHandshakeNonce = (): Uint8Array => {
|
||||
const nonce = new Uint8Array(HANDSHAKE_NONCE_BYTES);
|
||||
globalThis.crypto.getRandomValues(nonce);
|
||||
return nonce;
|
||||
};
|
||||
|
||||
export interface SessionKeys {
|
||||
clientToHost: CryptoKey;
|
||||
hostToClient: CryptoKey;
|
||||
}
|
||||
|
||||
// Both sides call this with their own private key and the peer's public key;
|
||||
// ECDH yields the same shared secret, so the derived key pair matches.
|
||||
export const deriveSessionKeys = async (
|
||||
ownPrivateKey: CryptoKey,
|
||||
peerPublicKey: CryptoKey,
|
||||
handshakeNonce: Uint8Array,
|
||||
): Promise<SessionKeys> => {
|
||||
if (handshakeNonce.length !== HANDSHAKE_NONCE_BYTES) {
|
||||
throw new RelayCryptoError('invalid handshake nonce length');
|
||||
}
|
||||
const sharedSecret = await subtle.deriveBits(
|
||||
{ name: 'ECDH', public: peerPublicKey },
|
||||
ownPrivateKey,
|
||||
256,
|
||||
);
|
||||
const hkdfKey = await subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveBits']);
|
||||
const keyMaterial = new Uint8Array(
|
||||
await subtle.deriveBits(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: handshakeNonce as BufferSource,
|
||||
info: new TextEncoder().encode(RELAY_HKDF_INFO),
|
||||
},
|
||||
hkdfKey,
|
||||
SESSION_KEY_BYTES * 2 * 8,
|
||||
),
|
||||
);
|
||||
const importAesKey = (bytes: Uint8Array, usage: KeyUsage[]) =>
|
||||
subtle.importKey('raw', bytes as BufferSource, { name: 'AES-GCM' }, false, usage);
|
||||
return {
|
||||
clientToHost: await importAesKey(keyMaterial.slice(0, SESSION_KEY_BYTES), ['encrypt', 'decrypt']),
|
||||
hostToClient: await importAesKey(keyMaterial.slice(SESSION_KEY_BYTES), ['encrypt', 'decrypt']),
|
||||
};
|
||||
};
|
||||
|
||||
export interface FrameEncryptor {
|
||||
encrypt(plaintext: Uint8Array): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface FrameDecryptor {
|
||||
decrypt(frame: Uint8Array): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
const writeCounter = (target: Uint8Array, offset: number, counter: bigint): void => {
|
||||
for (let i = IV_COUNTER_BYTES - 1; i >= 0; i -= 1) {
|
||||
target[offset + i] = Number(counter & 0xffn);
|
||||
counter >>= 8n;
|
||||
}
|
||||
};
|
||||
|
||||
const readCounter = (source: Uint8Array, offset: number): bigint => {
|
||||
let value = 0n;
|
||||
for (let i = 0; i < IV_COUNTER_BYTES; i += 1) {
|
||||
value = (value << 8n) | BigInt(source[offset + i]);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const createFrameEncryptor = (key: CryptoKey): FrameEncryptor => {
|
||||
const ivPrefix = new Uint8Array(IV_PREFIX_BYTES);
|
||||
globalThis.crypto.getRandomValues(ivPrefix);
|
||||
let counter = 0n;
|
||||
return {
|
||||
async encrypt(plaintext: Uint8Array): Promise<Uint8Array> {
|
||||
if (plaintext.length > MAX_PLAINTEXT_FRAME_BYTES) {
|
||||
throw new RelayCryptoError('plaintext frame exceeds maximum size');
|
||||
}
|
||||
counter += 1n;
|
||||
const iv = new Uint8Array(ENCRYPTED_FRAME_IV_BYTES);
|
||||
iv.set(ivPrefix, 0);
|
||||
writeCounter(iv, IV_PREFIX_BYTES, counter);
|
||||
const ciphertext = new Uint8Array(
|
||||
await subtle.encrypt({ name: 'AES-GCM', iv: iv as BufferSource }, key, plaintext as BufferSource),
|
||||
);
|
||||
const frame = new Uint8Array(ENCRYPTED_FRAME_HEADER_BYTES + ciphertext.length);
|
||||
frame[0] = ENCRYPTED_FRAME_VERSION;
|
||||
frame.set(iv, 1);
|
||||
frame.set(ciphertext, ENCRYPTED_FRAME_HEADER_BYTES);
|
||||
return frame;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Enforces strictly increasing per-direction counters: the relay WS preserves
|
||||
// ordering, so any regression or replay means tampering and must fail closed.
|
||||
export const createFrameDecryptor = (key: CryptoKey): FrameDecryptor => {
|
||||
let lastCounter = 0n;
|
||||
return {
|
||||
async decrypt(frame: Uint8Array): Promise<Uint8Array> {
|
||||
if (frame.length < ENCRYPTED_FRAME_HEADER_BYTES + GCM_TAG_BYTES) {
|
||||
throw new RelayCryptoError('encrypted frame too short');
|
||||
}
|
||||
if (frame[0] !== ENCRYPTED_FRAME_VERSION) {
|
||||
throw new RelayCryptoError('unsupported encrypted frame version');
|
||||
}
|
||||
const iv = frame.slice(1, ENCRYPTED_FRAME_HEADER_BYTES);
|
||||
const counter = readCounter(iv, IV_PREFIX_BYTES);
|
||||
if (counter <= lastCounter) {
|
||||
throw new RelayCryptoError('frame counter regression');
|
||||
}
|
||||
let plaintext: ArrayBuffer;
|
||||
try {
|
||||
plaintext = await subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||
key,
|
||||
frame.slice(ENCRYPTED_FRAME_HEADER_BYTES) as BufferSource,
|
||||
);
|
||||
} catch {
|
||||
throw new RelayCryptoError('frame decryption failed');
|
||||
}
|
||||
lastCounter = counter;
|
||||
return new Uint8Array(plaintext);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
|
||||
export const bytesToBase64Url = (bytes: Uint8Array): string => {
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b0 = bytes[i];
|
||||
const b1 = i + 1 < bytes.length ? bytes[i + 1] : undefined;
|
||||
const b2 = i + 2 < bytes.length ? bytes[i + 2] : undefined;
|
||||
out += BASE64URL_ALPHABET[b0 >> 2];
|
||||
out += BASE64URL_ALPHABET[((b0 & 0x03) << 4) | ((b1 ?? 0) >> 4)];
|
||||
if (b1 !== undefined) out += BASE64URL_ALPHABET[((b1 & 0x0f) << 2) | ((b2 ?? 0) >> 6)];
|
||||
if (b2 !== undefined) out += BASE64URL_ALPHABET[b2 & 0x3f];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const base64UrlToBytes = (value: string): Uint8Array => {
|
||||
if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
|
||||
throw new RelayCryptoError('invalid base64url input');
|
||||
}
|
||||
const out = new Uint8Array(Math.floor((value.length * 3) / 4));
|
||||
let outIndex = 0;
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
for (const char of value) {
|
||||
buffer = (buffer << 6) | BASE64URL_ALPHABET.indexOf(char);
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out[outIndex] = (buffer >> bits) & 0xff;
|
||||
outIndex += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
// openchamber_relay_gate
|
||||
//
|
||||
// Feature gate for the private-relay UI — the surfaces for enabling the relay and
|
||||
// pairing devices through it (Settings → Remote Instances "Relay" section and its
|
||||
// settings-search entry). The relay transport itself is fully implemented and
|
||||
// tested; this flag only hides the UI entry points until the feature is ready for
|
||||
// public release (the connect flow is being unified across LAN / tunnels / relay).
|
||||
//
|
||||
// TO UNBLOCK FOR PUBLIC RELEASE: set RELAY_UI_ENABLED to true. Grep this token —
|
||||
// `openchamber_relay_gate` — to find this file. Nothing else needs to change; the
|
||||
// gated surfaces read this one constant. Also add a CHANGELOG entry then — the
|
||||
// relay's changelog note is intentionally held back while this is off.
|
||||
//
|
||||
// Note: existing saved relay connections keep working regardless (this gates the
|
||||
// UI for ADDING/pairing, not the runtime transport). If you also want to hide the
|
||||
// mobile side of importing a relay link, gate the relay branch in
|
||||
// packages/ui/src/apps/mobileQrScan.ts / mobileConnections.ts on this same flag.
|
||||
// Typed as boolean (not the literal `false`) so gated call sites don't trip
|
||||
// "condition always false" / unreachable-code checks — flipping to true is a
|
||||
// one-word change with no other edits.
|
||||
export const RELAY_UI_ENABLED: boolean = false;
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { exportPublicKeyJwk, generateEcdhKeyPair } from './crypto';
|
||||
import {
|
||||
createClientHandshake,
|
||||
createHostHandshake,
|
||||
type EstablishedChannelCrypto,
|
||||
type HandshakeAction,
|
||||
} from './handshake';
|
||||
import { RelayCloseCode } from './protocol';
|
||||
|
||||
const createHostIdentity = async () => {
|
||||
const keyPair = await generateEcdhKeyPair();
|
||||
return {
|
||||
privateKey: keyPair.privateKey,
|
||||
publicJwk: await exportPublicKeyJwk(keyPair.publicKey),
|
||||
};
|
||||
};
|
||||
|
||||
const expectEstablished = (
|
||||
action: HandshakeAction,
|
||||
): { channel: EstablishedChannelCrypto; replyText?: string } => {
|
||||
if (action.type !== 'established') {
|
||||
throw new Error(`expected established, got ${action.type}`);
|
||||
}
|
||||
return action;
|
||||
};
|
||||
|
||||
const runFullHandshake = async () => {
|
||||
const host = await createHostIdentity();
|
||||
const client = await createClientHandshake(host.publicJwk);
|
||||
const hostMachine = createHostHandshake(host.privateKey);
|
||||
|
||||
const hostResult = expectEstablished(await hostMachine.handleText(client.helloText));
|
||||
expect(hostResult.replyText).toBeDefined();
|
||||
const clientResult = expectEstablished(await client.handleText(hostResult.replyText as string));
|
||||
return { client, hostMachine, clientChannel: clientResult.channel, hostChannel: hostResult.channel };
|
||||
};
|
||||
|
||||
describe('relay E2EE handshake', () => {
|
||||
test('full handshake establishes a working bidirectional channel', async () => {
|
||||
const { clientChannel, hostChannel } = await runFullHandshake();
|
||||
|
||||
const toHost = await clientChannel.encryptor.encrypt(new TextEncoder().encode('ping'));
|
||||
expect(new TextDecoder().decode(await hostChannel.decryptor.decrypt(toHost))).toBe('ping');
|
||||
|
||||
const toClient = await hostChannel.encryptor.encrypt(new TextEncoder().encode('pong'));
|
||||
expect(new TextDecoder().decode(await clientChannel.decryptor.decrypt(toClient))).toBe('pong');
|
||||
});
|
||||
|
||||
test('negotiates batching only when both peers advertise it', async () => {
|
||||
const assertNegotiated = async (
|
||||
clientBatch: boolean | undefined,
|
||||
hostBatch: boolean | undefined,
|
||||
expected: boolean,
|
||||
) => {
|
||||
const host = await createHostIdentity();
|
||||
const client = await createClientHandshake(host.publicJwk, { batch: clientBatch });
|
||||
const hostMachine = createHostHandshake(host.privateKey, { batch: hostBatch });
|
||||
const hostResult = await hostMachine.handleText(client.helloText);
|
||||
if (hostResult.type !== 'established') throw new Error('host did not establish');
|
||||
const clientResult = await client.handleText(hostResult.replyText as string);
|
||||
if (clientResult.type !== 'established') throw new Error('client did not establish');
|
||||
// Symmetric: both sides agree on the same negotiated value.
|
||||
expect(hostResult.batch).toBe(expected);
|
||||
expect(clientResult.batch).toBe(expected);
|
||||
};
|
||||
|
||||
await assertNegotiated(true, true, true);
|
||||
await assertNegotiated(undefined, undefined, true); // default is batch-on
|
||||
await assertNegotiated(false, true, false); // legacy client
|
||||
await assertNegotiated(true, false, false); // legacy host
|
||||
await assertNegotiated(false, false, false); // both legacy
|
||||
});
|
||||
|
||||
test('host re-sends ready for an identical retried hello', async () => {
|
||||
const host = await createHostIdentity();
|
||||
const client = await createClientHandshake(host.publicJwk);
|
||||
const hostMachine = createHostHandshake(host.privateKey);
|
||||
|
||||
const first = expectEstablished(await hostMachine.handleText(client.helloText));
|
||||
const retry = await hostMachine.handleText(client.helloText);
|
||||
expect(retry).toEqual({ type: 'send-text', text: first.replyText as string });
|
||||
});
|
||||
|
||||
test('client ignores a duplicate ready after establishment (host re-answers retried hellos)', async () => {
|
||||
const { client } = await runFullHandshake();
|
||||
const action = await client.handleText(JSON.stringify({ t: 'ready', v: 1 }));
|
||||
expect(action.type).toBe('ignore');
|
||||
});
|
||||
|
||||
test('hello with a different key after establishment fails with rekey mismatch (1008)', async () => {
|
||||
const host = await createHostIdentity();
|
||||
const firstClient = await createClientHandshake(host.publicJwk);
|
||||
const hostMachine = createHostHandshake(host.privateKey);
|
||||
expectEstablished(await hostMachine.handleText(firstClient.helloText));
|
||||
|
||||
const attacker = await createClientHandshake(host.publicJwk);
|
||||
const action = await hostMachine.handleText(attacker.helloText);
|
||||
expect(action.type).toBe('fail');
|
||||
if (action.type === 'fail') {
|
||||
expect(action.closeCode).toBe(RelayCloseCode.RekeyMismatch);
|
||||
}
|
||||
});
|
||||
|
||||
test('plaintext after establishment fails closed (1011) on both sides', async () => {
|
||||
const { client, hostMachine } = await runFullHandshake();
|
||||
|
||||
const hostAction = await hostMachine.handleText('{"anything":"plaintext"}');
|
||||
expect(hostAction.type).toBe('fail');
|
||||
if (hostAction.type === 'fail') {
|
||||
expect(hostAction.closeCode).toBe(RelayCloseCode.ChannelFailure);
|
||||
}
|
||||
|
||||
const clientAction = await client.handleText('{"anything":"plaintext"}');
|
||||
expect(clientAction.type).toBe('fail');
|
||||
if (clientAction.type === 'fail') {
|
||||
expect(clientAction.closeCode).toBe(RelayCloseCode.ChannelFailure);
|
||||
}
|
||||
});
|
||||
|
||||
test('pre-establishment noise is ignored, not fatal', async () => {
|
||||
const host = await createHostIdentity();
|
||||
const client = await createClientHandshake(host.publicJwk);
|
||||
const hostMachine = createHostHandshake(host.privateKey);
|
||||
|
||||
expect((await client.handleText('not json')).type).toBe('ignore');
|
||||
expect((await client.handleText('{"type":"sync","connectionIds":[]}')).type).toBe('ignore');
|
||||
expect((await hostMachine.handleText('not json')).type).toBe('ignore');
|
||||
expect((await hostMachine.handleText(JSON.stringify({ t: 'ready', v: 1 }))).type).toBe('ignore');
|
||||
});
|
||||
|
||||
test('malformed hello fails closed without corrupting host state', async () => {
|
||||
const host = await createHostIdentity();
|
||||
const hostMachine = createHostHandshake(host.privateKey);
|
||||
const badHello = JSON.stringify({
|
||||
t: 'hello',
|
||||
v: 1,
|
||||
clientPubJwk: { kty: 'EC', crv: 'P-256', x: '!!', y: '!!' },
|
||||
nonce: 'AAAA',
|
||||
});
|
||||
const action = await hostMachine.handleText(badHello);
|
||||
expect(action.type).toBe('fail');
|
||||
expect(hostMachine.established).toBe(false);
|
||||
|
||||
// A valid client can still complete against a fresh machine after garbage.
|
||||
const client = await createClientHandshake(host.publicJwk);
|
||||
expectEstablished(await hostMachine.handleText(client.helloText));
|
||||
});
|
||||
|
||||
test('wrong protocol version hello is ignored', async () => {
|
||||
const host = await createHostIdentity();
|
||||
const client = await createClientHandshake(host.publicJwk);
|
||||
const hostMachine = createHostHandshake(host.privateKey);
|
||||
const tampered = JSON.stringify({ ...JSON.parse(client.helloText), v: 99 });
|
||||
expect((await hostMachine.handleText(tampered)).type).toBe('ignore');
|
||||
});
|
||||
|
||||
test('client bound to a different host key derives non-matching channel keys', async () => {
|
||||
const realHost = await createHostIdentity();
|
||||
const otherHost = await createHostIdentity();
|
||||
// Client trusts otherHost's public key, but realHost answers.
|
||||
const client = await createClientHandshake(otherHost.publicJwk);
|
||||
const hostMachine = createHostHandshake(realHost.privateKey);
|
||||
const hostResult = expectEstablished(await hostMachine.handleText(client.helloText));
|
||||
const clientResult = expectEstablished(await client.handleText(hostResult.replyText as string));
|
||||
|
||||
const frame = await hostResult.channel.encryptor.encrypt(new Uint8Array([1, 2, 3]));
|
||||
await expect(clientResult.channel.decryptor.decrypt(frame)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
// E2EE handshake state machines (Layer 2 of the protocol spec).
|
||||
// Transport-agnostic: callers feed inbound frames in and deliver the returned
|
||||
// outbound frames; text frames are plaintext handshake JSON, binary frames are
|
||||
// encrypted traffic. Wire-up to actual WebSockets happens in the host client
|
||||
// (packages/web/server/lib/relay) and the tunnel client (Phase 3).
|
||||
//
|
||||
// Client (initiator): sends `hello` with an ephemeral public key + nonce,
|
||||
// waits for `ready`. Host (responder): waits for `hello`, derives session
|
||||
// keys with its long-lived encryption private key, replies `ready`.
|
||||
//
|
||||
// Fail-closed rules (adopted from the spec):
|
||||
// - a repeated identical `hello` re-sends `ready` (client retry race);
|
||||
// - a `hello` with a DIFFERENT key on an established channel is a rekey
|
||||
// attack -> close 1008, never rekey in place;
|
||||
// - plaintext after `ready`, or any decrypt failure -> close 1011.
|
||||
|
||||
import {
|
||||
createFrameDecryptor,
|
||||
createFrameEncryptor,
|
||||
base64UrlToBytes,
|
||||
bytesToBase64Url,
|
||||
deriveSessionKeys,
|
||||
exportPublicKeyJwk,
|
||||
generateEcdhKeyPair,
|
||||
generateHandshakeNonce,
|
||||
importEcdhPublicKey,
|
||||
publicKeyJwkFingerprint,
|
||||
type FrameDecryptor,
|
||||
type FrameEncryptor,
|
||||
} from './crypto';
|
||||
import {
|
||||
RELAY_PROTOCOL_VERSION,
|
||||
RelayCloseCode,
|
||||
type E2eeHelloMessage,
|
||||
type E2eeReadyMessage,
|
||||
} from './protocol';
|
||||
|
||||
export interface EstablishedChannelCrypto {
|
||||
encryptor: FrameEncryptor;
|
||||
decryptor: FrameDecryptor;
|
||||
}
|
||||
|
||||
export type HandshakeAction =
|
||||
| { type: 'send-text'; text: string }
|
||||
// `replyText`, when present, must be sent to the peer before any encrypted frame.
|
||||
// `batch` is the negotiated frame-batching capability for the session.
|
||||
| { type: 'established'; channel: EstablishedChannelCrypto; batch: boolean; replyText?: string }
|
||||
| { type: 'ignore' }
|
||||
| { type: 'fail'; closeCode: number; reason: string };
|
||||
|
||||
const parseHandshakeMessage = (raw: string): E2eeHelloMessage | E2eeReadyMessage | null => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
const message = parsed as Record<string, unknown>;
|
||||
if (message.v !== RELAY_PROTOCOL_VERSION) return null;
|
||||
// Unknown/missing capability flag = false = legacy behavior.
|
||||
const batch = message.batch === true;
|
||||
if (message.t === 'ready') {
|
||||
return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch };
|
||||
}
|
||||
if (
|
||||
message.t === 'hello' &&
|
||||
typeof message.nonce === 'string' &&
|
||||
typeof message.clientPubJwk === 'object' &&
|
||||
message.clientPubJwk !== null
|
||||
) {
|
||||
return {
|
||||
t: 'hello',
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
clientPubJwk: message.clientPubJwk as JsonWebKey,
|
||||
nonce: message.nonce,
|
||||
batch,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const failClosed = (reason: string): HandshakeAction => ({
|
||||
type: 'fail',
|
||||
closeCode: RelayCloseCode.ChannelFailure,
|
||||
reason,
|
||||
});
|
||||
|
||||
export interface ClientHandshake {
|
||||
/** The `hello` text frame. Send on open and re-send on a retry interval until established. */
|
||||
helloText: string;
|
||||
/** Feed every inbound text frame received before the channel is established. */
|
||||
handleText(raw: string): Promise<HandshakeAction>;
|
||||
readonly established: boolean;
|
||||
}
|
||||
|
||||
export interface ClientHandshakeOptions {
|
||||
/** Advertise frame batching. Default true; set false to force legacy behavior. */
|
||||
batch?: boolean;
|
||||
}
|
||||
|
||||
// hostEncPubJwk comes from the pairing offer (QR / deep link) and is the trust
|
||||
// anchor: only the real host can derive the same session keys.
|
||||
export const createClientHandshake = async (
|
||||
hostEncPubJwk: JsonWebKey,
|
||||
options: ClientHandshakeOptions = {},
|
||||
): Promise<ClientHandshake> => {
|
||||
const localBatch = options.batch !== false;
|
||||
const hostPublicKey = await importEcdhPublicKey(hostEncPubJwk);
|
||||
const ephemeralKeyPair = await generateEcdhKeyPair();
|
||||
const nonce = generateHandshakeNonce();
|
||||
const hello: E2eeHelloMessage = {
|
||||
t: 'hello',
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
clientPubJwk: await exportPublicKeyJwk(ephemeralKeyPair.publicKey),
|
||||
nonce: bytesToBase64Url(nonce),
|
||||
...(localBatch ? { batch: true } : {}),
|
||||
};
|
||||
let established = false;
|
||||
return {
|
||||
helloText: JSON.stringify(hello),
|
||||
get established() {
|
||||
return established;
|
||||
},
|
||||
async handleText(raw: string): Promise<HandshakeAction> {
|
||||
if (established) {
|
||||
// The host answers every retried `hello` with `ready`, so a duplicate
|
||||
// `ready` after establishment is protocol-legal (first-connect latency
|
||||
// exceeding the hello retry interval). Any other plaintext fails closed.
|
||||
const message = parseHandshakeMessage(raw);
|
||||
if (message?.t === 'ready') return { type: 'ignore' };
|
||||
return failClosed('plaintext frame on established channel');
|
||||
}
|
||||
const message = parseHandshakeMessage(raw);
|
||||
if (message?.t !== 'ready') {
|
||||
// Not established yet: tolerate unknown plaintext (relay control noise,
|
||||
// late frames) rather than tearing down a connection that may recover.
|
||||
return { type: 'ignore' };
|
||||
}
|
||||
const keys = await deriveSessionKeys(ephemeralKeyPair.privateKey, hostPublicKey, nonce);
|
||||
established = true;
|
||||
return {
|
||||
type: 'established',
|
||||
// Batching runs only if both peers advertised it.
|
||||
batch: localBatch && message.batch === true,
|
||||
channel: {
|
||||
encryptor: createFrameEncryptor(keys.clientToHost),
|
||||
decryptor: createFrameDecryptor(keys.hostToClient),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export interface HostHandshake {
|
||||
/** Feed every inbound text frame. */
|
||||
handleText(raw: string): Promise<HandshakeAction>;
|
||||
readonly established: boolean;
|
||||
}
|
||||
|
||||
export interface HostHandshakeOptions {
|
||||
/** Support frame batching. Default true; set false to force legacy behavior. */
|
||||
batch?: boolean;
|
||||
}
|
||||
|
||||
export const createHostHandshake = (
|
||||
hostEncPrivateKey: CryptoKey,
|
||||
options: HostHandshakeOptions = {},
|
||||
): HostHandshake => {
|
||||
const localBatch = options.batch !== false;
|
||||
let established = false;
|
||||
let acceptedClientKeyFingerprint: string | null = null;
|
||||
let readyText: string | null = null;
|
||||
let negotiatedBatch = false;
|
||||
return {
|
||||
get established() {
|
||||
return established;
|
||||
},
|
||||
async handleText(raw: string): Promise<HandshakeAction> {
|
||||
const message = parseHandshakeMessage(raw);
|
||||
if (message?.t !== 'hello') {
|
||||
if (established) {
|
||||
return failClosed('plaintext frame on established channel');
|
||||
}
|
||||
return { type: 'ignore' };
|
||||
}
|
||||
const fingerprint = publicKeyJwkFingerprint(message.clientPubJwk);
|
||||
if (acceptedClientKeyFingerprint !== null) {
|
||||
if (fingerprint === acceptedClientKeyFingerprint && readyText !== null) {
|
||||
// Client retried `hello` before our `ready` arrived — answer again.
|
||||
return { type: 'send-text', text: readyText };
|
||||
}
|
||||
return {
|
||||
type: 'fail',
|
||||
closeCode: RelayCloseCode.RekeyMismatch,
|
||||
reason: 'rekey mismatch',
|
||||
};
|
||||
}
|
||||
let clientPublicKey: CryptoKey;
|
||||
let nonce: Uint8Array;
|
||||
try {
|
||||
clientPublicKey = await importEcdhPublicKey(message.clientPubJwk);
|
||||
nonce = base64UrlToBytes(message.nonce);
|
||||
} catch {
|
||||
return failClosed('malformed hello');
|
||||
}
|
||||
let keys;
|
||||
try {
|
||||
keys = await deriveSessionKeys(hostEncPrivateKey, clientPublicKey, nonce);
|
||||
} catch {
|
||||
return failClosed('key derivation failed');
|
||||
}
|
||||
acceptedClientKeyFingerprint = fingerprint;
|
||||
// Batching runs only if both peers advertised it.
|
||||
negotiatedBatch = localBatch && message.batch === true;
|
||||
const ready: E2eeReadyMessage = {
|
||||
t: 'ready',
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
...(negotiatedBatch ? { batch: true } : {}),
|
||||
};
|
||||
readyText = JSON.stringify(ready);
|
||||
established = true;
|
||||
return {
|
||||
type: 'established',
|
||||
batch: negotiatedBatch,
|
||||
replyText: readyText,
|
||||
channel: {
|
||||
encryptor: createFrameEncryptor(keys.hostToClient),
|
||||
decryptor: createFrameDecryptor(keys.clientToHost),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { buildRelayOfferUrl, parseRelayOfferUrl, redactOffer } from './offer';
|
||||
import type { RelayOfferV1 } from './protocol';
|
||||
|
||||
const baseOffer: RelayOfferV1 = {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
relayUrl: 'wss://relay.example.com/host',
|
||||
serverId: 'srv_0123456789abcdef',
|
||||
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x-coordinate-b64u', y: 'y-coordinate-b64u' },
|
||||
};
|
||||
|
||||
const fullOffer: RelayOfferV1 = {
|
||||
...baseOffer,
|
||||
label: 'My Mac',
|
||||
token: 'oc_client_secret_token_value',
|
||||
grant: 'grant-value',
|
||||
};
|
||||
|
||||
describe('buildRelayOfferUrl / parseRelayOfferUrl', () => {
|
||||
test('round-trips a minimal offer', () => {
|
||||
expect(parseRelayOfferUrl(buildRelayOfferUrl(baseOffer))).toEqual(baseOffer);
|
||||
});
|
||||
|
||||
test('round-trips a full offer with optional fields', () => {
|
||||
expect(parseRelayOfferUrl(buildRelayOfferUrl(fullOffer))).toEqual(fullOffer);
|
||||
});
|
||||
|
||||
test('URL has the expected shape', () => {
|
||||
const url = buildRelayOfferUrl(baseOffer);
|
||||
expect(url.startsWith('openchamber://connect?v=1&mode=relay#offer=')).toBe(true);
|
||||
});
|
||||
|
||||
test('token appears only in the fragment, never in the query string', () => {
|
||||
const url = buildRelayOfferUrl(fullOffer);
|
||||
const [beforeFragment, fragment] = url.split('#');
|
||||
expect(beforeFragment).toBe('openchamber://connect?v=1&mode=relay');
|
||||
expect(beforeFragment.includes(fullOffer.token as string)).toBe(false);
|
||||
expect(fragment.startsWith('offer=')).toBe(true);
|
||||
// Token round-trips through the fragment payload.
|
||||
expect(parseRelayOfferUrl(url)?.token).toBe(fullOffer.token as string);
|
||||
});
|
||||
|
||||
const encodeOffer = (value: unknown): string => {
|
||||
const json = JSON.stringify(value);
|
||||
const b64 = Buffer.from(json, 'utf8').toString('base64url');
|
||||
return `openchamber://connect?v=1&mode=relay#offer=${b64}`;
|
||||
};
|
||||
|
||||
test('rejects wrong scheme, host, version, and mode', () => {
|
||||
const url = buildRelayOfferUrl(baseOffer);
|
||||
expect(parseRelayOfferUrl(url.replace('openchamber://', 'https://'))).toBeNull();
|
||||
expect(parseRelayOfferUrl(url.replace('//connect', '//pair'))).toBeNull();
|
||||
expect(parseRelayOfferUrl(url.replace('v=1', 'v=2'))).toBeNull();
|
||||
expect(parseRelayOfferUrl(url.replace('mode=relay', 'mode=lan'))).toBeNull();
|
||||
expect(parseRelayOfferUrl('not a url')).toBeNull();
|
||||
expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay')).toBeNull();
|
||||
expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay#offer=')).toBeNull();
|
||||
expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay#offer=!!not-b64url!!')).toBeNull();
|
||||
});
|
||||
|
||||
const without = (key: keyof RelayOfferV1): Record<string, unknown> => {
|
||||
const clone: Record<string, unknown> = { ...fullOffer };
|
||||
delete clone[key];
|
||||
return clone;
|
||||
};
|
||||
|
||||
test('rejects wholly when any required field is missing or malformed', () => {
|
||||
const cases: unknown[] = [
|
||||
{ ...fullOffer, v: 2 },
|
||||
without('v'),
|
||||
{ ...fullOffer, mode: 'direct' },
|
||||
without('mode'),
|
||||
without('relayUrl'),
|
||||
{ ...fullOffer, relayUrl: '' },
|
||||
{ ...fullOffer, relayUrl: 'not-a-url' },
|
||||
{ ...fullOffer, relayUrl: 'ftp://relay.example.com' },
|
||||
without('serverId'),
|
||||
{ ...fullOffer, serverId: '' },
|
||||
{ ...fullOffer, serverId: 42 },
|
||||
without('hostEncPubJwk'),
|
||||
{ ...fullOffer, hostEncPubJwk: { ...baseOffer.hostEncPubJwk, kty: 'RSA' } },
|
||||
{ ...fullOffer, hostEncPubJwk: { ...baseOffer.hostEncPubJwk, crv: 'P-384' } },
|
||||
{ ...fullOffer, hostEncPubJwk: { kty: 'EC', crv: 'P-256', y: 'y' } },
|
||||
{ ...fullOffer, hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x' } },
|
||||
{ ...fullOffer, hostEncPubJwk: 'jwk' },
|
||||
{ ...fullOffer, label: '' },
|
||||
{ ...fullOffer, token: '' },
|
||||
{ ...fullOffer, token: 123 },
|
||||
{ ...fullOffer, grant: '' },
|
||||
['array'],
|
||||
];
|
||||
for (const payload of cases) {
|
||||
expect(parseRelayOfferUrl(encodeOffer(payload))).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('parse strips unknown fields', () => {
|
||||
const parsed = parseRelayOfferUrl(encodeOffer({ ...baseOffer, extra: 'field' }));
|
||||
expect(parsed).toEqual(baseOffer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactOffer', () => {
|
||||
test('masks token, grant, and host public key coordinates', () => {
|
||||
const redacted = redactOffer(fullOffer);
|
||||
expect(redacted.token).toBe('[redacted]');
|
||||
expect(redacted.grant).toBe('[redacted]');
|
||||
expect(redacted.hostEncPubJwk.x).toBe('[redacted]');
|
||||
expect(redacted.hostEncPubJwk.y).toBe('[redacted]');
|
||||
const serialized = JSON.stringify(redacted);
|
||||
expect(serialized.includes(fullOffer.token as string)).toBe(false);
|
||||
expect(serialized.includes(baseOffer.hostEncPubJwk.x as string)).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps non-secret fields and omits absent optionals', () => {
|
||||
const redacted = redactOffer(baseOffer);
|
||||
expect(redacted.relayUrl).toBe(baseOffer.relayUrl);
|
||||
expect(redacted.serverId).toBe(baseOffer.serverId);
|
||||
expect('token' in redacted).toBe(false);
|
||||
expect('grant' in redacted).toBe(false);
|
||||
});
|
||||
|
||||
test('does not mutate the input offer', () => {
|
||||
const copy = structuredClone(fullOffer);
|
||||
redactOffer(fullOffer);
|
||||
expect(fullOffer).toEqual(copy);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// Relay pairing offer URL codec (spec §Pairing payload).
|
||||
// The offer JSON travels ONLY in the URL fragment so secrets (token) never
|
||||
// reach servers, logs, or referrer headers via the query string.
|
||||
// Shared by: settings UI (build), mobile scan (parse), desktop host import
|
||||
// (parse), CLI (build).
|
||||
|
||||
import { base64UrlToBytes, bytesToBase64Url } from './crypto';
|
||||
import type { RelayOfferV1 } from './protocol';
|
||||
|
||||
const OFFER_SCHEME = 'openchamber:';
|
||||
const OFFER_HOST = 'connect';
|
||||
const OFFER_FRAGMENT_KEY = 'offer=';
|
||||
|
||||
const REDACTED = '[redacted]';
|
||||
|
||||
export const buildRelayOfferUrl = (offer: RelayOfferV1): string => {
|
||||
const json = JSON.stringify(offer);
|
||||
const encoded = bytesToBase64Url(new TextEncoder().encode(json));
|
||||
return `openchamber://connect?v=1&mode=relay#${OFFER_FRAGMENT_KEY}${encoded}`;
|
||||
};
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0;
|
||||
|
||||
const isValidHttpOrWsUrl = (value: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === 'wss:' || parsed.protocol === 'ws:' || parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const parsePublicKeyJwk = (value: unknown): JsonWebKey | null => {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
||||
const jwk = value as Record<string, unknown>;
|
||||
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') return null;
|
||||
if (!isNonEmptyString(jwk.x) || !isNonEmptyString(jwk.y)) return null;
|
||||
return { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y };
|
||||
};
|
||||
|
||||
// Strict parse: every required field is validated; any malformed or missing
|
||||
// field rejects the whole offer (returns null, never a partial object).
|
||||
export const parseRelayOfferUrl = (url: string): RelayOfferV1 | null => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== OFFER_SCHEME) return null;
|
||||
// Custom-scheme URLs may surface the authority as hostname or pathname
|
||||
// depending on the runtime's parser.
|
||||
const authority = parsed.hostname || parsed.pathname.replace(/^\/*/, '').split(/[/?#]/)[0];
|
||||
if (authority !== OFFER_HOST) return null;
|
||||
if (parsed.searchParams.get('v') !== '1') return null;
|
||||
if (parsed.searchParams.get('mode') !== 'relay') return null;
|
||||
|
||||
const fragment = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash;
|
||||
if (!fragment.startsWith(OFFER_FRAGMENT_KEY)) return null;
|
||||
const encoded = fragment.slice(OFFER_FRAGMENT_KEY.length);
|
||||
if (!encoded) return null;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(new TextDecoder().decode(base64UrlToBytes(encoded)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
|
||||
const candidate = raw as Record<string, unknown>;
|
||||
|
||||
if (candidate.v !== 1) return null;
|
||||
if (candidate.mode !== 'relay') return null;
|
||||
if (!isNonEmptyString(candidate.relayUrl) || !isValidHttpOrWsUrl(candidate.relayUrl)) return null;
|
||||
if (!isNonEmptyString(candidate.serverId)) return null;
|
||||
const hostEncPubJwk = parsePublicKeyJwk(candidate.hostEncPubJwk);
|
||||
if (!hostEncPubJwk) return null;
|
||||
if (candidate.label !== undefined && !isNonEmptyString(candidate.label)) return null;
|
||||
if (candidate.token !== undefined && !isNonEmptyString(candidate.token)) return null;
|
||||
if (candidate.grant !== undefined && !isNonEmptyString(candidate.grant)) return null;
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
relayUrl: candidate.relayUrl,
|
||||
serverId: candidate.serverId,
|
||||
hostEncPubJwk,
|
||||
...(candidate.label !== undefined ? { label: candidate.label } : {}),
|
||||
...(candidate.token !== undefined ? { token: candidate.token } : {}),
|
||||
...(candidate.grant !== undefined ? { grant: candidate.grant } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
// Safe-for-logging copy: masks the access token and the host public key
|
||||
// coordinates. Never log a raw offer.
|
||||
export const redactOffer = (offer: RelayOfferV1): RelayOfferV1 => ({
|
||||
...offer,
|
||||
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: REDACTED, y: REDACTED },
|
||||
...(offer.token !== undefined ? { token: REDACTED } : {}),
|
||||
...(offer.grant !== undefined ? { grant: REDACTED } : {}),
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// OpenChamber private relay protocol constants and shared types.
|
||||
// Spec: .opencode/plans/private-relay/01-protocol-spec.md
|
||||
// Three layers: relay routing (Layer 1), E2EE channel (Layer 2), tunnel mux (Layer 3).
|
||||
// This module is isomorphic: browser, Node, and Cloudflare Workers.
|
||||
|
||||
export const RELAY_PROTOCOL_VERSION = 1;
|
||||
|
||||
export const RELAY_HKDF_INFO = 'openchamber-relay-v1';
|
||||
|
||||
// Encrypted frame layout: [1 byte version][12 byte IV][ciphertext + 16 byte GCM tag].
|
||||
export const ENCRYPTED_FRAME_VERSION = 1;
|
||||
export const ENCRYPTED_FRAME_IV_BYTES = 12;
|
||||
export const ENCRYPTED_FRAME_HEADER_BYTES = 1 + ENCRYPTED_FRAME_IV_BYTES;
|
||||
|
||||
// Max plaintext per encrypted frame. Keeps relay-forwarded WS messages far
|
||||
// below Cloudflare's 1 MiB cap even after GCM tag + header overhead.
|
||||
export const MAX_PLAINTEXT_FRAME_BYTES = 64 * 1024;
|
||||
|
||||
// Tunnel frame layout: [1 byte frameType(+fragment flag)][4 byte BE streamId][payload].
|
||||
export const TUNNEL_FRAME_HEADER_BYTES = 5;
|
||||
export const TUNNEL_FRAGMENT_FLAG = 0x80;
|
||||
|
||||
// Batch envelope (Layer 2 plaintext container, used only when both peers
|
||||
// negotiated `batch`). Plaintext = [1 byte container tag] then either the raw
|
||||
// tunnel frame (tag 0x00) or repeated [4 byte BE length][frame] (tag 0x01).
|
||||
// See tunnel-codec encodeFrameBatch/decodeFrameBatch.
|
||||
export const BATCH_CONTAINER_TAG_SINGLE = 0x00;
|
||||
export const BATCH_CONTAINER_TAG_BATCH = 0x01;
|
||||
export const BATCH_FRAME_LENGTH_BYTES = 4;
|
||||
// Worst-case per-frame envelope overhead inside a batch (tag + length prefix).
|
||||
// Reserved from the tunnel payload budget so any single frame — even at the
|
||||
// maximum size — still fits inside one 64 KiB encrypted plaintext once wrapped.
|
||||
export const BATCH_ENVELOPE_RESERVED_BYTES = 1 + BATCH_FRAME_LENGTH_BYTES;
|
||||
export const MAX_TUNNEL_PAYLOAD_BYTES =
|
||||
MAX_PLAINTEXT_FRAME_BYTES - TUNNEL_FRAME_HEADER_BYTES - BATCH_ENVELOPE_RESERVED_BYTES;
|
||||
|
||||
export const TunnelFrameType = {
|
||||
HttpRequest: 1,
|
||||
HttpBody: 2,
|
||||
HttpResponse: 3,
|
||||
StreamEnd: 4,
|
||||
StreamAbort: 5,
|
||||
WsOpen: 6,
|
||||
WsOpened: 7,
|
||||
WsText: 8,
|
||||
WsBinary: 9,
|
||||
WsClose: 10,
|
||||
Ping: 11,
|
||||
Pong: 12,
|
||||
} as const;
|
||||
|
||||
export type TunnelFrameTypeValue = (typeof TunnelFrameType)[keyof typeof TunnelFrameType];
|
||||
|
||||
const TUNNEL_FRAME_TYPE_VALUES = new Set<number>(Object.values(TunnelFrameType));
|
||||
|
||||
export const isTunnelFrameType = (value: number): value is TunnelFrameTypeValue =>
|
||||
TUNNEL_FRAME_TYPE_VALUES.has(value);
|
||||
|
||||
export interface TunnelHttpRequestPayload {
|
||||
method: string;
|
||||
path: string;
|
||||
query: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TunnelHttpResponsePayload {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TunnelStreamAbortPayload {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface TunnelWsOpenPayload {
|
||||
path: string;
|
||||
query: string;
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
export interface TunnelWsOpenedPayload {
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
export interface TunnelWsClosePayload {
|
||||
code: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// Layer 2 handshake messages (plaintext JSON text frames, before encryption starts).
|
||||
export interface E2eeHelloMessage {
|
||||
t: 'hello';
|
||||
v: typeof RELAY_PROTOCOL_VERSION;
|
||||
clientPubJwk: JsonWebKey;
|
||||
nonce: string; // base64url, 16 bytes
|
||||
// Capability advertisement: the client can pack multiple tunnel frames into
|
||||
// one encrypted WS message. Missing/false = legacy (one frame per message).
|
||||
batch?: boolean;
|
||||
}
|
||||
|
||||
export interface E2eeReadyMessage {
|
||||
t: 'ready';
|
||||
v: typeof RELAY_PROTOCOL_VERSION;
|
||||
// Host echoes `batch: true` only when it also supports batching AND the client
|
||||
// advertised it. Batching is enabled for the session only if both agree.
|
||||
batch?: boolean;
|
||||
}
|
||||
|
||||
// Layer 1 control messages (relay <-> host control socket).
|
||||
export type RelayControlMessage =
|
||||
| { type: 'sync'; connectionIds: string[] }
|
||||
| { type: 'connected'; connectionId: string }
|
||||
| { type: 'disconnected'; connectionId: string }
|
||||
| { type: 'limit'; reason: string };
|
||||
|
||||
// Relay-assigned WebSocket close codes.
|
||||
export const RelayCloseCode = {
|
||||
ControlReplaced: 4001,
|
||||
DuplicateClient: 4002,
|
||||
StuckControlReset: 4003,
|
||||
HostUnavailable: 4008,
|
||||
AuthFailed: 4010,
|
||||
LimitExceeded: 4029,
|
||||
HostWentAway: 1012,
|
||||
RekeyMismatch: 1008,
|
||||
ChannelFailure: 1011,
|
||||
} as const;
|
||||
|
||||
// Pairing payload carried in QR / deep-link URL fragments only.
|
||||
export interface RelayOfferV1 {
|
||||
v: 1;
|
||||
mode: 'relay';
|
||||
relayUrl: string;
|
||||
serverId: string;
|
||||
hostEncPubJwk: JsonWebKey;
|
||||
label?: string;
|
||||
token?: string;
|
||||
grant?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Opens a runtime WebSocket the right way for the active runtime: through the
|
||||
// relay tunnel when relay mode is active, or a native browser WebSocket
|
||||
// otherwise (wrapped to the same shape). Every runtime WS consumer — the event
|
||||
// pipeline, dictation, terminal — must go through here so relay mode carries
|
||||
// ALL socket traffic, not just the main event stream. A raw `new WebSocket(url)`
|
||||
// against a relay-mode runtime fails: the resolver yields a tunnel-virtual URL
|
||||
// (or a capacitor:// origin) that the platform WebSocket rejects with
|
||||
// "The string did not match the expected pattern".
|
||||
|
||||
import { getActiveRelayTunnel } from './runtime-tunnel';
|
||||
import { wsUrlToTunnelPath } from './tunnel-payloads';
|
||||
import { wrapBrowserWebSocket, type RelayTunnelWebSocket } from './tunnel-client';
|
||||
|
||||
export const openRuntimeWebSocket = (url: string, protocols?: string[]): RelayTunnelWebSocket => {
|
||||
const relay = getActiveRelayTunnel();
|
||||
if (relay) {
|
||||
return relay.openWebSocket(wsUrlToTunnelPath(url), protocols);
|
||||
}
|
||||
return wrapBrowserWebSocket(protocols ? new WebSocket(url, protocols) : new WebSocket(url));
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
// Module-level singleton holding the active relay tunnel client, if the runtime
|
||||
// is in relay mode. Kept in its own module so runtime-switch, runtime-fetch,
|
||||
// runtime-url, and the event pipeline can all read it without an import cycle
|
||||
// (runtime-switch <-> runtime-url).
|
||||
|
||||
import { createRelayTunnelClient, type RelayTunnelClient } from './tunnel-client';
|
||||
|
||||
export interface RelayRuntimeDescriptor {
|
||||
relayUrl: string;
|
||||
serverId: string;
|
||||
hostEncPubJwk: JsonWebKey;
|
||||
grant?: string;
|
||||
}
|
||||
|
||||
let activeTunnel: RelayTunnelClient | null = null;
|
||||
let activeDescriptor: RelayRuntimeDescriptor | null = null;
|
||||
|
||||
const descriptorsEqual = (a: RelayRuntimeDescriptor, b: RelayRuntimeDescriptor): boolean =>
|
||||
a.relayUrl === b.relayUrl &&
|
||||
a.serverId === b.serverId &&
|
||||
a.grant === b.grant &&
|
||||
JSON.stringify(a.hostEncPubJwk) === JSON.stringify(b.hostEncPubJwk);
|
||||
|
||||
export const getActiveRelayTunnel = (): RelayTunnelClient | null => activeTunnel;
|
||||
|
||||
export const isRelayModeActive = (): boolean => activeTunnel !== null;
|
||||
|
||||
/**
|
||||
* Activates relay mode with the given descriptor, replacing any previous tunnel.
|
||||
* Reuses the existing client when the descriptor is unchanged so a redundant
|
||||
* runtime switch does not tear down a live tunnel.
|
||||
*/
|
||||
export const activateRelayTunnel = (descriptor: RelayRuntimeDescriptor): RelayTunnelClient => {
|
||||
if (activeTunnel && activeDescriptor && descriptorsEqual(activeDescriptor, descriptor)) {
|
||||
return activeTunnel;
|
||||
}
|
||||
activeTunnel?.close();
|
||||
activeDescriptor = descriptor;
|
||||
activeTunnel = createRelayTunnelClient(descriptor);
|
||||
return activeTunnel;
|
||||
};
|
||||
|
||||
export const deactivateRelayTunnel = (): void => {
|
||||
activeTunnel?.close();
|
||||
activeTunnel = null;
|
||||
activeDescriptor = null;
|
||||
};
|
||||
@@ -0,0 +1,576 @@
|
||||
// Unit tests for the relay tunnel client against an in-memory wire pair whose
|
||||
// responder side is built from the SAME protocol modules (createHostHandshake +
|
||||
// the tunnel codec). No network, no real WebSocket.
|
||||
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
exportPublicKeyJwk,
|
||||
generateEcdhKeyPair,
|
||||
type FrameDecryptor,
|
||||
type FrameEncryptor,
|
||||
} from './crypto';
|
||||
import { createHostHandshake } from './handshake';
|
||||
import { TunnelFrameType } from './protocol';
|
||||
import {
|
||||
createFragmentAssembler,
|
||||
decodeFrameBatch,
|
||||
decodeJsonPayload,
|
||||
decodeTunnelFrame,
|
||||
encodeFrameBatch,
|
||||
encodeJsonPayload,
|
||||
encodeTunnelFrame,
|
||||
type TunnelFrame,
|
||||
} from './tunnel-codec';
|
||||
import {
|
||||
createRelayTunnelClient,
|
||||
type RelayTunnelClient,
|
||||
type TunnelWireSocket,
|
||||
} from './tunnel-client';
|
||||
|
||||
const WS_OPEN = 1;
|
||||
const WS_CLOSED = 3;
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const isWsOpenPayload = (
|
||||
value: unknown,
|
||||
): value is { path: string; query: string; protocols?: string[] } =>
|
||||
typeof value === 'object' && value !== null && typeof (value as { path?: unknown }).path === 'string';
|
||||
|
||||
const isHttpRequestPayload = (
|
||||
value: unknown,
|
||||
): value is { method: string; path: string; query: string; headers: Record<string, string> } =>
|
||||
typeof value === 'object' && value !== null && typeof (value as { path?: unknown }).path === 'string';
|
||||
|
||||
class FakeEndpoint implements TunnelWireSocket {
|
||||
readyState = WS_OPEN;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null;
|
||||
onclose: ((event: { code: number; reason: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
peer: FakeEndpoint | null = null;
|
||||
closed = false;
|
||||
// Count binary (encrypted) WS messages that cross this endpoint's send path —
|
||||
// the billable unit the batching optimization is designed to reduce.
|
||||
binarySent = 0;
|
||||
|
||||
send(data: string | ArrayBuffer | Uint8Array): void {
|
||||
if (this.closed) return;
|
||||
if (typeof data !== 'string') this.binarySent += 1;
|
||||
const peer = this.peer;
|
||||
if (!peer) return;
|
||||
// Copy bytes so the receiver can't observe later mutation.
|
||||
const payload = typeof data === 'string' ? data : data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0));
|
||||
queueMicrotask(() => {
|
||||
if (peer.closed) return;
|
||||
peer.onmessage?.({ data: payload });
|
||||
});
|
||||
}
|
||||
|
||||
close(code = 1000, reason = ''): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.readyState = WS_CLOSED;
|
||||
const peer = this.peer;
|
||||
queueMicrotask(() => this.onclose?.({ code, reason }));
|
||||
if (peer && !peer.closed) {
|
||||
peer.closed = true;
|
||||
peer.readyState = WS_CLOSED;
|
||||
queueMicrotask(() => peer.onclose?.({ code, reason }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MiniHostOptions = {
|
||||
silent?: boolean;
|
||||
onConnect?: () => void;
|
||||
// Delay handling of the first inbound text frame: with a delay longer than
|
||||
// the client's helloRetryMs this reproduces the first-connect race where the
|
||||
// client retries `hello` and the host answers every retry with `ready`.
|
||||
firstHelloDelayMs?: number;
|
||||
// Advertise batching from the host (default true = matches production).
|
||||
batch?: boolean;
|
||||
// Records every tunnel frame the host received, in arrival order.
|
||||
recordFrame?: (frame: TunnelFrame) => void;
|
||||
};
|
||||
|
||||
// A minimal host responder wired to one endpoint. Answers a few routes so the
|
||||
// client's HTTP/WS/abort paths can be exercised end to end.
|
||||
const attachMiniHost = (endpoint: FakeEndpoint, hostPrivateKey: CryptoKey, options: MiniHostOptions = {}): void => {
|
||||
const handshake = createHostHandshake(hostPrivateKey, { batch: options.batch });
|
||||
let encryptor: FrameEncryptor | null = null;
|
||||
let decryptor: FrameDecryptor | null = null;
|
||||
let batchNegotiated = false;
|
||||
const assembler = createFragmentAssembler();
|
||||
const httpBodies = new Map<number, Uint8Array[]>();
|
||||
const aborted = new Set<number>();
|
||||
let sendChain: Promise<void> = Promise.resolve();
|
||||
let recvChain: Promise<void> = Promise.resolve();
|
||||
|
||||
const sendFrame = (frame: Uint8Array): void => {
|
||||
sendChain = sendChain.then(async () => {
|
||||
if (!encryptor || endpoint.closed) return;
|
||||
// When batching is negotiated the client always expects a container tag,
|
||||
// so wrap even single frames (tag 0x00). The host here does not coalesce.
|
||||
const plaintext = batchNegotiated ? encodeFrameBatch([frame]) : frame;
|
||||
endpoint.send(await encryptor.encrypt(plaintext));
|
||||
});
|
||||
};
|
||||
|
||||
const respondJson = (streamId: number, status: number, body: unknown): void => {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpResponse, streamId, encodeJsonPayload({ status, headers: { 'content-type': 'application/json' } })));
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, textEncoder.encode(JSON.stringify(body))));
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0)));
|
||||
};
|
||||
|
||||
const handleTunnelFrame = (frame: TunnelFrame): void => {
|
||||
options.recordFrame?.(frame);
|
||||
if (options.silent) return;
|
||||
if (frame.frameType === TunnelFrameType.Ping) {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.Pong, frame.streamId, new Uint8Array(0)));
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.HttpRequest) {
|
||||
const req = decodeJsonPayload(frame.payload, isHttpRequestPayload);
|
||||
httpBodies.set(frame.streamId, []);
|
||||
(endpoint as FakeEndpoint & { pendingPath?: Map<number, string> }).pendingPath ??= new Map();
|
||||
(endpoint as FakeEndpoint & { pendingPath: Map<number, string> }).pendingPath.set(frame.streamId, req.path);
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.HttpBody) {
|
||||
httpBodies.get(frame.streamId)?.push(frame.payload);
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.StreamAbort) {
|
||||
aborted.add(frame.streamId);
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.StreamEnd) {
|
||||
const paths = (endpoint as FakeEndpoint & { pendingPath?: Map<number, string> }).pendingPath;
|
||||
const path = paths?.get(frame.streamId) ?? '';
|
||||
const bodyChunks = httpBodies.get(frame.streamId) ?? [];
|
||||
const total = bodyChunks.reduce((sum, c) => sum + c.length, 0);
|
||||
const body = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const c of bodyChunks) {
|
||||
body.set(c, off);
|
||||
off += c.length;
|
||||
}
|
||||
const streamId = frame.streamId;
|
||||
if (path === '/health') {
|
||||
respondJson(streamId, 200, { ok: true });
|
||||
} else if (path === '/echo-body') {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpResponse, streamId, encodeJsonPayload({ status: 200, headers: {} })));
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, body));
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0)));
|
||||
} else if (path === '/stream') {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpResponse, streamId, encodeJsonPayload({ status: 200, headers: {} })));
|
||||
const emit = (index: number): void => {
|
||||
if (aborted.has(streamId)) return;
|
||||
if (index >= 3) {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0)));
|
||||
return;
|
||||
}
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, textEncoder.encode(`chunk${index};`)));
|
||||
setTimeout(() => emit(index + 1), 10);
|
||||
};
|
||||
emit(0);
|
||||
} else if (path === '/never-ends') {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpResponse, streamId, encodeJsonPayload({ status: 200, headers: {} })));
|
||||
const pump = (): void => {
|
||||
if (aborted.has(streamId) || endpoint.closed) return;
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, textEncoder.encode('tick;')));
|
||||
setTimeout(pump, 10);
|
||||
};
|
||||
pump();
|
||||
} else {
|
||||
respondJson(streamId, 404, { error: 'not found' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.WsOpen) {
|
||||
const open = decodeJsonPayload(frame.payload, isWsOpenPayload);
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.WsOpened, frame.streamId, encodeJsonPayload(open.protocols?.length ? { protocol: open.protocols[0] } : {})));
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.WsText) {
|
||||
const complete = assembler.push(frame);
|
||||
if (!complete) return;
|
||||
const text = textDecoder.decode(complete);
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.WsText, frame.streamId, textEncoder.encode(`echo:${text}`)));
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.WsClose) {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.WsClose, frame.streamId, frame.payload));
|
||||
}
|
||||
};
|
||||
|
||||
let firstHelloDelayed = false;
|
||||
endpoint.onmessage = (event) => {
|
||||
const data = event.data;
|
||||
recvChain = recvChain.then(async () => {
|
||||
if (typeof data === 'string') {
|
||||
if (options.firstHelloDelayMs && !firstHelloDelayed) {
|
||||
firstHelloDelayed = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, options.firstHelloDelayMs));
|
||||
}
|
||||
const action = await handshake.handleText(data);
|
||||
if (action.type === 'established') {
|
||||
encryptor = action.channel.encryptor;
|
||||
decryptor = action.channel.decryptor;
|
||||
batchNegotiated = action.batch;
|
||||
if (action.replyText) endpoint.send(action.replyText);
|
||||
options.onConnect?.();
|
||||
} else if (action.type === 'send-text' && action.text) {
|
||||
endpoint.send(action.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!decryptor) return;
|
||||
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
|
||||
const plaintext = await decryptor.decrypt(bytes);
|
||||
const frames = batchNegotiated ? decodeFrameBatch(plaintext) : [plaintext];
|
||||
for (const frame of frames) handleTunnelFrame(decodeTunnelFrame(frame));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const setupClient = async (
|
||||
hostOptions: MiniHostOptions = {},
|
||||
clientOverrides: Partial<Parameters<typeof createRelayTunnelClient>[0]> = {},
|
||||
): Promise<{
|
||||
client: RelayTunnelClient;
|
||||
connectionCount: () => number;
|
||||
killWire: () => void;
|
||||
sendTextToClient: (text: string) => void;
|
||||
clientBinaryCount: () => number;
|
||||
}> => {
|
||||
const hostKeyPair = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeyPair.publicKey);
|
||||
let count = 0;
|
||||
let lastClientEndpoint: FakeEndpoint | null = null;
|
||||
let lastHostEndpoint: FakeEndpoint | null = null;
|
||||
const client = createRelayTunnelClient({
|
||||
relayUrl: 'wss://relay.test/ws',
|
||||
serverId: 'server-1',
|
||||
hostEncPubJwk: hostPubJwk,
|
||||
helloRetryMs: 20,
|
||||
pingIntervalMs: 40,
|
||||
pingTimeoutMs: 120,
|
||||
reconnectBaseDelayMs: 20,
|
||||
reconnectMaxDelayMs: 80,
|
||||
...clientOverrides,
|
||||
createWireSocket: () => {
|
||||
count += 1;
|
||||
const clientEndpoint = new FakeEndpoint();
|
||||
const hostEndpoint = new FakeEndpoint();
|
||||
clientEndpoint.peer = hostEndpoint;
|
||||
hostEndpoint.peer = clientEndpoint;
|
||||
lastClientEndpoint = clientEndpoint;
|
||||
lastHostEndpoint = hostEndpoint;
|
||||
attachMiniHost(hostEndpoint, hostKeyPair.privateKey, hostOptions);
|
||||
queueMicrotask(() => clientEndpoint.onopen?.());
|
||||
return clientEndpoint;
|
||||
},
|
||||
});
|
||||
return {
|
||||
client,
|
||||
connectionCount: () => count,
|
||||
killWire: () => lastClientEndpoint?.close(1006, 'killed'),
|
||||
sendTextToClient: (text: string) => lastHostEndpoint?.send(text),
|
||||
clientBinaryCount: () => lastClientEndpoint?.binarySent ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
let openClients: RelayTunnelClient[] = [];
|
||||
afterEach(() => {
|
||||
for (const client of openClients) client.close();
|
||||
openClients = [];
|
||||
});
|
||||
|
||||
const track = (client: RelayTunnelClient): RelayTunnelClient => {
|
||||
openClients.push(client);
|
||||
return client;
|
||||
};
|
||||
|
||||
describe('createRelayTunnelClient', () => {
|
||||
test('performs concurrent fetches over one tunnel', async () => {
|
||||
const { client } = await setupClient();
|
||||
track(client);
|
||||
const [a, b, c] = await Promise.all([
|
||||
client.fetch('/health'),
|
||||
client.fetch('/health'),
|
||||
client.fetch('/echo-body', { method: 'POST', body: 'payload-xyz' }),
|
||||
]);
|
||||
expect(a.status).toBe(200);
|
||||
expect(await a.json()).toEqual({ ok: true });
|
||||
expect(b.status).toBe(200);
|
||||
expect(await b.text()).toBe(await new Response('{"ok":true}').text());
|
||||
expect(await c.text()).toBe('payload-xyz');
|
||||
});
|
||||
|
||||
test('streams a response body incrementally', async () => {
|
||||
const { client } = await setupClient();
|
||||
track(client);
|
||||
const response = await client.fetch('/stream');
|
||||
expect(response.body).not.toBeNull();
|
||||
const reader = response.body!.getReader();
|
||||
const chunks: string[] = [];
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) chunks.push(textDecoder.decode(value));
|
||||
}
|
||||
expect(chunks.join('')).toBe('chunk0;chunk1;chunk2;');
|
||||
// The body arrived as multiple frames, not one buffered blob.
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('propagates abort to the host and errors the stream', async () => {
|
||||
const { client } = await setupClient();
|
||||
track(client);
|
||||
const controller = new AbortController();
|
||||
const response = await client.fetch('/never-ends', { signal: controller.signal });
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read();
|
||||
controller.abort();
|
||||
await expect(reader.read()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('opens, echoes, and closes a tunneled WebSocket', async () => {
|
||||
const { client } = await setupClient();
|
||||
track(client);
|
||||
const socket = client.openWebSocket('/api/global/event/ws?x=1');
|
||||
const opened = new Promise<void>((resolve) => {
|
||||
socket.onopen = () => resolve();
|
||||
});
|
||||
await opened;
|
||||
expect(socket.readyState).toBe(WS_OPEN);
|
||||
const message = new Promise<string>((resolve) => {
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') resolve(event.data);
|
||||
};
|
||||
});
|
||||
socket.send('hello');
|
||||
expect(await message).toBe('echo:hello');
|
||||
const closed = new Promise<number>((resolve) => {
|
||||
socket.onclose = (event) => resolve(event.code);
|
||||
});
|
||||
socket.close(1000, 'done');
|
||||
await closed;
|
||||
expect(socket.readyState).toBe(WS_CLOSED);
|
||||
});
|
||||
|
||||
test('fails open streams on reconnect and recovers on retry', async () => {
|
||||
const { client, connectionCount, killWire } = await setupClient();
|
||||
track(client);
|
||||
const response = await client.fetch('/never-ends');
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read();
|
||||
const socket = client.openWebSocket('/api/event/ws');
|
||||
const socketClosed = new Promise<number>((resolve) => {
|
||||
socket.onclose = (event) => resolve(event.code);
|
||||
});
|
||||
const firstConnections = connectionCount();
|
||||
|
||||
// Kill the relay socket: all open streams must fail so callers' retry
|
||||
// machinery recovers. Tunnel-killed sockets close with 1012.
|
||||
killWire();
|
||||
await expect(reader.read()).rejects.toThrow();
|
||||
expect(await socketClosed).toBe(1012);
|
||||
|
||||
// The client reconnects a fresh wire and works again.
|
||||
const health = await client.fetch('/health');
|
||||
expect(health.status).toBe(200);
|
||||
expect(connectionCount()).toBeGreaterThan(firstConnections);
|
||||
});
|
||||
|
||||
test('reconnects when keepalive times out against a silent host', async () => {
|
||||
const { client, connectionCount } = await setupClient({ silent: true });
|
||||
track(client);
|
||||
// Wait for the first handshake to establish, then for the keepalive timeout
|
||||
// to fire and trigger a reconnect (a new wire connection).
|
||||
await wait(400);
|
||||
expect(connectionCount()).toBeGreaterThan(1);
|
||||
const status = client.getStatus();
|
||||
expect(['reconnecting', 'connecting', 'connected', 'error']).toContain(status.state);
|
||||
});
|
||||
|
||||
test('survives duplicate ready frames from a slow first handshake (first-request 500 regression)', async () => {
|
||||
// firstHelloDelayMs > helloRetryMs (20ms): the client retries `hello`
|
||||
// several times, and the host answers every retry with `ready`. The
|
||||
// duplicate `ready` frames arrive after the client established and must
|
||||
// NOT reset the channel or fail the first in-flight request.
|
||||
const { client, connectionCount, sendTextToClient } = await setupClient({ firstHelloDelayMs: 70 });
|
||||
track(client);
|
||||
// First request in flight with a streamed response...
|
||||
const response = await client.fetch('/stream');
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read();
|
||||
// ...when a straggler duplicate `ready` (the host's answer to a retried
|
||||
// hello) lands on the established channel. Real-world timing: the retry
|
||||
// answer crosses the relay ~helloRetryMs after the first `ready`.
|
||||
sendTextToClient(JSON.stringify({ t: 'ready', v: 1 }));
|
||||
const chunks: string[] = [];
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) chunks.push(textDecoder.decode(value));
|
||||
}
|
||||
expect(chunks.join('')).toContain('chunk');
|
||||
expect(connectionCount()).toBe(1);
|
||||
expect(client.getStatus().state).toBe('connected');
|
||||
const again = await client.fetch('/health');
|
||||
expect(again.status).toBe(200);
|
||||
expect(connectionCount()).toBe(1);
|
||||
});
|
||||
|
||||
test('fails closed on non-ready plaintext after establishment', async () => {
|
||||
const { client, connectionCount, sendTextToClient } = await setupClient();
|
||||
track(client);
|
||||
await client.fetch('/health');
|
||||
expect(connectionCount()).toBe(1);
|
||||
sendTextToClient('{"anything":"plaintext"}');
|
||||
// The channel must reset (fail closed) and the client reconnect a new wire.
|
||||
await wait(150);
|
||||
expect(connectionCount()).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('publishes status transitions to subscribers', async () => {
|
||||
const { client } = await setupClient();
|
||||
track(client);
|
||||
const seen: string[] = [];
|
||||
client.subscribeStatus((status) => seen.push(status.state));
|
||||
await client.fetch('/health');
|
||||
expect(seen).toContain('connected');
|
||||
});
|
||||
|
||||
test('packs a burst of WS messages into far fewer wire messages, preserving order', async () => {
|
||||
const received: TunnelFrame[] = [];
|
||||
const { client, clientBinaryCount } = await setupClient(
|
||||
{ recordFrame: (frame) => received.push(frame) },
|
||||
{ batchWindowMs: 100 },
|
||||
);
|
||||
track(client);
|
||||
const socket = client.openWebSocket('/api/event/ws');
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.onopen = () => resolve();
|
||||
});
|
||||
|
||||
const echoes: string[] = [];
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') echoes.push(event.data);
|
||||
};
|
||||
|
||||
const BURST = 50;
|
||||
const baseline = clientBinaryCount(); // WsOpen etc. before the burst
|
||||
for (let i = 0; i < BURST; i += 1) socket.send(`m${i}`);
|
||||
|
||||
// Wait for the trailing window to flush and echoes to round-trip.
|
||||
await wait(250);
|
||||
|
||||
const bodyFrames = received.filter((f) => f.frameType === TunnelFrameType.WsText);
|
||||
expect(bodyFrames.length).toBe(BURST);
|
||||
// Order preserved: the host saw m0..m49 in sequence.
|
||||
expect(bodyFrames.map((f) => textDecoder.decode(f.payload))).toEqual(
|
||||
Array.from({ length: BURST }, (_, i) => `m${i}`),
|
||||
);
|
||||
// Echoes arrived in order too.
|
||||
expect(echoes).toEqual(Array.from({ length: BURST }, (_, i) => `echo:m${i}`));
|
||||
|
||||
// The 50 frames crossed the wire as a handful of encrypted messages, not 50.
|
||||
const burstWireMessages = clientBinaryCount() - baseline;
|
||||
expect(burstWireMessages).toBeLessThan(BURST / 3);
|
||||
expect(burstWireMessages).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('leading edge: a single frame after idle is delivered immediately, not a window later', async () => {
|
||||
const WINDOW = 300;
|
||||
let firstWsTextAt = 0;
|
||||
const { client } = await setupClient(
|
||||
{
|
||||
recordFrame: (frame) => {
|
||||
if (frame.frameType === TunnelFrameType.WsText && firstWsTextAt === 0) {
|
||||
firstWsTextAt = Date.now();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ batchWindowMs: WINDOW },
|
||||
);
|
||||
track(client);
|
||||
const socket = client.openWebSocket('/api/event/ws');
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.onopen = () => resolve();
|
||||
});
|
||||
// Stay idle beyond the window so the next frame takes the leading edge.
|
||||
await wait(WINDOW + 50);
|
||||
const sentAt = Date.now();
|
||||
socket.send('solo');
|
||||
await wait(WINDOW / 2);
|
||||
expect(firstWsTextAt).toBeGreaterThan(0);
|
||||
// Delivered well within a full window (leading-edge flush), not delayed.
|
||||
expect(firstWsTextAt - sentAt).toBeLessThan(WINDOW / 2);
|
||||
});
|
||||
|
||||
test('boundary frame (StreamEnd) flushes buffered body immediately', async () => {
|
||||
// A large batch window would stall a POST body if StreamEnd did not force a
|
||||
// flush; the request completing quickly proves the boundary flush.
|
||||
const { client } = await setupClient({}, { batchWindowMs: 1_000 });
|
||||
track(client);
|
||||
const start = Date.now();
|
||||
const response = await client.fetch('/echo-body', { method: 'POST', body: 'boundary-body' });
|
||||
expect(await response.text()).toBe('boundary-body');
|
||||
expect(Date.now() - start).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('keepalive: no ping while frames flow, ping fires after idle', async () => {
|
||||
const pings: number[] = [];
|
||||
const { client } = await setupClient(
|
||||
{
|
||||
recordFrame: (frame) => {
|
||||
if (frame.frameType === TunnelFrameType.Ping) pings.push(Date.now());
|
||||
},
|
||||
},
|
||||
{ pingIntervalMs: 40, pingTimeoutMs: 5_000, batchWindowMs: 20 },
|
||||
);
|
||||
track(client);
|
||||
const socket = client.openWebSocket('/api/event/ws');
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.onopen = () => resolve();
|
||||
});
|
||||
|
||||
// Keep traffic flowing faster than the ping interval for a few intervals.
|
||||
const busyUntil = Date.now() + 200;
|
||||
while (Date.now() < busyUntil) {
|
||||
socket.send('keepbusy');
|
||||
await wait(10);
|
||||
}
|
||||
expect(pings.length).toBe(0);
|
||||
|
||||
// Now go idle: a ping must appear once we exceed the interval.
|
||||
await wait(150);
|
||||
expect(pings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('negotiates legacy (no batch) when the host does not advertise batching', async () => {
|
||||
// Host advertises batch:false -> both directions fall back to one frame per
|
||||
// encrypted message. Everything still works end to end.
|
||||
const { client } = await setupClient({ batch: false });
|
||||
track(client);
|
||||
const socket = client.openWebSocket('/api/event/ws');
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.onopen = () => resolve();
|
||||
});
|
||||
const message = new Promise<string>((resolve) => {
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') resolve(event.data);
|
||||
};
|
||||
});
|
||||
socket.send('legacy');
|
||||
expect(await message).toBe('echo:legacy');
|
||||
const health = await client.fetch('/health');
|
||||
expect(health.status).toBe(200);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
MAX_TUNNEL_PAYLOAD_BYTES,
|
||||
TunnelFrameType,
|
||||
type TunnelHttpRequestPayload,
|
||||
} from './protocol';
|
||||
import {
|
||||
chunkPayload,
|
||||
createFragmentAssembler,
|
||||
createStreamIdAllocator,
|
||||
decodeFrameBatch,
|
||||
decodeJsonPayload,
|
||||
decodeTunnelFrame,
|
||||
encodeFragmentedMessage,
|
||||
encodeFrameBatch,
|
||||
encodeJsonPayload,
|
||||
encodeTunnelFrame,
|
||||
TunnelCodecError,
|
||||
} from './tunnel-codec';
|
||||
import { MAX_PLAINTEXT_FRAME_BYTES } from './protocol';
|
||||
|
||||
const randomBytes = (length: number): Uint8Array => {
|
||||
const bytes = new Uint8Array(length);
|
||||
// getRandomValues caps at 64 KiB per call.
|
||||
for (let offset = 0; offset < length; offset += 65536) {
|
||||
globalThis.crypto.getRandomValues(bytes.subarray(offset, Math.min(offset + 65536, length)));
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
describe('tunnel codec', () => {
|
||||
test('frame round trip preserves type, stream id, and payload', () => {
|
||||
const payload = randomBytes(1024);
|
||||
for (const streamId of [1, 3, 0x7fffffff, 0xffffffff]) {
|
||||
const frame = decodeTunnelFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, payload));
|
||||
expect(frame.frameType).toBe(TunnelFrameType.HttpBody);
|
||||
expect(frame.streamId).toBe(streamId);
|
||||
expect(frame.payload).toEqual(payload);
|
||||
expect(frame.hasMoreFragments).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('fragment flag round trips and is separated from the frame type', () => {
|
||||
const frame = decodeTunnelFrame(
|
||||
encodeTunnelFrame(TunnelFrameType.WsBinary, 5, new Uint8Array([1]), true),
|
||||
);
|
||||
expect(frame.frameType).toBe(TunnelFrameType.WsBinary);
|
||||
expect(frame.hasMoreFragments).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects invalid stream ids, oversized payloads, short and unknown frames', () => {
|
||||
const payload = new Uint8Array(1);
|
||||
expect(() => encodeTunnelFrame(TunnelFrameType.Ping, -1, payload)).toThrow(TunnelCodecError);
|
||||
expect(() => encodeTunnelFrame(TunnelFrameType.Ping, 2 ** 32, payload)).toThrow(TunnelCodecError);
|
||||
expect(() => encodeTunnelFrame(TunnelFrameType.Ping, 1.5, payload)).toThrow(TunnelCodecError);
|
||||
expect(() =>
|
||||
encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new Uint8Array(MAX_TUNNEL_PAYLOAD_BYTES + 1)),
|
||||
).toThrow('tunnel payload exceeds maximum size');
|
||||
expect(() => decodeTunnelFrame(new Uint8Array(4))).toThrow('tunnel frame too short');
|
||||
const unknown = new Uint8Array(5);
|
||||
unknown[0] = 63;
|
||||
expect(() => decodeTunnelFrame(unknown)).toThrow('unknown tunnel frame type 63');
|
||||
});
|
||||
|
||||
test('json payload helpers validate shape', () => {
|
||||
const isHttpRequest = (parsed: unknown): parsed is TunnelHttpRequestPayload =>
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
typeof (parsed as TunnelHttpRequestPayload).method === 'string' &&
|
||||
typeof (parsed as TunnelHttpRequestPayload).path === 'string';
|
||||
const payload = encodeJsonPayload({ method: 'GET', path: '/health', query: '', headers: {} });
|
||||
const decoded = decodeJsonPayload(payload, isHttpRequest);
|
||||
expect(decoded.method).toBe('GET');
|
||||
expect(() => decodeJsonPayload(new Uint8Array([0x7b]), isHttpRequest)).toThrow(
|
||||
'malformed JSON tunnel payload',
|
||||
);
|
||||
expect(() => decodeJsonPayload(encodeJsonPayload({ nope: true }), isHttpRequest)).toThrow(
|
||||
'unexpected JSON tunnel payload shape',
|
||||
);
|
||||
});
|
||||
|
||||
test('chunkPayload splits exactly and yields one empty chunk for empty input', () => {
|
||||
expect(chunkPayload(new Uint8Array(0))).toEqual([new Uint8Array(0)]);
|
||||
const bytes = randomBytes(10);
|
||||
const chunks = chunkPayload(bytes, 4);
|
||||
expect(chunks.map((c) => c.length)).toEqual([4, 4, 2]);
|
||||
expect(() => chunkPayload(bytes, 0)).toThrow(TunnelCodecError);
|
||||
expect(() => chunkPayload(bytes, MAX_TUNNEL_PAYLOAD_BYTES + 1)).toThrow(TunnelCodecError);
|
||||
});
|
||||
|
||||
test('large message fragments and reassembles byte-identically', () => {
|
||||
const message = randomBytes(MAX_TUNNEL_PAYLOAD_BYTES * 2 + 12345);
|
||||
const frames = encodeFragmentedMessage(TunnelFrameType.WsBinary, 7, message);
|
||||
expect(frames.length).toBe(3);
|
||||
const assembler = createFragmentAssembler();
|
||||
let result: Uint8Array | null = null;
|
||||
for (const encoded of frames) {
|
||||
result = assembler.push(decodeTunnelFrame(encoded));
|
||||
}
|
||||
expect(result).toEqual(message);
|
||||
});
|
||||
|
||||
test('assembler keeps interleaved streams separate and passes unfragmented frames through', () => {
|
||||
const assembler = createFragmentAssembler();
|
||||
const a1 = { frameType: TunnelFrameType.WsBinary, streamId: 1, payload: new Uint8Array([1]), hasMoreFragments: true };
|
||||
const b = { frameType: TunnelFrameType.WsText, streamId: 3, payload: new Uint8Array([9]), hasMoreFragments: false };
|
||||
const a2 = { frameType: TunnelFrameType.WsBinary, streamId: 1, payload: new Uint8Array([2]), hasMoreFragments: false };
|
||||
expect(assembler.push(a1)).toBeNull();
|
||||
expect(assembler.push(b)).toEqual(new Uint8Array([9]));
|
||||
expect(assembler.push(a2)).toEqual(new Uint8Array([1, 2]));
|
||||
});
|
||||
|
||||
test('assembler enforces max message size and dropStream clears pending state', () => {
|
||||
const assembler = createFragmentAssembler(8);
|
||||
const fragment = (payload: Uint8Array, more: boolean) => ({
|
||||
frameType: TunnelFrameType.WsBinary,
|
||||
streamId: 1,
|
||||
payload,
|
||||
hasMoreFragments: more,
|
||||
});
|
||||
expect(assembler.push(fragment(new Uint8Array(6), true))).toBeNull();
|
||||
expect(() => assembler.push(fragment(new Uint8Array(6), false))).toThrow(
|
||||
'fragmented message exceeds maximum size',
|
||||
);
|
||||
|
||||
expect(assembler.push(fragment(new Uint8Array([1]), true))).toBeNull();
|
||||
assembler.dropStream(1);
|
||||
// After drop, a terminal fragment stands alone rather than joining stale chunks.
|
||||
expect(assembler.push(fragment(new Uint8Array([2]), false))).toEqual(new Uint8Array([2]));
|
||||
});
|
||||
|
||||
test('stream id allocator yields odd ascending ids', () => {
|
||||
const allocator = createStreamIdAllocator();
|
||||
expect([allocator.next(), allocator.next(), allocator.next()]).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
test('frame batch round-trips N frames byte-identically, in order', () => {
|
||||
const frames = [
|
||||
encodeTunnelFrame(TunnelFrameType.HttpBody, 1, randomBytes(10)),
|
||||
encodeTunnelFrame(TunnelFrameType.WsText, 3, randomBytes(64)),
|
||||
encodeTunnelFrame(TunnelFrameType.WsBinary, 5, randomBytes(500)),
|
||||
];
|
||||
const decoded = decodeFrameBatch(encodeFrameBatch(frames));
|
||||
expect(decoded.length).toBe(frames.length);
|
||||
decoded.forEach((frame, index) => expect(frame).toEqual(frames[index]));
|
||||
});
|
||||
|
||||
test('single-frame batch uses the compact tag with 1 byte of overhead', () => {
|
||||
const frame = encodeTunnelFrame(TunnelFrameType.HttpBody, 7, randomBytes(128));
|
||||
const encoded = encodeFrameBatch([frame]);
|
||||
expect(encoded[0]).toBe(0x00); // BATCH_CONTAINER_TAG_SINGLE
|
||||
expect(encoded.length).toBe(frame.length + 1);
|
||||
const decoded = decodeFrameBatch(encoded);
|
||||
expect(decoded.length).toBe(1);
|
||||
expect(decoded[0]).toEqual(frame);
|
||||
});
|
||||
|
||||
test('multi-frame batch uses the length-prefixed tag', () => {
|
||||
const encoded = encodeFrameBatch([
|
||||
encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new Uint8Array([1])),
|
||||
encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new Uint8Array([2])),
|
||||
]);
|
||||
expect(encoded[0]).toBe(0x01); // BATCH_CONTAINER_TAG_BATCH
|
||||
});
|
||||
|
||||
test('rejects empty input and oversized batches, and truncated/unknown containers', () => {
|
||||
expect(() => encodeFrameBatch([])).toThrow(TunnelCodecError);
|
||||
const huge = new Uint8Array(MAX_PLAINTEXT_FRAME_BYTES); // no room for the tag
|
||||
expect(() => encodeFrameBatch([huge])).toThrow('frame batch exceeds maximum plaintext size');
|
||||
expect(() => decodeFrameBatch(new Uint8Array(0))).toThrow('empty batch plaintext');
|
||||
expect(() => decodeFrameBatch(new Uint8Array([0x09]))).toThrow('unknown batch container tag 9');
|
||||
// tag 0x01 then a length claiming more bytes than present.
|
||||
expect(() => decodeFrameBatch(new Uint8Array([0x01, 0, 0, 0, 8, 1, 2]))).toThrow(
|
||||
'truncated batch frame body',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
// Tunnel mux frame codec (Layer 3 of the protocol spec). Pure functions, no I/O.
|
||||
// Frame layout: [1 byte frameType (high bit = fragment-continues)][4 byte BE streamId][payload].
|
||||
// Client-initiated streams use odd streamIds starting at 1; even ids are reserved.
|
||||
// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 3).
|
||||
|
||||
import {
|
||||
BATCH_CONTAINER_TAG_BATCH,
|
||||
BATCH_CONTAINER_TAG_SINGLE,
|
||||
BATCH_FRAME_LENGTH_BYTES,
|
||||
MAX_PLAINTEXT_FRAME_BYTES,
|
||||
MAX_TUNNEL_PAYLOAD_BYTES,
|
||||
TUNNEL_FRAGMENT_FLAG,
|
||||
TUNNEL_FRAME_HEADER_BYTES,
|
||||
TunnelFrameType,
|
||||
isTunnelFrameType,
|
||||
type TunnelFrameTypeValue,
|
||||
} from './protocol';
|
||||
|
||||
const MAX_STREAM_ID = 0xffffffff;
|
||||
|
||||
export class TunnelCodecError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TunnelCodecError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface TunnelFrame {
|
||||
frameType: TunnelFrameTypeValue;
|
||||
streamId: number;
|
||||
payload: Uint8Array;
|
||||
/** True when this frame is a fragment and more fragments of the same message follow. */
|
||||
hasMoreFragments: boolean;
|
||||
}
|
||||
|
||||
export const encodeTunnelFrame = (
|
||||
frameType: TunnelFrameTypeValue,
|
||||
streamId: number,
|
||||
payload: Uint8Array,
|
||||
hasMoreFragments = false,
|
||||
): Uint8Array => {
|
||||
if (!Number.isInteger(streamId) || streamId < 0 || streamId > MAX_STREAM_ID) {
|
||||
throw new TunnelCodecError('invalid stream id');
|
||||
}
|
||||
if (payload.length > MAX_TUNNEL_PAYLOAD_BYTES) {
|
||||
throw new TunnelCodecError('tunnel payload exceeds maximum size');
|
||||
}
|
||||
const frame = new Uint8Array(TUNNEL_FRAME_HEADER_BYTES + payload.length);
|
||||
frame[0] = hasMoreFragments ? frameType | TUNNEL_FRAGMENT_FLAG : frameType;
|
||||
frame[1] = (streamId >>> 24) & 0xff;
|
||||
frame[2] = (streamId >>> 16) & 0xff;
|
||||
frame[3] = (streamId >>> 8) & 0xff;
|
||||
frame[4] = streamId & 0xff;
|
||||
frame.set(payload, TUNNEL_FRAME_HEADER_BYTES);
|
||||
return frame;
|
||||
};
|
||||
|
||||
export const decodeTunnelFrame = (frame: Uint8Array): TunnelFrame => {
|
||||
if (frame.length < TUNNEL_FRAME_HEADER_BYTES) {
|
||||
throw new TunnelCodecError('tunnel frame too short');
|
||||
}
|
||||
const rawType = frame[0];
|
||||
const hasMoreFragments = (rawType & TUNNEL_FRAGMENT_FLAG) !== 0;
|
||||
const frameType = rawType & ~TUNNEL_FRAGMENT_FLAG;
|
||||
if (!isTunnelFrameType(frameType)) {
|
||||
throw new TunnelCodecError(`unknown tunnel frame type ${frameType}`);
|
||||
}
|
||||
const streamId = ((frame[1] << 24) | (frame[2] << 16) | (frame[3] << 8) | frame[4]) >>> 0;
|
||||
return {
|
||||
frameType,
|
||||
streamId,
|
||||
payload: frame.slice(TUNNEL_FRAME_HEADER_BYTES),
|
||||
hasMoreFragments,
|
||||
};
|
||||
};
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export const encodeJsonPayload = (value: unknown): Uint8Array => textEncoder.encode(JSON.stringify(value));
|
||||
|
||||
export const decodeJsonPayload = <T>(payload: Uint8Array, validate: (parsed: unknown) => parsed is T): T => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(textDecoder.decode(payload));
|
||||
} catch {
|
||||
throw new TunnelCodecError('malformed JSON tunnel payload');
|
||||
}
|
||||
if (!validate(parsed)) {
|
||||
throw new TunnelCodecError('unexpected JSON tunnel payload shape');
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/** Split a body/message into payload-sized chunks. Empty input yields one empty chunk. */
|
||||
export const chunkPayload = (bytes: Uint8Array, chunkSize = MAX_TUNNEL_PAYLOAD_BYTES): Uint8Array[] => {
|
||||
if (chunkSize <= 0 || chunkSize > MAX_TUNNEL_PAYLOAD_BYTES) {
|
||||
throw new TunnelCodecError('invalid chunk size');
|
||||
}
|
||||
if (bytes.length === 0) return [new Uint8Array(0)];
|
||||
const chunks: Uint8Array[] = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
chunks.push(bytes.slice(offset, offset + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encode one logical message as one or more frames, setting the fragment flag
|
||||
* on all but the last. Used for WS messages that exceed the frame budget.
|
||||
*/
|
||||
export const encodeFragmentedMessage = (
|
||||
frameType: TunnelFrameTypeValue,
|
||||
streamId: number,
|
||||
payload: Uint8Array,
|
||||
): Uint8Array[] => {
|
||||
const chunks = chunkPayload(payload);
|
||||
return chunks.map((chunk, index) =>
|
||||
encodeTunnelFrame(frameType, streamId, chunk, index < chunks.length - 1),
|
||||
);
|
||||
};
|
||||
|
||||
/** Reassembles fragmented messages per (streamId, frameType). Bounded to protect memory. */
|
||||
export const createFragmentAssembler = (maxMessageBytes = 16 * 1024 * 1024) => {
|
||||
const pending = new Map<string, { chunks: Uint8Array[]; totalBytes: number }>();
|
||||
return {
|
||||
/**
|
||||
* Returns the complete message payload once all fragments arrived, or null
|
||||
* while more fragments are expected.
|
||||
*/
|
||||
push(frame: TunnelFrame): Uint8Array | null {
|
||||
const key = `${frame.streamId}:${frame.frameType}`;
|
||||
const entry = pending.get(key);
|
||||
if (!frame.hasMoreFragments && !entry) {
|
||||
return frame.payload;
|
||||
}
|
||||
const chunks = entry?.chunks ?? [];
|
||||
const totalBytes = (entry?.totalBytes ?? 0) + frame.payload.length;
|
||||
if (totalBytes > maxMessageBytes) {
|
||||
pending.delete(key);
|
||||
throw new TunnelCodecError('fragmented message exceeds maximum size');
|
||||
}
|
||||
chunks.push(frame.payload);
|
||||
if (frame.hasMoreFragments) {
|
||||
pending.set(key, { chunks, totalBytes });
|
||||
return null;
|
||||
}
|
||||
pending.delete(key);
|
||||
const message = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
message.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return message;
|
||||
},
|
||||
dropStream(streamId: number): void {
|
||||
for (const key of pending.keys()) {
|
||||
if (key.startsWith(`${streamId}:`)) pending.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Batch envelope encoder (Layer 2 plaintext container). Only used when both
|
||||
* peers negotiated `batch`. One encrypted WS message still equals one
|
||||
* encrypt() call — this only changes how many tunnel frames it carries.
|
||||
*
|
||||
* - 1 frame -> [0x00][frame bytes] (single, 1-byte overhead)
|
||||
* - N frames -> [0x01]([4B BE length][frame])×N (batch)
|
||||
*
|
||||
* Callers must keep the encoded size within MAX_PLAINTEXT_FRAME_BYTES; the
|
||||
* outbound batcher flushes before an add would exceed the budget.
|
||||
*/
|
||||
export const encodeFrameBatch = (frames: Uint8Array[]): Uint8Array => {
|
||||
if (frames.length === 0) {
|
||||
throw new TunnelCodecError('cannot encode an empty frame batch');
|
||||
}
|
||||
if (frames.length === 1) {
|
||||
const frame = frames[0];
|
||||
const out = new Uint8Array(1 + frame.length);
|
||||
out[0] = BATCH_CONTAINER_TAG_SINGLE;
|
||||
out.set(frame, 1);
|
||||
if (out.length > MAX_PLAINTEXT_FRAME_BYTES) {
|
||||
throw new TunnelCodecError('frame batch exceeds maximum plaintext size');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let total = 1;
|
||||
for (const frame of frames) total += BATCH_FRAME_LENGTH_BYTES + frame.length;
|
||||
if (total > MAX_PLAINTEXT_FRAME_BYTES) {
|
||||
throw new TunnelCodecError('frame batch exceeds maximum plaintext size');
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
out[0] = BATCH_CONTAINER_TAG_BATCH;
|
||||
let offset = 1;
|
||||
for (const frame of frames) {
|
||||
out[offset] = (frame.length >>> 24) & 0xff;
|
||||
out[offset + 1] = (frame.length >>> 16) & 0xff;
|
||||
out[offset + 2] = (frame.length >>> 8) & 0xff;
|
||||
out[offset + 3] = frame.length & 0xff;
|
||||
offset += BATCH_FRAME_LENGTH_BYTES;
|
||||
out.set(frame, offset);
|
||||
offset += frame.length;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Decodes a batch-envelope plaintext into its ordered tunnel frames. */
|
||||
export const decodeFrameBatch = (plaintext: Uint8Array): Uint8Array[] => {
|
||||
if (plaintext.length < 1) {
|
||||
throw new TunnelCodecError('empty batch plaintext');
|
||||
}
|
||||
const tag = plaintext[0];
|
||||
if (tag === BATCH_CONTAINER_TAG_SINGLE) {
|
||||
return [plaintext.slice(1)];
|
||||
}
|
||||
if (tag !== BATCH_CONTAINER_TAG_BATCH) {
|
||||
throw new TunnelCodecError(`unknown batch container tag ${tag}`);
|
||||
}
|
||||
const frames: Uint8Array[] = [];
|
||||
let offset = 1;
|
||||
while (offset < plaintext.length) {
|
||||
if (offset + BATCH_FRAME_LENGTH_BYTES > plaintext.length) {
|
||||
throw new TunnelCodecError('truncated batch frame length');
|
||||
}
|
||||
const length =
|
||||
((plaintext[offset] << 24) |
|
||||
(plaintext[offset + 1] << 16) |
|
||||
(plaintext[offset + 2] << 8) |
|
||||
plaintext[offset + 3]) >>>
|
||||
0;
|
||||
offset += BATCH_FRAME_LENGTH_BYTES;
|
||||
if (offset + length > plaintext.length) {
|
||||
throw new TunnelCodecError('truncated batch frame body');
|
||||
}
|
||||
frames.push(plaintext.slice(offset, offset + length));
|
||||
offset += length;
|
||||
}
|
||||
if (frames.length === 0) {
|
||||
throw new TunnelCodecError('empty frame batch');
|
||||
}
|
||||
return frames;
|
||||
};
|
||||
|
||||
// Only high-volume body/stream data is buffered; setup/teardown/keepalive
|
||||
// frames flush immediately so TTFT, terminal echo, and liveness stay snappy.
|
||||
const BUFFERED_FRAME_TYPES = new Set<number>([
|
||||
TunnelFrameType.HttpBody,
|
||||
TunnelFrameType.WsText,
|
||||
TunnelFrameType.WsBinary,
|
||||
]);
|
||||
|
||||
// 150ms: the chat render pipeline already gates visible streaming updates well below this — a
|
||||
// 100ms input throttle (useStreamingTextThrottle) feeding a ~64ms paced-reveal (usePacedText) that
|
||||
// buffers-and-smooths arrival bursts, and the app already tolerates 200ms under backpressure. So a
|
||||
// 150ms batch window is invisible to users while cutting DO messages ~33% more than 100ms.
|
||||
// Leading-edge flush keeps time-to-first-token and terminal echo instant regardless of this value.
|
||||
export const DEFAULT_BATCH_WINDOW_MS = 150;
|
||||
export const DEFAULT_BATCH_MAX_BYTES = 24 * 1024;
|
||||
export const DEFAULT_BATCH_MAX_FRAMES = 32;
|
||||
|
||||
export interface OutboundFrameBatcherOptions {
|
||||
/** Trailing flush window in ms. Buffered frames flush no later than this. */
|
||||
windowMs?: number;
|
||||
maxBatchBytes?: number;
|
||||
maxBatchFrames?: number;
|
||||
/** Encrypt + write one batched plaintext to the wire. Called in enqueue order. */
|
||||
sendBatch: (plaintext: Uint8Array) => void;
|
||||
// Injectable clock/timer so tests can drive timing deterministically.
|
||||
now?: () => number;
|
||||
setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
||||
clearTimer?: (handle: ReturnType<typeof setTimeout>) => void;
|
||||
}
|
||||
|
||||
export interface OutboundFrameBatcher {
|
||||
/** Buffer or immediately flush a tunnel frame per the batching policy. */
|
||||
enqueue(frame: Uint8Array): void;
|
||||
/** Force-flush any buffered frames now. */
|
||||
flush(): void;
|
||||
/** Stop the batcher; drops any un-flushed frames (channel is being torn down). */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound batching buffer shared by the client and (mirrored in JS) the host
|
||||
* send paths. Policy:
|
||||
* - Leading edge: if nothing flushed within windowMs, the frame ships now
|
||||
* (batch of 1) — keeps time-to-first-token and keystroke echo instant.
|
||||
* - Trailing window: subsequent body frames buffer and flush when the timer
|
||||
* fires, buffered bytes >= maxBatchBytes, buffered frames >= maxBatchFrames,
|
||||
* or the plaintext budget would be exceeded.
|
||||
* - Non-buffered frame types (setup/teardown/keepalive) flush immediately, and
|
||||
* flush any pending buffer first so per-stream ordering is preserved.
|
||||
*/
|
||||
export const createOutboundFrameBatcher = (
|
||||
options: OutboundFrameBatcherOptions,
|
||||
): OutboundFrameBatcher => {
|
||||
const windowMs = options.windowMs ?? DEFAULT_BATCH_WINDOW_MS;
|
||||
const maxBatchBytes = options.maxBatchBytes ?? DEFAULT_BATCH_MAX_BYTES;
|
||||
const maxBatchFrames = options.maxBatchFrames ?? DEFAULT_BATCH_MAX_FRAMES;
|
||||
const now = options.now ?? (() => Date.now());
|
||||
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
||||
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
||||
|
||||
let buffer: Uint8Array[] = [];
|
||||
let bufferedBytes = 0; // conservative multi-envelope size estimate
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastFlushAt = 0; // 0 => idle, so the first frame takes the leading edge
|
||||
let disposed = false;
|
||||
|
||||
const clearPendingTimer = (): void => {
|
||||
if (timer !== null) {
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const flush = (): void => {
|
||||
clearPendingTimer();
|
||||
if (buffer.length === 0) return;
|
||||
const frames = buffer;
|
||||
buffer = [];
|
||||
bufferedBytes = 0;
|
||||
lastFlushAt = now();
|
||||
options.sendBatch(encodeFrameBatch(frames));
|
||||
};
|
||||
|
||||
const enqueue = (frame: Uint8Array): void => {
|
||||
if (disposed) return;
|
||||
const frameType = frame[0] & ~TUNNEL_FRAGMENT_FLAG;
|
||||
if (!BUFFERED_FRAME_TYPES.has(frameType)) {
|
||||
// Immediate frame: append then flush so it never overtakes buffered body.
|
||||
buffer.push(frame);
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
const at = now();
|
||||
if (buffer.length === 0 && at - lastFlushAt >= windowMs) {
|
||||
// Leading edge: nothing flushed recently, ship this one right away.
|
||||
buffer.push(frame);
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
const frameCost = BATCH_FRAME_LENGTH_BYTES + frame.length;
|
||||
if (buffer.length > 0 && 1 + bufferedBytes + frameCost > MAX_PLAINTEXT_FRAME_BYTES) {
|
||||
flush();
|
||||
}
|
||||
buffer.push(frame);
|
||||
bufferedBytes += frameCost;
|
||||
if (bufferedBytes >= maxBatchBytes || buffer.length >= maxBatchFrames) {
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
if (timer === null) timer = setTimer(flush, windowMs);
|
||||
};
|
||||
|
||||
return {
|
||||
enqueue,
|
||||
flush,
|
||||
dispose(): void {
|
||||
disposed = true;
|
||||
clearPendingTimer();
|
||||
buffer = [];
|
||||
bufferedBytes = 0;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Allocates client-initiated stream ids: odd, starting at 1. */
|
||||
export const createStreamIdAllocator = () => {
|
||||
let next = 1;
|
||||
return {
|
||||
next(): number {
|
||||
if (next > MAX_STREAM_ID) {
|
||||
throw new TunnelCodecError('stream id space exhausted');
|
||||
}
|
||||
const id = next;
|
||||
next += 2;
|
||||
return id;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
// JSON payload guards and HTTP request normalization for the relay tunnel client.
|
||||
// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 3).
|
||||
|
||||
import type {
|
||||
TunnelHttpResponsePayload,
|
||||
TunnelStreamAbortPayload,
|
||||
TunnelWsClosePayload,
|
||||
} from './protocol';
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const isStringRecord = (value: unknown): value is Record<string, string> =>
|
||||
isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string');
|
||||
|
||||
export const isHttpResponsePayload = (value: unknown): value is TunnelHttpResponsePayload =>
|
||||
isRecord(value) && typeof value.status === 'number' && isStringRecord(value.headers);
|
||||
|
||||
export const isStreamAbortPayload = (value: unknown): value is TunnelStreamAbortPayload =>
|
||||
isRecord(value) && typeof value.reason === 'string';
|
||||
|
||||
export const isWsClosePayload = (value: unknown): value is TunnelWsClosePayload =>
|
||||
isRecord(value) && typeof value.code === 'number' && typeof value.reason === 'string';
|
||||
|
||||
const ABSOLUTE_URL_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i;
|
||||
|
||||
// Placeholder base for parsing origin-relative request paths; never fetched.
|
||||
// Throwaway base for parsing relative runtime paths — only pathname+search are
|
||||
// ever read, the host is discarded. Shared so relay modules don't diverge on it.
|
||||
export const TUNNEL_PARSE_BASE = 'http://tunnel.invalid';
|
||||
|
||||
/** Extracts `pathname?search` from an absolute or relative WS/HTTP URL. */
|
||||
export const wsUrlToTunnelPath = (url: string): string => {
|
||||
try {
|
||||
const parsed = ABSOLUTE_URL_PATTERN.test(url) ? new URL(url) : new URL(url, TUNNEL_PARSE_BASE);
|
||||
return `${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
export interface NormalizedTunnelRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
query: string;
|
||||
headers: Record<string, string>;
|
||||
body: AsyncIterable<Uint8Array> | null;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const singleChunk = (bytes: Uint8Array): AsyncIterable<Uint8Array> => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield bytes;
|
||||
},
|
||||
});
|
||||
|
||||
const streamChunks = (stream: ReadableStream<Uint8Array>): AsyncIterable<Uint8Array> => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
if (value) yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const copyBytes = (view: ArrayBufferView): Uint8Array => {
|
||||
const copy = new Uint8Array(view.byteLength);
|
||||
copy.set(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
||||
return copy;
|
||||
};
|
||||
|
||||
const resolveBody = async (
|
||||
body: BodyInit | ReadableStream<Uint8Array> | null,
|
||||
): Promise<{ body: AsyncIterable<Uint8Array> | null; contentType?: string }> => {
|
||||
if (body === null || body === undefined) return { body: null };
|
||||
if (body instanceof ReadableStream) return { body: streamChunks(body) };
|
||||
if (typeof body === 'string') return { body: singleChunk(new TextEncoder().encode(body)) };
|
||||
if (body instanceof ArrayBuffer) return { body: singleChunk(new Uint8Array(body.slice(0))) };
|
||||
if (ArrayBuffer.isView(body)) return { body: singleChunk(copyBytes(body)) };
|
||||
// Blob / FormData / URLSearchParams: let Response serialize the body exactly
|
||||
// like a native fetch would, and surface the content-type it derives
|
||||
// (e.g. the multipart boundary for FormData).
|
||||
const probe = new Response(body);
|
||||
const contentType = probe.headers.get('content-type') ?? undefined;
|
||||
const bytes = new Uint8Array(await probe.arrayBuffer());
|
||||
return { body: singleChunk(bytes), contentType };
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattens a fetch-style (input, init) pair into the tunnel HttpRequest shape,
|
||||
* preserving method, headers, body bytes/stream, and abort signal.
|
||||
*/
|
||||
export const normalizeTunnelRequest = async (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
): Promise<NormalizedTunnelRequest> => {
|
||||
let urlValue: string;
|
||||
const headers = new Headers();
|
||||
let method = 'GET';
|
||||
let bodySource: BodyInit | ReadableStream<Uint8Array> | null = null;
|
||||
let signal: AbortSignal | undefined;
|
||||
|
||||
if (input instanceof Request) {
|
||||
urlValue = input.url;
|
||||
method = input.method;
|
||||
input.headers.forEach((value, key) => headers.set(key, value));
|
||||
signal = input.signal;
|
||||
bodySource = input.body;
|
||||
} else {
|
||||
urlValue = input.toString();
|
||||
}
|
||||
|
||||
if (init) {
|
||||
if (init.method) method = init.method;
|
||||
if (init.headers) new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
||||
if (init.body !== undefined) bodySource = init.body;
|
||||
if (init.signal) signal = init.signal;
|
||||
}
|
||||
|
||||
const url = ABSOLUTE_URL_PATTERN.test(urlValue) ? new URL(urlValue) : new URL(urlValue, TUNNEL_PARSE_BASE);
|
||||
const { body, contentType } = await resolveBody(bodySource);
|
||||
if (contentType && !headers.has('content-type')) headers.set('content-type', contentType);
|
||||
|
||||
const headerRecord: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
headerRecord[key] = value;
|
||||
});
|
||||
|
||||
return {
|
||||
method: method.toUpperCase(),
|
||||
path: url.pathname,
|
||||
query: url.search.startsWith('?') ? url.search.slice(1) : url.search,
|
||||
headers: headerRecord,
|
||||
body,
|
||||
signal,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getActiveRelayTunnel } from '@/lib/relay/runtime-tunnel';
|
||||
|
||||
type RuntimeAuthCredential =
|
||||
| { type: 'bearer'; token: string }
|
||||
| null;
|
||||
@@ -201,11 +203,16 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise<string> =>
|
||||
if (credential?.type === 'bearer') {
|
||||
headers.set('Authorization', `Bearer ${credential.token}`);
|
||||
}
|
||||
const response = await fetch(buildAuthUrl(apiBaseUrl, '/auth/url-token'), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
// In relay mode the mint must ride the tunnel, not the network: there is no
|
||||
// reachable network base URL. Same auth headers, same route, tunneled.
|
||||
const relay = getActiveRelayTunnel();
|
||||
const response = relay
|
||||
? await relay.fetch('/auth/url-token', { method: 'POST', headers })
|
||||
: await fetch(buildAuthUrl(apiBaseUrl, '/auth/url-token'), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (generation === runtimeAuthGeneration) {
|
||||
clearRuntimeUrlAuthToken();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@/lib/runtime-auth';
|
||||
import { configureRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import {
|
||||
activateRelayTunnel,
|
||||
deactivateRelayTunnel,
|
||||
getActiveRelayTunnel,
|
||||
type RelayRuntimeDescriptor,
|
||||
} from '@/lib/relay/runtime-tunnel';
|
||||
|
||||
export { getActiveRelayTunnel };
|
||||
|
||||
export type RuntimeEndpointChangedDetail = {
|
||||
apiBaseUrl: string;
|
||||
@@ -85,7 +93,7 @@ export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null;
|
||||
activeRuntimeKey = options.runtimeKey?.trim() || (sameOrigin(apiBaseUrl, readInjectedLocalOrigin()) ? 'local' : normalizeRuntimeUrlKey(apiBaseUrl));
|
||||
};
|
||||
|
||||
export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null; requestHeaders?: Record<string, string> | null }): void => {
|
||||
export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null; requestHeaders?: Record<string, string> | null; relay?: RelayRuntimeDescriptor | null }): void => {
|
||||
const apiBaseUrl = options.apiBaseUrl.trim();
|
||||
const previousApiBaseUrl = getRuntimeApiBaseUrl();
|
||||
const previousRuntimeKey = getRuntimeKey();
|
||||
@@ -105,6 +113,14 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken
|
||||
configureRuntimeUrlResolver({ apiBaseUrl, realtimeBaseUrl: apiBaseUrl });
|
||||
setRuntimeExtraHeaders(options.requestHeaders || null);
|
||||
setRuntimeBearerToken(options.clientToken || null);
|
||||
// Relay mode routes runtime HTTP/WS through an E2EE tunnel instead of the
|
||||
// network. Activate the tunnel BEFORE minting the url token, since the mint
|
||||
// itself rides the tunnel (runtimeFetch -> tunnel.fetch).
|
||||
if (options.relay) {
|
||||
activateRelayTunnel(options.relay);
|
||||
} else {
|
||||
deactivateRelayTunnel();
|
||||
}
|
||||
void refreshRuntimeUrlAuthToken(apiBaseUrl).catch(() => {});
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent<RuntimeEndpointChangedDetail>(RUNTIME_ENDPOINT_CHANGED_EVENT, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { I18nKey } from '@/lib/i18n/store';
|
||||
import type { SettingsPageSlug, SettingsRuntimeContext } from './metadata';
|
||||
import { getSettingsPageMeta } from './metadata';
|
||||
import { RELAY_UI_ENABLED } from '@/lib/relay/gate';
|
||||
|
||||
interface SettingsSearchItem {
|
||||
id: string;
|
||||
@@ -426,6 +427,15 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
keywords: ['pairing link', 'client token', 'connect desktop', 'remote access'],
|
||||
isAvailable: (ctx) => !ctx.isVSCode,
|
||||
},
|
||||
{
|
||||
id: 'remote-instances.relay',
|
||||
page: 'remote-instances',
|
||||
titleKey: 'settings.remoteInstances.relay.title',
|
||||
descriptionKey: 'settings.remoteInstances.relay.description',
|
||||
keywords: ['relay', 'pairing', 'no ports', 'end-to-end encrypted', 'remote access', 'connect from anywhere'],
|
||||
// Gated by openchamber_relay_gate until the relay UI ships publicly.
|
||||
isAvailable: (ctx) => !ctx.isVSCode && RELAY_UI_ENABLED,
|
||||
},
|
||||
{
|
||||
id: 'remote-instances.direct-hosts',
|
||||
page: 'remote-instances',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getRuntimeUrlResolver } from './runtime-url';
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
import { openRuntimeWebSocket } from './relay/runtime-socket';
|
||||
import { type RelayTunnelWebSocket } from './relay/tunnel-client';
|
||||
|
||||
interface TerminalWebSocketDescriptor {
|
||||
path: string;
|
||||
@@ -132,11 +134,11 @@ const createTransportError = (code: string | undefined): Error => {
|
||||
};
|
||||
|
||||
class TerminalTransportManager {
|
||||
private socket: WebSocket | null = null;
|
||||
private socket: RelayTunnelWebSocket | null = null;
|
||||
private socketUrl = '';
|
||||
private boundSessionId: string | null = null;
|
||||
private requestedSessionId: string | null = null;
|
||||
private openPromise: Promise<WebSocket | null> | null = null;
|
||||
private openPromise: Promise<RelayTunnelWebSocket | null> | null = null;
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private closed = false;
|
||||
@@ -311,7 +313,7 @@ class TerminalTransportManager {
|
||||
subscription.connectionTimeoutId = null;
|
||||
}
|
||||
|
||||
private async getOpenSocket(waitMs: number): Promise<WebSocket | null> {
|
||||
private async getOpenSocket(waitMs: number): Promise<RelayTunnelWebSocket | null> {
|
||||
if (this.socket && this.socket.readyState === WS_READY_STATE_OPEN) {
|
||||
return this.socket;
|
||||
}
|
||||
@@ -355,11 +357,11 @@ class TerminalTransportManager {
|
||||
|
||||
this.clearReconnectTimeout();
|
||||
|
||||
this.openPromise = new Promise<WebSocket | null>((resolve) => {
|
||||
this.openPromise = new Promise<RelayTunnelWebSocket | null>((resolve) => {
|
||||
let settled = false;
|
||||
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const settle = (value: WebSocket | null) => {
|
||||
const settle = (value: RelayTunnelWebSocket | null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
@@ -373,7 +375,7 @@ class TerminalTransportManager {
|
||||
};
|
||||
|
||||
try {
|
||||
const socket = new WebSocket(this.socketUrl);
|
||||
const socket = openRuntimeWebSocket(this.socketUrl);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
|
||||
socket.onopen = () => {
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { Event, OpencodeClient, SessionStatus } from "@opencode-ai/sdk/v2/c
|
||||
import { opencodeClient } from "@/lib/opencode/client"
|
||||
import { getRuntimeUrlResolver } from "@/lib/runtime-url"
|
||||
import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from "@/lib/runtime-auth"
|
||||
import { type RelayTunnelWebSocket } from "@/lib/relay/tunnel-client"
|
||||
import { openRuntimeWebSocket } from "@/lib/relay/runtime-socket"
|
||||
import { syncDebug } from "./debug"
|
||||
|
||||
const FLUSH_FRAME_MS = 33
|
||||
@@ -212,6 +214,17 @@ function buildGlobalEventWsUrl(lastEventId?: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
// In relay mode the global-event WebSocket rides the E2EE tunnel instead of a
|
||||
// native network socket. The resolver still builds the authenticated URL (it
|
||||
// carries the oc_url_token the host replays to the loopback origin); we hand
|
||||
// its path+query to the tunnel, which returns a socket-like with the exact
|
||||
// on* handler surface this pipeline uses. Direct-URL runtimes keep the native
|
||||
// WebSocket path, wrapped to the same shape so the caller holds one type.
|
||||
function openGlobalEventSocket(lastEventId?: string): RelayTunnelWebSocket {
|
||||
const url = buildGlobalEventWsUrl(lastEventId)
|
||||
return openRuntimeWebSocket(url)
|
||||
}
|
||||
|
||||
type DirectoryQueue = {
|
||||
queue: Event[]
|
||||
buffer: Event[]
|
||||
@@ -569,7 +582,7 @@ export function createEventPipeline(input: EventPipelineInput): EventPipeline {
|
||||
let settled = false
|
||||
let opened = false
|
||||
let readyAt = 0
|
||||
const socket = new WebSocket(buildGlobalEventWsUrl(lastEventId))
|
||||
const socket: RelayTunnelWebSocket = openGlobalEventSocket(lastEventId)
|
||||
const setFallbackCode = (error: Error, force = false) => {
|
||||
if ((force || !opened) && transport === "auto") {
|
||||
wsFallbackUntil = Date.now() + WS_FALLBACK_WINDOW_MS
|
||||
|
||||
Vendored
+7
-1
@@ -10,9 +10,14 @@ declare module "bun:test" {
|
||||
toBeTruthy(): void;
|
||||
toBeFalsy(): void;
|
||||
toBeNull(): void;
|
||||
toThrow(expected?: string | RegExp): void;
|
||||
toThrow(expected?: string | RegExp | (new (...args: never[]) => unknown)): void;
|
||||
toContain(expected: unknown): void;
|
||||
toBeDefined(): void;
|
||||
rejects: {
|
||||
toThrow(expected?: string | RegExp | (new (...args: never[]) => unknown)): Promise<void>;
|
||||
};
|
||||
toBeGreaterThan(expected: number): void;
|
||||
toBeGreaterThanOrEqual(expected: number): void;
|
||||
toBeLessThan(expected: number): void;
|
||||
toHaveLength(expected: number): void;
|
||||
toBeInstanceOf(expected: unknown): void;
|
||||
@@ -24,6 +29,7 @@ declare module "bun:test" {
|
||||
};
|
||||
};
|
||||
export function beforeEach(fn: () => void | Promise<void>): void;
|
||||
export function afterEach(fn: () => void | Promise<void>): void;
|
||||
export function afterAll(fn: () => void | Promise<void>): void;
|
||||
export function mock<T extends (...args: never[]) => unknown>(fn?: T): T;
|
||||
export namespace mock {
|
||||
|
||||
@@ -204,6 +204,14 @@ describe('cli args', () => {
|
||||
expect(parsed.options.server).toBe('http://homebridge:3002');
|
||||
});
|
||||
|
||||
it('parses connect-url --relay flag', () => {
|
||||
const parsed = parseArgs(['connect-url', '--relay', '--name', 'My laptop']);
|
||||
|
||||
expect(parsed.command).toBe('connect-url');
|
||||
expect(parsed.options.relay).toBe(true);
|
||||
expect(parsed.options.name).toBe('My laptop');
|
||||
});
|
||||
|
||||
it('parses connect-url api-only help', () => {
|
||||
const parsed = parseArgs(['connect-url', '--api-only', '--help']);
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ Command modules implement user-facing commands and preserve output contracts acr
|
||||
- `commands-connect-url.js`
|
||||
- Implements `openchamber connect-url`.
|
||||
- Finds or starts a local instance and prints the browser/connect URL according to the selected output mode.
|
||||
- `--relay` builds an end-to-end-encrypted relay pairing link instead: it mints a client token and an offer from the instance's local relay identity (no server URL, no auto-start). The relay endpoint follows `OPENCHAMBER_RELAY_URL` / the stored setting / the default, matching the running host; clients read it from the offer.
|
||||
|
||||
- `commands-update.js`
|
||||
- Implements `openchamber update`.
|
||||
|
||||
@@ -264,6 +264,9 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'relay':
|
||||
options.relay = true;
|
||||
break;
|
||||
case 'qr':
|
||||
options.qr = true;
|
||||
options.explicitQr = true;
|
||||
@@ -384,6 +387,7 @@ OPTIONS:
|
||||
--hostname Alias for --host outside tunnel commands
|
||||
--lan Bind to 0.0.0.0 for LAN access
|
||||
--server <url> Public/server URL for connect-url links
|
||||
--relay connect-url: generate an end-to-end-encrypted relay pairing link
|
||||
--ui-password Protect browser UI with single password
|
||||
--api-only Start API routes only, without serving browser UI assets
|
||||
--foreground Run server in foreground (use with systemd/process managers)
|
||||
@@ -461,6 +465,10 @@ OPTIONS:
|
||||
--lan Bind to 0.0.0.0 for LAN access when starting
|
||||
--server <url> Public URL saved into the connection link
|
||||
--server-url <url> Alias for --server
|
||||
--relay Generate an end-to-end-encrypted relay pairing link
|
||||
(no server URL needed; requires the relay enabled on
|
||||
this instance). Set OPENCHAMBER_RELAY_URL to use a
|
||||
self-hosted relay.
|
||||
--name <label> Label saved with the remote client token
|
||||
--ui-password <value> Protect browser access when UI routes are enabled
|
||||
--api-only Start in headless/API-only mode when starting
|
||||
@@ -473,6 +481,7 @@ EXAMPLES:
|
||||
openchamber connect-url --port 3000 --qr
|
||||
openchamber connect-url --port 3000 --api-only --lan --server http://workstation.local:3000 --qr
|
||||
openchamber connect-url --server https://openchamber.example.com --name Workstation
|
||||
openchamber connect-url --relay --name "My laptop"
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
import { discoverRunningInstances } from './cli-lifecycle.js';
|
||||
import { getInstanceFilePath, readInstanceOptions } from './cli-process.js';
|
||||
import { createRemoteClientAuthRuntime } from '../../server/lib/client-auth/remote-clients.js';
|
||||
import { createRelayIdentityRuntime } from '../../server/lib/relay/identity.js';
|
||||
import { DEFAULT_RELAY_URL } from '../../server/lib/relay/service.js';
|
||||
import { bytesToBase64Url } from '../../server/lib/relay/e2ee.js';
|
||||
import {
|
||||
intro as clackIntro,
|
||||
outro as clackOutro,
|
||||
@@ -24,6 +27,102 @@ import {
|
||||
} from '../cli-output.js';
|
||||
|
||||
const REMOTE_CLIENTS_FILE_NAME = 'remote-clients.json';
|
||||
const SETTINGS_FILE_NAME = 'settings.json';
|
||||
|
||||
function isValidRelayUrl(value) {
|
||||
if (typeof value !== 'string') return false;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return url.protocol === 'ws:' || url.protocol === 'wss:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the relay endpoint the same way the running host does (service.js):
|
||||
// OPENCHAMBER_RELAY_URL env override, then the stored setting, then the default —
|
||||
// so the pairing link points at the same relay the host connects out to.
|
||||
function resolveRelayUrl(settings) {
|
||||
const envUrl = process.env.OPENCHAMBER_RELAY_URL;
|
||||
if (isValidRelayUrl(envUrl)) return envUrl.trim();
|
||||
const stored = settings?.privateRelay?.relayUrl;
|
||||
if (isValidRelayUrl(stored)) return stored.trim();
|
||||
return DEFAULT_RELAY_URL;
|
||||
}
|
||||
|
||||
// Minimal settings.json read/write for the relay identity runtime. It reads the
|
||||
// whole object and writes it back with the relay keys added, so other settings
|
||||
// are preserved. Enough for the CLI without wiring the full settings runtime.
|
||||
function createSettingsAccessors() {
|
||||
const settingsPath = path.join(getOpenChamberDataDir(), SETTINGS_FILE_NAME);
|
||||
const readSettingsFromDiskMigrated = async () => {
|
||||
try {
|
||||
return JSON.parse(await fs.promises.readFile(settingsPath, 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
const writeSettingsToDisk = async (settings) => {
|
||||
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true });
|
||||
await fs.promises.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
};
|
||||
return { readSettingsFromDiskMigrated, writeSettingsToDisk };
|
||||
}
|
||||
|
||||
// Builds an end-to-end-encrypted relay pairing link. Reuses the instance's relay
|
||||
// identity (serverId + encryption public key), generating it if the relay was
|
||||
// never enabled. The client reads the relay URL from the offer, so no client-side
|
||||
// configuration is needed.
|
||||
async function buildRelayConnectionPayload({ token, label }) {
|
||||
const accessors = createSettingsAccessors();
|
||||
const settings = await accessors.readSettingsFromDiskMigrated();
|
||||
const relayUrl = resolveRelayUrl(settings);
|
||||
const identityRuntime = createRelayIdentityRuntime({ crypto, ...accessors });
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
const offer = {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
relayUrl,
|
||||
serverId: identity.serverId,
|
||||
hostEncPubJwk: identity.hostEncPubJwk,
|
||||
label,
|
||||
token,
|
||||
};
|
||||
const encoded = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(offer)));
|
||||
return { connectUrl: `openchamber://connect?v=1&mode=relay#offer=${encoded}`, relayUrl, serverId: identity.serverId };
|
||||
}
|
||||
|
||||
async function generateRelayConnectUrl(options) {
|
||||
const label = options.name || os.hostname();
|
||||
const runtime = createRemoteClientAuthRuntime({
|
||||
fsPromises: fs.promises,
|
||||
path,
|
||||
crypto,
|
||||
storePath: path.join(getOpenChamberDataDir(), REMOTE_CLIENTS_FILE_NAME),
|
||||
});
|
||||
const result = await runtime.createClient({ label, clientKind: 'relay' });
|
||||
const { connectUrl, relayUrl, serverId } = await buildRelayConnectionPayload({ token: result.token, label });
|
||||
|
||||
if (isJsonMode(options)) {
|
||||
printJson({ mode: 'relay', relayUrl, serverId, connectUrl, token: result.token, client: result.client });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isQuietMode(options)) {
|
||||
process.stdout.write(`${connectUrl}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
clackIntro('OpenChamber relay connect URL');
|
||||
logStatus('success', connectUrl);
|
||||
clackLog.info(`Relay: ${relayUrl}`);
|
||||
logStatus('info', '[RELAY_ENABLE]', 'Enable the relay on this instance so this link can connect (Settings -> Remote Instances).');
|
||||
clackLog.info('Copy this link into another OpenChamber client. The token is shown only once.');
|
||||
if (options.qr === true) {
|
||||
await displayTunnelQrCode(connectUrl);
|
||||
}
|
||||
clackOutro('relay connect URL generated');
|
||||
}
|
||||
|
||||
async function resolveConnectUrlServerUrl(options) {
|
||||
let hostOverride = options.host;
|
||||
@@ -119,6 +218,13 @@ function createConnectUrlCommand({ serveCommand }) {
|
||||
throw new TunnelCliError('Invalid --server URL. Use an http:// or https:// URL.', EXIT_CODE.USAGE_ERROR);
|
||||
}
|
||||
|
||||
// Relay pairing needs neither a reachable server URL nor a running server:
|
||||
// the link is built from the instance's local relay identity + a fresh client
|
||||
// token. The client reads the relay endpoint from the offer.
|
||||
if (options.relay) {
|
||||
return await generateRelayConnectUrl(options);
|
||||
}
|
||||
|
||||
const running = await discoverRunningInstances();
|
||||
const serverState = running.some((entry) => entry.port === options.port)
|
||||
? { port: options.port, autoStarted: false }
|
||||
|
||||
@@ -89,6 +89,7 @@ import { createProjectConfigRuntime } from './lib/projects/project-config.js';
|
||||
import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js';
|
||||
import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js';
|
||||
import { attachRealtimeProxy } from './lib/realtime-proxy.js';
|
||||
import { createRelayService } from './lib/relay/service.js';
|
||||
import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
|
||||
import webPush from 'web-push';
|
||||
|
||||
@@ -1286,6 +1287,19 @@ async function main(options = {}) {
|
||||
const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port);
|
||||
const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext;
|
||||
|
||||
// Private relay host service: config + management routes + host client
|
||||
// lifecycle. Loopback port comes from the same source the tunnel uses so
|
||||
// relay-tunneled requests hit the local Express app on 127.0.0.1.
|
||||
const relayService = createRelayService({
|
||||
crypto,
|
||||
os,
|
||||
readSettingsFromDiskMigrated,
|
||||
writeSettingsToDisk,
|
||||
remoteClientAuthRuntime,
|
||||
getLocalPort: () => tunnelRuntimeContext.getActivePort(),
|
||||
});
|
||||
relayService.registerRoutes(app);
|
||||
|
||||
await featureRoutesRuntime.registerRoutes(app, {
|
||||
crypto,
|
||||
fs,
|
||||
@@ -1395,6 +1409,9 @@ async function main(options = {}) {
|
||||
console.warn('[ScheduledTasks] Failed to start runtime:', error?.message || error);
|
||||
}
|
||||
|
||||
// Only opens a relay control socket when the user opted in (config enabled).
|
||||
void relayService.startIfEnabled();
|
||||
|
||||
return {
|
||||
expressApp: app,
|
||||
httpServer: server,
|
||||
@@ -1425,6 +1442,11 @@ async function main(options = {}) {
|
||||
},
|
||||
stop: (shutdownOptions = {}) => {
|
||||
realtimeProxyRuntime.stop();
|
||||
try {
|
||||
relayService.stop();
|
||||
} catch {
|
||||
// best-effort teardown of the relay host client
|
||||
}
|
||||
try {
|
||||
dictationRuntime?.stop?.();
|
||||
} catch {
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
// Wired into the same trigger fanout as web push (see runtime.js); the relay carries only
|
||||
// generic, model-based text (no session content) — see APNS.md.
|
||||
|
||||
import {
|
||||
getOrCreateRelaySigningKeypair,
|
||||
signRelayMessage as signRelayMessageShared,
|
||||
} from '../relay/signing-key.js';
|
||||
|
||||
const APNS_TOKENS_VERSION = 1;
|
||||
const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
|
||||
const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
|
||||
@@ -51,27 +56,15 @@ export const createApnsRuntime = (deps) => {
|
||||
// device token alone can't be used to push. Zero-config: the keypair generates on first use.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Key access lives in lib/relay/signing-key.js now (shared with the private
|
||||
// relay identity — same keypair, same storage, same serverId derivation).
|
||||
const getOrCreateRelayKeypair = async () => {
|
||||
if (cachedRelayKey) return cachedRelayKey;
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const existing = settings?.relaySigningKey;
|
||||
if (existing && existing.privateJwk && existing.publicJwk) {
|
||||
cachedRelayKey = {
|
||||
privateKey: crypto.createPrivateKey({ key: existing.privateJwk, format: 'jwk' }),
|
||||
publicJwk: existing.publicJwk,
|
||||
};
|
||||
return cachedRelayKey;
|
||||
}
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const privateJwk = privateKey.export({ format: 'jwk' });
|
||||
const publicJwk = publicKey.export({ format: 'jwk' });
|
||||
await writeSettingsToDisk({ ...settings, relaySigningKey: { privateJwk, publicJwk } });
|
||||
cachedRelayKey = { privateKey, publicJwk };
|
||||
cachedRelayKey = await getOrCreateRelaySigningKeypair({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk });
|
||||
return cachedRelayKey;
|
||||
};
|
||||
|
||||
const signRelayMessage = (privateKey, message) =>
|
||||
crypto.sign('SHA256', Buffer.from(message), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url');
|
||||
const signRelayMessage = (privateKey, message) => signRelayMessageShared({ crypto }, privateKey, message);
|
||||
|
||||
// Trim to the 4 fields the relay's schema accepts (and that feed the serverId hash).
|
||||
const relayPublicJwk = (publicJwk) => ({
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Relay Module Documentation
|
||||
|
||||
## Purpose
|
||||
|
||||
The private relay lets an OpenChamber client (mobile app, browser, or another desktop) reach a user's OpenChamber instance through OpenChamber-hosted infrastructure when the instance is not directly reachable (behind NAT, no public URL, no tunnel). The instance dials **outbound** to the relay; nothing needs to be exposed inbound.
|
||||
|
||||
Traffic is **end-to-end encrypted between the two endpoints** (client and host instance). The relay infrastructure forwards opaque ciphertext and cannot read application traffic — it is an untrusted transport, not a trusted middlebox.
|
||||
|
||||
This module (`packages/web/server/lib/relay/`) is the **host side**: it runs inside the OpenChamber web server (so it works for Electron desktop, headless server, and CLI installs alike). The **client side** lives in `packages/ui/src/lib/relay/`. The **relay service itself** is a separate Cloudflare Worker in the `openchamber-website` repo and only brokers connections.
|
||||
|
||||
## The three layers
|
||||
|
||||
Traffic is modeled as three stacked layers. The relay understands only Layer 1; Layers 2–3 exist solely between the client and the host.
|
||||
|
||||
1. **Relay routing (Layer 1)** — outbound WebSocket connections to the relay, connection brokering, and host authentication to the relay. The relay routes each client to the correct host and forwards frames verbatim.
|
||||
2. **End-to-end encryption (Layer 2)** — an authenticated encrypted channel established directly between client and host, keyed so the relay cannot participate. Built on standard WebCrypto primitives (ECDH key agreement + AEAD framing). The host's encryption public key is distributed to the client out-of-band via the pairing payload and is the client's trust anchor.
|
||||
3. **Tunnel multiplexing (Layer 3)** — because an OpenChamber client speaks many concurrent HTTP requests, an event stream (SSE), and WebSockets to one origin, the encrypted channel carries a small multiplexing protocol. It frames HTTP request/response (including streamed bodies) and WebSocket sub-streams so the whole app works over one encrypted connection.
|
||||
|
||||
## Entrypoints and structure
|
||||
|
||||
Host side (`packages/web/server/lib/relay/`):
|
||||
- `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable,offer}`), and lifecycle wiring. Started from `packages/web/server/index.js` only when the user has explicitly enabled the relay. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay via the `OPENCHAMBER_RELAY_URL` env var (must be `ws://`/`wss://`); when set it overrides the stored setting for the host connection, the pairing offer, and status, so paired clients inherit the endpoint automatically from the offer.
|
||||
- `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
|
||||
- `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime.
|
||||
- `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection.
|
||||
- `tunnel-host.js` — the per-connection dispatcher: decrypts tunnel frames and forwards HTTP/SSE/WS to the local server over loopback, then streams responses back. Enforces a path allowlist and never injects credentials.
|
||||
- `e2ee.js`, `tunnel-codec.js` — host-side (JS) mirrors of the shared crypto and framing (see "Two implementations" below).
|
||||
|
||||
Client side (`packages/ui/src/lib/relay/`):
|
||||
- `protocol.ts` — the shared contract: constants, frame types, message shapes. The normative source both implementations follow.
|
||||
- `crypto.ts`, `handshake.ts` — the E2EE primitives and handshake state machines (initiator + responder).
|
||||
- `tunnel-codec.ts` — Layer 3 frame codec, fragmentation, and outbound frame batching.
|
||||
- `tunnel-client.ts` — the client tunnel: exposes a `fetch()`-compatible and a WebSocket-compatible surface backed by the encrypted tunnel.
|
||||
- `tunnel-payloads.ts`, `runtime-tunnel.ts`, `runtime-socket.ts` — payload helpers, the active-tunnel singleton, and the shared "open a runtime WebSocket the right way" helper.
|
||||
- `offer.ts` — the pairing payload builder/parser (secrets travel in URL fragments only).
|
||||
|
||||
## What travels the tunnel
|
||||
|
||||
Everything a client normally sends to the single OpenChamber origin:
|
||||
- **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`.
|
||||
- **SSE** — long-lived streamed responses (the event stream, notifications, terminal output fallback). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling.
|
||||
- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation).
|
||||
|
||||
The host dispatcher restricts tunneled traffic to explicit path allowlists (one for HTTP, one for WS).
|
||||
|
||||
## Authentication model
|
||||
|
||||
- The tunnel is **transport only**. The OpenChamber server still authenticates every tunneled request exactly as it authenticates a direct remote client. The relay path grants reachability, not authorization.
|
||||
- Clients carry their normal credential. HTTP and SSE requests authenticate with the client's bearer token (a header). **WebSocket upgrades cannot send headers**, so they authenticate with a short-lived URL-scoped token minted beforehand and passed as a query parameter. This asymmetry is important when adding new WebSocket features (see the skill).
|
||||
- The host authenticates itself to the relay with a signed handshake using its long-lived signing key.
|
||||
- Enabling the relay is explicit opt-in and disabled by default; disabling it severs all relay reachability immediately.
|
||||
|
||||
## End-to-end flow (overview)
|
||||
|
||||
1. **Pairing.** The host builds an offer describing the relay endpoint, its routing id, and its encryption public key, rendered as a QR code / deep link. Secrets are carried in the URL fragment so they never reach any server. The client imports it and stores the connection.
|
||||
2. **Presence.** When the relay is enabled, the host opens one outbound control connection and waits.
|
||||
3. **Connect.** The client connects for a given routing id; the relay notifies the host over the control connection; the host opens a matching per-client data connection.
|
||||
4. **Handshake.** Over that connection pair, client and host run the E2EE handshake and derive a shared encrypted channel the relay cannot read.
|
||||
5. **Traffic.** All normal app traffic is multiplexed and encrypted through that channel. On the host, decrypted requests are dispatched to the local server over loopback; responses stream back encrypted. Reconnects re-establish a fresh channel and the app's existing retry machinery recovers.
|
||||
|
||||
## Two implementations, kept in sync
|
||||
|
||||
The E2EE and framing logic exists twice: TypeScript in `packages/ui/src/lib/relay/` (shared by the client and the normative reference) and a JavaScript mirror in this module (the host, which is plain JS ESM). They **must stay byte-compatible** — a client encrypted by one must decrypt on the other. A cross-compatibility test (`cross-compat.test.js`) imports the TS modules directly and exercises a full TS-client ↔ JS-host exchange. Any change to the wire format, frame codec, handshake, or batching must update both sides and keep that test green.
|
||||
|
||||
## Runtime integration (client)
|
||||
|
||||
Relay mode plugs into the existing client transport layer rather than a parallel path: `runtime-switch` activates the tunnel singleton, `runtime-fetch` routes runtime requests through it, `runtime-url`/`runtime-socket` yield tunnel-backed URLs and sockets, and `runtime-auth` mints the URL-scoped token through the tunnel. Direct-URL connections and the Electron realtime-proxy path are unaffected.
|
||||
|
||||
## Design invariants (do not regress)
|
||||
|
||||
- The relay never sees plaintext application traffic; it sees only routing metadata (routing id, connection identifiers, timestamps, coarse counts).
|
||||
- Pairing secrets travel in URL fragments only, never in query strings, never logged.
|
||||
- The host dispatcher never injects credentials; the server authenticates each tunneled request.
|
||||
- The tunnel is transparent to the app: adding relay support to a feature should not require the feature to know the relay exists — it goes through the shared runtime transport helpers.
|
||||
- The two implementations stay byte-compatible and the wire format is versioned/negotiated so mixed client/host app versions degrade gracefully rather than break.
|
||||
|
||||
For the operational rules that keep future changes (new WebSocket endpoints, transport refactors, terminal/voice porting) from breaking this, load the `relay-transport` skill.
|
||||
@@ -0,0 +1,135 @@
|
||||
// Cross-compatibility: the JS host e2ee must interoperate with the normative TS
|
||||
// modules in packages/ui/src/lib/relay. bun runs TS directly, so import the TS
|
||||
// client handshake and drive a full TS-client <-> JS-host exchange both ways.
|
||||
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { createHostHandshake, exportPublicKeyJwk, generateEcdhKeyPair } from './e2ee.js';
|
||||
import { createClientHandshake } from '../../../../ui/src/lib/relay/handshake.ts';
|
||||
import {
|
||||
TunnelFrameType as JsFrameType,
|
||||
decodeFrameBatch as jsDecodeBatch,
|
||||
decodeTunnelFrame as jsDecode,
|
||||
encodeFrameBatch as jsEncodeBatch,
|
||||
encodeTunnelFrame as jsEncode,
|
||||
} from './tunnel-codec.js';
|
||||
import {
|
||||
decodeFrameBatch as tsDecodeBatch,
|
||||
decodeTunnelFrame as tsDecode,
|
||||
encodeFrameBatch as tsEncodeBatch,
|
||||
encodeTunnelFrame as tsEncode,
|
||||
} from '../../../../ui/src/lib/relay/tunnel-codec.ts';
|
||||
import { TunnelFrameType as TsFrameType } from '../../../../ui/src/lib/relay/protocol.ts';
|
||||
|
||||
describe('relay JS-host <-> TS-client cross compatibility', () => {
|
||||
it('completes a handshake and exchanges frames both ways', async () => {
|
||||
const hostKeys = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeys.publicKey);
|
||||
|
||||
const jsHost = createHostHandshake(hostKeys.privateKey);
|
||||
const tsClient = await createClientHandshake(hostPubJwk);
|
||||
|
||||
// TS client hello -> JS host establishes and replies ready.
|
||||
const hostAction = await jsHost.handleText(tsClient.helloText);
|
||||
expect(hostAction.type).toBe('established');
|
||||
const hostChannel = hostAction.channel;
|
||||
|
||||
// JS host ready -> TS client establishes.
|
||||
const clientAction = await tsClient.handleText(hostAction.replyText);
|
||||
expect(clientAction.type).toBe('established');
|
||||
const clientChannel = clientAction.channel;
|
||||
|
||||
// TS client -> JS host.
|
||||
const up = new TextEncoder().encode('ts client speaking');
|
||||
const upPlain = await hostChannel.decryptor.decrypt(await clientChannel.encryptor.encrypt(up));
|
||||
expect(new TextDecoder().decode(upPlain)).toBe('ts client speaking');
|
||||
|
||||
// JS host -> TS client.
|
||||
const down = new TextEncoder().encode('js host replying');
|
||||
const downPlain = await clientChannel.decryptor.decrypt(await hostChannel.encryptor.encrypt(down));
|
||||
expect(new TextDecoder().decode(downPlain)).toBe('js host replying');
|
||||
});
|
||||
|
||||
it('tunnel frames are byte-compatible across TS and JS codecs', () => {
|
||||
const payload = new TextEncoder().encode('{"method":"GET"}');
|
||||
const tsFrame = tsEncode(TsFrameType.HttpRequest, 5, payload);
|
||||
const jsFrame = jsEncode(JsFrameType.HttpRequest, 5, payload);
|
||||
expect(Array.from(jsFrame)).toEqual(Array.from(tsFrame));
|
||||
|
||||
const decodedByJs = jsDecode(tsFrame);
|
||||
const decodedByTs = tsDecode(jsFrame);
|
||||
expect(decodedByJs.streamId).toBe(5);
|
||||
expect(decodedByTs.streamId).toBe(5);
|
||||
expect(decodedByJs.frameType).toBe(TsFrameType.HttpRequest);
|
||||
});
|
||||
|
||||
it('negotiates batching between a TS client and a JS host, then exchanges a batch', async () => {
|
||||
const hostKeys = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeys.publicKey);
|
||||
|
||||
const jsHost = createHostHandshake(hostKeys.privateKey);
|
||||
const tsClient = await createClientHandshake(hostPubJwk);
|
||||
|
||||
const hostAction = await jsHost.handleText(tsClient.helloText);
|
||||
expect(hostAction.type).toBe('established');
|
||||
expect(hostAction.batch).toBe(true);
|
||||
const clientAction = await tsClient.handleText(hostAction.replyText);
|
||||
expect(clientAction.type).toBe('established');
|
||||
expect(clientAction.batch).toBe(true);
|
||||
|
||||
// TS client encodes a multi-frame batch -> JS host decodes it byte-identically.
|
||||
const frames = [
|
||||
tsEncode(TsFrameType.HttpBody, 1, new TextEncoder().encode('alpha')),
|
||||
tsEncode(TsFrameType.HttpBody, 1, new TextEncoder().encode('beta')),
|
||||
tsEncode(TsFrameType.HttpBody, 1, new TextEncoder().encode('gamma')),
|
||||
];
|
||||
const overWire = await hostAction.channel.decryptor.decrypt(
|
||||
await clientAction.channel.encryptor.encrypt(tsEncodeBatch(frames)),
|
||||
);
|
||||
const jsFrames = jsDecodeBatch(overWire);
|
||||
expect(jsFrames.length).toBe(3);
|
||||
jsFrames.forEach((frame, index) => expect(Array.from(frame)).toEqual(Array.from(frames[index])));
|
||||
|
||||
// JS host encodes a batch -> TS client decodes it.
|
||||
const downFrames = [
|
||||
jsEncode(JsFrameType.HttpBody, 1, new TextEncoder().encode('down-1')),
|
||||
jsEncode(JsFrameType.HttpBody, 1, new TextEncoder().encode('down-2')),
|
||||
];
|
||||
const downWire = await clientAction.channel.decryptor.decrypt(
|
||||
await hostAction.channel.encryptor.encrypt(jsEncodeBatch(downFrames)),
|
||||
);
|
||||
const tsFrames = tsDecodeBatch(downWire);
|
||||
expect(tsFrames.length).toBe(2);
|
||||
tsFrames.forEach((frame, index) => expect(Array.from(frame)).toEqual(Array.from(downFrames[index])));
|
||||
});
|
||||
|
||||
it('falls back to legacy (no batch) when either peer does not advertise batching', async () => {
|
||||
const hostKeys = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeys.publicKey);
|
||||
|
||||
// Legacy JS host (batch:false) vs batch-capable TS client -> batching off.
|
||||
const legacyHost = createHostHandshake(hostKeys.privateKey, { batch: false });
|
||||
const tsClient = await createClientHandshake(hostPubJwk);
|
||||
const hostAction = await legacyHost.handleText(tsClient.helloText);
|
||||
expect(hostAction.type).toBe('established');
|
||||
expect(hostAction.batch).toBe(false);
|
||||
const clientAction = await tsClient.handleText(hostAction.replyText);
|
||||
expect(clientAction.type).toBe('established');
|
||||
expect(clientAction.batch).toBe(false);
|
||||
|
||||
// Legacy wire: plaintext is a single raw tunnel frame (no container tag).
|
||||
const frame = tsEncode(TsFrameType.HttpBody, 1, new TextEncoder().encode('legacy'));
|
||||
const overWire = await hostAction.channel.decryptor.decrypt(
|
||||
await clientAction.channel.encryptor.encrypt(frame),
|
||||
);
|
||||
expect(jsDecode(overWire).frameType).toBe(JsFrameType.HttpBody);
|
||||
|
||||
// Batch-capable JS host vs legacy TS client (batch:false) -> also off.
|
||||
const host2 = createHostHandshake(hostKeys.privateKey);
|
||||
const legacyClient = await createClientHandshake(hostPubJwk, { batch: false });
|
||||
const host2Action = await host2.handleText(legacyClient.helloText);
|
||||
expect(host2Action.batch).toBe(false);
|
||||
const client2Action = await legacyClient.handleText(host2Action.replyText);
|
||||
expect(client2Action.batch).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
// E2EE primitives + responder handshake for the private relay (Layer 2).
|
||||
// JS mirror of the normative TS implementation in
|
||||
// packages/ui/src/lib/relay/{protocol,crypto,handshake}.ts — the web server is
|
||||
// plain JS and cannot import from packages/ui, so the logic is copied verbatim
|
||||
// (converted to JSDoc'd JS) and MUST stay byte-compatible with those modules.
|
||||
// WebCrypto only: `globalThis.crypto.subtle` (Node >= 22).
|
||||
// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 2).
|
||||
|
||||
const subtle = globalThis.crypto.subtle;
|
||||
|
||||
export const RELAY_PROTOCOL_VERSION = 1;
|
||||
export const RELAY_HKDF_INFO = 'openchamber-relay-v1';
|
||||
|
||||
// Encrypted frame layout: [1 byte version][12 byte IV][ciphertext + 16 byte GCM tag].
|
||||
export const ENCRYPTED_FRAME_VERSION = 1;
|
||||
export const ENCRYPTED_FRAME_IV_BYTES = 12;
|
||||
export const ENCRYPTED_FRAME_HEADER_BYTES = 1 + ENCRYPTED_FRAME_IV_BYTES;
|
||||
export const MAX_PLAINTEXT_FRAME_BYTES = 64 * 1024;
|
||||
|
||||
// Relay-assigned WebSocket close codes (subset the host needs).
|
||||
export const RelayCloseCode = {
|
||||
RekeyMismatch: 1008,
|
||||
ChannelFailure: 1011,
|
||||
};
|
||||
|
||||
const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' };
|
||||
const HANDSHAKE_NONCE_BYTES = 16;
|
||||
const SESSION_KEY_BYTES = 32;
|
||||
const GCM_TAG_BYTES = 16;
|
||||
// IV = 4-byte random per-direction prefix || 8-byte big-endian frame counter.
|
||||
const IV_PREFIX_BYTES = 4;
|
||||
const IV_COUNTER_BYTES = 8;
|
||||
|
||||
export class RelayCryptoError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'RelayCryptoError';
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<CryptoKeyPair>} */
|
||||
export const generateEcdhKeyPair = () => subtle.generateKey(ECDH_PARAMS, true, ['deriveBits']);
|
||||
|
||||
/**
|
||||
* @param {CryptoKey} key
|
||||
* @returns {Promise<JsonWebKey>} public JWK reduced to the fields that define the point
|
||||
*/
|
||||
export const exportPublicKeyJwk = async (key) => {
|
||||
const jwk = await subtle.exportKey('jwk', key);
|
||||
return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
|
||||
};
|
||||
|
||||
/** @param {JsonWebKey} jwk */
|
||||
export const importEcdhPublicKey = async (jwk) => {
|
||||
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || typeof jwk.x !== 'string' || typeof jwk.y !== 'string') {
|
||||
throw new RelayCryptoError('invalid ECDH public key JWK');
|
||||
}
|
||||
try {
|
||||
return await subtle.importKey(
|
||||
'jwk',
|
||||
{ kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true },
|
||||
ECDH_PARAMS,
|
||||
true,
|
||||
[],
|
||||
);
|
||||
} catch {
|
||||
throw new RelayCryptoError('invalid ECDH public key JWK');
|
||||
}
|
||||
};
|
||||
|
||||
/** @param {JsonWebKey} jwk private ECDH JWK (d + point) */
|
||||
export const importEcdhPrivateKey = async (jwk) => {
|
||||
try {
|
||||
return await subtle.importKey('jwk', jwk, ECDH_PARAMS, false, ['deriveBits']);
|
||||
} catch {
|
||||
throw new RelayCryptoError('invalid ECDH private key JWK');
|
||||
}
|
||||
};
|
||||
|
||||
// Stable fingerprint of a public key, used to detect rekey attempts on re-hello.
|
||||
/** @param {JsonWebKey} jwk */
|
||||
export const publicKeyJwkFingerprint = (jwk) =>
|
||||
JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
|
||||
|
||||
export const generateHandshakeNonce = () => {
|
||||
const nonce = new Uint8Array(HANDSHAKE_NONCE_BYTES);
|
||||
globalThis.crypto.getRandomValues(nonce);
|
||||
return nonce;
|
||||
};
|
||||
|
||||
/**
|
||||
* Both sides call this with their own private key and the peer's public key;
|
||||
* ECDH yields the same shared secret, so the derived key pair matches.
|
||||
* @param {CryptoKey} ownPrivateKey
|
||||
* @param {CryptoKey} peerPublicKey
|
||||
* @param {Uint8Array} handshakeNonce
|
||||
* @returns {Promise<{ clientToHost: CryptoKey, hostToClient: CryptoKey }>}
|
||||
*/
|
||||
export const deriveSessionKeys = async (ownPrivateKey, peerPublicKey, handshakeNonce) => {
|
||||
if (handshakeNonce.length !== HANDSHAKE_NONCE_BYTES) {
|
||||
throw new RelayCryptoError('invalid handshake nonce length');
|
||||
}
|
||||
const sharedSecret = await subtle.deriveBits({ name: 'ECDH', public: peerPublicKey }, ownPrivateKey, 256);
|
||||
const hkdfKey = await subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveBits']);
|
||||
const keyMaterial = new Uint8Array(
|
||||
await subtle.deriveBits(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: handshakeNonce,
|
||||
info: new TextEncoder().encode(RELAY_HKDF_INFO),
|
||||
},
|
||||
hkdfKey,
|
||||
SESSION_KEY_BYTES * 2 * 8,
|
||||
),
|
||||
);
|
||||
const importAesKey = (bytes, usage) => subtle.importKey('raw', bytes, { name: 'AES-GCM' }, false, usage);
|
||||
return {
|
||||
clientToHost: await importAesKey(keyMaterial.slice(0, SESSION_KEY_BYTES), ['encrypt', 'decrypt']),
|
||||
hostToClient: await importAesKey(keyMaterial.slice(SESSION_KEY_BYTES), ['encrypt', 'decrypt']),
|
||||
};
|
||||
};
|
||||
|
||||
const writeCounter = (target, offset, counter) => {
|
||||
for (let i = IV_COUNTER_BYTES - 1; i >= 0; i -= 1) {
|
||||
target[offset + i] = Number(counter & 0xffn);
|
||||
counter >>= 8n;
|
||||
}
|
||||
};
|
||||
|
||||
const readCounter = (source, offset) => {
|
||||
let value = 0n;
|
||||
for (let i = 0; i < IV_COUNTER_BYTES; i += 1) {
|
||||
value = (value << 8n) | BigInt(source[offset + i]);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/** @param {CryptoKey} key AES-256-GCM key for this direction */
|
||||
export const createFrameEncryptor = (key) => {
|
||||
const ivPrefix = new Uint8Array(IV_PREFIX_BYTES);
|
||||
globalThis.crypto.getRandomValues(ivPrefix);
|
||||
let counter = 0n;
|
||||
return {
|
||||
/** @param {Uint8Array} plaintext */
|
||||
async encrypt(plaintext) {
|
||||
if (plaintext.length > MAX_PLAINTEXT_FRAME_BYTES) {
|
||||
throw new RelayCryptoError('plaintext frame exceeds maximum size');
|
||||
}
|
||||
counter += 1n;
|
||||
const iv = new Uint8Array(ENCRYPTED_FRAME_IV_BYTES);
|
||||
iv.set(ivPrefix, 0);
|
||||
writeCounter(iv, IV_PREFIX_BYTES, counter);
|
||||
const ciphertext = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext));
|
||||
const frame = new Uint8Array(ENCRYPTED_FRAME_HEADER_BYTES + ciphertext.length);
|
||||
frame[0] = ENCRYPTED_FRAME_VERSION;
|
||||
frame.set(iv, 1);
|
||||
frame.set(ciphertext, ENCRYPTED_FRAME_HEADER_BYTES);
|
||||
return frame;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Enforces strictly increasing per-direction counters: the relay WS preserves
|
||||
// ordering, so any regression or replay means tampering and must fail closed.
|
||||
/** @param {CryptoKey} key AES-256-GCM key for this direction */
|
||||
export const createFrameDecryptor = (key) => {
|
||||
let lastCounter = 0n;
|
||||
return {
|
||||
/** @param {Uint8Array} frame */
|
||||
async decrypt(frame) {
|
||||
if (frame.length < ENCRYPTED_FRAME_HEADER_BYTES + GCM_TAG_BYTES) {
|
||||
throw new RelayCryptoError('encrypted frame too short');
|
||||
}
|
||||
if (frame[0] !== ENCRYPTED_FRAME_VERSION) {
|
||||
throw new RelayCryptoError('unsupported encrypted frame version');
|
||||
}
|
||||
const iv = frame.slice(1, ENCRYPTED_FRAME_HEADER_BYTES);
|
||||
const counter = readCounter(iv, IV_PREFIX_BYTES);
|
||||
if (counter <= lastCounter) {
|
||||
throw new RelayCryptoError('frame counter regression');
|
||||
}
|
||||
let plaintext;
|
||||
try {
|
||||
plaintext = await subtle.decrypt({ name: 'AES-GCM', iv }, key, frame.slice(ENCRYPTED_FRAME_HEADER_BYTES));
|
||||
} catch {
|
||||
throw new RelayCryptoError('frame decryption failed');
|
||||
}
|
||||
lastCounter = counter;
|
||||
return new Uint8Array(plaintext);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
|
||||
/** @param {Uint8Array} bytes */
|
||||
export const bytesToBase64Url = (bytes) => {
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b0 = bytes[i];
|
||||
const b1 = i + 1 < bytes.length ? bytes[i + 1] : undefined;
|
||||
const b2 = i + 2 < bytes.length ? bytes[i + 2] : undefined;
|
||||
out += BASE64URL_ALPHABET[b0 >> 2];
|
||||
out += BASE64URL_ALPHABET[((b0 & 0x03) << 4) | ((b1 ?? 0) >> 4)];
|
||||
if (b1 !== undefined) out += BASE64URL_ALPHABET[((b1 & 0x0f) << 2) | ((b2 ?? 0) >> 6)];
|
||||
if (b2 !== undefined) out += BASE64URL_ALPHABET[b2 & 0x3f];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** @param {string} value */
|
||||
export const base64UrlToBytes = (value) => {
|
||||
if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
|
||||
throw new RelayCryptoError('invalid base64url input');
|
||||
}
|
||||
const out = new Uint8Array(Math.floor((value.length * 3) / 4));
|
||||
let outIndex = 0;
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
for (const char of value) {
|
||||
buffer = (buffer << 6) | BASE64URL_ALPHABET.indexOf(char);
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out[outIndex] = (buffer >> bits) & 0xff;
|
||||
outIndex += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Responder handshake state machine (host side). Mirror of createHostHandshake
|
||||
// in packages/ui/src/lib/relay/handshake.ts.
|
||||
//
|
||||
// Fail-closed rules (from the spec):
|
||||
// - a repeated identical `hello` re-sends `ready` (client retry race);
|
||||
// - a `hello` with a DIFFERENT key on an established channel is a rekey
|
||||
// attack -> close 1008, never rekey in place;
|
||||
// - plaintext after `ready`, or any decrypt failure -> close 1011.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const parseHandshakeMessage = (raw) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
if (parsed.v !== RELAY_PROTOCOL_VERSION) return null;
|
||||
// Unknown/missing capability flag = false = legacy behavior.
|
||||
const batch = parsed.batch === true;
|
||||
if (parsed.t === 'ready') {
|
||||
return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch };
|
||||
}
|
||||
if (parsed.t === 'hello' && typeof parsed.nonce === 'string' && typeof parsed.clientPubJwk === 'object' && parsed.clientPubJwk !== null) {
|
||||
return { t: 'hello', v: RELAY_PROTOCOL_VERSION, clientPubJwk: parsed.clientPubJwk, nonce: parsed.nonce, batch };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const failClosed = (reason) => ({
|
||||
type: 'fail',
|
||||
closeCode: RelayCloseCode.ChannelFailure,
|
||||
reason,
|
||||
});
|
||||
|
||||
/**
|
||||
* Host (responder) handshake. Feed every inbound text frame to `handleText`;
|
||||
* it returns one of:
|
||||
* { type: 'send-text', text } — send this plaintext frame
|
||||
* { type: 'established', channel, replyText } — send replyText first, then switch to encrypted frames
|
||||
* { type: 'ignore' } — drop the frame
|
||||
* { type: 'fail', closeCode, reason } — close the socket with closeCode
|
||||
* @param {CryptoKey} hostEncPrivateKey long-lived ECDH private key
|
||||
* @param {{ batch?: boolean }} [options] `batch` defaults true; set false to force legacy behavior
|
||||
*/
|
||||
export const createHostHandshake = (hostEncPrivateKey, options = {}) => {
|
||||
const localBatch = options.batch !== false;
|
||||
let established = false;
|
||||
let acceptedClientKeyFingerprint = null;
|
||||
let readyText = null;
|
||||
let negotiatedBatch = false;
|
||||
return {
|
||||
get established() {
|
||||
return established;
|
||||
},
|
||||
/** @param {string} raw */
|
||||
async handleText(raw) {
|
||||
const message = parseHandshakeMessage(raw);
|
||||
if (message?.t !== 'hello') {
|
||||
if (established) {
|
||||
return failClosed('plaintext frame on established channel');
|
||||
}
|
||||
return { type: 'ignore' };
|
||||
}
|
||||
const fingerprint = publicKeyJwkFingerprint(message.clientPubJwk);
|
||||
if (acceptedClientKeyFingerprint !== null) {
|
||||
if (fingerprint === acceptedClientKeyFingerprint && readyText !== null) {
|
||||
// Client retried `hello` before our `ready` arrived — answer again.
|
||||
return { type: 'send-text', text: readyText };
|
||||
}
|
||||
return { type: 'fail', closeCode: RelayCloseCode.RekeyMismatch, reason: 'rekey mismatch' };
|
||||
}
|
||||
let clientPublicKey;
|
||||
let nonce;
|
||||
try {
|
||||
clientPublicKey = await importEcdhPublicKey(message.clientPubJwk);
|
||||
nonce = base64UrlToBytes(message.nonce);
|
||||
} catch {
|
||||
return failClosed('malformed hello');
|
||||
}
|
||||
let keys;
|
||||
try {
|
||||
keys = await deriveSessionKeys(hostEncPrivateKey, clientPublicKey, nonce);
|
||||
} catch {
|
||||
return failClosed('key derivation failed');
|
||||
}
|
||||
acceptedClientKeyFingerprint = fingerprint;
|
||||
// Batching runs only if both peers advertised it.
|
||||
negotiatedBatch = localBatch && message.batch === true;
|
||||
readyText = JSON.stringify(
|
||||
negotiatedBatch
|
||||
? { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch: true }
|
||||
: { t: 'ready', v: RELAY_PROTOCOL_VERSION },
|
||||
);
|
||||
established = true;
|
||||
return {
|
||||
type: 'established',
|
||||
batch: negotiatedBatch,
|
||||
replyText: readyText,
|
||||
channel: {
|
||||
encryptor: createFrameEncryptor(keys.hostToClient),
|
||||
decryptor: createFrameDecryptor(keys.clientToHost),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
base64UrlToBytes,
|
||||
bytesToBase64Url,
|
||||
createFrameDecryptor,
|
||||
createFrameEncryptor,
|
||||
createHostHandshake,
|
||||
deriveSessionKeys,
|
||||
exportPublicKeyJwk,
|
||||
generateEcdhKeyPair,
|
||||
generateHandshakeNonce,
|
||||
RELAY_PROTOCOL_VERSION,
|
||||
} from './e2ee.js';
|
||||
|
||||
const subtle = globalThis.crypto.subtle;
|
||||
|
||||
// A minimal client-side initiator so the host handshake can be exercised
|
||||
// end-to-end without importing the browser TS modules.
|
||||
const createClientHandshake = async (hostEncPubJwk) => {
|
||||
const hostPublicKey = await subtle.importKey(
|
||||
'jwk',
|
||||
{ kty: hostEncPubJwk.kty, crv: hostEncPubJwk.crv, x: hostEncPubJwk.x, y: hostEncPubJwk.y, ext: true },
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
[],
|
||||
);
|
||||
const ephemeral = await generateEcdhKeyPair();
|
||||
const nonce = generateHandshakeNonce();
|
||||
const helloText = JSON.stringify({
|
||||
t: 'hello',
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
clientPubJwk: await exportPublicKeyJwk(ephemeral.publicKey),
|
||||
nonce: bytesToBase64Url(nonce),
|
||||
});
|
||||
const deriveChannel = async () => {
|
||||
const keys = await deriveSessionKeys(ephemeral.privateKey, hostPublicKey, nonce);
|
||||
return {
|
||||
encryptor: createFrameEncryptor(keys.clientToHost),
|
||||
decryptor: createFrameDecryptor(keys.hostToClient),
|
||||
};
|
||||
};
|
||||
return { helloText, deriveChannel };
|
||||
};
|
||||
|
||||
describe('relay e2ee', () => {
|
||||
it('round-trips frames in both directions after handshake', async () => {
|
||||
const hostKeys = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeys.publicKey);
|
||||
const host = createHostHandshake(hostKeys.privateKey);
|
||||
const client = await createClientHandshake(hostPubJwk);
|
||||
|
||||
const action = await host.handleText(client.helloText);
|
||||
expect(action.type).toBe('established');
|
||||
const hostChannel = action.channel;
|
||||
const clientChannel = await client.deriveChannel();
|
||||
|
||||
const c2h = new TextEncoder().encode('client-to-host payload');
|
||||
const decodedAtHost = await hostChannel.decryptor.decrypt(await clientChannel.encryptor.encrypt(c2h));
|
||||
expect(new TextDecoder().decode(decodedAtHost)).toBe('client-to-host payload');
|
||||
|
||||
const h2c = new TextEncoder().encode('host-to-client payload');
|
||||
const decodedAtClient = await clientChannel.decryptor.decrypt(await hostChannel.encryptor.encrypt(h2c));
|
||||
expect(new TextDecoder().decode(decodedAtClient)).toBe('host-to-client payload');
|
||||
});
|
||||
|
||||
it('rejects tampered ciphertext', async () => {
|
||||
const keyBytes = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(keyBytes);
|
||||
const key = await subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||
const enc = createFrameEncryptor(key);
|
||||
const dec = createFrameDecryptor(key);
|
||||
const frame = await enc.encrypt(new Uint8Array([1, 2, 3]));
|
||||
frame[frame.length - 1] ^= 0xff;
|
||||
await expect(dec.decrypt(frame)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects counter regression / replay', async () => {
|
||||
const keyBytes = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(keyBytes);
|
||||
const key = await subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||
const enc = createFrameEncryptor(key);
|
||||
const dec = createFrameDecryptor(key);
|
||||
const first = await enc.encrypt(new Uint8Array([9]));
|
||||
await dec.decrypt(first);
|
||||
// Replaying the same frame (counter no longer strictly increasing) fails.
|
||||
await expect(dec.decrypt(first)).rejects.toThrow('frame counter regression');
|
||||
});
|
||||
|
||||
it('re-sends ready on identical re-hello and fails on rekey', async () => {
|
||||
const hostKeys = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeys.publicKey);
|
||||
const host = createHostHandshake(hostKeys.privateKey);
|
||||
const client = await createClientHandshake(hostPubJwk);
|
||||
|
||||
const first = await host.handleText(client.helloText);
|
||||
expect(first.type).toBe('established');
|
||||
|
||||
const repeat = await host.handleText(client.helloText);
|
||||
expect(repeat.type).toBe('send-text');
|
||||
expect(repeat.text).toBe(first.replyText);
|
||||
|
||||
const other = await createClientHandshake(hostPubJwk);
|
||||
const rekey = await host.handleText(other.helloText);
|
||||
expect(rekey.type).toBe('fail');
|
||||
expect(rekey.closeCode).toBe(1008);
|
||||
});
|
||||
|
||||
it('fails closed on plaintext after ready', async () => {
|
||||
const hostKeys = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeys.publicKey);
|
||||
const host = createHostHandshake(hostKeys.privateKey);
|
||||
const client = await createClientHandshake(hostPubJwk);
|
||||
await host.handleText(client.helloText);
|
||||
const action = await host.handleText(JSON.stringify({ hello: 'not a handshake' }));
|
||||
expect(action.type).toBe('fail');
|
||||
expect(action.closeCode).toBe(1011);
|
||||
});
|
||||
|
||||
it('base64url helpers round-trip', () => {
|
||||
const bytes = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]);
|
||||
expect(Array.from(base64UrlToBytes(bytesToBase64Url(bytes)))).toEqual(Array.from(bytes));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
// Long-lived relay host client: maintains the signed `host-control` socket to
|
||||
// the relay, and per connected client a signed `host-data` socket that runs the
|
||||
// responder E2EE handshake and feeds decrypted frames into a tunnel-host
|
||||
// dispatcher. Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 1).
|
||||
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import { RELAY_PROTOCOL_VERSION, RelayCloseCode, createHostHandshake } from './e2ee.js';
|
||||
import { createOutboundFrameBatcher, decodeFrameBatch } from './tunnel-codec.js';
|
||||
import { createTunnelHost } from './tunnel-host.js';
|
||||
|
||||
const BACKOFF_BASE_MS = 1000;
|
||||
const BACKOFF_CAP_MS = 30000;
|
||||
const DATA_SOCKET_OPEN_TIMEOUT_MS = 15000;
|
||||
const DEFAULT_BATCH_WINDOW_MS = 150;
|
||||
|
||||
// Resolve the frame-batching flush window: explicit option wins, then env, then
|
||||
// the 150 ms default. Only applies on directions where batching was negotiated.
|
||||
const resolveBatchWindowMs = (option) => {
|
||||
if (Number.isFinite(option) && option >= 0) return option;
|
||||
const envValue = Number.parseInt(process.env.OPENCHAMBER_RELAY_BATCH_WINDOW_MS ?? '', 10);
|
||||
if (Number.isFinite(envValue) && envValue >= 0) return envValue;
|
||||
return DEFAULT_BATCH_WINDOW_MS;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* relayUrl: string,
|
||||
* identity: { serverId: string, hostEncPrivateKey: CryptoKey, signRelayAuth: (role: string, connectionId?: string | null) => { ts: number, sig: string, pk: string } },
|
||||
* localPort?: number,
|
||||
* getLocalPort?: () => number,
|
||||
* onStatus?: (status: { state: string, lastError: string | null, connectedClients: number }) => void,
|
||||
* logger?: Pick<Console, 'warn'>,
|
||||
* }} options
|
||||
*/
|
||||
export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch }) => {
|
||||
const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
|
||||
const localBatch = batch !== false;
|
||||
const resolvedBatchWindowMs = resolveBatchWindowMs(batchWindowMs);
|
||||
|
||||
let stopped = false;
|
||||
let state = 'connecting';
|
||||
let lastError = null;
|
||||
let controlSocket = null;
|
||||
let reconnectTimer = null;
|
||||
let consecutiveFailures = 0;
|
||||
/** @type {Map<string, { socket: WebSocket, tunnel: ReturnType<typeof createTunnelHost> | null, openTimer: NodeJS.Timeout | null }>} */
|
||||
const dataSockets = new Map();
|
||||
|
||||
const emitStatus = () => {
|
||||
try {
|
||||
onStatus?.({ state, lastError, connectedClients: dataSockets.size });
|
||||
} catch {
|
||||
// status consumers must not break the transport
|
||||
}
|
||||
};
|
||||
|
||||
const setState = (nextState, error) => {
|
||||
state = nextState;
|
||||
if (error !== undefined) lastError = error;
|
||||
emitStatus();
|
||||
};
|
||||
|
||||
const buildSocketUrl = (role, connectionId) => {
|
||||
const url = new URL(relayUrl);
|
||||
url.searchParams.set('v', String(RELAY_PROTOCOL_VERSION));
|
||||
url.searchParams.set('role', role);
|
||||
url.searchParams.set('serverId', identity.serverId);
|
||||
if (connectionId) url.searchParams.set('connectionId', connectionId);
|
||||
const auth = identity.signRelayAuth(role, connectionId ?? null);
|
||||
url.searchParams.set('ts', String(auth.ts));
|
||||
url.searchParams.set('sig', auth.sig);
|
||||
url.searchParams.set('pk', auth.pk);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const teardownDataSocket = (connectionId, closeCode, reason) => {
|
||||
const entry = dataSockets.get(connectionId);
|
||||
if (!entry) return;
|
||||
dataSockets.delete(connectionId);
|
||||
if (entry.openTimer) clearTimeout(entry.openTimer);
|
||||
entry.batcher?.dispose();
|
||||
entry.tunnel?.close();
|
||||
try {
|
||||
if (entry.socket.readyState === WebSocket.OPEN || entry.socket.readyState === WebSocket.CONNECTING) {
|
||||
if (closeCode) entry.socket.close(closeCode, reason ?? '');
|
||||
else entry.socket.terminate();
|
||||
}
|
||||
} catch {
|
||||
// socket already gone
|
||||
}
|
||||
emitStatus();
|
||||
};
|
||||
|
||||
const openDataSocket = (connectionId) => {
|
||||
if (stopped || dataSockets.has(connectionId)) return;
|
||||
|
||||
let socket;
|
||||
try {
|
||||
socket = new WebSocket(buildSocketUrl('host-data', connectionId));
|
||||
} catch (error) {
|
||||
logger.warn(`[Relay] host-data dial failed: ${error?.message ?? error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = { socket, tunnel: null, openTimer: null, batcher: null };
|
||||
dataSockets.set(connectionId, entry);
|
||||
entry.openTimer = setTimeout(() => {
|
||||
logger.warn('[Relay] host-data socket open timeout');
|
||||
teardownDataSocket(connectionId);
|
||||
}, DATA_SOCKET_OPEN_TIMEOUT_MS);
|
||||
|
||||
const handshake = createHostHandshake(identity.hostEncPrivateKey, { batch: localBatch });
|
||||
let channel = null;
|
||||
let batchNegotiated = false;
|
||||
// Serialize async message handling so encrypted frame order (and the
|
||||
// strictly-increasing decrypt counter) is preserved.
|
||||
let processing = Promise.resolve();
|
||||
// Serialize encrypt+send so the per-direction IV counter reaches the wire in
|
||||
// encryption order. One encrypt() == one WS message == one counter tick,
|
||||
// whether it carries a batch or a lone frame.
|
||||
let sendChain = Promise.resolve();
|
||||
const sendEncryptedPlaintext = (plaintext) => {
|
||||
sendChain = sendChain
|
||||
.then(async () => {
|
||||
if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN || !channel) return;
|
||||
const encrypted = await channel.encryptor.encrypt(plaintext);
|
||||
socket.send(encrypted, { binary: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn(`[Relay] host-data send failed: ${error?.message ?? error}`);
|
||||
});
|
||||
};
|
||||
|
||||
const failChannel = (closeCode, reason) => {
|
||||
// connectionId + reason only — never payload contents.
|
||||
logger.warn(`[Relay] data channel failed connectionId=${connectionId} reason=${reason ?? 'unknown'}`);
|
||||
teardownDataSocket(connectionId, closeCode, reason);
|
||||
};
|
||||
|
||||
const handleMessage = async (data, isBinary) => {
|
||||
const current = dataSockets.get(connectionId);
|
||||
if (current !== entry) return;
|
||||
|
||||
if (!isBinary) {
|
||||
const action = await handshake.handleText(data.toString('utf8'));
|
||||
if (action.type === 'send-text') {
|
||||
socket.send(action.text);
|
||||
} else if (action.type === 'established') {
|
||||
channel = action.channel;
|
||||
batchNegotiated = action.batch === true;
|
||||
entry.batcher = batchNegotiated
|
||||
? createOutboundFrameBatcher({ windowMs: resolvedBatchWindowMs, sendBatch: sendEncryptedPlaintext })
|
||||
: null;
|
||||
entry.tunnel = createTunnelHost({
|
||||
connectionId,
|
||||
getLocalPort: resolveLocalPort,
|
||||
getBufferedAmount: () => socket.bufferedAmount,
|
||||
sendFrame: (plaintextFrame) => {
|
||||
if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN) return;
|
||||
if (entry.batcher) entry.batcher.enqueue(plaintextFrame);
|
||||
else sendEncryptedPlaintext(plaintextFrame);
|
||||
},
|
||||
});
|
||||
if (action.replyText) socket.send(action.replyText);
|
||||
} else if (action.type === 'fail') {
|
||||
failChannel(action.closeCode, action.reason);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!channel || !entry.tunnel) {
|
||||
// Encrypted traffic before the handshake completed: fail closed.
|
||||
failChannel(RelayCloseCode.ChannelFailure, 'binary frame before handshake');
|
||||
return;
|
||||
}
|
||||
let plaintext;
|
||||
try {
|
||||
plaintext = await channel.decryptor.decrypt(new Uint8Array(data));
|
||||
} catch {
|
||||
failChannel(RelayCloseCode.ChannelFailure, 'frame decryption failed');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (batchNegotiated) {
|
||||
// One encrypted message may carry several tunnel frames; dispatch each
|
||||
// in order through the same per-frame handling as legacy.
|
||||
for (const frame of decodeFrameBatch(plaintext)) {
|
||||
if (dataSockets.get(connectionId) !== entry) return;
|
||||
await entry.tunnel.handleFrame(frame);
|
||||
}
|
||||
} else {
|
||||
await entry.tunnel.handleFrame(plaintext);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[Relay] tunnel frame handling failed: ${error?.message ?? error}`);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('open', () => {
|
||||
if (entry.openTimer) {
|
||||
clearTimeout(entry.openTimer);
|
||||
entry.openTimer = null;
|
||||
}
|
||||
emitStatus();
|
||||
});
|
||||
socket.on('message', (data, isBinary) => {
|
||||
processing = processing
|
||||
.then(() => handleMessage(data, isBinary))
|
||||
.catch((error) => {
|
||||
logger.warn(`[Relay] data socket message failed: ${error?.message ?? error}`);
|
||||
failChannel(RelayCloseCode.ChannelFailure, 'internal error');
|
||||
});
|
||||
});
|
||||
socket.on('close', () => {
|
||||
teardownDataSocket(connectionId);
|
||||
});
|
||||
socket.on('error', (error) => {
|
||||
logger.warn(`[Relay] host-data socket error: ${error?.message ?? error}`);
|
||||
});
|
||||
};
|
||||
|
||||
const handleControlMessage = (raw) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object') return;
|
||||
if (message.type === 'sync' && Array.isArray(message.connectionIds)) {
|
||||
const wanted = new Set(message.connectionIds.filter((id) => typeof id === 'string' && id.length > 0));
|
||||
for (const connectionId of [...dataSockets.keys()]) {
|
||||
if (!wanted.has(connectionId)) teardownDataSocket(connectionId);
|
||||
}
|
||||
for (const connectionId of wanted) {
|
||||
openDataSocket(connectionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === 'connected' && typeof message.connectionId === 'string') {
|
||||
openDataSocket(message.connectionId);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'disconnected' && typeof message.connectionId === 'string') {
|
||||
teardownDataSocket(message.connectionId);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (stopped || reconnectTimer) return;
|
||||
const delay = Math.min(BACKOFF_BASE_MS * 2 ** consecutiveFailures, BACKOFF_CAP_MS);
|
||||
consecutiveFailures += 1;
|
||||
setState('reconnecting');
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connectControl();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const connectControl = () => {
|
||||
if (stopped) return;
|
||||
setState(consecutiveFailures === 0 ? 'connecting' : 'reconnecting');
|
||||
|
||||
let socket;
|
||||
try {
|
||||
socket = new WebSocket(buildSocketUrl('host-control'));
|
||||
} catch (error) {
|
||||
lastError = error?.message ?? String(error);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
controlSocket = socket;
|
||||
|
||||
socket.on('open', () => {
|
||||
if (controlSocket !== socket) return;
|
||||
consecutiveFailures = 0;
|
||||
setState('connected', null);
|
||||
});
|
||||
socket.on('message', (data, isBinary) => {
|
||||
if (controlSocket !== socket || isBinary) return;
|
||||
handleControlMessage(data.toString('utf8'));
|
||||
});
|
||||
socket.on('error', (error) => {
|
||||
if (controlSocket !== socket) return;
|
||||
lastError = error?.message ?? String(error);
|
||||
});
|
||||
socket.on('close', (code, reasonBuffer) => {
|
||||
if (controlSocket !== socket) return;
|
||||
controlSocket = null;
|
||||
const reason = reasonBuffer ? reasonBuffer.toString('utf8') : '';
|
||||
if (!lastError && code && code !== 1000) {
|
||||
lastError = `control socket closed (${code}${reason ? `: ${reason}` : ''})`;
|
||||
}
|
||||
// Data sockets ride their own relay connections; the relay keeps clients
|
||||
// alive through a 30 s control-reconnect grace window, so leave them up.
|
||||
scheduleReconnect();
|
||||
});
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
for (const connectionId of [...dataSockets.keys()]) {
|
||||
teardownDataSocket(connectionId, 1001, 'host stopping');
|
||||
}
|
||||
const socket = controlSocket;
|
||||
controlSocket = null;
|
||||
if (socket) {
|
||||
try {
|
||||
socket.close(1001, 'host stopping');
|
||||
} catch {
|
||||
socket.terminate();
|
||||
}
|
||||
}
|
||||
setState('disabled');
|
||||
};
|
||||
|
||||
connectControl();
|
||||
|
||||
return {
|
||||
stop,
|
||||
getStatus: () => ({ state, lastError, connectedClients: dataSockets.size }),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
// Integration test: fake relay (minimal Layer 1) + real host-client + a scripted
|
||||
// client using the JS e2ee initiator. Verifies the full handshake and a tunneled
|
||||
// HTTP GET /health, and asserts only binary frames cross the relay post-handshake.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
|
||||
import { startRelayHost } from './host-client.js';
|
||||
import {
|
||||
bytesToBase64Url,
|
||||
createFrameDecryptor,
|
||||
createFrameEncryptor,
|
||||
deriveSessionKeys,
|
||||
exportPublicKeyJwk,
|
||||
generateEcdhKeyPair,
|
||||
generateHandshakeNonce,
|
||||
importEcdhPrivateKey,
|
||||
RELAY_PROTOCOL_VERSION,
|
||||
} from './e2ee.js';
|
||||
import {
|
||||
TunnelFrameType,
|
||||
decodeTunnelFrame,
|
||||
encodeJsonPayload,
|
||||
encodeTunnelFrame,
|
||||
} from './tunnel-codec.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake relay: routes host-control <-> host-data <-> client by (serverId, connectionId).
|
||||
// Forwards frames verbatim, never inspects them.
|
||||
// ---------------------------------------------------------------------------
|
||||
const startFakeRelay = () => {
|
||||
const server = http.createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
const state = {
|
||||
control: null,
|
||||
hostData: new Map(), // connectionId -> ws
|
||||
clients: new Map(), // connectionId -> ws
|
||||
buffered: new Map(), // connectionId -> [[data, isBinary]] awaiting host-data
|
||||
relayFrames: [], // observed forwarded frames (for plaintext assertions)
|
||||
};
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const role = url.searchParams.get('role');
|
||||
const connectionId = url.searchParams.get('connectionId');
|
||||
|
||||
if (role === 'host-control') {
|
||||
state.control = ws;
|
||||
// Announce any already-waiting clients.
|
||||
ws.send(JSON.stringify({ type: 'sync', connectionIds: [...state.clients.keys()] }));
|
||||
for (const id of state.clients.keys()) {
|
||||
ws.send(JSON.stringify({ type: 'connected', connectionId: id }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === 'host-data') {
|
||||
state.hostData.set(connectionId, ws);
|
||||
// Flush any client frames that arrived before this socket attached.
|
||||
const buffered = state.buffered.get(connectionId) || [];
|
||||
state.buffered.delete(connectionId);
|
||||
for (const [data, isBinary] of buffered) ws.send(data, { binary: isBinary });
|
||||
ws.on('message', (data, isBinary) => {
|
||||
state.relayFrames.push({ from: 'host', isBinary });
|
||||
const client = state.clients.get(connectionId);
|
||||
if (client && client.readyState === WebSocket.OPEN) client.send(data, { binary: isBinary });
|
||||
});
|
||||
ws.on('close', () => state.hostData.delete(connectionId));
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === 'client') {
|
||||
state.clients.set(connectionId, ws);
|
||||
ws.on('message', (data, isBinary) => {
|
||||
state.relayFrames.push({ from: 'client', isBinary });
|
||||
const host = state.hostData.get(connectionId);
|
||||
if (host && host.readyState === WebSocket.OPEN) {
|
||||
host.send(data, { binary: isBinary });
|
||||
} else {
|
||||
const queue = state.buffered.get(connectionId) || [];
|
||||
queue.push([data, isBinary]);
|
||||
state.buffered.set(connectionId, queue);
|
||||
}
|
||||
});
|
||||
ws.on('close', () => state.clients.delete(connectionId));
|
||||
if (state.control && state.control.readyState === WebSocket.OPEN) {
|
||||
state.control.send(JSON.stringify({ type: 'connected', connectionId }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address().port;
|
||||
resolve({
|
||||
wsUrl: `ws://127.0.0.1:${port}`,
|
||||
state,
|
||||
stop: () => new Promise((r) => {
|
||||
wss.close();
|
||||
server.close(() => r());
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// A stub loopback origin serving /health.
|
||||
const startLoopbackOrigin = () =>
|
||||
new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/health') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, service: 'stub', relayConn: req.headers['x-openchamber-relay-connection'] || null }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => resolve({ port: server.address().port, stop: () => new Promise((r) => server.close(() => r())) }));
|
||||
});
|
||||
|
||||
// Build the host identity around a fresh keypair (ECDH enc key + ECDSA sign key).
|
||||
const buildIdentity = async () => {
|
||||
const enc = await generateEcdhKeyPair();
|
||||
const encPrivJwk = await globalThis.crypto.subtle.exportKey('jwk', enc.privateKey);
|
||||
const { privateKey: signPriv, publicKey: signPub } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const signPubJwk = signPub.export({ format: 'jwk' });
|
||||
const canonical = JSON.stringify({ crv: signPubJwk.crv, kty: signPubJwk.kty, x: signPubJwk.x, y: signPubJwk.y });
|
||||
const serverId = crypto.createHash('sha256').update(canonical).digest('base64url');
|
||||
return {
|
||||
serverId,
|
||||
hostEncPubJwk: await exportPublicKeyJwk(enc.publicKey),
|
||||
hostEncPrivateKey: await importEcdhPrivateKey(encPrivJwk),
|
||||
signRelayAuth: (role, connectionId) => {
|
||||
const ts = Date.now();
|
||||
const sig = crypto
|
||||
.sign('SHA256', Buffer.from(`${ts}.${serverId}.${role}.${connectionId ?? ''}`), { key: signPriv, dsaEncoding: 'ieee-p1363' })
|
||||
.toString('base64url');
|
||||
return { ts, sig, pk: Buffer.from(canonical, 'utf8').toString('base64url') };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Scripted client using the JS initiator: connects, handshakes, does a GET.
|
||||
const runScriptedClient = async ({ relayUrl, serverId, hostEncPubJwk }) => {
|
||||
const connectionId = 'conn-test-1';
|
||||
const url = new URL(`${relayUrl}/`);
|
||||
url.searchParams.set('v', String(RELAY_PROTOCOL_VERSION));
|
||||
url.searchParams.set('role', 'client');
|
||||
url.searchParams.set('serverId', serverId);
|
||||
url.searchParams.set('connectionId', connectionId);
|
||||
const ws = new WebSocket(url.toString());
|
||||
|
||||
const hostPub = await globalThis.crypto.subtle.importKey(
|
||||
'jwk',
|
||||
{ kty: hostEncPubJwk.kty, crv: hostEncPubJwk.crv, x: hostEncPubJwk.x, y: hostEncPubJwk.y, ext: true },
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
[],
|
||||
);
|
||||
const ephemeral = await generateEcdhKeyPair();
|
||||
const nonce = generateHandshakeNonce();
|
||||
|
||||
let channel = null;
|
||||
const responseChunks = [];
|
||||
let responseStatus = null;
|
||||
let resolveDone;
|
||||
const done = new Promise((resolve) => {
|
||||
resolveDone = resolve;
|
||||
});
|
||||
|
||||
ws.on('open', async () => {
|
||||
ws.send(JSON.stringify({
|
||||
t: 'hello',
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
clientPubJwk: await exportPublicKeyJwk(ephemeral.publicKey),
|
||||
nonce: bytesToBase64Url(nonce),
|
||||
}));
|
||||
});
|
||||
|
||||
// Serialize message handling: an async ws handler runs per-message tasks
|
||||
// concurrently, letting StreamEnd overtake HttpBody and trip the decryptor's
|
||||
// strict counter ordering (the production tunnel client chains decrypts).
|
||||
let processing = Promise.resolve();
|
||||
const handleMessage = async (data, isBinary) => {
|
||||
if (!isBinary) {
|
||||
const msg = JSON.parse(data.toString('utf8'));
|
||||
if (msg.t === 'ready') {
|
||||
const keys = await deriveSessionKeys(ephemeral.privateKey, hostPub, nonce);
|
||||
channel = {
|
||||
encryptor: createFrameEncryptor(keys.clientToHost),
|
||||
decryptor: createFrameDecryptor(keys.hostToClient),
|
||||
};
|
||||
// Send an HTTP GET /health over stream 1.
|
||||
const req = encodeTunnelFrame(TunnelFrameType.HttpRequest, 1, encodeJsonPayload({
|
||||
method: 'GET',
|
||||
path: '/health',
|
||||
query: '',
|
||||
headers: { accept: 'application/json' },
|
||||
}));
|
||||
ws.send(await channel.encryptor.encrypt(req), { binary: true });
|
||||
ws.send(await channel.encryptor.encrypt(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array(0))), { binary: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!channel) return;
|
||||
const plaintext = await channel.decryptor.decrypt(new Uint8Array(data));
|
||||
const frame = decodeTunnelFrame(plaintext);
|
||||
if (frame.frameType === TunnelFrameType.HttpResponse) {
|
||||
responseStatus = JSON.parse(new TextDecoder().decode(frame.payload)).status;
|
||||
} else if (frame.frameType === TunnelFrameType.HttpBody) {
|
||||
responseChunks.push(frame.payload);
|
||||
} else if (frame.frameType === TunnelFrameType.StreamEnd) {
|
||||
const total = responseChunks.reduce((n, c) => n + c.length, 0);
|
||||
const body = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const c of responseChunks) {
|
||||
body.set(c, off);
|
||||
off += c.length;
|
||||
}
|
||||
resolveDone({ status: responseStatus, body: JSON.parse(new TextDecoder().decode(body)) });
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
ws.on('message', (data, isBinary) => {
|
||||
processing = processing.then(() => handleMessage(data, isBinary));
|
||||
});
|
||||
|
||||
return done;
|
||||
};
|
||||
|
||||
describe('relay host-client integration', () => {
|
||||
let relay;
|
||||
let origin;
|
||||
let host;
|
||||
|
||||
beforeAll(async () => {
|
||||
relay = await startFakeRelay();
|
||||
origin = await startLoopbackOrigin();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
host?.stop();
|
||||
await relay?.stop();
|
||||
await origin?.stop();
|
||||
});
|
||||
|
||||
it('tunnels an HTTP GET /health with only binary frames post-handshake', async () => {
|
||||
const identity = await buildIdentity();
|
||||
host = startRelayHost({
|
||||
relayUrl: `${relay.wsUrl}/`,
|
||||
identity,
|
||||
getLocalPort: () => origin.port,
|
||||
onStatus: () => {},
|
||||
logger: { warn: () => {} },
|
||||
});
|
||||
|
||||
// Give the control socket a moment to connect before the client arrives.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const result = await runScriptedClient({
|
||||
relayUrl: relay.wsUrl,
|
||||
serverId: identity.serverId,
|
||||
hostEncPubJwk: identity.hostEncPubJwk,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.ok).toBe(true);
|
||||
expect(result.body.relayConn).toBe('conn-test-1');
|
||||
|
||||
// Every forwarded frame after the two plaintext handshake frames (client
|
||||
// hello, host ready) must be binary.
|
||||
const forwarded = relay.state.relayFrames;
|
||||
const plaintextForwarded = forwarded.filter((f) => !f.isBinary);
|
||||
expect(plaintextForwarded.length).toBe(2); // hello + ready only
|
||||
expect(forwarded.filter((f) => f.isBinary).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// Host relay identity: the EXISTING ECDSA P-256 signing keypair (shared with
|
||||
// the push relay via signing-key.js — same storage, same serverId) plus a NEW
|
||||
// long-lived ECDH P-256 encryption keypair for the E2EE channel (WebCrypto
|
||||
// keys are single-purpose, so signing and encryption keys must differ).
|
||||
// The encryption keypair is persisted as `settings.relayEncryptionKey =
|
||||
// { privateJwk, publicJwk }`, mirroring the relaySigningKey precedent.
|
||||
|
||||
import {
|
||||
canonicalPublicJwkString,
|
||||
deriveServerId,
|
||||
getOrCreateRelaySigningKeypair,
|
||||
signRelayMessage,
|
||||
} from './signing-key.js';
|
||||
import { exportPublicKeyJwk, generateEcdhKeyPair, importEcdhPrivateKey } from './e2ee.js';
|
||||
|
||||
const isJwkPair = (value) => Boolean(value && typeof value === 'object' && value.privateJwk && value.publicJwk);
|
||||
|
||||
/**
|
||||
* @param {{ crypto: typeof import('node:crypto'), readSettingsFromDiskMigrated: () => Promise<object>, writeSettingsToDisk: (settings: object) => Promise<void> }} deps
|
||||
*/
|
||||
export const createRelayIdentityRuntime = (deps) => {
|
||||
const { crypto, readSettingsFromDiskMigrated, writeSettingsToDisk } = deps;
|
||||
|
||||
let cachedIdentity = null;
|
||||
|
||||
const getOrCreateEncryptionKeypair = async () => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const existing = settings?.relayEncryptionKey;
|
||||
if (isJwkPair(existing)) {
|
||||
return existing;
|
||||
}
|
||||
const keyPair = await generateEcdhKeyPair();
|
||||
const privateJwk = await globalThis.crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
||||
const publicJwk = await exportPublicKeyJwk(keyPair.publicKey);
|
||||
await writeSettingsToDisk({ ...settings, relayEncryptionKey: { privateJwk, publicJwk } });
|
||||
return { privateJwk, publicJwk };
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {Promise<{
|
||||
* serverId: string,
|
||||
* hostEncPubJwk: JsonWebKey,
|
||||
* hostEncPrivateKey: CryptoKey,
|
||||
* signRelayAuth: (role: string, connectionId?: string | null) => { ts: number, sig: string, pk: string },
|
||||
* }>}
|
||||
*/
|
||||
const getRelayIdentity = async () => {
|
||||
if (cachedIdentity) return cachedIdentity;
|
||||
const signing = await getOrCreateRelaySigningKeypair({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk });
|
||||
const serverId = deriveServerId({ crypto }, signing.publicJwk);
|
||||
const encryption = await getOrCreateEncryptionKeypair();
|
||||
const hostEncPrivateKey = await importEcdhPrivateKey(encryption.privateJwk);
|
||||
const pk = Buffer.from(canonicalPublicJwkString(signing.publicJwk), 'utf8').toString('base64url');
|
||||
|
||||
// Relay-layer auth for host-control / host-data upgrades. Signature payload
|
||||
// string is `${ts}.${serverId}.${role}.${connectionId ?? ""}` (spec Layer 1).
|
||||
const signRelayAuth = (role, connectionId) => {
|
||||
const ts = Date.now();
|
||||
const sig = signRelayMessage({ crypto }, signing.privateKey, `${ts}.${serverId}.${role}.${connectionId ?? ''}`);
|
||||
return { ts, sig, pk };
|
||||
};
|
||||
|
||||
cachedIdentity = {
|
||||
serverId,
|
||||
hostEncPubJwk: encryption.publicJwk,
|
||||
hostEncPrivateKey,
|
||||
signRelayAuth,
|
||||
};
|
||||
return cachedIdentity;
|
||||
};
|
||||
|
||||
return { getRelayIdentity };
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { createRelayIdentityRuntime } from './identity.js';
|
||||
import { canonicalPublicJwkString } from './signing-key.js';
|
||||
|
||||
// In-memory settings store standing in for the on-disk settings file.
|
||||
const makeSettingsStore = (initial = {}) => {
|
||||
let settings = { ...initial };
|
||||
return {
|
||||
readSettingsFromDiskMigrated: async () => ({ ...settings }),
|
||||
writeSettingsToDisk: async (next) => {
|
||||
settings = { ...next };
|
||||
},
|
||||
peek: () => settings,
|
||||
};
|
||||
};
|
||||
|
||||
describe('relay identity', () => {
|
||||
it('derives a stable serverId from the signing key and persists both keypairs', async () => {
|
||||
const store = makeSettingsStore();
|
||||
const runtime = createRelayIdentityRuntime({ crypto, ...store });
|
||||
const identity = await runtime.getRelayIdentity();
|
||||
|
||||
const stored = store.peek();
|
||||
expect(stored.relaySigningKey).toBeDefined();
|
||||
expect(stored.relayEncryptionKey).toBeDefined();
|
||||
|
||||
const expectedServerId = crypto
|
||||
.createHash('sha256')
|
||||
.update(canonicalPublicJwkString(stored.relaySigningKey.publicJwk))
|
||||
.digest('base64url');
|
||||
expect(identity.serverId).toBe(expectedServerId);
|
||||
expect(identity.hostEncPubJwk.crv).toBe('P-256');
|
||||
});
|
||||
|
||||
it('reuses an existing signing key (serverId stays stable across installs)', async () => {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
void privateKey;
|
||||
const publicJwk = publicKey.export({ format: 'jwk' });
|
||||
const store = makeSettingsStore({
|
||||
relaySigningKey: {
|
||||
privateJwk: crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ format: 'jwk' }),
|
||||
publicJwk,
|
||||
},
|
||||
});
|
||||
// Match private to public so importing works.
|
||||
const pair = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
store.peek().relaySigningKey.privateJwk = pair.privateKey.export({ format: 'jwk' });
|
||||
store.peek().relaySigningKey.publicJwk = pair.publicKey.export({ format: 'jwk' });
|
||||
|
||||
const runtime = createRelayIdentityRuntime({ crypto, ...store });
|
||||
const identity = await runtime.getRelayIdentity();
|
||||
const expected = crypto
|
||||
.createHash('sha256')
|
||||
.update(canonicalPublicJwkString(pair.publicKey.export({ format: 'jwk' })))
|
||||
.digest('base64url');
|
||||
expect(identity.serverId).toBe(expected);
|
||||
});
|
||||
|
||||
it('produces a verifiable relay auth signature', async () => {
|
||||
const store = makeSettingsStore();
|
||||
const runtime = createRelayIdentityRuntime({ crypto, ...store });
|
||||
const identity = await runtime.getRelayIdentity();
|
||||
const { ts, sig, pk } = identity.signRelayAuth('host-control', null);
|
||||
|
||||
const canonical = Buffer.from(pk, 'base64url').toString('utf8');
|
||||
const publicJwk = JSON.parse(canonical);
|
||||
const key = crypto.createPublicKey({ key: publicJwk, format: 'jwk' });
|
||||
const ok = crypto.verify(
|
||||
'SHA256',
|
||||
Buffer.from(`${ts}.${identity.serverId}.host-control.`),
|
||||
{ key, dsaEncoding: 'ieee-p1363' },
|
||||
Buffer.from(sig, 'base64url'),
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// Private relay service: config persistence, lifecycle of the relay host
|
||||
// client, and the /api/openchamber/relay/* management routes.
|
||||
//
|
||||
// Config lives in the server settings file as `settings.privateRelay =
|
||||
// { enabled, relayUrl }` (same storage precedent as tunnels/notifications).
|
||||
// Routes are registered with the other OpenChamber feature routes, before the
|
||||
// generic OpenCode proxy, and are covered by the same global UI auth gate.
|
||||
//
|
||||
// Cross-runtime parity note: relay host mode intentionally targets the web
|
||||
// server runtime only in v1 (Electron shares this server in-process). The VS
|
||||
// Code runtime does not host a relay; shared UI must treat these routes as
|
||||
// web-runtime capabilities.
|
||||
|
||||
import express from 'express';
|
||||
|
||||
import { createRelayIdentityRuntime } from './identity.js';
|
||||
import { startRelayHost } from './host-client.js';
|
||||
import { bytesToBase64Url } from './e2ee.js';
|
||||
|
||||
export const DEFAULT_RELAY_URL = 'wss://relay.openchamber.dev/ws';
|
||||
|
||||
const isValidRelayUrl = (value) => {
|
||||
if (typeof value !== 'string') return false;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return url.protocol === 'ws:' || url.protocol === 'wss:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeRelayUrl = (value) => {
|
||||
if (typeof value !== 'string') return DEFAULT_RELAY_URL;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !isValidRelayUrl(trimmed)) return DEFAULT_RELAY_URL;
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
// A deployment can pin the relay endpoint via env (e.g. a self-hosted relay on
|
||||
// your own Cloudflare account/domain). When set and valid it overrides the
|
||||
// stored setting entirely, so the host connection, the pairing offer, and the
|
||||
// status all point at it — clients then inherit it from the offer automatically.
|
||||
const envRelayUrlOverride = () => {
|
||||
const raw = process.env.OPENCHAMBER_RELAY_URL;
|
||||
if (typeof raw !== 'string' || !raw.trim() || !isValidRelayUrl(raw)) return null;
|
||||
return raw.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* crypto: typeof import('node:crypto'),
|
||||
* os: typeof import('node:os'),
|
||||
* readSettingsFromDiskMigrated: () => Promise<object>,
|
||||
* writeSettingsToDisk: (settings: object) => Promise<void>,
|
||||
* remoteClientAuthRuntime: { createClient: (options: object) => Promise<{ client: object, token: string }> },
|
||||
* getLocalPort: () => number,
|
||||
* logger?: Pick<Console, 'warn'>,
|
||||
* }} deps
|
||||
*/
|
||||
export const createRelayService = ({
|
||||
crypto,
|
||||
os,
|
||||
readSettingsFromDiskMigrated,
|
||||
writeSettingsToDisk,
|
||||
remoteClientAuthRuntime,
|
||||
getLocalPort,
|
||||
logger = console,
|
||||
}) => {
|
||||
const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk });
|
||||
|
||||
let hostClient = null;
|
||||
let status = { state: 'disabled', lastError: null, connectedClients: 0 };
|
||||
|
||||
const readConfig = async () => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const stored = settings?.privateRelay;
|
||||
const override = envRelayUrlOverride();
|
||||
return {
|
||||
enabled: stored?.enabled === true,
|
||||
relayUrl: override ?? normalizeRelayUrl(stored?.relayUrl),
|
||||
// True when the endpoint is pinned by OPENCHAMBER_RELAY_URL (a self-hosted
|
||||
// relay); the stored setting is ignored while it is set.
|
||||
relayUrlLocked: override !== null,
|
||||
};
|
||||
};
|
||||
|
||||
const writeConfig = async (config) => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
await writeSettingsToDisk({
|
||||
...settings,
|
||||
privateRelay: { enabled: config.enabled === true, relayUrl: normalizeRelayUrl(config.relayUrl) },
|
||||
});
|
||||
};
|
||||
|
||||
const start = async (relayUrl) => {
|
||||
if (hostClient) return;
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
hostClient = startRelayHost({
|
||||
relayUrl,
|
||||
identity,
|
||||
getLocalPort,
|
||||
logger,
|
||||
onStatus: (next) => {
|
||||
status = next;
|
||||
},
|
||||
});
|
||||
status = hostClient.getStatus();
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!hostClient) return;
|
||||
hostClient.stop();
|
||||
hostClient = null;
|
||||
status = { state: 'disabled', lastError: null, connectedClients: 0 };
|
||||
};
|
||||
|
||||
const startIfEnabled = async () => {
|
||||
try {
|
||||
const config = await readConfig();
|
||||
if (config.enabled) {
|
||||
await start(config.relayUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[Relay] startup failed: ${error?.message ?? error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatus = async () => {
|
||||
const config = await readConfig();
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
const live = hostClient ? hostClient.getStatus() : status;
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
state: hostClient ? live.state : 'disabled',
|
||||
serverId: identity.serverId,
|
||||
connectedClients: live.connectedClients,
|
||||
relayUrl: config.relayUrl,
|
||||
relayUrlLocked: config.relayUrlLocked,
|
||||
...(live.lastError ? { lastError: live.lastError } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const buildOffer = async ({ includeToken = false, clientLabel } = {}) => {
|
||||
const config = await readConfig();
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
const offer = {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
relayUrl: config.relayUrl,
|
||||
serverId: identity.serverId,
|
||||
hostEncPubJwk: identity.hostEncPubJwk,
|
||||
label: os.hostname(),
|
||||
};
|
||||
if (includeToken) {
|
||||
const label = typeof clientLabel === 'string' && clientLabel.trim().length > 0
|
||||
? clientLabel.trim()
|
||||
: 'Relay client';
|
||||
const { token } = await remoteClientAuthRuntime.createClient({ label, clientKind: 'relay' });
|
||||
offer.token = token;
|
||||
}
|
||||
const encoded = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(offer)));
|
||||
return {
|
||||
offer,
|
||||
url: `openchamber://connect?v=1&mode=relay#offer=${encoded}`,
|
||||
};
|
||||
};
|
||||
|
||||
const registerRoutes = (app) => {
|
||||
app.get('/api/openchamber/relay/status', async (_req, res) => {
|
||||
try {
|
||||
res.json(await getStatus());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message ?? 'Failed to read relay status' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/relay/enable', express.json({ limit: '16kb' }), async (req, res) => {
|
||||
try {
|
||||
const current = await readConfig();
|
||||
const relayUrl = typeof req.body?.relayUrl === 'string' ? normalizeRelayUrl(req.body.relayUrl) : current.relayUrl;
|
||||
await writeConfig({ enabled: true, relayUrl });
|
||||
if (hostClient) stop();
|
||||
await start(relayUrl);
|
||||
res.json(await getStatus());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message ?? 'Failed to enable relay' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/relay/disable', async (_req, res) => {
|
||||
try {
|
||||
const current = await readConfig();
|
||||
await writeConfig({ enabled: false, relayUrl: current.relayUrl });
|
||||
stop();
|
||||
res.json(await getStatus());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message ?? 'Failed to disable relay' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/relay/offer', express.json({ limit: '16kb' }), async (req, res) => {
|
||||
try {
|
||||
const result = await buildOffer({
|
||||
includeToken: req.body?.includeToken === true,
|
||||
clientLabel: req.body?.clientLabel,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message ?? 'Failed to build relay offer' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
registerRoutes,
|
||||
startIfEnabled,
|
||||
stop,
|
||||
getStatus,
|
||||
buildOffer,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
// Per-server relay signing identity (ECDSA P-256), extracted from
|
||||
// lib/notifications/apns-runtime.js so both the push relay and the private
|
||||
// relay share the SAME keypair and thus the SAME serverId
|
||||
// (base64url(SHA-256(canonical public JWK))). Storage format is unchanged:
|
||||
// `settings.relaySigningKey = { privateJwk, publicJwk }` — existing installs'
|
||||
// serverId must stay stable because push token binding depends on it.
|
||||
|
||||
/**
|
||||
* @param {{ crypto: typeof import('node:crypto'), readSettingsFromDiskMigrated: () => Promise<object>, writeSettingsToDisk: (settings: object) => Promise<void> }} deps
|
||||
* @returns {Promise<{ privateKey: import('node:crypto').KeyObject, publicJwk: JsonWebKey }>}
|
||||
*/
|
||||
export const getOrCreateRelaySigningKeypair = async ({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk }) => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const existing = settings?.relaySigningKey;
|
||||
if (existing && existing.privateJwk && existing.publicJwk) {
|
||||
return {
|
||||
privateKey: crypto.createPrivateKey({ key: existing.privateJwk, format: 'jwk' }),
|
||||
publicJwk: existing.publicJwk,
|
||||
};
|
||||
}
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const privateJwk = privateKey.export({ format: 'jwk' });
|
||||
const publicJwk = publicKey.export({ format: 'jwk' });
|
||||
await writeSettingsToDisk({ ...settings, relaySigningKey: { privateJwk, publicJwk } });
|
||||
return { privateKey, publicJwk };
|
||||
};
|
||||
|
||||
// Fixed key order so the hash is stable regardless of stored JSON field order.
|
||||
// Byte-for-byte mirror of canonicalJwk in openchamber-website apps/api relay-auth.ts.
|
||||
/** @param {JsonWebKey} jwk */
|
||||
export const canonicalPublicJwkString = (jwk) =>
|
||||
JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
|
||||
|
||||
/**
|
||||
* serverId = base64url(SHA-256(canonical public JWK)). Must match the push
|
||||
* relay's deriveServerId — this id is the routing key for both relays.
|
||||
* @param {{ crypto: typeof import('node:crypto') }} deps
|
||||
* @param {JsonWebKey} publicJwk
|
||||
*/
|
||||
export const deriveServerId = ({ crypto }, publicJwk) =>
|
||||
crypto.createHash('sha256').update(canonicalPublicJwkString(publicJwk)).digest('base64url');
|
||||
|
||||
/**
|
||||
* ECDSA-SHA256, IEEE P1363 (raw r||s) signature — the form WebCrypto verifies.
|
||||
* @param {{ crypto: typeof import('node:crypto') }} deps
|
||||
* @param {import('node:crypto').KeyObject} privateKey
|
||||
* @param {string} message
|
||||
*/
|
||||
export const signRelayMessage = ({ crypto }, privateKey, message) =>
|
||||
crypto.sign('SHA256', Buffer.from(message), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url');
|
||||
@@ -0,0 +1,373 @@
|
||||
// Tunnel mux frame codec (Layer 3 of the protocol spec). Pure functions, no I/O.
|
||||
// JS mirror of packages/ui/src/lib/relay/tunnel-codec.ts (+ the Layer 3
|
||||
// constants from protocol.ts) — MUST stay byte-compatible with those modules.
|
||||
// Frame layout: [1 byte frameType (high bit = fragment-continues)][4 byte BE streamId][payload].
|
||||
// Client-initiated streams use odd streamIds starting at 1; even ids are reserved.
|
||||
// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 3).
|
||||
|
||||
import { MAX_PLAINTEXT_FRAME_BYTES } from './e2ee.js';
|
||||
|
||||
|
||||
export const TUNNEL_FRAME_HEADER_BYTES = 5;
|
||||
export const TUNNEL_FRAGMENT_FLAG = 0x80;
|
||||
|
||||
// Batch envelope container (mirror of protocol.ts). Only used when both peers
|
||||
// negotiated `batch`. Reserve the per-frame envelope overhead from the payload
|
||||
// budget so any single frame still fits one 64 KiB encrypted plaintext.
|
||||
export const BATCH_CONTAINER_TAG_SINGLE = 0x00;
|
||||
export const BATCH_CONTAINER_TAG_BATCH = 0x01;
|
||||
export const BATCH_FRAME_LENGTH_BYTES = 4;
|
||||
export const BATCH_ENVELOPE_RESERVED_BYTES = 1 + BATCH_FRAME_LENGTH_BYTES;
|
||||
export const MAX_TUNNEL_PAYLOAD_BYTES =
|
||||
MAX_PLAINTEXT_FRAME_BYTES - TUNNEL_FRAME_HEADER_BYTES - BATCH_ENVELOPE_RESERVED_BYTES;
|
||||
|
||||
export const TunnelFrameType = {
|
||||
HttpRequest: 1,
|
||||
HttpBody: 2,
|
||||
HttpResponse: 3,
|
||||
StreamEnd: 4,
|
||||
StreamAbort: 5,
|
||||
WsOpen: 6,
|
||||
WsOpened: 7,
|
||||
WsText: 8,
|
||||
WsBinary: 9,
|
||||
WsClose: 10,
|
||||
Ping: 11,
|
||||
Pong: 12,
|
||||
};
|
||||
|
||||
const TUNNEL_FRAME_TYPE_VALUES = new Set(Object.values(TunnelFrameType));
|
||||
|
||||
/** @param {number} value */
|
||||
export const isTunnelFrameType = (value) => TUNNEL_FRAME_TYPE_VALUES.has(value);
|
||||
|
||||
const MAX_STREAM_ID = 0xffffffff;
|
||||
|
||||
export class TunnelCodecError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'TunnelCodecError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} frameType
|
||||
* @param {number} streamId
|
||||
* @param {Uint8Array} payload
|
||||
* @param {boolean} [hasMoreFragments]
|
||||
*/
|
||||
export const encodeTunnelFrame = (frameType, streamId, payload, hasMoreFragments = false) => {
|
||||
if (!Number.isInteger(streamId) || streamId < 0 || streamId > MAX_STREAM_ID) {
|
||||
throw new TunnelCodecError('invalid stream id');
|
||||
}
|
||||
if (payload.length > MAX_TUNNEL_PAYLOAD_BYTES) {
|
||||
throw new TunnelCodecError('tunnel payload exceeds maximum size');
|
||||
}
|
||||
const frame = new Uint8Array(TUNNEL_FRAME_HEADER_BYTES + payload.length);
|
||||
frame[0] = hasMoreFragments ? frameType | TUNNEL_FRAGMENT_FLAG : frameType;
|
||||
frame[1] = (streamId >>> 24) & 0xff;
|
||||
frame[2] = (streamId >>> 16) & 0xff;
|
||||
frame[3] = (streamId >>> 8) & 0xff;
|
||||
frame[4] = streamId & 0xff;
|
||||
frame.set(payload, TUNNEL_FRAME_HEADER_BYTES);
|
||||
return frame;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} frame
|
||||
* @returns {{ frameType: number, streamId: number, payload: Uint8Array, hasMoreFragments: boolean }}
|
||||
*/
|
||||
export const decodeTunnelFrame = (frame) => {
|
||||
if (frame.length < TUNNEL_FRAME_HEADER_BYTES) {
|
||||
throw new TunnelCodecError('tunnel frame too short');
|
||||
}
|
||||
const rawType = frame[0];
|
||||
const hasMoreFragments = (rawType & TUNNEL_FRAGMENT_FLAG) !== 0;
|
||||
const frameType = rawType & ~TUNNEL_FRAGMENT_FLAG;
|
||||
if (!isTunnelFrameType(frameType)) {
|
||||
throw new TunnelCodecError(`unknown tunnel frame type ${frameType}`);
|
||||
}
|
||||
const streamId = ((frame[1] << 24) | (frame[2] << 16) | (frame[3] << 8) | frame[4]) >>> 0;
|
||||
return {
|
||||
frameType,
|
||||
streamId,
|
||||
payload: frame.slice(TUNNEL_FRAME_HEADER_BYTES),
|
||||
hasMoreFragments,
|
||||
};
|
||||
};
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
/** @param {unknown} value */
|
||||
export const encodeJsonPayload = (value) => textEncoder.encode(JSON.stringify(value));
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} payload
|
||||
* @param {(parsed: unknown) => boolean} validate
|
||||
*/
|
||||
export const decodeJsonPayload = (payload, validate) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(textDecoder.decode(payload));
|
||||
} catch {
|
||||
throw new TunnelCodecError('malformed JSON tunnel payload');
|
||||
}
|
||||
if (!validate(parsed)) {
|
||||
throw new TunnelCodecError('unexpected JSON tunnel payload shape');
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Split a body/message into payload-sized chunks. Empty input yields one empty chunk.
|
||||
* @param {Uint8Array} bytes
|
||||
* @param {number} [chunkSize]
|
||||
*/
|
||||
export const chunkPayload = (bytes, chunkSize = MAX_TUNNEL_PAYLOAD_BYTES) => {
|
||||
if (chunkSize <= 0 || chunkSize > MAX_TUNNEL_PAYLOAD_BYTES) {
|
||||
throw new TunnelCodecError('invalid chunk size');
|
||||
}
|
||||
if (bytes.length === 0) return [new Uint8Array(0)];
|
||||
const chunks = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
chunks.push(bytes.slice(offset, offset + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encode one logical message as one or more frames, setting the fragment flag
|
||||
* on all but the last. Used for WS messages that exceed the frame budget.
|
||||
* @param {number} frameType
|
||||
* @param {number} streamId
|
||||
* @param {Uint8Array} payload
|
||||
*/
|
||||
export const encodeFragmentedMessage = (frameType, streamId, payload) => {
|
||||
const chunks = chunkPayload(payload);
|
||||
return chunks.map((chunk, index) => encodeTunnelFrame(frameType, streamId, chunk, index < chunks.length - 1));
|
||||
};
|
||||
|
||||
/**
|
||||
* Reassembles fragmented messages per (streamId, frameType). Bounded to protect memory.
|
||||
* @param {number} [maxMessageBytes]
|
||||
*/
|
||||
export const createFragmentAssembler = (maxMessageBytes = 16 * 1024 * 1024) => {
|
||||
const pending = new Map();
|
||||
return {
|
||||
/**
|
||||
* Returns the complete message payload once all fragments arrived, or null
|
||||
* while more fragments are expected.
|
||||
* @param {{ frameType: number, streamId: number, payload: Uint8Array, hasMoreFragments: boolean }} frame
|
||||
*/
|
||||
push(frame) {
|
||||
const key = `${frame.streamId}:${frame.frameType}`;
|
||||
const entry = pending.get(key);
|
||||
if (!frame.hasMoreFragments && !entry) {
|
||||
return frame.payload;
|
||||
}
|
||||
const chunks = entry?.chunks ?? [];
|
||||
const totalBytes = (entry?.totalBytes ?? 0) + frame.payload.length;
|
||||
if (totalBytes > maxMessageBytes) {
|
||||
pending.delete(key);
|
||||
throw new TunnelCodecError('fragmented message exceeds maximum size');
|
||||
}
|
||||
chunks.push(frame.payload);
|
||||
if (frame.hasMoreFragments) {
|
||||
pending.set(key, { chunks, totalBytes });
|
||||
return null;
|
||||
}
|
||||
pending.delete(key);
|
||||
const message = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
message.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return message;
|
||||
},
|
||||
/** @param {number} streamId */
|
||||
dropStream(streamId) {
|
||||
for (const key of pending.keys()) {
|
||||
if (key.startsWith(`${streamId}:`)) pending.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Batch envelope encoder (mirror of tunnel-codec.ts encodeFrameBatch). Only used
|
||||
* when both peers negotiated `batch`. One encrypted WS message still equals one
|
||||
* encrypt() call — this only changes how many tunnel frames it carries.
|
||||
* @param {Uint8Array[]} frames
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export const encodeFrameBatch = (frames) => {
|
||||
if (frames.length === 0) {
|
||||
throw new TunnelCodecError('cannot encode an empty frame batch');
|
||||
}
|
||||
if (frames.length === 1) {
|
||||
const frame = frames[0];
|
||||
const out = new Uint8Array(1 + frame.length);
|
||||
out[0] = BATCH_CONTAINER_TAG_SINGLE;
|
||||
out.set(frame, 1);
|
||||
if (out.length > MAX_PLAINTEXT_FRAME_BYTES) {
|
||||
throw new TunnelCodecError('frame batch exceeds maximum plaintext size');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let total = 1;
|
||||
for (const frame of frames) total += BATCH_FRAME_LENGTH_BYTES + frame.length;
|
||||
if (total > MAX_PLAINTEXT_FRAME_BYTES) {
|
||||
throw new TunnelCodecError('frame batch exceeds maximum plaintext size');
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
out[0] = BATCH_CONTAINER_TAG_BATCH;
|
||||
let offset = 1;
|
||||
for (const frame of frames) {
|
||||
out[offset] = (frame.length >>> 24) & 0xff;
|
||||
out[offset + 1] = (frame.length >>> 16) & 0xff;
|
||||
out[offset + 2] = (frame.length >>> 8) & 0xff;
|
||||
out[offset + 3] = frame.length & 0xff;
|
||||
offset += BATCH_FRAME_LENGTH_BYTES;
|
||||
out.set(frame, offset);
|
||||
offset += frame.length;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes a batch-envelope plaintext into its ordered tunnel frames.
|
||||
* @param {Uint8Array} plaintext
|
||||
* @returns {Uint8Array[]}
|
||||
*/
|
||||
export const decodeFrameBatch = (plaintext) => {
|
||||
if (plaintext.length < 1) {
|
||||
throw new TunnelCodecError('empty batch plaintext');
|
||||
}
|
||||
const tag = plaintext[0];
|
||||
if (tag === BATCH_CONTAINER_TAG_SINGLE) {
|
||||
return [plaintext.slice(1)];
|
||||
}
|
||||
if (tag !== BATCH_CONTAINER_TAG_BATCH) {
|
||||
throw new TunnelCodecError(`unknown batch container tag ${tag}`);
|
||||
}
|
||||
const frames = [];
|
||||
let offset = 1;
|
||||
while (offset < plaintext.length) {
|
||||
if (offset + BATCH_FRAME_LENGTH_BYTES > plaintext.length) {
|
||||
throw new TunnelCodecError('truncated batch frame length');
|
||||
}
|
||||
const length =
|
||||
((plaintext[offset] << 24)
|
||||
| (plaintext[offset + 1] << 16)
|
||||
| (plaintext[offset + 2] << 8)
|
||||
| plaintext[offset + 3]) >>> 0;
|
||||
offset += BATCH_FRAME_LENGTH_BYTES;
|
||||
if (offset + length > plaintext.length) {
|
||||
throw new TunnelCodecError('truncated batch frame body');
|
||||
}
|
||||
frames.push(plaintext.slice(offset, offset + length));
|
||||
offset += length;
|
||||
}
|
||||
if (frames.length === 0) {
|
||||
throw new TunnelCodecError('empty frame batch');
|
||||
}
|
||||
return frames;
|
||||
};
|
||||
|
||||
// Only high-volume body/stream data is buffered; setup/teardown/keepalive frames
|
||||
// flush immediately so TTFT, terminal echo, and liveness stay snappy.
|
||||
const BUFFERED_FRAME_TYPES = new Set([
|
||||
TunnelFrameType.HttpBody,
|
||||
TunnelFrameType.WsText,
|
||||
TunnelFrameType.WsBinary,
|
||||
]);
|
||||
|
||||
// See the TS mirror (tunnel-codec.ts) for the 150ms rationale: the chat render pipeline's
|
||||
// 100ms input throttle + ~64ms paced-reveal smoothing make a 150ms batch window invisible.
|
||||
export const DEFAULT_BATCH_WINDOW_MS = 150;
|
||||
export const DEFAULT_BATCH_MAX_BYTES = 24 * 1024;
|
||||
export const DEFAULT_BATCH_MAX_FRAMES = 32;
|
||||
|
||||
/**
|
||||
* Outbound batching buffer (mirror of tunnel-codec.ts createOutboundFrameBatcher).
|
||||
* @param {{
|
||||
* windowMs?: number,
|
||||
* maxBatchBytes?: number,
|
||||
* maxBatchFrames?: number,
|
||||
* sendBatch: (plaintext: Uint8Array) => void,
|
||||
* now?: () => number,
|
||||
* setTimer?: (fn: () => void, ms: number) => any,
|
||||
* clearTimer?: (handle: any) => void,
|
||||
* }} options
|
||||
*/
|
||||
export const createOutboundFrameBatcher = (options) => {
|
||||
const windowMs = options.windowMs ?? DEFAULT_BATCH_WINDOW_MS;
|
||||
const maxBatchBytes = options.maxBatchBytes ?? DEFAULT_BATCH_MAX_BYTES;
|
||||
const maxBatchFrames = options.maxBatchFrames ?? DEFAULT_BATCH_MAX_FRAMES;
|
||||
const now = options.now ?? (() => Date.now());
|
||||
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
||||
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
||||
|
||||
let buffer = [];
|
||||
let bufferedBytes = 0;
|
||||
let timer = null;
|
||||
let lastFlushAt = 0;
|
||||
let disposed = false;
|
||||
|
||||
const clearPendingTimer = () => {
|
||||
if (timer !== null) {
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
clearPendingTimer();
|
||||
if (buffer.length === 0) return;
|
||||
const frames = buffer;
|
||||
buffer = [];
|
||||
bufferedBytes = 0;
|
||||
lastFlushAt = now();
|
||||
options.sendBatch(encodeFrameBatch(frames));
|
||||
};
|
||||
|
||||
const enqueue = (frame) => {
|
||||
if (disposed) return;
|
||||
const frameType = frame[0] & ~TUNNEL_FRAGMENT_FLAG;
|
||||
if (!BUFFERED_FRAME_TYPES.has(frameType)) {
|
||||
buffer.push(frame);
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
const at = now();
|
||||
if (buffer.length === 0 && at - lastFlushAt >= windowMs) {
|
||||
buffer.push(frame);
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
const frameCost = BATCH_FRAME_LENGTH_BYTES + frame.length;
|
||||
if (buffer.length > 0 && 1 + bufferedBytes + frameCost > MAX_PLAINTEXT_FRAME_BYTES) {
|
||||
flush();
|
||||
}
|
||||
buffer.push(frame);
|
||||
bufferedBytes += frameCost;
|
||||
if (bufferedBytes >= maxBatchBytes || buffer.length >= maxBatchFrames) {
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
if (timer === null) timer = setTimer(flush, windowMs);
|
||||
};
|
||||
|
||||
return {
|
||||
enqueue,
|
||||
flush,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
clearPendingTimer();
|
||||
buffer = [];
|
||||
bufferedBytes = 0;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
TunnelCodecError,
|
||||
TunnelFrameType,
|
||||
createFragmentAssembler,
|
||||
decodeTunnelFrame,
|
||||
encodeFragmentedMessage,
|
||||
encodeTunnelFrame,
|
||||
MAX_TUNNEL_PAYLOAD_BYTES,
|
||||
} from './tunnel-codec.js';
|
||||
|
||||
describe('relay tunnel codec', () => {
|
||||
it('round-trips a frame', () => {
|
||||
const payload = new TextEncoder().encode('hello tunnel');
|
||||
const frame = encodeTunnelFrame(TunnelFrameType.HttpRequest, 7, payload);
|
||||
const decoded = decodeTunnelFrame(frame);
|
||||
expect(decoded.frameType).toBe(TunnelFrameType.HttpRequest);
|
||||
expect(decoded.streamId).toBe(7);
|
||||
expect(decoded.hasMoreFragments).toBe(false);
|
||||
expect(new TextDecoder().decode(decoded.payload)).toBe('hello tunnel');
|
||||
});
|
||||
|
||||
it('preserves large stream ids without sign issues', () => {
|
||||
const frame = encodeTunnelFrame(TunnelFrameType.HttpBody, 0xfffffffd, new Uint8Array(0));
|
||||
expect(decodeTunnelFrame(frame).streamId).toBe(0xfffffffd);
|
||||
});
|
||||
|
||||
it('rejects truncated and unknown frames', () => {
|
||||
expect(() => decodeTunnelFrame(new Uint8Array([1, 2]))).toThrow(TunnelCodecError);
|
||||
expect(() => decodeTunnelFrame(new Uint8Array([99, 0, 0, 0, 1]))).toThrow(TunnelCodecError);
|
||||
});
|
||||
|
||||
it('fragments and reassembles oversized messages', () => {
|
||||
const big = new Uint8Array(MAX_TUNNEL_PAYLOAD_BYTES * 2 + 10);
|
||||
for (let i = 0; i < big.length; i += 1) big[i] = i & 0xff;
|
||||
const frames = encodeFragmentedMessage(TunnelFrameType.WsBinary, 3, big);
|
||||
expect(frames.length).toBe(3);
|
||||
|
||||
const assembler = createFragmentAssembler();
|
||||
let result = null;
|
||||
for (const frame of frames) {
|
||||
result = assembler.push(decodeTunnelFrame(frame));
|
||||
}
|
||||
expect(result).not.toBeNull();
|
||||
expect(Array.from(result)).toEqual(Array.from(big));
|
||||
});
|
||||
|
||||
it('bounds fragment reassembly memory', () => {
|
||||
const assembler = createFragmentAssembler(MAX_TUNNEL_PAYLOAD_BYTES + 1);
|
||||
const chunk = new Uint8Array(MAX_TUNNEL_PAYLOAD_BYTES);
|
||||
// First fragment fits, second pushes past the cap.
|
||||
assembler.push({ frameType: TunnelFrameType.WsText, streamId: 1, payload: chunk, hasMoreFragments: true });
|
||||
expect(() =>
|
||||
assembler.push({ frameType: TunnelFrameType.WsText, streamId: 1, payload: chunk, hasMoreFragments: true }),
|
||||
).toThrow(TunnelCodecError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
// Host side of the tunnel mux (Layer 3): consumes decrypted tunnel frames for
|
||||
// ONE relay connection and dispatches them to the local loopback origin.
|
||||
// HTTP streams -> fetch http://127.0.0.1:<port> with streamed duplex bodies;
|
||||
// WS streams -> `ws` client to the loopback WebSocket endpoints.
|
||||
// The dispatcher NEVER injects credentials: tunneled requests authenticate
|
||||
// exactly like any remote client (bearer oc_client_* header, oc_url_token query).
|
||||
// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 3).
|
||||
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import {
|
||||
MAX_TUNNEL_PAYLOAD_BYTES,
|
||||
TunnelFrameType,
|
||||
chunkPayload,
|
||||
createFragmentAssembler,
|
||||
decodeJsonPayload,
|
||||
decodeTunnelFrame,
|
||||
encodeFragmentedMessage,
|
||||
encodeJsonPayload,
|
||||
encodeTunnelFrame,
|
||||
} from './tunnel-codec.js';
|
||||
|
||||
// Path allowlists (defense in depth; same families realtime-proxy.js allows).
|
||||
const isAllowedHttpPath = (pathname) =>
|
||||
pathname === '/health'
|
||||
|| pathname === '/api'
|
||||
|| pathname.startsWith('/api/')
|
||||
|| pathname === '/auth'
|
||||
|| pathname.startsWith('/auth/');
|
||||
|
||||
const ALLOWED_WS_PATHS = new Set([
|
||||
'/api/global/event/ws',
|
||||
'/api/event/ws',
|
||||
'/api/terminal/ws',
|
||||
'/api/dictation/ws',
|
||||
]);
|
||||
|
||||
// Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
|
||||
// to the loopback origin. content-length is dropped too because the body is
|
||||
// re-chunked through the tunnel and undici computes framing itself.
|
||||
const STRIPPED_REQUEST_HEADERS = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
'host',
|
||||
'content-length',
|
||||
]);
|
||||
|
||||
// Response framing headers that no longer apply once the body crosses the
|
||||
// tunnel as HttpBody chunks (loopback fetch already decoded content-encoding).
|
||||
const STRIPPED_RESPONSE_HEADERS = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'transfer-encoding',
|
||||
'content-length',
|
||||
'content-encoding',
|
||||
]);
|
||||
|
||||
// v1 backpressure rule: pause reading the loopback source while the outbound
|
||||
// relay socket has more than this buffered.
|
||||
const BACKPRESSURE_LIMIT_BYTES = 4 * 1024 * 1024;
|
||||
const BACKPRESSURE_POLL_MS = 20;
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const isHttpRequestPayload = (parsed) =>
|
||||
Boolean(parsed && typeof parsed === 'object'
|
||||
&& typeof parsed.method === 'string'
|
||||
&& typeof parsed.path === 'string'
|
||||
&& typeof parsed.query === 'string'
|
||||
&& parsed.headers && typeof parsed.headers === 'object');
|
||||
|
||||
const isWsOpenPayload = (parsed) =>
|
||||
Boolean(parsed && typeof parsed === 'object'
|
||||
&& typeof parsed.path === 'string'
|
||||
&& typeof parsed.query === 'string'
|
||||
&& (parsed.protocols === undefined || Array.isArray(parsed.protocols)));
|
||||
|
||||
const isWsClosePayload = (parsed) => Boolean(parsed && typeof parsed === 'object');
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* connectionId: string,
|
||||
* getLocalPort: () => number,
|
||||
* sendFrame: (plaintextFrame: Uint8Array) => void | Promise<void>,
|
||||
* getBufferedAmount: () => number,
|
||||
* }} deps
|
||||
*/
|
||||
export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBufferedAmount }) => {
|
||||
/** @type {Map<number, { kind: 'http', abort: AbortController, body: ReadableStreamDefaultController | null } | { kind: 'ws', socket: WebSocket, opened: boolean }>} */
|
||||
const streams = new Map();
|
||||
const assembler = createFragmentAssembler();
|
||||
let closed = false;
|
||||
|
||||
const send = async (frame) => {
|
||||
if (closed) return;
|
||||
await sendFrame(frame);
|
||||
};
|
||||
|
||||
const sendJson = (frameType, streamId, payload) =>
|
||||
send(encodeTunnelFrame(frameType, streamId, encodeJsonPayload(payload)));
|
||||
|
||||
const sendAbort = async (streamId, reason) => {
|
||||
await sendJson(TunnelFrameType.StreamAbort, streamId, { reason: String(reason ?? 'stream error') });
|
||||
};
|
||||
|
||||
const dropStream = (streamId) => {
|
||||
streams.delete(streamId);
|
||||
assembler.dropStream(streamId);
|
||||
};
|
||||
|
||||
const abortLocalStream = (streamId, reason) => {
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream) return;
|
||||
dropStream(streamId);
|
||||
if (stream.kind === 'http') {
|
||||
try {
|
||||
stream.body?.error(new Error(String(reason ?? 'aborted')));
|
||||
} catch {
|
||||
// body already closed
|
||||
}
|
||||
stream.abort.abort();
|
||||
} else {
|
||||
try {
|
||||
stream.socket.terminate();
|
||||
} catch {
|
||||
// socket already gone
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const waitForBackpressure = async (signal) => {
|
||||
while (!closed && getBufferedAmount() > BACKPRESSURE_LIMIT_BYTES) {
|
||||
if (signal?.aborted) return;
|
||||
await sleep(BACKPRESSURE_POLL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HTTP
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const buildRequestHeaders = (rawHeaders) => {
|
||||
const headers = {};
|
||||
for (const [name, value] of Object.entries(rawHeaders)) {
|
||||
if (typeof name !== 'string' || typeof value !== 'string') continue;
|
||||
const lower = name.toLowerCase();
|
||||
if (STRIPPED_REQUEST_HEADERS.has(lower)) continue;
|
||||
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) continue;
|
||||
headers[lower] = value;
|
||||
}
|
||||
headers['x-openchamber-relay-connection'] = connectionId;
|
||||
return headers;
|
||||
};
|
||||
|
||||
// Synthetic responses never ship an empty body: `reason` states explicitly
|
||||
// that the relay host (not the upstream server) produced this response.
|
||||
const syntheticResponse = async (streamId, status, message) => {
|
||||
await sendJson(TunnelFrameType.HttpResponse, streamId, {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
await send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, encodeJsonPayload({ error: message, reason: message, source: 'relay-tunnel-host' })));
|
||||
await send(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0)));
|
||||
};
|
||||
|
||||
const runHttpStream = async (streamId, request) => {
|
||||
const method = request.method.toUpperCase();
|
||||
if (!isAllowedHttpPath(request.path)) {
|
||||
dropStream(streamId);
|
||||
await syntheticResponse(streamId, 403, 'Path is not allowed through the relay');
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream || stream.kind !== 'http') return;
|
||||
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
let requestBody;
|
||||
if (hasBody) {
|
||||
requestBody = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.body = controller;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
stream.body = null;
|
||||
stream.noBody = true;
|
||||
}
|
||||
|
||||
const url = `http://127.0.0.1:${getLocalPort()}${request.path}${request.query ? `?${request.query}` : ''}`;
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers: buildRequestHeaders(request.headers),
|
||||
body: requestBody,
|
||||
duplex: hasBody ? 'half' : undefined,
|
||||
signal: stream.abort.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (streams.get(streamId) === stream) {
|
||||
dropStream(streamId);
|
||||
await sendAbort(streamId, error?.message ?? 'loopback request failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const responseHeaders = {};
|
||||
for (const [name, value] of response.headers.entries()) {
|
||||
if (STRIPPED_RESPONSE_HEADERS.has(name)) continue;
|
||||
responseHeaders[name] = value;
|
||||
}
|
||||
await sendJson(TunnelFrameType.HttpResponse, streamId, { status: response.status, headers: responseHeaders });
|
||||
|
||||
try {
|
||||
if (response.body) {
|
||||
for await (const chunk of response.body) {
|
||||
if (closed || stream.abort.signal.aborted) return;
|
||||
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
||||
for (const piece of chunkPayload(bytes, MAX_TUNNEL_PAYLOAD_BYTES)) {
|
||||
await waitForBackpressure(stream.abort.signal);
|
||||
if (closed || stream.abort.signal.aborted) return;
|
||||
await send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, piece));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (streams.get(streamId) === stream) {
|
||||
dropStream(streamId);
|
||||
await send(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0)));
|
||||
}
|
||||
} catch (error) {
|
||||
if (streams.get(streamId) === stream) {
|
||||
dropStream(streamId);
|
||||
await sendAbort(streamId, error?.message ?? 'loopback response failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleHttpRequest = (streamId, payload) => {
|
||||
if (streams.has(streamId)) {
|
||||
abortLocalStream(streamId, 'duplicate stream id');
|
||||
void sendAbort(streamId, 'duplicate stream id');
|
||||
return;
|
||||
}
|
||||
let request;
|
||||
try {
|
||||
request = decodeJsonPayload(payload, isHttpRequestPayload);
|
||||
} catch (error) {
|
||||
void sendAbort(streamId, error?.message ?? 'malformed request');
|
||||
return;
|
||||
}
|
||||
const stream = { kind: 'http', abort: new AbortController(), body: null, noBody: false };
|
||||
streams.set(streamId, stream);
|
||||
void runHttpStream(streamId, request);
|
||||
};
|
||||
|
||||
const handleHttpBody = (streamId, payload) => {
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream || stream.kind !== 'http' || stream.noBody) return;
|
||||
// The body controller attaches synchronously in runHttpStream before any
|
||||
// await, so by the time body frames arrive it is set for body-carrying
|
||||
// methods; drop stray body bytes otherwise.
|
||||
try {
|
||||
stream.body?.enqueue(payload);
|
||||
} catch {
|
||||
// stream already errored/closed
|
||||
}
|
||||
};
|
||||
|
||||
const handleStreamEnd = (streamId) => {
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream || stream.kind !== 'http') return;
|
||||
try {
|
||||
stream.body?.close();
|
||||
} catch {
|
||||
// stream already errored/closed
|
||||
}
|
||||
// Response side keeps running; only the request body is half-closed.
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WebSocket
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const handleWsOpen = (streamId, payload) => {
|
||||
if (streams.has(streamId)) {
|
||||
abortLocalStream(streamId, 'duplicate stream id');
|
||||
void sendAbort(streamId, 'duplicate stream id');
|
||||
return;
|
||||
}
|
||||
let open;
|
||||
try {
|
||||
open = decodeJsonPayload(payload, isWsOpenPayload);
|
||||
} catch (error) {
|
||||
void sendAbort(streamId, error?.message ?? 'malformed ws open');
|
||||
return;
|
||||
}
|
||||
if (!ALLOWED_WS_PATHS.has(open.path)) {
|
||||
void sendAbort(streamId, 'Path is not allowed through the relay');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `ws://127.0.0.1:${getLocalPort()}${open.path}${open.query ? `?${open.query}` : ''}`;
|
||||
// Present the loopback origin we're actually dialing. The server derives this
|
||||
// as a trusted same-origin candidate from the Host header (127.0.0.1:<port>),
|
||||
// so the WS origin check passes reliably for every client platform. We do NOT
|
||||
// use the client's window.location.origin: it's unreliable in WKWebView (empty
|
||||
// or "null" for custom schemes), and the `ws` client sends no Origin at all
|
||||
// otherwise — a no-origin upgrade is rejected 403. The request itself is still
|
||||
// authenticated by the tunneled oc_url_token, not by this origin.
|
||||
const dialHeaders = {
|
||||
'x-openchamber-relay-connection': connectionId,
|
||||
origin: `http://127.0.0.1:${getLocalPort()}`,
|
||||
};
|
||||
let socket;
|
||||
try {
|
||||
socket = new WebSocket(url, open.protocols, {
|
||||
headers: dialHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
void sendAbort(streamId, error?.message ?? 'ws dial failed');
|
||||
return;
|
||||
}
|
||||
const stream = { kind: 'ws', socket, opened: false };
|
||||
streams.set(streamId, stream);
|
||||
|
||||
socket.on('open', () => {
|
||||
if (streams.get(streamId) !== stream) return;
|
||||
stream.opened = true;
|
||||
void sendJson(TunnelFrameType.WsOpened, streamId, socket.protocol ? { protocol: socket.protocol } : {});
|
||||
});
|
||||
socket.on('message', (data, isBinary) => {
|
||||
if (streams.get(streamId) !== stream || closed) return;
|
||||
const bytes = Buffer.isBuffer(data) ? new Uint8Array(data) : new Uint8Array(Buffer.concat(data));
|
||||
const frameType = isBinary ? TunnelFrameType.WsBinary : TunnelFrameType.WsText;
|
||||
void (async () => {
|
||||
for (const frame of encodeFragmentedMessage(frameType, streamId, bytes)) {
|
||||
await waitForBackpressure(null);
|
||||
if (streams.get(streamId) !== stream || closed) return;
|
||||
await send(frame);
|
||||
}
|
||||
})();
|
||||
});
|
||||
socket.on('close', (code, reasonBuffer) => {
|
||||
if (streams.get(streamId) !== stream) return;
|
||||
dropStream(streamId);
|
||||
const reason = reasonBuffer ? reasonBuffer.toString('utf8') : '';
|
||||
if (stream.opened) {
|
||||
void sendJson(TunnelFrameType.WsClose, streamId, { code: code || 1000, reason });
|
||||
} else {
|
||||
void sendAbort(streamId, reason || `upstream ws closed (${code || 'no code'})`);
|
||||
}
|
||||
});
|
||||
socket.on('error', (error) => {
|
||||
if (streams.get(streamId) !== stream) return;
|
||||
if (!stream.opened) {
|
||||
dropStream(streamId);
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
void sendAbort(streamId, error?.message ?? 'upstream ws error');
|
||||
}
|
||||
// Post-open errors are followed by 'close', handled above.
|
||||
});
|
||||
};
|
||||
|
||||
const handleWsMessage = (streamId, frameType, message) => {
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream || stream.kind !== 'ws' || stream.socket.readyState !== WebSocket.OPEN) return;
|
||||
if (frameType === TunnelFrameType.WsText) {
|
||||
stream.socket.send(Buffer.from(message).toString('utf8'));
|
||||
} else {
|
||||
stream.socket.send(message, { binary: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleWsClose = (streamId, payload) => {
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream || stream.kind !== 'ws') return;
|
||||
dropStream(streamId);
|
||||
let close = { code: 1000, reason: '' };
|
||||
try {
|
||||
close = decodeJsonPayload(payload, isWsClosePayload);
|
||||
} catch {
|
||||
// fall through with defaults
|
||||
}
|
||||
const code = Number.isInteger(close.code) && close.code >= 1000 && close.code <= 4999 ? close.code : 1000;
|
||||
try {
|
||||
stream.socket.close(code, typeof close.reason === 'string' ? close.reason : '');
|
||||
} catch {
|
||||
stream.socket.terminate();
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Frame entrypoint
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** @param {Uint8Array} plaintextFrame one decrypted tunnel frame */
|
||||
const handleFrame = async (plaintextFrame) => {
|
||||
if (closed) return;
|
||||
const frame = decodeTunnelFrame(plaintextFrame);
|
||||
|
||||
// WS message frames can be fragmented; everything else arrives whole.
|
||||
if (frame.frameType === TunnelFrameType.WsText || frame.frameType === TunnelFrameType.WsBinary) {
|
||||
const message = assembler.push(frame);
|
||||
if (message === null) return;
|
||||
handleWsMessage(frame.streamId, frame.frameType, message);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (frame.frameType) {
|
||||
case TunnelFrameType.HttpRequest:
|
||||
handleHttpRequest(frame.streamId, frame.payload);
|
||||
return;
|
||||
case TunnelFrameType.HttpBody:
|
||||
handleHttpBody(frame.streamId, frame.payload);
|
||||
return;
|
||||
case TunnelFrameType.StreamEnd:
|
||||
handleStreamEnd(frame.streamId);
|
||||
return;
|
||||
case TunnelFrameType.StreamAbort:
|
||||
abortLocalStream(frame.streamId, 'aborted by client');
|
||||
return;
|
||||
case TunnelFrameType.WsOpen:
|
||||
handleWsOpen(frame.streamId, frame.payload);
|
||||
return;
|
||||
case TunnelFrameType.WsClose:
|
||||
handleWsClose(frame.streamId, frame.payload);
|
||||
return;
|
||||
case TunnelFrameType.Ping:
|
||||
await send(encodeTunnelFrame(TunnelFrameType.Pong, frame.streamId, new Uint8Array(0)));
|
||||
return;
|
||||
case TunnelFrameType.Pong:
|
||||
return;
|
||||
default:
|
||||
// Host never receives HttpResponse/WsOpened; ignore rather than tear down.
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const streamId of [...streams.keys()]) {
|
||||
abortLocalStream(streamId, 'connection closed');
|
||||
}
|
||||
streams.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
handleFrame,
|
||||
close,
|
||||
get streamCount() {
|
||||
return streams.size;
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user