Repository navigation
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, noIP_TRANSPARENT, and no original-destination recovery; the only listeners werehttp_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
ip rule add fwmark 1 lookup 100 ip route add local 0.0.0.0/0 dev lo table 100Design points:
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 as0.0.0.0.IP_TRANSPARENTis 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.gocovers 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_ADMINand kernel policy routing, which a unit test must not require.Locally, against go1.26.8:
Not covered
tproxystatement here is TCP; UDP would need its own handling).table ip); aninettable would extend it.cidrsfor 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.