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
144 lines
5.1 KiB
TypeScript
144 lines
5.1 KiB
TypeScript
// 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,
|
|
};
|
|
};
|