Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,11 @@ proxy:
http_listen: ":80"
https_listen: ":443"
tunnel_listen: ":8080" # Optional CONNECT/SOCKS5 listener
reverse: # Optional mTLS listener for clients that name the upstream in the path
listen: ":8444"
cert: "/etc/iron-proxy/server.crt"
key: "/etc/iron-proxy/server.key"
client_ca: "/etc/iron-proxy/clients.crt"
max_request_body_bytes: 1048576 # 1 MiB (default)
max_response_body_bytes: 0 # uncapped (default)

Expand Down Expand Up @@ -766,6 +771,56 @@ export ALL_PROXY=socks5h://172.20.0.2:8080
The SOCKS5 implementation supports no-auth only and accepts IPv4, IPv6,
and domain name address types.

### Reverse listener (mTLS)

Some clients can neither trust iron-proxy's CA nor route through its DNS or a
`HTTPS_PROXY`: a Cloudflare Worker's `fetch` only trusts public certificate
authorities and offers no proxy setting. The reverse listener serves them
under iron-proxy's own name. The client names the upstream host in the first
path segment, and iron-proxy sends the request there over TLS through the same
transform pipeline:

```
https://proxy.example.com/api.anthropic.com/v1/messages -> https://api.anthropic.com/v1/messages
```

Anyone who can send it a request gets the credentials your transforms inject,
so the listener requires a client certificate issued by `client_ca`. `cert`
and `key` are iron-proxy's own certificate, which clients must trust, such as a
public certificate for the proxy's hostname:

```yaml
proxy:
reverse:
listen: ":8444"
cert: "/etc/iron-proxy/server.crt"
key: "/etc/iron-proxy/server.key"
client_ca: "/etc/iron-proxy/clients.crt"
```

The allowlist, secrets and every other transform see the upstream host, path
and query, exactly as for requests on the other listeners. WebSocket upgrades
are relayed to `wss://` upstreams. Paths with dot segments and a first segment
that is not a hostname or IP address, with an optional port, are rejected with
400. The listener removes `Cf-*` and `Cdn-Loop` request headers: Cloudflare
adds `CF-Worker` to every Worker subrequest, and some upstreams refuse
requests that carry it. iron-proxy reads `cert` and `key` at startup, so
restart it after renewing the certificate.

A Worker presents its client certificate through an `mtls_certificates`
binding. Upload the certificate and key with
`wrangler mtls-certificate upload --cert client.crt --key client.key`, bind
the returned ID in `wrangler.jsonc`, and send requests with the binding's
`fetch`:

```jsonc
"mtls_certificates": [{ "binding": "IRON_PROXY", "certificate_id": "<id>" }]
```

```js
await env.IRON_PROXY.fetch('https://proxy.example.com/api.anthropic.com/v1/messages', init)
```

### TLS

iron-proxy generates leaf certificates on the fly, signed by the CA you provide.
Expand Down
17 changes: 16 additions & 1 deletion cmd/iron-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main

import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"flag"
Expand Down Expand Up @@ -228,6 +229,15 @@ func main() {
logger.Info("response retry handler enabled", slog.Any("statuses", responseRetryStatuses))
}

var reverseTLS *tls.Config
if r := cfg.Proxy.Reverse; r.Listen != "" {
reverseTLS, err = proxy.ReverseTLSConfig(r.Cert, r.Key, r.ClientCA)
if err != nil {
logger.Error("initializing reverse listener", slog.String("error", err.Error()))
os.Exit(1)
}
}

