Skip to content

feat(gateway): onecli-gateway relay mode (Phase 3) - #4

Open
marcorivm wants to merge 1 commit into
phase2/client-cert-issuancefrom
phase3/relay-mode
Open

feat(gateway): onecli-gateway relay mode (Phase 3)#4
marcorivm wants to merge 1 commit into
phase2/client-cert-issuancefrom
phase3/relay-mode

Conversation

@marcorivm

Copy link
Copy Markdown
Member

Phase 3 of remote-gateway hardening — onecli-gateway relay mode

Stacked on #3 (Phase 2) — base is phase2/client-cert-issuance. Review/merge #2 then #3 first; this diff shows only Phase 3.

What this PR does

Adds a relay subcommand to the gateway binary so local agents can reach a remote gateway. Agent HTTP clients can't reliably present a client cert to an HTTPS proxy, so agents keep pointing HTTPS_PROXY at a plain local proxy — the relay — which holds the client cert and carries their traffic to the gateway's Phase 1 mTLS listener.

The design (why it's small and safe)

The relay is a blind byte-splice. It accepts the agent's CONNECT / absolute-form request, opens one mTLS connection to the remote gateway presenting its client cert, and copy_bidirectionals bytes. It does not parse or rewrite the agent's CONNECT line or Proxy-Authorization — so the agent's aoc_ token reaches the gateway verbatim and the gateway authenticates + MITMs exactly as today. Both identities arrive together: the relay's host cert (from the mTLS handshake) and the agent's token (in the header) — which is exactly what Phase 5 will bind. The relay holds no MITM, DB, crypto, or policy.

Security properties (all with direct test evidence)

  • Never falls back to plaintext or direct egress. Every error path in the tunnel — dial fail, TLS handshake fail, dial timeout, expired cert — funnels through one refuse() (drain → 502 → close). Agent bytes leave the host only through the verified mTLS socket.
  • Always verifies the remote server cert from an out-of-band --gateway-server-ca (real rustls WebPKI roots). The accept-any verifier that exists for the WebSocket leg is not reused here.
  • Token + CONNECT preserved byte-for-byte — asserted on the literal bytes (Proxy-Authorization: Basic …) at a fake gateway.
  • Private key never leaves the relay — it generates its own keypair + CSR (CSR-based, Phase 2); only the CSR is sent. The key file is written O_CREAT mode 0600 atomically, hard-erroring on failure (no world-readable window).
  • Fail-closed startup + renewal: no cert / unreachable enrollment / missing-or-bad server CA → refuse to start; a bad renewed config is never swapped in; past-expiry with failing renewal → stop serving, never present an expired cert.
  • **bare onecli-gateway/--port Nstill runs the SERVER**, unchanged — therelay` subcommand is additive (regression-tested).

Config (relay)

--bind (default 127.0.0.1:10255), --gateway-addr, --gateway-server-name, --gateway-server-ca (required, PEM or path), --api-url, --api-key (oc_), --label, --state-dir — all with RELAY_* env fallbacks.

Testing

646 tests (28 in relay); cargo clippy --all-targets -- -D warnings + cargo fmt --check clean. Covers token-passthrough, all fail-closed paths (wrong CA → 502, expired → refuse, timeout → 502, enrollment unreachable → err), 0600 key atomicity + hard-error, renewal timing/backoff, and the clap default-arm regression. cargo build --features cloud unchanged (27 pre-existing ee/*.rs errors, none from this change).

Notes for reviewers

  • No gateway-side change needed — the relay rides the existing CONNECT dispatch on the mTLS listener.
  • Out of scope (later phases): nanoclaw wiring (Phase 4), cert↔token binding enforcement (Phase 5). Deferred: gateway-connection pooling, enroll-route rate-limiting.
  • Pipeline: Opus plan (protocol traced against real code) → Sonnet dev (found+fixed a real TCP-RST bug) → Opus security review (no blockers) → fixes → Sonnet QA (GO).

https://claude.ai/code/session_01BgJuqEJqf7ZiUdqHWxi6bt

Add a `relay` subcommand to the gateway binary: a plain HTTP-proxy →
mTLS byte-tunnel that lets local agents reach a REMOTE gateway. Agents
point HTTPS_PROXY at the relay (they can't reliably present a client
cert to an HTTPS proxy); the relay holds the client cert and carries
their traffic to the gateway's mTLS listener.

The relay is a BLIND BYTE-SPLICE: it accepts the agent's CONNECT /
absolute-form request, opens one mTLS connection to the remote gateway
presenting its client cert, and copy_bidirectionals bytes. It does NOT
parse or rewrite the agent's CONNECT line or Proxy-Authorization, so the
agent's aoc_ token reaches the gateway verbatim and the gateway
authenticates + MITMs exactly as today. Both identities arrive together:
the relay's host cert (mTLS handshake) and the agent's token (header).

- New relay.rs + relay/{enroll,renew,tunnel}.rs. No MITM, DB, crypto, or
  policy in the relay — it only reuses client_ca PEM loaders + shutdown.
- Enrollment: the relay generates its own keypair + CSR (private key
  never leaves it), enrolls via Phase 2 POST /v1/gateway/client-cert
  with an oc_ API key, and renews before expiry (ArcSwap hot-swap,
  reusing the stored hostId).
- SECURITY: always verifies the remote server cert via out-of-band
  --gateway-server-ca (never an accept-any verifier); fails closed on
  every error path (dial/handshake/timeout/expired-cert/unreachable
  enrollment → 502 + close, never direct egress or plaintext). Private
  key file written O_CREAT 0600 atomically, hard-error on failure.
- clap restructured so bare `onecli-gateway` / `--port N` still runs the
  SERVER unchanged; `relay` is an added subcommand.

Stacked on Phase 2 (cert issuance). 646 tests (28 relay), clippy
--all-targets -D warnings + fmt clean. cargo build --features cloud
unchanged (27 pre-existing ee/*.rs errors, none from this change).

Claude-Session: https://claude.ai/code/session_01BgJuqEJqf7ZiUdqHWxi6bt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant