Skip to content

Commit a6d4552

Browse files
authored
feat(server,sandbox): supervisor-initiated SSH connect and exec over gRPC-multiplexed relay (#867)
1 parent c960d48 commit a6d4552

41 files changed

Lines changed: 3960 additions & 1801 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

architecture/gateway-security.md

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -269,20 +269,28 @@ The gateway enforces two concurrent connection limits to bound the impact of cre
269269

270270
These limits are tracked in-memory and decremented when tunnels close. Exceeding either limit returns HTTP 429 (Too Many Requests).
271271

272-
### NSSH1 Handshake
272+
### Supervisor-Initiated Relay Model
273273

274-
After the gateway connects to the sandbox pod's SSH port, it performs a cryptographic handshake:
274+
The gateway never dials the sandbox. Instead, the sandbox supervisor opens an outbound `ConnectSupervisor` bidirectional gRPC stream to the gateway on startup and keeps it alive for the sandbox lifetime. SSH traffic for `/connect/ssh` (and exec traffic for `ExecSandbox`) rides this same TCP+TLS+HTTP/2 connection as separate multiplexed HTTP/2 streams. The gateway-side registry and `RelayStream` handler live in `crates/openshell-server/src/supervisor_session.rs`; the supervisor-side bridge lives in `crates/openshell-sandbox/src/supervisor_session.rs`.
275275

276-
```
277-
NSSH1 <token> <timestamp> <nonce> <hmac_signature>\n
278-
```
276+
Per-connection flow:
277+
278+
1. CLI presents `x-sandbox-id` + `x-sandbox-token` at `/connect/ssh` and passes gateway token validation.
279+
2. Gateway calls `SupervisorSessionRegistry::open_relay(sandbox_id, ...)`, which allocates a `channel_id` (UUID) and sends a `RelayOpen` message to the supervisor over the already-established `ConnectSupervisor` stream. If no session is registered yet, it polls with exponential backoff up to a bounded timeout (30 s for `/connect/ssh`, 15 s for `ExecSandbox`).
280+
3. The supervisor opens a new `RelayStream` RPC on the same `Channel` — a new HTTP/2 stream, no new TCP connection and no new TLS handshake. The first `RelayFrame` is a `RelayInit { channel_id }` that claims the pending slot on the gateway.
281+
4. `claim_relay` pairs the gateway-side waiter with the supervisor-side RPC via a `tokio::io::duplex(64 KiB)` pair. Subsequent `RelayFrame::data` frames carry raw SSH bytes in both directions. The supervisor is a dumb byte bridge: it has no protocol awareness of the SSH bytes flowing through.
282+
5. Inside the sandbox pod, the supervisor connects the relay to sshd over a Unix domain socket at `/run/openshell/ssh.sock` (see `crates/openshell-driver-kubernetes/src/main.rs`).
283+
284+
Security properties of this model:
279285

280-
- **HMAC**: `HMAC-SHA256(secret, "{token}|{timestamp}|{nonce}")`, hex-encoded.
281-
- **Secret**: shared via `OPENSHELL_SSH_HANDSHAKE_SECRET` env var, set on both the gateway and sandbox.
282-
- **Clock skew tolerance**: configurable via `OPENSHELL_SSH_HANDSHAKE_SKEW_SECS` (default 300 seconds).
283-
- **Expected response**: `OK\n` from the sandbox.
286+
- **One auth boundary.** mTLS on the `ConnectSupervisor` stream is the only identity check between gateway and sandbox. Every relay rides that same authenticated HTTP/2 connection.
287+
- **No inbound network path into the sandbox.** The sandbox exposes no TCP port for gateway ingress; all relays are supervisor-initiated. The pod only needs egress to the gateway.
288+
- **In-pod access control is filesystem permissions on the Unix socket.** sshd listens on `/run/openshell/ssh.sock` with the parent directory at `0700` and the socket itself at `0600`, both owned by the supervisor (root). The sandbox entrypoint runs as an unprivileged user and cannot open either. Any process in the supervisor's filesystem view that can open the socket can reach sshd — same trust model as any local Unix socket with `0600` permissions. See `crates/openshell-sandbox/src/ssh.rs:55-83`.
289+
- **Supersede race is closed.** A supervisor reconnect registers a new `session_id` for the same sandbox id. Cleanup on the old session's task uses `remove_if_current(sandbox_id, session_id)` so a late-finishing old task cannot evict the new registration or serve relays meant for the new instance. See `SupervisorSessionRegistry::remove_if_current` in `crates/openshell-server/src/supervisor_session.rs`.
290+
- **Pending-relay reaper.** A background task sweeps `pending_relays` entries older than 10 s (`RELAY_PENDING_TIMEOUT`). If the supervisor acknowledges `RelayOpen` but never initiates `RelayStream` — crash, deadlock, or adversarial stall — the gateway-side slot does not pin indefinitely.
291+
- **Client-side keepalives.** The CLI's `ssh` invocation sets `ServerAliveInterval=15` / `ServerAliveCountMax=3` (`crates/openshell-cli/src/ssh.rs:150`), so a silently-dropped relay (gateway restart, supervisor restart, or adversarial TCP drop) surfaces to the user within roughly 45 s rather than hanging.
284292

285-
This handshake prevents direct connections to sandbox SSH ports from within the cluster, even from pods that share the network.
293+
Observability (sandbox side, OCSF): `session_established`, `session_closed`, `session_failed`, `relay_open`, `relay_closed`, `relay_failed`, `relay_close_from_gateway` — all emitted as `NetworkActivity` events. Gateway-side OCSF emission for the same lifecycle is a tracked follow-up.
286294

287295
## Port Configuration
288296

@@ -325,8 +333,8 @@ graph LR
325333
CLI -- "mTLS<br/>(cluster CA)" --> TLS
326334
SDK -- "mTLS<br/>(cluster CA)" --> TLS
327335
TLS --> API
328-
SBX -- "mTLS<br/>(cluster CA)" --> TLS
329-
API -- "SSH + NSSH1<br/>handshake" --> SBX
336+
SBX -- "mTLS + ConnectSupervisor<br/>(supervisor-initiated)" --> TLS
337+
API -- "RelayStream<br/>(HTTP/2 on same mTLS conn)" --> SBX
330338
SBX -- "OPA policy +<br/>process identity" --> HOSTS
331339
```
332340

@@ -335,8 +343,9 @@ graph LR
335343
| Boundary | Mechanism |
336344
|---|---|
337345
| External → Gateway | mTLS with cluster CA by default, or trusted reverse-proxy/Cloudflare boundary in edge mode |
338-
| Sandbox → Gateway | mTLS with shared client cert |
339-
| Gateway → Sandbox (SSH) | Session token + HMAC-SHA256 handshake (NSSH1) |
346+
| Sandbox → Gateway | mTLS with shared client cert (supervisor-initiated `ConnectSupervisor` stream) |
347+
| Gateway → Sandbox (SSH/exec) | Rides the supervisor's mTLS `ConnectSupervisor` HTTP/2 connection as a `RelayStream` — no separate gateway-to-pod connection |
348+
| Supervisor → in-pod sshd | Unix-socket filesystem permissions (`/run/openshell/ssh.sock`, 0700 parent / 0600 socket) |
340349
| Sandbox → External (network) | OPA policy + process identity binding via `/proc` |
341350

342351
### What Is Not Authenticated (by Design)
@@ -387,8 +396,11 @@ This section defines the primary attacker profiles, what the current design prot
387396
|---|---|---|
388397
| MITM or passive interception of gateway traffic | Mandatory mTLS with cluster CA, or trusted reverse-proxy boundary in Cloudflare mode | Default mode is direct mTLS; reverse-proxy mode shifts the outer trust boundary upstream |
389398
| Unauthenticated API/health access | mTLS by default, or Cloudflare/reverse-proxy auth in edge mode | `/health*` are direct-mTLS only in the default deployment mode |
390-
| Forged SSH tunnel connection to sandbox | Session token validation + NSSH1 HMAC handshake | Requires token and shared handshake secret |
391-
| Direct access to sandbox SSH port from cluster peers | NSSH1 challenge-response | Connection denied without valid signature |
399+
| Forged SSH tunnel connection to sandbox | Session token validation at the gateway; only the supervisor's authenticated mTLS `ConnectSupervisor` stream can carry a `RelayStream` to its sandbox | Forging a relay requires stealing a valid mTLS client identity |
400+
| Direct access to sandbox sshd from cluster peers | sshd listens on a Unix socket (`0700` parent / `0600` socket) inside the pod | No network path exists to sshd from cluster peers |
401+
| Stale or reconnecting supervisor serves relays for a new instance | `session_id`-scoped `remove_if_current` on the registry | Old session cleanup cannot evict a newer registration |
402+
| Supervisor acknowledges `RelayOpen` but never initiates `RelayStream` | Gateway-side pending-relay reaper (10 s timeout) | Prevents indefinite resource pinning by a buggy or malicious supervisor |
403+
| Silent TCP drop of an in-flight relay | CLI `ServerAliveInterval=15` / `ServerAliveCountMax=3` | Client detects a dead relay within ~45 s instead of hanging |
392404
| Unauthorized outbound internet access from sandbox | OPA policy + process identity checks | Applies to sandbox egress policy layer |
393405

394406
### Residual Risks and Current Tradeoffs
@@ -414,7 +426,7 @@ This section defines the primary attacker profiles, what the current design prot
414426
- The cluster CA is generated and distributed without interception during bootstrap.
415427
- Kubernetes secret access is restricted to intended workloads and operators.
416428
- Gateway and sandbox container images are trusted and not tampered with.
417-
- System clocks are reasonably synchronized for timestamp-based SSH handshake checks.
429+
- The sandbox pod's filesystem is trusted: only the supervisor process (root) can open `/run/openshell/ssh.sock`, which is enforced by the `0700` parent directory and `0600` socket permissions set at sshd start.
418430

419431
## Sandbox Outbound TLS (L7 Inspection)
420432

0 commit comments

Comments
 (0)