From 6188d7f8bbd638b70115e972ccfaac6e1dee2f80 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 28 May 2026 13:34:52 -0700 Subject: [PATCH] Add self-hosted runtime relay Co-authored-by: Orca --- .gitignore | 1 + config/tsconfig.cli.json | 3 +- config/tsconfig.node.json | 1 + docs/runtime-relay-design.md | 853 ++++++++++++++++++ mobile/src/transport/rpc-client.test.ts | 34 + mobile/src/transport/rpc-client.ts | 119 ++- src/cli/handlers/core.ts | 176 ++++ src/cli/index.test.ts | 184 ++++ src/cli/index.ts | 47 +- src/cli/specs/core.ts | 19 + src/main/ipc/mobile.ts | 87 ++ src/main/runtime/relay-client.test.ts | 133 +++ src/main/runtime/relay-client.ts | 358 ++++++++ src/main/runtime/relay-config.ts | 46 + src/main/runtime/relay-identity.ts | 56 ++ src/main/runtime/rpc/e2ee-channel.test.ts | 117 ++- src/main/runtime/rpc/e2ee-channel.ts | 108 ++- src/main/runtime/rpc/e2ee-integration.test.ts | 2 +- src/main/runtime/runtime-metadata.test.ts | 49 +- src/main/runtime/runtime-relay-e2e.test.ts | 101 +++ src/main/runtime/runtime-rpc.test.ts | 36 +- src/main/runtime/runtime-rpc.ts | 278 ++++-- src/preload/api-types.ts | 32 + src/preload/index.ts | 45 + .../MobileNetworkInterfaceSection.tsx | 21 +- .../src/components/settings/MobilePane.tsx | 167 +++- .../settings/RuntimePairingUrlGenerator.tsx | 153 +++- .../settings/RuntimeRelaySettingsSection.tsx | 311 +++++++ .../components/settings/mobile-pane-search.ts | 6 + src/renderer/src/web/web-preload-api.ts | 14 + .../src/web/web-runtime-client.test.ts | 107 ++- src/renderer/src/web/web-runtime-client.ts | 107 ++- src/runtime-relay-server/protocol.ts | 131 +++ .../relay-server-test-sockets.ts | 157 ++++ .../server-limits.test.ts | 264 ++++++ src/runtime-relay-server/server.test.ts | 294 ++++++ src/runtime-relay-server/server.ts | 796 ++++++++++++++++ .../remote-runtime-client-relay.test.ts | 411 +++++++++ src/shared/remote-runtime-client.test.ts | 30 +- src/shared/remote-runtime-client.ts | 172 +++- .../remote-runtime-request-connection.ts | 73 +- src/shared/remote-runtime-request-frames.ts | 5 +- src/shared/runtime-relay-limits.ts | 1 + src/shared/runtime-relay-transport.ts | 97 ++ 44 files changed, 5998 insertions(+), 204 deletions(-) create mode 100644 docs/runtime-relay-design.md create mode 100644 src/main/runtime/relay-client.test.ts create mode 100644 src/main/runtime/relay-client.ts create mode 100644 src/main/runtime/relay-config.ts create mode 100644 src/main/runtime/relay-identity.ts create mode 100644 src/main/runtime/runtime-relay-e2e.test.ts create mode 100644 src/renderer/src/components/settings/RuntimeRelaySettingsSection.tsx create mode 100644 src/runtime-relay-server/protocol.ts create mode 100644 src/runtime-relay-server/relay-server-test-sockets.ts create mode 100644 src/runtime-relay-server/server-limits.test.ts create mode 100644 src/runtime-relay-server/server.test.ts create mode 100644 src/runtime-relay-server/server.ts create mode 100644 src/shared/remote-runtime-client-relay.test.ts create mode 100644 src/shared/runtime-relay-limits.ts create mode 100644 src/shared/runtime-relay-transport.ts diff --git a/.gitignore b/.gitignore index 1078c5105dd..160d7a59c2d 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ docs/** !docs/reference/ !docs/reference/** !docs/STYLEGUIDE.md +!docs/runtime-relay-design.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 111ac7a86bf..2c678fba59a 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -22,7 +22,8 @@ "../src/main/grok/hook-service.ts", "../src/main/hermes/hook-service.ts", "../src/main/runtime/runtime-metadata.ts", - "../src/main/win32-utils.ts" + "../src/main/win32-utils.ts", + "../src/runtime-relay-server/**/*" ], "compilerOptions": { "composite": true, diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index a8145eb76c3..0d06db3e415 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -5,6 +5,7 @@ "../src/main/**/*", "../src/preload/**/*", "../src/shared/**/*", + "../src/runtime-relay-server/**/*", "../src/relay/**/*", "../src/types/**/*" ], diff --git a/docs/runtime-relay-design.md b/docs/runtime-relay-design.md new file mode 100644 index 00000000000..b3b30cb140c --- /dev/null +++ b/docs/runtime-relay-design.md @@ -0,0 +1,853 @@ +# Runtime Relay Connections + +## Status + +Implemented plan for issue #2471. + +## Problem + +Orca mobile, web, and desktop runtime clients currently pair to the host runtime by +connecting directly to the host's WebSocket endpoint. That works on a shared LAN, +VPN, or tailnet, but fails for users who want to reach their host runtime from a +different network without exposing an inbound port on the host. + +We need a self-hostable relay option that lets the host and client both connect +outbound to a public or private relay, while preserving Orca's current security +model: + +- the relay must not be trusted with source code, terminal output, browser + screenshots, file contents, credentials, or RPC payloads +- existing per-device tokens must remain the authorization mechanism +- existing end-to-end encryption must remain the confidentiality and integrity + boundary +- existing direct WebSocket pairing must keep working +- SSH workspaces must continue to work because the runtime RPC surface can target + local and SSH-backed worktrees + +## Goals + +1. Add a self-hostable relay server that forwards WebSocket frames between Orca + runtime hosts and runtime clients without decrypting them. +2. Let mobile, web, and desktop runtime clients pair through the relay without + being on the same LAN or tailnet as the host. +3. Reuse the existing `E2EEChannel`, `DeviceRegistry`, mobile method allowlist, + streaming dispatch, and binary terminal stream handling. +4. Keep relay deployment independent from the Electron app so users can run it on + a VPS, container host, or internal server. +5. Keep direct pairing as the default low-latency path when it is reachable. + +## Non-goals + +- Do not replace the SSH relay binary in `src/relay/`. That code is for remote + workspace execution over SSH, not public runtime client pairing. +- Do not make the relay a trusted service or add server-side RPC awareness. +- Do not add user accounts, cloud-hosted account identity, or central device + sync. +- Do not add offline message delivery. A relay can buffer a small number of + pre-data-socket frames during connection setup only; it is not a queue. +- Do not tunnel arbitrary TCP ports through this relay. Browser and terminal + behavior continue to use Orca runtime RPCs. +- Do not remove the existing direct WebSocket pairing flow. + +## Current Orca Architecture + +Direct runtime pairing today has these pieces: + +- `src/shared/pairing.ts` defines a pairing offer containing: + - `endpoint` + - `deviceToken` + - `publicKeyB64` +- `src/main/runtime/runtime-rpc.ts` creates pairing offers from the local + WebSocket transport and owns authorization. +- `src/main/runtime/rpc/ws-transport.ts` accepts local-network WebSocket clients. +- `src/main/runtime/rpc/e2ee-channel.ts` performs the E2EE handshake and binds the + authenticated `deviceToken` to the WebSocket. +- `mobile/src/transport/rpc-client.ts`, `src/renderer/src/web/web-runtime-client.ts`, + and `src/shared/remote-runtime-request-connection.ts` connect to the pairing + endpoint and run the same E2EE handshake. + +The current WebSocket path already has the security boundary we want. The relay +should only provide reachability. + +## Proposed Architecture + +Add an untrusted frame-forwarding runtime relay with one control socket per host +and one data socket per client connection. + +```text + control: connected/sync/disconnected + +-----------------------------------------------+ + | | + v | ++-------------+ outbound WS +----------------+ | +| Orca host |-------------------------->| Runtime relay |<-----+ +| runtime | | server | +| |<--------------------------| |<----------------+ ++-------------+ outbound WS +----------------+ | + ^ server data socket ^ | + | | client data socket | + | existing runtime RPC after E2EE | v + +--------------------------------------------------------------+-------------+ + | Orca client | + +-------------+ +``` + +The relay never sees decrypted RPC payloads. It sees routing metadata in the +WebSocket URL and frame sizes/timing only. Relay pairing also requires a +fresh-session E2EE handshake so an active relay cannot replay a captured +authenticated session while a device token remains valid. + +### Why control plus per-client data sockets + +Orca's runtime server treats each WebSocket as an independent security and +lifecycle boundary: + +- each WebSocket owns an `E2EEChannel` +- each WebSocket gets a connection id used for subscription cleanup +- binary terminal stream handlers are registered per connection +- closing one socket must not tear down other sockets for the same device token +- mobile clients may keep multiple concurrent sockets + +A single multiplexed relay tunnel would require inventing a second framing layer +inside the existing encrypted frame stream and then remapping lifecycle cleanup +back to virtual sockets. That is higher risk and would duplicate behavior that +already works. A per-client data socket lets the relay present the same shape as +a direct WebSocket, so the runtime can reuse the existing attachment path. + +## Protocol + +All relay endpoints use WebSocket upgrades under `/ws`. + +### Query parameters + +```text +role=server|client +serverId= +connectionId= +v=1 +``` + +`serverId` is a stable high-entropy id persisted in the host's user data +directory. It is a routing key, not an authorization secret. The pairing offer is +still the authorization grant because it carries a revocable `deviceToken` and +the host public key. + +Host-side relay sockets also require a host-only relay secret. That secret is +generated with the relay `serverId`, stored only on the host, and sent on +`role=server` control/data socket upgrades in an `Authorization: Bearer +` header. It is never included in client pairing offers or query +strings. Without this separate host capability, anyone who learns a pairing URL +could connect as `role=server` and replace the real host's relay sockets. + +First-time host enrollment also requires a relay enrollment token generated or +configured when `orca relay serve` starts. Operators add the token to the saved +host relay URL as `?enrollmentToken=`. The host strips it from WebSocket +query strings and sends it only as `X-Orca-Relay-Enrollment` on the host control +socket. The token is not included in client pairing offers, host data sockets, +or relay client URLs. + +The relay persists a server-side binding for each host: + +- the first valid host control connection for an unknown `serverId` must present + the relay enrollment token and then stores `sha256(hostToken)` in the relay + state file before the control socket is accepted +- later host control and host data sockets for that `serverId` must present a + token whose hash matches the stored binding +- client sockets for an unregistered `serverId` are rejected so they cannot + create unbounded pending state before a host has enrolled +- host data sockets cannot create a binding; only the host control socket can + enroll a `serverId` +- relay restart reloads bindings from the state file before accepting sockets +- rotation requires the host to generate a new relay identity or the relay + operator to explicitly reset that `serverId` binding, after which old pairing + offers for the previous `serverId` stop working + +The enrollment token prevents unauthenticated callers from squatting arbitrary +`serverId` values or filling the binding file on an exposed relay. Once enrolled, +the relay state file is the source of truth. Relay startup fails closed if that +file exists but cannot be parsed or has an invalid shape; silently treating +corrupt persisted bindings as empty would allow accidental re-enrollment. + +### Host control socket + +The host opens: + +```text +wss://relay.example.com/ws?role=server&serverId=&v=1 +``` + +The relay sends plaintext control messages on that socket: + +```ts +type RelayControlMessage = + | { type: 'sync'; connectionIds: string[] } + | { type: 'connected'; connectionId: string } + | { type: 'disconnected'; connectionId: string } +``` + +Control messages contain only relay routing ids. They do not include device +tokens, public keys, RPC methods, file paths, or workspace identifiers. + +The host treats the relay control socket as untrusted input. It validates every +control message against the same `connectionId` shape as the relay server, +deduplicates ids, ignores duplicate `connected` messages for already-open local +data sockets, and applies host-side caps before opening outbound data sockets. +If a `sync` list exceeds the configured host cap or contains invalid ids, the +host closes the control socket and reconnects with backoff rather than trying to +service the list. + +### Client data socket + +The client opens the endpoint from the pairing offer: + +```text +wss://relay.example.com/ws?role=client&serverId=&v=1 +``` + +The relay assigns a random `connectionId`, accepts the client socket, notifies the +host control socket, and buffers up to a small cap of early client frames until +the matching server data socket attaches. + +### Host data socket + +After a `connected` or `sync` control message, the host opens: + +```text +wss://relay.example.com/ws?role=server&serverId=&connectionId=&v=1 +``` + +The relay forwards frames in both directions between: + +- the single client socket assigned to `connectionId` +- the one server data socket tagged `server:` + +For v1, allow exactly one client socket per `connectionId`. A replacement client +socket closes the older client socket for that connection id only as a duplicate +connection guard. This keeps the relay shape aligned with Orca's existing +one-transport-one-`E2EEChannel` lifecycle. If the host data socket closes, the +relay closes the client for that `connectionId` so the client reconnects and +re-handshakes. + +### Frame handling + +The relay treats frames as opaque bytes or text and does not parse application +payloads. + +Rules: + +- max accepted frame size matches or is below Orca's direct WebSocket max +- pending early frames are capped per `connectionId` +- pending clients, active data sockets, and buffered bytes are capped per + `serverId` and globally before the relay notifies the host +- pending client connections without a matching host data socket are closed after + an attach deadline +- control sockets do not forward data frames +- server data socket replacement closes the older server data socket +- client disconnect of the last client for a `connectionId` closes the server data + socket and removes pending frames +- host-role sockets without a matching stored host token binding are rejected + before any routing state is changed + +## Pairing Offer Shape + +Use a compatible first version by keeping `PairingOffer` v2 and setting +`endpoint` to the relay client WebSocket URL. Existing clients already treat the +endpoint as opaque. + +Direct offer: + +```json +{ + "v": 2, + "endpoint": "ws://192.168.1.20:6768", + "deviceToken": "...", + "publicKeyB64": "..." +} +``` + +Relay offer: + +```json +{ + "v": 2, + "endpoint": "wss://relay.example.com/ws?role=client&serverId=...&v=1", + "deviceToken": "...", + "publicKeyB64": "..." +} +``` + +This minimizes client migration. Future work can add multi-endpoint pairing +offers so a saved environment can prefer direct when reachable and fall back to +relay. That is intentionally deferred to keep the first implementation smaller. + +## Components + +### Runtime relay server + +New module: `src/runtime-relay-server/` + +Responsibilities: + +- start an HTTP server with `/health` and `/ws` +- validate relay query parameters +- route sockets by `serverId`, `role`, and `connectionId` +- forward frames without parsing application payloads +- enforce connection, frame, and buffer caps +- close paired sockets on disconnect/replacement +- expose clear logs for host/control/data lifecycle without logging secrets + +The module name avoids conflict with `src/relay/`, which remains SSH workspace +relay code. + +Initial runtime target: Node. Packaging can be a CLI entry in `out/cli` or a +separate built script invoked by `orca relay serve`. The implementation should +not depend on Electron APIs. + +### Host relay client + +New main-process module: `src/main/runtime/relay-client.ts` + +Responsibilities: + +- connect the host control socket outbound +- reconnect control with bounded backoff +- heartbeat control and data sockets +- open host data sockets when control reports client connections +- close data sockets when control reports disconnection +- enforce host-side caps for control messages from an untrusted relay: + - reject invalid `connectionId` values + - reject `sync` lists longer than the host relay data socket cap + - deduplicate `sync` ids before reconciling + - ignore duplicate `connected` events for already-open ids + - refuse to open new data sockets once pending plus active relay sockets reach + the host cap + - close local data sockets that do not reach the runtime E2EE ready state by + the host data attach deadline +- pass each host data socket into the runtime's external WebSocket attach path +- expose relay status for settings UI and diagnostics + +### Runtime external socket attachment + +Refactor `OrcaRuntimeRpcServer` so the local WebSocket listener and relay data +sockets share one attachment method. + +The extracted method must own these existing invariants: + +- create per-WebSocket connection id +- instantiate `E2EEChannel` +- validate device token through `DeviceRegistry` +- set client id after E2EE auth +- update device `lastSeenAt` +- call `handleWebSocketMessage` +- forward encrypted binary frames to terminal stream handlers +- abort dispatches and clean subscriptions on close +- call `runtime.onClientDisconnected` only when the last socket for a device token + closes +- terminate both direct and relay-backed sockets when a device token is revoked + +Local direct WebSocket behavior must not change. + +### Settings and IPC + +Add IPC to: + +- read relay config and runtime status +- update relay URL and enabled state +- generate a relay mobile QR +- generate a relay runtime pairing URL +- report whether relay pairing is available + +Suggested config fields: + +```ts +type RuntimeRelayConfig = { + enabled: boolean + endpoint: string +} +``` + +`endpoint` is a canonical WebSocket URL such as `ws://127.0.0.1:8787` or +`wss://relay.example.com`. TLS is derived from the URL scheme so settings do not +carry two conflicting sources of truth. Store this in the same user settings path +used for runtime/mobile settings if one exists. If there is no durable settings +owner that fits, add a narrow runtime-relay config file under user data with +secure write behavior for consistency. + +### CLI + +Add a self-hosted relay command: + +```text +orca relay serve --host 0.0.0.0 --port 8787 +``` + +Optional flags: + +```text +--host +--port +--public-url +--state-path +--enrollment-token +--json +``` + +Do not add TLS certificate management in the first implementation. Production +TLS can be handled by a reverse proxy. The command should print the HTTP and +WebSocket bind URL, and should separately print the public WebSocket URL users +must configure when the relay binds to `0.0.0.0`, `::`, or another non-client +address. `--public-url` must point at the relay `/ws` endpoint, usually a +reverse-proxied `wss://relay.example.com/ws` URL. Keep stdout JSON-pure when +`--json` is passed. + +`--json` prints exactly one startup object to stdout and all subsequent logs to +stderr: + +```json +{ + "ok": true, + "kind": "runtime_relay_server", + "protocolVersion": 1, + "httpUrl": "http://127.0.0.1:8787", + "webSocketUrl": "ws://127.0.0.1:8787/ws", + "advertisedWebSocketUrl": "ws://127.0.0.1:8787/ws", + "publicUrlRequired": false +} +``` + +Startup failures print one JSON failure object to stdout and exit non-zero. No +other stdout bytes are allowed in `--json` mode. + +### UI + +Add relay controls to the existing mobile/runtime pairing settings area instead +of creating a separate top-level page. + +User-facing states: + +| State | Direct pairing | Relay pairing | +| --- | --- | --- | +| WebSocket disabled | unavailable | unavailable with instruction to enable runtime WebSocket | +| Relay not configured | available if LAN address exists | unavailable with endpoint input | +| Relay connecting | available if LAN address exists | disabled, shows connecting | +| Relay connected | available if LAN address exists | available | +| Relay error | available if LAN address exists | disabled, shows concise error | + +Use existing design tokens, shadcn primitives, and settings layout patterns. +Relay QR/link generation is disabled unless the host control socket is currently +connected. This v1 rule favors fewer broken pairing links over offline +pre-generation. + +## Data Flows + +### Happy path + +```text +1. Host runtime starts direct WebSocket and E2EE keypair/device registry. +2. Host relay client connects control socket to relay. +3. User generates a relay QR or runtime pairing URL. +4. Client opens relay client URL from the pairing offer. +5. Relay assigns connectionId and notifies host control socket. +6. Host opens server data socket for connectionId. +7. Client sends plaintext e2ee_hello through relay. +8. Host E2EEChannel replies e2ee_ready with a fresh host challenge through relay. +9. Client sends encrypted e2ee_auth with deviceToken and that host challenge. +10. Host validates the challenge and token, marks device lastSeenAt, and + dispatches RPCs. +``` + +### Nil path + +Relay config is absent or disabled. + +- Direct pairing works as it does today. +- Relay QR and relay runtime URL actions are disabled. +- IPC returns `{ available: false, reason: 'relay_not_configured' }`. + +### Empty path + +Relay is enabled but no client is connected. + +- Control socket stays connected. +- Relay status shows connected with zero active data sockets. +- No runtime subscriptions or E2EE channels are created until a client data socket + exists. + +### Upstream error path + +Relay endpoint is unreachable, returns non-WebSocket response, or closes control. + +- Host relay client reconnects with bounded backoff. +- Settings status shows the latest concise error. +- Existing direct WebSocket clients remain connected. +- Existing relay clients reconnect according to their current client behavior. +- No device tokens are rotated automatically because endpoint failure is not token + compromise. + +## Security Model + +### Trust boundaries + +Trusted: + +- Orca host runtime process +- paired client that has a valid pairing offer +- secure local user data files storing device tokens and E2EE keypair + +Untrusted: + +- runtime relay server +- network path between relay and host/client +- other clients that can guess or learn `serverId` but do not have a valid + device token + +### Relay visibility + +The relay can see: + +- source IPs +- connection timing +- frame sizes +- `serverId` +- relay `connectionId` +- role query parameter + +The relay must not see: + +- device tokens +- decrypted RPC methods or params +- file paths +- terminal data +- browser screenshots +- source code +- E2EE shared keys + +The relay may see plaintext `e2ee_hello` and `e2ee_ready` frames because those are +already part of the existing E2EE handshake and contain public-key/control data +only. It cannot complete `e2ee_auth` without a valid device token encrypted under +the derived shared key. + +### Authorization + +`serverId` is not authorization. It is routing metadata. Host enrollment and +runtime authorization are separate: + +1. first host control enrollment requires the relay enrollment token +2. every host control/data socket requires the host token bound to `serverId` +3. runtime clients require possession of the pairing offer's device token +4. runtime clients complete the E2EE handshake against the host public key +5. the host checks `DeviceRegistry.validateToken` +6. the host applies the method allowlist for `mobile` scoped devices + +### Freshness, replay, and downgrade + +Relay pairing must upgrade the existing E2EE handshake before it ships. The host +will include a fresh random challenge in `e2ee_ready`, and the client will echo +that challenge inside encrypted `e2ee_auth`. The host accepts authentication only +when the encrypted auth frame contains the current challenge for that WebSocket. + +Why this is required: with a direct LAN connection, the current protocol relies +on network reachability plus E2EE authentication. With an untrusted relay in the +middle, the relay can observe and replay encrypted frames even though it cannot +decrypt them. A per-socket host challenge makes a previously captured +`e2ee_auth` unusable on a new relay data socket. + +Post-auth encrypted frames already carry a random NaCl box nonce in Orca's shared +`encryptBytes` format, so modified frames fail authentication. Relay-backed +sockets must also reject exact encrypted-frame replay on the same live socket by +adding a monotonic sequence number inside every encrypted post-auth frame: + +- each endpoint keeps independent outbound and inbound sequence counters per + WebSocket +- text frames encrypt `{ type: 'e2ee_frame', seq, payload }`, where `payload` is + the JSON string the direct transport already encrypts +- binary frames encrypt an 8-byte big-endian `seq` prefix followed by the binary + payload direct transport already encrypts +- the receiving side accepts only `seq === previousInboundSeq + 1` +- duplicate, old, skipped, non-finite, or malformed sequence numbers close the + socket before RPC dispatch or binary-frame delivery +- counters reset only when the WebSocket closes; replaying frames onto a new + socket is still rejected by the fresh auth challenge + +The relay sees only ciphertext and cannot change sequence numbers without failing +authentication. Relay protocol version `v=1` exists so future incompatible +routing changes can add a different framing format if we need one. + +Compatibility rule: + +- Direct local-network WebSocket sockets may temporarily accept legacy + challenge-less auth so existing mobile/runtime clients do not break on host + upgrade. +- Relay-backed sockets must reject challenge-less auth. +- New clients always include the challenge when the `e2ee_ready` frame includes + one. +- New host and client transports enforce monotonic inbound encrypted frame + sequence numbers on relay-backed sockets. + +The runtime socket attachment receives a transport kind (`direct` or `relay`) and +passes it into `E2EEChannel` so the channel can enforce the stricter relay auth +and sequence rules. Runtime clients receive the same transport kind from the +pairing endpoint so they can enforce sequence numbers for host-to-client frames. +Tests must cover both branches: legacy direct auth still works during the +compatibility window, legacy relay auth fails, and replaying, skipping, or +reordering an encrypted frame on a live relay socket closes before duplicate RPC +dispatch. + +### Logging + +Relay and host logs must not include: + +- full pairing URLs +- `hostToken` +- `deviceToken` +- `publicKeyB64` +- decrypted RPC payloads +- file paths from encrypted payloads + +Log routing ids, connection counts, close codes, and redacted endpoints. The +host token is carried in an authorization header rather than the WebSocket URL to +avoid routine reverse-proxy query logging; relay docs must still call out proxy +header redaction for production deployments. + +## Operational Design + +### Deployment + +Self-hosted deployment can run: + +```text +orca relay serve --host 0.0.0.0 --port 8787 +``` + +The command prints a bind-only WebSocket URL and an enrollment token. Configure Orca +with the public endpoint plus `?enrollmentToken=` for first enrollment. +For internet use, run behind a TLS reverse proxy and configure Orca with the +public `wss://` endpoint, or pass it directly as `--public-url +wss://relay.example.com/ws` so startup output names the usable client URL. + +### Resource limits + +Initial defaults: + +| Limit | Default | +| --- | --- | +| Max frame bytes | 1 MiB | +| Max host control sockets per serverId | 1 | +| Max server data sockets per connectionId | 1 | +| Max client sockets per connectionId | 1 | +| Max pending client connections per serverId | 64 | +| Max active data sockets per serverId | 64 | +| Max buffered early frames per connectionId | 4 | +| Max buffered early bytes per connectionId | 4 MiB | +| Max total buffered early bytes per serverId | 16 MiB | +| Client attach deadline | 15 s | +| Control heartbeat interval | 15 s | +| Stale control timeout | 45 s | +| Host max relay data sockets | 32 | +| Host max sync ids | 32 | +| Host data attach deadline | 15 s | +| E2EE relay frame counters | uint53 monotonic per direction per socket | + +Why these limits: direct WebSocket already caps message size and the runtime caps +connections. The relay should fail closed before it can become an unbounded memory +sink. With a 1 MiB frame cap and 4 MiB per-connection byte cap, the effective +early buffer is four maximum-sized frames; smaller frames are still bounded by the +byte cap. When a per-host or global cap is hit, the relay rejects or closes the +newest unauthenticated client with a retryable overload close code before +notifying the host. + +The relay-side limits are not the only defense. The host relay client also caps +control-plane fanout because the relay is untrusted. A malicious relay can send +arbitrary `connected` and `sync` messages, so the host must bound local socket +creation independently of any relay-side admission checks. + +The relay frame counters use constant memory. A peer that reaches the maximum +safe JavaScript integer sequence value must reconnect and reauthenticate before +sending more frames; this is not expected in normal use, and it avoids wraparound +ambiguity. + +### Rollback + +Relay is additive. If a problem is found: + +1. Disable relay in settings or unset the relay endpoint. +2. Existing direct pairing continues to work. +3. Previously generated relay pairing offers stop working once the host relay + client disconnects, but their device tokens remain revocable through the + existing device management UI. + +No data migration is required for rollback if the first version stores only relay +configuration and a stable `serverId`. + +## UX Journey + +### Pair mobile through relay + +1. User opens Settings -> Mobile. +2. User enters relay endpoint and enables relay. +3. UI shows relay status as connecting, then connected. +4. User chooses relay QR. +5. Phone scans QR and connects from any network. +6. Phone appears in paired devices after successful E2EE authentication. + +Failure states: + +- Invalid relay URL: inline validation error, QR disabled. +- Relay unreachable: status shows last connection error, QR disabled unless we + deliberately allow pre-generation. Initial implementation disables QR to avoid + giving users a link that cannot work yet. +- Auth rejected: existing mobile auth-failed state remains unchanged. + +### Pair desktop/web runtime through relay + +1. User opens runtime pairing controls. +2. User copies relay runtime pairing URL. +3. Remote desktop/web client imports the URL. +4. Client connects through relay and receives `status.get`/runtime metadata after + E2EE authentication. + +Failure states mirror mobile. + +## Implementation Plan + +### Phase 1: Relay server + +- Add `src/runtime-relay-server/protocol.ts` with query parsing and typed control + messages. +- Add `src/runtime-relay-server/server.ts` with HTTP/WebSocket server lifecycle. +- Add host-only relay identity storage containing `serverId` and `hostToken`. +- Add CLI handler `orca relay serve`. +- Add tests for enrollment-token-gated first-use host token binding, wrong-token + rejection, malformed persisted state fail-closed behavior, routing, + replacement, caps, attach deadline cleanup, and close propagation. + +### Phase 2: Runtime external socket attach + +- Add fresh host challenge support to the E2EE channel and all runtime clients. +- Add relay-backed encrypted frame sequence enforcement to the host + `E2EEChannel` and to mobile/web/desktop runtime clients. +- Extract WebSocket/E2EE attachment from `OrcaRuntimeRpcServer.start()`. +- Keep `WebSocketTransport` responsible for local accept/heartbeat only. +- Add tests proving direct WebSocket pairing behavior is unchanged. +- Add tests proving relay-backed sockets reject challenge-less auth while direct + sockets keep the documented compatibility behavior. +- Add tests proving duplicate, skipped, and reordered encrypted frames on a live + relay socket close before duplicate RPC dispatch or duplicate binary-frame + delivery. + +### Phase 3: Host relay client + +- Add relay config and stable host relay id. +- Add `RuntimeRelayClient` for control/data sockets. +- Start/stop relay client with runtime RPC server lifecycle. +- Add status reporting and tests for reconnect, sync, connected, and disconnected. +- Define `sync` reconciliation as idempotent: open only missing data sockets, + close local data sockets absent from `sync`, and never replace a live local + data socket only because its id appeared again in a later `sync`. +- Add tests for malicious control messages: oversized `sync`, invalid + `connectionId`, duplicate `connected`, data-socket cap exhaustion, and data + sockets that never reach E2EE ready. + +### Phase 4: Pairing and IPC + +- Add relay offer generation for mobile and runtime pairing. +- Keep existing direct offer IPC responses stable. +- Add redaction helpers for relay endpoints in logs. +- Add tests for relay pairing URL shape and no token leakage in public responses. +- Add tests proving host tokens are sent only in authorization headers and never + in pairing URLs, relay logs, or user-facing copy. + +### Phase 5: UI + +- Add relay configuration and status controls to mobile/runtime pairing settings. +- Use existing settings components and tokens. +- Include direct-vs-relay QR choice only where it does not overload the current + screen. If space is tight, show a segmented control between Direct and Relay. + +### Phase 6: Verification + +- Unit tests for protocol parsing, relay forwarding, runtime attach, host relay + client, and pairing. +- Integration test with in-process relay and runtime client. +- Hands-on e2e: + 1. Start `orca relay serve` locally. + 2. Start Orca runtime with relay enabled. + 3. Generate relay pairing offer. + 4. Connect a mobile-equivalent or runtime client through the relay from a + separate process. + 5. Call `status.get`. + 6. Create/list a terminal or subscribe to terminal output through relay. + 7. Revoke the paired device and verify active relay sockets close. + +## Test Plan + +Automated: + +- `src/runtime-relay-server/*.test.ts` +- `src/main/runtime/relay-client*.test.ts` +- `src/main/runtime/runtime-rpc*.test.ts` +- `src/main/runtime/rpc/e2ee-channel*.test.ts` +- `src/shared/pairing*.test.ts` +- mobile/web pairing parser tests if endpoint normalization changes + +Manual e2e: + +```text +Terminal A: pnpm run build:cli or dev equivalent +Terminal B: orca relay serve --host 127.0.0.1 --port 8787 +Terminal C: start Orca runtime with relay endpoint ws://127.0.0.1:8787/ws?enrollmentToken= +Terminal D: connect through relay pairing URL and call status.get +``` + +The final verification must prove traffic goes through the relay by using a relay +client endpoint, not by connecting to the direct `ws://0.0.0.0:6768` runtime +listener. + +## Compatibility + +- macOS, Linux, and Windows: use Node URL parsing and `path.join`; no platform path + separators in relay code. +- Browser clients: relay endpoints should use `wss://` when the web app is served + over `https://` to avoid mixed-content blocking. +- SSH workspaces: relay clients call the same runtime RPC surface, so SSH-backed + worktrees are supported as long as the host runtime is connected to the SSH + target. +- Git providers: relay does not inspect review/source-control RPC payloads and + therefore remains provider-neutral. + +## Open Questions + +1. Should the first shipped UI show both direct and relay QR codes side-by-side or + use a mode switch? Recommendation: mode switch to preserve settings density. +2. Should `orca relay serve` include TLS directly? Recommendation: not initially; + reverse proxy support is simpler and avoids certificate storage UX. +3. Should relay status be persisted in runtime metadata for CLI discovery? + Recommendation: expose through IPC/UI first; add CLI discovery only if we need + automation to choose relay pairing. + +## Acceptance Criteria + +- Direct mobile/runtime pairing still works. +- Relay mobile/runtime pairing works when host and client can only reach the relay. +- Relay server cannot read or log decrypted payloads. +- Clients cannot replace host control/data sockets because host relay sockets + require a host-only token that is not in pairing offers. +- Unknown hosts cannot bind arbitrary `serverId` values without the relay + enrollment token. +- Relay host-token bindings survive relay restart and reject wrong-token host + sockets before routing state changes. +- A malicious relay control socket cannot make the host exceed the host-side + relay data socket cap. +- Device revocation closes active relay-backed sockets. +- Replaying an old encrypted auth frame against a new relay-backed socket fails. +- Replaying a post-auth encrypted frame on the same relay-backed socket fails + before duplicate RPC dispatch or duplicate binary-frame delivery. +- Connection floods hit per-host/global relay caps before unbounded host data + socket churn or unbounded buffering. +- Mobile method allowlist still applies over relay. +- Terminal streaming works over relay. +- Browser screencast or another binary-frame path works over relay, or the design + explicitly blocks release until binary frames are verified. +- All new code is cross-platform and has no provider-specific assumptions. +- No generated artifact, code comment, commit message, or user-facing text names + any external reference implementation. diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index 11b6419cb6e..453c4baec23 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -120,6 +120,11 @@ function encodeTerminalOutput(streamId: number, chunk: string): Uint8Array { }) } +async function flushInboundQueue(): Promise { + await Promise.resolve() + await Promise.resolve() +} + describe('mobile rpc-client connection timeout', () => { beforeEach(() => { vi.useFakeTimers() @@ -169,6 +174,35 @@ describe('mobile rpc-client connection timeout', () => { client.close() }) + it('ignores queued handshake messages from sockets replaced during reconnect', async () => { + const client = connect( + 'ws://desktop.invalid?role=client&serverId=relay-1', + 'token', + 'server-key' + ) + const firstSocket = mockSockets[0]! + + firstSocket.open() + firstSocket.receive(JSON.stringify({ type: 'e2ee_ready', challenge: 'first-challenge' })) + firstSocket.receive('encrypted:{"type":"e2ee_authenticated"}') + await flushInboundQueue() + + expect(client.getState()).toBe('connected') + + firstSocket.close() + vi.advanceTimersByTime(500) + const secondSocket = mockSockets[1]! + secondSocket.open() + firstSocket.receive(JSON.stringify({ type: 'e2ee_ready', challenge: 'stale-challenge' })) + await flushInboundQueue() + + expect(secondSocket.sent).not.toContain( + 'encrypted:{"type":"e2ee_auth","deviceToken":"token","challenge":"stale-challenge"}' + ) + + client.close() + }) + it('sends session tabs unsubscribe when a session tab stream is disposed', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const socket = mockSockets[0]! diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 995169f9bfb..075798f73d8 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -106,6 +106,8 @@ const HANDSHAKE_TIMEOUT_MS = 5_000 // Why: RN's WebSocket implementation may not expose static readyState // constants, but the protocol value for CONNECTING is stable across runtimes. const WEBSOCKET_CONNECTING_STATE = 0 +const RELAY_BINARY_SEQUENCE_BYTES = 8 +const RELAY_MAX_SEQUENCE = Number.MAX_SAFE_INTEGER // Why: app-level liveness probe. The server runs its own ping/pong sweep // at 15s, but RN's WebSocket runtime auto-pongs at the native layer @@ -120,6 +122,17 @@ const WEBSOCKET_CONNECTING_STATE = 0 // window and well below iOS's typical background-disconnect window. const ACTIVITY_PROBE_INTERVAL_MS = 20_000 +function getRuntimeWebSocketTransportKind(endpoint: string): 'direct' | 'relay' { + try { + const url = new URL(endpoint) + return url.searchParams.get('role') === 'client' && url.searchParams.has('serverId') + ? 'relay' + : 'direct' + } catch { + return 'direct' + } +} + export type ConnectOptions = { onStateChange?: (state: ConnectionState) => void // Fires for every observable lifecycle event so the UI can render a @@ -141,6 +154,7 @@ export function connect( : (optionsOrLegacy ?? {}) const onStateChange = options.onStateChange const onLog = options.onLog + const transportKind = getRuntimeWebSocketTransportKind(endpoint) let logCounter = 0 function emitLog(level: ConnectionLogLevel, message: string, detail?: string) { if (!onLog) return @@ -179,6 +193,9 @@ export function connect( // The shared key is derived from our ephemeral secret + server's static public key. let sharedKey: Uint8Array | null = null const serverPublicKey = publicKeyFromBase64(serverPublicKeyB64) + let inboundRelaySeq = 0 + let outboundRelaySeq = 0 + let inboundMessageQueue = Promise.resolve() const pending = new Map() const streamListeners = new Map() @@ -268,6 +285,8 @@ export function connect( }) setState('connecting') sharedKey = null + inboundRelaySeq = 0 + outboundRelaySeq = 0 currentWsOpenedAt = now emitLog( @@ -336,7 +355,17 @@ export function connect( } ws.onmessage = (event) => { - void handleSocketMessage(event.data) + // Why: relay sequencing is shared across text and binary frames. RN may + // convert binary payloads asynchronously, so process wire events in order. + const messageWs = openingWs + inboundMessageQueue = inboundMessageQueue + .then(() => { + if (messageWs !== ws) { + return + } + return handleSocketMessage(event.data) + }) + .catch(() => {}) } async function handleSocketMessage(rawData: unknown) { @@ -355,7 +384,11 @@ export function connect( const msg = JSON.parse(raw) if (msg.type === 'e2ee_ready') { emitLog('success', 'Received e2ee_ready', 'Sending device token') - sendEncrypted({ type: 'e2ee_auth', deviceToken }) + sendEncrypted({ + type: 'e2ee_auth', + deviceToken, + challenge: typeof msg.challenge === 'string' ? msg.challenge : undefined + }) return } } catch { @@ -438,11 +471,19 @@ export function connect( if (!plaintextBytes) { return } - handleBinaryFrame(plaintextBytes) + const binaryPayload = unwrapRelayBinaryFrame(plaintextBytes) + if (!binaryPayload) { + return + } + handleBinaryFrame(binaryPayload) return } - const plaintext = decrypt(raw, sharedKey) + const decrypted = decrypt(raw, sharedKey) + if (decrypted === null) { + return + } + const plaintext = unwrapRelayTextFrame(decrypted) if (plaintext === null) { return } @@ -927,7 +968,7 @@ export function connect( function sendEncrypted(request: unknown): boolean { if (ws && ws.readyState === WebSocket.OPEN && sharedKey) { - ws.send(encrypt(JSON.stringify(request), sharedKey)) + ws.send(encrypt(wrapRelayTextFrame(JSON.stringify(request)), sharedKey)) return true } console.log('[net] sendEncrypted FAILED — channel not ready', { @@ -950,6 +991,74 @@ export function connect( return false } + function nextOutboundRelaySeq(): number | null { + if (outboundRelaySeq >= RELAY_MAX_SEQUENCE) { + ws?.close() + return null + } + outboundRelaySeq += 1 + return outboundRelaySeq + } + + function acceptInboundRelaySeq(seq: number): boolean { + if (!Number.isSafeInteger(seq) || seq !== inboundRelaySeq + 1) { + ws?.close() + return false + } + inboundRelaySeq = seq + return true + } + + function wrapRelayTextFrame(payload: string): string { + if (transportKind !== 'relay' || state !== 'connected') { + return payload + } + const seq = nextOutboundRelaySeq() + if (seq === null) { + return payload + } + return JSON.stringify({ type: 'e2ee_frame', seq, payload }) + } + + function unwrapRelayTextFrame(plaintext: string): string | null { + if (transportKind !== 'relay') { + return plaintext + } + let frame: { type?: unknown; seq?: unknown; payload?: unknown } + try { + frame = JSON.parse(plaintext) as { type?: unknown; seq?: unknown; payload?: unknown } + } catch { + ws?.close() + return null + } + if ( + frame.type !== 'e2ee_frame' || + typeof frame.seq !== 'number' || + typeof frame.payload !== 'string' || + !acceptInboundRelaySeq(frame.seq) + ) { + ws?.close() + return null + } + return frame.payload + } + + function unwrapRelayBinaryFrame(plaintext: Uint8Array): Uint8Array | null { + if (transportKind !== 'relay') { + return plaintext + } + if (plaintext.length < RELAY_BINARY_SEQUENCE_BYTES) { + ws?.close() + return null + } + const view = new DataView(plaintext.buffer, plaintext.byteOffset, RELAY_BINARY_SEQUENCE_BYTES) + const seq = Number(view.getBigUint64(0, false)) + if (!acceptInboundRelaySeq(seq)) { + return null + } + return plaintext.slice(RELAY_BINARY_SEQUENCE_BYTES) + } + function sendBrowserScreencastUnsubscribe(subscriptionId: string): void { sendEncrypted({ id: nextId(), diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index 895aa7abca8..bcbcf3dcbe4 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -1,6 +1,9 @@ +import { homedir } from 'os' +import { join } from 'path' import type { CommandHandler } from '../dispatch' import { formatCliStatus, formatStatus, printResult } from '../format' import { RuntimeClientError, serveOrcaApp } from '../runtime-client' +import { startRuntimeRelayServer } from '../../runtime-relay-server/server' export const CORE_HANDLERS: Record = { open: async ({ client, json }) => { @@ -33,6 +36,83 @@ export const CORE_HANDLERS: Record = { }) process.exitCode = exitCode }, + 'relay serve': async ({ flags, json }) => { + const rawHost = flags.get('host') + if (flags.has('host') && (!isNonBlankString(rawHost) || rawHost.trim() !== rawHost)) { + throw new RuntimeClientError('invalid_argument', 'Missing required --host value.') + } + const host = typeof rawHost === 'string' ? rawHost : '127.0.0.1' + const rawPort = flags.get('port') + if (flags.has('port') && !isNonBlankString(rawPort)) { + throw new RuntimeClientError('invalid_argument', 'Missing required --port value.') + } + const port = typeof rawPort === 'string' ? Number(rawPort) : 8787 + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new RuntimeClientError('invalid_argument', `Invalid --port value: ${String(rawPort)}`) + } + const rawStatePath = flags.get('state-path') + if (flags.has('state-path') && !isNonBlankString(rawStatePath)) { + throw new RuntimeClientError('invalid_argument', 'Missing required --state-path value.') + } + const rawEnrollmentToken = flags.get('enrollment-token') + if (flags.has('enrollment-token') && !isNonBlankString(rawEnrollmentToken)) { + throw new RuntimeClientError('invalid_argument', 'Missing required --enrollment-token value.') + } + const rawPublicUrl = flags.get('public-url') + if ( + flags.has('public-url') && + (!isNonBlankString(rawPublicUrl) || rawPublicUrl.trim() !== rawPublicUrl) + ) { + throw new RuntimeClientError('invalid_argument', 'Missing required --public-url value.') + } + const advertisedWebSocketUrl = + typeof rawPublicUrl === 'string' ? normalizeRelayPublicWebSocketUrl(rawPublicUrl) : null + try { + const relay = await startRuntimeRelayServer({ + host, + port, + statePath: + typeof rawStatePath === 'string' ? rawStatePath : defaultRuntimeRelayServerStatePath(), + enrollmentToken: typeof rawEnrollmentToken === 'string' ? rawEnrollmentToken : undefined, + log: (message) => { + const output = `${message}\n` + if (json) { + process.stderr.write(output) + } else { + process.stdout.write(output) + } + } + }) + const startup = { + ok: true, + kind: 'runtime_relay_server', + protocolVersion: relay.protocolVersion, + httpUrl: relay.httpUrl, + webSocketUrl: relay.webSocketUrl, + advertisedWebSocketUrl: + advertisedWebSocketUrl ?? (isWildcardBindHost(host) ? null : relay.webSocketUrl), + publicUrlRequired: advertisedWebSocketUrl === null && isWildcardBindHost(host), + enrollmentToken: relay.enrollmentToken + } + process.stdout.write(json ? `${JSON.stringify(startup)}\n` : formatRelayStartup(startup)) + await waitForRelayShutdown(relay.stop) + } catch (error) { + if (json) { + const code = error instanceof RuntimeClientError ? error.code : 'runtime_error' + const message = error instanceof Error ? error.message : String(error) + process.stdout.write( + `${JSON.stringify({ + ok: false, + kind: 'runtime_relay_server', + error: { code, message } + })}\n` + ) + process.exitCode = 1 + return + } + throw error + } + }, status: async ({ client, json }) => { const result = await client.getCliStatus() if (!json && !result.result.runtime.reachable) { @@ -41,3 +121,99 @@ export const CORE_HANDLERS: Record = { printResult(result, json, formatStatus) } } + +function isNonBlankString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +function normalizeRelayPublicWebSocketUrl(rawUrl: string): string { + let url: URL + try { + url = new URL(rawUrl) + } catch { + throw new RuntimeClientError('invalid_argument', `Invalid --public-url value: ${rawUrl}`) + } + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw new RuntimeClientError('invalid_argument', 'Use a ws:// or wss:// URL for --public-url.') + } + if (url.username || url.password || url.search || url.hash) { + throw new RuntimeClientError( + 'invalid_argument', + 'Pass --public-url without credentials, query parameters, or fragments.' + ) + } + if (url.pathname === '/' || url.pathname === '') { + url.pathname = '/ws' + } + if (url.pathname !== '/ws') { + throw new RuntimeClientError('invalid_argument', 'Use a --public-url ending in /ws.') + } + return url.toString() +} + +function isWildcardBindHost(host: string): boolean { + return ['0.0.0.0', '::', '[::]'].includes(host) +} + +function defaultRuntimeRelayServerStatePath(): string { + const envPath = process.env.ORCA_USER_DATA_PATH + const userDataPath = + envPath && envPath.trim() + ? envPath + : process.platform === 'darwin' + ? join(homedir(), 'Library', 'Application Support', 'orca') + : process.platform === 'win32' + ? join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'orca') + : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'orca') + return join(userDataPath, 'runtime-relay-server-state.json') +} + +function formatRelayStartup(startup: { + httpUrl: string + webSocketUrl: string + advertisedWebSocketUrl: string | null + publicUrlRequired: boolean + enrollmentToken: string + protocolVersion: number +}): string { + const lines = [ + `Orca runtime relay listening on ${startup.httpUrl}`, + startup.advertisedWebSocketUrl + ? `Configure Orca/mobile with: ${startup.advertisedWebSocketUrl}` + : 'Configure Orca/mobile with your public wss://.../ws endpoint; 0.0.0.0 and :: are bind addresses only.', + startup.advertisedWebSocketUrl === startup.webSocketUrl + ? null + : `Bind-only WebSocket endpoint: ${startup.webSocketUrl}`, + `Enrollment token: ${startup.enrollmentToken}`, + `Protocol version: ${startup.protocolVersion}`, + 'Stop it with Ctrl+C.', + '' + ] + if (startup.publicUrlRequired) { + lines.splice( + 3, + 0, + 'For self-hosting, pass --public-url wss://relay.example.com/ws or configure that public URL in Orca settings.' + ) + } + return lines.filter((line): line is string => line !== null).join('\n') +} + +function waitForRelayShutdown(stop: () => Promise): Promise { + return new Promise((resolve) => { + let stopping = false + const shutdown = () => { + if (stopping) { + return + } + stopping = true + void stop().finally(() => { + process.off('SIGINT', shutdown) + process.off('SIGTERM', shutdown) + resolve() + }) + } + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + }) +} diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index b1efaa23cb8..45d75b571f6 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -5,12 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { callMock, serveOrcaAppMock, + startRuntimeRelayServerMock, getDefaultUserDataPathMock, addEnvironmentFromPairingCodeMock, listEnvironmentsMock } = vi.hoisted(() => ({ callMock: vi.fn(), serveOrcaAppMock: vi.fn(), + startRuntimeRelayServerMock: vi.fn(), getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), addEnvironmentFromPairingCodeMock: vi.fn(), listEnvironmentsMock: vi.fn() @@ -79,6 +81,10 @@ vi.mock('./runtime/environments', () => ({ resolveEnvironment: vi.fn() })) +vi.mock('../runtime-relay-server/server', () => ({ + startRuntimeRelayServer: startRuntimeRelayServerMock +})) + import { buildCurrentWorktreeSelector, COMMAND_SPECS, @@ -110,6 +116,7 @@ describe('orca cli worktree awareness', () => { delete process.env.ORCA_TERMINAL_HANDLE delete process.env.ORCA_USER_DATA_PATH serveOrcaAppMock.mockReset() + startRuntimeRelayServerMock.mockReset() getDefaultUserDataPathMock.mockClear() addEnvironmentFromPairingCodeMock.mockReset() listEnvironmentsMock.mockReset() @@ -179,6 +186,31 @@ describe('orca cli worktree awareness', () => { ) }) + it('lists the relay group from root help', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['--help'], '/tmp/repo') + + const output = logSpy.mock.calls.flat().join('\n') + expect(output).toContain('Relay:') + expect(output).toContain('relay serve') + expect(output).toContain('Start a self-hosted Orca runtime relay server') + expect(callMock).not.toHaveBeenCalled() + }) + + it('prints relay group help', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['relay', '--help'], '/tmp/repo') + + const output = logSpy.mock.calls.flat().join('\n') + expect(output).toContain('orca relay') + expect(output).toContain('Usage: orca relay [options]') + expect(output).toContain('serve') + expect(output).toContain('Run `orca relay --help`') + expect(callMock).not.toHaveBeenCalled() + }) + it('shows the enclosing worktree for `worktree current`', async () => { queueFixtures( callMock, @@ -710,6 +742,142 @@ describe('orca cli worktree awareness', () => { }) }) + it('does not present wildcard relay bind hosts as client endpoints', async () => { + const stopMock = vi.fn().mockResolvedValue(undefined) + startRuntimeRelayServerMock.mockResolvedValue({ + httpUrl: 'http://0.0.0.0:8787', + webSocketUrl: 'ws://0.0.0.0:8787/ws', + enrollmentToken: 'enroll-token', + protocolVersion: 1, + stop: stopMock + }) + let resolveStartupWritten: () => void = () => {} + const startupWritten = new Promise((resolve) => { + resolveStartupWritten = resolve + }) + const stdoutSpy = vi.spyOn(process.stdout, 'write') + stdoutSpy.mockImplementation(((chunk: unknown) => { + if (String(chunk).includes('Orca runtime relay listening')) { + resolveStartupWritten() + } + return true + }) as never) + + const run = main(['relay', 'serve', '--host', '0.0.0.0'], '/tmp/repo') + await startupWritten + await new Promise((resolve) => setTimeout(resolve, 0)) + process.emit('SIGINT') + await run + + const output = stdoutSpy.mock.calls.map((call) => String(call[0])).join('') + expect(output).toContain('Configure Orca/mobile with your public wss://.../ws endpoint') + expect(output).toContain('0.0.0.0 and :: are bind addresses only') + expect(output).toContain('Bind-only WebSocket endpoint: ws://0.0.0.0:8787/ws') + expect(output).not.toMatch(/^WebSocket endpoint: ws:\/\/0\.0\.0\.0:8787\/ws$/m) + expect(stopMock).toHaveBeenCalled() + }) + + it('emits the configured public relay URL in json startup output', async () => { + const stopMock = vi.fn().mockResolvedValue(undefined) + startRuntimeRelayServerMock.mockResolvedValue({ + httpUrl: 'http://0.0.0.0:8787', + webSocketUrl: 'ws://0.0.0.0:8787/ws', + enrollmentToken: 'enroll-token', + protocolVersion: 1, + stop: stopMock + }) + let resolveStartupWritten: () => void = () => {} + const startupWritten = new Promise((resolve) => { + resolveStartupWritten = resolve + }) + const stdoutSpy = vi.spyOn(process.stdout, 'write') + stdoutSpy.mockImplementation(((chunk: unknown) => { + if (String(chunk).includes('"kind":"runtime_relay_server"')) { + resolveStartupWritten() + } + return true + }) as never) + + const run = main( + [ + 'relay', + 'serve', + '--host', + '0.0.0.0', + '--public-url', + 'wss://relay.example.com/ws', + '--json' + ], + '/tmp/repo' + ) + await startupWritten + await new Promise((resolve) => setTimeout(resolve, 0)) + process.emit('SIGINT') + await run + + const startup = JSON.parse(String(stdoutSpy.mock.calls[0]?.[0])) + expect(startup).toMatchObject({ + ok: true, + kind: 'runtime_relay_server', + httpUrl: 'http://0.0.0.0:8787', + webSocketUrl: 'ws://0.0.0.0:8787/ws', + advertisedWebSocketUrl: 'wss://relay.example.com/ws', + publicUrlRequired: false, + enrollmentToken: 'enroll-token' + }) + expect(stopMock).toHaveBeenCalled() + }) + + it('rejects invalid public relay URLs before starting the relay', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + const invalidUrls = [ + 'http://relay.example.com/ws', + 'wss://user:pass@relay.example.com/ws', + 'wss://relay.example.com/ws?enrollmentToken=secret', + 'wss://relay.example.com/ws#secret', + 'wss://relay.example.com/not-ws' + ] + + for (const invalidUrl of invalidUrls) { + process.exitCode = undefined + await main(['relay', 'serve', '--public-url', invalidUrl, '--json'], '/tmp/repo') + } + + expect(startRuntimeRelayServerMock).not.toHaveBeenCalled() + const output = [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n') + expect(output).toContain('Use a ws:// or wss:// URL for --public-url.') + expect(output).toContain( + 'Pass --public-url without credentials, query parameters, or fragments.' + ) + expect(output).toContain('Use a --public-url ending in /ws.') + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('emits structured json when relay startup fails', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const priorExitCode = process.exitCode + startRuntimeRelayServerMock.mockRejectedValue(new Error('listen EADDRINUSE')) + + await main(['relay', 'serve', '--json'], '/tmp/repo') + + const output = JSON.parse(String(stdoutSpy.mock.calls[0]?.[0])) + expect(output).toMatchObject({ + ok: false, + kind: 'runtime_relay_server', + error: { + code: 'runtime_error', + message: 'listen EADDRINUSE' + } + }) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('rejects contradictory serve pairing flags', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -742,6 +910,22 @@ describe('orca cli worktree awareness', () => { process.exitCode = priorExitCode }) + it('rejects blank relay serve flag values before starting the relay', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['relay', 'serve', '--port', '', '--json'], '/tmp/repo') + + expect(startRuntimeRelayServerMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Missing required --port value.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('lists saved environments even when ORCA_ENVIRONMENT is set', async () => { process.env.ORCA_ENVIRONMENT = 'stale-env' listEnvironmentsMock.mockReturnValue([addEnvironmentFromPairingCodeMock()]) diff --git a/src/cli/index.ts b/src/cli/index.ts index d8218a07000..58710137743 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -18,7 +18,10 @@ export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selec function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { return ( - commandPath[0] === 'environment' || commandPath[0] === 'serve' || commandPath[0] === 'agent' + commandPath[0] === 'environment' || + commandPath[0] === 'serve' || + commandPath[0] === 'agent' || + (commandPath[0] === 'relay' && commandPath[1] === 'serve') ) } @@ -26,18 +29,19 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv)) const helpPath = resolveHelpPath(parsed) if (helpPath !== null) { - printHelp(COMMAND_SPECS, helpPath) + printCliHelp(helpPath) if ( helpPath.length > 0 && !findCommandSpec(COMMAND_SPECS, helpPath) && - !isCommandGroup(helpPath) + !isCommandGroup(helpPath) && + !isSpecBackedCommandGroup(helpPath) ) { process.exitCode = 1 } return } if (parsed.commandPath.length === 0) { - printHelp(COMMAND_SPECS, []) + printCliHelp([]) return } const json = parsed.flags.has('json') @@ -53,7 +57,7 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P // Why: pass `null` (not `undefined`) when remote selection is suppressed // so the RuntimeClient default parameter does not re-activate the // ORCA_PAIRING_CODE / ORCA_ENVIRONMENT env-var fallback for commands - // that must run locally (environment / serve). + // that must run locally. const client = new RuntimeClient( undefined, undefined, @@ -79,3 +83,36 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P if (require.main === module) { void main() } + +function printCliHelp(commandPath: string[]): void { + if (isSpecBackedCommandGroup(commandPath)) { + console.log(formatSpecBackedGroupHelp(commandPath[0])) + return + } + printHelp(COMMAND_SPECS, commandPath) + if (commandPath.length === 0) { + console.log( + [ + '', + 'Relay:', + ' relay serve Start a self-hosted Orca runtime relay server' + ].join('\n') + ) + } +} + +function isSpecBackedCommandGroup(commandPath: string[]): boolean { + return ( + commandPath.length === 1 && + COMMAND_SPECS.some((spec) => spec.path.length > 1 && spec.path[0] === commandPath[0]) + ) +} + +function formatSpecBackedGroupHelp(group: string): string { + const lines = [`orca ${group}`, '', `Usage: orca ${group} [options]`, '', 'Commands:'] + for (const spec of COMMAND_SPECS.filter((entry) => entry.path[0] === group)) { + lines.push(` ${spec.path.slice(1).join(' ').padEnd(18)} ${spec.summary}`) + } + lines.push('', `Run \`orca ${group} --help\` for command-specific usage.`) + return lines.join('\n') +} diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 6eea616b412..4cf4a67fd76 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -28,6 +28,25 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'orca serve --pairing-address 100.64.1.20 --mobile-pairing' ] }, + { + path: ['relay', 'serve'], + summary: 'Start a self-hosted Orca runtime relay server', + usage: + 'orca relay serve [--host ] [--port ] [--public-url ] [--state-path ] [--enrollment-token ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'host', 'port', 'public-url', 'state-path', 'enrollment-token'], + notes: [ + 'Runs in the foreground and forwards encrypted runtime WebSocket frames between hosts and clients.', + 'Use --public-url wss://relay.example.com/ws when binding to 0.0.0.0, ::, or a private interface.', + 'Host token bindings persist under the Orca user-data directory by default; pass --state-path for an explicit server state file.', + 'First-time host enrollment requires the startup enrollment token. Add it to the saved Orca relay URL as ?enrollmentToken=.', + 'Use a TLS reverse proxy and configure Orca with the public wss:// endpoint for internet use.', + 'When --json is passed, stdout contains exactly one startup object; later logs go to stderr.' + ], + examples: [ + 'orca relay serve --host 0.0.0.0 --port 8787 --public-url wss://relay.example.com/ws', + 'orca relay serve --json' + ] + }, { path: ['status'], summary: 'Show app/runtime/graph readiness', diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 258e3f85ccd..300c0e60df4 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -44,6 +44,20 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant { } } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' +} + +function parseRelayPairingScope(value: unknown): 'mobile' | 'runtime' { + if (value === undefined) { + return 'mobile' + } + if (value === 'mobile' || value === 'runtime') { + return value + } + throw new Error('Invalid relay pairing scope') +} + // Why: the mobile IPC handlers provide the renderer with QR code pairing data, // device management, and WebSocket readiness status. They depend on the // OrcaRuntimeRpcServer because it owns the device registry and TLS state. @@ -127,6 +141,79 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { } ) + ipcMain.handle( + 'mobile:getRelayPairingQR', + async (_event, args?: { rotate?: boolean; scope?: 'mobile' | 'runtime' }) => { + const relayScope = parseRelayPairingScope(isRecord(args) ? args.scope : undefined) + const offer = rpcServer.createRelayPairingOffer({ + rotate: isRecord(args) ? args.rotate === true : false, + name: `${relayScope === 'runtime' ? 'Runtime' : 'Mobile'} ${new Date().toLocaleDateString()}`, + scope: relayScope + }) + if (!offer.available) { + return { available: false as const, reason: offer.reason } + } + const qrDataUrl = await QRCode.toDataURL(offer.pairingUrl, { + errorCorrectionLevel: 'M', + margin: 2, + width: 256 + }) + return { + available: true as const, + qrDataUrl, + pairingUrl: offer.pairingUrl, + endpoint: offer.endpoint, + deviceId: offer.deviceId, + webClientUrl: offer.webClientUrl + } + } + ) + + ipcMain.handle( + 'mobile:getRelayRuntimePairingUrl', + async (_event, args?: { rotate?: boolean }) => { + const offer = rpcServer.createRelayPairingOffer({ + rotate: isRecord(args) ? args.rotate === true : false, + name: `Runtime ${new Date().toLocaleDateString()}`, + scope: 'runtime' + }) + if (!offer.available) { + return { available: false as const, reason: offer.reason } + } + return { + available: true as const, + pairingUrl: offer.pairingUrl, + webClientUrl: offer.webClientUrl, + endpoint: offer.endpoint, + deviceId: offer.deviceId + } + } + ) + + ipcMain.handle('mobile:getRelayConfig', () => ({ + config: rpcServer.getRelayConfig(), + status: rpcServer.getRelayStatus() + })) + + ipcMain.handle( + 'mobile:updateRelayConfig', + (_event, args: { enabled?: boolean; endpoint?: string }) => { + if (!isRecord(args)) { + throw new Error('Invalid relay config update') + } + const current = rpcServer.getRelayConfig() + return { + config: rpcServer.updateRelayConfig({ + enabled: typeof args.enabled === 'boolean' ? args.enabled : current.enabled, + endpoint: typeof args.endpoint === 'string' ? args.endpoint : current.endpoint + }), + status: rpcServer.getRelayStatus() + } + } + ) + + ipcMain.handle('mobile:getRelayStatus', () => ({ status: rpcServer.getRelayStatus() })) + ipcMain.handle('mobile:listDevices', () => { const registry = rpcServer.getDeviceRegistry() if (!registry) { diff --git a/src/main/runtime/relay-client.test.ts b/src/main/runtime/relay-client.test.ts new file mode 100644 index 00000000000..8dc6d1d04de --- /dev/null +++ b/src/main/runtime/relay-client.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from 'vitest' +import { RUNTIME_RELAY_MAX_FRAME_BYTES } from '../../shared/runtime-relay-limits' +import { RuntimeRelayClient } from './relay-client' + +const { FakeWebSocket, createdSockets } = vi.hoisted(() => { + class HoistedFakeWebSocket { + static OPEN = 1 + readonly OPEN = 1 + readyState = 1 + readonly endpoint: URL + readonly options: unknown + readonly closeCalls: { code?: number; reason?: string }[] = [] + private listeners = new Map void)[]>() + + constructor(endpoint: URL, options: unknown) { + this.endpoint = endpoint + this.options = options + sockets.push(this) + } + + on(event: string, listener: (...args: unknown[]) => void): this { + this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]) + return this + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(...args) + } + } + + close(code?: number, reason?: string): void { + this.closeCalls.push({ code, reason }) + } + } + + const sockets: HoistedFakeWebSocket[] = [] + return { FakeWebSocket: HoistedFakeWebSocket, createdSockets: sockets } +}) + +vi.mock('ws', () => ({ + default: FakeWebSocket +})) + +describe('RuntimeRelayClient', () => { + it('ignores stale control socket events after config rotation', () => { + createdSockets.length = 0 + const attachDataSocket = vi.fn() + const client = new RuntimeRelayClient({ + config: { enabled: true, endpoint: 'ws://relay-a.test/ws' }, + identity: { serverId: 'serverIdForRelayClientTest01', hostToken: 'host-token' }, + attachDataSocket + }) + + client.start() + const oldControl = createdSockets[0]! + oldControl.emit('open') + + client.updateConfig({ enabled: true, endpoint: 'ws://relay-b.test/ws' }) + expect(createdSockets).toHaveLength(2) + + oldControl.emit( + 'message', + JSON.stringify({ type: 'connected', connectionId: 'abc123abc123abcd' }) + ) + + expect(createdSockets).toHaveLength(2) + expect(attachDataSocket).not.toHaveBeenCalled() + }) + + it('closes the control socket on invalid JSON values from the relay', () => { + createdSockets.length = 0 + const attachDataSocket = vi.fn() + const client = new RuntimeRelayClient({ + config: { enabled: true, endpoint: 'ws://relay-a.test/ws' }, + identity: { serverId: 'serverIdForRelayClientTest02', hostToken: 'host-token' }, + attachDataSocket + }) + + client.start() + const control = createdSockets[0]! + control.emit('open') + + expect(() => control.emit('message', 'null')).not.toThrow() + expect(control.closeCalls).toContainEqual({ + code: 1008, + reason: 'Invalid relay control message' + }) + }) + + it('sends enrollment tokens as a host-only header', () => { + createdSockets.length = 0 + const client = new RuntimeRelayClient({ + config: { + enabled: true, + endpoint: 'ws://relay-a.test/ws?enrollmentToken=enrollment-secret' + }, + identity: { serverId: 'serverIdForRelayClientTest03', hostToken: 'host-token' }, + attachDataSocket: vi.fn() + }) + + client.start() + const control = createdSockets[0]! + + expect(control.endpoint.searchParams.has('enrollmentToken')).toBe(false) + expect((control.options as { headers: Record }).headers).toMatchObject({ + Authorization: 'Bearer host-token', + 'X-Orca-Relay-Enrollment': 'enrollment-secret' + }) + }) + + it('caps received relay frames on control and data sockets', () => { + createdSockets.length = 0 + const client = new RuntimeRelayClient({ + config: { enabled: true, endpoint: 'ws://relay-a.test/ws' }, + identity: { serverId: 'serverIdForRelayClientTest04', hostToken: 'host-token' }, + attachDataSocket: vi.fn() + }) + + client.start() + const control = createdSockets[0]! + control.emit('open') + control.emit('message', JSON.stringify({ type: 'connected', connectionId: 'abc123abc123abcd' })) + + expect(createdSockets).toHaveLength(2) + expect((control.options as { maxPayload: number }).maxPayload).toBe( + RUNTIME_RELAY_MAX_FRAME_BYTES + ) + expect((createdSockets[1]!.options as { maxPayload: number }).maxPayload).toBe( + RUNTIME_RELAY_MAX_FRAME_BYTES + ) + }) +}) diff --git a/src/main/runtime/relay-client.ts b/src/main/runtime/relay-client.ts new file mode 100644 index 00000000000..2bdc2fa4cbc --- /dev/null +++ b/src/main/runtime/relay-client.ts @@ -0,0 +1,358 @@ +/* eslint-disable max-lines -- Why: the host relay client is one socket lifecycle state machine; splitting control/data cleanup would obscure stale-socket guards. */ +import WebSocket from 'ws' +import { RUNTIME_RELAY_MAX_FRAME_BYTES } from '../../shared/runtime-relay-limits' +import type { RuntimeRelayIdentity } from './relay-identity' + +export type RuntimeRelayConfig = { + enabled: boolean + endpoint: string +} + +export type RuntimeRelayStatus = + | { state: 'disabled'; activeDataSockets: 0; error: null } + | { state: 'connecting'; activeDataSockets: number; error: string | null } + | { state: 'connected'; activeDataSockets: number; error: null } + | { state: 'error'; activeDataSockets: number; error: string } + +type RuntimeRelayClientOptions = { + config: RuntimeRelayConfig + identity: RuntimeRelayIdentity + attachDataSocket: (ws: WebSocket, onAuthenticated: () => void) => void + onStatusChange?: (status: RuntimeRelayStatus) => void + reconnectBaseMs?: number + maxDataSockets?: number + maxSyncIds?: number + dataAttachDeadlineMs?: number +} + +type RelayControlMessage = + | { type: 'sync'; connectionIds: string[] } + | { type: 'connected'; connectionId: string } + | { type: 'disconnected'; connectionId: string } + +const DEFAULT_RECONNECT_BASE_MS = 1_000 +const DEFAULT_MAX_DATA_SOCKETS = 32 +const DEFAULT_DATA_ATTACH_DEADLINE_MS = 15_000 +const CONNECTION_ID_RE = /^[A-Za-z0-9_-]{16,128}$/ + +export class RuntimeRelayClient { + private config: RuntimeRelayConfig + private readonly identity: RuntimeRelayIdentity + private readonly attachDataSocket: (ws: WebSocket, onAuthenticated: () => void) => void + private readonly onStatusChange: ((status: RuntimeRelayStatus) => void) | undefined + private readonly reconnectBaseMs: number + private readonly maxDataSockets: number + private readonly maxSyncIds: number + private readonly dataAttachDeadlineMs: number + private control: WebSocket | null = null + private stopped = true + private reconnectAttempt = 0 + private reconnectTimer: ReturnType | null = null + private dataSockets = new Map() + private dataAttachTimers = new Map>() + private status: RuntimeRelayStatus = { state: 'disabled', activeDataSockets: 0, error: null } + + constructor(options: RuntimeRelayClientOptions) { + this.config = options.config + this.identity = options.identity + this.attachDataSocket = options.attachDataSocket + this.onStatusChange = options.onStatusChange + this.reconnectBaseMs = options.reconnectBaseMs ?? DEFAULT_RECONNECT_BASE_MS + this.maxDataSockets = options.maxDataSockets ?? DEFAULT_MAX_DATA_SOCKETS + this.maxSyncIds = options.maxSyncIds ?? this.maxDataSockets + this.dataAttachDeadlineMs = options.dataAttachDeadlineMs ?? DEFAULT_DATA_ATTACH_DEADLINE_MS + } + + start(): void { + this.stopped = false + if (!this.config.enabled) { + this.setStatus({ state: 'disabled', activeDataSockets: 0, error: null }) + return + } + this.connectControl() + } + + stop(): void { + this.stopped = true + this.clearReconnectTimer() + this.control?.close(1000, 'Relay client stopped') + this.control = null + for (const ws of this.dataSockets.values()) { + ws.close(1000, 'Relay client stopped') + } + this.dataSockets.clear() + for (const timer of this.dataAttachTimers.values()) { + clearTimeout(timer) + } + this.dataAttachTimers.clear() + this.setStatus({ state: 'disabled', activeDataSockets: 0, error: null }) + } + + updateConfig(config: RuntimeRelayConfig): void { + this.config = config + this.stop() + this.start() + } + + getStatus(): RuntimeRelayStatus { + return this.status + } + + isConnected(): boolean { + return this.status.state === 'connected' + } + + createClientEndpoint(): string | null { + if (!this.config.enabled || !this.isConnected()) { + return null + } + const endpoint = normalizeRelayEndpoint(this.config.endpoint) + if (!endpoint) { + return null + } + endpoint.searchParams.set('role', 'client') + endpoint.searchParams.set('serverId', this.identity.serverId) + endpoint.searchParams.set('v', '1') + return endpoint.toString() + } + + private connectControl(): void { + if (this.stopped || !this.config.enabled) { + return + } + const endpoint = normalizeRelayEndpoint(this.config.endpoint) + if (!endpoint) { + this.setStatus({ + state: 'error', + activeDataSockets: this.dataSockets.size, + error: 'Invalid relay endpoint' + }) + return + } + endpoint.searchParams.set('role', 'server') + endpoint.searchParams.set('serverId', this.identity.serverId) + endpoint.searchParams.set('v', '1') + const enrollmentToken = extractRelayEnrollmentToken(this.config.endpoint) + this.setStatus({ state: 'connecting', activeDataSockets: this.dataSockets.size, error: null }) + const ws = new WebSocket(endpoint, { + maxPayload: RUNTIME_RELAY_MAX_FRAME_BYTES, + headers: { + Authorization: `Bearer ${this.identity.hostToken}`, + ...(enrollmentToken ? { 'X-Orca-Relay-Enrollment': enrollmentToken } : {}) + } + }) + this.control = ws + ws.on('open', () => { + if (!this.isCurrentControl(ws)) { + return + } + this.reconnectAttempt = 0 + this.setStatus({ state: 'connected', activeDataSockets: this.dataSockets.size, error: null }) + }) + ws.on('message', (data) => { + if (!this.isCurrentControl(ws)) { + return + } + this.handleControlMessage(data.toString()) + }) + ws.on('close', () => this.handleControlClosed(ws)) + ws.on('error', (error) => { + if (!this.isCurrentControl(ws)) { + return + } + this.setStatus({ + state: 'error', + activeDataSockets: this.dataSockets.size, + error: error instanceof Error ? error.message : String(error) + }) + }) + } + + private isCurrentControl(ws: WebSocket): boolean { + return this.control === ws && !this.stopped && this.config.enabled + } + + private handleControlMessage(raw: string): void { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + this.control?.close(1008, 'Invalid relay control message') + return + } + if (!parsed || typeof parsed !== 'object') { + this.control?.close(1008, 'Invalid relay control message') + return + } + const message = parsed as RelayControlMessage + if (message.type === 'sync') { + this.handleSync(message.connectionIds) + return + } + if (message.type === 'connected') { + this.openDataSocket(message.connectionId) + return + } + if (message.type === 'disconnected') { + this.closeDataSocket(message.connectionId) + return + } + this.control?.close(1008, 'Unknown relay control message') + } + + private handleSync(connectionIds: string[]): void { + if (!Array.isArray(connectionIds) || connectionIds.length > this.maxSyncIds) { + this.control?.close(1008, 'Relay sync too large') + return + } + const ids = new Set() + for (const id of connectionIds) { + if (!CONNECTION_ID_RE.test(id)) { + this.control?.close(1008, 'Invalid relay connection id') + return + } + ids.add(id) + } + for (const id of ids) { + this.openDataSocket(id) + } + for (const id of Array.from(this.dataSockets.keys())) { + if (!ids.has(id)) { + this.closeDataSocket(id) + } + } + } + + private openDataSocket(connectionId: string): void { + if (!CONNECTION_ID_RE.test(connectionId) || this.dataSockets.has(connectionId)) { + return + } + if (this.dataSockets.size >= this.maxDataSockets) { + this.control?.close(1013, 'Relay data socket cap reached') + return + } + const endpoint = normalizeRelayEndpoint(this.config.endpoint) + if (!endpoint) { + return + } + endpoint.searchParams.set('role', 'server') + endpoint.searchParams.set('serverId', this.identity.serverId) + endpoint.searchParams.set('connectionId', connectionId) + endpoint.searchParams.set('v', '1') + const ws = new WebSocket(endpoint, { + maxPayload: RUNTIME_RELAY_MAX_FRAME_BYTES, + headers: { Authorization: `Bearer ${this.identity.hostToken}` } + }) + this.dataSockets.set(connectionId, ws) + const timer = setTimeout(() => { + ws.close(1008, 'Relay data E2EE attach timeout') + }, this.dataAttachDeadlineMs) + if (typeof timer.unref === 'function') { + timer.unref() + } + this.dataAttachTimers.set(connectionId, timer) + // Why: the relay may flush client frames immediately when the data socket + // upgrades, so attach the encrypted RPC channel before the open callback. + this.attachDataSocket(ws, () => { + const current = this.dataAttachTimers.get(connectionId) + if (current) { + clearTimeout(current) + this.dataAttachTimers.delete(connectionId) + } + }) + ws.on('open', () => { + this.emitCurrentStatus() + }) + ws.on('close', () => this.closeDataSocket(connectionId, ws)) + ws.on('error', () => this.closeDataSocket(connectionId, ws)) + this.emitCurrentStatus() + } + + private closeDataSocket(connectionId: string, expected?: WebSocket): void { + const ws = this.dataSockets.get(connectionId) + if (!ws || (expected && ws !== expected)) { + return + } + this.dataSockets.delete(connectionId) + const timer = this.dataAttachTimers.get(connectionId) + if (timer) { + clearTimeout(timer) + this.dataAttachTimers.delete(connectionId) + } + ws.close() + this.emitCurrentStatus() + } + + private handleControlClosed(ws: WebSocket): void { + if (this.control !== ws) { + return + } + this.control = null + for (const id of Array.from(this.dataSockets.keys())) { + this.closeDataSocket(id) + } + if (!this.stopped && this.config.enabled) { + this.scheduleReconnect() + } + } + + private scheduleReconnect(): void { + this.clearReconnectTimer() + this.reconnectAttempt += 1 + const delay = Math.min(this.reconnectBaseMs * 2 ** (this.reconnectAttempt - 1), 30_000) + this.setStatus({ + state: 'connecting', + activeDataSockets: this.dataSockets.size, + error: 'Relay disconnected' + }) + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + this.connectControl() + }, delay) + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + } + + private emitCurrentStatus(): void { + if (this.status.state === 'connected') { + this.setStatus({ state: 'connected', activeDataSockets: this.dataSockets.size, error: null }) + } + } + + private setStatus(status: RuntimeRelayStatus): void { + this.status = status + this.onStatusChange?.(status) + } +} + +export function normalizeRelayEndpoint(endpoint: string): URL | null { + try { + const url = new URL(endpoint) + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + return null + } + url.pathname = url.pathname.replace(/\/$/, '') || '/ws' + if (url.pathname !== '/ws') { + url.pathname = `${url.pathname}/ws` + } + url.search = '' + url.hash = '' + return url + } catch { + return null + } +} + +function extractRelayEnrollmentToken(endpoint: string): string | null { + try { + const url = new URL(endpoint) + return url.searchParams.get('enrollmentToken') || null + } catch { + return null + } +} diff --git a/src/main/runtime/relay-config.ts b/src/main/runtime/relay-config.ts new file mode 100644 index 00000000000..bab9fbfc97e --- /dev/null +++ b/src/main/runtime/relay-config.ts @@ -0,0 +1,46 @@ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' +import type { RuntimeRelayConfig } from './relay-client' + +const CONFIG_FILE = 'runtime-relay-config.json' + +export const DEFAULT_RUNTIME_RELAY_CONFIG: RuntimeRelayConfig = { + enabled: false, + endpoint: '' +} + +export function loadRuntimeRelayConfig(userDataPath: string): RuntimeRelayConfig { + const path = runtimeRelayConfigPath(userDataPath) + if (!existsSync(path)) { + return DEFAULT_RUNTIME_RELAY_CONFIG + } + try { + hardenExistingSecureFile(path) + const parsed = JSON.parse(readFileSync(path, 'utf8')) as RuntimeRelayConfig + return normalizeRuntimeRelayConfig(parsed) + } catch { + return DEFAULT_RUNTIME_RELAY_CONFIG + } +} + +export function saveRuntimeRelayConfig( + userDataPath: string, + config: RuntimeRelayConfig +): RuntimeRelayConfig { + const normalized = normalizeRuntimeRelayConfig(config) + const path = runtimeRelayConfigPath(userDataPath) + writeSecureJsonFile(path, normalized) + return normalized +} + +function runtimeRelayConfigPath(userDataPath: string): string { + return join(userDataPath, CONFIG_FILE) +} + +function normalizeRuntimeRelayConfig(config: RuntimeRelayConfig): RuntimeRelayConfig { + return { + enabled: config.enabled === true, + endpoint: typeof config.endpoint === 'string' ? config.endpoint.trim() : '' + } +} diff --git a/src/main/runtime/relay-identity.ts b/src/main/runtime/relay-identity.ts new file mode 100644 index 00000000000..d83d56178d5 --- /dev/null +++ b/src/main/runtime/relay-identity.ts @@ -0,0 +1,56 @@ +import { randomBytes } from 'crypto' +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' + +export type RuntimeRelayIdentity = { + serverId: string + hostToken: string +} + +const IDENTITY_FILE = 'runtime-relay-identity.json' + +export function loadOrCreateRuntimeRelayIdentity(userDataPath: string): RuntimeRelayIdentity { + const path = runtimeRelayIdentityPath(userDataPath) + if (existsSync(path)) { + try { + hardenExistingSecureFile(path) + const parsed = JSON.parse(readFileSync(path, 'utf8')) as RuntimeRelayIdentity + if (isValidRelayIdentity(parsed)) { + return parsed + } + } catch { + // Fall through and replace corrupt identity with a fresh host-only secret. + } + } + const identity = createRuntimeRelayIdentity() + writeSecureJsonFile(path, identity) + return identity +} + +export function rotateRuntimeRelayIdentity(userDataPath: string): RuntimeRelayIdentity { + const identity = createRuntimeRelayIdentity() + const path = runtimeRelayIdentityPath(userDataPath) + writeSecureJsonFile(path, identity) + return identity +} + +function runtimeRelayIdentityPath(userDataPath: string): string { + return join(userDataPath, IDENTITY_FILE) +} + +function createRuntimeRelayIdentity(): RuntimeRelayIdentity { + return { + serverId: randomBytes(24).toString('base64url'), + hostToken: randomBytes(32).toString('base64url') + } +} + +function isValidRelayIdentity(value: RuntimeRelayIdentity): boolean { + return ( + typeof value.serverId === 'string' && + value.serverId.length >= 16 && + typeof value.hostToken === 'string' && + value.hostToken.length >= 32 + ) +} diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts index a1daec13669..331260be0b5 100644 --- a/src/main/runtime/rpc/e2ee-channel.test.ts +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -1,7 +1,21 @@ +/* eslint-disable max-lines -- Why: these protocol tests cover direct and relay E2EE compatibility as one matrix so handshake/frame regressions are easier to audit. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import type { WebSocket } from 'ws' import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel' -import { generateKeyPair, deriveSharedKey, encrypt, decrypt, encryptBytes } from './e2ee-crypto' +import { + generateKeyPair, + deriveSharedKey, + encrypt, + decrypt, + encryptBytes, + decryptBytes +} from './e2ee-crypto' +import { + createRelayTextFrame, + decodeRelayBinaryFrame, + encodeRelayBinaryFrame, + parseRelayTextFrame +} from '../../../shared/runtime-relay-transport' function publicKeyToBase64(key: Uint8Array): string { return Buffer.from(key).toString('base64') @@ -49,6 +63,27 @@ function doHandshake(ctx: ReturnType) { return sharedKey } +function doRelayHandshake(ctx: ReturnType) { + const hello = JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey) + }) + ctx.channel.handleRawMessage(hello) + const ready = JSON.parse(ctx.ws.sent[0]!) as { challenge: string } + const sharedKey = deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) + ctx.channel.handleRawMessage( + encrypt( + JSON.stringify({ + type: 'e2ee_auth', + deviceToken: 'valid-token', + challenge: ready.challenge + }), + sharedKey + ) + ) + return sharedKey +} + describe('E2EEChannel', () => { beforeEach(() => { vi.useFakeTimers() @@ -68,7 +103,8 @@ describe('E2EEChannel', () => { expect(ctx.channel.deviceToken).toBe('valid-token') const readyMsg = JSON.parse(ctx.ws.sent[0]!) - expect(readyMsg).toEqual({ type: 'e2ee_ready' }) + expect(readyMsg).toMatchObject({ type: 'e2ee_ready' }) + expect(typeof readyMsg.challenge).toBe('string') const authMsg = decrypt( ctx.ws.sent[1]!, deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) @@ -86,7 +122,7 @@ describe('E2EEChannel', () => { ) expect(ctx.onReady).not.toHaveBeenCalled() - expect(JSON.parse(ctx.ws.sent[0]!)).toEqual({ type: 'e2ee_ready' }) + expect(JSON.parse(ctx.ws.sent[0]!)).toMatchObject({ type: 'e2ee_ready' }) }) it('rejects invalid encrypted token', () => { @@ -246,6 +282,81 @@ describe('E2EEChannel', () => { } expect(ctx.onError).not.toHaveBeenCalled() }) + + it('requires relay auth challenge on relay sockets', () => { + const ctx = setup({ transportKind: 'relay' }) + const sharedKey = doHandshake(ctx) + + expect(sharedKey).toBeTruthy() + expect(ctx.onReady).not.toHaveBeenCalled() + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Missing relay auth challenge') + }) + + it('rejects duplicate relay text frame sequence before dispatch', () => { + const ctx = setup({ transportKind: 'relay' }) + const sharedKey = doRelayHandshake(ctx) + const received: string[] = [] + ctx.channel.onMessage((plaintext) => { + received.push(plaintext) + }) + + const request = '{"id":"rpc-1","method":"status.get"}' + const frame = encrypt(createRelayTextFrame(1, request), sharedKey) + ctx.channel.handleRawMessage(frame) + ctx.channel.handleRawMessage(frame) + + expect(received).toEqual([request]) + expect(ctx.onError).toHaveBeenCalledWith(4007, 'Invalid relay frame sequence') + }) + + it('wraps relay replies with outbound sequence numbers', () => { + const ctx = setup({ transportKind: 'relay' }) + const sharedKey = doRelayHandshake(ctx) + + ctx.channel.onMessage((_plaintext, encryptedReply) => { + encryptedReply('{"id":"rpc-1","ok":true}') + }) + ctx.channel.handleRawMessage( + encrypt(createRelayTextFrame(1, '{"id":"rpc-1","method":"status.get"}'), sharedKey) + ) + + const replyPlain = decrypt(ctx.ws.sent[2]!, sharedKey) + const frame = parseRelayTextFrame(replyPlain!) + expect(frame).toMatchObject({ seq: 1, payload: '{"id":"rpc-1","ok":true}' }) + }) + + it('rejects duplicate relay binary frame sequence before dispatch', () => { + const ctx = setup({ transportKind: 'relay' }) + const sharedKey = doRelayHandshake(ctx) + const received: Uint8Array[] = [] + ctx.channel.onBinaryMessage((plaintext) => { + received.push(plaintext) + }) + + const frame = encryptBytes(encodeRelayBinaryFrame(1, new Uint8Array([4, 5])), sharedKey) + ctx.channel.handleRawMessage(frame) + ctx.channel.handleRawMessage(frame) + + expect([...received[0]!]).toEqual([4, 5]) + expect(ctx.onError).toHaveBeenCalledWith(4007, 'Invalid relay frame sequence') + }) + + it('wraps relay binary replies with outbound sequence numbers', () => { + const ctx = setup({ transportKind: 'relay' }) + const sharedKey = doRelayHandshake(ctx) + + ctx.channel.onMessage((_plaintext, _encryptedReply, encryptedBinaryReply) => { + encryptedBinaryReply(new Uint8Array([9, 8, 7])) + }) + ctx.channel.handleRawMessage( + encrypt(createRelayTextFrame(1, '{"id":"rpc-1","method":"browser.screencast"}'), sharedKey) + ) + + const replyPlain = decryptBytes(ctx.ws.sent[2]! as unknown as Uint8Array, sharedKey) + const frame = decodeRelayBinaryFrame(replyPlain!) + expect(frame?.seq).toBe(1) + expect([...(frame?.payload ?? [])]).toEqual([9, 8, 7]) + }) }) describe('cross-compatibility', () => { diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index 97ce9c5c813..8c3f6543b43 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -1,8 +1,17 @@ // Why: the E2EE channel sits between the WebSocket transport and the RPC handler. // It owns the handshake state machine and transparent encrypt/decrypt so the RPC // handler only sees plaintext JSON, identical to the Unix socket path. +import { randomBytes } from 'crypto' import type { WebSocket } from 'ws' import { deriveSharedKey, encrypt, decrypt, encryptBytes, decryptBytes } from './e2ee-crypto' +import { + RELAY_MAX_SEQUENCE, + createRelayTextFrame, + decodeRelayBinaryFrame, + encodeRelayBinaryFrame, + parseRelayTextFrame, + type RuntimeWebSocketTransportKind +} from '../../../shared/runtime-relay-transport' type ChannelState = 'awaiting_hello' | 'awaiting_auth' | 'ready' @@ -18,10 +27,12 @@ type E2EEHello = { type E2EEAuth = { type: 'e2ee_auth' deviceToken: string + challenge?: string } export type E2EEChannelOptions = { serverSecretKey: Uint8Array + transportKind?: RuntimeWebSocketTransportKind validateToken: (token: string) => boolean onReady: (channel: E2EEChannel) => void onError: (code: number, reason: string) => void @@ -34,9 +45,13 @@ export class E2EEChannel { private handshakeTimer: ReturnType | null = null private readonly ws: WebSocket private readonly serverSecretKey: Uint8Array + private readonly transportKind: RuntimeWebSocketTransportKind private readonly validateToken: (token: string) => boolean private readonly onReady: (channel: E2EEChannel) => void private readonly onError: (code: number, reason: string) => void + private challenge: string | null = null + private inboundRelaySeq = 0 + private outboundRelaySeq = 0 // Why: the RPC handler is set after the channel is ready, so the channel // can forward decrypted messages. Kept as a callback rather than constructor // param because the handler needs the encrypt function for replies. @@ -54,6 +69,7 @@ export class E2EEChannel { constructor(ws: WebSocket, options: E2EEChannelOptions) { this.ws = ws this.serverSecretKey = options.serverSecretKey + this.transportKind = options.transportKind ?? 'direct' this.validateToken = options.validateToken this.onReady = options.onReady this.onError = options.onError @@ -102,7 +118,14 @@ export class E2EEChannel { this.onError(4001, 'Invalid binary message before authentication') return } - this.binaryMessageHandler?.(plaintextBytes) + const binaryPayload = + this.transportKind === 'relay' + ? this.unwrapRelayBinaryFrame(plaintextBytes) + : plaintextBytes + if (!binaryPayload) { + return + } + this.binaryMessageHandler?.(binaryPayload) return } @@ -118,6 +141,14 @@ export class E2EEChannel { return } + const messagePlaintext = + this.state === 'ready' && this.transportKind === 'relay' + ? this.unwrapRelayTextFrame(plaintext) + : plaintext + if (messagePlaintext === null) { + return + } + // Why: streaming RPC handlers (e.g. terminal.subscribe) retain this // closure and may fire emits long after the inbound message handled // here. If destroy() runs in between (mobile disconnect, handshake @@ -128,7 +159,7 @@ export class E2EEChannel { if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) { return } - this.ws.send(encrypt(response, this.sharedKey)) + this.ws.send(encrypt(this.wrapRelayTextFrame(response), this.sharedKey)) } const encryptedBinaryReply = (response: Uint8Array): boolean => { if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) { @@ -137,10 +168,12 @@ export class E2EEChannel { if (this.ws.bufferedAmount > MAX_BINARY_BUFFERED_AMOUNT) { return false } - this.ws.send(Buffer.from(encryptBytes(response, this.sharedKey)), { binary: true }) + this.ws.send(Buffer.from(encryptBytes(this.wrapRelayBinaryFrame(response), this.sharedKey)), { + binary: true + }) return true } - this.messageHandler?.(plaintext, encryptedReply, encryptedBinaryReply) + this.messageHandler?.(messagePlaintext, encryptedReply, encryptedBinaryReply) } private trackDecryptFailure(): void { @@ -174,11 +207,13 @@ export class E2EEChannel { this.sharedKey = deriveSharedKey(this.serverSecretKey, clientPublicKey) this.state = 'awaiting_auth' + this.challenge = randomBytes(24).toString('base64url') // Why: send e2ee_ready as plaintext — the client needs it to know the // key exchange succeeded before it can send encrypted authentication. + // The challenge is echoed inside encrypted auth to make relay handshakes fresh. if (this.ws.readyState === this.ws.OPEN) { - this.ws.send(JSON.stringify({ type: 'e2ee_ready' })) + this.ws.send(JSON.stringify({ type: 'e2ee_ready', challenge: this.challenge })) } } @@ -197,6 +232,11 @@ export class E2EEChannel { this.onError(4001, 'Invalid e2ee_auth') return } + if (this.transportKind === 'relay' && auth.challenge !== this.challenge) { + this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } }) + this.onError(4001, 'Missing relay auth challenge') + return + } if (!this.validateToken(auth.deviceToken)) { this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'unauthorized' } }) this.onError(4001, 'Unauthorized') @@ -221,12 +261,70 @@ export class E2EEChannel { } } + private nextOutboundRelaySeq(): number { + if (this.transportKind !== 'relay') { + return 0 + } + if (this.outboundRelaySeq >= RELAY_MAX_SEQUENCE) { + this.onError(4008, 'Relay frame sequence exhausted') + return 0 + } + this.outboundRelaySeq += 1 + return this.outboundRelaySeq + } + + private wrapRelayTextFrame(payload: string): string { + if (this.transportKind !== 'relay') { + return payload + } + const seq = this.nextOutboundRelaySeq() + return createRelayTextFrame(seq, payload) + } + + private wrapRelayBinaryFrame(payload: Uint8Array): Uint8Array { + if (this.transportKind !== 'relay') { + return payload + } + return encodeRelayBinaryFrame(this.nextOutboundRelaySeq(), payload) + } + + private acceptRelaySeq(seq: unknown): boolean { + if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq !== this.inboundRelaySeq + 1) { + this.onError(4007, 'Invalid relay frame sequence') + return false + } + this.inboundRelaySeq = seq + return true + } + + private unwrapRelayTextFrame(plaintext: string): string | null { + const frame = parseRelayTextFrame(plaintext) + if (!frame) { + this.onError(4007, 'Invalid relay text frame') + return null + } + return this.acceptRelaySeq(frame.seq) ? frame.payload : null + } + + private unwrapRelayBinaryFrame(plaintext: Uint8Array): Uint8Array | null { + const frame = decodeRelayBinaryFrame(plaintext) + if (!frame) { + this.onError(4007, 'Invalid relay binary frame') + return null + } + if (!this.acceptRelaySeq(frame.seq)) { + return null + } + return frame.payload + } + destroy(): void { if (this.handshakeTimer) { clearTimeout(this.handshakeTimer) this.handshakeTimer = null } this.sharedKey = null + this.challenge = null this.messageHandler = null this.binaryMessageHandler = null } diff --git a/src/main/runtime/rpc/e2ee-integration.test.ts b/src/main/runtime/rpc/e2ee-integration.test.ts index f4029b82473..ce869b462d4 100644 --- a/src/main/runtime/rpc/e2ee-integration.test.ts +++ b/src/main/runtime/rpc/e2ee-integration.test.ts @@ -75,7 +75,7 @@ describe('E2EE integration (simulated mobile ↔ desktop)', () => { // Desktop should respond with plaintext ready, then encrypted auth ack. expect(onReady).toHaveBeenCalled() expect(wsSent).toHaveLength(2) - expect(JSON.parse(wsSent[0]!)).toEqual({ type: 'e2ee_ready' }) + expect(JSON.parse(wsSent[0]!)).toMatchObject({ type: 'e2ee_ready' }) expect(JSON.parse(decrypt(wsSent[1]!, mobileShared)!)).toEqual({ type: 'e2ee_authenticated' }) diff --git a/src/main/runtime/runtime-metadata.test.ts b/src/main/runtime/runtime-metadata.test.ts index da369b01d40..30d95b87e13 100644 --- a/src/main/runtime/runtime-metadata.test.ts +++ b/src/main/runtime/runtime-metadata.test.ts @@ -11,6 +11,8 @@ import { } from '../../shared/runtime-environment-store' import { DeviceRegistry } from './device-registry' import { loadOrCreateE2EEKeypair } from './e2ee-keypair' +import { loadRuntimeRelayConfig, saveRuntimeRelayConfig } from './relay-config' +import { loadOrCreateRuntimeRelayIdentity } from './relay-identity' import { clearRuntimeMetadata, clearRuntimeMetadataIfOwned, @@ -178,6 +180,11 @@ describe('runtime metadata', () => { new DeviceRegistry(userDataPath).addDevice('phone') loadOrCreateE2EEKeypair(userDataPath) + loadOrCreateRuntimeRelayIdentity(userDataPath) + saveRuntimeRelayConfig(userDataPath, { + enabled: true, + endpoint: 'ws://127.0.0.1:6768/ws?enrollmentToken=secret' + }) addEnvironmentFromPairingCode(userDataPath, { name: 'desk', pairingCode: encodePairingOffer({ @@ -191,6 +198,8 @@ describe('runtime metadata', () => { for (const path of [ join(userDataPath, 'orca-devices.json'), join(userDataPath, 'orca-e2ee-keypair.json'), + join(userDataPath, 'runtime-relay-identity.json'), + join(userDataPath, 'runtime-relay-config.json'), getEnvironmentStorePath(userDataPath) ]) { expect(statSync(path).mode & 0o777).toBe(0o600) @@ -219,6 +228,8 @@ describe('runtime metadata', () => { const devicesPath = join(userDataPath, 'orca-devices.json') const keypairPath = join(userDataPath, 'orca-e2ee-keypair.json') + const relayIdentityPath = join(userDataPath, 'runtime-relay-identity.json') + const relayConfigPath = join(userDataPath, 'runtime-relay-config.json') const environmentsPath = getEnvironmentStorePath(userDataPath) writeFileSync( devicesPath, @@ -236,7 +247,27 @@ describe('runtime metadata', () => { keypairPath, JSON.stringify({ v: 1, publicKeyB64: keyMaterial, secretKeyB64: keyMaterial }) ) - for (const path of [devicesPath, keypairPath, environmentsPath]) { + writeFileSync( + relayIdentityPath, + JSON.stringify({ + serverId: 'relay-server-id-0001', + hostToken: 'relay-host-token-000000000000000000' + }) + ) + writeFileSync( + relayConfigPath, + JSON.stringify({ + enabled: true, + endpoint: 'ws://127.0.0.1:6768/ws?enrollmentToken=secret' + }) + ) + for (const path of [ + devicesPath, + keypairPath, + relayIdentityPath, + relayConfigPath, + environmentsPath + ]) { chmodSync(path, 0o644) } chmodSync(userDataPath, 0o755) @@ -246,9 +277,23 @@ describe('runtime metadata', () => { scope: 'mobile' }) expect(loadOrCreateE2EEKeypair(userDataPath).publicKeyB64).toBe(keyMaterial) + expect(loadOrCreateRuntimeRelayIdentity(userDataPath)).toMatchObject({ + serverId: 'relay-server-id-0001', + hostToken: 'relay-host-token-000000000000000000' + }) + expect(loadRuntimeRelayConfig(userDataPath)).toEqual({ + enabled: true, + endpoint: 'ws://127.0.0.1:6768/ws?enrollmentToken=secret' + }) expect(listEnvironments(userDataPath)[0]?.id).toBe(environment.id) - for (const path of [devicesPath, keypairPath, environmentsPath]) { + for (const path of [ + devicesPath, + keypairPath, + relayIdentityPath, + relayConfigPath, + environmentsPath + ]) { expect(statSync(path).mode & 0o777).toBe(0o600) } expect(statSync(userDataPath).mode & 0o777).toBe(0o700) diff --git a/src/main/runtime/runtime-relay-e2e.test.ts b/src/main/runtime/runtime-relay-e2e.test.ts new file mode 100644 index 00000000000..579a47dd6c6 --- /dev/null +++ b/src/main/runtime/runtime-relay-e2e.test.ts @@ -0,0 +1,101 @@ +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it, vi } from 'vitest' +import { startRuntimeRelayServer } from '../../runtime-relay-server/server' +import { parsePairingCode } from '../../shared/pairing' +import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection' +import { saveRuntimeRelayConfig } from './relay-config' +import { OrcaRuntimeRpcServer } from './runtime-rpc' +import type { OrcaRuntimeService } from './orca-runtime' + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('timed out waiting for condition') + } + await sleep(20) + } +} + +describe('runtime relay end-to-end', () => { + it('serves encrypted runtime RPC over a self-hosted relay', async () => { + const relayDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-e2e-relay-')) + const runtimeDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-e2e-runtime-')) + const relay = await startRuntimeRelayServer({ + port: 0, + statePath: join(relayDataPath, 'relay-state.json') + }) + saveRuntimeRelayConfig(runtimeDataPath, { + enabled: true, + endpoint: `${relay.webSocketUrl}?enrollmentToken=${relay.enrollmentToken}` + }) + + const runtime = { + getRuntimeId: () => 'runtime-relay-e2e', + getStartedAt: () => 1_700_000_000_000, + getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ready' }), + cleanupSubscriptionsForConnection: vi.fn(), + cancelMobileDictationForConnection: vi.fn(), + onClientDisconnected: vi.fn() + } as unknown as OrcaRuntimeService + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath: runtimeDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + await waitFor(() => server.getRelayStatus().state === 'connected') + expect( + server.createRelayPairingOffer({ + name: 'Invalid Scope', + scope: 'admin' as never + }) + ).toEqual({ available: false, reason: 'invalid_scope' }) + const offer = server.createRelayPairingOffer({ + name: 'Relay E2E', + rotate: true, + scope: 'runtime' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('Relay pairing unavailable') + } + expect(offer.endpoint).toContain('role=client') + + const pairing = parsePairingCode(offer.pairingUrl) + expect(pairing).toBeTruthy() + if (!pairing) { + throw new Error('Relay pairing parse failed') + } + + const connection = new RemoteRuntimeRequestConnection(pairing) + try { + const response = await connection.request<{ graphStatus: string }>( + 'status.get', + undefined, + 2_000 + ) + expect(response).toMatchObject({ + ok: true, + result: { graphStatus: 'ready' }, + _meta: { runtimeId: 'runtime-relay-e2e' } + }) + expect(runtime.getStatus).toHaveBeenCalledTimes(1) + } finally { + connection.close() + } + } finally { + await server.stop() + await relay.stop() + } + }) +}) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index bacf4b81dc8..9161e0a15a5 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -159,7 +159,7 @@ async function authenticateMobileWs(pairingUrl: string): Promise { publicKeyB64: Buffer.from(mobileKeys.publicKey).toString('base64') }) ) - expect(JSON.parse(await nextWsMessage(ws))).toEqual({ type: 'e2ee_ready' }) + expect(JSON.parse(await nextWsMessage(ws))).toMatchObject({ type: 'e2ee_ready' }) ws.send( encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: parsed!.deviceToken }), sharedKey) @@ -305,6 +305,37 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('omits web client URLs from relay pairing offers even with a web bundle configured', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0, + webClientRoot: userDataPath + }) + + await server.start() + + try { + server['relayClient'] = { + createClientEndpoint: () => 'wss://relay.example.test/runtime?role=client', + stop: vi.fn() + } as never + + const offer = server.createRelayPairingOffer({ name: 'Relay web test' }) + expect(offer.available).toBe(true) + if (offer.available) { + expect(offer.endpoint).toBe('wss://relay.example.test/runtime?role=client') + expect(parsePairingCode(offer.pairingUrl)?.endpoint).toBe(offer.endpoint) + expect(offer.webClientUrl).toBeNull() + } + } finally { + await server.stop() + } + }) + it('preserves proxy path prefixes in web client URLs', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() @@ -440,7 +471,7 @@ describe('OrcaRuntimeRpcServer', () => { publicKeyB64: Buffer.from(mobileKeys.publicKey).toString('base64') }) ) - expect(JSON.parse(await nextWsMessage(ws))).toEqual({ type: 'e2ee_ready' }) + expect(JSON.parse(await nextWsMessage(ws))).toMatchObject({ type: 'e2ee_ready' }) expect(server['e2eeChannels'].size).toBe(1) expect(server['wsConnectionIds'].size).toBe(1) @@ -1894,6 +1925,7 @@ describe('OrcaRuntimeRpcServer', () => { expect(existsSync(endpoint)).toBe(false) expect(server['transports']).toEqual([]) expect(server['activeTransports']).toEqual([]) + expect(server['relayClient']).toBeNull() writeMetadataSpy.mockRestore() }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index f93950c7713..eba75a9e08d 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -20,6 +20,14 @@ import type { WebSocket } from 'ws' import { DeviceRegistry, type DeviceScope } from './device-registry' import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' import { E2EEChannel } from './rpc/e2ee-channel' +import { loadRuntimeRelayConfig, saveRuntimeRelayConfig } from './relay-config' +import { + RuntimeRelayClient, + type RuntimeRelayConfig, + type RuntimeRelayStatus +} from './relay-client' +import { loadOrCreateRuntimeRelayIdentity } from './relay-identity' +import type { RuntimeWebSocketTransportKind } from '../../shared/runtime-relay-transport' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' import { decodeTerminalStreamFrame, @@ -311,7 +319,8 @@ export class OrcaRuntimeRpcServer { private deviceRegistry: DeviceRegistry | null = null private e2eeKeypair: E2EEKeypair | null = null private tlsFingerprint: string | null = null - private wsTransport: WebSocketTransport | null = null + private relayClient: RuntimeRelayClient | null = null + private relayConfig: RuntimeRelayConfig | null = null private activeTransports: RpcTransport[] = [] private transports: RuntimeTransportMetadata[] = [] // Why: each WebSocket connection has its own E2EE channel that manages the @@ -321,6 +330,7 @@ export class OrcaRuntimeRpcServer { // subscriptions, so the server can reap a closing socket's subscriptions // without affecting other live sockets that share the same deviceToken. private wsConnectionIds = new Map() + private runtimeWsClientIds = new Map() private readonly binaryStreamHandlers = new Map< string, Map void> @@ -378,7 +388,7 @@ export class OrcaRuntimeRpcServer { if (device?.scope !== 'mobile' || !this.deviceRegistry?.removeDevice(deviceId)) { return false } - this.wsTransport?.terminateClientConnections(device.token) + this.terminateRuntimeWebSocketConnections(device.token) return true } @@ -387,15 +397,98 @@ export class OrcaRuntimeRpcServer { if (device?.scope !== 'runtime' || !this.deviceRegistry?.removeDevice(deviceId)) { return false } - this.wsTransport?.terminateClientConnections(device.token) + this.terminateRuntimeWebSocketConnections(device.token) return true } + attachExternalWebSocket( + ws: WebSocket, + transportKind: RuntimeWebSocketTransportKind = 'relay', + onAuthenticated?: (deviceToken: string) => void + ): void { + this.attachRuntimeWebSocket(ws, transportKind, onAuthenticated) + ws.on('message', (data, isBinary) => { + const msg = + typeof data === 'string' + ? data + : isBinary + ? new Uint8Array(data as Buffer) + : data.toString() + this.e2eeChannels.get(ws)?.handleRawMessage(msg) + }) + ws.on('close', () => this.cleanupRuntimeWebSocket(ws)) + ws.on('error', () => { + this.cleanupRuntimeWebSocket(ws) + ws.close() + }) + } + getWebSocketEndpoint(): string | null { const ws = this.transports.find((t) => t.kind === 'websocket') return ws?.endpoint ?? null } + getRelayConfig(): RuntimeRelayConfig { + return this.relayConfig ?? loadRuntimeRelayConfig(this.userDataPath) + } + + updateRelayConfig(config: RuntimeRelayConfig): RuntimeRelayConfig { + const saved = saveRuntimeRelayConfig(this.userDataPath, config) + this.relayConfig = saved + this.relayClient?.updateConfig(saved) + return saved + } + + getRelayStatus(): RuntimeRelayStatus { + return ( + this.relayClient?.getStatus() ?? { + state: 'disabled', + activeDataSockets: 0, + error: null + } + ) + } + + createRelayPairingOffer(args: { name?: string; rotate?: boolean; scope?: DeviceScope }): + | { available: false; reason: string } + | { + available: true + pairingUrl: string + endpoint: string + deviceId: string + webClientUrl: string | null + } { + const endpoint = this.relayClient?.createClientEndpoint() + const publicKeyB64 = this.getE2EEPublicKey() + if (!endpoint || !this.deviceRegistry || !publicKeyB64) { + return { available: false, reason: 'relay_unavailable' } + } + const rawScope: unknown = args.scope + if (rawScope !== undefined && rawScope !== 'mobile' && rawScope !== 'runtime') { + return { available: false, reason: 'invalid_scope' } + } + const scope: DeviceScope = rawScope ?? 'runtime' + const deviceName = args.name ?? `Relay ${new Date().toLocaleDateString()}` + const device = args.rotate + ? this.deviceRegistry.rotatePendingDevice(deviceName, scope) + : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope) + const pairingUrl = encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint, + deviceToken: device.token, + publicKeyB64 + }) + return { + available: true, + pairingUrl, + endpoint, + deviceId: device.deviceId, + // Why: relay origins are user-configured and may be untrusted; pairing + // URLs carry credentials, so only direct trusted web roots may embed them. + webClientUrl: null + } + } + createPairingOffer(args: { address?: string | null name?: string @@ -418,7 +511,11 @@ export class OrcaRuntimeRpcServer { const endpoint = resolvePairingEndpoint(rawEndpoint, args.address) const deviceName = args.name ?? `CLI ${new Date().toLocaleDateString()}` - const scope = args.scope ?? 'runtime' + const rawScope: unknown = args.scope + if (rawScope !== undefined && rawScope !== 'mobile' && rawScope !== 'runtime') { + return { available: false } + } + const scope: DeviceScope = rawScope ?? 'runtime' const device = args.rotate ? this.deviceRegistry.rotatePendingDevice(deviceName, scope) : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope) @@ -476,6 +573,90 @@ export class OrcaRuntimeRpcServer { this.binaryStreamHandlers.get(connectionId)?.get(frame.streamId)?.(frame) } + private attachRuntimeWebSocket( + ws: WebSocket, + transportKind: RuntimeWebSocketTransportKind, + onAuthenticated?: (deviceToken: string) => void + ): E2EEChannel { + let channel = this.e2eeChannels.get(ws) + if (channel) { + return channel + } + if (!this.e2eeKeypair || !this.deviceRegistry) { + ws.close(1011, 'Runtime WebSocket auth unavailable') + throw new Error('Runtime WebSocket auth unavailable') + } + // Why: stable per-ws id used as the cleanup-index key for streaming + // subscriptions, so direct and relay sockets reap identical runtime state. + this.wsConnectionIds.set(ws, randomBytes(8).toString('hex')) + channel = new E2EEChannel(ws, { + serverSecretKey: this.e2eeKeypair.secretKey, + transportKind, + validateToken: (token) => this.deviceRegistry?.validateToken(token) != null, + onReady: (ch) => { + if (!ch.deviceToken) { + return + } + this.runtimeWsClientIds.set(ws, ch.deviceToken) + onAuthenticated?.(ch.deviceToken) + const device = this.deviceRegistry?.validateToken(ch.deviceToken) + if (device) { + this.deviceRegistry?.updateLastSeen(device.deviceId) + } + }, + onError: (code, reason) => { + this.e2eeChannels.get(ws)?.destroy() + this.e2eeChannels.delete(ws) + ws.close(code, reason) + } + }) + channel.onMessage((plaintext, encryptedReply, encryptedBinaryReply) => { + const authenticatedDeviceToken = this.e2eeChannels.get(ws)?.deviceToken ?? null + void this.handleWebSocketMessage( + plaintext, + encryptedReply, + encryptedBinaryReply, + undefined, + ws, + authenticatedDeviceToken + ) + }) + channel.onBinaryMessage((bytes) => this.handleWebSocketBinaryMessage(bytes, ws)) + this.e2eeChannels.set(ws, channel) + return channel + } + + private cleanupRuntimeWebSocket(ws: WebSocket, fallbackClientId: string | null = null): void { + this.abortWebSocketDispatches(ws) + const connectionId = this.wsConnectionIds.get(ws) + if (connectionId) { + this.runtime.cleanupSubscriptionsForConnection(connectionId) + this.runtime.cancelMobileDictationForConnection(connectionId) + this.binaryStreamHandlers.delete(connectionId) + this.wsConnectionIds.delete(ws) + } + const channel = this.e2eeChannels.get(ws) + if (channel) { + channel.destroy() + this.e2eeChannels.delete(ws) + } + const clientId = this.runtimeWsClientIds.get(ws) ?? fallbackClientId + this.runtimeWsClientIds.delete(ws) + if (clientId && !Array.from(this.runtimeWsClientIds.values()).includes(clientId)) { + this.runtime.onClientDisconnected(clientId) + } + } + + private terminateRuntimeWebSocketConnections(clientId: string): number { + const sockets = Array.from(this.runtimeWsClientIds.entries()) + .filter(([, candidateClientId]) => candidateClientId === clientId) + .map(([ws]) => ws) + for (const ws of sockets) { + ws.terminate() + } + return sockets.length + } + private registerWebSocketDispatchAbort(ws: WebSocket): { signal: AbortSignal dispose: () => void @@ -611,54 +792,10 @@ export class OrcaRuntimeRpcServer { port: this.wsPort, staticRoot: this.webClientRoot }) - this.wsTransport = wsTransport - - // Why: each WebSocket connection gets an E2EE channel that handles the - // handshake before any RPC messages are processed. The channel decrypts - // inbound messages and encrypts outbound replies transparently. wsTransport.onMessage((msg, _reply, ws) => { - let channel = this.e2eeChannels.get(ws) - if (!channel) { - // Why: stable per-ws id used as the cleanup-index key for - // streaming subscriptions, so the server can reap them exactly - // when this socket closes (without affecting other live sockets - // that share the same deviceToken). - this.wsConnectionIds.set(ws, randomBytes(8).toString('hex')) - channel = new E2EEChannel(ws, { - serverSecretKey: this.e2eeKeypair!.secretKey, - validateToken: (token) => this.deviceRegistry?.validateToken(token) != null, - onReady: (ch) => { - if (ch.deviceToken) { - wsTransport.setClientId(ws, ch.deviceToken) - // Why: mark the device as actually connected so it appears - // in the "Paired Devices" list. Devices that were only - // generated as QR codes but never scanned stay hidden. - const device = this.deviceRegistry?.validateToken(ch.deviceToken) - if (device) { - this.deviceRegistry?.updateLastSeen(device.deviceId) - } - } - }, - onError: (code, reason) => { - this.e2eeChannels.get(ws)?.destroy() - this.e2eeChannels.delete(ws) - ws.close(code, reason) - } - }) - channel.onMessage((plaintext, encryptedReply, encryptedBinaryReply) => { - const authenticatedDeviceToken = this.e2eeChannels.get(ws)?.deviceToken ?? null - void this.handleWebSocketMessage( - plaintext, - encryptedReply, - encryptedBinaryReply, - wsTransport, - ws, - authenticatedDeviceToken - ) - }) - channel.onBinaryMessage((bytes) => this.handleWebSocketBinaryMessage(bytes, ws)) - this.e2eeChannels.set(ws, channel) - } + const channel = this.attachRuntimeWebSocket(ws, 'direct', (token) => + wsTransport.setClientId(ws, token) + ) channel.handleRawMessage(msg) }) @@ -668,27 +805,8 @@ export class OrcaRuntimeRpcServer { // multiple concurrent sockets (host screen + accounts screen, etc.), // so destroy the channel for THIS exact ws and skip the per-client // teardown when other sockets for the same token are still alive. - wsTransport.onConnectionClose((clientId, ws, hasOtherConnections) => { - this.abortWebSocketDispatches(ws) - // Why: sweep streaming subscriptions for THIS ws regardless of - // hasOtherConnections, so per-ws listeners (notifications, - // accounts, terminal) don't leak across reconnects. This is - // independent of the deviceToken-scoped onClientDisconnected. - const connectionId = this.wsConnectionIds.get(ws) - if (connectionId) { - this.runtime.cleanupSubscriptionsForConnection(connectionId) - this.runtime.cancelMobileDictationForConnection(connectionId) - this.binaryStreamHandlers.delete(connectionId) - this.wsConnectionIds.delete(ws) - } - const channel = this.e2eeChannels.get(ws) - if (channel) { - channel.destroy() - this.e2eeChannels.delete(ws) - } - if (clientId && !hasOtherConnections) { - this.runtime.onClientDisconnected(clientId) - } + wsTransport.onConnectionClose((clientId, ws, _hasOtherConnections) => { + this.cleanupRuntimeWebSocket(ws, clientId) }) await wsTransport.start() @@ -697,12 +815,21 @@ export class OrcaRuntimeRpcServer { kind: 'websocket', endpoint: `ws://0.0.0.0:${wsTransport.resolvedPort}` }) + this.relayConfig = loadRuntimeRelayConfig(this.userDataPath) + const relayIdentity = loadOrCreateRuntimeRelayIdentity(this.userDataPath) + this.relayClient = new RuntimeRelayClient({ + config: this.relayConfig, + identity: relayIdentity, + attachDataSocket: (ws, onAuthenticated) => { + this.attachExternalWebSocket(ws, 'relay', () => onAuthenticated()) + } + }) + this.relayClient.start() } catch (error) { // Why: WebSocket transport is supplementary — the runtime must still // function if it fails to start (e.g., port in use). Log and continue // with Unix socket only. console.error('[runtime] Failed to start WebSocket transport:', error) - this.wsTransport = null } } @@ -718,6 +845,8 @@ export class OrcaRuntimeRpcServer { // Why: a runtime that cannot publish bootstrap metadata is invisible to // the `orca` CLI. Close all transports immediately instead of leaving // behind a live but undiscoverable control plane. + this.relayClient?.stop() + this.relayClient = null this.activeTransports = [] this.transports = [] await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {}) @@ -729,7 +858,8 @@ export class OrcaRuntimeRpcServer { const transports = this.activeTransports this.activeTransports = [] this.transports = [] - this.wsTransport = null + this.relayClient?.stop() + this.relayClient = null if (transports.length === 0) { return } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 835c9e2786b..6ceb41ccfc7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2140,6 +2140,38 @@ export type PreloadApi = { deviceId: string } > + getRelayPairingQR: (args?: { rotate?: boolean; scope?: 'mobile' | 'runtime' }) => Promise< + | { available: false; reason: string } + | { + available: true + qrDataUrl: string + pairingUrl: string + webClientUrl: string | null + endpoint: string + deviceId: string + } + > + getRelayRuntimePairingUrl: (args?: { rotate?: boolean }) => Promise< + | { available: false; reason: string } + | { + available: true + pairingUrl: string + webClientUrl: string | null + endpoint: string + deviceId: string + } + > + getRelayConfig: () => Promise<{ + config: { enabled: boolean; endpoint: string } + status: { state: string; activeDataSockets: number; error: string | null } + }> + updateRelayConfig: (args: { enabled?: boolean; endpoint?: string }) => Promise<{ + config: { enabled: boolean; endpoint: string } + status: { state: string; activeDataSockets: number; error: string | null } + }> + getRelayStatus: () => Promise<{ + status: { state: string; activeDataSockets: number; error: string | null } + }> listDevices: () => Promise<{ devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] }> diff --git a/src/preload/index.ts b/src/preload/index.ts index 68be46b6143..39a9d1a53de 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3253,6 +3253,51 @@ const api = { } > => ipcRenderer.invoke('mobile:getRuntimePairingUrl', args), + getRelayPairingQR: (args?: { + rotate?: boolean + scope?: 'mobile' | 'runtime' + }): Promise< + | { available: false; reason: string } + | { + available: true + qrDataUrl: string + pairingUrl: string + webClientUrl: string | null + endpoint: string + deviceId: string + } + > => ipcRenderer.invoke('mobile:getRelayPairingQR', args), + + getRelayRuntimePairingUrl: (args?: { + rotate?: boolean + }): Promise< + | { available: false; reason: string } + | { + available: true + pairingUrl: string + webClientUrl: string | null + endpoint: string + deviceId: string + } + > => ipcRenderer.invoke('mobile:getRelayRuntimePairingUrl', args), + + getRelayConfig: (): Promise<{ + config: { enabled: boolean; endpoint: string } + status: { state: string; activeDataSockets: number; error: string | null } + }> => ipcRenderer.invoke('mobile:getRelayConfig'), + + updateRelayConfig: (args: { + enabled?: boolean + endpoint?: string + }): Promise<{ + config: { enabled: boolean; endpoint: string } + status: { state: string; activeDataSockets: number; error: string | null } + }> => ipcRenderer.invoke('mobile:updateRelayConfig', args), + + getRelayStatus: (): Promise<{ + status: { state: string; activeDataSockets: number; error: string | null } + }> => ipcRenderer.invoke('mobile:getRelayStatus'), + listDevices: (): Promise<{ devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] }> => ipcRenderer.invoke('mobile:listDevices'), diff --git a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx index b460f58f66d..283574a7ee0 100644 --- a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx +++ b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx @@ -14,6 +14,7 @@ type MobileNetworkInterfaceSectionProps = { refreshingNetworkInterfaces: boolean onRefreshNetworkInterfaces: () => void loading: boolean + disabled?: boolean hasQrCode: boolean onGenerateQr: () => void } @@ -29,6 +30,7 @@ export function MobileNetworkInterfaceSection({ refreshingNetworkInterfaces, onRefreshNetworkInterfaces, loading, + disabled = false, hasQrCode, onGenerateQr }: MobileNetworkInterfaceSectionProps): React.JSX.Element { @@ -36,12 +38,11 @@ export function MobileNetworkInterfaceSection({
- Network Interface + Direct pairing

