Skip to content
Closed
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
80 changes: 63 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -855,33 +855,79 @@ In a production setup you'd load the rules in an entrypoint wrapper and then

For environments where you can't control the workload's DNS at all, nftables
TPROXY can redirect traffic at the kernel level without any cooperation from
the workload. This intercepts packets in the PREROUTING chain and hands them
directly to iron-proxy:
the workload. It intercepts packets in the `prerouting` chain and hands them
directly to iron-proxy, which recovers each connection's **original
destination** from the socket.

This is the difference between TPROXY and DNAT, and it matters: **DNAT rewrites
the destination away**, so a proxy behind it can never learn the real
`host:port` and you need one DNAT rule *per port*. TPROXY preserves the original
destination, so a **single rule covers every port**.

Enable the ingress with `transparent_listen`:

```yaml
proxy:
http_listen: "0.0.0.0:80"
https_listen: "0.0.0.0:443"
tunnel_listen: "0.0.0.0:8080"
# TPROXY ingress: one listener for every intercepted port. The listener is
# bound with IP_TRANSPARENT, so it needs CAP_NET_ADMIN at bind time.
transparent_listen: "0.0.0.0:5555"
```
table ip iron {

Then install the interception rules **in the proxy's own network namespace**:

```nft
table ip iron-tproxy {
chain prerouting {
type filter hook prerouting priority mangle; policy accept;

# redirect HTTP/HTTPS to iron-proxy via TPROXY
tcp dport 80 tproxy to 172.20.0.2:80 meta mark set 1 accept
tcp dport 443 tproxy to 172.20.0.2:443 meta mark set 1 accept
}

chain output {
type route hook output priority mangle; policy accept;
# Never intercept the proxy's own control plane or its LAN peers, or you
# intercept the proxy's own traffic and loop.
ip daddr { 10.10.10.0/24, 172.28.0.0/24, 127.0.0.0/8 } return

# mark locally-originated packets for policy routing
tcp dport { 80, 443 } meta mark set 1
# One rule, EVERY destination port. `tproxy` is a statement, not a target
# — `tproxy to :5555` (what we want), not `to-destination :5555`.
meta l4proto tcp tproxy to :5555 meta mark set 1 accept
}
}
```

This requires `ip rule` and `ip route` setup to route marked packets to a
local socket, plus iron-proxy must bind with `IP_TRANSPARENT`. This is more
complex to set up but provides the strongest guarantee that traffic can't
bypass the proxy. TPROXY operates below DNS, so it catches hardcoded IPs,
custom resolvers, and anything else the workload might try.
```bash
# Policy routing: send marked packets to a local socket instead of forwarding.
ip rule add fwmark 1 lookup 100
ip route add local 0.0.0.0/0 dev lo table 100
```

Notes that matter in practice:

- **The mark targets the intercepted flows, not the listener port.** Marking
`tcp dport 5555` marks nothing useful: by then the destination is already the
proxy.
- **The `output` chain is only needed when the workload shares the proxy's
namespace.** In the usual sidecar arrangement the workload is in a *different*
namespace, so its packets arrive in `prerouting`; the proxy's own outbound
connections are locally generated and traverse `output`, so they are not
captured and no explicit exclusion is required. If they *do* share a
namespace, mark in `output` **and** exclude the proxy's own egress (by UID or
by upstream address) or you get a redirect loop.
- **`CAP_NET_ADMIN` is needed to install the rules and to bind the listener**
with `IP_TRANSPARENT`. Both are setup-time; the accepted connections need no
capability. In a container the usual pattern is a privileged one-shot that
installs the rules and exits, with the proxy itself otherwise unprivileged.
- Connections are dispatched through the same transform pipeline as everything
else — a synthetic `CONNECT` evaluated against your allowlist and secrets
transforms — so transparency does not bypass default-deny. The destination is
an IP:port (there is no hostname to match), so allowlist by `cidrs`.

TPROXY operates below DNS, so it catches hardcoded IPs, custom resolvers, and
anything else the workload might try.

Protocol support is unchanged by the ingress: after the policy check the proxy
peeks the first byte, so TLS is MITM'd and plain HTTP is served. A protocol that
is neither (raw SSH, for example) is rejected — transparency broadens *which
ports* are intercepted, not which protocols can be carried.

## Docker Compose example

