Skip to content

Commit 0680516

Browse files
authored
Merge pull request #485 from huang195/feat/transparent-proxy-egress-redirect
feat: Add transparent listener + enforce-redirect for proxy-sidecar egress
2 parents dcc1a92 + 178c66e commit 0680516

19 files changed

Lines changed: 1263 additions & 277 deletions

File tree

authbridge/authlib/config/config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,19 @@ 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. The proxy-sidecar / lite presets default it
325+
// to ":8082", so for those shapes the listener is effectively always on —
326+
// binding is harmless when nothing is redirected to it (cooperative
327+
// HTTP_PROXY deployments simply never receive connections on it). An empty
328+
// value only disables the listener for modes that have no preset default for
329+
// this field (e.g. waypoint / envoy-sidecar); under proxy-sidecar / lite the
330+
// preset refills it, matching the always-on enforce-redirect design.
331+
TransparentProxyAddr string `yaml:"transparent_proxy_addr" json:"transparent_proxy_addr"`
332+
320333
// SessionAPIAddr is the bind address for the session events HTTP server
321334
// (JSON snapshots + SSE stream consumed by abctl or curl). Default per
322335
// 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: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package forwardproxy
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
cryptotls "crypto/tls"
7+
"errors"
8+
"io"
9+
"net"
10+
"net/http"
11+
"time"
12+
)
13+
14+
// Captured (iptables-REDIRECTed) connections carry no CONNECT line, so the
15+
// destination hostname must be recovered from the connection's own first bytes:
16+
// the TLS ClientHello SNI for HTTPS, or the HTTP Host header for plaintext HTTP.
17+
// This gives policy parity with the explicit-proxy path (which reads r.Host).
18+
// The recovered name is used ONLY as the policy key (pctx.Host); the dial target
19+
// stays the SO_ORIGINAL_DST IP. See HandleTransparentConn.
20+
21+
const (
22+
// sniffBufSize bounds how much of the leading bytes we buffer to find the
23+
// SNI / Host header. Real ClientHellos and request header blocks fit well
24+
// within this; anything larger falls back to the IP.
25+
sniffBufSize = 8192
26+
// sniffTimeout bounds the peek so a client that connects but sends nothing
27+
// (or a server-first protocol) can't pin a goroutine. Only relevant on the
28+
// sniffed ports, where the client speaks first, so it is rarely hit.
29+
sniffTimeout = 5 * time.Second
30+
)
31+
32+
// errSniffDone aborts the throwaway TLS handshake once we have the SNI.
33+
var errSniffDone = errors.New("forwardproxy: sni sniff complete")
34+
35+
// shouldSniff reports whether dst's port is one where the client speaks first
36+
// with an HTTP/TLS preamble we can parse. Gating on these ports avoids adding
37+
// peek latency to non-HTTP, often server-first protocols (SSH:22, SMTP:25, ...)
38+
// that we would only ever blind-tunnel anyway.
39+
func shouldSniff(dst string) bool {
40+
_, port, err := net.SplitHostPort(dst)
41+
if err != nil {
42+
return false
43+
}
44+
switch port {
45+
case "80", "443", "8080", "8443":
46+
return true
47+
default:
48+
return false
49+
}
50+
}
51+
52+
// sniffHost peeks the leading bytes of conn to recover the destination hostname
53+
// (TLS SNI or HTTP Host header) without consuming them: it returns the hostname
54+
// (without port; "" if none could be recovered) and a net.Conn that replays the
55+
// peeked bytes so the downstream tunnel still forwards the handshake/request
56+
// verbatim. A read deadline bounds the peek and is cleared before returning.
57+
func sniffHost(conn net.Conn) (string, net.Conn) {
58+
br := bufio.NewReaderSize(conn, sniffBufSize)
59+
wrapped := &peekedConn{Conn: conn, r: br}
60+
61+
_ = conn.SetReadDeadline(time.Now().Add(sniffTimeout))
62+
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
63+
64+
first, err := br.Peek(1)
65+
if err != nil || len(first) == 0 {
66+
return "", wrapped
67+
}
68+
switch {
69+
case first[0] == 0x16: // TLS handshake record
70+
return stripPort(sniffTLSSNI(br)), wrapped
71+
case first[0] >= 'A' && first[0] <= 'Z': // ASCII HTTP method
72+
return stripPort(sniffHTTPHost(br)), wrapped
73+
default:
74+
return "", wrapped
75+
}
76+
}
77+
78+
// sniffTLSSNI peeks the first TLS record (the ClientHello) and extracts the SNI.
79+
func sniffTLSSNI(br *bufio.Reader) string {
80+
hdr, err := br.Peek(5)
81+
if err != nil || len(hdr) < 5 {
82+
return ""
83+
}
84+
end := 5 + (int(hdr[3])<<8 | int(hdr[4]))
85+
if end > br.Size() {
86+
end = br.Size()
87+
}
88+
full, _ := br.Peek(end) // best effort: parse whatever is buffered
89+
return extractSNI(full)
90+
}
91+
92+
// extractSNI parses the SNI out of a buffered ClientHello by driving a throwaway
93+
// server-side handshake over the bytes and capturing ServerName in the config
94+
// callback, then aborting. Leans on crypto/tls's hardened parser rather than a
95+
// hand-rolled one. Returns "" if the bytes are not a parseable ClientHello.
96+
func extractSNI(clientHello []byte) string {
97+
var sni string
98+
_ = cryptotls.Server(readOnlyConn{r: bytes.NewReader(clientHello)}, &cryptotls.Config{
99+
GetConfigForClient: func(chi *cryptotls.ClientHelloInfo) (*cryptotls.Config, error) {
100+
sni = chi.ServerName
101+
return nil, errSniffDone
102+
},
103+
}).Handshake()
104+
return sni
105+
}
106+
107+
// sniffHTTPHost peeks the request header block and returns the Host header.
108+
func sniffHTTPHost(br *bufio.Reader) string {
109+
end := br.Buffered()
110+
if end < 1 {
111+
end = 1
112+
}
113+
for {
114+
buf, err := br.Peek(end)
115+
if i := bytes.Index(buf, []byte("\r\n\r\n")); i >= 0 {
116+
return parseHTTPHost(buf[:i+4])
117+
}
118+
if err != nil || end >= br.Size() {
119+
return parseHTTPHost(buf) // best effort on what we have
120+
}
121+
end++
122+
}
123+
}
124+
125+
func parseHTTPHost(headerBytes []byte) string {
126+
req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(headerBytes)))
127+
if err != nil {
128+
return ""
129+
}
130+
return req.Host
131+
}
132+
133+
// stripPort drops a trailing :port if present (HTTP Host headers may carry one;
134+
// SNI never does). The real port comes from the SO_ORIGINAL_DST destination.
135+
func stripPort(host string) string {
136+
if host == "" {
137+
return ""
138+
}
139+
if h, _, err := net.SplitHostPort(host); err == nil {
140+
return h
141+
}
142+
return host
143+
}
144+
145+
// peekedConn is a net.Conn whose Read replays bytes buffered by a bufio.Reader
146+
// during sniffing, then continues from the underlying conn. Writes, Close,
147+
// deadlines, and addresses delegate to the embedded conn.
148+
type peekedConn struct {
149+
net.Conn
150+
r *bufio.Reader
151+
}
152+
153+
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
154+
155+
// readOnlyConn adapts a byte buffer to net.Conn for crypto/tls's server-side
156+
// parser. Reads come from the buffer; writes are discarded (the throwaway
157+
// handshake never needs to send), and the parser aborts via errSniffDone before
158+
// any write would matter.
159+
type readOnlyConn struct{ r io.Reader }
160+
161+
func (c readOnlyConn) Read(p []byte) (int, error) { return c.r.Read(p) }
162+
func (c readOnlyConn) Write(p []byte) (int, error) { return len(p), nil }
163+
func (c readOnlyConn) Close() error { return nil }
164+
func (c readOnlyConn) LocalAddr() net.Addr { return nil }
165+
func (c readOnlyConn) RemoteAddr() net.Addr { return nil }
166+
func (c readOnlyConn) SetDeadline(time.Time) error { return nil }
167+
func (c readOnlyConn) SetReadDeadline(time.Time) error { return nil }
168+
func (c readOnlyConn) SetWriteDeadline(time.Time) error { return nil }

0 commit comments

Comments
 (0)