Skip to content

feat(proxy): add an mTLS reverse listener for clients that cannot trust the MITM CA - #294

Open
gakonst wants to merge 2 commits into
mainfrom
reverse-listener
Open

gakonst wants to merge 2 commits into
mainfrom
reverse-listener

Conversation

@gakonst

@gakonst gakonst commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Adds an opt-in reverse listener. It's for clients that can neither trust iron-proxy's MITM CA nor route through its DNS or HTTPS_PROXY. The motivating case is a Cloudflare Worker, whose fetch only trusts public CAs and has no proxy setting. Today such a client can only send plain HTTP, which iron-proxy forwards upstream as plain HTTP. The tunnel listener also refuses absolute-form https:// requests.

proxy:
  reverse:
    listen: ":8444"
    cert: "/etc/iron-proxy/server.crt"   # iron-proxy's own certificate, e.g. public for its hostname
    key: "/etc/iron-proxy/server.key"
    client_ca: "/etc/iron-proxy/clients.crt"

The client reaches iron-proxy under its own name and names the upstream in the first path segment. For example, https://proxy.example.com/api.anthropic.com/v1/messages goes to https://api.anthropic.com/v1/messages.

  • Client certificates are required. The listener uses tls.RequireAndVerifyClientCert against client_ca, and config validation refuses a reverse listener without one. Anyone who can send it a request gets the credentials the transforms inject, so this is the access control. A Worker presents its certificate through an mtls_certificates binding.
  • The pipeline runs unchanged. handleHTTP is split into the listener-specific checks and proxyRequest. The reverse handler rewrites Host, path and RawPath, then calls proxyRequest with scheme https. The allowlist, secrets, header allowlist, MCP policy and audit all see the upstream host, path and query, as on the other listeners. The upstream deny CIDRs still apply at dial time.
  • Bad paths are rejected with 400. That covers a first segment that isn't a hostname or IP with an optional port, so empty, percent-encoded or userinfo segments and out-of-range ports. It also covers request-targets that parse as an authority (//host/...) and dot segments after the host.
  • Cloudflare hop headers are removed. Cf-* and Cdn-Loop are dropped. Cloudflare adds CF-Worker to every Worker subrequest, and chatgpt.com answers requests that carry it with 403.
  • WebSockets are relayed to wss:// upstreams. WebSocket dials now use the upstream transport's TLS config, through websocketTLSConfig, so they verify upstreams as HTTP requests do, offering only http/1.1 in ALPN. http.Transport adds h2 to its NextProtos once it has served a request. The first version of this PR cloned that config, so chatgpt.com negotiated h2 and the client got HTTP/2 frames in reply to its HTTP/1.1 upgrade. The second commit fixes that, and TestReverse_WebSocketToTLSUpstream now checks the negotiated protocol; it failed with h2 before the fix.
  • Certificates load at startup. The README says to restart after renewing them.

The README documents the listener, including the Worker binding, and so does iron-proxy.example.yaml.

Tests. internal/proxy/reverse_test.go covers:

  • forwarding to the named upstream, keeping path and query, applying transforms and stripping Cf-* and Cdn-Loop;
  • refusing a missing client certificate or one from another CA;
  • 400 for bad paths;
  • 403 from a rejecting transform;
  • a WebSocket to a TLS upstream that offers h2, negotiating http/1.1.

internal/config has validation cases for a missing cert/key and a missing client_ca. go test ./... (excluding integration_test), go test -race for the touched packages, go vet ./... and staticcheck all pass.

End to end, 2026-10-28. This branch ran on a GCP VM under systemd with a Let's Encrypt certificate for its sslip.io name and a client CA of our own. The allowlist was limited to the ChatGPT and Claude model endpoints, and the secrets transform injected:

  • a ChatGPT plan's access token, read from a file that the last iron-token-broker (built from 064f6cb) keeps refreshed;
  • the plan's account ID;
  • a Claude plan's claude setup-token token.

A Cloudflare Worker running nanocode-ts presented its client certificate through an mtls_certificates binding. It held no credentials. It ran real agent turns that computed a value in a Code Mode cell:

  • Claude Haiku 5.5 over HTTPS completed in 4 s.
  • GPT-6 Luna over the Codex WebSocket (responses_websocket_v2) completed in 6 s.

The audit log shows each request allowed, with the secrets transform applied, and internet scanners refused at the handshake with client didn't provide a certificate. Before the second commit, the Worker's WebSocket got Cloudflare's 520, and Node's ws reported Parse Error: Expected HTTP/.

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