Expand Down
4 changes: 4 additions & 0 deletions cmd/iron-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ func main() {
HTTPAddr: cfg.Proxy.HTTPListen,
HTTPSAddr: cfg.Proxy.HTTPSListen,
TunnelAddr: cfg.Proxy.TunnelListen,
TransparentAddr: cfg.Proxy.TransparentListen,
TLSMode: cfg.TLS.Mode,
CertCache: certCache,
Pipeline: holder,
Expand Down Expand Up @@ -296,6 +297,9 @@ func main() {
if cfg.Proxy.TunnelListen != "" {
startAttrs = append(startAttrs, slog.String("tunnel_listen", cfg.Proxy.TunnelListen))
}
if cfg.Proxy.TransparentListen != "" {
startAttrs = append(startAttrs, slog.String("transparent_listen", cfg.Proxy.TransparentListen))
}
logger.Info("iron-proxy starting", startAttrs...)
if pipeline := holder.Load(); !pipeline.Empty() {
logger.Info("transform pipeline", slog.String("transforms", pipeline.Names()))
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ require (
go.opentelemetry.io/otel/sdk/log v0.20.0
golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.47.0
google.golang.org/grpc v1.83.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
Expand Down Expand Up @@ -129,7 +130,6 @@ require (
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.48.0 // indirect
Expand Down
16 changes: 13 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,19 @@ type DNSRecord struct {

// Proxy configures the HTTP/HTTPS listener addresses.
type Proxy struct {
HTTPListen string `yaml:"http_listen"`
HTTPSListen string `yaml:"https_listen"`
TunnelListen string `yaml:"tunnel_listen"`
HTTPListen string `yaml:"http_listen"`
HTTPSListen string `yaml:"https_listen"`
TunnelListen string `yaml:"tunnel_listen"`
// TransparentListen enables the TPROXY ingress: a listener bound with
// IP_TRANSPARENT that recovers each connection's ORIGINAL destination from
// the socket, so kernel-level TPROXY rules can hand it traffic for any
// destination port without per-port NAT. Unlike TunnelListen there is no
// client handshake — the destination comes from the socket, not a CONNECT
// line or SOCKS5 request. Connections are dispatched through the normal
// transform pipeline (default-deny allowlist), then MITM'd or served as
// plain HTTP. Requires NET_ADMIN on a short-lived setup step plus the
// nftables/ip-rule wiring; the listener itself needs no capability.
TransparentListen string `yaml:"transparent_listen"`
MaxRequestBodyBytes int64 `yaml:"max_request_body_bytes"`
MaxResponseBodyBytes int64 `yaml:"max_response_body_bytes"`
// UpstreamResponseHeaderTimeout caps how long the proxy waits for an
Expand Down
3 changes: 3 additions & 0 deletions internal/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ func applyEnvOverrides(cfg *Config) error {
if v := os.Getenv("IRON_PROXY_TUNNEL_LISTEN"); v != "" {
cfg.Proxy.TunnelListen = v
}
if v := os.Getenv("IRON_PROXY_TRANSPARENT_LISTEN"); v != "" {
cfg.Proxy.TransparentListen = v
}
if v := os.Getenv("IRON_TLS_MODE"); v != "" {
cfg.TLS.Mode = v
}
Expand Down
42 changes: 32 additions & 10 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ type Proxy struct {
tunnelAddr string
tunnelListener net.Listener
tunnelDone chan struct{}
transparentAddr string
transparentListener net.Listener
transparentDone chan struct{}
certCache *certcache.Cache
pipeline *transform.PipelineHolder
transport *http.Transport
Expand Down Expand Up @@ -72,13 +75,15 @@ type Options struct {
HTTPAddr string
HTTPSAddr string
TunnelAddr string
TLSMode string
CertCache *certcache.Cache // required when TLSMode == config.TLSModeMITM
Pipeline *transform.PipelineHolder
Resolver *net.Resolver
Guard *dnsguard.Guard // nil is treated as an empty (no-op) guard
MCPPolicy *mcp.PolicyHolder // optional MCP-aware policy interceptor; nil disables MCP handling
MCPGateway *mcpgateway.Holder
// TransparentAddr enables the TPROXY ingress (see config.Proxy.TransparentListen).
TransparentAddr string
TLSMode string
CertCache *certcache.Cache // required when TLSMode == config.TLSModeMITM
Pipeline *transform.PipelineHolder
Resolver *net.Resolver
Guard *dnsguard.Guard // nil is treated as an empty (no-op) guard
MCPPolicy *mcp.PolicyHolder // optional MCP-aware policy interceptor; nil disables MCP handling
MCPGateway *mcpgateway.Holder
// ResponseRetryHandler may add headers and replay the exact transformed
// request once after selected upstream response statuses.
ResponseRetryHandler *responseretry.Handler
Expand Down Expand Up @@ -116,6 +121,8 @@ func New(opts Options) *Proxy {
tlsMode: opts.TLSMode,
tunnelAddr: opts.TunnelAddr,
tunnelDone: make(chan struct{}),
transparentAddr: opts.TransparentAddr,
transparentDone: make(chan struct{}),
certCache: opts.CertCache,
pipeline: opts.Pipeline,
transport: buildTransport(opts.Resolver, guard, opts.UpstreamResponseHeaderTimeout, opts.UpstreamProxy),
Expand Down Expand Up @@ -148,12 +155,15 @@ func New(opts Options) *Proxy {
return p
}

// ListenAndServe starts the HTTP, HTTPS, and (optionally) tunnel listeners.
// It blocks until any server has stopped.
// ListenAndServe starts the HTTP, HTTPS, and (optionally) tunnel and
// transparent (TPROXY) listeners. It blocks until any server has stopped.
func (p *Proxy) ListenAndServe() error {
n := 2
if p.tunnelAddr != "" {
n = 3
n++
}
if p.transparentAddr != "" {
n++
}
errc := make(chan error, n)

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

if p.transparentAddr != "" {
go func() {
errc <- fmt.Errorf("transparent: %w", p.listenTransparent())
}()
}

return <-errc
}

Expand Down Expand Up @@ -243,6 +259,12 @@ func (p *Proxy) Shutdown(ctx context.Context) error {
p.tunnelListener.Close()
}

// Same for the transparent (TPROXY) accept loop.
close(p.transparentDone)
if p.transparentListener != nil {
p.transparentListener.Close()
}

if errHTTP != nil {
return errHTTP
}
Expand Down
127 changes: 127 additions & 0 deletions internal/proxy/transparent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package proxy

import (
"context"
"fmt"
"log/slog"
"net"
"syscall"

"golang.org/x/sys/unix"
)

// listenTransparent starts the TPROXY ingress.
//
// The listener is bound with IP_TRANSPARENT so nftables/policy-routing can hand
// it connections addressed to *any* destination, and each accepted socket
// carries the connection's ORIGINAL destination as its local address. That is
// the whole point of TPROXY over DNAT: DNAT rewrites the destination away, so a
// proxy behind it can never learn the real host:port; TPROXY preserves it. This
// lets one kernel rule intercept every TCP port instead of one DNAT per port.
//
// There is no client handshake here — unlike the tunnel listener, the client is
// not speaking proxy protocol, it believes it is talking to the destination.
// So the destination comes off the socket, and the connection is dispatched
// through the same transform pipeline (default-deny allowlist) and the same
// MITM/plain-HTTP handling as a tunnelled connection.
func (p *Proxy) listenTransparent() error {
lc := net.ListenConfig{Control: transparentControl}
ln, err := lc.Listen(context.Background(), "tcp", p.transparentAddr)
if err != nil {
return fmt.Errorf("transparent listen: %w", err)
}
p.transparentListener = ln
p.logger.Info("transparent (tproxy) proxy starting", slog.String("addr", ln.Addr().String()))

for {
conn, err := ln.Accept()
if err != nil {
select {
case <-p.transparentDone:
return nil
default:
}
p.logger.Warn("transparent accept error", slog.String("error", err.Error()))
continue
}
go p.handleTransparent(conn)
}
}

// transparentControl sets IP_TRANSPARENT on the listening socket.
//
// This is setup-only privilege: it is the socket option that makes the kernel
// willing to deliver non-local destinations to this listener. Accepted sockets
// inherit it, and serving them (read/write) needs no capability at all, so the
// process can drop NET_ADMIN after the listener is bound. Failure is fatal for
// this listener only — it means the TPROXY rules would silently never match, so
// we refuse to pretend the ingress is up.
func transparentControl(network, address string, c syscall.RawConn) error {
var serr error
if err := c.Control(func(fd uintptr) {
serr = unix.SetsockoptInt(int(fd), unix.SOL_IP, unix.IP_TRANSPARENT, 1)
}); err != nil {
return err
}
return serr
}

// handleTransparent serves one transparently-intercepted connection.
func (p *Proxy) handleTransparent(conn net.Conn) {
defer conn.Close()

target, err := transparentTarget(conn)
if err != nil {
p.logger.Warn("transparent connection has no original destination",
slog.String("remote", conn.RemoteAddr().String()),
slog.String("error", err.Error()),
)
return
}

p.logger.Debug("transparent connection", slog.String("target", target))

// Same policy gate as the tunnel listener: a synthetic CONNECT evaluated
// against the allowlist and secrets transforms, so transparent traffic is
// subject to the same default-deny posture (and emits the same audit
// entry) as anything else.
ok, rejectResp, tunnelInfo := p.tunnelTransformCheck(conn.RemoteAddr().String(), target, nil)
if !ok {
status := 0
if rejectResp != nil {
status = rejectResp.StatusCode
}
p.logger.Info("transparent connection denied by transform",
slog.String("target", target),
slog.Int("status", status),
)
return
}

if err := p.serveTunnel(conn, target, tunnelInfo); err != nil {
p.logger.Debug("transparent serve error",
slog.String("target", target),
slog.String("error", err.Error()),
)
}
}

// transparentTarget recovers the destination the client originally addressed.
//
// With TPROXY the kernel assigns the original destination as the accepted
// socket's local address, so it is read back from the socket rather than from a
// client-supplied CONNECT line or SOCKS5 request. An unspecified address means
// the socket never received a transparent destination — the usual cause is a
// missing/incorrect TPROXY rule or policy route, so it is reported loudly
// instead of being served as a bogus 0.0.0.0 target.
func transparentTarget(conn net.Conn) (string, error) {
local := conn.LocalAddr()
addr, ok := local.(*net.TCPAddr)
if !ok {
return "", fmt.Errorf("unexpected local address type %T", local)
}
if addr.IP == nil || addr.IP.IsUnspecified() {
return "", fmt.Errorf("original destination not preserved on socket (local address %s): check the TPROXY rule and policy route", addr.String())
}
return addr.String(), nil
}
Loading