Skip to content
Merged
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
13 changes: 13 additions & 0 deletions authbridge/authlib/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,19 @@ type ListenerConfig struct {
ReverseProxyAddr string `yaml:"reverse_proxy_addr" json:"reverse_proxy_addr"`
ReverseProxyBackend string `yaml:"reverse_proxy_backend" json:"reverse_proxy_backend"`

// TransparentProxyAddr is the bind address for the outbound transparent
// listener used by proxy-sidecar enforce-redirect mode: iptables REDIRECTs
// the agent's bypass egress here, and the listener recovers the original
// destination via SO_ORIGINAL_DST and tunnels it through the same outbound
// pipeline as the forward proxy. The proxy-sidecar / lite presets default it
// to ":8082", so for those shapes the listener is effectively always on —
// binding is harmless when nothing is redirected to it (cooperative
// HTTP_PROXY deployments simply never receive connections on it). An empty
// value only disables the listener for modes that have no preset default for
// this field (e.g. waypoint / envoy-sidecar); under proxy-sidecar / lite the
// preset refills it, matching the always-on enforce-redirect design.
TransparentProxyAddr string `yaml:"transparent_proxy_addr" json:"transparent_proxy_addr"`

// SessionAPIAddr is the bind address for the session events HTTP server
// (JSON snapshots + SSE stream consumed by abctl or curl). Default per
// mode preset is ":9094". Set to empty string to disable the endpoint.
Expand Down
4 changes: 4 additions & 0 deletions authbridge/authlib/config/presets.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ func ApplyPreset(cfg *Config) {
case ModeProxySidecar:
setDefault(&cfg.Listener.ReverseProxyAddr, ":8080")
setDefault(&cfg.Listener.ForwardProxyAddr, ":8081")
// Outbound transparent listener for enforce-redirect mode. Binding it
// is harmless when nothing is redirected here (cooperative HTTP_PROXY
// deployments simply never receive connections on it).
setDefault(&cfg.Listener.TransparentProxyAddr, ":8082")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Session events API is default-on for every mode. Operators who
Expand Down
2 changes: 1 addition & 1 deletion authbridge/authlib/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/open-policy-agent/opa v1.4.2
github.com/spiffe/go-spiffe/v2 v2.6.0
golang.org/x/sys v0.42.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171
google.golang.org/grpc v1.81.1
gopkg.in/yaml.v3 v3.0.1
Expand Down Expand Up @@ -68,7 +69,6 @@ require (
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
Expand Down
38 changes: 4 additions & 34 deletions authbridge/authlib/listener/forwardproxy/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -738,41 +738,11 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {

// Record a SessionRequest event so /v1/sessions and abctl show that
// a tunnel was opened. Mirrors the HTTP path's post-Allow recording
// (see handleRequest above). The MCP / Inference snapshots are nil
// by definition (CONNECT bytes are opaque), but Invocations from
// gate plugins (ibac, token-exchange's skip/no_route, etc.) and
// any plugin-public Plugins entries are still meaningful.
if s.Sessions != nil {
sid := s.Sessions.ActiveSession()
if sid == "" {
sid = session.DefaultSessionID
}
plugins := pipeline.SnapshotPlugins(pctx.Extensions.Custom)
ev := pipeline.SessionEvent{
At: time.Now(),
Direction: pipeline.Outbound,
Phase: pipeline.SessionRequest,
Invocations: pipeline.SnapshotInvocations(pctx.Extensions.Invocations, pipeline.InvocationPhaseRequest),
Plugins: plugins,
Identity: pipeline.SnapshotIdentity(pctx),
Host: pctx.Host,
}
if ev.Invocations != nil || plugins != nil {
s.Sessions.Append(sid, ev)
}
}
// (see handleRequest above). Shared with the transparent-redirect path.
s.recordTunnelOpened(pctx)

// Bidirectional copy. When either side closes, propagate the close
// to the other so both io.Copy goroutines exit. Close-on-each-side
// is idempotent on net.Conn.
go func() {
_, _ = io.Copy(upstream, clientConn)
_ = upstream.Close()
_ = clientConn.Close()
}()
_, _ = io.Copy(clientConn, upstream)
_ = clientConn.Close()
_ = upstream.Close()
// Bidirectional copy until either side closes.
tunnel(clientConn, upstream)
}

// writeSSEFrame writes one SSE event built from a sseframe-decoded
Expand Down
168 changes: 168 additions & 0 deletions authbridge/authlib/listener/forwardproxy/sniff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package forwardproxy

import (
"bufio"
"bytes"
cryptotls "crypto/tls"
"errors"
"io"
"net"
"net/http"
"time"
)

// Captured (iptables-REDIRECTed) connections carry no CONNECT line, so the
// destination hostname must be recovered from the connection's own first bytes:
// the TLS ClientHello SNI for HTTPS, or the HTTP Host header for plaintext HTTP.
// This gives policy parity with the explicit-proxy path (which reads r.Host).
// The recovered name is used ONLY as the policy key (pctx.Host); the dial target
// stays the SO_ORIGINAL_DST IP. See HandleTransparentConn.

const (
// sniffBufSize bounds how much of the leading bytes we buffer to find the
// SNI / Host header. Real ClientHellos and request header blocks fit well
// within this; anything larger falls back to the IP.
sniffBufSize = 8192
// sniffTimeout bounds the peek so a client that connects but sends nothing
// (or a server-first protocol) can't pin a goroutine. Only relevant on the
// sniffed ports, where the client speaks first, so it is rarely hit.
sniffTimeout = 5 * time.Second
)

// errSniffDone aborts the throwaway TLS handshake once we have the SNI.
var errSniffDone = errors.New("forwardproxy: sni sniff complete")

// shouldSniff reports whether dst's port is one where the client speaks first
// with an HTTP/TLS preamble we can parse. Gating on these ports avoids adding
// peek latency to non-HTTP, often server-first protocols (SSH:22, SMTP:25, ...)
// that we would only ever blind-tunnel anyway.
func shouldSniff(dst string) bool {
_, port, err := net.SplitHostPort(dst)
if err != nil {
return false
}
switch port {
case "80", "443", "8080", "8443":
return true
default:
return false
}
}

// sniffHost peeks the leading bytes of conn to recover the destination hostname
// (TLS SNI or HTTP Host header) without consuming them: it returns the hostname
// (without port; "" if none could be recovered) and a net.Conn that replays the
// peeked bytes so the downstream tunnel still forwards the handshake/request
// verbatim. A read deadline bounds the peek and is cleared before returning.
func sniffHost(conn net.Conn) (string, net.Conn) {
br := bufio.NewReaderSize(conn, sniffBufSize)
wrapped := &peekedConn{Conn: conn, r: br}

_ = conn.SetReadDeadline(time.Now().Add(sniffTimeout))
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()

first, err := br.Peek(1)
if err != nil || len(first) == 0 {
return "", wrapped
}
switch {
case first[0] == 0x16: // TLS handshake record
return stripPort(sniffTLSSNI(br)), wrapped
case first[0] >= 'A' && first[0] <= 'Z': // ASCII HTTP method
return stripPort(sniffHTTPHost(br)), wrapped
default:
return "", wrapped
}
}

// sniffTLSSNI peeks the first TLS record (the ClientHello) and extracts the SNI.
func sniffTLSSNI(br *bufio.Reader) string {
hdr, err := br.Peek(5)
if err != nil || len(hdr) < 5 {
return ""
}
end := 5 + (int(hdr[3])<<8 | int(hdr[4]))
if end > br.Size() {
end = br.Size()
}
full, _ := br.Peek(end) // best effort: parse whatever is buffered
return extractSNI(full)
}

// extractSNI parses the SNI out of a buffered ClientHello by driving a throwaway
// server-side handshake over the bytes and capturing ServerName in the config
// callback, then aborting. Leans on crypto/tls's hardened parser rather than a
// hand-rolled one. Returns "" if the bytes are not a parseable ClientHello.
func extractSNI(clientHello []byte) string {
var sni string
_ = cryptotls.Server(readOnlyConn{r: bytes.NewReader(clientHello)}, &cryptotls.Config{
GetConfigForClient: func(chi *cryptotls.ClientHelloInfo) (*cryptotls.Config, error) {
sni = chi.ServerName
return nil, errSniffDone
},
}).Handshake()
return sni
}

// sniffHTTPHost peeks the request header block and returns the Host header.
func sniffHTTPHost(br *bufio.Reader) string {
end := br.Buffered()
if end < 1 {
end = 1
}
for {
buf, err := br.Peek(end)
if i := bytes.Index(buf, []byte("\r\n\r\n")); i >= 0 {
return parseHTTPHost(buf[:i+4])
}
if err != nil || end >= br.Size() {
return parseHTTPHost(buf) // best effort on what we have
}
end++
}
}

func parseHTTPHost(headerBytes []byte) string {
req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(headerBytes)))
if err != nil {
return ""
}
return req.Host
}

// stripPort drops a trailing :port if present (HTTP Host headers may carry one;
// SNI never does). The real port comes from the SO_ORIGINAL_DST destination.
func stripPort(host string) string {
if host == "" {
return ""
}
if h, _, err := net.SplitHostPort(host); err == nil {
return h
}
return host
}

// peekedConn is a net.Conn whose Read replays bytes buffered by a bufio.Reader
// during sniffing, then continues from the underlying conn. Writes, Close,
// deadlines, and addresses delegate to the embedded conn.
type peekedConn struct {
net.Conn
r *bufio.Reader
}

func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }

// readOnlyConn adapts a byte buffer to net.Conn for crypto/tls's server-side
// parser. Reads come from the buffer; writes are discarded (the throwaway
// handshake never needs to send), and the parser aborts via errSniffDone before
// any write would matter.
type readOnlyConn struct{ r io.Reader }

func (c readOnlyConn) Read(p []byte) (int, error) { return c.r.Read(p) }
func (c readOnlyConn) Write(p []byte) (int, error) { return len(p), nil }
func (c readOnlyConn) Close() error { return nil }
func (c readOnlyConn) LocalAddr() net.Addr { return nil }
func (c readOnlyConn) RemoteAddr() net.Addr { return nil }
func (c readOnlyConn) SetDeadline(time.Time) error { return nil }
func (c readOnlyConn) SetReadDeadline(time.Time) error { return nil }
func (c readOnlyConn) SetWriteDeadline(time.Time) error { return nil }
Loading
Loading