mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
@@ -81,6 +81,7 @@ docs/**
|
||||
!docs/reference/
|
||||
!docs/reference/**
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/runtime-relay-design.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"../src/main/**/*",
|
||||
"../src/preload/**/*",
|
||||
"../src/shared/**/*",
|
||||
"../src/runtime-relay-server/**/*",
|
||||
"../src/relay/**/*",
|
||||
"../src/types/**/*"
|
||||
],
|
||||
|
||||
@@ -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=<stable host relay id>
|
||||
connectionId=<relay assigned connection id, data sockets only>
|
||||
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
|
||||
<hostToken>` 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=<token>`. 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=<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=<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=<serverId>&connectionId=<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:<connectionId>`
|
||||
|
||||
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 <host>
|
||||
--port <port>
|
||||
--public-url <ws-or-wss-url>
|
||||
--state-path <path>
|
||||
--enrollment-token <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=<token>` 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=<token from Terminal B>
|
||||
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.
|
||||
@@ -120,6 +120,11 @@ function encodeTerminalOutput(streamId: number, chunk: string): Uint8Array {
|
||||
})
|
||||
}
|
||||
|
||||
async function flushInboundQueue(): Promise<void> {
|
||||
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]!
|
||||
|
||||
@@ -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<string, PendingRequest>()
|
||||
const streamListeners = new Map<string, StreamRequest>()
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<string, CommandHandler> = {
|
||||
open: async ({ client, json }) => {
|
||||
@@ -33,6 +36,83 @@ export const CORE_HANDLERS: Record<string, CommandHandler> = {
|
||||
})
|
||||
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<string, CommandHandler> = {
|
||||
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<void>): Promise<void> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 <command> [options]')
|
||||
expect(output).toContain('serve')
|
||||
expect(output).toContain('Run `orca relay <command> --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<void>((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<void>((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()])
|
||||
|
||||
+42
-5
@@ -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} <command> [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} <command> --help\` for command-specific usage.`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
@@ -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 <host>] [--port <port>] [--public-url <url>] [--state-path <path>] [--enrollment-token <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=<token>.',
|
||||
'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',
|
||||
|
||||
@@ -44,6 +44,20 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant {
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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) {
|
||||
|
||||
@@ -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<string, ((...args: unknown[]) => 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<string, string> }).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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof setTimeout> | null = null
|
||||
private dataSockets = new Map<string, WebSocket>()
|
||||
private dataAttachTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
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<string>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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() : ''
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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<typeof setup>) {
|
||||
return sharedKey
|
||||
}
|
||||
|
||||
function doRelayHandshake(ctx: ReturnType<typeof setup>) {
|
||||
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<ArrayBufferLike>[] = []
|
||||
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', () => {
|
||||
|
||||
@@ -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<typeof setTimeout> | 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<ArrayBufferLike>): 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<ArrayBufferLike>): 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<ArrayBufferLike>): 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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -159,7 +159,7 @@ async function authenticateMobileWs(pairingUrl: string): Promise<WebSocket> {
|
||||
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()
|
||||
})
|
||||
|
||||
+204
-74
@@ -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<WebSocket, string>()
|
||||
private runtimeWsClientIds = new Map<WebSocket, string>()
|
||||
private readonly binaryStreamHandlers = new Map<
|
||||
string,
|
||||
Map<number, (frame: TerminalStreamFrame) => 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
|
||||
}
|
||||
|
||||
@@ -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 }[]
|
||||
}>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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({
|
||||
<div className="rounded-lg border border-border/60 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Wifi className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Network Interface</span>
|
||||
<span className="text-sm font-medium">Direct pairing</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
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.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -80,7 +81,7 @@ export function MobileNetworkInterfaceSection({
|
||||
</div>
|
||||
<Button
|
||||
onClick={onGenerateQr}
|
||||
disabled={loading || !selectedAddress}
|
||||
disabled={disabled || loading || !selectedAddress}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
@@ -91,19 +92,19 @@ export function MobileNetworkInterfaceSection({
|
||||
) : (
|
||||
<QrCode className="size-3.5" />
|
||||
)}
|
||||
{hasQrCode ? 'Regenerate' : 'Generate QR Code'}
|
||||
{loading ? 'Generating…' : hasQrCode ? 'Regenerate' : 'Generate QR Code'}
|
||||
</Button>
|
||||
</div>
|
||||
<Accordion type="single" collapsible className="mt-4 border-t border-border/60 pt-2">
|
||||
<AccordionItem value="remote-pairing-guide">
|
||||
<AccordionTrigger className="py-2 text-xs">
|
||||
Connect outside your Wi-Fi with a tailnet
|
||||
Pair across networks with a tailnet
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 text-xs text-muted-foreground">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<ol className="list-decimal space-y-1 pl-4">
|
||||
<li>
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [pairingUrl, setPairingUrl] = useState<string | null>(null)
|
||||
const [pairingSource, setPairingSource] = useState<'direct' | 'relay' | null>(null)
|
||||
const [endpoint, setEndpoint] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [devices, setDevices] = useState<PairedDevice[]>([])
|
||||
@@ -52,6 +55,37 @@ export function MobilePane(): React.JSX.Element {
|
||||
const [selectedAddress, setSelectedAddress] = useState<string | undefined>(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<number | null>(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 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-border/60 py-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQrEnlarged(true)}
|
||||
className="group relative cursor-pointer rounded-lg border border-border/60 bg-white p-3"
|
||||
>
|
||||
<img src={qrDataUrl} alt="QR Code for mobile pairing" className="size-48" />
|
||||
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
{endpoint && <span className="text-muted-foreground font-mono text-xs">{endpoint}</span>}
|
||||
<p className="text-muted-foreground max-w-xs text-center text-xs">
|
||||
Scan this code with the Orca mobile app. Each code creates a unique device token.
|
||||
</p>
|
||||
{pairingUrl && (
|
||||
<div className="flex w-full max-w-lg flex-col gap-1.5 px-4">
|
||||
<div className="text-muted-foreground text-center text-xs">
|
||||
Or paste this code in the mobile app:
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void copyPairingCode()}
|
||||
className="font-mono text-[11px] leading-tight whitespace-normal break-all h-auto py-2 px-3"
|
||||
>
|
||||
<span className="flex-1 text-left">{pairingUrl}</span>
|
||||
{codeCopied ? (
|
||||
<Check className="ml-2 size-3.5 shrink-0 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="ml-2 size-3.5 shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<MobileNetworkInterfaceSection
|
||||
@@ -165,47 +261,34 @@ export function MobilePane(): React.JSX.Element {
|
||||
refreshingNetworkInterfaces={refreshingNetworkInterfaces}
|
||||
onRefreshNetworkInterfaces={() => 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 && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-border/60 py-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQrEnlarged(true)}
|
||||
className="group relative cursor-pointer rounded-lg border border-border/60 bg-white p-3"
|
||||
>
|
||||
<img src={qrDataUrl} alt="QR Code for mobile pairing" className="size-48" />
|
||||
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
{endpoint && <span className="text-muted-foreground font-mono text-xs">{endpoint}</span>}
|
||||
<p className="text-muted-foreground max-w-xs text-center text-xs">
|
||||
Scan this code with the Orca mobile app. Each code creates a unique device token.
|
||||
</p>
|
||||
{pairingUrl && (
|
||||
<div className="flex w-full max-w-lg flex-col gap-1.5 px-4">
|
||||
<div className="text-muted-foreground text-center text-xs">
|
||||
Or paste this code in the mobile app:
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void copyPairingCode()}
|
||||
className="font-mono text-[11px] leading-tight whitespace-normal break-all h-auto py-2 px-3"
|
||||
>
|
||||
<span className="flex-1 text-left">{pairingUrl}</span>
|
||||
{codeCopied ? (
|
||||
<Check className="ml-2 size-3.5 shrink-0 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="ml-2 size-3.5 shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<RuntimeRelaySettingsSection
|
||||
onMobilePairingGenerated={(result) => {
|
||||
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 */}
|
||||
<div>
|
||||
|
||||
@@ -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<string | null>(
|
||||
runtimePairingUrlCache.runtimePairingUrl
|
||||
)
|
||||
@@ -109,9 +111,13 @@ export function RuntimePairingUrlGenerator({
|
||||
const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
|
||||
const [revokingGrantId, setRevokingGrantId] = useState<string | null>(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<number | null>(null)
|
||||
|
||||
const loadRuntimeAccessGrants = useCallback(
|
||||
async (options: { showToastOnError?: boolean } = {}): Promise<void> => {
|
||||
@@ -175,6 +181,37 @@ export function RuntimePairingUrlGenerator({
|
||||
}
|
||||
}, [loadRuntimeAccessGrants])
|
||||
|
||||
const loadRelayConnectionStatus = useCallback(async (): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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.
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => void generateRuntimePairingUrl()}
|
||||
disabled={isGeneratingPairing}
|
||||
disabled={isGeneratingAnyPairing}
|
||||
>
|
||||
{isGeneratingPairing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
Generate Access Link
|
||||
{isGeneratingPairing ? 'Generating…' : 'Generate direct link'}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Why: wrap in span so the tooltip still surfaces while the
|
||||
relay button is disabled — Radix can't fire on a disabled
|
||||
button itself. */}
|
||||
<span tabIndex={relayConnected ? -1 : 0}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => void generateRelayRuntimePairingUrl()}
|
||||
disabled={isGeneratingAnyPairing || !relayConnected}
|
||||
>
|
||||
{isGeneratingRelayPairing ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<RadioTower />
|
||||
)}
|
||||
{isGeneratingRelayPairing ? 'Generating…' : 'Generate relay link'}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!relayConnected ? (
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Connect a relay in Mobile settings to share this server through it.
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<RuntimeRelayConfig>({ enabled: false, endpoint: '' })
|
||||
const [endpointInput, setEndpointInput] = useState('')
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [status, setStatus] = useState<RuntimeRelayStatus>({
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
// 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<void> => {
|
||||
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<void> => {
|
||||
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 (
|
||||
<div className="rounded-lg border border-border/60 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<RadioTower className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Self-hosted relay (fallback)</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full border px-2 py-0.5 text-[11px]',
|
||||
relayStatusToneClass(status)
|
||||
)}
|
||||
>
|
||||
{relayStatusLabel(status)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => void refreshRelayStatus({ showToastOnError: true })}
|
||||
disabled={refreshingStatus}
|
||||
aria-label="Refresh relay status"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<RefreshCw className={refreshingStatus ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Refresh relay status
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Label htmlFor="runtime-relay-endpoint">Relay endpoint</Label>
|
||||
<Input
|
||||
id="runtime-relay-endpoint"
|
||||
value={endpointInput}
|
||||
onChange={(event) => setEndpointInput(event.target.value)}
|
||||
placeholder="wss://relay.example.com/ws?enrollmentToken=..."
|
||||
disabled={relayConfigLocked}
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<SettingsSwitch
|
||||
checked={enabled}
|
||||
onChange={() => setEnabled((current) => !current)}
|
||||
ariaLabel="Enable self-hosted relay"
|
||||
disabled={relayConfigLocked}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasUnsavedChanges ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => void saveRelayConfig()}
|
||||
disabled={relayConfigLocked || !hasUnsavedChanges}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-3.5" />
|
||||
)}
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{status.error ? <p className="text-xs text-destructive">{status.error}</p> : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Why: wrap in span so the tooltip still surfaces while the
|
||||
button is disabled (Radix triggers can't fire on the disabled
|
||||
button itself). */}
|
||||
<span tabIndex={relayConnected ? -1 : 0}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => void generateRelayQr()}
|
||||
disabled={
|
||||
!relayConnected || generatingQr || mobilePairingBusy || hasUnsavedChanges
|
||||
}
|
||||
>
|
||||
{generatingQr ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<QrCode className="size-3.5" />
|
||||
)}
|
||||
{generatingQr ? 'Generating…' : 'Generate pairing QR'}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!relayConnected || hasUnsavedChanges ? (
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{hasUnsavedChanges
|
||||
? 'Save changes before generating a relay code.'
|
||||
: 'Connect to the relay to generate a relay code.'}
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste a relay URL with its enrollment token, save, then generate a code for the mobile
|
||||
app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -448,6 +448,20 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
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: [] }),
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
releaseBlob = resolve
|
||||
})
|
||||
class DelayedBlob extends Blob {
|
||||
override async arrayBuffer(): Promise<ArrayBuffer> {
|
||||
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()
|
||||
|
||||
@@ -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<WebRuntimeClient>()
|
||||
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<ArrayBufferLike>): 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<ArrayBufferLike>): 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<void> {
|
||||
if (this.state === 'connected') {
|
||||
return Promise.resolve()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<WebSocket> {
|
||||
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<string> {
|
||||
return new Promise((resolve) => {
|
||||
ws.once('message', (data) => {
|
||||
resolve(data.toString())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function waitForControlMessage(
|
||||
ws: WebSocket,
|
||||
type: string
|
||||
): Promise<Record<string, string>> {
|
||||
return new Promise((resolve) => {
|
||||
const onMessage = (data: RawData) => {
|
||||
const parsed = JSON.parse(data.toString()) as Record<string, string>
|
||||
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<Socket> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => {
|
||||
socket.once('close', () => resolve())
|
||||
})
|
||||
}
|
||||
|
||||
export function delay(ms: number): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve('timeout'), ms)
|
||||
})
|
||||
}
|
||||
@@ -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<RuntimeRelayServer> {
|
||||
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<WebSocket> {
|
||||
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() })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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<RuntimeRelayServer> {
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
type RelayHostState = {
|
||||
control: WebSocket | null
|
||||
pendingClients: Map<string, RelayConnectionState>
|
||||
serverDataSockets: Map<string, WebSocket>
|
||||
totalBufferedBytes: number
|
||||
forwardedBufferedBytes: number
|
||||
}
|
||||
|
||||
type RelayServerState = {
|
||||
hosts: Map<string, RelayHostState>
|
||||
hostTokenHashes: Map<string, string>
|
||||
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<number, { target: WebSocket; byteLength: number }>
|
||||
forwardedCloseHandlers: Map<WebSocket, () => void>
|
||||
nextForwardedFrameId: number
|
||||
attachTimer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
type RelayStateFile = {
|
||||
hostTokenHashes?: Record<string, string>
|
||||
}
|
||||
|
||||
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<RuntimeRelayServer> {
|
||||
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<void>((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<string, RelayHostState>, 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<string, string> {
|
||||
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<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function writeRelayBindings(statePath: string, bindings: Map<string, string>): 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<void> {
|
||||
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<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpServer.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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<void>((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<Uint8Array>
|
||||
}> {
|
||||
const serverKeyPair = generateKeyPair()
|
||||
let resolveBinary: (bytes: Uint8Array) => void = () => {}
|
||||
const nextBinary = new Promise<Uint8Array>((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<typeof vi.fn>
|
||||
}> {
|
||||
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<PairingOffer> {
|
||||
await new Promise<void>((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<ArrayBufferLike>,
|
||||
expectedSeq: number
|
||||
): Uint8Array<ArrayBufferLike> | null {
|
||||
const frame = decodeRelayBinaryFrame(plaintext)
|
||||
expect(frame?.seq).toBe(expectedSeq)
|
||||
return frame?.payload ?? null
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<TResult>(
|
||||
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<TResult>(
|
||||
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<TResult>(
|
||||
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<TResult>(
|
||||
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<TResult>(
|
||||
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<TResult>(
|
||||
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<TResult>(
|
||||
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<TResult>(
|
||||
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<TResult>(
|
||||
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<TResult>(
|
||||
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<TResult>(
|
||||
)
|
||||
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<ArrayBufferLike>,
|
||||
transportKind: RuntimeWebSocketTransportKind,
|
||||
relaySequencer: RelayFrameSequencer
|
||||
): Uint8Array<ArrayBufferLike> | null {
|
||||
if (transportKind !== 'relay') {
|
||||
return payload
|
||||
}
|
||||
const seq = relaySequencer.nextOutboundSeq()
|
||||
return seq === null ? null : encodeRelayBinaryFrame(seq, payload)
|
||||
}
|
||||
|
||||
function unwrapRelayBinaryFrame(
|
||||
plaintext: Uint8Array<ArrayBufferLike>,
|
||||
transportKind: RuntimeWebSocketTransportKind,
|
||||
relaySequencer: RelayFrameSequencer
|
||||
): Uint8Array<ArrayBufferLike> | null {
|
||||
if (transportKind !== 'relay') {
|
||||
return plaintext
|
||||
}
|
||||
const frame = decodeRelayBinaryFrame(plaintext)
|
||||
if (!frame || !relaySequencer.acceptInboundSeq(frame.seq)) {
|
||||
return null
|
||||
}
|
||||
return frame.payload
|
||||
}
|
||||
|
||||
@@ -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<string, PendingRequest<unknown>>()
|
||||
private readonly readyWaiters: ReadyWaiter[] = []
|
||||
private idleCloseTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(pairing: PairingOffer) {
|
||||
this.pairing = pairing
|
||||
this.transportKind = getRuntimeWebSocketTransportKind(pairing.endpoint)
|
||||
}
|
||||
|
||||
request<TResult>(
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const RUNTIME_RELAY_MAX_FRAME_BYTES = 1024 * 1024
|
||||
@@ -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<ArrayBufferLike>
|
||||
): 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<ArrayBufferLike>
|
||||
): { seq: number; payload: Uint8Array<ArrayBufferLike> } | 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user