Skip to content

feat(proxy): add a TPROXY transparent ingress (transparent_listen) - #268

Closed
Ethnical wants to merge 1 commit into
paradigmxyz:mainfrom
Ethnical:feat/transparent-tproxy-ingress
Closed

Ethnical wants to merge 1 commit into
paradigmxyz:mainfrom
Ethnical:feat/transparent-tproxy-ingress

Conversation

@Ethnical

@Ethnical Ethnical commented Oct 1, 2026 •

Copy link
Copy Markdown

Summary

Adds proxy.transparent_listen: a TPROXY ingress that recovers each connection's original destination from the socket, so one kernel rule can intercept every destination port instead of one DNAT per port.

The README already documented TPROXY and said the proxy "must bind with IP_TRANSPARENT" — but nothing in the code did. There was no transparent listener, no IP_TRANSPARENT, and no original-destination recovery; the only listeners were http_listen / https_listen / tunnel_listen, so transparency was limited to 80/443 via captive DNS + SNI. This implements what the README describes.

Why TPROXY and not DNAT

DNAT rewrites the destination away, so a proxy behind it can never learn the real host:port — which is exactly why intercepting arbitrary ports behind DNAT needs a rule per port. TPROXY preserves the original destination (it is published as the accepted socket's local address), so a single rule covers every port. That is the whole point of this ingress.

How it works

proxy:
  transparent_listen: "0.0.0.0:5555"   # optional; disabled when omitted
table ip iron-tproxy {
  chain prerouting {
    type filter hook prerouting priority mangle; policy accept;
    ip daddr { 10.10.10.0/24, 172.28.0.0/24, 127.0.0.0/8 } return
    meta l4proto tcp tproxy to :5555 meta mark set 1 accept
  }
}
ip rule add fwmark 1 lookup 100
ip route add local 0.0.0.0/0 dev lo table 100

Design points:

  • No client handshake. The client believes it is talking to the destination, so the destination is read off the socket (conn.LocalAddr), never from a CONNECT line or SOCKS5 request. An unspecified address means the TPROXY rule or policy route is missing, so the connection is refused and logged rather than served as 0.0.0.0.
  • Policy is not bypassed. The connection runs through the same synthetic-CONNECT transform check as the tunnel listener — default-deny allowlist, same audit entry — and then the same first-byte dispatch (TLS → MITM, plain HTTP → transform pipeline, anything else → rejected).
  • IP_TRANSPARENT is setup-only privilege. It is needed to bind the listener, not to serve it. The nft/policy-route wiring is the operator's job and is documented in the README.

Tests

internal/proxy/transparent_test.go covers destination recovery (IPv4, IPv6, unspecified, non-TCP), plain HTTP, TLS MITM, transform rejection, and the missing-destination refusal.

The harness fakes TPROXY delivery (a listener whose accepted conns report a chosen local address) so the tests are hermetic — real TPROXY needs NET_ADMIN and kernel policy routing, which a unit test must not require.

Locally, against go1.26.8:

go build ./...                          clean
go vet ./...                            clean
gofmt -l .                              clean
go test -race -count=1 ./...            exit 0, 32 packages ok (CI's exact test command)
go mod tidy                             moves golang.org/x/sys to a direct require (already in the graph; no new module)

Not covered

  • TCP only (the tproxy statement here is TCP; UDP would need its own handling).
  • IPv4 only in the example ruleset (table ip); an inet table would extend it.
  • 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 still rejected. This broadens which ports are intercepted, not which protocols are carried — a raw passthrough remains a separate feature.
  • Allowlisting is by IP/cidrs for transparent traffic, since there is no hostname to match.

The README's TPROXY section is updated to document the key and a recipe that works for arbitrary ports: the previous snippet only covered 80/443, and marked the listener port rather than the intercepted flows.

The README documented TPROXY and stated the proxy "must bind with
IP_TRANSPARENT", but nothing in the code did: there was no transparent
listener, no IP_TRANSPARENT, and no original-destination recovery. The only
listeners were http_listen/https_listen/tunnel_listen, so transparency was
limited to 80/443 via captive DNS + SNI.

Add proxy.transparent_listen: a listener bound with IP_TRANSPARENT that
recovers each connection's ORIGINAL destination from the accepted socket.

Why this matters: TPROXY preserves the original destination, while DNAT
rewrites it away. Behind DNAT a proxy can never learn the real host:port, so
intercepting arbitrary ports requires one DNAT rule per port. With TPROXY a
single nftables rule covers every port, which is what makes "intercept all
egress without opening every port" possible.

Design notes:

  - The destination comes off the socket (conn.LocalAddr), never from a
    client-supplied CONNECT line or SOCKS5 request: the client believes it is
    talking to the destination. An unspecified address means the TPROXY rule
    or policy route is missing, so the connection is refused loudly rather
    than served as 0.0.0.0.
  - Policy is not bypassed: the connection runs through the same synthetic
    CONNECT transform check as the tunnel listener (default-deny allowlist,
    same audit entry), then the same first-byte dispatch (TLS -> MITM, plain
    HTTP -> transform pipeline, anything else -> rejected).
  - IP_TRANSPARENT is setup-only privilege: it is required to bind the
    listener, not to serve it. Wiring (nftables tproxy statement + policy
    route) is the operator's job and is documented in the README.

Also correct the README's TPROXY section: it now documents the config key and
a recipe that works for arbitrary ports (the old snippet only covered 80/443
and marked the listener port rather than the intercepted flows).

Tests cover destination recovery (IPv4/IPv6, unspecified, non-TCP), plain
HTTP, TLS MITM, transform rejection, and the missing-destination refusal. The
harness fakes TPROXY delivery so the tests are hermetic — real TPROXY needs
NET_ADMIN and kernel policy routing, which a unit test must not require.
@Ethnical Ethnical closed this Oct 4, 2026
@Ethnical
Ethnical deleted the feat/transparent-tproxy-ingress branch October 4, 2026 13:56
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