Skip to content

Commit bb15d20

Browse files
committed
feat: Add transparent listener + enforce-redirect for proxy-sidecar egress
proxy-sidecar egress enforcement could previously only DROP bypass traffic (MODE=enforce-drop), because the forward proxy only speaks explicit CONNECT — an iptables-REDIRECTed raw connection arrives with no destination it can recover, so there was nothing to redirect to. DROP is fail-closed but breaks agents that ignore HTTP_PROXY, which is why it stayed opt-in. This adds the missing half: a transparent listener that captures redirected egress, so enforcement can REDIRECT instead of DROP (capture, do not break). - authlib/listener/transparentproxy: an outbound transparent listener that recovers the original destination via SO_ORIGINAL_DST (Linux, build-tagged; pure cross-platform sockaddr parser with a unit test) and dispatches to a ConnHandler. This is the Go analogue of Envoy's original_dst listener filter. - forwardproxy.Server.HandleTransparentConn: gates on destination then blind-tunnels, reusing the exact CONNECT-path pipeline + tunnel (extracted into shared recordTunnelOpened/tunnel helpers). Emits no proxy-protocol bytes so the agent's end-to-end TLS is preserved. - Wire the listener into authbridge-proxy and authbridge-lite (proxy-sidecar shapes); new listener.transparent_proxy_addr config, default :8082. - init-iptables.sh MODE=enforce-redirect: nat OUTPUT chain that REDIRECTs external TCP to TRANSPARENT_PORT (exempting proxy UID, loopback, cluster CIDRs, ztunnel mark) and DROPs non-TCP external egress so HTTP/3 cannot bypass. Inserted at position 1 to preempt Istio ambient's nat redirect. - test-enforce-redirect.sh: netns harness proving capture + ambient preemption + non-TCP drop via packet counters. README documents the new mode. enforce-drop is retained as a no-transparent-listener fallback. Inert until the operator wires MODE=enforce-redirect (companion kagenti-operator PR). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent dcc1a92 commit bb15d20

16 files changed

Lines changed: 848 additions & 64 deletions

File tree