// Initialize proxy.
p := proxy.New(proxy.Options{
HTTPAddr: cfg.Proxy.HTTPListen,
Expand All @@ -247,7 +257,9 @@ func main() {
// Managed proxies fail closed until the first control-plane config
// has been applied; an un-synced pipeline would otherwise pass
// requests through with placeholder credentials intact.
Ready: managedReady(poller),
Ready: managedReady(poller),
ReverseAddr: cfg.Proxy.Reverse.Listen,
ReverseTLS: reverseTLS,
})

// Initialize metrics server.
Expand Down Expand Up @@ -296,6 +308,9 @@ func main() {
if cfg.Proxy.TunnelListen != "" {
startAttrs = append(startAttrs, slog.String("tunnel_listen", cfg.Proxy.TunnelListen))
}
if cfg.Proxy.Reverse.Listen != "" {
startAttrs = append(startAttrs, slog.String("reverse_listen", cfg.Proxy.Reverse.Listen))
}
logger.Info("iron-proxy starting", startAttrs...)
if pipeline := holder.Load(); !pipeline.Empty() {
logger.Info("transform pipeline", slog.String("transforms", pipeline.Names()))
Expand Down
23 changes: 23 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,20 @@ type Proxy struct {
// upstream SOCKS5/HTTP CONNECT proxy. The standard HTTP_PROXY/HTTPS_PROXY/
// NO_PROXY environment variables override these fields when set.
UpstreamProxy UpstreamProxy `yaml:"upstream_proxy"`
// Reverse configures the reverse listener, for clients that can neither
// trust the MITM CA nor route through the proxy's DNS.
Reverse Reverse `yaml:"reverse"`
}

// Reverse configures the reverse listener. Clients reach it under the proxy's
// own name, authenticate with a certificate issued by ClientCA, and name the
// upstream host in the first path segment: https://<proxy>/<host>/<path> is
// sent to https://<host>/<path>.
type Reverse struct {
Listen string `yaml:"listen"`
Cert string `yaml:"cert"`
Key string `yaml:"key"`
ClientCA string `yaml:"client_ca"`
}

// CIDRList is a list of CIDR strings whose presence in YAML is distinguishable
Expand Down Expand Up @@ -316,6 +330,15 @@ func Validate(cfg *Config) error {
return fmt.Errorf("proxy.upstream_deny_cidrs: %w", err)
}

if r := cfg.Proxy.Reverse; r.Listen != "" {
if r.Cert == "" || r.Key == "" {
return fmt.Errorf("proxy.reverse.cert and proxy.reverse.key are required when proxy.reverse.listen is set")
}
if r.ClientCA == "" {
return fmt.Errorf("proxy.reverse.client_ca is required when proxy.reverse.listen is set: the listener only serves clients with a certificate")
}
}

if cfg.Management.Listen != "" {
if cfg.Management.APIKeyEnv == "" {
return fmt.Errorf("management.api_key_env is required when management.listen is set")
Expand Down
31 changes: 31 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,37 @@ tls:
`,
wantErr: "tls.ca_key is required",
},
{
name: "reverse listener without a certificate",
yaml: `
dns:
proxy_ip: "10.0.0.1"
tls:
ca_cert: "/tmp/ca.crt"
ca_key: "/tmp/ca.key"
proxy:
reverse:
listen: ":8444"
client_ca: "/tmp/clients.crt"
`,
wantErr: "proxy.reverse.cert and proxy.reverse.key are required",
},
{
name: "reverse listener without client_ca",
yaml: `
dns:
proxy_ip: "10.0.0.1"
tls:
ca_cert: "/tmp/ca.crt"
ca_key: "/tmp/ca.key"
proxy:
reverse:
listen: ":8444"
cert: "/tmp/server.crt"
key: "/tmp/server.key"
`,
wantErr: "proxy.reverse.client_ca is required",
},
{
name: "invalid log level",
yaml: `
Expand Down
72 changes: 60 additions & 12 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type Proxy struct {
tunnelAddr string
tunnelListener net.Listener
tunnelDone chan struct{}
reverseServer *http.Server
certCache *certcache.Cache
pipeline *transform.PipelineHolder
transport *http.Transport
Expand Down Expand Up @@ -96,6 +97,11 @@ type Options struct {
// requests can never pass through un-transformed (leaking placeholder
// credentials upstream) during startup.
Ready func() bool

// ReverseAddr, when set, serves the reverse listener with ReverseTLS,
// which must require client certificates (see ReverseTLSConfig).
ReverseAddr string
ReverseTLS *tls.Config
}

// New creates a new Proxy. In TLSModeMITM, certCache must be non-nil. In
Expand Down Expand Up @@ -144,6 +150,15 @@ func New(opts Options) *Proxy {
// negotiated conns through the existing handler); zero config never errors.
_ = http2.ConfigureServer(p.httpsServer, &http2.Server{})

if opts.ReverseAddr != "" {
p.reverseServer = &http.Server{
Addr: opts.ReverseAddr,
Handler: http.HandlerFunc(p.handleReverse),
TLSConfig: opts.ReverseTLS,
}
_ = http2.ConfigureServer(p.reverseServer, &http2.Server{}) // Zero config never errors.
}

return p
}

Expand All @@ -152,7 +167,10 @@ func New(opts Options) *Proxy {
func (p *Proxy) ListenAndServe() error {
n := 2
if p.tunnelAddr != "" {
n = 3
n++
}
if p.reverseServer != nil {
n++
}
errc := make(chan error, n)

Expand Down Expand Up @@ -180,6 +198,12 @@ func (p *Proxy) ListenAndServe() error {
}()
}

if p.reverseServer != nil {
go func() {
errc <- p.serveReverse()
}()
}

return <-errc
}

Expand Down Expand Up @@ -227,6 +251,11 @@ func (p *Proxy) Shutdown(ctx context.Context) error {

errHTTP := p.httpServer.Shutdown(ctx)

var errReverse error
if p.reverseServer != nil {
errReverse = p.reverseServer.Shutdown(ctx)
}

var errHTTPS error
if p.tlsMode == config.TLSModeSNIOnly {
if p.tlsListener != nil {
Expand All @@ -245,7 +274,10 @@ func (p *Proxy) Shutdown(ctx context.Context) error {
if errHTTP != nil {
return errHTTP
}
return errHTTPS
if errHTTPS != nil {
return errHTTPS
}
return errReverse
}

func (p *Proxy) getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
Expand Down Expand Up @@ -324,30 +356,36 @@ func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request, tunnelInfo *t
}

// Validate SNI matches Host header on TLS connections
sni := ""
if r.TLS != nil {
sni = r.TLS.ServerName
hostOnly := r.Host
if h, _, err := net.SplitHostPort(hostOnly); err == nil {
hostOnly = h
}
if r.TLS.ServerName != hostOnly {
if sni != hostOnly {
p.logger.Warn("SNI/Host mismatch",
slog.String("sni", r.TLS.ServerName),
slog.String("sni", sni),
slog.String("host", r.Host),
)
http.Error(w, "SNI and Host header mismatch", http.StatusBadRequest)
return
}
}

p.proxyRequest(w, r, tunnelInfo, scheme, host, sni)
}

// proxyRequest runs a request that the listener has already validated through
// the transform pipeline and sends it to scheme://host.
func (p *Proxy) proxyRequest(w http.ResponseWriter, r *http.Request, tunnelInfo *transform.TunnelInfo, scheme, host, sni string) {
// Clone tunnelInfo so a transform that mutates the annotations map can't
// leak state into sibling requests that share the same tunnel.
tctx := &transform.TransformContext{
Logger: p.logger,
Mode: transform.ModeMITM,
Tunnel: cloneTunnelInfo(tunnelInfo),
}
if r.TLS != nil {
tctx.SNI = r.TLS.ServerName
SNI: sni,
}

result := &transform.PipelineResult{
Expand Down Expand Up @@ -773,11 +811,7 @@ func (p *Proxy) handleWebSocket(w http.ResponseWriter, r *http.Request, scheme,
Control: p.guard.DialControl,
}
if upstreamScheme == "wss" {
upstreamConn, err = tls.DialWithDialer(
dialer,
"tcp", upstreamHost,
&tls.Config{MinVersion: tls.VersionTLS12},
)
upstreamConn, err = tls.DialWithDialer(dialer, "tcp", upstreamHost, p.websocketTLSConfig())
} else {
upstreamConn, err = dialer.DialContext(r.Context(), "tcp", upstreamHost)
}
Expand Down Expand Up @@ -932,6 +966,20 @@ func buildTransport(resolver *net.Resolver, guard *dnsguard.Guard, responseHeade
}
}

// websocketTLSConfig is the TLS configuration of upstream WebSocket
// connections: the upstream transport's, so WebSockets verify upstreams as
// HTTP requests do, offering only HTTP/1.1. The transport adds h2 to its
// NextProtos once it has served a request, and an upstream that negotiates h2
// would not understand the HTTP/1.1 upgrade the proxy writes.
func (p *Proxy) websocketTLSConfig() *tls.Config {
cfg := &tls.Config{MinVersion: tls.VersionTLS12}
if p.transport.TLSClientConfig != nil {
cfg = p.transport.TLSClientConfig.Clone()
}
cfg.NextProtos = []string{"http/1.1"}
return cfg
}

func (p *Proxy) doUpstream(req *http.Request) (*http.Response, error) {
return p.transport.RoundTrip(req)
}
Expand Down
Loading
Loading