- Choose which network address to advertise in the QR code. Use your LAN address for - same-network pairing, or an overlay network address (Tailscale, ZeroTier) for cross-network - access. + Your phone connects straight to this computer. Use your LAN address on the same Wi-Fi, or a + Tailscale or ZeroTier address to reach it from another network.

@@ -80,7 +81,7 @@ export function MobileNetworkInterfaceSection({
- Connect outside your Wi-Fi with a tailnet + Pair across networks with a tailnet

- Orca Mobile connects directly to this computer. To use it away from the same local - network, put your computer and phone on the same private overlay network, then - generate the QR code with that network address selected. + Put your computer and phone on the same private overlay network, then generate the QR + code with that network address selected. If you can't install a tailnet on both + devices, use a self-hosted relay below instead.

  1. diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 4972911ac1a..a17bcc48597 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from 'react' +/* eslint-disable max-lines -- Why: direct and relay mobile pairing share QR/device state; keeping the pane together prevents credential-rotation drift. */ +import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { Check, Copy, Maximize2, Smartphone, Trash2 } from 'lucide-react' import { Button } from '../ui/button' @@ -11,6 +12,7 @@ import { type MobileNetworkInterface } from './mobile-network-interface-selection' import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection' +import { RuntimeRelaySettingsSection } from './RuntimeRelaySettingsSection' export { MOBILE_PANE_SEARCH_ENTRIES } from './mobile-pane-search' // Why: the section heading "When you leave the mobile app" carries the @@ -44,6 +46,7 @@ export function MobilePane(): React.JSX.Element { const updateSettings = useAppStore((s) => s.updateSettings) const [qrDataUrl, setQrDataUrl] = useState(null) const [pairingUrl, setPairingUrl] = useState(null) + const [pairingSource, setPairingSource] = useState<'direct' | 'relay' | null>(null) const [endpoint, setEndpoint] = useState(null) const [loading, setLoading] = useState(false) const [devices, setDevices] = useState([]) @@ -52,6 +55,37 @@ export function MobilePane(): React.JSX.Element { const [selectedAddress, setSelectedAddress] = useState(undefined) const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false) const [codeCopied, setCodeCopied] = useState(false) + const [mobilePairingGenerationSource, setMobilePairingGenerationSource] = useState< + 'direct' | 'relay' | null + >(null) + const mobilePairingGenerationIdRef = useRef(0) + const activeMobilePairingGenerationIdRef = useRef(null) + + const tryBeginMobilePairingGeneration = useCallback( + (source: 'direct' | 'relay'): number | null => { + if (activeMobilePairingGenerationIdRef.current !== null) { + return null + } + const requestId = mobilePairingGenerationIdRef.current + 1 + mobilePairingGenerationIdRef.current = requestId + activeMobilePairingGenerationIdRef.current = requestId + setMobilePairingGenerationSource(source) + return requestId + }, + [] + ) + + const isCurrentMobilePairingGeneration = useCallback((requestId: number): boolean => { + return activeMobilePairingGenerationIdRef.current === requestId + }, []) + + const finishMobilePairingGeneration = useCallback((requestId: number): void => { + if (activeMobilePairingGenerationIdRef.current !== requestId) { + return + } + activeMobilePairingGenerationIdRef.current = null + setMobilePairingGenerationSource(null) + }, []) const loadDevices = useCallback(async () => { try { @@ -81,6 +115,10 @@ export function MobilePane(): React.JSX.Element { const generateQR = useCallback( async (opts: { rotate?: boolean } = {}) => { + const requestId = tryBeginMobilePairingGeneration('direct') + if (requestId === null) { + return + } setLoading(true) try { // Why: pass rotate=true on explicit Regenerate clicks so the runtime @@ -90,9 +128,13 @@ export function MobilePane(): React.JSX.Element { ...(selectedAddress ? { address: selectedAddress } : {}), ...(opts.rotate ? { rotate: true } : {}) }) + if (!isCurrentMobilePairingGeneration(requestId)) { + return + } if (result.available) { setQrDataUrl(result.qrDataUrl) setPairingUrl(result.pairingUrl) + setPairingSource('direct') setEndpoint(result.endpoint) setCodeCopied(false) void loadDevices() @@ -100,12 +142,21 @@ export function MobilePane(): React.JSX.Element { toast.error('WebSocket transport is not running') } } catch { - toast.error('Failed to generate QR code') + if (isCurrentMobilePairingGeneration(requestId)) { + toast.error('Failed to generate QR code') + } } finally { setLoading(false) + finishMobilePairingGeneration(requestId) } }, - [loadDevices, selectedAddress] + [ + finishMobilePairingGeneration, + isCurrentMobilePairingGeneration, + loadDevices, + selectedAddress, + tryBeginMobilePairingGeneration + ] ) useEffect(() => { @@ -156,6 +207,51 @@ export function MobilePane(): React.JSX.Element { } } + const clearPairingCode = useCallback(() => { + setQrDataUrl(null) + setPairingUrl(null) + setPairingSource(null) + setEndpoint(null) + setCodeCopied(false) + }, []) + + const pairingCodeDisplay = qrDataUrl ? ( +
    + + {endpoint && {endpoint}} +

    + Scan this code with the Orca mobile app. Each code creates a unique device token. +

    + {pairingUrl && ( +
    +
    + Or paste this code in the mobile app: +
    + +
    + )} +
    + ) : null + return (
    void loadNetworkInterfaces({ notifyOnError: true })} loading={loading} - hasQrCode={qrDataUrl != null} - onGenerateQr={() => void generateQR({ rotate: qrDataUrl != null })} + disabled={mobilePairingGenerationSource !== null} + hasQrCode={pairingSource === 'direct' && qrDataUrl != null} + onGenerateQr={() => + void generateQR({ rotate: pairingSource === 'direct' && qrDataUrl != null }) + } /> + {pairingSource === 'direct' ? pairingCodeDisplay : null} - {/* QR code display */} - {qrDataUrl && ( -
    - - {endpoint && {endpoint}} -

    - Scan this code with the Orca mobile app. Each code creates a unique device token. -

    - {pairingUrl && ( -
    -
    - Or paste this code in the mobile app: -
    - -
    - )} -
    - )} + { + setQrDataUrl(result.qrDataUrl) + setPairingUrl(result.pairingUrl) + setPairingSource('relay') + setEndpoint(result.endpoint) + setCodeCopied(false) + void loadDevices() + }} + onRelayConfigChanged={() => { + if (pairingSource === 'relay') { + clearPairingCode() + } + }} + mobilePairingBusy={mobilePairingGenerationSource !== null} + tryBeginMobilePairingGeneration={() => tryBeginMobilePairingGeneration('relay')} + isCurrentMobilePairingGeneration={isCurrentMobilePairingGeneration} + finishMobilePairingGeneration={finishMobilePairingGeneration} + /> + {pairingSource === 'relay' ? pairingCodeDisplay : null} {/* Paired devices */}
    diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx index dbf79339e6e..c1a6622a7c9 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx @@ -1,4 +1,5 @@ -import { Check, Copy, Loader2, RefreshCw } from 'lucide-react' +/* eslint-disable max-lines -- Why: this settings component keeps direct, relay, and grant-management state together so generated access links and revocation stay in sync. */ +import { Check, Copy, Loader2, RadioTower, RefreshCw } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import type { RuntimeAccessGrant } from '../../../../shared/runtime-access-grants' @@ -95,6 +96,7 @@ export function RuntimePairingUrlGenerator({ ) const [selectedAddress, setSelectedAddress] = useState(runtimePairingUrlCache.selectedAddress) const [customAddress, setCustomAddress] = useState(runtimePairingUrlCache.customAddress) + const [relayConnected, setRelayConnected] = useState(false) const [runtimePairingUrl, setRuntimePairingUrl] = useState( runtimePairingUrlCache.runtimePairingUrl ) @@ -109,9 +111,13 @@ export function RuntimePairingUrlGenerator({ const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false) const [revokingGrantId, setRevokingGrantId] = useState(null) const [copiedTarget, setCopiedTarget] = useState<'web' | 'pairing' | null>(null) - const [isGeneratingPairing, setIsGeneratingPairing] = useState(false) + const [runtimePairingGenerationSource, setRuntimePairingGenerationSource] = useState< + 'direct' | 'relay' | null + >(null) const networkInterfaceLoadIdRef = useRef(0) const accessGrantLoadIdRef = useRef(0) + const runtimePairingGenerationIdRef = useRef(0) + const activeRuntimePairingGenerationIdRef = useRef(null) const loadRuntimeAccessGrants = useCallback( async (options: { showToastOnError?: boolean } = {}): Promise => { @@ -175,6 +181,37 @@ export function RuntimePairingUrlGenerator({ } }, [loadRuntimeAccessGrants]) + const loadRelayConnectionStatus = useCallback(async (): Promise => { + try { + const result = await window.api.mobile.getRelayStatus() + setRelayConnected(result.status.state === 'connected') + } catch { + setRelayConnected(false) + } + }, []) + + // Why: the relay can be enabled from the Mobile pane while this generator is + // already mounted, so keep the relay action's disabled state current. + useEffect(() => { + if (!showGeneratorForm) { + return + } + let cancelled = false + const refresh = async (): Promise => { + if (!cancelled) { + await loadRelayConnectionStatus() + } + } + void refresh() + const interval = window.setInterval(() => { + void refresh() + }, 2_000) + return () => { + cancelled = true + window.clearInterval(interval) + } + }, [loadRelayConnectionStatus, showGeneratorForm]) + const clearGeneratedUrls = (): void => { runtimePairingUrlCache.runtimePairingUrl = null runtimePairingUrlCache.webClientUrl = null @@ -184,14 +221,43 @@ export function RuntimePairingUrlGenerator({ setRuntimePairingDeviceId(null) } + const tryBeginRuntimePairingGeneration = (source: 'direct' | 'relay'): number | null => { + if (activeRuntimePairingGenerationIdRef.current !== null) { + return null + } + const requestId = runtimePairingGenerationIdRef.current + 1 + runtimePairingGenerationIdRef.current = requestId + activeRuntimePairingGenerationIdRef.current = requestId + setRuntimePairingGenerationSource(source) + return requestId + } + + const isCurrentRuntimePairingGeneration = (requestId: number): boolean => { + return activeRuntimePairingGenerationIdRef.current === requestId + } + + const finishRuntimePairingGeneration = (requestId: number): void => { + if (activeRuntimePairingGenerationIdRef.current !== requestId) { + return + } + activeRuntimePairingGenerationIdRef.current = null + setRuntimePairingGenerationSource(null) + } + const generateRuntimePairingUrl = async (): Promise => { - setIsGeneratingPairing(true) + const requestId = tryBeginRuntimePairingGeneration('direct') + if (requestId === null) { + return + } try { const advertiseAddress = customAddress.trim() || selectedAddress const result = await window.api.mobile.getRuntimePairingUrl({ address: advertiseAddress, rotate: true }) + if (!isCurrentRuntimePairingGeneration(requestId)) { + return + } if (!result.available) { clearGeneratedUrls() toast.error('Runtime pairing is unavailable.') @@ -206,9 +272,47 @@ export function RuntimePairingUrlGenerator({ await loadRuntimeAccessGrants() toast.success(result.webClientUrl ? 'Generated web client URL.' : 'Generated pairing URL.') } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to generate pairing URL.') + if (isCurrentRuntimePairingGeneration(requestId)) { + toast.error(error instanceof Error ? error.message : 'Failed to generate pairing URL.') + } } finally { - setIsGeneratingPairing(false) + finishRuntimePairingGeneration(requestId) + } + } + + const generateRelayRuntimePairingUrl = async (): Promise => { + const requestId = tryBeginRuntimePairingGeneration('relay') + if (requestId === null) { + return + } + try { + const result = await window.api.mobile.getRelayRuntimePairingUrl({ rotate: true }) + if (!isCurrentRuntimePairingGeneration(requestId)) { + return + } + if (!result.available) { + clearGeneratedUrls() + toast.error('Relay access link is unavailable.') + return + } + runtimePairingUrlCache.runtimePairingUrl = result.pairingUrl + runtimePairingUrlCache.webClientUrl = result.webClientUrl + runtimePairingUrlCache.runtimePairingDeviceId = result.deviceId + setRuntimePairingUrl(result.pairingUrl) + setWebClientUrl(result.webClientUrl) + setRuntimePairingDeviceId(result.deviceId) + await loadRuntimeAccessGrants() + toast.success( + result.webClientUrl ? 'Generated relay web client URL.' : 'Generated relay pairing URL.' + ) + } catch (error) { + if (isCurrentRuntimePairingGeneration(requestId)) { + toast.error( + error instanceof Error ? error.message : 'Failed to generate relay pairing URL.' + ) + } + } finally { + finishRuntimePairingGeneration(requestId) } } @@ -252,6 +356,9 @@ export function RuntimePairingUrlGenerator({ ? 'space-y-3 rounded-lg border border-border/50 bg-muted/25 p-3' : 'space-y-4' const sharedAccessClassName = showGeneratorForm ? 'border-t border-border/40 pt-3' : '' + const isGeneratingPairing = runtimePairingGenerationSource === 'direct' + const isGeneratingRelayPairing = runtimePairingGenerationSource === 'relay' + const isGeneratingAnyPairing = runtimePairingGenerationSource !== null const updateSelectedAddress = (address: string): void => { runtimePairingUrlCache.selectedAddress = address @@ -342,18 +449,46 @@ export function RuntimePairingUrlGenerator({ 127.0.0.1 only works on this computer. Use a LAN, Tailscale, or custom address for another device.

    -
    +
    + + + {/* Why: wrap in span so the tooltip still surfaces while the + relay button is disabled — Radix can't fire on a disabled + button itself. */} + + + + + {!relayConnected ? ( + + Connect a relay in Mobile settings to share this server through it. + + ) : null} +
    diff --git a/src/renderer/src/components/settings/RuntimeRelaySettingsSection.tsx b/src/renderer/src/components/settings/RuntimeRelaySettingsSection.tsx new file mode 100644 index 00000000000..42f66457b41 --- /dev/null +++ b/src/renderer/src/components/settings/RuntimeRelaySettingsSection.tsx @@ -0,0 +1,311 @@ +import { Loader2, QrCode, RadioTower, RefreshCw, Save } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { SettingsSwitch } from './SettingsFormControls' +import { cn } from '@/lib/utils' + +type RuntimeRelayConfig = { + enabled: boolean + endpoint: string +} + +type RuntimeRelayStatus = { + state: string + activeDataSockets: number + error: string | null +} + +type RelayMobilePairingResult = { + qrDataUrl: string + pairingUrl: string + endpoint: string + deviceId: string +} + +type RuntimeRelaySettingsSectionProps = { + onMobilePairingGenerated: (result: RelayMobilePairingResult) => void + onRelayConfigChanged?: () => void + mobilePairingBusy?: boolean + tryBeginMobilePairingGeneration?: () => number | null + isCurrentMobilePairingGeneration?: (requestId: number) => boolean + finishMobilePairingGeneration?: (requestId: number) => void +} + +function relayStatusLabel(status: RuntimeRelayStatus): string { + if (status.state === 'connected') { + return status.activeDataSockets > 0 + ? `Connected · ${status.activeDataSockets} active` + : 'Connected' + } + if (status.state === 'connecting') { + return 'Connecting…' + } + if (status.state === 'error') { + return 'Error' + } + return 'Off' +} + +function relayStatusToneClass(status: RuntimeRelayStatus): string { + if (status.state === 'error') { + return 'border-destructive/40 text-destructive' + } + if (status.state === 'connected') { + return 'border-border/60 text-foreground/80' + } + return 'border-border/60 text-muted-foreground' +} + +export function RuntimeRelaySettingsSection({ + onMobilePairingGenerated, + onRelayConfigChanged, + mobilePairingBusy = false, + tryBeginMobilePairingGeneration, + isCurrentMobilePairingGeneration, + finishMobilePairingGeneration +}: RuntimeRelaySettingsSectionProps): React.JSX.Element { + const [config, setConfig] = useState({ enabled: false, endpoint: '' }) + const [endpointInput, setEndpointInput] = useState('') + const [enabled, setEnabled] = useState(false) + const [status, setStatus] = useState({ + state: 'disabled', + activeDataSockets: 0, + error: null + }) + const [loadingConfig, setLoadingConfig] = useState(true) + const [saving, setSaving] = useState(false) + const [refreshingStatus, setRefreshingStatus] = useState(false) + const [generatingQr, setGeneratingQr] = useState(false) + const refreshingStatusRef = useRef(false) + + const loadRelayConfig = useCallback(async (): Promise => { + setLoadingConfig(true) + try { + const result = await window.api.mobile.getRelayConfig() + setConfig(result.config) + setEndpointInput(result.config.endpoint) + setEnabled(result.config.enabled) + setStatus(result.status) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to load relay settings.') + } finally { + setLoadingConfig(false) + } + }, []) + + const refreshRelayStatus = useCallback( + async (options: { showToastOnError?: boolean } = {}): Promise => { + // Why: interval and manual refresh can fire together; one in-flight read + // keeps older relay status responses from replacing newer UI state. + if (refreshingStatusRef.current) { + return + } + refreshingStatusRef.current = true + setRefreshingStatus(true) + try { + const result = await window.api.mobile.getRelayStatus() + setStatus(result.status) + } catch (error) { + if (options.showToastOnError) { + toast.error(error instanceof Error ? error.message : 'Failed to refresh relay status.') + } + } finally { + refreshingStatusRef.current = false + setRefreshingStatus(false) + } + }, + [] + ) + + useEffect(() => { + void loadRelayConfig() + }, [loadRelayConfig]) + + useEffect(() => { + if (!enabled) { + return + } + const interval = window.setInterval(() => { + void refreshRelayStatus() + }, 2_000) + return () => window.clearInterval(interval) + }, [enabled, refreshRelayStatus]) + + const saveRelayConfig = async (): Promise => { + setSaving(true) + try { + const result = await window.api.mobile.updateRelayConfig({ + enabled, + endpoint: endpointInput.trim() + }) + setConfig(result.config) + setEndpointInput(result.config.endpoint) + setEnabled(result.config.enabled) + setStatus(result.status) + onRelayConfigChanged?.() + toast.success(result.config.enabled ? 'Relay enabled.' : 'Relay disabled.') + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to save relay settings.') + } finally { + setSaving(false) + } + } + + const generateRelayQr = async (): Promise => { + const pairingRequestId = tryBeginMobilePairingGeneration?.() + if (pairingRequestId === null) { + return + } + const requestId = pairingRequestId ?? 0 + setGeneratingQr(true) + try { + const result = await window.api.mobile.getRelayPairingQR({ rotate: true, scope: 'mobile' }) + if (isCurrentMobilePairingGeneration && !isCurrentMobilePairingGeneration(requestId)) { + return + } + if (!result.available) { + toast.error('Relay pairing is unavailable.') + await refreshRelayStatus() + return + } + onMobilePairingGenerated(result) + toast.success('Generated relay pairing code.') + } catch (error) { + if (!isCurrentMobilePairingGeneration || isCurrentMobilePairingGeneration(requestId)) { + toast.error(error instanceof Error ? error.message : 'Failed to generate relay QR code.') + } + } finally { + setGeneratingQr(false) + finishMobilePairingGeneration?.(requestId) + } + } + + const hasUnsavedChanges = config.enabled !== enabled || config.endpoint !== endpointInput.trim() + const relayConnected = status.state === 'connected' + const relayConfigLocked = loadingConfig || saving || generatingQr || mobilePairingBusy + + return ( +
    +
    +
    + + Self-hosted relay (fallback) +
    +
    + + {relayStatusLabel(status)} + + + + + + + Refresh relay status + + +
    +
    +

    + If direct pairing can't reach this computer, route traffic through a relay you trust. + Pairing data and runtime traffic stay end-to-end encrypted; the relay only sees ciphertext. +

    +
    +
    +
    + + setEndpointInput(event.target.value)} + placeholder="wss://relay.example.com/ws?enrollmentToken=..." + disabled={relayConfigLocked} + className="h-8 font-mono text-xs" + /> +
    +
    + setEnabled((current) => !current)} + ariaLabel="Enable self-hosted relay" + disabled={relayConfigLocked} + /> + +
    +
    + {status.error ?

    {status.error}

    : null} +
    + + + {/* Why: wrap in span so the tooltip still surfaces while the + button is disabled (Radix triggers can't fire on the disabled + button itself). */} + + + + + {!relayConnected || hasUnsavedChanges ? ( + + {hasUnsavedChanges + ? 'Save changes before generating a relay code.' + : 'Connect to the relay to generate a relay code.'} + + ) : null} + +

    + Paste a relay URL with its enrollment token, save, then generate a code for the mobile + app. +

    +
    +
    +
    + ) +} diff --git a/src/renderer/src/components/settings/mobile-pane-search.ts b/src/renderer/src/components/settings/mobile-pane-search.ts index 8a1d9e4f8ed..179d6ce88a8 100644 --- a/src/renderer/src/components/settings/mobile-pane-search.ts +++ b/src/renderer/src/components/settings/mobile-pane-search.ts @@ -28,6 +28,12 @@ export const MOBILE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'remote' ] }, + { + title: 'Self-hosted relay', + description: + 'Connect mobile and remote clients through a relay when direct pairing cannot reach this computer.', + keywords: ['mobile', 'relay', 'remote', 'pair', 'websocket', 'server', 'client', 'self-hosted'] + }, { title: 'When you leave the mobile app', description: diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d8e36f37890..1c19406cd2f 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -448,6 +448,20 @@ function createWebPreloadApi(): Partial { listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }), getPairingQR: () => Promise.resolve({ available: false }), getRuntimePairingUrl: () => Promise.resolve({ available: false }), + getRelayPairingQR: () => Promise.resolve({ available: false, reason: 'web_client' }), + getRelayRuntimePairingUrl: () => Promise.resolve({ available: false, reason: 'web_client' }), + getRelayConfig: () => + Promise.resolve({ + config: { enabled: false, endpoint: '' }, + status: { state: 'disabled', activeDataSockets: 0, error: null } + }), + updateRelayConfig: () => + Promise.resolve({ + config: { enabled: false, endpoint: '' }, + status: { state: 'disabled', activeDataSockets: 0, error: null } + }), + getRelayStatus: () => + Promise.resolve({ status: { state: 'disabled', activeDataSockets: 0, error: null } }), listDevices: () => Promise.resolve({ devices: [] }), revokeDevice: () => Promise.resolve({ revoked: false }), listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }), diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 0380620a9fb..7c635b423a6 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -14,16 +14,24 @@ import { encryptBytes as encryptSharedBytes } from '../../../shared/e2ee-crypto' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { + createRelayTextFrame, + encodeRelayBinaryFrame +} from '../../../shared/runtime-relay-transport' class FakeWebSocket { static readonly CONNECTING = 0 static readonly OPEN = 1 + static instances: FakeWebSocket[] = [] readyState = FakeWebSocket.CONNECTING binaryType = 'arraybuffer' close = vi.fn() send = vi.fn() + onmessage?: (event: { data: unknown }) => void - constructor(readonly _url: string) {} + constructor(readonly _url: string) { + FakeWebSocket.instances.push(this) + } } describe('WebRuntimeClient', () => { @@ -35,6 +43,7 @@ describe('WebRuntimeClient', () => { btoa: (value: string) => Buffer.from(value, 'binary').toString('base64') }) vi.stubGlobal('WebSocket', FakeWebSocket) + FakeWebSocket.instances = [] }) afterEach(() => { @@ -378,6 +387,102 @@ describe('WebRuntimeClient', () => { client.close() }) + it('ignores handshake messages from sockets that are no longer current', () => { + const serverKeys = generateKeyPair() + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'wss://relay.orca.test/runtime?role=client&serverId=server-1', + deviceToken: 'token', + publicKeyB64: publicKeyToBase64(serverKeys.publicKey) + }) + const staleSocket = FakeWebSocket.instances[0] + const currentSocket = new FakeWebSocket('wss://relay.orca.test/runtime') + currentSocket.readyState = FakeWebSocket.OPEN + const internals = client as unknown as { + ws: WebSocket + state: 'handshaking' + sharedKey: Uint8Array + } + internals.ws = currentSocket as unknown as WebSocket + internals.state = 'handshaking' + internals.sharedKey = new Uint8Array(32).fill(7) + + staleSocket?.onmessage?.({ + data: JSON.stringify({ type: 'e2ee_ready', challenge: 'old-challenge' }) + }) + + expect(currentSocket.send).not.toHaveBeenCalled() + client.close() + }) + + it('serializes delayed relay Blob frames before later text frames', async () => { + let releaseBlob!: () => void + const blobReleased = new Promise((resolve) => { + releaseBlob = resolve + }) + class DelayedBlob extends Blob { + override async arrayBuffer(): Promise { + await blobReleased + return super.arrayBuffer() + } + } + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'wss://relay.orca.test/runtime?role=client&serverId=server-1', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const socket = FakeWebSocket.instances[0] + const sharedKey = new Uint8Array(32).fill(7) + const calls: string[] = [] + const onResponse = vi.fn(() => calls.push('text')) + const onBinary = vi.fn(() => calls.push('binary')) + const internals = client as unknown as { + state: 'connected' + sharedKey: Uint8Array + subscriptions: Map< + string, + { callbacks: { onResponse: typeof onResponse; onBinary: typeof onBinary } } + > + } + internals.state = 'connected' + internals.sharedKey = sharedKey + internals.subscriptions.set('stream-1', { callbacks: { onResponse, onBinary } }) + + const binaryFrame = encryptSharedBytes( + encodeRelayBinaryFrame(1, new Uint8Array([9, 8, 7])), + sharedKey + ) + const textFrame = encrypt( + createRelayTextFrame( + 2, + JSON.stringify({ + id: 'stream-1', + ok: true, + streaming: true, + result: { type: 'ready' }, + _meta: { runtimeId: 'runtime-web-test' } + }) + ), + sharedKey + ) + + socket?.onmessage?.({ data: new DelayedBlob([Uint8Array.from(binaryFrame)]) }) + socket?.onmessage?.({ data: textFrame }) + + await Promise.resolve() + expect(onResponse).not.toHaveBeenCalled() + expect(socket?.close).not.toHaveBeenCalled() + + releaseBlob() + + await vi.waitFor(() => expect(onResponse).toHaveBeenCalledTimes(1)) + expect(onBinary).toHaveBeenCalledWith(new Uint8Array([9, 8, 7])) + expect(calls).toEqual(['binary', 'text']) + expect(socket?.close).not.toHaveBeenCalled() + client.close() + }) + it('receives encrypted subscription binary frames over a paired web socket', async () => { vi.stubGlobal('WebSocket', WebSocket) const serverKeys = generateKeyPair() diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 22212b314a1..d9c0203be8f 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -3,6 +3,15 @@ binary frame forwarding as one transport boundary. */ import type { RuntimeRpcResponse, RuntimeRpcSuccess } from '../../../shared/runtime-rpc-envelope' import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope' +import { + RelayFrameSequencer, + createRelayTextFrame, + decodeRelayBinaryFrame, + encodeRelayBinaryFrame, + getRuntimeWebSocketTransportKind, + parseRelayTextFrame, + type RuntimeWebSocketTransportKind +} from '../../../shared/runtime-relay-transport' import type { WebPairingOffer } from './web-pairing' import { decrypt, @@ -69,9 +78,12 @@ export class WebRuntimeClient { private readonly childClients = new Set() private readonly waiters: { resolve: () => void; reject: (error: Error) => void }[] = [] private readonly serverPublicKey: Uint8Array + private readonly transportKind: RuntimeWebSocketTransportKind + private relaySequencer = new RelayFrameSequencer() constructor(private readonly pairing: WebPairingOffer) { this.serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) + this.transportKind = getRuntimeWebSocketTransportKind(pairing.endpoint) this.openConnection() } @@ -313,6 +325,7 @@ export class WebRuntimeClient { ws.binaryType = 'arraybuffer' this.ws = ws this.sharedKey = null + this.relaySequencer = new RelayFrameSequencer() this.setState('connecting') this.connectTimer = window.setTimeout(() => { @@ -343,7 +356,27 @@ export class WebRuntimeClient { }, HANDSHAKE_TIMEOUT_MS) } + let relayInboundQueue = Promise.resolve() ws.onmessage = (event) => { + if (this.ws !== ws) { + return + } + if (this.transportKind === 'relay') { + const rawData = event.data + // Why: browser Blob decoding is async, so relay frames must be + // processed in socket arrival order before sequence validation. + relayInboundQueue = relayInboundQueue + .catch(() => undefined) + .then(() => { + if (this.ws !== ws) { + return + } + return this.handleSocketMessage(rawData) + }) + .catch(() => undefined) + void relayInboundQueue + return + } void this.handleSocketMessage(event.data) } @@ -364,7 +397,14 @@ export class WebRuntimeClient { try { const control = JSON.parse(raw) as { type?: unknown } if (control.type === 'e2ee_ready') { - this.sendEncrypted({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken }) + this.sendEncrypted({ + type: 'e2ee_auth', + deviceToken: this.pairing.deviceToken, + challenge: + typeof (control as { challenge?: unknown }).challenge === 'string' + ? (control as { challenge: string }).challenge + : undefined + }) return } } catch { @@ -410,13 +450,21 @@ export class WebRuntimeClient { if (!plaintext) { return } + const payload = this.unwrapRelayBinaryFrame(plaintext) + if (!payload) { + return + } for (const subscription of this.subscriptions.values()) { - subscription.callbacks.onBinary?.(plaintext) + subscription.callbacks.onBinary?.(payload) } return } - const plaintext = decrypt(raw, this.sharedKey) + const decrypted = decrypt(raw, this.sharedKey) + if (decrypted === null) { + return + } + const plaintext = this.unwrapRelayTextFrame(decrypted) if (plaintext === null) { return } @@ -466,7 +514,8 @@ export class WebRuntimeClient { if (!ws || ws.readyState !== WebSocket.OPEN || !this.sharedKey) { return false } - ws.send(encrypt(JSON.stringify(message), this.sharedKey)) + const plaintext = JSON.stringify(message) + ws.send(encrypt(this.wrapRelayTextFrame(plaintext), this.sharedKey)) return true } @@ -475,10 +524,58 @@ export class WebRuntimeClient { if (!ws || ws.readyState !== WebSocket.OPEN || !this.sharedKey) { return false } - ws.send(encryptBytes(bytes, this.sharedKey)) + ws.send(encryptBytes(this.wrapRelayBinaryFrame(bytes), this.sharedKey)) return true } + private wrapRelayTextFrame(payload: string): string { + if (this.transportKind !== 'relay' || this.state !== 'connected') { + return payload + } + const seq = this.relaySequencer.nextOutboundSeq() + if (seq === null) { + this.ws?.close() + return payload + } + return createRelayTextFrame(seq, payload) + } + + private wrapRelayBinaryFrame(payload: Uint8Array): Uint8Array { + if (this.transportKind !== 'relay' || this.state !== 'connected') { + return payload + } + const seq = this.relaySequencer.nextOutboundSeq() + if (seq === null) { + this.ws?.close() + return payload + } + return encodeRelayBinaryFrame(seq, payload) + } + + private unwrapRelayTextFrame(plaintext: string): string | null { + if (this.transportKind !== 'relay') { + return plaintext + } + const frame = parseRelayTextFrame(plaintext) + if (!frame || !this.relaySequencer.acceptInboundSeq(frame.seq)) { + this.ws?.close() + return null + } + return frame.payload + } + + private unwrapRelayBinaryFrame(plaintext: Uint8Array): Uint8Array | null { + if (this.transportKind !== 'relay') { + return plaintext + } + const frame = decodeRelayBinaryFrame(plaintext) + if (!frame || !this.relaySequencer.acceptInboundSeq(frame.seq)) { + this.ws?.close() + return null + } + return frame.payload + } + private waitForConnected(timeoutMs = REQUEST_TIMEOUT_MS): Promise { if (this.state === 'connected') { return Promise.resolve() diff --git a/src/runtime-relay-server/protocol.ts b/src/runtime-relay-server/protocol.ts new file mode 100644 index 00000000000..1dab75bb27d --- /dev/null +++ b/src/runtime-relay-server/protocol.ts @@ -0,0 +1,131 @@ +import { randomBytes, randomUUID } from 'crypto' +import type { IncomingMessage } from 'http' +export { RUNTIME_RELAY_MAX_FRAME_BYTES } from '../shared/runtime-relay-limits' + +export const RUNTIME_RELAY_PROTOCOL_VERSION = 1 +export const RUNTIME_RELAY_WS_PATH = '/ws' +export const RUNTIME_RELAY_DEFAULT_PORT = 8787 +export const RUNTIME_RELAY_ENROLLMENT_TOKEN_BYTES = 32 +export const RUNTIME_RELAY_MAX_PENDING_CLIENTS_PER_SERVER = 64 +export const RUNTIME_RELAY_MAX_ACTIVE_DATA_SOCKETS_PER_SERVER = 64 +export const RUNTIME_RELAY_MAX_GLOBAL_PENDING_CLIENTS = 1024 +export const RUNTIME_RELAY_MAX_GLOBAL_ACTIVE_DATA_SOCKETS = 1024 +export const RUNTIME_RELAY_MAX_BOUND_SERVERS = 10_000 +export const RUNTIME_RELAY_MAX_EARLY_FRAMES_PER_CONNECTION = 4 +export const RUNTIME_RELAY_MAX_EARLY_BYTES_PER_CONNECTION = 4 * 1024 * 1024 +export const RUNTIME_RELAY_MAX_TOTAL_EARLY_BYTES_PER_SERVER = 16 * 1024 * 1024 +export const RUNTIME_RELAY_MAX_GLOBAL_EARLY_BYTES = 64 * 1024 * 1024 +export const RUNTIME_RELAY_MAX_FORWARD_BUFFERED_BYTES = 8 * 1024 * 1024 +export const RUNTIME_RELAY_MAX_CONTROL_BUFFERED_BYTES = 1024 * 1024 +export const RUNTIME_RELAY_ATTACH_DEADLINE_MS = 15_000 +export const RUNTIME_RELAY_HEARTBEAT_INTERVAL_MS = 15_000 +export const RUNTIME_RELAY_STALE_SOCKET_TIMEOUT_MS = 45_000 + +const SERVER_ID_RE = /^[A-Za-z0-9_-]{16,128}$/ +const CONNECTION_ID_RE = /^[A-Za-z0-9_-]{16,128}$/ +const HOST_TOKEN_HASH_RE = /^[a-f0-9]{64}$/ +const MIN_HOST_TOKEN_LENGTH = 32 + +export type RuntimeRelayRole = 'server' | 'client' + +export type RuntimeRelayClientRequest = { + role: 'client' + serverId: string +} + +export type RuntimeRelayServerRequest = + | { + role: 'server' + serverId: string + hostToken: string + enrollmentToken: string | null + connectionId: null + } + | { + role: 'server' + serverId: string + hostToken: string + enrollmentToken: string | null + connectionId: string + } + +export type RuntimeRelayRequest = RuntimeRelayClientRequest | RuntimeRelayServerRequest + +export type RelayControlMessage = + | { type: 'sync'; connectionIds: string[] } + | { type: 'connected'; connectionId: string } + | { type: 'disconnected'; connectionId: string } + +export function createRelayConnectionId(): string { + return randomUUID().replace(/-/g, '') +} + +export function createRelayEnrollmentToken(): string { + return randomBytes(RUNTIME_RELAY_ENROLLMENT_TOKEN_BYTES).toString('base64url') +} + +export function isRuntimeRelayServerId(value: unknown): value is string { + return typeof value === 'string' && SERVER_ID_RE.test(value) +} + +export function isRuntimeRelayHostTokenHash(value: unknown): value is string { + return typeof value === 'string' && HOST_TOKEN_HASH_RE.test(value) +} + +export function parseRuntimeRelayRequest( + request: IncomingMessage +): { ok: true; value: RuntimeRelayRequest } | { ok: false; statusCode: number; reason: string } { + const host = request.headers.host ?? '127.0.0.1' + let url: URL + try { + url = new URL(request.url ?? '/', `http://${host}`) + } catch { + return { ok: false, statusCode: 400, reason: 'invalid_url' } + } + if (url.pathname !== RUNTIME_RELAY_WS_PATH) { + return { ok: false, statusCode: 404, reason: 'not_found' } + } + if (url.searchParams.get('v') !== String(RUNTIME_RELAY_PROTOCOL_VERSION)) { + return { ok: false, statusCode: 400, reason: 'invalid_version' } + } + const role = url.searchParams.get('role') + const serverId = url.searchParams.get('serverId') + if ((role !== 'server' && role !== 'client') || !isRuntimeRelayServerId(serverId)) { + return { ok: false, statusCode: 400, reason: 'invalid_relay_request' } + } + if (role === 'client') { + return { ok: true, value: { role, serverId } } + } + const hostToken = parseHostAuthorization(request) + if (!hostToken || hostToken.length < MIN_HOST_TOKEN_LENGTH) { + return { ok: false, statusCode: 401, reason: 'invalid_host_token' } + } + const connectionId = url.searchParams.get('connectionId') + if (connectionId !== null && !CONNECTION_ID_RE.test(connectionId)) { + return { ok: false, statusCode: 400, reason: 'invalid_connection_id' } + } + return { + ok: true, + value: { + role, + serverId, + hostToken, + enrollmentToken: parseEnrollmentToken(request), + connectionId + } + } +} + +function parseHostAuthorization(request: IncomingMessage): string | null { + const authorization = request.headers.authorization + if (!authorization) { + return null + } + const match = /^Bearer\s+(.+)$/i.exec(authorization.trim()) + return match?.[1] ?? null +} + +function parseEnrollmentToken(request: IncomingMessage): string | null { + const value = request.headers['x-orca-relay-enrollment'] + return typeof value === 'string' && value.trim().length > 0 ? value : null +} diff --git a/src/runtime-relay-server/relay-server-test-sockets.ts b/src/runtime-relay-server/relay-server-test-sockets.ts new file mode 100644 index 00000000000..90790214197 --- /dev/null +++ b/src/runtime-relay-server/relay-server-test-sockets.ts @@ -0,0 +1,157 @@ +import { randomBytes } from 'crypto' +import { connect, type Socket } from 'net' +import WebSocket, { type RawData } from 'ws' + +const OPEN_TIMEOUT_MS = 1_000 + +export function openRelaySocket( + baseUrl: string, + options: + | { role: 'client'; serverId: string } + | { + role: 'server' + serverId: string + token: string + connectionId?: string + enrollmentToken?: string + } +): Promise { + const url = new URL(baseUrl) + url.searchParams.set('role', options.role) + url.searchParams.set('serverId', options.serverId) + url.searchParams.set('v', '1') + if (options.role === 'server' && options.connectionId) { + url.searchParams.set('connectionId', options.connectionId) + } + const ws = new WebSocket(url, { + headers: + options.role === 'server' + ? { + Authorization: `Bearer ${options.token}`, + ...(options.enrollmentToken + ? { 'X-Orca-Relay-Enrollment': options.enrollmentToken } + : {}) + } + : undefined + }) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ws.close() + reject(new Error('Timed out opening relay socket')) + }, OPEN_TIMEOUT_MS) + ws.once('open', () => { + clearTimeout(timer) + resolve(ws) + }) + ws.once('error', (error) => { + clearTimeout(timer) + reject(error) + }) + ws.once('close', () => { + clearTimeout(timer) + reject(new Error('Relay socket closed before open')) + }) + }) +} + +export function waitForMessage(ws: WebSocket): Promise { + return new Promise((resolve) => { + ws.once('message', (data) => { + resolve(data.toString()) + }) + }) +} + +export function waitForControlMessage( + ws: WebSocket, + type: string +): Promise> { + return new Promise((resolve) => { + const onMessage = (data: RawData) => { + const parsed = JSON.parse(data.toString()) as Record + if (parsed.type !== type) { + return + } + ws.off('message', onMessage) + resolve(parsed) + } + ws.on('message', onMessage) + }) +} + +export function waitForClose(ws: WebSocket): Promise<{ code: number; reason: string }> { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Timed out waiting for relay socket close')) + }, OPEN_TIMEOUT_MS) + ws.once('close', (code, reason) => { + clearTimeout(timer) + resolve({ code, reason: reason.toString() }) + }) + }) +} + +export function openRawRelaySocket( + baseUrl: string, + options: { + role: 'server' + serverId: string + token: string + enrollmentToken: string + } +): Promise { + const url = new URL(baseUrl) + url.searchParams.set('role', options.role) + url.searchParams.set('serverId', options.serverId) + url.searchParams.set('v', '1') + return new Promise((resolve, reject) => { + const socket = connect({ host: url.hostname, port: Number(url.port) }) + const timer = setTimeout(() => { + socket.destroy() + reject(new Error('Timed out opening raw relay socket')) + }, OPEN_TIMEOUT_MS) + socket.once('connect', () => { + socket.write( + [ + `GET ${url.pathname}${url.search} HTTP/1.1`, + `Host: ${url.host}`, + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Key: ${randomBytes(16).toString('base64')}`, + 'Sec-WebSocket-Version: 13', + `Authorization: Bearer ${options.token}`, + `X-Orca-Relay-Enrollment: ${options.enrollmentToken}`, + '\r\n' + ].join('\r\n') + ) + }) + socket.once('error', (error) => { + clearTimeout(timer) + reject(error) + }) + socket.on('data', (chunk) => { + if (!chunk.toString('utf8').includes('\r\n\r\n')) { + return + } + clearTimeout(timer) + if (chunk.toString('utf8').startsWith('HTTP/1.1 101')) { + resolve(socket) + return + } + socket.destroy() + reject(new Error(`Unexpected relay upgrade response: ${chunk.toString('utf8')}`)) + }) + }) +} + +export function waitForRawSocketClose(socket: Socket): Promise { + return new Promise((resolve) => { + socket.once('close', () => resolve()) + }) +} + +export function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve('timeout'), ms) + }) +} diff --git a/src/runtime-relay-server/server-limits.test.ts b/src/runtime-relay-server/server-limits.test.ts new file mode 100644 index 00000000000..0b84504eb3c --- /dev/null +++ b/src/runtime-relay-server/server-limits.test.ts @@ -0,0 +1,264 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import WebSocket, { type RawData } from 'ws' +import { startRuntimeRelayServer, type RuntimeRelayServer } from './server' + +const OPEN_TIMEOUT_MS = 1_000 + +let relay: RuntimeRelayServer | null = null +let tempDir: string | null = null + +afterEach(async () => { + await relay?.stop() + relay = null + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + tempDir = null + } +}) + +describe('runtime relay server limits', () => { + it('enforces the relay-wide pending client cap', async () => { + relay = await startTestRelay({ maxGlobalPendingClients: 1 }) + const serverId = 'serverGlobalPend01' + const token = 'f'.repeat(43) + const control = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId, + token, + enrollmentToken: relay.enrollmentToken + }) + const firstClient = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId }) + const secondClient = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId }) + + await expect(waitForClose(secondClient)).resolves.toMatchObject({ + code: 1013, + reason: 'Relay unavailable' + }) + + control.close() + firstClient.close() + }) + + it('fails closed when a control message would exceed the control buffer cap', async () => { + relay = await startTestRelay({ maxControlBufferedBytes: 48 }) + const serverId = 'serverControlCap01' + const control = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId, + token: 'o'.repeat(43), + enrollmentToken: relay.enrollmentToken + }) + const controlClosed = waitForClose(control) + const client = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId }) + const clientClosed = waitForClose(client) + + await expect(clientClosed).resolves.toMatchObject({ + code: 1013, + reason: 'Relay control buffer full' + }) + await expect(controlClosed).resolves.toMatchObject({ + code: 1013, + reason: 'Relay control buffer full' + }) + }) + + it('enforces the relay-wide active data socket cap', async () => { + relay = await startTestRelay({ maxGlobalActiveDataSockets: 1 }) + const firstServerId = 'serverGlobalData01' + const secondServerId = 'serverGlobalData02' + const firstToken = 'g'.repeat(43) + const secondToken = 'h'.repeat(43) + const firstControl = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId: firstServerId, + token: firstToken, + enrollmentToken: relay.enrollmentToken + }) + const firstConnected = waitForControlMessage(firstControl, 'connected') + const firstClient = await openRelaySocket(relay.webSocketUrl, { + role: 'client', + serverId: firstServerId + }) + const firstConnection = await firstConnected + const firstServerData = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId: firstServerId, + token: firstToken, + connectionId: firstConnection.connectionId + }) + + const secondControl = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId: secondServerId, + token: secondToken, + enrollmentToken: relay.enrollmentToken + }) + const secondConnected = waitForControlMessage(secondControl, 'connected') + const secondClient = await openRelaySocket(relay.webSocketUrl, { + role: 'client', + serverId: secondServerId + }) + const secondConnection = await secondConnected + const secondServerData = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId: secondServerId, + token: secondToken, + connectionId: secondConnection.connectionId + }) + + await expect(waitForClose(secondServerData)).resolves.toMatchObject({ + code: 1008, + reason: 'Unknown relay connection' + }) + + firstControl.close() + firstClient.close() + firstServerData.close() + secondControl.close() + secondClient.close() + }) + + it('enforces the relay-wide early frame byte cap', async () => { + relay = await startTestRelay({ maxGlobalEarlyBytes: 4 }) + const serverId = 'serverGlobalEarly01' + const token = 'i'.repeat(43) + const control = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId, + token, + enrollmentToken: relay.enrollmentToken + }) + const client = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId }) + const closed = waitForClose(client) + + client.send('12345') + + await expect(closed).resolves.toMatchObject({ + code: 1013, + reason: 'Relay early buffer full' + }) + + control.close() + }) + + it('terminates sockets that miss relay heartbeat liveness checks', async () => { + relay = await startTestRelay({ heartbeatIntervalMs: 5, staleSocketTimeoutMs: 0 }) + const control = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId: 'serverHeartbeat01', + token: 'j'.repeat(43), + enrollmentToken: relay.enrollmentToken + }) + + await expect(waitForClose(control)).resolves.toMatchObject({ + code: 1006, + reason: '' + }) + }) +}) + +type TestRelayOptions = { + maxGlobalPendingClients?: number + maxGlobalActiveDataSockets?: number + maxGlobalEarlyBytes?: number + maxControlBufferedBytes?: number + heartbeatIntervalMs?: number + staleSocketTimeoutMs?: number +} + +async function startTestRelay(options: TestRelayOptions = {}): Promise { + tempDir = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-')) + return startRuntimeRelayServer({ + host: '127.0.0.1', + port: 0, + statePath: join(tempDir, 'relay-state.json'), + maxGlobalPendingClients: options.maxGlobalPendingClients, + maxGlobalActiveDataSockets: options.maxGlobalActiveDataSockets, + maxGlobalEarlyBytes: options.maxGlobalEarlyBytes, + maxControlBufferedBytes: options.maxControlBufferedBytes, + heartbeatIntervalMs: options.heartbeatIntervalMs, + staleSocketTimeoutMs: options.staleSocketTimeoutMs + }) +} + +function openRelaySocket( + baseUrl: string, + options: + | { role: 'client'; serverId: string } + | { + role: 'server' + serverId: string + token: string + connectionId?: string + enrollmentToken?: string + } +): Promise { + const url = new URL(baseUrl) + url.searchParams.set('role', options.role) + url.searchParams.set('serverId', options.serverId) + url.searchParams.set('v', '1') + if (options.role === 'server' && options.connectionId) { + url.searchParams.set('connectionId', options.connectionId) + } + const ws = new WebSocket(url, { + headers: + options.role === 'server' + ? { + Authorization: `Bearer ${options.token}`, + ...(options.enrollmentToken + ? { 'X-Orca-Relay-Enrollment': options.enrollmentToken } + : {}) + } + : undefined + }) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ws.close() + reject(new Error('Timed out opening relay socket')) + }, OPEN_TIMEOUT_MS) + ws.once('open', () => { + clearTimeout(timer) + resolve(ws) + }) + ws.once('error', (error) => { + clearTimeout(timer) + reject(error) + }) + ws.once('close', () => { + clearTimeout(timer) + reject(new Error('Relay socket closed before open')) + }) + }) +} + +function waitForControlMessage( + ws: WebSocket, + type: string +): Promise<{ type: string; connectionId: string }> { + return new Promise((resolve) => { + const onMessage = (data: RawData) => { + const parsed = JSON.parse(data.toString()) as { type: string; connectionId: string } + if (parsed.type !== type) { + return + } + ws.off('message', onMessage) + resolve(parsed) + } + ws.on('message', onMessage) + }) +} + +function waitForClose(ws: WebSocket): Promise<{ code: number; reason: string }> { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Timed out waiting for relay socket close')) + }, OPEN_TIMEOUT_MS) + ws.once('close', (code, reason) => { + clearTimeout(timer) + resolve({ code, reason: reason.toString() }) + }) + }) +} diff --git a/src/runtime-relay-server/server.test.ts b/src/runtime-relay-server/server.test.ts new file mode 100644 index 00000000000..a58a28d7baa --- /dev/null +++ b/src/runtime-relay-server/server.test.ts @@ -0,0 +1,294 @@ +import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { + delay, + openRawRelaySocket, + openRelaySocket, + waitForClose, + waitForControlMessage, + waitForMessage, + waitForRawSocketClose +} from './relay-server-test-sockets' +import { startRuntimeRelayServer, type RuntimeRelayServer } from './server' + +let relay: RuntimeRelayServer | null = null +let tempDir: string | null = null + +afterEach(async () => { + await relay?.stop() + relay = null + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + tempDir = null + } +}) + +describe('runtime relay server', () => { + it('enrolls host control token and rejects later wrong host token', async () => { + relay = await startTestRelay() + const serverId = 'serverTokenBinding01' + const control = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId, + token: 'a'.repeat(43), + enrollmentToken: relay.enrollmentToken + }) + + await expect( + openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId, + token: 'b'.repeat(43) + }) + ).rejects.toThrow() + + control.close() + }) + + it('forwards opaque frames between client and host data sockets', async () => { + relay = await startTestRelay() + const serverId = 'serverForwarding01' + const token = 'c'.repeat(43) + const control = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId, + token, + enrollmentToken: relay.enrollmentToken + }) + const connectedControl = waitForControlMessage(control, 'connected') + const client = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId }) + const connectedMessage = (await connectedControl) as { + type: string + connectionId: string + } + expect(connectedMessage.type).toBe('connected') + + const serverData = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId, + token, + connectionId: connectedMessage.connectionId + }) + const serverReceived = waitForMessage(serverData) + client.send('client-frame') + expect(await serverReceived).toBe('client-frame') + + const clientReceived = waitForMessage(client) + serverData.send('server-frame') + expect(await clientReceived).toBe('server-frame') + + control.close() + client.close() + serverData.close() + }) + + it('advertises bracketed URLs for IPv6 hosts', async () => { + relay = await startTestRelay({ host: '::1' }) + + expect(relay.httpUrl).toMatch(/^http:\/\/\[::1\]:\d+$/) + expect(relay.webSocketUrl).toMatch(/^ws:\/\/\[::1\]:\d+\/ws$/) + }) + + it('rejects clients for unknown server ids', async () => { + relay = await startTestRelay() + await expect( + openRelaySocket(relay.webSocketUrl, { role: 'client', serverId: 'unknownServerId01' }) + ).rejects.toThrow() + }) + + it('rejects first host enrollment without the relay enrollment token', async () => { + relay = await startTestRelay() + await expect( + openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId: 'serverNoEnrollment', + token: 'e'.repeat(43) + }) + ).rejects.toThrow() + }) + + it('fails closed when persisted relay bindings are unreadable', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-')) + const statePath = join(tempDir, 'relay-state.json') + writeFileSync(statePath, '{not-valid-json') + + await expect( + startRuntimeRelayServer({ + host: '127.0.0.1', + port: 0, + statePath + }) + ).rejects.toThrow('Failed to read runtime relay state') + }) + + it('fails closed when persisted relay bindings have an invalid shape', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-')) + const statePath = join(tempDir, 'relay-state.json') + writeFileSync(statePath, JSON.stringify({ hostTokenHashes: [] })) + + await expect( + startRuntimeRelayServer({ + host: '127.0.0.1', + port: 0, + statePath + }) + ).rejects.toThrow('Failed to read runtime relay state') + }) + + it('fails closed when persisted relay state is not an object', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-')) + const statePath = join(tempDir, 'relay-state.json') + writeFileSync(statePath, JSON.stringify([])) + + await expect( + startRuntimeRelayServer({ + host: '127.0.0.1', + port: 0, + statePath + }) + ).rejects.toThrow('Failed to read runtime relay state') + }) + + it('rejects first host enrollment and rolls back binding when persistence fails', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-')) + const blockerPath = join(tempDir, 'relay-state-parent') + const statePath = join(blockerPath, 'relay-state.json') + const logs: string[] = [] + writeFileSync(blockerPath, 'not a directory') + relay = await startRuntimeRelayServer({ + host: '127.0.0.1', + port: 0, + statePath, + log: (message) => logs.push(message) + }) + const serverId = 'serverPersistFail1' + + await expect( + openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId, + token: 'm'.repeat(43), + enrollmentToken: relay.enrollmentToken + }) + ).rejects.toThrow() + + unlinkSync(blockerPath) + const control = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId, + token: 'n'.repeat(43), + enrollmentToken: relay.enrollmentToken + }) + + expect(logs.some((message) => message.includes('failed to persist host binding'))).toBe(true) + control.close() + }) + + it('closes a connection instead of buffering indefinitely when forwarding stalls', async () => { + relay = await startTestRelay({ maxForwardBufferedBytes: 0 }) + const serverId = 'serverBackpressure1' + const token = 'd'.repeat(43) + const control = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId, + token, + enrollmentToken: relay.enrollmentToken + }) + const connectedControl = waitForControlMessage(control, 'connected') + const client = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId }) + const connectedMessage = (await connectedControl) as { + type: string + connectionId: string + } + const serverData = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId, + token, + connectionId: connectedMessage.connectionId + }) + + const clientClosed = waitForClose(client) + client.send('client-frame') + + await expect(clientClosed).resolves.toMatchObject({ + code: 1013, + reason: 'Relay forward buffer full' + }) + + control.close() + serverData.close() + }) + + it('closes before enqueueing a frame that would exceed the forward buffer cap', async () => { + relay = await startTestRelay({ maxForwardBufferedBytes: 4 }) + const serverId = 'serverFrameCap0001' + const token = 'k'.repeat(43) + const control = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId, + token, + enrollmentToken: relay.enrollmentToken + }) + const connectedControl = waitForControlMessage(control, 'connected') + const client = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId }) + const connectedMessage = (await connectedControl) as { + type: string + connectionId: string + } + const serverData = await openRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId, + token, + connectionId: connectedMessage.connectionId + }) + + const clientClosed = waitForClose(client) + client.send('12345') + + await expect(clientClosed).resolves.toMatchObject({ + code: 1013, + reason: 'Relay forward buffer full' + }) + + control.close() + serverData.close() + }) + + it('force-terminates relay sockets that do not finish shutdown close', async () => { + relay = await startTestRelay({ shutdownGraceMs: 10 }) + const rawSocket = await openRawRelaySocket(relay.webSocketUrl, { + role: 'server', + serverId: 'serverShutdown001', + token: 'l'.repeat(43), + enrollmentToken: relay.enrollmentToken + }) + const rawSocketClosed = waitForRawSocketClose(rawSocket) + + const stopPromise = relay.stop() + relay = null + + await expect(Promise.race([stopPromise.then(() => 'stopped'), delay(500)])).resolves.toBe( + 'stopped' + ) + await expect(rawSocketClosed).resolves.toBeUndefined() + }) +}) + +type TestRelayOptions = { + host?: string + maxForwardBufferedBytes?: number + shutdownGraceMs?: number +} + +async function startTestRelay(options: TestRelayOptions = {}): Promise { + tempDir = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-')) + return startRuntimeRelayServer({ + host: options.host ?? '127.0.0.1', + port: 0, + statePath: join(tempDir, 'relay-state.json'), + maxForwardBufferedBytes: options.maxForwardBufferedBytes, + shutdownGraceMs: options.shutdownGraceMs + }) +} diff --git a/src/runtime-relay-server/server.ts b/src/runtime-relay-server/server.ts new file mode 100644 index 00000000000..03994879351 --- /dev/null +++ b/src/runtime-relay-server/server.ts @@ -0,0 +1,796 @@ +/* eslint-disable max-lines -- Why: the relay server keeps the HTTP upgrade, host binding, and socket-forwarding lifecycle in one protocol module so invariants are visible together. */ +import { createHash } from 'crypto' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { createServer, type Server as HttpServer } from 'http' +import { isIP } from 'net' +import { dirname, join } from 'path' +import { WebSocket, WebSocketServer, type RawData } from 'ws' +import { + RUNTIME_RELAY_ATTACH_DEADLINE_MS, + RUNTIME_RELAY_DEFAULT_PORT, + RUNTIME_RELAY_HEARTBEAT_INTERVAL_MS, + RUNTIME_RELAY_MAX_ACTIVE_DATA_SOCKETS_PER_SERVER, + RUNTIME_RELAY_MAX_CONTROL_BUFFERED_BYTES, + RUNTIME_RELAY_MAX_BOUND_SERVERS, + RUNTIME_RELAY_MAX_EARLY_BYTES_PER_CONNECTION, + RUNTIME_RELAY_MAX_EARLY_FRAMES_PER_CONNECTION, + RUNTIME_RELAY_MAX_FORWARD_BUFFERED_BYTES, + RUNTIME_RELAY_MAX_FRAME_BYTES, + RUNTIME_RELAY_MAX_GLOBAL_ACTIVE_DATA_SOCKETS, + RUNTIME_RELAY_MAX_GLOBAL_EARLY_BYTES, + RUNTIME_RELAY_MAX_GLOBAL_PENDING_CLIENTS, + RUNTIME_RELAY_MAX_PENDING_CLIENTS_PER_SERVER, + RUNTIME_RELAY_MAX_TOTAL_EARLY_BYTES_PER_SERVER, + RUNTIME_RELAY_PROTOCOL_VERSION, + RUNTIME_RELAY_STALE_SOCKET_TIMEOUT_MS, + RUNTIME_RELAY_WS_PATH, + createRelayEnrollmentToken, + createRelayConnectionId, + isRuntimeRelayHostTokenHash, + isRuntimeRelayServerId, + parseRuntimeRelayRequest, + type RelayControlMessage +} from './protocol' + +type RuntimeRelayServerOptions = { + host?: string + port?: number + statePath?: string + log?: (message: string) => void + maxForwardBufferedBytes?: number + enrollmentToken?: string + maxGlobalPendingClients?: number + maxGlobalActiveDataSockets?: number + maxGlobalEarlyBytes?: number + maxTotalForwardBufferedBytesPerServer?: number + maxGlobalForwardBufferedBytes?: number + maxControlBufferedBytes?: number + heartbeatIntervalMs?: number + staleSocketTimeoutMs?: number + shutdownGraceMs?: number +} + +export type RuntimeRelayServer = { + httpUrl: string + webSocketUrl: string + enrollmentToken: string + protocolVersion: number + stop: () => Promise +} + +type RelayHostState = { + control: WebSocket | null + pendingClients: Map + serverDataSockets: Map + totalBufferedBytes: number + forwardedBufferedBytes: number +} + +type RelayServerState = { + hosts: Map + hostTokenHashes: Map + statePath: string + globalPendingClients: number + globalActiveDataSockets: number + globalEarlyBytes: number + globalForwardedBytes: number + limits: RelayServerLimits +} + +type RelayConnectionState = { + connectionId: string + client: WebSocket + server: WebSocket | null + earlyFrames: { data: RawData; isBinary: boolean; byteLength: number }[] + bufferedBytes: number + forwardedBufferedBytes: number + pendingForwardedFrames: Map + forwardedCloseHandlers: Map void> + nextForwardedFrameId: number + attachTimer: ReturnType +} + +type RelayStateFile = { + hostTokenHashes?: Record +} + +type RelayServerLimits = { + maxForwardBufferedBytes: number + maxGlobalPendingClients: number + maxGlobalActiveDataSockets: number + maxGlobalEarlyBytes: number + maxTotalForwardBufferedBytesPerServer: number + maxGlobalForwardBufferedBytes: number + maxControlBufferedBytes: number + staleSocketTimeoutMs: number + shutdownGraceMs: number +} + +type RelayHeartbeatSocket = WebSocket & { + lastPongAt?: number +} + +const DEFAULT_RELAY_STATE_PATH = join(process.cwd(), '.orca-runtime-relay-state.json') +const DEFAULT_SHUTDOWN_GRACE_MS = 1_000 + +export async function startRuntimeRelayServer( + options: RuntimeRelayServerOptions = {} +): Promise { + const host = options.host ?? '127.0.0.1' + const port = options.port ?? RUNTIME_RELAY_DEFAULT_PORT + const statePath = options.statePath ?? DEFAULT_RELAY_STATE_PATH + const log = options.log ?? (() => {}) + const limits: RelayServerLimits = { + maxForwardBufferedBytes: + options.maxForwardBufferedBytes ?? RUNTIME_RELAY_MAX_FORWARD_BUFFERED_BYTES, + maxGlobalPendingClients: + options.maxGlobalPendingClients ?? RUNTIME_RELAY_MAX_GLOBAL_PENDING_CLIENTS, + maxGlobalActiveDataSockets: + options.maxGlobalActiveDataSockets ?? RUNTIME_RELAY_MAX_GLOBAL_ACTIVE_DATA_SOCKETS, + maxGlobalEarlyBytes: options.maxGlobalEarlyBytes ?? RUNTIME_RELAY_MAX_GLOBAL_EARLY_BYTES, + maxTotalForwardBufferedBytesPerServer: + options.maxTotalForwardBufferedBytesPerServer ?? + RUNTIME_RELAY_MAX_ACTIVE_DATA_SOCKETS_PER_SERVER * RUNTIME_RELAY_MAX_FORWARD_BUFFERED_BYTES, + maxGlobalForwardBufferedBytes: + options.maxGlobalForwardBufferedBytes ?? + (options.maxGlobalActiveDataSockets ?? RUNTIME_RELAY_MAX_GLOBAL_ACTIVE_DATA_SOCKETS) * + RUNTIME_RELAY_MAX_FORWARD_BUFFERED_BYTES, + maxControlBufferedBytes: + options.maxControlBufferedBytes ?? RUNTIME_RELAY_MAX_CONTROL_BUFFERED_BYTES, + staleSocketTimeoutMs: options.staleSocketTimeoutMs ?? RUNTIME_RELAY_STALE_SOCKET_TIMEOUT_MS, + shutdownGraceMs: options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS + } + const heartbeatIntervalMs = options.heartbeatIntervalMs ?? RUNTIME_RELAY_HEARTBEAT_INTERVAL_MS + const enrollmentToken = options.enrollmentToken ?? createRelayEnrollmentToken() + const state: RelayServerState = { + hosts: new Map(), + hostTokenHashes: loadRelayBindings(statePath), + statePath, + globalPendingClients: 0, + globalActiveDataSockets: 0, + globalEarlyBytes: 0, + globalForwardedBytes: 0, + limits + } + const httpServer = createServer((request, response) => { + if (request.url === '/health') { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ ok: true, kind: 'runtime_relay_server' })) + return + } + response.writeHead(404) + response.end('not found') + }) + const wss = new WebSocketServer({ + noServer: true, + maxPayload: RUNTIME_RELAY_MAX_FRAME_BYTES + }) + + httpServer.on('upgrade', (request, socket, head) => { + const parsed = parseRuntimeRelayRequest(request) + if (!parsed.ok) { + socket.write(`HTTP/1.1 ${parsed.statusCode} ${parsed.reason}\r\n\r\n`) + socket.destroy() + return + } + if (parsed.value.role === 'server') { + const accepted = acceptHostToken( + state, + parsed.value.serverId, + parsed.value.hostToken, + parsed.value.connectionId === null, + parsed.value.enrollmentToken, + enrollmentToken, + log + ) + if (!accepted) { + socket.write('HTTP/1.1 401 invalid_host_token\r\n\r\n') + socket.destroy() + return + } + } else if (!state.hostTokenHashes.has(parsed.value.serverId)) { + socket.write('HTTP/1.1 404 unknown_server\r\n\r\n') + socket.destroy() + return + } + wss.handleUpgrade(request, socket, head, (ws) => { + markSocketAlive(ws) + ws.on('pong', () => markSocketAlive(ws)) + if (parsed.value.role === 'server') { + if (parsed.value.connectionId === null) { + attachControlSocket(state, parsed.value.serverId, ws, log) + } else { + attachServerDataSocket(state, parsed.value.serverId, parsed.value.connectionId, ws, log) + } + return + } + attachClientSocket(state, parsed.value.serverId, ws, log) + }) + }) + + const heartbeatTimer = setInterval(() => { + for (const client of wss.clients) { + if (client.readyState === client.OPEN) { + if (isSocketStale(client, limits.staleSocketTimeoutMs)) { + client.terminate() + continue + } + client.ping() + } + } + }, heartbeatIntervalMs) + if (typeof heartbeatTimer.unref === 'function') { + heartbeatTimer.unref() + } + + await new Promise((resolve, reject) => { + httpServer.once('error', reject) + httpServer.listen(port, host, () => { + httpServer.off('error', reject) + resolve() + }) + }) + + const resolved = httpServer.address() + const resolvedPort = typeof resolved === 'object' && resolved ? resolved.port : port + const urlHost = formatRelayUrlHost(host) + return { + httpUrl: `http://${urlHost}:${resolvedPort}`, + webSocketUrl: `ws://${urlHost}:${resolvedPort}${RUNTIME_RELAY_WS_PATH}`, + enrollmentToken, + protocolVersion: RUNTIME_RELAY_PROTOCOL_VERSION, + stop: async () => { + clearInterval(heartbeatTimer) + await Promise.all([ + closeWebSocketServer(wss, limits.shutdownGraceMs), + closeHttpServer(httpServer) + ]) + } + } +} + +function attachControlSocket( + state: RelayServerState, + serverId: string, + ws: WebSocket, + log: (message: string) => void +): void { + const host = getHostState(state.hosts, serverId) + host.control?.close(1012, 'Control socket replaced') + host.control = ws + if ( + !sendControl(ws, { + message: { type: 'sync', connectionIds: Array.from(host.pendingClients.keys()) }, + maxBufferedBytes: state.limits.maxControlBufferedBytes + }) + ) { + failControlSocket(state, host, ws, 1013, 'Relay control buffer full') + return + } + log(`[runtime-relay] control connected serverId=${serverId}`) + const cleanupControl = () => { + if (host.control === ws) { + host.control = null + for (const connectionId of Array.from(host.pendingClients.keys())) { + closeConnection(state, host, connectionId, 1011, 'Host control disconnected') + } + } + } + ws.on('close', cleanupControl) + ws.on('error', cleanupControl) +} + +function attachClientSocket( + state: RelayServerState, + serverId: string, + ws: WebSocket, + log: (message: string) => void +): void { + const host = getHostState(state.hosts, serverId) + if ( + !host.control || + host.pendingClients.size >= RUNTIME_RELAY_MAX_PENDING_CLIENTS_PER_SERVER || + state.globalPendingClients >= state.limits.maxGlobalPendingClients + ) { + ws.close(1013, 'Relay unavailable') + return + } + const connectionId = createRelayConnectionId() + const attachTimer = setTimeout(() => { + closeConnection(state, host, connectionId, 1013, 'Host data attach timeout') + }, RUNTIME_RELAY_ATTACH_DEADLINE_MS) + if (typeof attachTimer.unref === 'function') { + attachTimer.unref() + } + const connection: RelayConnectionState = { + connectionId, + client: ws, + server: null, + earlyFrames: [], + bufferedBytes: 0, + forwardedBufferedBytes: 0, + pendingForwardedFrames: new Map(), + forwardedCloseHandlers: new Map(), + nextForwardedFrameId: 0, + attachTimer + } + host.pendingClients.set(connectionId, connection) + state.globalPendingClients += 1 + if ( + !sendControl(host.control, { + message: { type: 'connected', connectionId }, + maxBufferedBytes: state.limits.maxControlBufferedBytes + }) + ) { + failControlSocket(state, host, host.control, 1013, 'Relay control buffer full') + return + } + log(`[runtime-relay] client connected serverId=${serverId} connectionId=${connectionId}`) + ws.on('message', (data, isBinary) => forwardClientFrame(state, host, connection, data, isBinary)) + ws.on('close', () => closeConnection(state, host, connectionId, 1000, 'Client disconnected')) + ws.on('error', () => closeConnection(state, host, connectionId, 1011, 'Client socket error')) +} + +function attachServerDataSocket( + state: RelayServerState, + serverId: string, + connectionId: string, + ws: WebSocket, + log: (message: string) => void +): void { + const host = getHostState(state.hosts, serverId) + const connection = host.pendingClients.get(connectionId) + const hadServerDataSocket = host.serverDataSockets.has(connectionId) + if ( + !connection || + (!hadServerDataSocket && + (host.serverDataSockets.size >= RUNTIME_RELAY_MAX_ACTIVE_DATA_SOCKETS_PER_SERVER || + state.globalActiveDataSockets >= state.limits.maxGlobalActiveDataSockets)) + ) { + ws.close(1008, 'Unknown relay connection') + return + } + connection.server?.close(1012, 'Server data socket replaced') + connection.server = ws + host.serverDataSockets.set(connectionId, ws) + if (!hadServerDataSocket) { + state.globalActiveDataSockets += 1 + } + clearTimeout(connection.attachTimer) + for (const frame of connection.earlyFrames.splice(0)) { + if (!forwardFrame(state, host, connection, ws, frame.data, frame.isBinary)) { + closeConnection(state, host, connectionId, 1013, 'Relay forward buffer full') + return + } + host.totalBufferedBytes = Math.max(0, host.totalBufferedBytes - frame.byteLength) + state.globalEarlyBytes = Math.max(0, state.globalEarlyBytes - frame.byteLength) + connection.bufferedBytes = Math.max(0, connection.bufferedBytes - frame.byteLength) + } + connection.bufferedBytes = 0 + log(`[runtime-relay] server data connected serverId=${serverId} connectionId=${connectionId}`) + ws.on('message', (data, isBinary) => { + if (connection.client.readyState === connection.client.OPEN) { + if (!forwardFrame(state, host, connection, connection.client, data, isBinary)) { + closeConnection(state, host, connectionId, 1013, 'Relay forward buffer full') + } + } + }) + ws.on('close', () => { + if (connection.server === ws) { + closeConnection(state, host, connectionId, 1011, 'Host data disconnected') + } + }) + ws.on('error', () => { + if (connection.server === ws) { + closeConnection(state, host, connectionId, 1011, 'Host data socket error') + } + }) +} + +function forwardClientFrame( + state: RelayServerState, + host: RelayHostState, + connection: RelayConnectionState, + data: RawData, + isBinary: boolean +): void { + const server = connection.server + if (server && server.readyState === WebSocket.OPEN) { + if (!forwardFrame(state, host, connection, server, data, isBinary)) { + closeConnection(state, host, connection.connectionId, 1013, 'Relay forward buffer full') + } + return + } + const byteLength = relayFrameByteLength(data) + if ( + connection.earlyFrames.length >= RUNTIME_RELAY_MAX_EARLY_FRAMES_PER_CONNECTION || + connection.bufferedBytes + byteLength > RUNTIME_RELAY_MAX_EARLY_BYTES_PER_CONNECTION || + host.totalBufferedBytes + byteLength > RUNTIME_RELAY_MAX_TOTAL_EARLY_BYTES_PER_SERVER || + state.globalEarlyBytes + byteLength > state.limits.maxGlobalEarlyBytes + ) { + closeConnection(state, host, connection.connectionId, 1013, 'Relay early buffer full') + return + } + connection.earlyFrames.push({ data, isBinary, byteLength }) + connection.bufferedBytes += byteLength + host.totalBufferedBytes += byteLength + state.globalEarlyBytes += byteLength +} + +function closeConnection( + state: RelayServerState, + host: RelayHostState, + connectionId: string, + code: number, + reason: string +): void { + const connection = host.pendingClients.get(connectionId) + if (!connection) { + return + } + releaseAllForwardedFrames(state, host, connection) + host.pendingClients.delete(connectionId) + state.globalPendingClients = Math.max(0, state.globalPendingClients - 1) + if (host.serverDataSockets.delete(connectionId)) { + state.globalActiveDataSockets = Math.max(0, state.globalActiveDataSockets - 1) + } + clearTimeout(connection.attachTimer) + host.totalBufferedBytes = Math.max(0, host.totalBufferedBytes - connection.bufferedBytes) + state.globalEarlyBytes = Math.max(0, state.globalEarlyBytes - connection.bufferedBytes) + connection.client.close(code, reason) + connection.server?.close(code, reason) + const control = host.control + if (control && control.readyState === WebSocket.OPEN) { + if ( + !sendControl(control, { + message: { type: 'disconnected', connectionId }, + maxBufferedBytes: state.limits.maxControlBufferedBytes + }) + ) { + failControlSocket(state, host, control, 1013, 'Relay control buffer full') + } + } +} + +function markSocketAlive(ws: WebSocket): void { + ;(ws as RelayHeartbeatSocket).lastPongAt = Date.now() +} + +function isSocketStale(ws: WebSocket, staleSocketTimeoutMs: number): boolean { + const lastPongAt = (ws as RelayHeartbeatSocket).lastPongAt + return typeof lastPongAt === 'number' && Date.now() - lastPongAt > staleSocketTimeoutMs +} + +function getHostState(hosts: Map, serverId: string): RelayHostState { + let host = hosts.get(serverId) + if (!host) { + host = { + control: null, + pendingClients: new Map(), + serverDataSockets: new Map(), + totalBufferedBytes: 0, + forwardedBufferedBytes: 0 + } + hosts.set(serverId, host) + } + return host +} + +function sendControl( + ws: WebSocket, + options: { message: RelayControlMessage; maxBufferedBytes: number } +): boolean { + if (ws.readyState !== ws.OPEN) { + return false + } + const payload = JSON.stringify(options.message) + const byteLength = Buffer.byteLength(payload) + if (ws.bufferedAmount + byteLength > options.maxBufferedBytes) { + return false + } + try { + ws.send(payload) + return true + } catch { + return false + } +} + +function failControlSocket( + state: RelayServerState, + host: RelayHostState, + ws: WebSocket, + code: number, + reason: string +): void { + if (host.control === ws) { + // Clear first so fail-closed connection cleanup cannot enqueue more control frames. + host.control = null + for (const connectionId of Array.from(host.pendingClients.keys())) { + closeConnection(state, host, connectionId, code, reason) + } + } + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(code, reason) + } +} + +function forwardFrame( + state: RelayServerState, + host: RelayHostState, + connection: RelayConnectionState, + target: WebSocket, + data: RawData, + isBinary: boolean +): boolean { + const byteLength = relayFrameByteLength(data) + if ( + target.bufferedAmount + byteLength > state.limits.maxForwardBufferedBytes || + connection.forwardedBufferedBytes + byteLength > state.limits.maxForwardBufferedBytes || + host.forwardedBufferedBytes + byteLength > state.limits.maxTotalForwardBufferedBytesPerServer || + state.globalForwardedBytes + byteLength > state.limits.maxGlobalForwardBufferedBytes + ) { + return false + } + const frameId = trackForwardedFrame(state, host, connection, target, byteLength) + try { + target.send(data, { binary: isBinary }, () => { + releaseForwardedFrame(state, host, connection, frameId) + }) + } catch { + releaseForwardedFrame(state, host, connection, frameId) + return false + } + return true +} + +function trackForwardedFrame( + state: RelayServerState, + host: RelayHostState, + connection: RelayConnectionState, + target: WebSocket, + byteLength: number +): number { + const frameId = connection.nextForwardedFrameId + connection.nextForwardedFrameId += 1 + connection.pendingForwardedFrames.set(frameId, { target, byteLength }) + connection.forwardedBufferedBytes += byteLength + host.forwardedBufferedBytes += byteLength + state.globalForwardedBytes += byteLength + ensureForwardedSocketCloseHandler(state, host, connection, target) + return frameId +} + +function ensureForwardedSocketCloseHandler( + state: RelayServerState, + host: RelayHostState, + connection: RelayConnectionState, + target: WebSocket +): void { + if (connection.forwardedCloseHandlers.has(target)) { + return + } + const releaseTargetFrames = () => { + releaseForwardedFramesForSocket(state, host, connection, target) + } + connection.forwardedCloseHandlers.set(target, releaseTargetFrames) + target.once('close', releaseTargetFrames) +} + +function releaseForwardedFrame( + state: RelayServerState, + host: RelayHostState, + connection: RelayConnectionState, + frameId: number +): void { + const frame = connection.pendingForwardedFrames.get(frameId) + if (!frame) { + return + } + connection.pendingForwardedFrames.delete(frameId) + subtractForwardedBytes(state, host, connection, frame.byteLength) + clearForwardedSocketCloseHandlerIfIdle(connection, frame.target) +} + +function releaseForwardedFramesForSocket( + state: RelayServerState, + host: RelayHostState, + connection: RelayConnectionState, + target: WebSocket +): void { + for (const [frameId, frame] of connection.pendingForwardedFrames) { + if (frame.target === target) { + connection.pendingForwardedFrames.delete(frameId) + subtractForwardedBytes(state, host, connection, frame.byteLength) + } + } + clearForwardedSocketCloseHandler(connection, target) +} + +function releaseAllForwardedFrames( + state: RelayServerState, + host: RelayHostState, + connection: RelayConnectionState +): void { + for (const frame of connection.pendingForwardedFrames.values()) { + subtractForwardedBytes(state, host, connection, frame.byteLength) + } + connection.pendingForwardedFrames.clear() + for (const [target, releaseTargetFrames] of connection.forwardedCloseHandlers) { + target.off('close', releaseTargetFrames) + } + connection.forwardedCloseHandlers.clear() +} + +function subtractForwardedBytes( + state: RelayServerState, + host: RelayHostState, + connection: RelayConnectionState, + byteLength: number +): void { + connection.forwardedBufferedBytes = Math.max(0, connection.forwardedBufferedBytes - byteLength) + host.forwardedBufferedBytes = Math.max(0, host.forwardedBufferedBytes - byteLength) + state.globalForwardedBytes = Math.max(0, state.globalForwardedBytes - byteLength) +} + +function clearForwardedSocketCloseHandlerIfIdle( + connection: RelayConnectionState, + target: WebSocket +): void { + for (const frame of connection.pendingForwardedFrames.values()) { + if (frame.target === target) { + return + } + } + clearForwardedSocketCloseHandler(connection, target) +} + +function clearForwardedSocketCloseHandler( + connection: RelayConnectionState, + target: WebSocket +): void { + const releaseTargetFrames = connection.forwardedCloseHandlers.get(target) + if (!releaseTargetFrames) { + return + } + target.off('close', releaseTargetFrames) + connection.forwardedCloseHandlers.delete(target) +} + +function relayFrameByteLength(data: RawData): number { + if (typeof data === 'string') { + return Buffer.byteLength(data) + } + if (Array.isArray(data)) { + return data.reduce((sum, buffer) => sum + buffer.byteLength, 0) + } + return data.byteLength +} + +function acceptHostToken( + state: RelayServerState, + serverId: string, + hostToken: string, + allowEnroll: boolean, + providedEnrollmentToken: string | null, + enrollmentToken: string, + log: (message: string) => void +): boolean { + const hash = hashHostToken(hostToken) + const existing = state.hostTokenHashes.get(serverId) + if (existing) { + return existing === hash + } + if ( + !allowEnroll || + providedEnrollmentToken !== enrollmentToken || + state.hostTokenHashes.size >= RUNTIME_RELAY_MAX_BOUND_SERVERS + ) { + return false + } + state.hostTokenHashes.set(serverId, hash) + try { + writeRelayBindings(state.statePath, state.hostTokenHashes) + } catch (error) { + // Failed first enrollment must not pin an in-memory token that was never persisted. + state.hostTokenHashes.delete(serverId) + log( + `[runtime-relay] failed to persist host binding serverId=${serverId}: ${ + error instanceof Error ? error.message : String(error) + }` + ) + return false + } + return true +} + +function hashHostToken(hostToken: string): string { + return createHash('sha256').update(hostToken).digest('hex') +} + +function loadRelayBindings(statePath: string): Map { + if (!existsSync(statePath)) { + return new Map() + } + try { + const parsed = JSON.parse(readFileSync(statePath, 'utf8')) as RelayStateFile + if (!isPlainRecord(parsed)) { + throw new Error('relay state must be an object') + } + const hostTokenHashes = parsed.hostTokenHashes ?? {} + if (!isPlainRecord(hostTokenHashes)) { + throw new Error('hostTokenHashes must be an object') + } + const entries = Object.entries(hostTokenHashes) + for (const [serverId, hash] of entries) { + if (!isRuntimeRelayServerId(serverId) || !isRuntimeRelayHostTokenHash(hash)) { + throw new Error('hostTokenHashes contains an invalid binding') + } + } + return new Map(entries) + } catch (error) { + throw new Error( + `Failed to read runtime relay state at ${statePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } +} + +function isPlainRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function writeRelayBindings(statePath: string, bindings: Map): void { + mkdirSync(dirname(statePath), { recursive: true }) + writeFileSync( + statePath, + JSON.stringify({ hostTokenHashes: Object.fromEntries(bindings) }, null, 2), + { mode: 0o600 } + ) +} + +function formatRelayUrlHost(host: string): string { + return isIP(host) === 6 ? `[${host}]` : host +} + +function closeWebSocketServer(wss: WebSocketServer, shutdownGraceMs: number): Promise { + return new Promise((resolve, reject) => { + const forceCloseTimer = setTimeout(() => { + for (const client of wss.clients) { + if (client.readyState !== WebSocket.CLOSED) { + client.terminate() + } + } + }, shutdownGraceMs) + + for (const client of wss.clients) { + if (client.readyState === WebSocket.OPEN) { + client.close(1001, 'Relay shutting down') + } else if (client.readyState !== WebSocket.CLOSED) { + client.terminate() + } + } + + wss.close((error) => { + clearTimeout(forceCloseTimer) + if (error) { + reject(error) + return + } + resolve() + }) + }) +} + +function closeHttpServer(httpServer: HttpServer): Promise { + return new Promise((resolve, reject) => { + httpServer.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) +} diff --git a/src/shared/remote-runtime-client-relay.test.ts b/src/shared/remote-runtime-client-relay.test.ts new file mode 100644 index 00000000000..f773425c34a --- /dev/null +++ b/src/shared/remote-runtime-client-relay.test.ts @@ -0,0 +1,411 @@ +/* oxlint-disable max-lines -- Why: relay E2EE tests keep paired client/server fixtures in one file. */ +import type { AddressInfo } from 'net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WebSocketServer, type WebSocket } from 'ws' +import { + decrypt, + decryptBytes, + deriveSharedKey, + encrypt, + encryptBytes, + generateKeyPair, + publicKeyFromBase64, + publicKeyToBase64 +} from './e2ee-crypto' +import { encodePairingOffer, parsePairingCode, type PairingOffer } from './pairing' +import { sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest } from './remote-runtime-client' +import { + decodeRelayBinaryFrame, + encodeRelayBinaryFrame, + parseRelayTextFrame +} from './runtime-relay-transport' + +const servers: WebSocketServer[] = [] + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + for (const client of server.clients) { + client.close() + } + server.close(() => resolve()) + }) + ) + ) +}) + +describe('legacy remote runtime relay transport', () => { + it('echoes relay challenges and sequences relay one-shot requests', async () => { + const server = await createOneShotRelayServer() + + const response = await sendRemoteRuntimeRequest<{ satisfied: boolean }>( + server.pairing, + 'terminal.wait', + { terminal: 't1', for: 'tui-idle', timeoutMs: 550 }, + 300 + ) + + expect(response).toMatchObject({ + ok: true, + result: { satisfied: true } + }) + }) + + it('echoes relay challenges and sequences subscription text and binary frames', async () => { + const server = await createSubscriptionRelayServer() + const onResponse = vi.fn() + const onError = vi.fn() + + const subscription = await subscribeRemoteRuntimeRequest( + server.pairing, + 'terminal.subscribe', + { terminal: 't1' }, + 1000, + { + onResponse, + onError + } + ) + + await vi.waitFor(() => + expect(onResponse).toHaveBeenCalledWith( + expect.objectContaining({ ok: true, result: { type: 'subscribed' } }) + ) + ) + const bytes = new Uint8Array([4, 5, 6]) + expect(subscription.sendBinary(bytes)).toBe(true) + await expect(server.nextBinary).resolves.toEqual(bytes) + expect(onError).not.toHaveBeenCalled() + subscription.close() + }) + + it('closes an established subscription after a replayed relay text frame', async () => { + const server = await createTamperedSubscriptionRelayServer('text') + const onError = vi.fn() + + const subscription = await subscribeRemoteRuntimeRequest( + server.pairing, + 'terminal.subscribe', + { terminal: 't1' }, + 1000, + { + onResponse: vi.fn(), + onError + } + ) + + await vi.waitFor(() => + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'invalid_runtime_response' }) + ) + ) + await vi.waitFor(() => expect(server.onClose).toHaveBeenCalled()) + subscription.close() + }) + + it('closes an established subscription after a replayed relay binary frame', async () => { + const server = await createTamperedSubscriptionRelayServer('binary') + const onError = vi.fn() + + const subscription = await subscribeRemoteRuntimeRequest( + server.pairing, + 'terminal.subscribe', + { terminal: 't1' }, + 1000, + { + onResponse: vi.fn(), + onError + } + ) + + await vi.waitFor(() => + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'invalid_runtime_response' }) + ) + ) + await vi.waitFor(() => expect(server.onClose).toHaveBeenCalled()) + subscription.close() + }) +}) + +async function createSubscriptionRelayServer(): Promise<{ + pairing: PairingOffer + nextBinary: Promise +}> { + const serverKeyPair = generateKeyPair() + let resolveBinary: (bytes: Uint8Array) => void = () => {} + const nextBinary = new Promise((resolve) => { + resolveBinary = resolve + }) + const wss = new WebSocketServer({ port: 0 }) + servers.push(wss) + + wss.on('connection', (ws) => { + let sharedKey: Uint8Array | null = null + let authenticated = false + let inboundSeq = 0 + let outboundSeq = 0 + + ws.on('message', (data, isBinary) => { + if (isBinary) { + if (!sharedKey) { + return + } + const decrypted = decryptBytes(new Uint8Array(data as Buffer), sharedKey) + if (!decrypted) { + return + } + const plaintext = unwrapRelayBinary(decrypted, ++inboundSeq) + if (plaintext) { + resolveBinary(plaintext) + } + return + } + + const frame = data.toString() + if (!sharedKey) { + const hello = JSON.parse(frame) as { publicKeyB64: string } + sharedKey = deriveSharedKey( + serverKeyPair.secretKey, + publicKeyFromBase64(hello.publicKeyB64) + ) + ws.send(JSON.stringify({ type: 'e2ee_ready', challenge: 'relay-challenge' })) + return + } + + const plaintext = decrypt(frame, sharedKey) + if (!plaintext) { + return + } + if (!authenticated) { + expect(JSON.parse(plaintext)).toMatchObject({ + type: 'e2ee_auth', + deviceToken: 'device-token', + challenge: 'relay-challenge' + }) + authenticated = true + sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }, null) + return + } + + const requestPlaintext = unwrapRelayText(plaintext, ++inboundSeq) + if (!requestPlaintext) { + return + } + const request = JSON.parse(requestPlaintext) as { id: string } + sendEncrypted( + ws, + sharedKey, + { + id: request.id, + ok: true, + streaming: true, + result: { type: 'subscribed' }, + _meta: { runtimeId: 'runtime-test' } + }, + ++outboundSeq + ) + }) + }) + + return { + pairing: await createRelayPairing(wss, serverKeyPair.publicKey, 'serverRelayClient01'), + nextBinary + } +} + +async function createTamperedSubscriptionRelayServer(kind: 'text' | 'binary'): Promise<{ + pairing: PairingOffer + onClose: ReturnType +}> { + const serverKeyPair = generateKeyPair() + const onClose = vi.fn() + const wss = new WebSocketServer({ port: 0 }) + servers.push(wss) + + wss.on('connection', (ws) => { + let sharedKey: Uint8Array | null = null + let authenticated = false + let inboundSeq = 0 + + ws.once('close', onClose) + ws.on('message', (data, isBinary) => { + if (isBinary) { + return + } + const frame = data.toString() + if (!sharedKey) { + const hello = JSON.parse(frame) as { publicKeyB64: string } + sharedKey = deriveSharedKey( + serverKeyPair.secretKey, + publicKeyFromBase64(hello.publicKeyB64) + ) + ws.send(JSON.stringify({ type: 'e2ee_ready', challenge: 'relay-challenge' })) + return + } + + const plaintext = decrypt(frame, sharedKey) + if (!plaintext) { + return + } + if (!authenticated) { + authenticated = true + sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }, null) + return + } + + const requestPlaintext = unwrapRelayText(plaintext, ++inboundSeq) + if (!requestPlaintext) { + return + } + const request = JSON.parse(requestPlaintext) as { id: string } + if (kind === 'text') { + sendEncrypted( + ws, + sharedKey, + { + id: request.id, + ok: true, + streaming: true, + result: { type: 'subscribed' }, + _meta: { runtimeId: 'runtime-test' } + }, + 2 + ) + return + } + ws.send(Buffer.from(encryptBytes(encodeRelayBinaryFrame(2, new Uint8Array([9])), sharedKey))) + }) + }) + + return { + pairing: await createRelayPairing(wss, serverKeyPair.publicKey, 'serverRelayClient03'), + onClose + } +} + +async function createOneShotRelayServer(): Promise<{ pairing: PairingOffer }> { + const serverKeyPair = generateKeyPair() + const wss = new WebSocketServer({ port: 0 }) + servers.push(wss) + + wss.on('connection', (ws) => { + let sharedKey: Uint8Array | null = null + let authenticated = false + let inboundSeq = 0 + let outboundSeq = 0 + + ws.on('message', (data, isBinary) => { + if (isBinary) { + return + } + const frame = data.toString() + if (!sharedKey) { + const hello = JSON.parse(frame) as { publicKeyB64: string } + sharedKey = deriveSharedKey( + serverKeyPair.secretKey, + publicKeyFromBase64(hello.publicKeyB64) + ) + ws.send(JSON.stringify({ type: 'e2ee_ready', challenge: 'relay-challenge' })) + return + } + + const plaintext = decrypt(frame, sharedKey) + if (!plaintext) { + return + } + if (!authenticated) { + expect(JSON.parse(plaintext)).toMatchObject({ + type: 'e2ee_auth', + deviceToken: 'device-token', + challenge: 'relay-challenge' + }) + authenticated = true + sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }, null) + return + } + + const requestPlaintext = unwrapRelayText(plaintext, ++inboundSeq) + if (!requestPlaintext) { + return + } + const request = JSON.parse(requestPlaintext) as { id: string } + const key = sharedKey + const keepalive = setInterval(() => { + sendEncrypted(ws, key, { _keepalive: true }, ++outboundSeq) + }, 100) + ws.once('close', () => clearInterval(keepalive)) + setTimeout(() => { + clearInterval(keepalive) + sendEncrypted( + ws, + key, + { + id: request.id, + ok: true, + result: { satisfied: true }, + _meta: { runtimeId: 'runtime-test' } + }, + ++outboundSeq + ) + }, 550) + }) + }) + + return { + pairing: await createRelayPairing(wss, serverKeyPair.publicKey, 'serverRelayClient02') + } +} + +async function createRelayPairing( + wss: WebSocketServer, + publicKey: Uint8Array, + serverId: string +): Promise { + await new Promise((resolve) => wss.once('listening', resolve)) + const address = wss.address() as AddressInfo + const pairing = parsePairingCode( + encodePairingOffer({ + v: 2, + endpoint: `ws://127.0.0.1:${address.port}?role=client&serverId=${serverId}&v=1`, + deviceToken: 'device-token', + publicKeyB64: publicKeyToBase64(publicKey) + }) + ) + if (!pairing) { + throw new Error('Failed to create test pairing') + } + return pairing +} + +function sendEncrypted( + ws: WebSocket, + sharedKey: Uint8Array, + message: unknown, + relaySeq: number | null +): void { + const payload = JSON.stringify(message) + ws.send( + encrypt( + relaySeq === null ? payload : JSON.stringify({ type: 'e2ee_frame', seq: relaySeq, payload }), + sharedKey + ) + ) +} + +function unwrapRelayText(plaintext: string, expectedSeq: number): string | null { + const frame = parseRelayTextFrame(plaintext) + expect(frame?.seq).toBe(expectedSeq) + return frame?.payload ?? null +} + +function unwrapRelayBinary( + plaintext: Uint8Array, + expectedSeq: number +): Uint8Array | null { + const frame = decodeRelayBinaryFrame(plaintext) + expect(frame?.seq).toBe(expectedSeq) + return frame?.payload ?? null +} diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index 5128094ae21..57b82e7be78 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -98,10 +98,11 @@ async function createSubscriptionServer(): Promise<{ if (!sharedKey) { return } - const plaintext = decryptBytes(new Uint8Array(data as Buffer), sharedKey) - if (plaintext) { - resolveBinary(plaintext) + const decrypted = decryptBytes(new Uint8Array(data as Buffer), sharedKey) + if (!decrypted) { + return } + resolveBinary(decrypted) return } @@ -112,7 +113,11 @@ async function createSubscriptionServer(): Promise<{ serverKeyPair.secretKey, publicKeyFromBase64(hello.publicKeyB64) ) - ws.send(JSON.stringify({ type: 'e2ee_ready' })) + ws.send( + JSON.stringify({ + type: 'e2ee_ready' + }) + ) return } @@ -121,6 +126,10 @@ async function createSubscriptionServer(): Promise<{ return } if (!authenticated) { + expect(JSON.parse(plaintext)).toMatchObject({ + type: 'e2ee_auth', + deviceToken: 'device-token' + }) authenticated = true sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }) return @@ -154,7 +163,8 @@ async function createSubscriptionServer(): Promise<{ } function sendEncrypted(ws: WebSocket, sharedKey: Uint8Array, message: unknown): void { - ws.send(encrypt(JSON.stringify(message), sharedKey)) + const payload = JSON.stringify(message) + ws.send(encrypt(payload, sharedKey)) } async function createOneShotServer(): Promise<{ pairing: PairingOffer }> { @@ -177,7 +187,11 @@ async function createOneShotServer(): Promise<{ pairing: PairingOffer }> { serverKeyPair.secretKey, publicKeyFromBase64(hello.publicKeyB64) ) - ws.send(JSON.stringify({ type: 'e2ee_ready' })) + ws.send( + JSON.stringify({ + type: 'e2ee_ready' + }) + ) return } @@ -186,6 +200,10 @@ async function createOneShotServer(): Promise<{ pairing: PairingOffer }> { return } if (!authenticated) { + expect(JSON.parse(plaintext)).toMatchObject({ + type: 'e2ee_auth', + deviceToken: 'device-token' + }) authenticated = true sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }) return diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index 05379f46df6..0d068fbe7fe 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -20,6 +20,15 @@ import { RuntimeRpcEnvelopeSchema, type RuntimeRpcResponse } from './runtime-rpc-envelope' +import { + RelayFrameSequencer, + createRelayTextFrame, + decodeRelayBinaryFrame, + encodeRelayBinaryFrame, + getRuntimeWebSocketTransportKind, + parseRelayTextFrame, + type RuntimeWebSocketTransportKind +} from './runtime-relay-transport' type HandshakeState = 'awaiting_ready' | 'awaiting_authenticated' | 'ready' @@ -57,6 +66,8 @@ export async function sendRemoteRuntimeRequest( const keyPair = generateKeyPair() const serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) const sharedKey = deriveSharedKey(keyPair.secretKey, serverPublicKey) + const transportKind = getRuntimeWebSocketTransportKind(pairing.endpoint) + const relaySequencer = new RelayFrameSequencer() let state: HandshakeState = 'awaiting_ready' let settled = false let ws: WebSocket | null = null @@ -174,7 +185,18 @@ export async function sendRemoteRuntimeRequest( return } - handleRpcFrame(plaintext) + const rpcPlaintext = unwrapRelayTextFrame(plaintext, transportKind, relaySequencer) + if (rpcPlaintext === null) { + finish({ + ok: false, + error: new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime replayed a relay frame.' + ) + }) + return + } + handleRpcFrame(rpcPlaintext) }) function handleReadyFrame(frame: string): void { @@ -206,8 +228,16 @@ export async function sendRemoteRuntimeRequest( return } state = 'awaiting_authenticated' + const challenge = (ready as { challenge?: unknown }).challenge ws?.send( - encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey) + encrypt( + JSON.stringify({ + type: 'e2ee_auth', + deviceToken: pairing.deviceToken, + challenge: typeof challenge === 'string' ? challenge : undefined + }), + sharedKey + ) ) } @@ -245,12 +275,16 @@ export async function sendRemoteRuntimeRequest( state = 'ready' ws?.send( encrypt( - JSON.stringify({ - id: requestId, - deviceToken: pairing.deviceToken, - method, - params - }), + wrapRelayTextFrame( + JSON.stringify({ + id: requestId, + deviceToken: pairing.deviceToken, + method, + params + }), + transportKind, + relaySequencer + ), sharedKey ) ) @@ -313,6 +347,8 @@ export async function subscribeRemoteRuntimeRequest( const keyPair = generateKeyPair() const serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) const sharedKey = deriveSharedKey(keyPair.secretKey, serverPublicKey) + const transportKind = getRuntimeWebSocketTransportKind(pairing.endpoint) + const relaySequencer = new RelayFrameSequencer() let state: HandshakeState = 'awaiting_ready' let settled = false let ws: WebSocket | null = null @@ -338,7 +374,12 @@ export async function subscribeRemoteRuntimeRequest( if (state !== 'ready' || !ws || ws.readyState !== WebSocket.OPEN) { return false } - ws.send(Buffer.from(encryptBytes(bytes, sharedKey)), { binary: true }) + const payload = wrapRelayBinaryFrame(bytes, transportKind, relaySequencer) + if (payload === null) { + close() + return false + } + ws.send(Buffer.from(encryptBytes(payload, sharedKey)), { binary: true }) return true } @@ -362,6 +403,12 @@ export async function subscribeRemoteRuntimeRequest( callbacks.onError(error) } + const failClosed = (error: RemoteRuntimeClientError): void => { + // Relay sequence failures invalidate the session; force the next attempt through a fresh handshake. + close() + fail(error) + } + try { ws = new WebSocket(pairing.endpoint) } catch (error) { @@ -430,7 +477,17 @@ export async function subscribeRemoteRuntimeRequest( return } - handleRpcFrame(plaintext) + const rpcPlaintext = unwrapRelayTextFrame(plaintext, transportKind, relaySequencer) + if (rpcPlaintext === null) { + failClosed( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime replayed a relay frame.' + ) + ) + return + } + handleRpcFrame(rpcPlaintext) }) function handleReadyFrame(frame: string): void { @@ -460,8 +517,16 @@ export async function subscribeRemoteRuntimeRequest( return } state = 'awaiting_authenticated' + const challenge = (ready as { challenge?: unknown }).challenge ws?.send( - encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey) + encrypt( + JSON.stringify({ + type: 'e2ee_auth', + deviceToken: pairing.deviceToken, + challenge: typeof challenge === 'string' ? challenge : undefined + }), + sharedKey + ) ) } @@ -492,12 +557,16 @@ export async function subscribeRemoteRuntimeRequest( state = 'ready' ws?.send( encrypt( - JSON.stringify({ - id: requestId, - deviceToken: pairing.deviceToken, - method, - params - }), + wrapRelayTextFrame( + JSON.stringify({ + id: requestId, + deviceToken: pairing.deviceToken, + method, + params + }), + transportKind, + relaySequencer + ), sharedKey ) ) @@ -554,7 +623,74 @@ export async function subscribeRemoteRuntimeRequest( ) return } - callbacks.onBinary?.(plaintext) + const binaryPayload = unwrapRelayBinaryFrame(plaintext, transportKind, relaySequencer) + if (binaryPayload === null) { + failClosed( + new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime replayed a relay binary frame.' + ) + ) + return + } + callbacks.onBinary?.(binaryPayload) } }) } + +function wrapRelayTextFrame( + payload: string, + transportKind: RuntimeWebSocketTransportKind, + relaySequencer: RelayFrameSequencer +): string { + if (transportKind !== 'relay') { + return payload + } + const seq = relaySequencer.nextOutboundSeq() + if (seq === null) { + return payload + } + return createRelayTextFrame(seq, payload) +} + +function unwrapRelayTextFrame( + plaintext: string, + transportKind: RuntimeWebSocketTransportKind, + relaySequencer: RelayFrameSequencer +): string | null { + if (transportKind !== 'relay') { + return plaintext + } + const frame = parseRelayTextFrame(plaintext) + if (!frame || !relaySequencer.acceptInboundSeq(frame.seq)) { + return null + } + return frame.payload +} + +function wrapRelayBinaryFrame( + payload: Uint8Array, + transportKind: RuntimeWebSocketTransportKind, + relaySequencer: RelayFrameSequencer +): Uint8Array | null { + if (transportKind !== 'relay') { + return payload + } + const seq = relaySequencer.nextOutboundSeq() + return seq === null ? null : encodeRelayBinaryFrame(seq, payload) +} + +function unwrapRelayBinaryFrame( + plaintext: Uint8Array, + transportKind: RuntimeWebSocketTransportKind, + relaySequencer: RelayFrameSequencer +): Uint8Array | null { + if (transportKind !== 'relay') { + return plaintext + } + const frame = decodeRelayBinaryFrame(plaintext) + if (!frame || !relaySequencer.acceptInboundSeq(frame.seq)) { + return null + } + return frame.payload +} diff --git a/src/shared/remote-runtime-request-connection.ts b/src/shared/remote-runtime-request-connection.ts index 17f6605ab87..d7b5d83ad57 100644 --- a/src/shared/remote-runtime-request-connection.ts +++ b/src/shared/remote-runtime-request-connection.ts @@ -1,7 +1,15 @@ +/* eslint-disable max-lines -- Why: the remote runtime client owns WebSocket lifecycle, E2EE handshake, relay sequencing, and request fan-out as one state machine. */ import { randomUUID } from 'crypto' import WebSocket from 'ws' import type { PairingOffer } from './pairing' import { decrypt, encrypt } from './e2ee-crypto' +import { + RelayFrameSequencer, + createRelayTextFrame, + getRuntimeWebSocketTransportKind, + parseRelayTextFrame, + type RuntimeWebSocketTransportKind +} from './runtime-relay-transport' import type { RuntimeRpcResponse } from './runtime-rpc-envelope' import { RemoteRuntimeClientError } from './remote-runtime-client' import { @@ -34,12 +42,15 @@ export class RemoteRuntimeRequestConnection { private state: ConnectionState = 'closed' private ws: WebSocket | null = null private sharedKey: Uint8Array | null = null + private readonly transportKind: RuntimeWebSocketTransportKind + private relaySequencer = new RelayFrameSequencer() private readonly pendingRequests = new Map>() private readonly readyWaiters: ReadyWaiter[] = [] private idleCloseTimer: ReturnType | null = null constructor(pairing: PairingOffer) { this.pairing = pairing + this.transportKind = getRuntimeWebSocketTransportKind(pairing.endpoint) } request( @@ -77,6 +88,7 @@ export class RemoteRuntimeRequestConnection { const ws = this.ws this.ws = null this.sharedKey = null + this.relaySequencer = new RelayFrameSequencer() this.state = 'closed' this.clearIdleCloseTimer() @@ -136,6 +148,7 @@ export class RemoteRuntimeRequestConnection { } this.ws = opened.socket.ws this.sharedKey = opened.socket.sharedKey + this.relaySequencer = new RelayFrameSequencer() this.state = 'awaiting_ready' } @@ -149,8 +162,8 @@ export class RemoteRuntimeRequestConnection { if (!sharedKey) { return } - const plaintext = decrypt(frame, sharedKey) - if (plaintext === null) { + const decrypted = decrypt(frame, sharedKey) + if (decrypted === null) { this.close( invalidRemoteRuntimeResponseError('Remote Orca runtime returned an undecryptable frame.') ) @@ -158,17 +171,21 @@ export class RemoteRuntimeRequestConnection { } if (this.state === 'awaiting_authenticated') { - this.handleAuthenticatedFrame(plaintext) + this.handleAuthenticatedFrame(decrypted) return } + const plaintext = this.unwrapRelayTextFrame(decrypted) + if (plaintext === null) { + return + } this.handleRpcFrame(plaintext) } private handleReadyFrame(frame: string): void { - const error = parseReadyFrame(frame) - if (error) { - this.close(error) + const ready = parseReadyFrame(frame) + if (ready instanceof RemoteRuntimeClientError) { + this.close(ready) return } this.state = 'awaiting_authenticated' @@ -178,7 +195,11 @@ export class RemoteRuntimeRequestConnection { } this.ws?.send( encrypt( - JSON.stringify({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken }), + JSON.stringify({ + type: 'e2ee_auth', + deviceToken: this.pairing.deviceToken, + challenge: ready.challenge + }), sharedKey ) ) @@ -229,17 +250,43 @@ export class RemoteRuntimeRequestConnection { } ws.send( encrypt( - JSON.stringify({ - id: requestId, - deviceToken: this.pairing.deviceToken, - method, - params - }), + this.wrapRelayTextFrame( + JSON.stringify({ + id: requestId, + deviceToken: this.pairing.deviceToken, + method, + params + }) + ), sharedKey ) ) } + private wrapRelayTextFrame(payload: string): string { + if (this.transportKind !== 'relay' || this.state !== 'ready') { + return payload + } + const seq = this.relaySequencer.nextOutboundSeq() + if (seq === null) { + this.close(remoteRuntimeUnavailableError('Relay frame sequence exhausted.')) + return payload + } + return createRelayTextFrame(seq, payload) + } + + private unwrapRelayTextFrame(plaintext: string): string | null { + if (this.transportKind !== 'relay') { + return plaintext + } + const frame = parseRelayTextFrame(plaintext) + if (!frame || !this.relaySequencer.acceptInboundSeq(frame.seq)) { + this.close(invalidRemoteRuntimeResponseError('Remote Orca runtime replayed a relay frame.')) + return null + } + return frame.payload + } + private rejectPendingRequest(requestId: string, error: Error): void { const pending = this.pendingRequests.get(requestId) if (!pending) { diff --git a/src/shared/remote-runtime-request-frames.ts b/src/shared/remote-runtime-request-frames.ts index b48212d0f5b..9edc990c230 100644 --- a/src/shared/remote-runtime-request-frames.ts +++ b/src/shared/remote-runtime-request-frames.ts @@ -27,7 +27,7 @@ export function invalidRemoteRuntimeResponseError(message: string): RemoteRuntim return new RemoteRuntimeClientError('invalid_runtime_response', message) } -export function parseReadyFrame(frame: string): RemoteRuntimeClientError | null { +export function parseReadyFrame(frame: string): { challenge?: string } | RemoteRuntimeClientError { let ready: unknown try { ready = JSON.parse(frame) @@ -45,7 +45,8 @@ export function parseReadyFrame(frame: string): RemoteRuntimeClientError | null 'Remote Orca runtime returned an unexpected E2EE handshake frame.' ) } - return null + const challenge = (ready as { challenge?: unknown }).challenge + return typeof challenge === 'string' ? { challenge } : {} } export function parseAuthenticatedFrame(plaintext: string): RemoteRuntimeClientError | null { diff --git a/src/shared/runtime-relay-limits.ts b/src/shared/runtime-relay-limits.ts new file mode 100644 index 00000000000..21809adf765 --- /dev/null +++ b/src/shared/runtime-relay-limits.ts @@ -0,0 +1 @@ +export const RUNTIME_RELAY_MAX_FRAME_BYTES = 1024 * 1024 diff --git a/src/shared/runtime-relay-transport.ts b/src/shared/runtime-relay-transport.ts new file mode 100644 index 00000000000..fabdcab1ce0 --- /dev/null +++ b/src/shared/runtime-relay-transport.ts @@ -0,0 +1,97 @@ +export const RELAY_BINARY_SEQUENCE_BYTES = 8 +export const RELAY_MAX_SEQUENCE = Number.MAX_SAFE_INTEGER + +export type RuntimeWebSocketTransportKind = 'direct' | 'relay' + +export type RelayTextFrame = { + type: 'e2ee_frame' + seq: number + payload: string +} + +export function getRuntimeWebSocketTransportKind(endpoint: string): RuntimeWebSocketTransportKind { + try { + const url = new URL(endpoint) + return url.searchParams.get('role') === 'client' && url.searchParams.has('serverId') + ? 'relay' + : 'direct' + } catch { + return 'direct' + } +} + +export function createRelayTextFrame(seq: number, payload: string): string { + return JSON.stringify({ type: 'e2ee_frame', seq, payload }) +} + +export function parseRelayTextFrame(plaintext: string): RelayTextFrame | null { + let parsed: unknown + try { + parsed = JSON.parse(plaintext) + } catch { + return null + } + if ( + typeof parsed !== 'object' || + parsed === null || + (parsed as { type?: unknown }).type !== 'e2ee_frame' || + typeof (parsed as { seq?: unknown }).seq !== 'number' || + typeof (parsed as { payload?: unknown }).payload !== 'string' + ) { + return null + } + const frame = parsed as RelayTextFrame + if (!Number.isSafeInteger(frame.seq) || frame.seq < 1) { + return null + } + return frame +} + +export function encodeRelayBinaryFrame( + seq: number, + payload: Uint8Array +): Uint8Array { + const framed = new Uint8Array(RELAY_BINARY_SEQUENCE_BYTES + payload.length) + const view = new DataView(framed.buffer, framed.byteOffset, RELAY_BINARY_SEQUENCE_BYTES) + view.setBigUint64(0, BigInt(seq), false) + framed.set(payload, RELAY_BINARY_SEQUENCE_BYTES) + return framed +} + +export function decodeRelayBinaryFrame( + plaintext: Uint8Array +): { seq: number; payload: Uint8Array } | null { + if (plaintext.length < RELAY_BINARY_SEQUENCE_BYTES) { + return null + } + const view = new DataView(plaintext.buffer, plaintext.byteOffset, RELAY_BINARY_SEQUENCE_BYTES) + const seq = Number(view.getBigUint64(0, false)) + if (!Number.isSafeInteger(seq) || seq < 1) { + return null + } + return { + seq, + payload: plaintext.slice(RELAY_BINARY_SEQUENCE_BYTES) + } +} + +export class RelayFrameSequencer { + private inboundSeq = 0 + private outboundSeq = 0 + + nextOutboundSeq(): number | null { + if (this.outboundSeq >= RELAY_MAX_SEQUENCE) { + return null + } + this.outboundSeq += 1 + return this.outboundSeq + } + + acceptInboundSeq(seq: number): boolean { + if (!Number.isSafeInteger(seq) || seq !== this.inboundSeq + 1) { + return false + } + this.inboundSeq = seq + return true + } +}