authbridge/authlib/config/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,14 @@ type ListenerConfig struct {
317317
ReverseProxyAddr string `yaml:"reverse_proxy_addr" json:"reverse_proxy_addr"`
318318
ReverseProxyBackend string `yaml:"reverse_proxy_backend" json:"reverse_proxy_backend"`
319319

320+
// TransparentProxyAddr is the bind address for the outbound transparent
321+
// listener used by proxy-sidecar enforce-redirect mode: iptables REDIRECTs
322+
// the agent's bypass egress here, and the listener recovers the original
323+
// destination via SO_ORIGINAL_DST and tunnels it through the same outbound
324+
// pipeline as the forward proxy. Default per proxy-sidecar preset is
325+
// ":8082". Empty disables the listener (cooperative HTTP_PROXY only).
326+
TransparentProxyAddr string `yaml:"transparent_proxy_addr" json:"transparent_proxy_addr"`
327+
320328
// SessionAPIAddr is the bind address for the session events HTTP server
321329
// (JSON snapshots + SSE stream consumed by abctl or curl). Default per
322330
// mode preset is ":9094". Set to empty string to disable the endpoint.

authbridge/authlib/config/presets.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ func ApplyPreset(cfg *Config) {
1616
case ModeProxySidecar:
1717
setDefault(&cfg.Listener.ReverseProxyAddr, ":8080")
1818
setDefault(&cfg.Listener.ForwardProxyAddr, ":8081")
19+
// Outbound transparent listener for enforce-redirect mode. Binding it
20+
// is harmless when nothing is redirected here (cooperative HTTP_PROXY
21+
// deployments simply never receive connections on it).
22+
setDefault(&cfg.Listener.TransparentProxyAddr, ":8082")
1923
}
2024

2125
// Session events API is default-on for every mode. Operators who

authbridge/authlib/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/lestrrat-go/jwx/v2 v2.1.6
1010
github.com/open-policy-agent/opa v1.4.2
1111
github.com/spiffe/go-spiffe/v2 v2.6.0
12+
golang.org/x/sys v0.42.0
1213
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171
1314
google.golang.org/grpc v1.81.1
1415
gopkg.in/yaml.v3 v3.0.1
@@ -68,7 +69,6 @@ require (
6869
golang.org/x/crypto v0.48.0 // indirect
6970
golang.org/x/net v0.51.0 // indirect
7071
golang.org/x/sync v0.20.0 // indirect
71-
golang.org/x/sys v0.42.0 // indirect
7272
golang.org/x/text v0.34.0 // indirect
7373
golang.org/x/time v0.12.0 // indirect
7474
google.golang.org/protobuf v1.36.11 // indirect

authbridge/authlib/listener/forwardproxy/server.go

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -738,41 +738,11 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
738738

739739
// Record a SessionRequest event so /v1/sessions and abctl show that
740740
// a tunnel was opened. Mirrors the HTTP path's post-Allow recording
741-
// (see handleRequest above). The MCP / Inference snapshots are nil
742-
// by definition (CONNECT bytes are opaque), but Invocations from
743-
// gate plugins (ibac, token-exchange's skip/no_route, etc.) and
744-
// any plugin-public Plugins entries are still meaningful.
745-
if s.Sessions != nil {
746-
sid := s.Sessions.ActiveSession()
747-
if sid == "" {
748-
sid = session.DefaultSessionID
749-
}
750-
plugins := pipeline.SnapshotPlugins(pctx.Extensions.Custom)
751-
ev := pipeline.SessionEvent{
752-
At: time.Now(),
753-
Direction: pipeline.Outbound,
754-
Phase: pipeline.SessionRequest,
755-
Invocations: pipeline.SnapshotInvocations(pctx.Extensions.Invocations, pipeline.InvocationPhaseRequest),
756-
Plugins: plugins,
757-
Identity: pipeline.SnapshotIdentity(pctx),
758-
Host: pctx.Host,
759-
}
760-
if ev.Invocations != nil || plugins != nil {
761-
s.Sessions.Append(sid, ev)
762-
}
763-
}
741+
// (see handleRequest above). Shared with the transparent-redirect path.
742+
s.recordTunnelOpened(pctx)
764743

765-
// Bidirectional copy. When either side closes, propagate the close
766-
// to the other so both io.Copy goroutines exit. Close-on-each-side
767-
// is idempotent on net.Conn.
768-
go func() {
769-
_, _ = io.Copy(upstream, clientConn)
770-
_ = upstream.Close()
771-
_ = clientConn.Close()
772-
}()
773-
_, _ = io.Copy(clientConn, upstream)
774-
_ = clientConn.Close()
775-
_ = upstream.Close()
744+
// Bidirectional copy until either side closes.
745+
tunnel(clientConn, upstream)
776746
}
777747

778748
// writeSSEFrame writes one SSE event built from a sseframe-decoded
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package forwardproxy
2+
3+
import (
4+
"context"
5+
"io"
6+
"log/slog"
7+
"net"
8+
"net/http"
9+
"time"
10+
11+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
12+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/session"
13+
)
14+
15+
// HandleTransparentConn processes one outbound connection captured by an
16+
// iptables REDIRECT (proxy-sidecar enforce-redirect mode). It is the
17+
// transparent-listener analogue of handleConnect, and shares its semantics:
18+
// the same outbound pipeline gates the connection on destination/identity, and
19+
// the bytes are then blind-tunnelled, preserving the agent's end-to-end TLS
20+
// (token-exchange and protocol parsers are no-ops on opaque TLS, exactly as on
21+
// the CONNECT path).
22+
//
23+
// The crucial difference from handleConnect: there is NO HTTP CONNECT request.
24+
// The agent believes it is talking directly to dst, so the proxy must emit no
25+
// protocol bytes back — no "200 Connection Established", no hijack. It simply
26+
// gates, dials dst, and copies bytes both ways. dst is "host:port" recovered
27+
// from SO_ORIGINAL_DST by the transparent listener.
28+
//
29+
// HandleTransparentConn owns clientConn's lifecycle and always closes it.
30+
func (s *Server) HandleTransparentConn(clientConn net.Conn, dst string) {
31+
defer func() { _ = clientConn.Close() }()
32+
33+
// Background context: there is no inbound *http.Request to tie cancellation
34+
// to. Tunnel teardown (either side closing) is what ends the connection;
35+
// the pipeline Run/Finish calls are short and don't need request scoping.
36+
ctx := context.Background()
37+
38+
pctx := &pipeline.Context{
39+
Direction: pipeline.Outbound,
40+
Method: http.MethodConnect, // synthetic: opaque tunnel, parity with handleConnect
41+
Scheme: "tcp", // marker: bytes are opaque, not HTTP
42+
Host: dst,
43+
Headers: http.Header{},
44+
Shared: s.Shared,
45+
StartedAt: time.Now(),
46+
}
47+
defer func() {
48+
s.OutboundPipeline.RunFinish(ctx, pctx, pipeline.OutcomeFromContext(pctx))
49+
}()
50+
51+
if s.Sessions != nil {
52+
if aid := s.Sessions.ActiveSession(); aid != "" {
53+
pctx.Session = s.Sessions.View(aid)
54+
}
55+
}
56+
57+
// Gate on host/identity before opening the tunnel — identical to the
58+
// CONNECT path. Parsers see no body and degrade gracefully.
59+
action := s.OutboundPipeline.Run(ctx, pctx)
60+
if action.Type == pipeline.Reject {
61+
s.recordOutboundReject(pctx, action)
62+
slog.Warn("transparent-proxy: outbound rejected by policy", "host", dst)
63+
return
64+
}
65+
66+
upstream, err := net.DialTimeout("tcp", dst, connectDialTimeout)
67+
if err != nil {
68+
slog.Warn("transparent-proxy: upstream dial failed", "host", dst, "error", err)
69+
return
70+
}
71+
defer func() { _ = upstream.Close() }()
72+
73+
enableKeepalive(upstream)
74+
enableKeepalive(clientConn)
75+
76+
s.recordTunnelOpened(pctx)
77+
tunnel(clientConn, upstream)
78+
}
79+
80+
// recordTunnelOpened emits the SessionRequest event for an opened opaque
81+
// tunnel (CONNECT or transparent-redirect). Shared by handleConnect and
82+
// HandleTransparentConn. MCP/Inference snapshots are nil by definition (the
83+
// bytes are opaque); Invocations from gate plugins and plugin-public Plugins
84+
// entries are still meaningful.
85+
func (s *Server) recordTunnelOpened(pctx *pipeline.Context) {
86+
if s.Sessions == nil {
87+
return
88+
}
89+
sid := s.Sessions.ActiveSession()
90+
if sid == "" {
91+
sid = session.DefaultSessionID
92+
}
93+
plugins := pipeline.SnapshotPlugins(pctx.Extensions.Custom)
94+
ev := pipeline.SessionEvent{
95+
At: time.Now(),
96+
Direction: pipeline.Outbound,
97+
Phase: pipeline.SessionRequest,
98+
Invocations: pipeline.SnapshotInvocations(pctx.Extensions.Invocations, pipeline.InvocationPhaseRequest),
99+
Plugins: plugins,
100+
Identity: pipeline.SnapshotIdentity(pctx),
101+
Host: pctx.Host,
102+
}
103+
if ev.Invocations != nil || plugins != nil {
104+
s.Sessions.Append(sid, ev)
105+
}
106+
}
107+
108+
// tunnel bidirectionally copies between two connections until either side
109+
// closes, then propagates the close to the other so both io.Copy goroutines
110+
// exit. Close-on-each-side is idempotent on net.Conn. Shared by handleConnect
111+
// and HandleTransparentConn.
112+
func tunnel(a, b net.Conn) {
113+
go func() {
114+
_, _ = io.Copy(b, a)
115+
_ = b.Close()
116+
_ = a.Close()
117+
}()
118+
_, _ = io.Copy(a, b)
119+
_ = a.Close()
120+
_ = b.Close()
121+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package forwardproxy
2+
3+
import (
4+
"io"
5+
"net"
6+
"testing"
7+
"time"
8+
9+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
10+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/plugintesting"
11+
)
12+
13+
// HandleTransparentConn gates then blind-tunnels: with an allow-all pipeline it
14+
// must dial the recovered destination and copy bytes both ways, emitting no
15+
// proxy-protocol bytes of its own (the agent thinks it's talking to dst).
16+
func TestHandleTransparentConn_Tunnels(t *testing.T) {
17+
const banner = "UPSTREAM-HELLO\n"
18+
19+
upstream, err := net.Listen("tcp", "127.0.0.1:0")
20+
if err != nil {
21+
t.Fatalf("listen upstream: %v", err)
22+
}
23+
defer func() { _ = upstream.Close() }()
24+
go func() {
25+
c, err := upstream.Accept()
26+
if err != nil {
27+
return
28+
}
29+
defer func() { _ = c.Close() }()
30+
_, _ = c.Write([]byte(banner))
31+
buf := make([]byte, 4)
32+
if _, err := io.ReadFull(c, buf); err != nil {
33+
return
34+
}
35+
_, _ = c.Write(buf) // echo
36+
}()
37+
38+
p, err := plugintesting.BuildPipeline(nil) // allow-all
39+
if err != nil {
40+
t.Fatalf("build pipeline: %v", err)
41+
}
42+
srv := &Server{OutboundPipeline: pipeline.NewHolder(p)}
43+
44+
agentSide, proxySide := net.Pipe()
45+
go srv.HandleTransparentConn(proxySide, upstream.Addr().String())
46+
47+
_ = agentSide.SetDeadline(time.Now().Add(5 * time.Second))
48+
49+
// The first bytes the agent sees must be the upstream's banner, NOT a
50+
// "200 Connection Established" — proving no proxy protocol leaked.
51+
got := make([]byte, len(banner))
52+
if _, err := io.ReadFull(agentSide, got); err != nil {
53+
t.Fatalf("read banner: %v", err)
54+
}
55+
if string(got) != banner {
56+
t.Fatalf("first bytes = %q, want upstream banner %q", got, banner)
57+
}
58+
59+
if _, err := agentSide.Write([]byte("ping")); err != nil {
60+
t.Fatalf("write: %v", err)
61+
}
62+
echo := make([]byte, 4)
63+
if _, err := io.ReadFull(agentSide, echo); err != nil {
64+
t.Fatalf("read echo: %v", err)
65+
}
66+
if string(echo) != "ping" {
67+
t.Fatalf("echo = %q, want %q", echo, "ping")
68+
}
69+
_ = agentSide.Close()
70+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//go:build linux
2+
3+
package transparentproxy
4+
5+
import (
6+
"fmt"
7+
"net"
8+
"unsafe"
9+
10+
"golang.org/x/sys/unix"
11+
)
12+
13+
// soOriginalDst is netfilter's SO_ORIGINAL_DST (IPv4, SOL_IP) and
14+
// IP6T_SO_ORIGINAL_DST (IPv6, SOL_IPV6) option number. After an iptables
15+
// REDIRECT/DNAT, conntrack records the pre-NAT destination and exposes it
16+
// to the receiving socket via getsockopt with this option.
17+
const soOriginalDst = 80
18+
19+
// originalDst recovers the pre-REDIRECT destination of a connection accepted on
20+
// a transparent (iptables-REDIRECTed) listener, via the SO_ORIGINAL_DST socket
21+
// option. It tries IPv4 (SOL_IP) first, then IPv6 (SOL_IPV6). Returns the
22+
// destination as "host:port".
23+
//
24+
// This relies on REDIRECT/DNAT (conntrack-backed) — the same mechanism Envoy's
25+
// original_dst listener filter uses — NOT TPROXY (which would instead read the
26+
// socket's local address).
27+
func originalDst(conn *net.TCPConn) (string, error) {
28+
raw, err := conn.SyscallConn()
29+
if err != nil {
30+
return "", fmt.Errorf("transparentproxy: SyscallConn: %w", err)
31+
}
32+
33+
var (
34+
dst string
35+
innerErr error
36+
)
37+
ctrlErr := raw.Control(func(fd uintptr) {
38+
dst, innerErr = getOrigDst(fd)
39+
})
40+
if ctrlErr != nil {
41+
return "", fmt.Errorf("transparentproxy: RawConn.Control: %w", ctrlErr)
42+
}
43+
return dst, innerErr
44+
}
45+
46+
// getOrigDst performs the raw getsockopt for SO_ORIGINAL_DST on fd. The buffer
47+
// is sized for sockaddr_in6 (the larger of the two); parseSockaddr reads only
48+
// the bytes the kernel reports via the (in/out) optlen.
49+
func getOrigDst(fd uintptr) (string, error) {
50+
var buf [unix.SizeofSockaddrInet6]byte
51+
size := uint32(len(buf))
52+
53+
// IPv4 (SOL_IP) first — the common case in kagenti clusters.
54+
if errno := getsockopt(fd, unix.SOL_IP, soOriginalDst, &buf[0], &size); errno == 0 {
55+
return parseSockaddr(buf[:size])
56+
}
57+
58+
// IPv6 (SOL_IPV6).
59+
size = uint32(len(buf))
60+
if errno := getsockopt(fd, unix.SOL_IPV6, soOriginalDst, &buf[0], &size); errno == 0 {
61+
return parseSockaddr(buf[:size])
62+
}
63+
64+
return "", fmt.Errorf("transparentproxy: getsockopt SO_ORIGINAL_DST failed (not a REDIRECTed connection?)")
65+
}
66+
67+
func getsockopt(fd uintptr, level, name int, val *byte, length *uint32) unix.Errno {
68+
_, _, errno := unix.Syscall6(
69+
unix.SYS_GETSOCKOPT,
70+
fd,
71+
uintptr(level),
72+
uintptr(name),
73+
uintptr(unsafe.Pointer(val)),
74+
uintptr(unsafe.Pointer(length)),
75+
0,
76+
)
77+
return errno
78+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//go:build !linux
2+
3+
package transparentproxy
4+
5+
import (
6+
"fmt"
7+
"net"
8+
)
9+
10+
// originalDst is unsupported off Linux. SO_ORIGINAL_DST is a netfilter feature;
11+
// the transparent listener only runs in-cluster (Linux). This stub keeps the
12+
// proxy binary buildable on dev hosts (e.g. macOS).
13+
func originalDst(_ *net.TCPConn) (string, error) {
14+
return "", fmt.Errorf("transparentproxy: SO_ORIGINAL_DST is only supported on Linux")
15+
}

0 commit comments

Comments
 (0)