From 6eb2b32eb7434f0242001b8b5204925c9d4a0ba6 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 13:53:58 -0400 Subject: [PATCH 01/24] Docs: Add TLS bridge design spec + Phase 1 plan; add package skeleton Self-contained design + implementation plan for the AuthBridge outbound TLS bridge (terminate agent egress TLS, run the existing pipeline on plaintext, re-originate verified TLS). Phase 1 is AuthBridge-only and test-only; Phase 2 (cert-manager CA) drops in via the CASource seam. Includes the empty tlsbridge package (doc.go). Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/tlsbridge/doc.go | 6 + .../2026-06-17-authbridge-tlsbridge-phase1.md | 1533 +++++++++++++++++ .../2026-06-12-authbridge-tlsbridge-design.md | 286 +++ 3 files changed, 1825 insertions(+) create mode 100644 authbridge/authlib/tlsbridge/doc.go create mode 100644 authbridge/docs/superpowers/plans/2026-06-17-authbridge-tlsbridge-phase1.md create mode 100644 authbridge/docs/superpowers/specs/2026-06-12-authbridge-tlsbridge-design.md diff --git a/authbridge/authlib/tlsbridge/doc.go b/authbridge/authlib/tlsbridge/doc.go new file mode 100644 index 000000000..9353a827e --- /dev/null +++ b/authbridge/authlib/tlsbridge/doc.go @@ -0,0 +1,6 @@ +// Package tlsbridge implements AuthBridge's outbound TLS bridge: it forges a +// per-origin leaf so the agent's egress TLS terminates at AuthBridge, the +// existing outbound pipeline runs on the decrypted request, and the call is +// relayed over a separately-verified upstream TLS connection. Un-bridgeable or +// pinned traffic falls open to a plain tunnel and self-heals via an auto-skip set. +package tlsbridge diff --git a/authbridge/docs/superpowers/plans/2026-06-17-authbridge-tlsbridge-phase1.md b/authbridge/docs/superpowers/plans/2026-06-17-authbridge-tlsbridge-phase1.md new file mode 100644 index 000000000..8cb727bce --- /dev/null +++ b/authbridge/docs/superpowers/plans/2026-06-17-authbridge-tlsbridge-phase1.md @@ -0,0 +1,1533 @@ +# AuthBridge Egress TLS Bridge — Phase 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an outbound **TLS bridge** to AuthBridge's Go forward proxy — terminate the agent's egress TLS, run the existing outbound pipeline on the decrypted request, and re-originate a separately-verified TLS connection to the real origin — in-process, with an ephemeral CA, no operator dependency, without ever breaking a currently-working agent call. + +> **Naming:** this feature was previously called "MITM". It is now the **TLS bridge** (terminate one TLS, run logic on plaintext, originate a fresh verified TLS — literally a bridge between two TLS connections). Package `tlsbridge`, config block `tls_bridge:`, type `tlsbridge.Engine`. + +**Architecture:** A new self-contained `authlib/tlsbridge/` package (CA source, leaf minter, bridge decision, TLS terminator, upstream client). The forward-proxy's two blind-tunnel sites (transparent listener + CONNECT) gain a *reversible* bridge branch: verify the upstream origin's TLS first, then forge a leaf and terminate the agent's TLS, then run the **unchanged** outbound pipeline on the decrypted request and relay over the verified upstream connection. Un-bridgeable or pinned traffic falls open to the existing tunnel and self-heals via an auto-skip set. + +**Scope (Phase 1 is test-only):** This PR is AuthBridge-only and proves the decrypt→pipeline→re-originate loop via **in-process integration tests** (a client configured to trust the ephemeral CA). It does **not** make a real operator-deployed agent trust the CA — that is Phase 2 (operator) work. On a real cluster, a live agent's egress will safely **tunnel** (the no-broken-calls guarantee), because it does not yet trust the minted leaf. See "Phase 2 compatibility" below — Phase 1 is built so the cert-manager path (2c) drops in with a one-line `main.go` swap and no changes to the bridge core. + +**Tech Stack:** Go 1.25, stdlib `crypto/tls` + `crypto/x509` (hand-rolled cert minting), `golang.org/x/net/http2` for h2. Module: `github.com/kagenti/kagenti-extensions/authbridge/authlib` (`go.mod` already has `golang.org/x/net v0.51.0` as an indirect dep — Task 5 promotes it to direct). Tests are package-internal `_test.go`, table-driven, loopback listeners; run with `go test ./...` from `authbridge/authlib`. + +**Spec:** `authbridge/docs/superpowers/specs/2026-06-12-authbridge-tlsbridge-design.md` + +--- + +## Verified anchors in the current code (source of truth, re-checked against current `main`) + +- `forwardproxy.Server` (`authlib/listener/forwardproxy/server.go:47-61`): fields `OutboundPipeline *pipeline.Holder`, `Sessions *session.Store`, `Shared pipeline.SharedStore`, `Client *http.Client`, `SkipHosts *skiphost.Matcher`. `Shared`/`SkipHosts` are set by the caller after `NewServer`, not inside it — the new `TLSBridge` field follows that pattern. +- `forwardproxy.NewServer(outbound *pipeline.Holder, sessions *session.Store, mtls *MTLSOptions) (*Server, error)` (`server.go:89`). When `mtls != nil` it sets `transport.DialContext = mtlsDialer(...).DialContext` (`server.go:90-117`) — a **TLS-or-fail dialer that presents the agent SVID and verifies against the SPIRE bundle**. This is exactly why bridge re-origination must use its **own** client, never `s.Client`. +- `(s *Server) handleRequest(w, r)` (`server.go`): builds `pctx := &pipeline.Context{Direction: pipeline.Outbound, Method, Scheme: r.URL.Scheme, Host: r.Host, Path: r.URL.Path, Headers: r.Header.Clone(), Shared, StartedAt}` (`server.go:197-206`); runs `action := s.OutboundPipeline.Run(r.Context(), pctx)` and checks `action.Type == pipeline.Reject` (`server.go:258-270`); strips hop-by-hop + `Proxy-Authorization`/`Proxy-Connection` (`server.go:320-329`); clears `r.RequestURI = ""` (`server.go:332`); re-originates via `s.Client.Do(r)` (`server.go:334`). **It does NOT set `r.URL.Scheme`/`Host`** — it relies on absolute-form proxy requests already carrying them. The bridge handler must set them (origin-form decrypted requests have empty `r.URL.Host`). +- `(s *Server) handleConnect(w, r)` (`server.go`): dials `upstream` via `net.DialTimeout("tcp", r.Host, …)` (`server.go:779`), hijacks `clientConn` (`server.go:786`), writes `200 Connection Established` (`server.go:802`), then `tunnel(clientConn, upstream)` (`server.go:822`). Upstream is plain TCP — never mTLS. +- `(s *Server) HandleTransparentConn(clientConn net.Conn, dst string)` (`transparent.go:46`): `name, wrapped := sniffHost(clientConn); clientConn = wrapped` (`transparent.go:69-70`); local var `host` = `net.JoinHostPort(name, port)` or `dst` (`transparent.go:67-78`); dials `upstream` against `dst` with `defer upstream.Close()` (`transparent.go:115-120`); `tunnel(clientConn, upstream)` (`transparent.go:125`). +- `sniffHost(conn net.Conn) (string, net.Conn)` (`sniff.go`): returns the recovered host string and a replayable `*peekedConn{net.Conn; r *bufio.Reader}` (`sniff.go:148-153`). It uses `br.Peek(...)` (non-consuming) internally but **discards the peeked bytes** and has **no exported peek method**. Task 6 adds `(c *peekedConn) Peek(n int) ([]byte, error)`. +- `config.Config` (`config/config.go`): optional pointer blocks `MTLS *MTLSConfig \`yaml:"mtls,omitempty"\`` (`:33`), `SPIFFE *SPIFFEConfig \`yaml:"spiffe,omitempty"\`` (`:39`), each with a `Validate()` method (`:86`, `:133`) called from the top-level loader (`cfg.MTLS.Validate()` `:441`, `cfg.SPIFFE.Validate()` `:460`). The loader is **`func Load(path string) (*Config, error)`** (`:415`) — it takes a **file path, not bytes**. `config.go` already imports `fmt` and `os`. +- main wiring (`cmd/authbridge-proxy/main.go`): `fpMTLS = &forwardproxy.MTLSOptions{...}` (`:243`); `fpSrv, err := forwardproxy.NewServer(outboundH, sessions, fpMTLS)` (`:256`); then `fpSrv.SkipHosts = …` (`:268`), `fpSrv.Shared = sharedStore` (`:272`); `transparentproxy.NewServer(fp.HandleTransparentConn)` (`:424`). main uses **`log/slog` + `os.Exit`** (imports `log/slog` `:17`, `os` `:20`) — **not** `log.Fatalf`. +- `pipeline`: `Action{Type ActionType, Violation *Violation}` with `Reject` const (`pipeline/action.go:11-24`); `SharedStore` is an interface (`pipeline/context.go:35`); `Context` (`pipeline/context.go:93`). The response phase (`RunResponse`/`RunResponseFrame`) lives inside the `handleRequest` body that Task 6 moves wholesale — no new response-phase code is authored. + +--- + +## File Structure + +**New package `authlib/tlsbridge/`** (one responsibility per file): +- `authlib/tlsbridge/doc.go` — package doc. +- `authlib/tlsbridge/ca.go` — `CASource` interface + `EphemeralSource` + `FileSource` (multi-format key parsing). +- `authlib/tlsbridge/minter.go` — `Minter` (per-host leaf, **one shared P-256 leaf key**, LRU/TTL cache). +- `authlib/tlsbridge/decision.go` — `Decision` (classify) + `SkipSet` (runtime auto-skip). +- `authlib/tlsbridge/upstream.go` — `NewUpstreamClient` (system + injected roots). +- `authlib/tlsbridge/terminator.go` — `Terminator` (tls.Server wrap, ALPN h2+http/1.1). +- `authlib/tlsbridge/serve.go` — `ServeConn` (one-conn keep-alive http.Server, h2-enabled). +- `authlib/tlsbridge/engine.go` — `Engine` facade + `RunTrustSelfCheck`. +- `authlib/tlsbridge/*_test.go` — per-unit tests. + +**Modified (proxy integration):** +- `authlib/listener/forwardproxy/server.go` — add `TLSBridge *tlsbridge.Engine` field; extract `serveOutbound(w, r, isBridge)`; add `bridgeServe` + small `hostOnly`/`portOf`/`nameOrIP` helpers; bridge branch in `handleConnect`. +- `authlib/listener/forwardproxy/transparent.go` — bridge branch before `tunnel(...)`. +- `authlib/listener/forwardproxy/sniff.go` — add `(*peekedConn).Peek(n)`. +- `authlib/config/config.go` — `TLSBridge *TLSBridgeConfig` block + validation. +- `cmd/authbridge-proxy/main.go` — construct the engine, set `fpSrv.TLSBridge`, run trust self-check. + +**Phase 2 (operator) — sketched at the end, not bite-sized here.** + +--- + +## Task 0: Branch + package skeleton + +**Files:** +- Create: `authlib/tlsbridge/doc.go` + +- [ ] **Step 1: Create the implementation branch from current upstream main** + +```bash +cd /Users/haihuang/works/go/src/github.com/kagenti/kagenti-extensions +git fetch upstream main +git checkout -b feat/tlsbridge-phase1 upstream/main +``` + +Expected: branch created off post-resolv.conf `main` (the design branch is docs-only and stale for code). + +- [ ] **Step 2: Create the package doc file** + +```go +// Package tlsbridge implements AuthBridge's outbound TLS bridge: it forges a +// per-origin leaf so the agent's egress TLS terminates at AuthBridge, the +// existing outbound pipeline runs on the decrypted request, and the call is +// relayed over a separately-verified upstream TLS connection. Un-bridgeable or +// pinned traffic falls open to a plain tunnel and self-heals via an auto-skip set. +package tlsbridge +``` + +- [ ] **Step 3: Verify it compiles and commit** + +Run: `cd authbridge/authlib && go build ./tlsbridge/` +Expected: builds (empty package). + +```bash +git add authbridge/authlib/tlsbridge/doc.go +git commit -s -m "feat(tlsbridge): add package skeleton" +``` + +--- + +## Task 1: CASource — ephemeral + file CA (multi-format key) + +**Files:** +- Create: `authlib/tlsbridge/ca.go` +- Test: `authlib/tlsbridge/ca_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package tlsbridge + +import ( + "crypto/x509" + "encoding/pem" + "testing" +) + +func TestEphemeralSource_IssuesUsableCA(t *testing.T) { + src, err := NewEphemeralSource() + if err != nil { + t.Fatalf("NewEphemeralSource: %v", err) + } + block, _ := pem.Decode(src.CACertPEM()) + if block == nil || block.Type != "CERTIFICATE" { + t.Fatalf("CACertPEM did not yield a CERTIFICATE PEM block") + } + caCert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("parse CA cert: %v", err) + } + if !caCert.IsCA { + t.Errorf("issued cert is not a CA (IsCA=false)") + } + if caCert.KeyUsage&x509.KeyUsageCertSign == 0 { + t.Errorf("CA cert lacks KeyUsageCertSign") + } + cert, key := src.Issuer() + if cert == nil || key == nil { + t.Fatalf("Issuer() returned nil cert/key") + } +} +``` + +- [ ] **Step 2: Run it — expect compile failure** + +Run: `cd authbridge/authlib && go test ./tlsbridge/ -run TestEphemeralSource -v` +Expected: FAIL — `undefined: NewEphemeralSource`. + +- [ ] **Step 3: Implement `ca.go`** + +```go +package tlsbridge + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "time" +) + +// CASource supplies the signing CA used to mint per-origin leaves. +type CASource interface { + // Issuer returns the CA certificate and its private key for signing leaves. + Issuer() (cert *x509.Certificate, key crypto.Signer) + // CACertPEM returns the CA certificate in PEM form (for the agent's trust store). + CACertPEM() []byte +} + +type staticSource struct { + cert *x509.Certificate + key crypto.Signer + certPEM []byte +} + +func (s *staticSource) Issuer() (*x509.Certificate, crypto.Signer) { return s.cert, s.key } +func (s *staticSource) CACertPEM() []byte { return s.certPEM } + +// NewEphemeralSource generates an in-memory self-signed CA. Used as the +// standalone / no-cert-manager fallback and in tests. +func NewEphemeralSource() (CASource, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("tlsbridge: generate CA key: %w", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "authbridge-tls-bridge-ca"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + if err != nil { + return nil, fmt.Errorf("tlsbridge: self-sign CA: %w", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, fmt.Errorf("tlsbridge: parse self-signed CA: %w", err) + } + return &staticSource{ + cert: cert, + key: key, + certPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + }, nil +} + +// NewFileSource loads a CA (tls.crt/tls.key) from disk — the cert-manager / +// operator-coordinated path (Phase 2). Keys may be PKCS#8, PKCS#1 (RSA) or +// SEC1 (EC); cert-manager's DEFAULT encoding is PKCS#1, so all three are tried. +func NewFileSource(certPath, keyPath string) (CASource, error) { + certPEM, err := os.ReadFile(certPath) + if err != nil { + return nil, fmt.Errorf("tlsbridge: read CA cert %s: %w", certPath, err) + } + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("tlsbridge: read CA key %s: %w", keyPath, err) + } + cb, _ := pem.Decode(certPEM) + if cb == nil { + return nil, fmt.Errorf("tlsbridge: CA cert %s is not PEM", certPath) + } + cert, err := x509.ParseCertificate(cb.Bytes) + if err != nil { + return nil, fmt.Errorf("tlsbridge: parse CA cert: %w", err) + } + kb, _ := pem.Decode(keyPEM) + if kb == nil { + return nil, fmt.Errorf("tlsbridge: CA key %s is not PEM", keyPath) + } + key, err := parsePrivateKey(kb.Bytes) + if err != nil { + return nil, err + } + return &staticSource{cert: cert, key: key, certPEM: certPEM}, nil +} + +// parsePrivateKey accepts PKCS#8, PKCS#1 (RSA) and SEC1 (EC) DER. +func parsePrivateKey(der []byte) (crypto.Signer, error) { + if k, err := x509.ParsePKCS8PrivateKey(der); err == nil { + if s, ok := k.(crypto.Signer); ok { + return s, nil + } + return nil, fmt.Errorf("tlsbridge: PKCS#8 key is not a crypto.Signer") + } + if k, err := x509.ParsePKCS1PrivateKey(der); err == nil { + return k, nil + } + if k, err := x509.ParseECPrivateKey(der); err == nil { + return k, nil + } + return nil, fmt.Errorf("tlsbridge: unsupported CA key format (tried PKCS#8, PKCS#1, SEC1)") +} +``` + +- [ ] **Step 4: Add a FileSource round-trip test (proves PKCS#1 + PKCS#8 both load)** + +Append to `ca_test.go`: + +```go +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509/pkix" + "math/big" + "os" + "path/filepath" + "time" +) + +func TestFileSource_LoadsPKCS1AndPKCS8(t *testing.T) { + for _, tc := range []struct { + name string + pkcs8 bool + }{{"ec-pkcs8", true}, {"ec-sec1", false}} { + t.Run(tc.name, func(t *testing.T) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "t"}, + NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), + IsCA: true, KeyUsage: x509.KeyUsageCertSign, BasicConstraintsValid: true, + } + der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + dir := t.TempDir() + certP := filepath.Join(dir, "tls.crt") + keyP := filepath.Join(dir, "tls.key") + os.WriteFile(certP, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600) + var keyDER []byte + if tc.pkcs8 { + keyDER, _ = x509.MarshalPKCS8PrivateKey(key) + } else { + keyDER, _ = x509.MarshalECPrivateKey(key) + } + os.WriteFile(keyP, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600) + if _, err := NewFileSource(certP, keyP); err != nil { + t.Fatalf("NewFileSource(%s): %v", tc.name, err) + } + }) + } +} +``` + +- [ ] **Step 5: Run the tests — expect PASS** + +Run: `go test ./tlsbridge/ -run 'TestEphemeralSource|TestFileSource' -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add authbridge/authlib/tlsbridge/ca.go authbridge/authlib/tlsbridge/ca_test.go +git commit -s -m "feat(tlsbridge): CASource (ephemeral + file, multi-format key)" +``` + +--- + +## Task 2: Minter — per-host leaf (shared key) + cache + +**Files:** +- Create: `authlib/tlsbridge/minter.go` +- Test: `authlib/tlsbridge/minter_test.go` + +> **Decision (locked):** one shared P-256 leaf key, generated once in `NewMinter` and reused for every minted leaf — only the certificate is per-host. The leaf key is not the secret here (it lives behind the same trust boundary as the CA); the CA key is what's protected. + +- [ ] **Step 1: Write the failing tests** + +```go +package tlsbridge + +import ( + "crypto/tls" + "crypto/x509" + "net" + "testing" + "time" +) + +func newTestMinter(t *testing.T) (*Minter, *x509.CertPool) { + t.Helper() + src, err := NewEphemeralSource() + if err != nil { + t.Fatalf("ca: %v", err) + } + m := NewMinter(src, MinterOpts{CacheMax: 8, LeafTTL: time.Hour}) + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(src.CACertPEM()) { + t.Fatal("append CA to pool") + } + return m, pool +} + +func TestMinter_LeafChainsToCA_AndHasSAN(t *testing.T) { + m, pool := newTestMinter(t) + cert, err := m.GetCertificate(&tls.ClientHelloInfo{ServerName: "api.example.com"}) + if err != nil { + t.Fatalf("GetCertificate: %v", err) + } + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + t.Fatalf("parse leaf: %v", err) + } + if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool, DNSName: "api.example.com"}); err != nil { + t.Errorf("leaf does not verify against CA for its SAN: %v", err) + } +} + +func TestMinter_IPLiteralSAN(t *testing.T) { + m, pool := newTestMinter(t) + c2, err := m.GetCertificateForHost("10.0.0.5") + if err != nil { + t.Fatalf("GetCertificateForHost(ip): %v", err) + } + leaf, _ := x509.ParseCertificate(c2.Certificate[0]) + if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool}); err != nil { + // IP SANs verify via IPAddresses, not DNSName; the explicit SAN check below is authoritative. + } + found := false + for _, ip := range leaf.IPAddresses { + if ip.Equal(net.ParseIP("10.0.0.5")) { + found = true + } + } + if !found { + t.Errorf("leaf for IP host lacks the IP SAN") + } +} + +func TestMinter_CacheHitReturnsSameCert(t *testing.T) { + m, _ := newTestMinter(t) + a, _ := m.GetCertificate(&tls.ClientHelloInfo{ServerName: "h.example.com"}) + b, _ := m.GetCertificate(&tls.ClientHelloInfo{ServerName: "h.example.com"}) + if &a.Certificate[0][0] != &b.Certificate[0][0] { + t.Errorf("expected cached cert reuse for same SNI") + } +} +``` + +- [ ] **Step 2: Run — expect compile failure** + +Run: `go test ./tlsbridge/ -run TestMinter -v` +Expected: FAIL — `undefined: NewMinter` / `MinterOpts`. + +- [ ] **Step 3: Implement `minter.go`** + +```go +package tlsbridge + +import ( + "container/list" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "net" + "sync" + "time" +) + +type MinterOpts struct { + CacheMax int // max cached leaves (LRU); <=0 → 1024 + LeafTTL time.Duration // leaf validity AND cache TTL; <=0 → 24h +} + +// Minter mints per-host leaf certs signed by a CASource, cached LRU+TTL by host. +type Minter struct { + src CASource + max int + ttl time.Duration + leafKey *ecdsa.PrivateKey // one key reused across leaves (cheaper; key is not the secret here) + + mu sync.Mutex + ll *list.List // MRU front + items map[string]*list.Element // host -> element(*cacheEntry) +} + +type cacheEntry struct { + host string + cert *tls.Certificate + expires time.Time +} + +func NewMinter(src CASource, o MinterOpts) *Minter { + if o.CacheMax <= 0 { + o.CacheMax = 1024 + } + if o.LeafTTL <= 0 { + o.LeafTTL = 24 * time.Hour + } + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &Minter{ + src: src, max: o.CacheMax, ttl: o.LeafTTL, leafKey: key, + ll: list.New(), items: make(map[string]*list.Element), + } +} + +// GetCertificate satisfies tls.Config.GetCertificate via the SNI server name. +func (m *Minter) GetCertificate(chi *tls.ClientHelloInfo) (*tls.Certificate, error) { + host := chi.ServerName + if host == "" { + return nil, fmt.Errorf("tlsbridge: no SNI; caller must use GetCertificateForHost with the dialed IP") + } + return m.GetCertificateForHost(host) +} + +func (m *Minter) GetCertificateForHost(host string) (*tls.Certificate, error) { + m.mu.Lock() + defer m.mu.Unlock() + if el, ok := m.items[host]; ok { + e := el.Value.(*cacheEntry) + if time.Now().Before(e.expires) { + m.ll.MoveToFront(el) + return e.cert, nil + } + m.ll.Remove(el) + delete(m.items, host) + } + cert, err := m.mint(host) + if err != nil { + return nil, err + } + el := m.ll.PushFront(&cacheEntry{host: host, cert: cert, expires: time.Now().Add(m.ttl)}) + m.items[host] = el + for m.ll.Len() > m.max { + back := m.ll.Back() + m.ll.Remove(back) + delete(m.items, back.Value.(*cacheEntry).host) + } + return cert, nil +} + +func (m *Minter) mint(host string) (*tls.Certificate, error) { + caCert, caKey := m.src.Issuer() + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: host}, + NotBefore: time.Now().Add(-time.Minute), + // Leaf validity must exceed the cache TTL so a cached leaf never serves past expiry. + NotAfter: time.Now().Add(m.ttl + time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + if ip := net.ParseIP(host); ip != nil { + tmpl.IPAddresses = []net.IP{ip} + } else { + tmpl.DNSNames = []string{host} + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, m.leafKey.Public(), caKey) + if err != nil { + return nil, fmt.Errorf("tlsbridge: mint leaf for %s: %w", host, err) + } + return &tls.Certificate{ + Certificate: [][]byte{der, caCert.Raw}, + PrivateKey: m.leafKey, + }, nil +} +``` + +- [ ] **Step 4: Run — expect PASS** + +Run: `go test ./tlsbridge/ -run TestMinter -v` +Expected: PASS (all three). + +- [ ] **Step 5: Commit** + +```bash +git add authbridge/authlib/tlsbridge/minter.go authbridge/authlib/tlsbridge/minter_test.go +git commit -s -m "feat(tlsbridge): per-host leaf Minter (shared P-256 key) with LRU/TTL cache" +``` + +--- + +## Task 3: Decision + SkipSet + +**Files:** +- Create: `authlib/tlsbridge/decision.go` +- Test: `authlib/tlsbridge/decision_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +package tlsbridge + +import "testing" + +func TestDecision_Classify(t *testing.T) { + d := NewDecision(DecisionOpts{ + Ports: map[int]bool{443: true, 8443: true}, + Scope: ScopeExternal, + InternalCIDRs: []string{"10.0.0.0/8"}, + SkipHosts: []string{"pinned.example.com"}, + }) + tlsHello := []byte{0x16, 0x03, 0x01, 0x00, 0x05} // handshake, TLS1.0 record, len + cases := []struct { + name string + host string + ip string + port int + first []byte + expect Verdict + reason string + }{ + {"happy external https", "api.example.com", "93.184.216.34", 443, tlsHello, Terminate, ""}, + {"non-tls first byte", "api.example.com", "93.184.216.34", 443, []byte("GET / "), Passthrough, "non-tls"}, + {"unlisted port", "api.example.com", "93.184.216.34", 9999, tlsHello, Passthrough, "port"}, + {"internal under external scope", "tool.svc", "10.96.1.2", 443, tlsHello, Passthrough, "in-cluster"}, + {"skip-listed host", "pinned.example.com", "1.2.3.4", 443, tlsHello, Passthrough, "skip"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v, reason := d.Classify(tc.host, tc.ip, tc.port, tc.first) + if v != tc.expect || (tc.expect == Passthrough && reason != tc.reason) { + t.Errorf("got (%v,%q), want (%v,%q)", v, reason, tc.expect, tc.reason) + } + }) + } +} + +func TestSkipSet_AutoSkip(t *testing.T) { + s := NewSkipSet() + if s.Contains("h") { + t.Fatal("empty set should not contain h") + } + s.Add("h") + if !s.Contains("h") { + t.Error("Add then Contains failed") + } +} +``` + +- [ ] **Step 2: Run — expect compile failure** + +Run: `go test ./tlsbridge/ -run 'TestDecision|TestSkipSet' -v` +Expected: FAIL — `undefined: NewDecision`. + +- [ ] **Step 3: Implement `decision.go`** + +```go +package tlsbridge + +import ( + "net" + "sync" +) + +type Verdict int + +const ( + Passthrough Verdict = iota + Terminate +) + +type Scope int + +const ( + ScopeExternal Scope = iota // default: do not bridge internal/mesh destinations + ScopeAll // bridge everything eligible (no-mesh / standalone) +) + +type DecisionOpts struct { + Ports map[int]bool + Scope Scope + InternalCIDRs []string + SkipHosts []string +} + +type Decision struct { + ports map[int]bool + scope Scope + internal []*net.IPNet + skip map[string]bool +} + +func NewDecision(o DecisionOpts) *Decision { + d := &Decision{ports: o.Ports, scope: o.Scope, skip: map[string]bool{}} + if d.ports == nil { + d.ports = map[int]bool{443: true, 8443: true} + } + for _, c := range o.InternalCIDRs { + if _, n, err := net.ParseCIDR(c); err == nil { + d.internal = append(d.internal, n) + } + } + for _, h := range o.SkipHosts { + d.skip[h] = true + } + return d +} + +// Classify decides whether to bridge. first is the peeked client bytes. +func (d *Decision) Classify(host, ip string, port int, first []byte) (Verdict, string) { + if !d.ports[port] { + return Passthrough, "port" + } + if !looksLikeTLSClientHello(first) { + return Passthrough, "non-tls" + } + if d.skip[host] { + return Passthrough, "skip" + } + if d.scope == ScopeExternal && d.isInternal(ip) { + return Passthrough, "in-cluster" + } + return Terminate, "" +} + +func (d *Decision) isInternal(ip string) bool { + parsed := net.ParseIP(ip) + if parsed == nil { + return false // a hostname (not an IP) is never matched as in-cluster — see CONNECT-path note + } + for _, n := range d.internal { + if n.Contains(parsed) { + return true + } + } + return false +} + +// looksLikeTLSClientHello validates the 5-byte TLS record header (not just 0x16): +// content type 22 (handshake), legacy record version 0x03 0x01-0x04. +func looksLikeTLSClientHello(b []byte) bool { + if len(b) < 5 { + return false + } + return b[0] == 0x16 && b[1] == 0x03 && b[2] <= 0x04 +} + +// SkipSet is the runtime auto-skip set (hosts whose minted leaf the client +// rejected). Concurrent-safe; augments the static skip list. +type SkipSet struct { + mu sync.RWMutex + m map[string]bool +} + +func NewSkipSet() *SkipSet { return &SkipSet{m: map[string]bool{}} } +func (s *SkipSet) Add(host string) { + s.mu.Lock() + s.m[host] = true + s.mu.Unlock() +} +func (s *SkipSet) Contains(host string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.m[host] +} +``` + +- [ ] **Step 4: Run — expect PASS** + +Run: `go test ./tlsbridge/ -run 'TestDecision|TestSkipSet' -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add authbridge/authlib/tlsbridge/decision.go authbridge/authlib/tlsbridge/decision_test.go +git commit -s -m "feat(tlsbridge): Decision (record-header + scope gate) and SkipSet" +``` + +--- + +## Task 4: UpstreamClient — system + injected roots + +**Files:** +- Create: `authlib/tlsbridge/upstream.go` +- Test: `authlib/tlsbridge/upstream_test.go` + +- [ ] **Step 1: Write the failing test** (private-CA origin must verify when its CA is injected, and fail when not) + +```go +package tlsbridge + +import ( + "encoding/pem" + "net/http" + "net/http/httptest" + "testing" +) + +func TestUpstreamClient_InjectedRootVerifies(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}) + + // With the origin's CA injected → verifies. + good, err := NewUpstreamClient(caPEM) + if err != nil { + t.Fatalf("NewUpstreamClient: %v", err) + } + resp, err := good.Get(srv.URL) + if err != nil { + t.Fatalf("expected success with injected root, got %v", err) + } + resp.Body.Close() + + // Without it (system roots only) → the self-signed httptest cert is rejected. + bare, _ := NewUpstreamClient(nil) + if _, err := bare.Get(srv.URL); err == nil { + t.Errorf("expected verification failure with system roots only") + } +} +``` + +- [ ] **Step 2: Run — expect compile failure** + +Run: `go test ./tlsbridge/ -run TestUpstreamClient -v` +Expected: FAIL — `undefined: NewUpstreamClient`. + +- [ ] **Step 3: Implement `upstream.go`** + +```go +package tlsbridge + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "time" +) + +// NewUpstreamClient builds the HTTP client the TLS bridge uses to re-originate +// to the real origin. RootCAs = system roots + extraRootsPEM (the agent's +// injected upstream trust). It NEVER sets InsecureSkipVerify and never uses the +// mesh-mTLS dialer — re-origination must verify the origin the way the agent would. +func NewUpstreamClient(extraRootsPEM []byte) (*http.Client, error) { + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + if len(extraRootsPEM) > 0 { + if !pool.AppendCertsFromPEM(extraRootsPEM) { + return nil, fmt.Errorf("tlsbridge: upstream_ca_bundle is not valid PEM") + } + } + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: time.Second, + }, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, + }, nil +} +``` + +- [ ] **Step 4: Run — expect PASS** + +Run: `go test ./tlsbridge/ -run TestUpstreamClient -v` +Expected: PASS (injected-root success; system-roots-only failure). + +- [ ] **Step 5: Commit** + +```bash +git add authbridge/authlib/tlsbridge/upstream.go authbridge/authlib/tlsbridge/upstream_test.go +git commit -s -m "feat(tlsbridge): upstream client with system + injected roots" +``` + +--- + +## Task 5: Terminator + ServeConn (h2 keep-alive) + +**Files:** +- Create: `authlib/tlsbridge/terminator.go`, `authlib/tlsbridge/serve.go` +- Test: `authlib/tlsbridge/terminator_test.go` + +- [ ] **Step 1: Write the failing test** (client trusting the ephemeral CA handshakes through the Terminator; ALPN offers h2+http/1.1) + +```go +package tlsbridge + +import ( + "crypto/tls" + "crypto/x509" + "net" + "testing" + "time" +) + +func TestTerminator_AgentTrustingCAHandshakes(t *testing.T) { + src, _ := NewEphemeralSource() + m := NewMinter(src, MinterOpts{}) + term := NewTerminator(m) + + c1, c2 := net.Pipe() + defer c1.Close() + defer c2.Close() + + errc := make(chan error, 1) + go func() { + tconn, err := term.Terminate(c2, "api.example.com") + if err != nil { + errc <- err + return + } + _ = tconn.Close() + errc <- nil + }() + + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(src.CACertPEM()) + client := tls.Client(c1, &tls.Config{ServerName: "api.example.com", RootCAs: pool, NextProtos: []string{"h2", "http/1.1"}}) + _ = c1.SetDeadline(time.Now().Add(2 * time.Second)) + if err := client.Handshake(); err != nil { + t.Fatalf("client handshake failed (agent should trust minted leaf): %v", err) + } + if alpn := client.ConnectionState().NegotiatedProtocol; alpn != "h2" && alpn != "http/1.1" { + t.Errorf("unexpected ALPN %q", alpn) + } + if err := <-errc; err != nil { + t.Fatalf("terminator: %v", err) + } +} +``` + +- [ ] **Step 2: Run — expect compile failure** + +Run: `go test ./tlsbridge/ -run TestTerminator -v` +Expected: FAIL — `undefined: NewTerminator`. + +- [ ] **Step 3: Implement `terminator.go`** + +```go +package tlsbridge + +import ( + "crypto/tls" + "net" +) + +// Terminator wraps a sniffed client conn as a tls.Server, using the Minter to +// forge a per-SNI leaf. ALPN offers h2 + http/1.1. +type Terminator struct { + minter *Minter +} + +func NewTerminator(m *Minter) *Terminator { return &Terminator{minter: m} } + +// Terminate completes the server-side TLS handshake against the agent. host is +// the dialed name/IP, used to mint when the ClientHello carries no SNI. +func (t *Terminator) Terminate(client net.Conn, host string) (*tls.Conn, error) { + cfg := &tls.Config{ + NextProtos: []string{"h2", "http/1.1"}, + GetCertificate: func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) { + if chi.ServerName != "" { + return t.minter.GetCertificateForHost(chi.ServerName) + } + return t.minter.GetCertificateForHost(host) + }, + } + conn := tls.Server(client, cfg) + if err := conn.Handshake(); err != nil { + return nil, err + } + return conn, nil +} +``` + +- [ ] **Step 4: Implement `serve.go` (one-conn keep-alive + h2)** + +```go +package tlsbridge + +import ( + "crypto/tls" + "net" + "net/http" + "sync" + + "golang.org/x/net/http2" +) + +// oneConnListener serves exactly one already-accepted conn to http.Server.Serve, +// so keep-alive (multiple requests on the same TLS conn) works. +type oneConnListener struct { + mu sync.Mutex + conn net.Conn +} + +func (l *oneConnListener) Accept() (net.Conn, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.conn == nil { + return nil, net.ErrClosed + } + c := l.conn + l.conn = nil + return c, nil +} +func (l *oneConnListener) Close() error { return nil } +func (l *oneConnListener) Addr() net.Addr { return dummyAddr{} } + +type dummyAddr struct{} + +func (dummyAddr) Network() string { return "tcp" } +func (dummyAddr) String() string { return "tls-bridge" } + +// ServeConn drives an already-terminated TLS conn through handler with HTTP +// keep-alive, negotiating h2 when ALPN selected it. +func ServeConn(tconn *tls.Conn, handler http.Handler) { + srv := &http.Server{Handler: handler} + if tconn.ConnectionState().NegotiatedProtocol == "h2" { + h2s := &http2.Server{} + h2s.ServeConn(tconn, &http2.ServeConnOpts{Handler: handler, BaseConfig: srv}) + return + } + _ = srv.Serve(&oneConnListener{conn: tconn}) +} +``` + +- [ ] **Step 5: Run terminator test — expect PASS, then whole package** + +Run: `go test ./tlsbridge/ -run TestTerminator -v` +Expected: PASS. Then `go test ./tlsbridge/ -v` — all green. + +- [ ] **Step 6: Promote the http2 dep and commit** + +Run: `cd authbridge/authlib && go get golang.org/x/net/http2 && go mod tidy` +Expected: `golang.org/x/net` becomes a direct dependency. + +```bash +git add authbridge/authlib/tlsbridge/terminator.go authbridge/authlib/tlsbridge/serve.go authbridge/authlib/tlsbridge/terminator_test.go authbridge/authlib/go.mod authbridge/authlib/go.sum +git commit -s -m "feat(tlsbridge): Terminator + one-conn keep-alive ServeConn (h2)" +``` + +--- + +## Task 6: `Engine` facade, `serveOutbound` extraction, sniff `Peek`, helpers + +**Files:** +- Create: `authlib/tlsbridge/engine.go` +- Modify: `authlib/listener/forwardproxy/server.go` (extract `serveOutbound`; add `TLSBridge` field; helpers) +- Modify: `authlib/listener/forwardproxy/sniff.go` (add `(*peekedConn).Peek`) + +- [ ] **Step 1: Write `engine.go` (facade the proxy holds)** + +```go +package tlsbridge + +import "net/http" + +// Engine bundles everything the forward proxy needs to bridge TLS. +// A nil *Engine means the bridge is disabled. +type Engine struct { + Decision *Decision + Term *Terminator + Skip *SkipSet + Upstream *http.Client + CAPEM []byte +} +``` + +- [ ] **Step 2: Add `Peek` to `peekedConn` in `sniff.go`** + +After the `peekedConn` definition (`sniff.go:148-153`): + +```go +// Peek returns the next n buffered bytes without consuming them. Used by the +// TLS-bridge classify step on the CONNECT and transparent paths. +func (c *peekedConn) Peek(n int) ([]byte, error) { return c.r.Peek(n) } +``` + +- [ ] **Step 3: Add the `TLSBridge` field + helpers + extract `serveOutbound` in `server.go`** + +Add the field to the `Server` struct (after `SkipHosts`): + +```go + TLSBridge *tlsbridge.Engine // nil = disabled +``` + +Add the import `"github.com/kagenti/kagenti-extensions/authbridge/authlib/tlsbridge"` and `"strconv"`. + +Add helpers (bottom of `server.go`): + +```go +// hostOnly strips the port from an authority ("h:443" → "h"); returns input if no port. +func hostOnly(authority string) string { + if h, _, err := net.SplitHostPort(authority); err == nil { + return h + } + return authority +} + +// portOf returns the port from an authority, defaulting to 443. +func portOf(authority string) int { + if _, p, err := net.SplitHostPort(authority); err == nil { + if n, err := strconv.Atoi(p); err == nil { + return n + } + } + return 443 +} + +// nameOrIP prefers the sniffed SNI name, falling back to the dialed IP. +func nameOrIP(name, ip string) string { + if name != "" { + return name + } + return ip +} +``` + +Extract the body of `handleRequest` (everything after the `MethodConnect` check) into `serveOutbound`. The **only behavioral change** is the re-origination client selection: + +```go +// serveOutbound runs the outbound pipeline for one decrypted/plaintext request +// and re-originates it. isBridge=true marks requests produced by TLS bridging: +// they are origin-form (the handler sets r.URL.Scheme/Host) and must re-originate +// via the dedicated upstream client, never the mesh-mTLS s.Client. +func (s *Server) serveOutbound(w http.ResponseWriter, r *http.Request, isBridge bool) { + // ... existing handleRequest body (pctx build :197-206, skip logic, pipeline.Run + // :258-270, Authorization/body/hop-by-hop handling :305-329, RequestURI clear + // :332, response phase) ... + + // At the single re-origination site (was `resp, err := s.Client.Do(r)` :334): + client := s.Client + if isBridge && s.TLSBridge != nil { + client = s.TLSBridge.Upstream + } + resp, err := client.Do(r) + // ... unchanged response handling ... +} + +func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + s.handleConnect(w, r) + return + } + s.serveOutbound(w, r, false) +} +``` + +> **No `!isBridge` guard is needed around the hop-by-hop / `Proxy-*` strip (`:320-329`).** Deleting `Connection`/`Proxy-Authorization`/etc. from an origin-form request is a harmless no-op (those headers aren't present). The `isBridge` flag's *only* job is selecting the upstream client. + +- [ ] **Step 4: Run the existing forward-proxy suite — expect PASS (pure refactor)** + +Run: `cd authbridge/authlib && go test ./listener/forwardproxy/ ./tlsbridge/ -v` +Expected: PASS — the refactor is behavior-preserving for the plaintext path. + +- [ ] **Step 5: Commit** + +```bash +git add authbridge/authlib/tlsbridge/engine.go authbridge/authlib/listener/forwardproxy/server.go authbridge/authlib/listener/forwardproxy/sniff.go +git commit -s -m "refactor(forwardproxy): extract serveOutbound; add tlsbridge.Engine facade + sniff Peek" +``` + +--- + +## Task 7: Reversible bridge branch on the transparent path + +**Files:** +- Modify: `authlib/listener/forwardproxy/transparent.go` (bridge-or-tunnel before `tunnel(...)` at `:125`) +- Modify: `authlib/listener/forwardproxy/server.go` (add `bridgeServe`) +- Test: `authlib/listener/forwardproxy/tlsbridge_integration_test.go` + +- [ ] **Step 1: Write the failing integration test** + +```go +package forwardproxy + +// Build a Server with a tlsbridge.Engine (ephemeral CA). Drive HandleTransparentConn +// with a tls.Client that trusts the CA, targeting an httptest TLS origin whose CA is +// in the engine's upstream client. Register a probe plugin that records the decrypted +// request path. Assert: probe saw "/secret", response body intact. +func TestTransparentBridge_DecryptsAndRunsPipeline(t *testing.T) { + // ... uses NewEphemeralSource, NewMinter, NewTerminator, NewDecision(ScopeAll), + // NewUpstreamClient(originCA), a recording probe plugin, httptest.NewTLSServer + // origin. Wire engine onto Server.TLSBridge, run a goroutine that calls + // s.HandleTransparentConn(serverSideConn, originIP:port), and drive a + // tls.Client trusting the ephemeral CA over the other end of a net.Pipe / + // loopback. (Mirror server_test.go's Server construction + pipeline probe.) +} +``` + +(Construct the recording probe via the existing `pipeline` test helpers used in `server_test.go`; mirror that file's Server construction.) + +- [ ] **Step 2: Run — expect FAIL** (`Server.TLSBridge` is nil; transparent path still tunnels). + +- [ ] **Step 3: Add `bridgeServe` to `server.go`** (shared by both paths): + +```go +// bridgeServe attempts to bridge: verify the upstream origin first (reversibility), +// then forge a leaf + terminate the agent TLS + run the UNCHANGED pipeline via +// serveOutbound. authority is host:port (used to dial+verify upstream and to set +// r.URL.Host); host is the skip/log key. Returns true if it consumed the connection +// (success OR an unrecoverable post-forge failure that was logged); false to fall +// back to a plain tunnel — so no working call is ever broken. +func (s *Server) bridgeServe(client net.Conn, authority, host string) bool { + // 1) Verify upstream reachability + cert via the dedicated client, BEFORE forging. + // HEAD avoids GET side-effects; a non-2xx/4xx/5xx status still returns err==nil + // (cert verified), which is all we need. Only a transport/TLS error fails here. + resp, err := s.TLSBridge.Upstream.Head("https://" + authority) + if err != nil { + slog.Info("tls-bridge passthrough", "host", host, "reason", "upstream-verify", "error", err) + return false // fall back to plain tunnel — agent's own e2e TLS still reaches origin + } + _ = resp.Body.Close() + + // 2) Forge + terminate downstream. + tconn, err := s.TLSBridge.Term.Terminate(client, hostOnly(authority)) + if err != nil { + s.TLSBridge.Skip.Add(host) // pinned client → its retry will passthrough + slog.Warn("tls-bridge passthrough", "host", host, "reason", "handshake-fail", "error", err) + return true // conn is dead post-forge; nothing left to tunnel + } + + // 3) Serve the decrypted conn through the UNCHANGED pipeline. + tlsbridge.ServeConn(tconn, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.URL.Scheme = "https" + r.URL.Host = authority // host:port — preserves non-443 origins + s.serveOutbound(w, r, true) + })) + return true +} +``` + +- [ ] **Step 4: Implement the branch** in `HandleTransparentConn`, replacing `tunnel(clientConn, upstream)` (`transparent.go:125`). At this point `clientConn` is the `wrapped` `*peekedConn` and `name`/`host` are in scope: + +```go + if s.TLSBridge != nil { + ip := hostOnly(dst) + key := nameOrIP(name, ip) + var first []byte + if pc, ok := clientConn.(*peekedConn); ok { + first, _ = pc.Peek(5) + } + if !s.TLSBridge.Skip.Contains(key) { + if v, reason := s.TLSBridge.Decision.Classify(key, ip, portOf(dst), first); v == tlsbridge.Terminate { + authority := net.JoinHostPort(key, strconv.Itoa(portOf(dst))) + _ = upstream.Close() // bridgeServe dials its own verified upstream; drop the pre-dial + if s.bridgeServe(clientConn, authority, key) { + return + } + // bridgeServe fell open (upstream-verify failed) → re-dial for the tunnel. + if up2, derr := net.DialTimeout("tcp", dst, connectDialTimeout); derr == nil { + tunnel(clientConn, up2) + _ = up2.Close() + } + return + } else { + slog.Info("tls-bridge passthrough", "host", key, "reason", reason) + } + } + } + tunnel(clientConn, upstream) +``` + +(Add `"strconv"` to `transparent.go` imports if not present. The pre-dialed `upstream` is closed on the bridge path; the existing `defer upstream.Close()` makes the non-bridge fall-through safe.) + +- [ ] **Step 5: Run the integration test — expect PASS** + +Run: `go test ./listener/forwardproxy/ -run TestTransparentBridge -v` +Expected: PASS — probe recorded `/secret`, response intact. + +- [ ] **Step 6: Commit** + +```bash +git add authbridge/authlib/listener/forwardproxy/ +git commit -s -m "feat(forwardproxy): reversible TLS bridge on transparent path" +``` + +--- + +## Task 8: Bridge branch on the CONNECT path + +**Files:** +- Modify: `authlib/listener/forwardproxy/server.go` (`handleConnect`, the `tunnel(clientConn, upstream)` at `:822`) +- Test: add `TestConnectBridge_DecryptsAndRunsPipeline` to `tlsbridge_integration_test.go` + +- [ ] **Step 1: Write the failing CONNECT integration test** (same shape as Task 7 but the client sends `CONNECT host:443`, reads `200`, then starts a TLS ClientHello on the same conn). + +- [ ] **Step 2: Run — expect FAIL** (CONNECT still always tunnels after the 200). + +- [ ] **Step 3: Implement** — after writing `200 Connection Established` and `recordTunnelOpened`, before `tunnel(clientConn, upstream)` (`server.go:822`). Unlike the transparent path, nothing has sniffed yet, so wrap the hijacked conn in a fresh `peekedConn` (a `bufio.Reader` over it) to peek the agent's ClientHello and stay replayable: + +```go + if s.TLSBridge != nil { + pc := &peekedConn{Conn: clientConn, r: bufio.NewReaderSize(clientConn, sniffBufSize)} + clientConn = pc // replay peeked bytes into whichever path runs + first, _ := pc.Peek(5) + authority := r.Host // CONNECT target is already host:port + key := hostOnly(r.Host) + if !s.TLSBridge.Skip.Contains(key) { + // ip is empty for a hostname CONNECT target → scope=external won't match + // it as in-cluster (documented limitation; transparent path keys on the IP). + if v, _ := s.TLSBridge.Decision.Classify(key, hostOnly(r.Host), portOf(r.Host), first); v == tlsbridge.Terminate { + _ = upstream.Close() // bridgeServe dials its own verified upstream + if s.bridgeServe(clientConn, authority, key) { + return + } + if up2, derr := net.DialTimeout("tcp", r.Host, connectDialTimeout); derr == nil { + tunnel(clientConn, up2) + _ = up2.Close() + } + return + } + } + } + tunnel(clientConn, upstream) +``` + +(Add `"bufio"` to `server.go` imports if not present. `sniffBufSize` is the existing const in `sniff.go`, same package.) + +- [ ] **Step 4: Run — expect PASS** + +Run: `go test ./listener/forwardproxy/ -run TestConnectBridge -v` + +- [ ] **Step 5: Commit** + +```bash +git commit -s -am "feat(forwardproxy): reversible TLS bridge on CONNECT path" +``` + +--- + +## Task 9: No-broken-calls guarantees (the critical safety tests) + +**Files:** +- Test: `authlib/listener/forwardproxy/tlsbridge_safety_test.go` + +- [ ] **Step 1: Write the safety tests** (these encode the spec's success criteria #4/#5) + +```go +// 1) Untrusted/unreachable origin → upstream-verify (HEAD) fails → request is +// TUNNELED (passthrough), NOT failed. Drive an origin whose CA is NOT in the +// engine's upstream client; assert the agent's own end-to-end TLS still reaches it. +func TestBridge_UnverifiableUpstream_FallsOpenToTunnel(t *testing.T) { /* ... */ } + +// 2) Pinned client (rejects minted leaf) → host auto-skipped → retry passes through. +// Use a tls.Client with RootCAs that does NOT include the ephemeral CA; first +// call fails, assert Skip.Contains(host), second call tunnels & the agent's TLS +// reaches the origin. +func TestBridge_PinnedClient_AutoSkipsThenTunnels(t *testing.T) { /* ... */ } + +// 3) Non-TLS bytes on a bridge-eligible port → passthrough (no tls.Server attempt). +func TestBridge_NonTLS_Passthrough(t *testing.T) { /* ... */ } +``` + +- [ ] **Step 2: Run — fix any gaps in Task 7/8 logic until all three PASS** + +Run: `go test ./listener/forwardproxy/ -run TestBridge_ -v` +Expected: all PASS — no configuration of the bridge turns a working call into a hard failure. + +- [ ] **Step 3: Commit** + +```bash +git commit -s -am "test(tlsbridge): no-broken-calls guarantees (fall-open + auto-skip)" +``` + +--- + +## Task 10: `TLSBridgeConfig` + `main.go` wiring + trust self-check + +**Files:** +- Modify: `authlib/config/config.go` (add `TLSBridge *TLSBridgeConfig` block, mirroring `MTLS`/`SPIFFE`) +- Modify: `cmd/authbridge-proxy/main.go` (construct the engine, set `fpSrv.TLSBridge`, run self-check) +- Modify: `authlib/tlsbridge/engine.go` (add `RunTrustSelfCheck`) +- Test: `authlib/config/config_test.go` (decode + validate) + +- [ ] **Step 1: Write the config decode test** (`Load` takes a **path** — write a temp file) + +```go +func TestConfig_TLSBridgeBlockDecodes(t *testing.T) { + y := []byte("mode: proxy-sidecar\n" + + "tls_bridge:\n" + + " enabled: true\n" + + " scope: external\n" + + " ca_source: ephemeral\n" + + " skip_hosts: [\"pinned.example.com\"]\n") + p := filepath.Join(t.TempDir(), "cfg.yaml") + if err := os.WriteFile(p, y, 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(p) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.TLSBridge == nil || !cfg.TLSBridge.Enabled || cfg.TLSBridge.Scope != "external" { + t.Fatalf("tls_bridge block did not decode: %+v", cfg.TLSBridge) + } +} +``` + +(Add `"os"` and `"path/filepath"` to `config_test.go` imports if not present.) + +- [ ] **Step 2: Run — expect FAIL** (`cfg.TLSBridge` undefined). + +- [ ] **Step 3: Add `TLSBridgeConfig` to `config.go`** (after the `SPIFFE *SPIFFEConfig` field at `:39`): + +```go + // TLSBridge, when non-nil and Enabled, terminates agent outbound TLS so the + // outbound pipeline sees decrypted HTTPS. See docs/.../tlsbridge-design.md. + TLSBridge *TLSBridgeConfig `yaml:"tls_bridge,omitempty" json:"tls_bridge,omitempty"` +``` + +```go +type TLSBridgeConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Scope string `yaml:"scope" json:"scope"` // external | all + InternalCIDRs []string `yaml:"internal_cidrs" json:"internal_cidrs"` + CASource string `yaml:"ca_source" json:"ca_source"` // file | ephemeral + CACertPath string `yaml:"ca_cert_path" json:"ca_cert_path"` + CAKeyPath string `yaml:"ca_key_path" json:"ca_key_path"` + UpstreamCABundle string `yaml:"upstream_ca_bundle" json:"upstream_ca_bundle"` + SkipHosts []string `yaml:"skip_hosts" json:"skip_hosts"` +} + +// Validate is called from the loader when TLSBridge != nil. +func (b *TLSBridgeConfig) Validate() error { + if b.Scope != "" && b.Scope != "external" && b.Scope != "all" { + return fmt.Errorf("tls_bridge.scope must be 'external' or 'all', got %q", b.Scope) + } + if b.CASource == "file" && (b.CACertPath == "" || b.CAKeyPath == "") { + return fmt.Errorf("tls_bridge.ca_source=file requires ca_cert_path and ca_key_path") + } + return nil +} +``` + +Hook it into the loader beside the existing `MTLS`/`SPIFFE` validation (`config.go:441`/`:460`): + +```go + if cfg.TLSBridge != nil { + if err := cfg.TLSBridge.Validate(); err != nil { + return nil, err + } + } +``` + +- [ ] **Step 4: Run config test — expect PASS** + +Run: `go test ./config/ -run TestConfig_TLSBridge -v` + +- [ ] **Step 5: Add `RunTrustSelfCheck` to `engine.go`** + +```go +import ( + "log/slog" + "os" + "strings" +) + +// RunTrustSelfCheck logs a loud WARN when the bridge CA does not appear in the +// trust file the agent runtime is told to use (SSL_CERT_FILE / NODE_EXTRA_CA_CERTS). +// Best-effort: a trust-miss is then a visible signal, not an opaque handshake error. +// (In Phase 1 — test-only — no agent trust env is set, so this simply notes that.) +func RunTrustSelfCheck(caPEM []byte) { + for _, env := range []string{"SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE"} { + p := os.Getenv(env) + if p == "" { + continue + } + b, err := os.ReadFile(p) + if err == nil && strings.Contains(string(b), strings.TrimSpace(string(caPEM))) { + slog.Info("tls-bridge trust self-check OK", "env", env, "path", p) + return + } + } + slog.Warn("tls-bridge trust self-check: CA not found in any agent trust file " + + "(SSL_CERT_FILE/NODE_EXTRA_CA_CERTS/REQUESTS_CA_BUNDLE) — agent will not trust " + + "minted leaves; egress will safely tunnel. Expected in Phase 1 (test-only).") +} +``` + +- [ ] **Step 6: Wire `main.go`** — beside the `fpMTLS` construction (`main.go:243`), build the engine when enabled; set the field after `NewServer` (mirroring `fpSrv.SkipHosts`/`fpSrv.Shared`). main uses `slog`+`os.Exit`, not `log.Fatalf`: + +```go + var bridge *tlsbridge.Engine + if cfg.TLSBridge != nil && cfg.TLSBridge.Enabled { + var src tlsbridge.CASource + if cfg.TLSBridge.CASource == "file" { + src, err = tlsbridge.NewFileSource(cfg.TLSBridge.CACertPath, cfg.TLSBridge.CAKeyPath) + } else { + src, err = tlsbridge.NewEphemeralSource() + } + if err != nil { + slog.Error("tls-bridge CA init failed", "error", err) + os.Exit(1) + } + var extra []byte + if cfg.TLSBridge.UpstreamCABundle != "" { + if extra, err = os.ReadFile(cfg.TLSBridge.UpstreamCABundle); err != nil { + slog.Error("tls-bridge upstream_ca_bundle read failed", "error", err) + os.Exit(1) + } + } + up, uerr := tlsbridge.NewUpstreamClient(extra) + if uerr != nil { + slog.Error("tls-bridge upstream client failed", "error", uerr) + os.Exit(1) + } + minter := tlsbridge.NewMinter(src, tlsbridge.MinterOpts{}) + scope := tlsbridge.ScopeExternal + if cfg.TLSBridge.Scope == "all" { + scope = tlsbridge.ScopeAll + } + bridge = &tlsbridge.Engine{ + Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ + Scope: scope, InternalCIDRs: cfg.TLSBridge.InternalCIDRs, SkipHosts: cfg.TLSBridge.SkipHosts, + }), + Term: tlsbridge.NewTerminator(minter), + Skip: tlsbridge.NewSkipSet(), + Upstream: up, + CAPEM: src.CACertPEM(), + } + tlsbridge.RunTrustSelfCheck(bridge.CAPEM) + slog.Info("tls-bridge enabled", "scope", cfg.TLSBridge.Scope, "ca_source", cfg.TLSBridge.CASource) + } + + fpSrv, err := forwardproxy.NewServer(outboundH, sessions, fpMTLS) + // ... existing fpSrv.SkipHosts / fpSrv.Shared assignments ... + fpSrv.TLSBridge = bridge +``` + +(Add the `tlsbridge` import to `main.go`.) + +- [ ] **Step 7: Build everything + full test run** + +Run: `cd authbridge/authlib && go build ./... && go test ./...` +Run: `cd authbridge && go build ./cmd/authbridge-proxy/` +Expected: all green. + +- [ ] **Step 8: Commit** + +```bash +git add authbridge/authlib/config/ authbridge/cmd/authbridge-proxy/main.go authbridge/authlib/tlsbridge/ +git commit -s -m "feat(tlsbridge): config block + main wiring + trust self-check" +``` + +--- + +## Phase 1 done — definition of done + +- `go test ./...` green in `authbridge/authlib`; `authbridge-proxy` builds. +- With `tls_bridge.enabled` + ephemeral CA + an **in-process client trusting the CA**: agent HTTPS egress is decrypted, the **unchanged** pipeline runs (probe sees method/path/headers/body), the call reaches the real (verified) origin, response is byte-intact — proven by in-process integration tests. (A real operator-deployed agent does **not** trust the CA yet and safely tunnels — that's Phase 2.) +- The three safety tests pass: unverifiable upstream → tunnel; pinned client → auto-skip then tunnel; non-TLS → passthrough. **No bridge configuration breaks a working call.** +- h2 and h1.1 origins both work; keep-alive (2 requests / 1 conn) works. + +--- + +## Phase 2 compatibility (cert-manager path is the target — 2c) + +Phase 1 is built so the operator-coordinated cert-manager CA drops in with **no changes to the bridge core**: + +- **The seam is `CASource`.** Phase 1 defaults to `EphemeralSource`; 2c sets `ca_source: file` + paths and `main.go` calls `NewFileSource` instead — already implemented (Task 1) and selected (Task 10 Step 6). `Minter`, `Terminator`, `Decision`, `ServeConn`, `bridgeServe`, `serveOutbound` are all CA-agnostic. +- **Key formats:** `FileSource` parses PKCS#8 / PKCS#1 / SEC1, so cert-manager's default (PKCS#1) loads (Task 1). +- **Name Constraints (2c CA):** no Minter change. A leaf for an origin outside the CA's permitted names simply fails the agent's verify → auto-skip → tunnel — the existing no-broken-calls path. +- **`upstream_ca_bundle`** already feeds the re-origination client (Task 4/10), independent of the signing CA. + +Phase 2 (operator) is intentionally **not** bite-sized here. When Phase 1 lands, write its own plan covering: `MITMMode`→**`TLSBridgeMode`** CRD field + resolution; per-agent cert-manager `Certificate`/`Issuer` reconciler with Name Constraints; cert-manager + namespaced-`Issuer` write RBAC; webhook hard-mounts (sidecar `tls.crt`+`tls.key` `0400`; agent `ca.crt` + trust env, `Optional:false` to gate pod start); `tls_bridge: {ca_source: file, ...}` config render; `:9094` session-API localhost-bind + raw-body redaction when the bridge is on; E2E on OpenShell; CA rotation follow-up. + +--- + +## Self-review notes + +- **Spec coverage:** every Phase-1 spec item maps to a task — `tlsbridge/` units (T1–T5), serveOutbound + full-pipeline reuse (T6), reversible decision + upstream-verify-first (T7/T8 `bridgeServe`), auto-skip (T7/T9), interception scope gate (T3/T10), upstream client with injected roots (T4), h2 + keep-alive (T5), config + wiring + self-check (T10), no-broken-calls (T9). Operator items → Phase 2. +- **Fixes folded in from the end-to-end audit (2026-06-17):** `Load(path)` not `Load(bytes)` (T10 temp file); `slog`+`os.Exit` not `log.Fatalf` (T10); `(*peekedConn).Peek` added since `sniffHost` discards the ClientHello (T6) — replaces the undefined `peekFirstBytes`/`peek(5)`; `hostOnly`/`portOf`/`nameOrIP` helpers defined (T6/T7); CONNECT `peekedConn` constructed **with** its `bufio.Reader` (T8); `serveOutbound` selects `s.TLSBridge.Upstream` for bridge re-origination, never the mesh-mTLS `s.Client` (T6, **core correctness**); authority (`host:port`) carried so non-443 origins re-originate and verify on the right port (T7/T8); upstream verify uses `HEAD` to avoid `GET /` side-effects (T7); `FileSource` multi-format key parsing for 2c (T1); pre-dialed upstream closed and re-dialed only on fall-open (T7/T8). +- **Known minor limitations (documented, not bugs):** CONNECT-path `scope=external` keys on a hostname (no IP) so it won't classify a hostname-addressed in-cluster service as internal — the transparent path (which has the SO_ORIGINAL_DST IP) does; the bridge pays one upstream HEAD per agent connection (reversibility cost) plus the relay handshake. +- **Type consistency:** `GetCertificateForHost(host string)`, `Terminate(client net.Conn, host string)`, `Classify(host, ip string, port int, first []byte) (Verdict, string)`, `Engine{Decision,Term,Skip,Upstream,CAPEM}`, `bridgeServe(client net.Conn, authority, host string) bool`, `serveOutbound(w, r, isBridge bool)` are used consistently across tasks. diff --git a/authbridge/docs/superpowers/specs/2026-06-12-authbridge-tlsbridge-design.md b/authbridge/docs/superpowers/specs/2026-06-12-authbridge-tlsbridge-design.md new file mode 100644 index 000000000..12fe668c2 --- /dev/null +++ b/authbridge/docs/superpowers/specs/2026-06-12-authbridge-tlsbridge-design.md @@ -0,0 +1,286 @@ +# AuthBridge Egress TLS Bridge — Design + +**Date:** 2026-06-12 (revised after adversarial review; renamed 2026-06-17) +**Status:** Converged design — ready for implementation plan. +**Repos touched:** `kagenti-extensions/AuthBridge` (proxy), `kagenti-operator` (CA coordination) + +> **Terminology:** this feature was originally called "MITM". It is now the +> **TLS bridge** — terminate one TLS connection, run the pipeline on plaintext, +> originate a fresh verified TLS connection (literally a bridge between two TLS +> connections); package `tlsbridge`, config block `tls_bridge:`. The body below +> still uses "MITM" in places as a synonym for the same terminate-and-re-originate +> mechanism. The implementation plan is +> `plans/2026-06-17-authbridge-tlsbridge-phase1.md`. + +## Problem + +AuthBridge's default Go proxy (`proxy-sidecar` mode) **never decrypts TLS**. For HTTPS it blind-tunnels: +`io.Copy` after CONNECT (`authlib/listener/forwardproxy/server.go`) or transparent intercept +(`authlib/listener/forwardproxy/transparent.go`). Every content plugin (token-exchange, A2A/MCP parsing, +inference parsing, IBAC, guardrails) runs **only on plaintext** (the pipeline `Run` in `forwardproxy`). So +wherever the agent speaks TLS to an origin and nothing else terminates it (standalone, or an OpenShell +sandbox with OpenShell's own proxy suppressed), AuthBridge's headline features silently do nothing on +HTTPS egress. + +This design adds **MITM (TLS-terminating interception)** to the Go proxy so the **existing outbound +pipeline sees the decrypted request, unchanged**. MITM = *forging an identity*: minting a leaf to +impersonate the remote origin so the agent's TLS terminates at AuthBridge. That only applies to +**agent-initiated outbound HTTPS**. + +## Goals / Non-goals + +**Goals** +- Decrypt agent **outbound** HTTPS (transparent + CONNECT paths) and feed the **existing, unchanged** + outbound pipeline. No plugin changes — plugins already self-gate (route-matched, body-matched, opt-in). +- General AuthBridge capability (mesh / standalone / sandbox), **validated first** in the OpenShell + suppressed-proxy scenario where trust injection is easiest. +- Operator-coordinated trust so the agent trusts AuthBridge's CA with no manual steps, **on supported + runtimes** (see Trust distribution). +- Never turn a currently-working agent call into a broken one: re-origination preserves the agent's trust + semantics; un-MITMable traffic fails *open* (passthrough) and self-heals. + +**Non-goals (this iteration)** +- **Inbound TLS termination** — that presents the agent's *own real* SVID (not forging) and is already the + existing `mtls:` reverse-proxy block's job; not MITM. +- Replacing SPIRE mTLS for sidecar-to-sidecar (orthogonal). +- Forcing cert-pinned origins through inspection (they fail open to passthrough + auto-skip). + +> **HTTP/2 is in scope, not deferred.** h1.1-only ALPN silently breaks h2-only origins (much of modern +> HTTPS). The Terminator must offer `h2` + `http/1.1` and serve h2 via `http2.ConfigureServer`. (An h1.1-only +> first cut may exist transiently during implementation, but h2 is a prerequisite for enabling MITM by +> default — it is not a fast-follow.) + +## Design decisions + +| Topic | Decision | +|-------|----------| +| Direction | **Outbound only** (agent-initiated HTTPS). Inbound TLS is the existing `mtls:` concern, not MITM. | +| Plugins | **Unchanged.** MITM produces plaintext; the existing pipeline runs as-is. Plugins self-gate, so all stay enabled (token-exchange route-gated/passthrough-default; mcp/a2a parsers body-matched + observe-only; IBAC opt-in). The plugin layer is genuinely "no new code." | +| Interception scope | A **configurable host/CIDR gate** in `Decision`. Default: **external-egress only** (perf; avoids redundant in-cluster double-decrypt where a mesh already gives the receiving sidecar L7-able plaintext). Overridable to include in-cluster for **no-mesh / standalone / OpenShell**, where MITM is the *only* way to inspect in-cluster HTTPS. | +| Upstream verification | A **dedicated upstream `http.Client`** whose `RootCAs` = system roots **+** the agent's injected trust bundle. Re-origination NEVER uses the SPIRE-mTLS dialer and NEVER sets `InsecureSkipVerify`. | +| Reversibility | The MITM decision is **reversible until the upstream handshake succeeds**: verify upstream first, then mint+terminate downstream; otherwise tunnel the buffered ClientHello. Plus **auto-skip on handshake-fail** so a pinned client's retry passes through. | +| CA model | **Operator-provisioned per-agent CA via cert-manager** (default); per-sidecar **ephemeral** CA as standalone/no-cert-manager fallback. Same `CASource` interface. | +| Build vs adopt | **Hand-roll** on stdlib `crypto/tls`/`crypto/x509`; reference martian's cert logic, not its framework. | +| HTTP/2 | **In scope** (prerequisite for default-on). ALPN offers `h2` + `http/1.1`. | + +## Architecture + +### New package `authlib/mitm/` + +| Unit | Responsibility | Depends on | +|------|----------------|------------| +| `CASource` (interface) | Supply the signing CA; expose `CACertPEM()`. Impls: `FileSource` (mounted cert-manager Secret — default), `EphemeralSource` (in-memory self-signed — fallback). | `crypto/x509` | +| `Minter` | Mint per-SNI leaf signed by the CA (SAN incl. IP literals for no-SNI/IP-dialed origins); LRU+TTL cache keyed by **SNI** (h1.1+h2 share). Exposes `GetCertificate(*tls.ClientHelloInfo)`. Leaf validity ≥ cache TTL. | `CASource` | +| `Terminator` | Wrap a sniffed client `net.Conn` as `tls.Server` with `GetCertificate=Minter`; ALPN `h2`,`http/1.1`. | `Minter` | +| `Decision` | `classify(host, port, firstBytes) → {terminate, passthrough}`: validates the 5-byte TLS record header (not just `0x16`), port gate, **internal/mesh host gate**, skip-list. | config | + +### Plugin integration — no new code in the pipeline + +Extract the core of `forwardproxy`'s request handler into a shared `serveOutbound(w, r)`: build `pctx`, run +`OutboundPipeline.Run/RunResponse/RunResponseFrame`, re-originate, write response. The plaintext +forward-proxy path and the MITM path both call it. MITM just produces decrypted `*http.Request`s; the +pipeline is identical. (This is a *refactor* of the proxy, not a plugin change.) + +`serveOutbound` needs an `isMITM`/scheme discriminator: the MITM path must **not** strip proxy-only headers +(`Proxy-Authorization`/`Proxy-Connection`) and the request URL is origin-form, not absolute-form. After the +`Terminator` returns the decrypted conn, serve it with a one-connection `http.Server` over a one-conn +`net.Listener` so HTTP **keep-alive** (multiple requests on the kept-alive TLS conn) works. + +### Upstream re-origination (the core correctness fix) + +Re-origination uses a **dedicated upstream `http.Client`**, never the proxy's mesh-mTLS dialer: +- `Transport.TLSClientConfig.RootCAs` = `x509.SystemCertPool()` **+** the agent's injected upstream trust + bundle (a configurable/mounted path; empty by default = system roots only, which covers public origins). +- No `InsecureSkipVerify`. This preserves the agent's trust semantics: an origin the agent would have + trusted directly is still verified; a private-CA origin works iff its CA is in the injected bundle. +- The SPIRE-mTLS dialer (which presents the agent SVID + verifies against the SPIRE bundle with + skip-verify) is for **mesh peers only** and is never used for MITM'd external origins. + +### Why outbound is the only MITM surface + +- **Outbound = forge identity:** mint a leaf per origin SNI; the agent must trust our CA. The only path that + needs a minting CA. +- **Inbound ≠ MITM:** terminating inbound TLS presents the agent's *own real* SVID (no forging) — already + the `mtls:` reverse-proxy block's job (mesh: ztunnel delivers plaintext; standalone: real SVID). No + PREROUTING / `InboundPipeline` changes here. + +## Data flow — outbound (agent → HTTPS origin) + +``` +1. Agent opens TLS (captured at transparent listener, or CONNECT on the forward proxy) +2. Peek ClientHello → SNI, ALPN, validate TLS record header (sniff.go peekedConn, replayable) +3. Decision.classify(host, port, recordHeader): + port not in {443,8443,…} → tunnel + log reason=port + not a valid TLS ClientHello → tunnel + log reason=non-tls + host ∈ skip_hosts → tunnel + log reason=skip + host internal/mesh & scope=external → tunnel + log reason=in-cluster + ECH / encrypted-SNI present → tunnel + log reason=ech + else → candidate for MITM ↓ +4. Dial + complete VERIFIED upstream handshake to the origin via the upstream client + upstream verify FAILS → tunnel the buffered ClientHello (blind passthrough) + log reason=upstream-verify + upstream verify OK ↓ (decision still reversible up to here) +5. Terminator: tls.Server(clientConn, {GetCertificate: Minter}); ALPN h2/http1.1 + agent rejects minted leaf (pinned) → record host→auto-skip; this call fails, retry passes through + agent accepts ↓ +6. one-conn http.Server → serveOutbound(w, r) [Scheme=https, Host=SNI, isMITM=true] + → OutboundPipeline.Run/RunResponse (UNCHANGED — full plugin set) + → relay over the already-verified upstream connection from step 4 + → response back through the terminated client TLS +``` + +The reversibility in steps 4–5 is what keeps "we tried to MITM" from breaking working calls: upstream is +proven before we forge anything; pinned clients self-heal on retry via auto-skip. + +## Interception scope (config gate) + +`Decision` takes an explicit scope: a set of host globs / CIDRs that are **internal** (don't MITM by +default) and the policy for them. Default policy = **external-egress only** — internal/mesh destinations +tunnel (the mesh + receiving sidecar already handle them, and MITM-ing them is a redundant double-decrypt). +Operators override to `all` for no-mesh / standalone / OpenShell, where MITM is the only L7 inspection +point. This is a *traffic* gate, never a plugin gate. + +## Trust distribution (operator-coordinated) + +**CA model: operator-provisioned per-agent CA via cert-manager** (the operator has no startup-ordering +primitive, so an ephemeral CA created after boot would race first egress; a mounted Secret gates pod start). + +Flow: +1. Operator creates a per-agent CA `Certificate` (cert-manager) → Secret (`tls.crt`/`tls.key`/`ca.crt`). + The CA carries **X.509 Name Constraints** scoping it to the agent's allowed egress hosts (so a per-agent + CA can't mint for arbitrary origins). +2. Operator mounts the Secret as a **hard (`Optional:false`) volume** so the pod blocks until it exists: + - into the **sidecar**: `tls.crt`+`tls.key` (signing) — mode **`0400`**, owned by the proxy UID. The + signing key never goes to the agent container. + - into the **agent**: `ca.crt` only (trust) + trust env (see below). +3. **Ordering** is a 3-actor liveness dependency (webhook mutates pod → reconciler creates the `Certificate` + → cert-manager issues the Secret). The hard mount makes the pod wait; a *soft* mount would degrade to + silent unverified egress and is disallowed. + +**Trust injection is per-runtime — scope the promise.** Setting `NODE_EXTRA_CA_CERTS` / `SSL_CERT_FILE` / +`REQUESTS_CA_BUNDLE` / `CURL_CA_BUNDLE` / `GIT_SSL_CAINFO` / **`AWS_CA_BUNDLE`** / +**`GRPC_DEFAULT_SSL_ROOTS_FILE_PATH`** covers Python-requests, curl, git, Node, Go-on-Linux, boto3, and +gRPC. It is **silently ignored** by Go-on-macOS and `certifi.where()`-pinned / custom-`SSLContext` clients. +So: document **supported runtimes**; emit a **startup self-check** (probe whether the injected CA is +actually honored) so a trust-miss is a loud signal, not an opaque in-agent handshake error. + +Pluggability: operator path = `FileSource`; standalone / no-cert-manager = `EphemeralSource` (writes CA cert +to a shared `emptyDir` consumed by an initContainer or baked trust). One-line `CASource` swap in `main.go`. + +### Operator: reused vs net-new (verified against source) + +*Reused:* sidecar injection webhook; agent **env** mutation (`pod_mutator.go`); per-agent `config.yaml` +generation (layering `mode`/`listener`/`mtls` blocks — `mitm` is the same pattern); `AuthBridgeMode`/ +`MTLSMode` CRD home for a `MITMMode` field; cluster-wide Secrets CRUD. + +*Net-new (do not assume reuse):* +- **Agent-side Secret mount.** Today the webhook only injects *env* into the agent; only the sidecar mounts + `shared-data`. Mounting `ca.crt` into the agent is new plumbing and deliberately punctures agent/sidecar + mount-namespace isolation. +- **Per-agent cert-manager reconciler.** The `SharedTrust` controller is a *hardcoded mesh-root shuttle*, + not a per-agent template — a new reconciler is needed. +- **cert-manager write RBAC**, including **namespaced `Issuer` create** (today only `get;list;watch` on + certs + `clusterissuers`). +- **CA rotation:** PoC reads `FileSource` once at start (restart to rotate; long-lived CA). Zero-downtime + rotation via cert-manager overlapping `ca.crt` bundle + file-watch is a tracked follow-up. + +## Fail-open / self-healing + +`Decision` defaults to passthrough on anything it can't safely MITM; every passthrough is logged with a +reason (`port|non-tls|skip|in-cluster|ech|upstream-verify|handshake-fail`). Beyond the static `skip_hosts`: +- **upstream-verify**: handled *before* forging (step 4) → clean blind tunnel. +- **handshake-fail** (pinned client): the proxy **auto-adds the host to an in-memory skip set** on first + failure, so the agent's retry passes through — no hand-edited config, no persistent outage. + +## Security hardening (in scope) + +- **CA signing key**: mode `0400`, dedicated UID, sidecar-only (never the agent volume); **Name + Constraints** scope each per-agent CA to that agent's egress hosts → a leaked key is a keyring, not a + cluster-wide skeleton key. +- **`:9094` session API** is unauthenticated and would now capture **decrypted HTTPS** bodies (tokens, PII). + Treat "MITM on" and "unauthenticated raw-body store" as mutually exclusive: localhost-only bind + + redact/disable raw-body capture when MITM is enabled. + +## Config + +A pointer block on `Config` mirroring the `MTLS`/`SPIFFE` idiom: + +```yaml +mitm: + enabled: true + scope: external # external | all (which traffic to intercept) + internal_cidrs: [] # treated as in-cluster when scope=external (else discovered) + ca_source: file # file | ephemeral + ca_cert_path: /etc/authbridge/mitm-ca/tls.crt + ca_key_path: /etc/authbridge/mitm-ca/tls.key + ca_export_path: /var/run/authbridge/mitm-ca.pem # ephemeral mode only + upstream_ca_bundle: "" # extra roots for re-origination (agent's private CAs); empty = system roots + skip_hosts: [] # static passthrough; auto-skip augments this at runtime + leaf_cache: { max: 1024, ttl: 24h } +``` + +Read in `main.go` beside the `fpMTLS` block; construct `CASource` + `Minter` + `Terminator` + the upstream +client; pass into `forwardproxy.NewServer` and the transparent listener. + +## Testing & success criteria + +**Unit (`authlib/mitm/`):** CASource (ephemeral gen; file load + error paths); Minter (SAN incl. IP +literals, chain-to-CA, validity ≥ TTL, cache hit/TTL/LRU); Decision (table-driven incl. internal-host gate, +record-header validation, ECH → passthrough). + +**Integration (in-process):** +- `httptest` TLS origin + client trusting ephemeral CA → MITM → probe plugin recorded decrypted + method/path/headers/body; response byte-intact; **h2 and h1.1 origins both**. +- **Custom-root origin**: origin signed by a private CA placed in `upstream_ca_bundle` → re-origination + **succeeds** (proves injected-roots fix); same origin with empty bundle → upstream-verify fails → + passthrough (no skip-verify regression, no broken call). +- Pinned client (rejects minted leaf) → first call fails, host **auto-skipped**, retry tunnels & succeeds. +- Skip-list / internal-host (scope=external) → tunneled, pipeline not run, logged. + +**E2E (OpenShell suppressed-proxy):** operator provisions CA, mounts both ways (hard volume), sets trust +env, injects `mitm` block; agent HTTPS call → AuthBridge logs decrypted request + a plugin acts on it; +trust-injection self-check passes; pinned/skip host tunneled, agent unbroken. + +**Success criteria:** +1. Agent HTTPS egress decrypted → full pipeline runs (logged) → real origin, response byte-intact. +2. Agent trusts the CA with no manual steps on a **supported runtime**; self-check confirms it. +3. No startup race (hard Secret-mount gate). +4. A private-CA origin the agent trusts still works (injected upstream roots). +5. Un-MITMable / pinned traffic fails open and **self-heals** (auto-skip); no working call is broken. + +## Implementation surface (summary) + +**AuthBridge (all TLS/transport layer — the pipeline is untouched):** new `authlib/mitm/` package; extract +`serveOutbound` + `isMITM` discriminator; reversible decision (upstream-verify before forge) at the two +capture sites (transparent + CONNECT-with-peek); dedicated upstream `http.Client` (system + injected +roots); auto-skip set; one-conn keep-alive server; h2 via `http2.ConfigureServer`; `MITMConfig` + `main.go` +wiring; trust-injection self-check. + +**Operator:** per-agent cert-manager `Certificate`/`Issuer` (with Name Constraints) + write RBAC (incl. +namespaced `Issuer`); **net-new** agent-side `ca.crt` hard mount + trust env (incl. `AWS_CA_BUNDLE`, +`GRPC_DEFAULT_SSL_ROOTS_FILE_PATH`); sidecar signing-key mount `0400`; `mitm` config block; `MITMMode` CRD +field + resolution. + +## Phasing (for the implementation plan) + +- **Phase 1 — AuthBridge, in-process:** `authlib/mitm/` + `serveOutbound` refactor + reversible decision + + upstream client + auto-skip + h2, with `EphemeralSource` CA. Fully unit/integration-testable, **no + operator dependency**. Proves the decrypt→pipeline→re-originate loop and the no-broken-calls guarantees. +- **Phase 2 — Operator trust coordination:** per-agent cert-manager CA (`FileSource`), hard mounts, trust + env + self-check, RBAC, CRD field. Enables the zero-manual-steps E2E on OpenShell. +- **Follow-ups:** CA rotation (overlapping bundle + file-watch); leaf-cache tuning. + +## Design evolution (record) + +This spec was reshaped by a four-pass source-grounded adversarial review (control-flow, TLS/PKI, +trust/operator, pipeline-value). Resolved conclusions now folded into the body: +- The hard problems are all in the **TLS/transport layer**, not the pipeline. The original + "drop token-exchange/MCP/A2A" recommendation was **rejected**: plugins self-gate, so the full pipeline + runs on the plaintext and interception **scope** (not plugin selection) is the real knob. +- Re-origination must use a dedicated upstream client with **system + injected roots** (the mesh-mTLS + dialer + skip-verify was a fail-closed / SVID-leak blocker, and system-roots-only would break private-CA + origins). +- The MITM decision must be **reversible** (upstream-verify first) and **self-healing** (auto-skip), so no + working call is broken. +- Trust injection is a **per-runtime** mechanism (scope + self-check), not a universal guarantee. +- CA-key handling (`0400` + Name Constraints) and `:9094` body-capture gating are **in scope**, not + deferred. h2 is a **prerequisite**, not a fast-follow. From 147cb1c0b5f79c4c9e51e42d52464d6a254b0de3 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 13:59:05 -0400 Subject: [PATCH 02/24] feat(tlsbridge): CASource (ephemeral + file, multi-format key) Signed-off-by: Hai Huang --- authbridge/authlib/tlsbridge/ca.go | 114 +++++++++++++++++++++++ authbridge/authlib/tlsbridge/ca_test.go | 116 ++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 authbridge/authlib/tlsbridge/ca.go create mode 100644 authbridge/authlib/tlsbridge/ca_test.go diff --git a/authbridge/authlib/tlsbridge/ca.go b/authbridge/authlib/tlsbridge/ca.go new file mode 100644 index 000000000..462109fb9 --- /dev/null +++ b/authbridge/authlib/tlsbridge/ca.go @@ -0,0 +1,114 @@ +package tlsbridge + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "time" +) + +// CASource supplies the signing CA used to mint per-origin leaves. +type CASource interface { + // Issuer returns the CA certificate and its private key for signing leaves. + Issuer() (cert *x509.Certificate, key crypto.Signer) + // CACertPEM returns the CA certificate in PEM form (for the agent's trust store). + CACertPEM() []byte +} + +type staticSource struct { + cert *x509.Certificate + key crypto.Signer + certPEM []byte +} + +func (s *staticSource) Issuer() (*x509.Certificate, crypto.Signer) { return s.cert, s.key } +func (s *staticSource) CACertPEM() []byte { return s.certPEM } + +// NewEphemeralSource generates an in-memory self-signed CA. Used as the +// standalone / no-cert-manager fallback and in tests. +func NewEphemeralSource() (CASource, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("tlsbridge: generate CA key: %w", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "authbridge-tls-bridge-ca"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + if err != nil { + return nil, fmt.Errorf("tlsbridge: self-sign CA: %w", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, fmt.Errorf("tlsbridge: parse self-signed CA: %w", err) + } + return &staticSource{ + cert: cert, + key: key, + certPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + }, nil +} + +// NewFileSource loads a CA (tls.crt/tls.key) from disk — the cert-manager / +// operator-coordinated path (Phase 2). Keys may be PKCS#8, PKCS#1 (RSA) or +// SEC1 (EC); cert-manager's DEFAULT encoding is PKCS#1, so all three are tried. +func NewFileSource(certPath, keyPath string) (CASource, error) { + certPEM, err := os.ReadFile(certPath) + if err != nil { + return nil, fmt.Errorf("tlsbridge: read CA cert %s: %w", certPath, err) + } + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("tlsbridge: read CA key %s: %w", keyPath, err) + } + cb, _ := pem.Decode(certPEM) + if cb == nil { + return nil, fmt.Errorf("tlsbridge: CA cert %s is not PEM", certPath) + } + cert, err := x509.ParseCertificate(cb.Bytes) + if err != nil { + return nil, fmt.Errorf("tlsbridge: parse CA cert: %w", err) + } + kb, _ := pem.Decode(keyPEM) + if kb == nil { + return nil, fmt.Errorf("tlsbridge: CA key %s is not PEM", keyPath) + } + key, err := parsePrivateKey(kb.Bytes) + if err != nil { + return nil, err + } + return &staticSource{cert: cert, key: key, certPEM: certPEM}, nil +} + +// parsePrivateKey accepts PKCS#8, PKCS#1 (RSA) and SEC1 (EC) DER. +func parsePrivateKey(der []byte) (crypto.Signer, error) { + if k, err := x509.ParsePKCS8PrivateKey(der); err == nil { + if s, ok := k.(crypto.Signer); ok { + return s, nil + } + // A successful PKCS#8 parse means the bytes ARE PKCS#8, so falling + // through to PKCS#1/SEC1 would be pointless — a non-Signer PKCS#8 + // key is a hard error, not a format we should keep guessing at. + return nil, fmt.Errorf("tlsbridge: PKCS#8 key is not a crypto.Signer") + } + if k, err := x509.ParsePKCS1PrivateKey(der); err == nil { + return k, nil + } + if k, err := x509.ParseECPrivateKey(der); err == nil { + return k, nil + } + return nil, fmt.Errorf("tlsbridge: unsupported CA key format (tried PKCS#8, PKCS#1, SEC1)") +} diff --git a/authbridge/authlib/tlsbridge/ca_test.go b/authbridge/authlib/tlsbridge/ca_test.go new file mode 100644 index 000000000..ebeda5fbf --- /dev/null +++ b/authbridge/authlib/tlsbridge/ca_test.go @@ -0,0 +1,116 @@ +package tlsbridge + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" +) + +func TestEphemeralSource_IssuesUsableCA(t *testing.T) { + src, err := NewEphemeralSource() + if err != nil { + t.Fatalf("NewEphemeralSource: %v", err) + } + block, _ := pem.Decode(src.CACertPEM()) + if block == nil || block.Type != "CERTIFICATE" { + t.Fatalf("CACertPEM did not yield a CERTIFICATE PEM block") + } + caCert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("parse CA cert: %v", err) + } + if !caCert.IsCA { + t.Errorf("issued cert is not a CA (IsCA=false)") + } + if caCert.KeyUsage&x509.KeyUsageCertSign == 0 { + t.Errorf("CA cert lacks KeyUsageCertSign") + } + cert, key := src.Issuer() + if cert == nil || key == nil { + t.Fatalf("Issuer() returned nil cert/key") + } +} + +func TestFileSource_LoadsPKCS1AndPKCS8(t *testing.T) { + for _, tc := range []struct { + name string + pkcs8 bool + }{{"ec-pkcs8", true}, {"ec-sec1", false}} { + t.Run(tc.name, func(t *testing.T) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "t"}, + NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), + IsCA: true, KeyUsage: x509.KeyUsageCertSign, BasicConstraintsValid: true, + } + der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + dir := t.TempDir() + certP := filepath.Join(dir, "tls.crt") + keyP := filepath.Join(dir, "tls.key") + if err := os.WriteFile(certP, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil { + t.Fatalf("write cert: %v", err) + } + var keyDER []byte + keyType := "PRIVATE KEY" + if tc.pkcs8 { + keyDER, _ = x509.MarshalPKCS8PrivateKey(key) + } else { + // SEC1 EC keys conventionally use the "EC PRIVATE KEY" PEM + // label (matches openssl / cert-manager artifacts). + keyType = "EC PRIVATE KEY" + keyDER, _ = x509.MarshalECPrivateKey(key) + } + if err := os.WriteFile(keyP, pem.EncodeToMemory(&pem.Block{Type: keyType, Bytes: keyDER}), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + if _, err := NewFileSource(certP, keyP); err != nil { + t.Fatalf("NewFileSource(%s): %v", tc.name, err) + } + }) + } +} + +func TestFileSource_RejectsGarbage(t *testing.T) { + // A valid cert paired with a garbage key, and a garbage cert, must both + // surface a non-nil error rather than a panic or a silently-broken source. + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "t"}, + NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), + IsCA: true, KeyUsage: x509.KeyUsageCertSign, BasicConstraintsValid: true, + } + der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + goodCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + + for _, tc := range []struct { + name string + certPEM []byte + keyPEM []byte + }{ + {"garbage-cert", []byte("not a pem cert at all\n"), []byte("not a pem key at all\n")}, + {"good-cert-garbage-key", goodCertPEM, []byte("not a pem key at all\n")}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + certP := filepath.Join(dir, "tls.crt") + keyP := filepath.Join(dir, "tls.key") + if err := os.WriteFile(certP, tc.certPEM, 0o600); err != nil { + t.Fatalf("write cert: %v", err) + } + if err := os.WriteFile(keyP, tc.keyPEM, 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + if _, err := NewFileSource(certP, keyP); err == nil { + t.Fatalf("NewFileSource(%s): expected error, got nil", tc.name) + } + }) + } +} From bf93538ad430eeca71f140654c54f9213b0819f5 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 14:09:59 -0400 Subject: [PATCH 03/24] feat(tlsbridge): per-host leaf Minter (shared P-256 key) with LRU/TTL cache Signed-off-by: Hai Huang --- authbridge/authlib/tlsbridge/minter.go | 118 ++++++++++++++++++ authbridge/authlib/tlsbridge/minter_test.go | 127 ++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 authbridge/authlib/tlsbridge/minter.go create mode 100644 authbridge/authlib/tlsbridge/minter_test.go diff --git a/authbridge/authlib/tlsbridge/minter.go b/authbridge/authlib/tlsbridge/minter.go new file mode 100644 index 000000000..218bff735 --- /dev/null +++ b/authbridge/authlib/tlsbridge/minter.go @@ -0,0 +1,118 @@ +package tlsbridge + +import ( + "container/list" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "net" + "sync" + "time" +) + +type MinterOpts struct { + CacheMax int // max cached leaves (LRU); <=0 → 1024 + LeafTTL time.Duration // leaf validity AND cache TTL; <=0 → 24h +} + +// Minter mints per-host leaf certs signed by a CASource, cached LRU+TTL by host. +type Minter struct { + src CASource + max int + ttl time.Duration + leafKey *ecdsa.PrivateKey // one key reused across leaves (cheaper; key is not the secret here) + + mu sync.Mutex + ll *list.List // MRU front + items map[string]*list.Element // host -> element(*cacheEntry) +} + +type cacheEntry struct { + host string + cert *tls.Certificate + expires time.Time +} + +func NewMinter(src CASource, o MinterOpts) *Minter { + if o.CacheMax <= 0 { + o.CacheMax = 1024 + } + if o.LeafTTL <= 0 { + o.LeafTTL = 24 * time.Hour + } + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &Minter{ + src: src, max: o.CacheMax, ttl: o.LeafTTL, leafKey: key, + ll: list.New(), items: make(map[string]*list.Element), + } +} + +// GetCertificate satisfies tls.Config.GetCertificate via the SNI server name. +func (m *Minter) GetCertificate(chi *tls.ClientHelloInfo) (*tls.Certificate, error) { + host := chi.ServerName + if host == "" { + return nil, fmt.Errorf("tlsbridge: no SNI; caller must use GetCertificateForHost with the dialed IP") + } + return m.GetCertificateForHost(host) +} + +func (m *Minter) GetCertificateForHost(host string) (*tls.Certificate, error) { + m.mu.Lock() + defer m.mu.Unlock() + if el, ok := m.items[host]; ok { + e := el.Value.(*cacheEntry) + if time.Now().Before(e.expires) { + m.ll.MoveToFront(el) + return e.cert, nil + } + m.ll.Remove(el) + delete(m.items, host) + } + cert, err := m.mint(host) + if err != nil { + return nil, err + } + el := m.ll.PushFront(&cacheEntry{host: host, cert: cert, expires: time.Now().Add(m.ttl)}) + m.items[host] = el + for m.ll.Len() > m.max { + back := m.ll.Back() + m.ll.Remove(back) + delete(m.items, back.Value.(*cacheEntry).host) + } + return cert, nil +} + +func (m *Minter) mint(host string) (*tls.Certificate, error) { + caCert, caKey := m.src.Issuer() + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("tlsbridge: serial for %s: %w", host, err) + } + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: host}, + NotBefore: time.Now().Add(-time.Minute), + // Leaf validity must exceed the cache TTL so a cached leaf never serves past expiry. + NotAfter: time.Now().Add(m.ttl + time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + if ip := net.ParseIP(host); ip != nil { + tmpl.IPAddresses = []net.IP{ip} + } else { + tmpl.DNSNames = []string{host} + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, m.leafKey.Public(), caKey) + if err != nil { + return nil, fmt.Errorf("tlsbridge: mint leaf for %s: %w", host, err) + } + return &tls.Certificate{ + Certificate: [][]byte{der, caCert.Raw}, + PrivateKey: m.leafKey, + }, nil +} diff --git a/authbridge/authlib/tlsbridge/minter_test.go b/authbridge/authlib/tlsbridge/minter_test.go new file mode 100644 index 000000000..7f77cb4bb --- /dev/null +++ b/authbridge/authlib/tlsbridge/minter_test.go @@ -0,0 +1,127 @@ +package tlsbridge + +import ( + "crypto/tls" + "crypto/x509" + "net" + "testing" + "time" +) + +func newTestMinter(t *testing.T) (*Minter, *x509.CertPool) { + t.Helper() + src, err := NewEphemeralSource() + if err != nil { + t.Fatalf("ca: %v", err) + } + m := NewMinter(src, MinterOpts{CacheMax: 8, LeafTTL: time.Hour}) + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(src.CACertPEM()) { + t.Fatal("append CA to pool") + } + return m, pool +} + +func TestMinter_LeafChainsToCA_AndHasSAN(t *testing.T) { + m, pool := newTestMinter(t) + cert, err := m.GetCertificate(&tls.ClientHelloInfo{ServerName: "api.example.com"}) + if err != nil { + t.Fatalf("GetCertificate: %v", err) + } + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + t.Fatalf("parse leaf: %v", err) + } + if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool, DNSName: "api.example.com"}); err != nil { + t.Errorf("leaf does not verify against CA for its SAN: %v", err) + } +} + +func TestMinter_IPLiteralSAN(t *testing.T) { + m, pool := newTestMinter(t) + c2, err := m.GetCertificateForHost("10.0.0.5") + if err != nil { + t.Fatalf("GetCertificateForHost(ip): %v", err) + } + leaf, _ := x509.ParseCertificate(c2.Certificate[0]) + if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool}); err != nil { + // IP SANs verify via IPAddresses, not DNSName; the explicit SAN check below is authoritative. + } + found := false + for _, ip := range leaf.IPAddresses { + if ip.Equal(net.ParseIP("10.0.0.5")) { + found = true + } + } + if !found { + t.Errorf("leaf for IP host lacks the IP SAN") + } +} + +func TestMinter_CacheHitReturnsSameCert(t *testing.T) { + m, _ := newTestMinter(t) + a, _ := m.GetCertificate(&tls.ClientHelloInfo{ServerName: "h.example.com"}) + b, _ := m.GetCertificate(&tls.ClientHelloInfo{ServerName: "h.example.com"}) + if &a.Certificate[0][0] != &b.Certificate[0][0] { + t.Errorf("expected cached cert reuse for same SNI") + } +} + +func TestMinter_TTLExpiryRemints(t *testing.T) { + src, err := NewEphemeralSource() + if err != nil { + t.Fatalf("ca: %v", err) + } + m := NewMinter(src, MinterOpts{CacheMax: 8, LeafTTL: 20 * time.Millisecond}) + a, err := m.GetCertificateForHost("h.example.com") + if err != nil { + t.Fatalf("first mint: %v", err) + } + time.Sleep(40 * time.Millisecond) // past the TTL + b, err := m.GetCertificateForHost("h.example.com") + if err != nil { + t.Fatalf("second mint: %v", err) + } + if &a.Certificate[0][0] == &b.Certificate[0][0] { + t.Errorf("expected a re-minted cert after TTL expiry, got the cached one") + } +} + +func TestMinter_LRUEvictsOldest(t *testing.T) { + src, err := NewEphemeralSource() + if err != nil { + t.Fatalf("ca: %v", err) + } + m := NewMinter(src, MinterOpts{CacheMax: 2, LeafTTL: time.Hour}) + + a1, err := m.GetCertificateForHost("a") + if err != nil { + t.Fatalf("mint a: %v", err) + } + if _, err := m.GetCertificateForHost("b"); err != nil { + t.Fatalf("mint b: %v", err) + } + // Minting "c" overflows CacheMax=2, evicting the least-recently-used host ("a"). + c1, err := m.GetCertificateForHost("c") + if err != nil { + t.Fatalf("mint c: %v", err) + } + + // "a" was evicted, so re-getting it mints a fresh cert. + a2, err := m.GetCertificateForHost("a") + if err != nil { + t.Fatalf("re-mint a: %v", err) + } + if &a1.Certificate[0][0] == &a2.Certificate[0][0] { + t.Errorf("expected evicted host \"a\" to be re-minted, got the original cert") + } + + // "c" is still the most-recent entry, so an immediate re-get is a cache hit. + c2, err := m.GetCertificateForHost("c") + if err != nil { + t.Fatalf("re-get c: %v", err) + } + if &c1.Certificate[0][0] != &c2.Certificate[0][0] { + t.Errorf("expected most-recent host \"c\" to still be cached") + } +} From 60fdb0b7c180025d95cbd770b826065819a92678 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 14:21:49 -0400 Subject: [PATCH 04/24] feat(tlsbridge): Decision (record-header + scope gate) and SkipSet Signed-off-by: Hai Huang --- authbridge/authlib/tlsbridge/decision.go | 108 ++++++++++++++++++ authbridge/authlib/tlsbridge/decision_test.go | 47 ++++++++ 2 files changed, 155 insertions(+) create mode 100644 authbridge/authlib/tlsbridge/decision.go create mode 100644 authbridge/authlib/tlsbridge/decision_test.go diff --git a/authbridge/authlib/tlsbridge/decision.go b/authbridge/authlib/tlsbridge/decision.go new file mode 100644 index 000000000..c5ee1bf61 --- /dev/null +++ b/authbridge/authlib/tlsbridge/decision.go @@ -0,0 +1,108 @@ +package tlsbridge + +import ( + "net" + "sync" +) + +type Verdict int + +const ( + Passthrough Verdict = iota + Terminate +) + +type Scope int + +const ( + ScopeExternal Scope = iota // default: do not bridge internal/mesh destinations + ScopeAll // bridge everything eligible (no-mesh / standalone) +) + +type DecisionOpts struct { + Ports map[int]bool + Scope Scope + InternalCIDRs []string + SkipHosts []string +} + +type Decision struct { + ports map[int]bool + scope Scope + internal []*net.IPNet + skip map[string]bool +} + +func NewDecision(o DecisionOpts) *Decision { + d := &Decision{ports: o.Ports, scope: o.Scope, skip: map[string]bool{}} + if d.ports == nil { + d.ports = map[int]bool{443: true, 8443: true} + } + for _, c := range o.InternalCIDRs { + if _, n, err := net.ParseCIDR(c); err == nil { + d.internal = append(d.internal, n) + } + } + for _, h := range o.SkipHosts { + d.skip[h] = true + } + return d +} + +// Classify decides whether to bridge. first is the peeked client bytes. +func (d *Decision) Classify(host, ip string, port int, first []byte) (Verdict, string) { + if !d.ports[port] { + return Passthrough, "port" + } + if !looksLikeTLSClientHello(first) { + return Passthrough, "non-tls" + } + if d.skip[host] { + return Passthrough, "skip" + } + if d.scope == ScopeExternal && d.isInternal(ip) { + return Passthrough, "in-cluster" + } + return Terminate, "" +} + +func (d *Decision) isInternal(ip string) bool { + parsed := net.ParseIP(ip) + if parsed == nil { + return false // a hostname (not an IP) is never matched as in-cluster — see CONNECT-path note + } + for _, n := range d.internal { + if n.Contains(parsed) { + return true + } + } + return false +} + +// looksLikeTLSClientHello validates the 5-byte TLS record header (not just 0x16): +// content type 22 (handshake), legacy record version 0x03 0x01-0x04. +func looksLikeTLSClientHello(b []byte) bool { + if len(b) < 5 { + return false + } + return b[0] == 0x16 && b[1] == 0x03 && b[2] <= 0x04 +} + +// SkipSet is the runtime auto-skip set (hosts whose minted leaf the client +// rejected). Concurrent-safe; augments the static skip list. +type SkipSet struct { + mu sync.RWMutex + m map[string]bool +} + +func NewSkipSet() *SkipSet { return &SkipSet{m: map[string]bool{}} } +func (s *SkipSet) Add(host string) { + s.mu.Lock() + s.m[host] = true + s.mu.Unlock() +} +func (s *SkipSet) Contains(host string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.m[host] +} diff --git a/authbridge/authlib/tlsbridge/decision_test.go b/authbridge/authlib/tlsbridge/decision_test.go new file mode 100644 index 000000000..cd8cd6c9c --- /dev/null +++ b/authbridge/authlib/tlsbridge/decision_test.go @@ -0,0 +1,47 @@ +package tlsbridge + +import "testing" + +func TestDecision_Classify(t *testing.T) { + d := NewDecision(DecisionOpts{ + Ports: map[int]bool{443: true, 8443: true}, + Scope: ScopeExternal, + InternalCIDRs: []string{"10.0.0.0/8"}, + SkipHosts: []string{"pinned.example.com"}, + }) + tlsHello := []byte{0x16, 0x03, 0x01, 0x00, 0x05} // handshake, TLS1.0 record, len + cases := []struct { + name string + host string + ip string + port int + first []byte + expect Verdict + reason string + }{ + {"happy external https", "api.example.com", "93.184.216.34", 443, tlsHello, Terminate, ""}, + {"non-tls first byte", "api.example.com", "93.184.216.34", 443, []byte("GET / "), Passthrough, "non-tls"}, + {"unlisted port", "api.example.com", "93.184.216.34", 9999, tlsHello, Passthrough, "port"}, + {"internal under external scope", "tool.svc", "10.96.1.2", 443, tlsHello, Passthrough, "in-cluster"}, + {"skip-listed host", "pinned.example.com", "1.2.3.4", 443, tlsHello, Passthrough, "skip"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v, reason := d.Classify(tc.host, tc.ip, tc.port, tc.first) + if v != tc.expect || (tc.expect == Passthrough && reason != tc.reason) { + t.Errorf("got (%v,%q), want (%v,%q)", v, reason, tc.expect, tc.reason) + } + }) + } +} + +func TestSkipSet_AutoSkip(t *testing.T) { + s := NewSkipSet() + if s.Contains("h") { + t.Fatal("empty set should not contain h") + } + s.Add("h") + if !s.Contains("h") { + t.Error("Add then Contains failed") + } +} From ba1be472f29d116697d91c54ddbc4e8db4f5d1b9 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 14:56:12 -0400 Subject: [PATCH 05/24] test(tlsbridge): drop empty-branch (SA9003) in minter IP-SAN test Signed-off-by: Hai Huang --- authbridge/authlib/tlsbridge/minter_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/authbridge/authlib/tlsbridge/minter_test.go b/authbridge/authlib/tlsbridge/minter_test.go index 7f77cb4bb..2a8df75a5 100644 --- a/authbridge/authlib/tlsbridge/minter_test.go +++ b/authbridge/authlib/tlsbridge/minter_test.go @@ -38,15 +38,13 @@ func TestMinter_LeafChainsToCA_AndHasSAN(t *testing.T) { } func TestMinter_IPLiteralSAN(t *testing.T) { - m, pool := newTestMinter(t) + m, _ := newTestMinter(t) c2, err := m.GetCertificateForHost("10.0.0.5") if err != nil { t.Fatalf("GetCertificateForHost(ip): %v", err) } leaf, _ := x509.ParseCertificate(c2.Certificate[0]) - if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool}); err != nil { - // IP SANs verify via IPAddresses, not DNSName; the explicit SAN check below is authoritative. - } + // IP SANs verify via IPAddresses, not DNSName; the explicit SAN check below is authoritative. found := false for _, ip := range leaf.IPAddresses { if ip.Equal(net.ParseIP("10.0.0.5")) { From c52a347fe9f644e4344ff48f5a2a301aa9cda7d1 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 15:00:56 -0400 Subject: [PATCH 06/24] test(tlsbridge): tighten TLS-record check (reject SSLv3) and cover Decision branches Signed-off-by: Hai Huang --- authbridge/authlib/tlsbridge/decision.go | 12 ++++--- authbridge/authlib/tlsbridge/decision_test.go | 35 ++++++++++++++++++- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/authbridge/authlib/tlsbridge/decision.go b/authbridge/authlib/tlsbridge/decision.go index c5ee1bf61..a15811111 100644 --- a/authbridge/authlib/tlsbridge/decision.go +++ b/authbridge/authlib/tlsbridge/decision.go @@ -54,7 +54,7 @@ func (d *Decision) Classify(host, ip string, port int, first []byte) (Verdict, s if !d.ports[port] { return Passthrough, "port" } - if !looksLikeTLSClientHello(first) { + if !looksLikeTLSRecord(first) { return Passthrough, "non-tls" } if d.skip[host] { @@ -79,13 +79,15 @@ func (d *Decision) isInternal(ip string) bool { return false } -// looksLikeTLSClientHello validates the 5-byte TLS record header (not just 0x16): -// content type 22 (handshake), legacy record version 0x03 0x01-0x04. -func looksLikeTLSClientHello(b []byte) bool { +// looksLikeTLSRecord validates the 5-byte TLS record header (not just 0x16): +// content type 22 (handshake), legacy record version 0x03 with minor 0x01-0x04 +// (TLS 1.0–1.3; SSLv3's 0x0300 is rejected). It checks the record layer, not the +// handshake message type. +func looksLikeTLSRecord(b []byte) bool { if len(b) < 5 { return false } - return b[0] == 0x16 && b[1] == 0x03 && b[2] <= 0x04 + return b[0] == 0x16 && b[1] == 0x03 && b[2] >= 0x01 && b[2] <= 0x04 } // SkipSet is the runtime auto-skip set (hosts whose minted leaf the client diff --git a/authbridge/authlib/tlsbridge/decision_test.go b/authbridge/authlib/tlsbridge/decision_test.go index cd8cd6c9c..52ea7221f 100644 --- a/authbridge/authlib/tlsbridge/decision_test.go +++ b/authbridge/authlib/tlsbridge/decision_test.go @@ -24,17 +24,50 @@ func TestDecision_Classify(t *testing.T) { {"unlisted port", "api.example.com", "93.184.216.34", 9999, tlsHello, Passthrough, "port"}, {"internal under external scope", "tool.svc", "10.96.1.2", 443, tlsHello, Passthrough, "in-cluster"}, {"skip-listed host", "pinned.example.com", "1.2.3.4", 443, tlsHello, Passthrough, "skip"}, + {"short record (<5 bytes)", "api.example.com", "1.2.3.4", 443, []byte{0x16, 0x03}, Passthrough, "non-tls"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { v, reason := d.Classify(tc.host, tc.ip, tc.port, tc.first) - if v != tc.expect || (tc.expect == Passthrough && reason != tc.reason) { + if v != tc.expect || reason != tc.reason { t.Errorf("got (%v,%q), want (%v,%q)", v, reason, tc.expect, tc.reason) } }) } } +func TestDecision_ScopeAll(t *testing.T) { + d := NewDecision(DecisionOpts{Scope: ScopeAll, InternalCIDRs: []string{"10.0.0.0/8"}}) + tlsHello := []byte{0x16, 0x03, 0x01, 0x00, 0x05} + // scope=all does NOT passthrough internal destinations. + if v, reason := d.Classify("tool.svc", "10.96.1.2", 443, tlsHello); v != Terminate || reason != "" { + t.Errorf("got (%v,%q), want (%v,%q)", v, reason, Terminate, "") + } +} + +func TestDecision_DefaultPortsWhenNil(t *testing.T) { + d := NewDecision(DecisionOpts{}) // nil Ports -> {443,8443} + tlsHello := []byte{0x16, 0x03, 0x01, 0x00, 0x05} + if v, reason := d.Classify("api.example.com", "1.2.3.4", 443, tlsHello); v != Terminate || reason != "" { + t.Errorf("port 443: got (%v,%q), want (%v,%q)", v, reason, Terminate, "") + } + if v, reason := d.Classify("api.example.com", "1.2.3.4", 80, tlsHello); v != Passthrough || reason != "port" { + t.Errorf("port 80: got (%v,%q), want (%v,%q)", v, reason, Passthrough, "port") + } +} + +func TestDecision_MultiCIDR(t *testing.T) { + d := NewDecision(DecisionOpts{ + Scope: ScopeExternal, + InternalCIDRs: []string{"10.0.0.0/8", "192.168.0.0/16"}, + }) + tlsHello := []byte{0x16, 0x03, 0x01, 0x00, 0x05} + // IP matching the SECOND cidr exercises the loop past the first entry. + if v, reason := d.Classify("tool.svc", "192.168.1.5", 443, tlsHello); v != Passthrough || reason != "in-cluster" { + t.Errorf("got (%v,%q), want (%v,%q)", v, reason, Passthrough, "in-cluster") + } +} + func TestSkipSet_AutoSkip(t *testing.T) { s := NewSkipSet() if s.Contains("h") { From 1c25fa69cb43965ec4fa76c1160b4edc757b8e72 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 15:06:48 -0400 Subject: [PATCH 07/24] feat(tlsbridge): upstream client with system + injected roots Signed-off-by: Hai Huang --- authbridge/authlib/tlsbridge/upstream.go | 36 +++++++++++++++++++ authbridge/authlib/tlsbridge/upstream_test.go | 33 +++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 authbridge/authlib/tlsbridge/upstream.go create mode 100644 authbridge/authlib/tlsbridge/upstream_test.go diff --git a/authbridge/authlib/tlsbridge/upstream.go b/authbridge/authlib/tlsbridge/upstream.go new file mode 100644 index 000000000..b090ce0fb --- /dev/null +++ b/authbridge/authlib/tlsbridge/upstream.go @@ -0,0 +1,36 @@ +package tlsbridge + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "time" +) + +// NewUpstreamClient builds the HTTP client the TLS bridge uses to re-originate +// to the real origin. RootCAs = system roots + extraRootsPEM (the agent's +// injected upstream trust). It NEVER sets InsecureSkipVerify and never uses the +// mesh-mTLS dialer — re-origination must verify the origin the way the agent would. +func NewUpstreamClient(extraRootsPEM []byte) (*http.Client, error) { + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + if len(extraRootsPEM) > 0 { + if !pool.AppendCertsFromPEM(extraRootsPEM) { + return nil, fmt.Errorf("tlsbridge: upstream_ca_bundle is not valid PEM") + } + } + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: time.Second, + }, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, + }, nil +} diff --git a/authbridge/authlib/tlsbridge/upstream_test.go b/authbridge/authlib/tlsbridge/upstream_test.go new file mode 100644 index 000000000..0115882aa --- /dev/null +++ b/authbridge/authlib/tlsbridge/upstream_test.go @@ -0,0 +1,33 @@ +package tlsbridge + +import ( + "encoding/pem" + "net/http" + "net/http/httptest" + "testing" +) + +func TestUpstreamClient_InjectedRootVerifies(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}) + + // With the origin's CA injected → verifies. + good, err := NewUpstreamClient(caPEM) + if err != nil { + t.Fatalf("NewUpstreamClient: %v", err) + } + resp, err := good.Get(srv.URL) + if err != nil { + t.Fatalf("expected success with injected root, got %v", err) + } + _ = resp.Body.Close() + + // Without it (system roots only) → the self-signed httptest cert is rejected. + bare, _ := NewUpstreamClient(nil) + if _, err := bare.Get(srv.URL); err == nil { + t.Errorf("expected verification failure with system roots only") + } +} From 54e7597303f6e8383d53dae499d134e099bb26e7 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 15:20:33 -0400 Subject: [PATCH 08/24] feat(tlsbridge): Terminator + one-conn keep-alive ServeConn (h2) Signed-off-by: Hai Huang --- authbridge/authlib/go.mod | 10 +- authbridge/authlib/go.sum | 28 ++--- authbridge/authlib/tlsbridge/serve.go | 47 +++++++ authbridge/authlib/tlsbridge/terminator.go | 33 +++++ .../authlib/tlsbridge/terminator_test.go | 119 ++++++++++++++++++ 5 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 authbridge/authlib/tlsbridge/serve.go create mode 100644 authbridge/authlib/tlsbridge/terminator.go create mode 100644 authbridge/authlib/tlsbridge/terminator_test.go diff --git a/authbridge/authlib/go.mod b/authbridge/authlib/go.mod index dd1627835..197612414 100644 --- a/authbridge/authlib/go.mod +++ b/authbridge/authlib/go.mod @@ -9,7 +9,8 @@ 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 + golang.org/x/net v0.56.0 + golang.org/x/sys v0.46.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 @@ -66,10 +67,9 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect - 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/text v0.34.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/protobuf v1.36.11 // indirect oras.land/oras-go/v2 v2.5.0 // indirect diff --git a/authbridge/authlib/go.sum b/authbridge/authlib/go.sum index 0887457ec..480bd3f3f 100644 --- a/authbridge/authlib/go.sum +++ b/authbridge/authlib/go.sum @@ -185,23 +185,23 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg= diff --git a/authbridge/authlib/tlsbridge/serve.go b/authbridge/authlib/tlsbridge/serve.go new file mode 100644 index 000000000..b3eb1fce8 --- /dev/null +++ b/authbridge/authlib/tlsbridge/serve.go @@ -0,0 +1,47 @@ +package tlsbridge + +import ( + "crypto/tls" + "net" + "net/http" + "sync" + + "golang.org/x/net/http2" +) + +// oneConnListener serves exactly one already-accepted conn to http.Server.Serve, +// so keep-alive (multiple requests on the same TLS conn) works. +type oneConnListener struct { + mu sync.Mutex + conn net.Conn +} + +func (l *oneConnListener) Accept() (net.Conn, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.conn == nil { + return nil, net.ErrClosed + } + c := l.conn + l.conn = nil + return c, nil +} +func (l *oneConnListener) Close() error { return nil } +func (l *oneConnListener) Addr() net.Addr { return dummyAddr{} } + +type dummyAddr struct{} + +func (dummyAddr) Network() string { return "tcp" } +func (dummyAddr) String() string { return "tls-bridge" } + +// ServeConn drives an already-terminated TLS conn through handler with HTTP +// keep-alive, negotiating h2 when ALPN selected it. +func ServeConn(tconn *tls.Conn, handler http.Handler) { + srv := &http.Server{Handler: handler} + if tconn.ConnectionState().NegotiatedProtocol == "h2" { + h2s := &http2.Server{} + h2s.ServeConn(tconn, &http2.ServeConnOpts{Handler: handler, BaseConfig: srv}) + return + } + _ = srv.Serve(&oneConnListener{conn: tconn}) +} diff --git a/authbridge/authlib/tlsbridge/terminator.go b/authbridge/authlib/tlsbridge/terminator.go new file mode 100644 index 000000000..58ea5f8c1 --- /dev/null +++ b/authbridge/authlib/tlsbridge/terminator.go @@ -0,0 +1,33 @@ +package tlsbridge + +import ( + "crypto/tls" + "net" +) + +// Terminator wraps a sniffed client conn as a tls.Server, using the Minter to +// forge a per-SNI leaf. ALPN offers h2 + http/1.1. +type Terminator struct { + minter *Minter +} + +func NewTerminator(m *Minter) *Terminator { return &Terminator{minter: m} } + +// Terminate completes the server-side TLS handshake against the agent. host is +// the dialed name/IP, used to mint when the ClientHello carries no SNI. +func (t *Terminator) Terminate(client net.Conn, host string) (*tls.Conn, error) { + cfg := &tls.Config{ + NextProtos: []string{"h2", "http/1.1"}, + GetCertificate: func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) { + if chi.ServerName != "" { + return t.minter.GetCertificateForHost(chi.ServerName) + } + return t.minter.GetCertificateForHost(host) + }, + } + conn := tls.Server(client, cfg) + if err := conn.Handshake(); err != nil { + return nil, err + } + return conn, nil +} diff --git a/authbridge/authlib/tlsbridge/terminator_test.go b/authbridge/authlib/tlsbridge/terminator_test.go new file mode 100644 index 000000000..7a7207f42 --- /dev/null +++ b/authbridge/authlib/tlsbridge/terminator_test.go @@ -0,0 +1,119 @@ +package tlsbridge + +import ( + "bufio" + "crypto/tls" + "crypto/x509" + "io" + "net" + "net/http" + "sync/atomic" + "testing" + "time" +) + +func TestTerminator_AgentTrustingCAHandshakes(t *testing.T) { + src, _ := NewEphemeralSource() + m := NewMinter(src, MinterOpts{}) + term := NewTerminator(m) + + c1, c2 := net.Pipe() + defer func() { _ = c1.Close() }() + defer func() { _ = c2.Close() }() + + errc := make(chan error, 1) + go func() { + tconn, err := term.Terminate(c2, "api.example.com") + if err != nil { + errc <- err + return + } + _ = tconn.Close() + errc <- nil + }() + + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(src.CACertPEM()) + client := tls.Client(c1, &tls.Config{ServerName: "api.example.com", RootCAs: pool, NextProtos: []string{"h2", "http/1.1"}}) + _ = c1.SetDeadline(time.Now().Add(2 * time.Second)) + if err := client.Handshake(); err != nil { + t.Fatalf("client handshake failed (agent should trust minted leaf): %v", err) + } + if alpn := client.ConnectionState().NegotiatedProtocol; alpn != "h2" && alpn != "http/1.1" { + t.Errorf("unexpected ALPN %q", alpn) + } + // Close the client's underlying pipe before reading <-errc so the + // server's tconn.Close() close_notify write unblocks immediately + // (with io.ErrClosedPipe) instead of stalling on its 5s write + // deadline against a non-reading net.Pipe peer. The deferred + // c1.Close() still runs (closing twice is harmless). + _ = c1.Close() + if err := <-errc; err != nil { + t.Fatalf("terminator: %v", err) + } +} + +// TestServeConn_HTTP11KeepAlive drives two sequential HTTP/1.1 requests over a +// single terminated TLS conn, proving ServeConn keeps the conn alive between +// requests (the oneConnListener path). +func TestServeConn_HTTP11KeepAlive(t *testing.T) { + src, _ := NewEphemeralSource() + m := NewMinter(src, MinterOpts{}) + term := NewTerminator(m) + + c1, c2 := net.Pipe() + defer func() { _ = c1.Close() }() + defer func() { _ = c2.Close() }() + + var hits int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) + }) + + // Server side: terminate, then serve with keep-alive. + go func() { + tconn, err := term.Terminate(c2, "api.example.com") + if err != nil { + return + } + ServeConn(tconn, handler) + }() + + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(src.CACertPEM()) + // Force http/1.1 so we exercise the oneConnListener keep-alive path. + client := tls.Client(c1, &tls.Config{ServerName: "api.example.com", RootCAs: pool, NextProtos: []string{"http/1.1"}}) + _ = c1.SetDeadline(time.Now().Add(5 * time.Second)) + if err := client.Handshake(); err != nil { + t.Fatalf("client handshake failed: %v", err) + } + if alpn := client.ConnectionState().NegotiatedProtocol; alpn != "http/1.1" { + t.Fatalf("expected http/1.1, got %q", alpn) + } + + br := bufio.NewReader(client) + for i := 0; i < 2; i++ { + req, _ := http.NewRequest(http.MethodGet, "https://api.example.com/", nil) + // Default keep-alive (no Connection: close) so the server reuses the conn. + if err := req.Write(client); err != nil { + t.Fatalf("request %d write: %v", i, err) + } + resp, err := http.ReadResponse(br, req) + if err != nil { + t.Fatalf("request %d read response: %v", i, err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("request %d status %d", i, resp.StatusCode) + } + // Fully drain and close the body so the reader is positioned at the + // start of the next response (chunked encoding leaves a trailer). + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + } + + if got := atomic.LoadInt32(&hits); got != 2 { + t.Fatalf("expected handler to be hit twice on one conn, got %d", got) + } +} From 8e52e20db837e290fd38432e8c15282f22b986a0 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 15:46:56 -0400 Subject: [PATCH 09/24] refactor(forwardproxy): extract serveOutbound; add tlsbridge.Engine facade + sniff Peek Signed-off-by: Hai Huang --- .../authlib/listener/forwardproxy/server.go | 44 ++++++++++++++++++- .../authlib/listener/forwardproxy/sniff.go | 4 ++ authbridge/authlib/tlsbridge/engine.go | 13 ++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 authbridge/authlib/tlsbridge/engine.go diff --git a/authbridge/authlib/listener/forwardproxy/server.go b/authbridge/authlib/listener/forwardproxy/server.go index 6c4a6ad71..ac7af45f2 100644 --- a/authbridge/authlib/listener/forwardproxy/server.go +++ b/authbridge/authlib/listener/forwardproxy/server.go @@ -12,6 +12,7 @@ import ( "log/slog" "net" "net/http" + "strconv" "strings" "sync" "time" @@ -24,6 +25,7 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/session" "github.com/kagenti/kagenti-extensions/authbridge/authlib/spiffe" authtls "github.com/kagenti/kagenti-extensions/authbridge/authlib/tls" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/tlsbridge" ) const maxBodySize = 1 << 20 // 1MB — matches Envoy's default per_stream_buffer_limit_bytes @@ -58,6 +60,8 @@ type Server struct { // See authlib/config/config.go ListenerConfig.SkipHosts for // motivation. SkipHosts *skiphost.Matcher + + TLSBridge *tlsbridge.Engine // nil = disabled; set by caller after NewServer } // MTLSOptions configures outbound mTLS for the forward proxy. When @@ -193,7 +197,14 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { s.handleConnect(w, r) return } + s.serveOutbound(w, r, false) +} +// serveOutbound runs the outbound pipeline for one decrypted/plaintext request +// and re-originates it. isBridge=true marks requests produced by TLS bridging: +// they are origin-form (the caller sets r.URL.Scheme/Host) and must re-originate +// via the dedicated upstream client, never the mesh-mTLS s.Client. +func (s *Server) serveOutbound(w http.ResponseWriter, r *http.Request, isBridge bool) { pctx := &pipeline.Context{ Direction: pipeline.Outbound, Method: r.Method, @@ -331,7 +342,11 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { // Clear RequestURI — set by the server but must be empty for client requests r.RequestURI = "" - resp, err := s.Client.Do(r) + client := s.Client + if isBridge && s.TLSBridge != nil { + client = s.TLSBridge.Upstream + } + resp, err := client.Do(r) if err != nil { http.Error(w, `{"error":"bad gateway"}`, http.StatusBadGateway) return @@ -908,7 +923,6 @@ func (i *idleReadCloser) closeIdempotent() { i.closeOnce.Do(func() { _ = i.rc.Close() }) } - // enableKeepalive turns on TCP keepalive with a 30s probe interval on // the underlying *net.TCPConn, if conn unwraps to one. No-op on other // connection types (notably *tls.Conn, which doesn't apply on the @@ -921,3 +935,29 @@ func enableKeepalive(conn net.Conn) { _ = tcp.SetKeepAlive(true) _ = tcp.SetKeepAlivePeriod(30 * time.Second) } + +// hostOnly strips the port from an authority ("h:443" → "h"); returns input if no port. +func hostOnly(authority string) string { + if h, _, err := net.SplitHostPort(authority); err == nil { + return h + } + return authority +} + +// portOf returns the port from an authority, defaulting to 443. +func portOf(authority string) int { + if _, p, err := net.SplitHostPort(authority); err == nil { + if n, err := strconv.Atoi(p); err == nil { + return n + } + } + return 443 +} + +// nameOrIP prefers the sniffed SNI name, falling back to the dialed IP. +func nameOrIP(name, ip string) string { + if name != "" { + return name + } + return ip +} diff --git a/authbridge/authlib/listener/forwardproxy/sniff.go b/authbridge/authlib/listener/forwardproxy/sniff.go index 73bf6687a..4c21d5bd5 100644 --- a/authbridge/authlib/listener/forwardproxy/sniff.go +++ b/authbridge/authlib/listener/forwardproxy/sniff.go @@ -152,6 +152,10 @@ type peekedConn struct { func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) } +// Peek returns the next n buffered bytes without consuming them. Used by the +// TLS-bridge classify step on the CONNECT and transparent paths. +func (c *peekedConn) Peek(n int) ([]byte, error) { return c.r.Peek(n) } + // 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 diff --git a/authbridge/authlib/tlsbridge/engine.go b/authbridge/authlib/tlsbridge/engine.go new file mode 100644 index 000000000..c270549a3 --- /dev/null +++ b/authbridge/authlib/tlsbridge/engine.go @@ -0,0 +1,13 @@ +package tlsbridge + +import "net/http" + +// Engine bundles everything the forward proxy needs to bridge TLS. +// A nil *Engine means the bridge is disabled. +type Engine struct { + Decision *Decision + Term *Terminator + Skip *SkipSet + Upstream *http.Client + CAPEM []byte +} From 50a123ff80a0b06b55c08776131afeb6c8530fa5 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 20:32:46 -0400 Subject: [PATCH 10/24] fix(tlsbridge): ServeConn blocks until the served conn closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http.Server.Serve dispatches the accepted conn to a goroutine and calls Accept again immediately; the old oneConnListener returned net.ErrClosed on that second Accept, so Serve — and ServeConn — returned before the request was handled, tearing the conn down mid-response. Make the second Accept block until the served conn is closed (via a close-notifying conn wrapper), so ServeConn serves the whole connection synchronously like a tunnel. The T5 keep-alive unit test masked this by running ServeConn in a goroutine; the synchronous transparent-bridge path (T7) needs it to block. Signed-off-by: Hai Huang --- authbridge/authlib/tlsbridge/serve.go | 49 +++++++++++++++++++++------ 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/authbridge/authlib/tlsbridge/serve.go b/authbridge/authlib/tlsbridge/serve.go index b3eb1fce8..425328855 100644 --- a/authbridge/authlib/tlsbridge/serve.go +++ b/authbridge/authlib/tlsbridge/serve.go @@ -9,33 +9,60 @@ import ( "golang.org/x/net/http2" ) -// oneConnListener serves exactly one already-accepted conn to http.Server.Serve, -// so keep-alive (multiple requests on the same TLS conn) works. +// oneConnListener feeds exactly one already-accepted conn to http.Server.Serve. +// http.Server dispatches the conn to a goroutine and immediately calls Accept +// again; if that second Accept returned an error right away, Serve — and thus +// ServeConn — would return before the request is handled, tearing the conn down +// mid-response. So the second Accept BLOCKS until the served conn is closed, +// keeping Serve (and ServeConn) alive for the whole connection, including +// HTTP/1.1 keep-alive across multiple requests. type oneConnListener struct { - mu sync.Mutex - conn net.Conn + mu sync.Mutex + conn net.Conn + closed chan struct{} +} + +func newOneConnListener(c net.Conn) *oneConnListener { + l := &oneConnListener{closed: make(chan struct{})} + l.conn = ¬ifyConn{Conn: c, closed: l.closed} + return l } func (l *oneConnListener) Accept() (net.Conn, error) { l.mu.Lock() - defer l.mu.Unlock() - if l.conn == nil { - return nil, net.ErrClosed - } c := l.conn l.conn = nil - return c, nil + l.mu.Unlock() + if c != nil { + return c, nil + } + <-l.closed // block until the served conn closes, then stop the accept loop + return nil, net.ErrClosed } func (l *oneConnListener) Close() error { return nil } func (l *oneConnListener) Addr() net.Addr { return dummyAddr{} } +// notifyConn signals the listener when the served conn is closed, so the +// listener's blocked Accept releases and Serve returns. +type notifyConn struct { + net.Conn + once sync.Once + closed chan struct{} +} + +func (c *notifyConn) Close() error { + c.once.Do(func() { close(c.closed) }) + return c.Conn.Close() +} + type dummyAddr struct{} func (dummyAddr) Network() string { return "tcp" } func (dummyAddr) String() string { return "tls-bridge" } // ServeConn drives an already-terminated TLS conn through handler with HTTP -// keep-alive, negotiating h2 when ALPN selected it. +// keep-alive, negotiating h2 when ALPN selected it. It blocks until the +// connection is closed (like a tunnel), so callers can serve it synchronously. func ServeConn(tconn *tls.Conn, handler http.Handler) { srv := &http.Server{Handler: handler} if tconn.ConnectionState().NegotiatedProtocol == "h2" { @@ -43,5 +70,5 @@ func ServeConn(tconn *tls.Conn, handler http.Handler) { h2s.ServeConn(tconn, &http2.ServeConnOpts{Handler: handler, BaseConfig: srv}) return } - _ = srv.Serve(&oneConnListener{conn: tconn}) + _ = srv.Serve(newOneConnListener(tconn)) } From 85ed852e76af046e82589f7a7b666352f269ec72 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 20:32:46 -0400 Subject: [PATCH 11/24] feat(forwardproxy): reversible TLS bridge on transparent path Add the bridge branch to HandleTransparentConn: peek the sniffed ClientHello, classify, and on Terminate verify the upstream origin first (reversibility) then forge a leaf, terminate the agent TLS, and run the unchanged outbound pipeline via serveOutbound, relaying over the verified upstream client. Falls open to a re-dialed tunnel when upstream verify fails; auto-skips a host whose minted leaf the client rejects. Integration test drives the full decrypt -> pipeline -> re-originate loop end to end. Signed-off-by: Hai Huang --- .../authlib/listener/forwardproxy/server.go | 42 +++- .../tlsbridge_integration_test.go | 224 ++++++++++++++++++ .../listener/forwardproxy/transparent.go | 29 +++ 3 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go diff --git a/authbridge/authlib/listener/forwardproxy/server.go b/authbridge/authlib/listener/forwardproxy/server.go index ac7af45f2..8cbadae76 100644 --- a/authbridge/authlib/listener/forwardproxy/server.go +++ b/authbridge/authlib/listener/forwardproxy/server.go @@ -441,6 +441,40 @@ func (s *Server) serveOutbound(w http.ResponseWriter, r *http.Request, isBridge } } +// bridgeServe attempts to bridge: verify the upstream origin first (reversibility), +// then forge a leaf + terminate the agent TLS + run the UNCHANGED pipeline via +// serveOutbound. authority is host:port (used to dial+verify upstream and to set +// r.URL.Host); host is the skip/log key. Returns true if it consumed the connection +// (success OR an unrecoverable post-forge failure that was logged); false to fall +// back to a plain tunnel — so no working call is ever broken. +func (s *Server) bridgeServe(client net.Conn, authority, host string) bool { + // 1) Verify upstream reachability + cert via the dedicated client, BEFORE forging. + // HEAD avoids GET side-effects; a non-2xx status still returns err==nil (cert + // verified), which is all we need. Only a transport/TLS error fails here. + resp, err := s.TLSBridge.Upstream.Head("https://" + authority) + if err != nil { + slog.Info("tls-bridge passthrough", "host", host, "reason", "upstream-verify", "error", err) + return false // fall back to plain tunnel — agent's own e2e TLS still reaches origin + } + _ = resp.Body.Close() + + // 2) Forge + terminate downstream. + tconn, err := s.TLSBridge.Term.Terminate(client, hostOnly(authority)) + if err != nil { + s.TLSBridge.Skip.Add(host) // pinned client → its retry will passthrough + slog.Warn("tls-bridge passthrough", "host", host, "reason", "handshake-fail", "error", err) + return true // conn is dead post-forge; nothing left to tunnel + } + + // 3) Serve the decrypted conn through the UNCHANGED pipeline. + tlsbridge.ServeConn(tconn, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.URL.Scheme = "https" + r.URL.Host = authority // host:port — preserves non-443 origins + s.serveOutbound(w, r, true) + })) + return true +} + // recordOutboundResponseEvent emits the SessionResponse event for a // completed outbound response. Extracted from handleRequest so the // streaming path can call it once at end-of-stream and the buffered @@ -953,11 +987,3 @@ func portOf(authority string) int { } return 443 } - -// nameOrIP prefers the sniffed SNI name, falling back to the dialed IP. -func nameOrIP(name, ip string) string { - if name != "" { - return name - } - return ip -} diff --git a/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go b/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go new file mode 100644 index 000000000..6bd2867bc --- /dev/null +++ b/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go @@ -0,0 +1,224 @@ +package forwardproxy + +import ( + "bufio" + "context" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/plugintesting" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/tlsbridge" +) + +// bridgeProbePlugin records the decrypted request method/path/headers it sees. +// It proves the UNCHANGED outbound pipeline runs on the plaintext request the +// TLS bridge produced after terminating the agent's TLS — i.e. the bridge +// decrypted the bytes and handed them to the pipeline, not an opaque tunnel. +type bridgeProbePlugin struct { + gotMethod string + gotPath string + gotHeader http.Header + calls int +} + +func (p *bridgeProbePlugin) Name() string { return "bridge-probe" } +func (p *bridgeProbePlugin) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{} +} +func (p *bridgeProbePlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + p.calls++ + p.gotMethod = pctx.Method + p.gotPath = pctx.Path + p.gotHeader = pctx.Headers.Clone() + return pipeline.Action{Type: pipeline.Continue} +} +func (p *bridgeProbePlugin) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} + +// TestTransparentBridge drives the full decrypt → pipeline → re-originate loop +// through HandleTransparentConn. An httptest TLS origin stands in for the real +// upstream. The agent side speaks TLS to the proxy trusting the bridge's +// ephemeral CA; the proxy forges a leaf, terminates, runs the pipeline (probe +// records the plaintext), and re-originates to the real origin over the +// upstream client (trusting the origin's own CA). The body must arrive intact. +func TestTransparentBridge(t *testing.T) { + // 1) TLS origin: records the decrypted path it actually received and + // returns a known body for /secret. + var gotOriginPath string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotOriginPath = r.URL.Path + if r.URL.Path == "/secret" { + _, _ = w.Write([]byte("OK-SECRET")) + return + } + w.WriteHeader(http.StatusNotFound) + }) + // The transparent listener only sniffs — and therefore only bridges — on the + // ports shouldSniff() accepts {80,443,8080,8443}; the bridge piggybacks on the + // sniffed *peekedConn for its record-header peek. Bind the origin to a non-root + // sniffable port so HandleTransparentConn engages the bridge rather than tunneling. + var ln net.Listener + for _, p := range []string{"8443", "8080"} { + if l, e := net.Listen("tcp", "127.0.0.1:"+p); e == nil { + ln = l + break + } + } + if ln == nil { + t.Skip("transparent bridge test needs a free sniffable port (8443 or 8080)") + } + origin := httptest.NewUnstartedServer(handler) + _ = origin.Listener.Close() + origin.Listener = ln + origin.StartTLS() + defer origin.Close() + + originCAPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: origin.Certificate().Raw, + }) + + originURL, err := url.Parse(origin.URL) + if err != nil { + t.Fatalf("parse origin URL: %v", err) + } + originHostPort := originURL.Host // "127.0.0.1:port" + + // 2) Build the bridge Engine. ScopeAll so the loopback origin isn't + // treated as in-cluster and skipped. + src, err := tlsbridge.NewEphemeralSource() + if err != nil { + t.Fatalf("NewEphemeralSource: %v", err) + } + minter := tlsbridge.NewMinter(src, tlsbridge.MinterOpts{}) + up, err := tlsbridge.NewUpstreamClient(originCAPEM) + if err != nil { + t.Fatalf("NewUpstreamClient: %v", err) + } + engine := &tlsbridge.Engine{ + Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ + Scope: tlsbridge.ScopeAll, + Ports: map[int]bool{portOf(originHostPort): true}, + }), + Term: tlsbridge.NewTerminator(minter), + Skip: tlsbridge.NewSkipSet(), + Upstream: up, + CAPEM: src.CACertPEM(), + } + + // 3) Server wired with a real OutboundPipeline carrying the recording + // probe plugin (mirrors server_test.go's plugintesting.BuildPipeline + + // pipeline.NewHolder construction). + probe := &bridgeProbePlugin{} + p, err := plugintesting.BuildPipeline([]pipeline.Plugin{probe}) + if err != nil { + t.Fatalf("BuildPipeline: %v", err) + } + srv := &Server{ + OutboundPipeline: pipeline.NewHolder(p), + Client: http.DefaultClient, + TLSBridge: engine, + } + + // 4) Drive HandleTransparentConn over an in-memory conn pair. The server + // side believes it captured a connection whose SO_ORIGINAL_DST is the + // origin's host:port. + clientSide, serverSide := net.Pipe() + defer clientSide.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + srv.HandleTransparentConn(serverSide, originHostPort) + }() + + // Agent-side TLS: trust the bridge's ephemeral CA, set SNI to 127.0.0.1 + // (the Minter forges an IP-SAN leaf for it; httptest's cert also has a + // 127.0.0.1 SAN so the upstream HEAD/relay verifies too). + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(engine.CAPEM) { + t.Fatalf("failed to append bridge CA PEM to pool") + } + host := hostOnly(originHostPort) // "127.0.0.1" + // Offer only http/1.1 so ALPN matches the h1.1 round-trip below. (The h2 + // serving path is covered by the tlsbridge ServeConn unit tests.) + tconn := tls.Client(clientSide, &tls.Config{ + ServerName: host, + RootCAs: pool, + NextProtos: []string{"http/1.1"}, + }) + _ = tconn.SetDeadline(time.Now().Add(10 * time.Second)) + if err := tconn.Handshake(); err != nil { + t.Fatalf("agent-side TLS handshake through bridge failed: %v", err) + } + + // Send GET /secret over the now-decryptable TLS conn. net.Pipe is a + // synchronous, unbuffered, full-duplex pair: a Write blocks until the + // peer Reads. Driving the request write and the response read from the + // same goroutine — as http.Transport.RoundTrip does internally — can + // wedge here, because the server side (ServeConn → serveOutbound) makes + // a blocking upstream round-trip to the origin between reading the + // request and writing the response, and the http/1.1 client write isn't + // guaranteed to have fully drained into the server before the server + // stops reading and starts its upstream call. Write the request in its + // own goroutine and read the response in this one, so both pipe + // directions can make progress independently. This mirrors the + // client/server concurrency split in tlsbridge's known-good + // TestServeConn_HTTP11KeepAlive (server serves in a goroutine; the + // client drives req.Write + http.ReadResponse manually). + req, err := http.NewRequest(http.MethodGet, "https://"+host+"/secret", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + writeErr := make(chan error, 1) + go func() { writeErr <- req.Write(tconn) }() + + resp, err := http.ReadResponse(bufio.NewReader(tconn), req) + if err != nil { + t.Fatalf("read response over bridged TLS conn: %v", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response body: %v", err) + } + if werr := <-writeErr; werr != nil { + t.Fatalf("write request over bridged TLS conn: %v", werr) + } + + // Assertions. + if probe.calls == 0 { + t.Fatalf("probe plugin never ran — pipeline did not see decrypted request (bridge branch missing?)") + } + if probe.gotPath != "/secret" { + t.Errorf("probe recorded path = %q, want /secret", probe.gotPath) + } + if probe.gotMethod != http.MethodGet { + t.Errorf("probe recorded method = %q, want GET", probe.gotMethod) + } + if gotOriginPath != "/secret" { + t.Errorf("origin received path = %q, want /secret", gotOriginPath) + } + if got := strings.TrimSpace(string(body)); got != "OK-SECRET" { + t.Errorf("response body = %q, want OK-SECRET", got) + } + + _ = clientSide.Close() + _ = serverSide.Close() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("HandleTransparentConn did not return after conns closed") + } +} diff --git a/authbridge/authlib/listener/forwardproxy/transparent.go b/authbridge/authlib/listener/forwardproxy/transparent.go index d2cb1e6ef..a7f79cf20 100644 --- a/authbridge/authlib/listener/forwardproxy/transparent.go +++ b/authbridge/authlib/listener/forwardproxy/transparent.go @@ -10,6 +10,7 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" "github.com/kagenti/kagenti-extensions/authbridge/authlib/session" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/tlsbridge" ) // HandleTransparentConn processes one outbound connection captured by an @@ -122,6 +123,34 @@ func (s *Server) HandleTransparentConn(clientConn net.Conn, dst string) { enableKeepalive(upstream) s.recordTunnelOpened(pctx) + + if s.TLSBridge != nil { + // host is the policy authority: ":port" when a name was + // recovered, else dst (":port"). key is the SNI name or dial IP; + // ip is always the dialed IP (for the in-cluster CIDR gate). + ip := hostOnly(dst) + key := hostOnly(host) + var first []byte + if pc, ok := clientConn.(*peekedConn); ok { + first, _ = pc.Peek(5) + } + if !s.TLSBridge.Skip.Contains(key) { + v, reason := s.TLSBridge.Decision.Classify(key, ip, portOf(dst), first) + if v == tlsbridge.Terminate { + _ = upstream.Close() // bridgeServe dials its own verified upstream; drop the pre-dial + if s.bridgeServe(clientConn, host, key) { + return + } + // bridgeServe fell open (upstream-verify failed) → re-dial for the tunnel. + if up2, derr := net.DialTimeout("tcp", dst, connectDialTimeout); derr == nil { + tunnel(clientConn, up2) + _ = up2.Close() + } + return + } + slog.Info("tls-bridge passthrough", "host", key, "reason", reason) + } + } tunnel(clientConn, upstream) } From 2f6faa2c91f694b77fb1000d71d461d701daed7b Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 20:37:24 -0400 Subject: [PATCH 12/24] feat(forwardproxy): reversible TLS bridge on CONNECT path Signed-off-by: Hai Huang --- .../authlib/listener/forwardproxy/server.go | 25 +++ .../tlsbridge_integration_test.go | 180 ++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/authbridge/authlib/listener/forwardproxy/server.go b/authbridge/authlib/listener/forwardproxy/server.go index 8cbadae76..b5100e387 100644 --- a/authbridge/authlib/listener/forwardproxy/server.go +++ b/authbridge/authlib/listener/forwardproxy/server.go @@ -4,6 +4,7 @@ package forwardproxy import ( + "bufio" "bytes" "context" cryptotls "crypto/tls" @@ -867,6 +868,30 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { s.recordTunnelOpened(pctx) } + if s.TLSBridge != nil { + pc := &peekedConn{Conn: clientConn, r: bufio.NewReaderSize(clientConn, sniffBufSize)} + clientConn = pc // replay peeked bytes into whichever path runs + first, _ := pc.Peek(5) + authority := r.Host // CONNECT target is already host:port + key := hostOnly(r.Host) + if !s.TLSBridge.Skip.Contains(key) { + // ip is the hostname for a name CONNECT target (ParseIP→nil ⇒ never + // matched as in-cluster; the transparent path keys on the dialed IP). + if v, _ := s.TLSBridge.Decision.Classify(key, key, portOf(r.Host), first); v == tlsbridge.Terminate { + _ = upstream.Close() // bridgeServe dials its own verified upstream + if s.bridgeServe(clientConn, authority, key) { + return + } + // fell open → re-dial for the tunnel + if up2, derr := net.DialTimeout("tcp", r.Host, connectDialTimeout); derr == nil { + tunnel(clientConn, up2) + _ = up2.Close() + } + return + } + } + } + // Bidirectional copy until either side closes. tunnel(clientConn, upstream) } diff --git a/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go b/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go index 6bd2867bc..0a7da19b9 100644 --- a/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go +++ b/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go @@ -222,3 +222,183 @@ func TestTransparentBridge(t *testing.T) { t.Fatalf("HandleTransparentConn did not return after conns closed") } } + +// TestConnectBridge drives the full CONNECT → 200 → agent-TLS → decrypt → +// pipeline → re-originate loop through the PUBLIC forward-proxy HTTP handler +// (so the real net/http Hijack path runs). An httptest TLS origin stands in +// for the upstream. The agent dials the proxy, issues CONNECT, reads the 200, +// then speaks TLS over the SAME raw conn trusting the bridge's ephemeral CA; +// the proxy forges a leaf, terminates, runs the pipeline (probe records the +// plaintext), and re-originates to the real origin. Body must arrive intact. +func TestConnectBridge(t *testing.T) { + // 1) TLS origin on a random port. CONNECT doesn't go through shouldSniff, + // so any port works here. + var gotOriginPath string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotOriginPath = r.URL.Path + if r.URL.Path == "/secret" { + _, _ = w.Write([]byte("OK-SECRET")) + return + } + w.WriteHeader(http.StatusNotFound) + }) + origin := httptest.NewTLSServer(handler) + defer origin.Close() + + originCAPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: origin.Certificate().Raw, + }) + + originURL, err := url.Parse(origin.URL) + if err != nil { + t.Fatalf("parse origin URL: %v", err) + } + originHostPort := originURL.Host // "127.0.0.1:port" + + // 2) Build the bridge Engine. ScopeAll so the loopback origin isn't + // treated as in-cluster and skipped. Ports must include the origin's + // random port (the CONNECT classify keys on portOf(r.Host)). + src, err := tlsbridge.NewEphemeralSource() + if err != nil { + t.Fatalf("NewEphemeralSource: %v", err) + } + minter := tlsbridge.NewMinter(src, tlsbridge.MinterOpts{}) + up, err := tlsbridge.NewUpstreamClient(originCAPEM) + if err != nil { + t.Fatalf("NewUpstreamClient: %v", err) + } + engine := &tlsbridge.Engine{ + Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ + Scope: tlsbridge.ScopeAll, + Ports: map[int]bool{portOf(originHostPort): true}, + }), + Term: tlsbridge.NewTerminator(minter), + Skip: tlsbridge.NewSkipSet(), + Upstream: up, + CAPEM: src.CACertPEM(), + } + + // 3) Server wired with a real OutboundPipeline carrying the recording probe. + probe := &bridgeProbePlugin{} + p, err := plugintesting.BuildPipeline([]pipeline.Plugin{probe}) + if err != nil { + t.Fatalf("BuildPipeline: %v", err) + } + srv := &Server{ + OutboundPipeline: pipeline.NewHolder(p), + Client: http.DefaultClient, + TLSBridge: engine, + } + + // 4) Stand up the public forward-proxy HTTP handler so Hijack works. + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + proxyURL, err := url.Parse(proxy.URL) + if err != nil { + t.Fatalf("parse proxy URL: %v", err) + } + + // 5) Raw-dial the proxy and issue CONNECT to the origin host:port. + rawConn, err := net.Dial("tcp", proxyURL.Host) + if err != nil { + t.Fatalf("dial proxy: %v", err) + } + defer func() { _ = rawConn.Close() }() + _ = rawConn.SetDeadline(time.Now().Add(10 * time.Second)) + + connectReq, err := http.NewRequest(http.MethodConnect, "//"+originHostPort, nil) + if err != nil { + t.Fatalf("new CONNECT request: %v", err) + } + connectReq.Host = originHostPort + connectReq.URL.Host = originHostPort + if err := connectReq.Write(rawConn); err != nil { + t.Fatalf("write CONNECT: %v", err) + } + + // Read the 200 Connection Established. http.ReadResponse against the + // CONNECT request consumes exactly the status line + headers (no body), + // leaving the raw conn positioned at the first post-200 byte — which is + // where the agent's ClientHello begins. + br := bufio.NewReader(rawConn) + connectResp, err := http.ReadResponse(br, connectReq) + if err != nil { + t.Fatalf("read CONNECT response: %v", err) + } + if connectResp.StatusCode != http.StatusOK { + t.Fatalf("CONNECT status = %d, want 200", connectResp.StatusCode) + } + _ = connectResp.Body.Close() + + // 6) Agent-side TLS over the SAME raw conn (wrapped so any bytes ReadResponse + // buffered past the 200 are replayed — there should be none, but be safe). + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(engine.CAPEM) { + t.Fatalf("failed to append bridge CA PEM to pool") + } + host := hostOnly(originHostPort) // "127.0.0.1" + tlsTransport := &bufferedConn{Conn: rawConn, r: br} + tconn := tls.Client(tlsTransport, &tls.Config{ + ServerName: host, + RootCAs: pool, + NextProtos: []string{"http/1.1"}, + }) + _ = tconn.SetDeadline(time.Now().Add(10 * time.Second)) + if err := tconn.Handshake(); err != nil { + t.Fatalf("agent-side TLS handshake through CONNECT bridge failed: %v", err) + } + + // 7) GET /secret over the bridged TLS conn. Same goroutine-write + + // http.ReadResponse split as TestTransparentBridge: the server makes a + // blocking upstream round-trip between reading the request and writing + // the response, so a single-goroutine RoundTrip can wedge. + req, err := http.NewRequest(http.MethodGet, "https://"+host+"/secret", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + writeErr := make(chan error, 1) + go func() { writeErr <- req.Write(tconn) }() + + resp, err := http.ReadResponse(bufio.NewReader(tconn), req) + if err != nil { + t.Fatalf("read response over bridged TLS conn: %v", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response body: %v", err) + } + if werr := <-writeErr; werr != nil { + t.Fatalf("write request over bridged TLS conn: %v", werr) + } + + // 8) Assertions. + if probe.calls == 0 { + t.Fatalf("probe plugin never ran — pipeline did not see decrypted request (CONNECT bridge branch missing?)") + } + if probe.gotPath != "/secret" { + t.Errorf("probe recorded path = %q, want /secret", probe.gotPath) + } + if probe.gotMethod != http.MethodGet { + t.Errorf("probe recorded method = %q, want GET", probe.gotMethod) + } + if gotOriginPath != "/secret" { + t.Errorf("origin received path = %q, want /secret", gotOriginPath) + } + if got := strings.TrimSpace(string(body)); got != "OK-SECRET" { + t.Errorf("response body = %q, want OK-SECRET", got) + } +} + +// bufferedConn wraps a net.Conn whose leading bytes were partly drained into a +// bufio.Reader (e.g. by http.ReadResponse on the CONNECT 200), so a subsequent +// reader (the agent's tls.Client) replays those buffered bytes before reading +// from the wire. Mirrors peekedConn's Read-replays-buffered semantics for the +// client side of the test harness. +type bufferedConn struct { + net.Conn + r *bufio.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { return c.r.Read(p) } From fe93eecd662af42ed042f72a41f1906ec869e89a Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 21:45:52 -0400 Subject: [PATCH 13/24] test(tlsbridge): no-broken-calls guarantees (fall-open + auto-skip) Signed-off-by: Hai Huang --- .../tlsbridge_integration_test.go | 437 ++++++++++++++++++ 1 file changed, 437 insertions(+) diff --git a/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go b/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go index 0a7da19b9..3dd3491b9 100644 --- a/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go +++ b/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go @@ -402,3 +402,440 @@ type bufferedConn struct { } func (c *bufferedConn) Read(p []byte) (int, error) { return c.r.Read(p) } + +// listenSniffable binds a listener on the first free port shouldSniff() accepts +// (8443 then 8080), so the transparent path engages the bridge instead of +// tunneling. Returns nil if none is free; callers t.Skip in that case (the +// transparent bridge path is gated on those ports — see TestTransparentBridge). +func listenSniffable() net.Listener { + for _, p := range []string{"8443", "8080"} { + if l, e := net.Listen("tcp", "127.0.0.1:"+p); e == nil { + return l + } + } + return nil +} + +// TestBridge_UnverifiableUpstream_FallsOpenToTunnel encodes the headline +// no-broken-calls guarantee for the un-bridgeable-origin case: when the bridge's +// own Upstream client does NOT trust the origin's CA, bridgeServe's upstream-verify +// HEAD fails BEFORE any leaf is forged, bridgeServe returns false, and the +// transparent branch re-dials and tunnels. The agent's OWN end-to-end TLS then +// reaches the origin untouched — the call succeeds and the pipeline never runs. +// probe.calls == 0 is the proof the traffic was tunneled, not bridged. +func TestBridge_UnverifiableUpstream_FallsOpenToTunnel(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/secret" { + _, _ = w.Write([]byte("OK-SECRET")) + return + } + w.WriteHeader(http.StatusNotFound) + }) + ln := listenSniffable() + if ln == nil { + t.Skip("transparent bridge test needs a free sniffable port (8443 or 8080)") + } + origin := httptest.NewUnstartedServer(handler) + _ = origin.Listener.Close() + origin.Listener = ln + origin.StartTLS() + defer origin.Close() + + originURL, err := url.Parse(origin.URL) + if err != nil { + t.Fatalf("parse origin URL: %v", err) + } + originHostPort := originURL.Host // "127.0.0.1:port" + + // Build the bridge Engine. The Upstream client trusts ONLY the system roots + // (NewUpstreamClient(nil)) — it does NOT trust the httptest origin's + // self-signed CA, so bridgeServe's upstream-verify HEAD fails and the branch + // falls open to a plain tunnel. + src, err := tlsbridge.NewEphemeralSource() + if err != nil { + t.Fatalf("NewEphemeralSource: %v", err) + } + minter := tlsbridge.NewMinter(src, tlsbridge.MinterOpts{}) + up, err := tlsbridge.NewUpstreamClient(nil) + if err != nil { + t.Fatalf("NewUpstreamClient: %v", err) + } + engine := &tlsbridge.Engine{ + Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ + Scope: tlsbridge.ScopeAll, + Ports: map[int]bool{portOf(originHostPort): true}, + }), + Term: tlsbridge.NewTerminator(minter), + Skip: tlsbridge.NewSkipSet(), + Upstream: up, + CAPEM: src.CACertPEM(), + } + + probe := &bridgeProbePlugin{} + p, err := plugintesting.BuildPipeline([]pipeline.Plugin{probe}) + if err != nil { + t.Fatalf("BuildPipeline: %v", err) + } + srv := &Server{ + OutboundPipeline: pipeline.NewHolder(p), + Client: http.DefaultClient, + TLSBridge: engine, + } + + clientSide, serverSide := net.Pipe() + defer func() { _ = clientSide.Close() }() + + done := make(chan struct{}) + go func() { + defer close(done) + srv.HandleTransparentConn(serverSide, originHostPort) + }() + + // Agent-side TLS trusts the ORIGIN's real CA (NOT the bridge's ephemeral CA). + // When the branch falls open and tunnels, the agent's own TLS handshake goes + // end-to-end to the origin and verifies cleanly — proving the fall-open + // preserved the working call. + pool := x509.NewCertPool() + pool.AddCert(origin.Certificate()) + host := hostOnly(originHostPort) // "127.0.0.1" + tconn := tls.Client(clientSide, &tls.Config{ + ServerName: host, + RootCAs: pool, + NextProtos: []string{"http/1.1"}, + }) + _ = tconn.SetDeadline(time.Now().Add(10 * time.Second)) + if err := tconn.Handshake(); err != nil { + t.Fatalf("agent-side TLS handshake (expected to tunnel to origin) failed: %v", err) + } + + req, err := http.NewRequest(http.MethodGet, "https://"+host+"/secret", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + writeErr := make(chan error, 1) + go func() { writeErr <- req.Write(tconn) }() + + resp, err := http.ReadResponse(bufio.NewReader(tconn), req) + if err != nil { + t.Fatalf("read response over tunneled TLS conn: %v", err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response body: %v", err) + } + if werr := <-writeErr; werr != nil { + t.Fatalf("write request over tunneled TLS conn: %v", werr) + } + + if got := strings.TrimSpace(string(body)); got != "OK-SECRET" { + t.Errorf("response body = %q, want OK-SECRET (fall-open tunnel should deliver the agent's own TLS to the origin)", got) + } + // PROOF the traffic was tunneled, not bridged: the bridge never decrypted the + // agent's TLS, so the pipeline never saw the inner GET /secret. HandleTransparentConn + // always runs ONE synthetic CONNECT-style egress gate per connection (Method=CONNECT, + // Path="") before the bridge branch — that pass is expected. What must NEVER appear is + // the decrypted inner request: if the bridge had terminated TLS, the probe would have + // recorded Method=GET, Path=/secret (as TestTransparentBridge asserts on the happy path). + if probe.gotMethod != http.MethodConnect || probe.gotPath != "" { + t.Errorf("probe saw decrypted request (method=%q path=%q) — pipeline must NOT decrypt when upstream-verify fails (traffic was tunneled, not bridged)", probe.gotMethod, probe.gotPath) + } + + _ = clientSide.Close() + _ = serverSide.Close() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("HandleTransparentConn did not return after conns closed") + } +} + +// TestBridge_PinnedClient_AutoSkipsThenTunnels encodes the no-broken-calls +// guarantee for the pinned-client case: a pinned agent rejects the forged leaf, +// so attempt 1's handshake fails inside bridgeServe (which Skip.Add's the host), +// and attempt 2 short-circuits on Skip.Contains straight to a plain tunnel that +// reaches the origin via the agent's own TLS. The same Engine is reused across +// both attempts so the SkipSet persists; probe.calls stays 0 throughout (the +// pipeline never ran — the first attempt died at the forged handshake, the +// second was tunneled). +func TestBridge_PinnedClient_AutoSkipsThenTunnels(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/secret" { + _, _ = w.Write([]byte("OK-SECRET")) + return + } + w.WriteHeader(http.StatusNotFound) + }) + ln := listenSniffable() + if ln == nil { + t.Skip("transparent bridge test needs a free sniffable port (8443 or 8080)") + } + origin := httptest.NewUnstartedServer(handler) + _ = origin.Listener.Close() + origin.Listener = ln + origin.StartTLS() + defer origin.Close() + + originCAPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: origin.Certificate().Raw, + }) + + originURL, err := url.Parse(origin.URL) + if err != nil { + t.Fatalf("parse origin URL: %v", err) + } + originHostPort := originURL.Host // "127.0.0.1:port" + + // Upstream trusts the origin (NewUpstreamClient(originCAPEM)) so upstream-verify + // PASSES and bridgeServe reaches the Terminate step where the pinned agent + // rejects the minted leaf. + src, err := tlsbridge.NewEphemeralSource() + if err != nil { + t.Fatalf("NewEphemeralSource: %v", err) + } + minter := tlsbridge.NewMinter(src, tlsbridge.MinterOpts{}) + up, err := tlsbridge.NewUpstreamClient(originCAPEM) + if err != nil { + t.Fatalf("NewUpstreamClient: %v", err) + } + engine := &tlsbridge.Engine{ + Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ + Scope: tlsbridge.ScopeAll, + Ports: map[int]bool{portOf(originHostPort): true}, + }), + Term: tlsbridge.NewTerminator(minter), + Skip: tlsbridge.NewSkipSet(), + Upstream: up, + CAPEM: src.CACertPEM(), + } + + probe := &bridgeProbePlugin{} + p, err := plugintesting.BuildPipeline([]pipeline.Plugin{probe}) + if err != nil { + t.Fatalf("BuildPipeline: %v", err) + } + srv := &Server{ + OutboundPipeline: pipeline.NewHolder(p), + Client: http.DefaultClient, + TLSBridge: engine, + } + + host := hostOnly(originHostPort) // "127.0.0.1" + // The transparent branch derives key = hostOnly(host); host is ":port" + // when a name was sniffed. The agent sets SNI = "127.0.0.1", so key == "127.0.0.1". + wantKey := host + + // Pinned agent: trusts ONLY the origin's real CA, never the bridge's ephemeral + // CA — so it rejects the minted leaf in BOTH attempts. + pinnedPool := x509.NewCertPool() + pinnedPool.AddCert(origin.Certificate()) + + // --- Attempt 1: bridge forges, pinned agent rejects → handshake fails, host skipped. --- + clientSide1, serverSide1 := net.Pipe() + go srv.HandleTransparentConn(serverSide1, originHostPort) + + tconn1 := tls.Client(clientSide1, &tls.Config{ + ServerName: host, + RootCAs: pinnedPool, + NextProtos: []string{"http/1.1"}, + }) + _ = tconn1.SetDeadline(time.Now().Add(10 * time.Second)) + hsErr := tconn1.Handshake() + if hsErr == nil { + t.Fatalf("attempt 1: pinned agent handshake unexpectedly SUCCEEDED — it must reject the bridge's minted leaf") + } + _ = clientSide1.Close() + _ = serverSide1.Close() + + // The rejected handshake must have auto-skipped the host (bridgeServe Skip.Add + // on Terminate error). Poll briefly: bridgeServe runs on the server goroutine, + // so Skip.Add may land just after the client observes the handshake error. + skipped := false + for i := 0; i < 100; i++ { + if engine.Skip.Contains(wantKey) { + skipped = true + break + } + time.Sleep(10 * time.Millisecond) + } + if !skipped { + t.Fatalf("attempt 1: engine.Skip does not contain %q after pinned-client handshake failure — auto-skip did not fire", wantKey) + } + + // --- Attempt 2: host is skipped → branch short-circuits to a plain tunnel. --- + // The same pinned agent now handshakes end-to-end to the origin (which it + // trusts) through the tunnel, and GET /secret succeeds. + clientSide2, serverSide2 := net.Pipe() + defer func() { _ = clientSide2.Close() }() + done2 := make(chan struct{}) + go func() { + defer close(done2) + srv.HandleTransparentConn(serverSide2, originHostPort) + }() + + tconn2 := tls.Client(clientSide2, &tls.Config{ + ServerName: host, + RootCAs: pinnedPool, + NextProtos: []string{"http/1.1"}, + }) + _ = tconn2.SetDeadline(time.Now().Add(10 * time.Second)) + if err := tconn2.Handshake(); err != nil { + t.Fatalf("attempt 2: tunneled handshake to origin failed (auto-skip retry should tunnel cleanly): %v", err) + } + + req, err := http.NewRequest(http.MethodGet, "https://"+host+"/secret", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + writeErr := make(chan error, 1) + go func() { writeErr <- req.Write(tconn2) }() + + resp, err := http.ReadResponse(bufio.NewReader(tconn2), req) + if err != nil { + t.Fatalf("attempt 2: read response over tunneled TLS conn: %v", err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("attempt 2: read response body: %v", err) + } + if werr := <-writeErr; werr != nil { + t.Fatalf("attempt 2: write request over tunneled TLS conn: %v", werr) + } + + if got := strings.TrimSpace(string(body)); got != "OK-SECRET" { + t.Errorf("attempt 2: response body = %q, want OK-SECRET (skipped host should tunnel to origin)", got) + } + // PROOF neither attempt bridged: the probe never saw a decrypted inner request. + // Each HandleTransparentConn runs one synthetic CONNECT egress gate (Method=CONNECT, + // Path=""); attempt 1 then died at the forged handshake (before any decrypted request + // could reach the pipeline) and attempt 2 short-circuited on Skip.Contains to a plain + // tunnel. If either had terminated TLS, the probe would have recorded GET /secret. + if probe.gotMethod != http.MethodConnect || probe.gotPath != "" { + t.Errorf("probe saw decrypted request (method=%q path=%q) — neither attempt should decrypt (attempt 1 died at the forged handshake; attempt 2 was tunneled)", probe.gotMethod, probe.gotPath) + } + + _ = clientSide2.Close() + _ = serverSide2.Close() + select { + case <-done2: + case <-time.After(5 * time.Second): + t.Fatalf("attempt 2: HandleTransparentConn did not return after conns closed") + } +} + +// TestBridge_NonTLS_Passthrough encodes the no-broken-calls guarantee for +// non-TLS traffic on a bridge-eligible port: the bridge peeks 5 bytes, sees an +// HTTP method (not a TLS record), Classify returns Passthrough,"non-tls", and +// the branch tunnels. A plaintext GET /secret reaches the origin and its body is +// returned; probe.calls stays 0 (the pipeline never ran on the opaque tunnel). +func TestBridge_NonTLS_Passthrough(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/secret" { + _, _ = w.Write([]byte("OK-SECRET")) + return + } + w.WriteHeader(http.StatusNotFound) + }) + ln := listenSniffable() + if ln == nil { + t.Skip("transparent bridge test needs a free sniffable port (8443 or 8080)") + } + // PLAINTEXT origin (not TLS) so the tunneled plaintext request reaches it cleanly. + origin := httptest.NewUnstartedServer(handler) + _ = origin.Listener.Close() + origin.Listener = ln + origin.Start() + defer origin.Close() + + originURL, err := url.Parse(origin.URL) + if err != nil { + t.Fatalf("parse origin URL: %v", err) + } + originHostPort := originURL.Host // "127.0.0.1:port" + + src, err := tlsbridge.NewEphemeralSource() + if err != nil { + t.Fatalf("NewEphemeralSource: %v", err) + } + minter := tlsbridge.NewMinter(src, tlsbridge.MinterOpts{}) + up, err := tlsbridge.NewUpstreamClient(nil) + if err != nil { + t.Fatalf("NewUpstreamClient: %v", err) + } + engine := &tlsbridge.Engine{ + Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ + Scope: tlsbridge.ScopeAll, + Ports: map[int]bool{portOf(originHostPort): true}, + }), + Term: tlsbridge.NewTerminator(minter), + Skip: tlsbridge.NewSkipSet(), + Upstream: up, + CAPEM: src.CACertPEM(), + } + + probe := &bridgeProbePlugin{} + p, err := plugintesting.BuildPipeline([]pipeline.Plugin{probe}) + if err != nil { + t.Fatalf("BuildPipeline: %v", err) + } + srv := &Server{ + OutboundPipeline: pipeline.NewHolder(p), + Client: http.DefaultClient, + TLSBridge: engine, + } + + clientSide, serverSide := net.Pipe() + defer func() { _ = clientSide.Close() }() + _ = clientSide.SetDeadline(time.Now().Add(10 * time.Second)) + + done := make(chan struct{}) + go func() { + defer close(done) + srv.HandleTransparentConn(serverSide, originHostPort) + }() + + // Drive a PLAINTEXT GET /secret (no TLS layer): the sniff peeks an HTTP method + // byte ('G', not 0x16), looksLikeTLSRecord is false → Passthrough,"non-tls" → tunnel. + // Same concurrency split as the TLS tests: write the request in a goroutine, + // read the response here, so both pipe directions progress through the tunnel. + req, err := http.NewRequest(http.MethodGet, "http://"+originHostPort+"/secret", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + writeErr := make(chan error, 1) + go func() { writeErr <- req.Write(clientSide) }() + + resp, err := http.ReadResponse(bufio.NewReader(clientSide), req) + if err != nil { + t.Fatalf("read response over plaintext tunnel: %v", err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response body: %v", err) + } + if werr := <-writeErr; werr != nil { + t.Fatalf("write plaintext request over tunnel: %v", werr) + } + + if got := strings.TrimSpace(string(body)); got != "OK-SECRET" { + t.Errorf("response body = %q, want OK-SECRET (non-TLS passthrough should tunnel to the plaintext origin)", got) + } + // PROOF the bytes were passthrough-tunneled, not bridged: the probe only saw the + // synthetic CONNECT egress gate (Method=CONNECT, Path=""), never a decrypted inner + // request. Classify returned Passthrough,"non-tls" so the bridge never forged a leaf + // or terminated TLS; the plaintext GET went straight down the tunnel to the origin. + if probe.gotMethod != http.MethodConnect || probe.gotPath != "" { + t.Errorf("probe saw decrypted request (method=%q path=%q) — non-TLS traffic must passthrough-tunnel, not be bridged", probe.gotMethod, probe.gotPath) + } + + _ = clientSide.Close() + _ = serverSide.Close() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("HandleTransparentConn did not return after conns closed") + } +} From 54a68590a3dd14316aa781a477fc3528b362c37e Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 21:55:09 -0400 Subject: [PATCH 14/24] feat(tlsbridge): config block + main wiring + trust self-check Signed-off-by: Hai Huang --- authbridge/authlib/config/config.go | 33 +++++++++++++++++ authbridge/authlib/config/config_test.go | 23 ++++++++++++ authbridge/authlib/tlsbridge/engine.go | 29 ++++++++++++++- authbridge/cmd/authbridge-proxy/main.go | 45 ++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) diff --git a/authbridge/authlib/config/config.go b/authbridge/authlib/config/config.go index c9ca9854c..adea89c3a 100644 --- a/authbridge/authlib/config/config.go +++ b/authbridge/authlib/config/config.go @@ -37,6 +37,33 @@ type Config struct { // = today's spiffe-helper-driven behavior (until the chart/operator // follow-ups land and start populating the block). SPIFFE *SPIFFEConfig `yaml:"spiffe,omitempty" json:"spiffe,omitempty"` + // TLSBridge, when non-nil and Enabled, terminates agent outbound TLS so the + // outbound pipeline sees decrypted HTTPS. See docs/.../tlsbridge-design.md. + TLSBridge *TLSBridgeConfig `yaml:"tls_bridge,omitempty" json:"tls_bridge,omitempty"` +} + +// TLSBridgeConfig configures the outbound TLS bridge (MITM termination of +// agent egress so the outbound plugin pipeline sees decrypted HTTPS). +type TLSBridgeConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Scope string `yaml:"scope" json:"scope"` // external | all + InternalCIDRs []string `yaml:"internal_cidrs" json:"internal_cidrs"` + CASource string `yaml:"ca_source" json:"ca_source"` // file | ephemeral + CACertPath string `yaml:"ca_cert_path" json:"ca_cert_path"` + CAKeyPath string `yaml:"ca_key_path" json:"ca_key_path"` + UpstreamCABundle string `yaml:"upstream_ca_bundle" json:"upstream_ca_bundle"` + SkipHosts []string `yaml:"skip_hosts" json:"skip_hosts"` +} + +// Validate is called from the loader when TLSBridge != nil. +func (b *TLSBridgeConfig) Validate() error { + if b.Scope != "" && b.Scope != "external" && b.Scope != "all" { + return fmt.Errorf("tls_bridge.scope must be 'external' or 'all', got %q", b.Scope) + } + if b.CASource == "file" && (b.CACertPath == "" || b.CAKeyPath == "") { + return fmt.Errorf("tls_bridge.ca_source=file requires ca_cert_path and ca_key_path") + } + return nil } // MTLSMode names the inbound + outbound TLS posture. Vocabulary @@ -461,5 +488,11 @@ func Load(path string) (*Config, error) { return nil, err } + if cfg.TLSBridge != nil { + if err := cfg.TLSBridge.Validate(); err != nil { + return nil, err + } + } + return &cfg, nil } diff --git a/authbridge/authlib/config/config_test.go b/authbridge/authlib/config/config_test.go index 215d0567a..697778b2b 100644 --- a/authbridge/authlib/config/config_test.go +++ b/authbridge/authlib/config/config_test.go @@ -727,6 +727,29 @@ spiffe: {} } } +// --- TLS bridge config --- + +// The tls_bridge block decodes into TLSBridgeConfig and Load surfaces it. +func TestConfig_TLSBridgeBlockDecodes(t *testing.T) { + y := []byte("mode: proxy-sidecar\n" + + "tls_bridge:\n" + + " enabled: true\n" + + " scope: external\n" + + " ca_source: ephemeral\n" + + " skip_hosts: [\"pinned.example.com\"]\n") + p := filepath.Join(t.TempDir(), "cfg.yaml") + if err := os.WriteFile(p, y, 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(p) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.TLSBridge == nil || !cfg.TLSBridge.Enabled || cfg.TLSBridge.Scope != "external" { + t.Fatalf("tls_bridge block did not decode: %+v", cfg.TLSBridge) + } +} + // Absent mtls block leaves cfg.MTLS nil — today's behavior, no TLS. func TestLoad_MTLS_AbsentBlock(t *testing.T) { dir := t.TempDir() diff --git a/authbridge/authlib/tlsbridge/engine.go b/authbridge/authlib/tlsbridge/engine.go index c270549a3..823ff3450 100644 --- a/authbridge/authlib/tlsbridge/engine.go +++ b/authbridge/authlib/tlsbridge/engine.go @@ -1,6 +1,11 @@ package tlsbridge -import "net/http" +import ( + "log/slog" + "net/http" + "os" + "strings" +) // Engine bundles everything the forward proxy needs to bridge TLS. // A nil *Engine means the bridge is disabled. @@ -11,3 +16,25 @@ type Engine struct { Upstream *http.Client CAPEM []byte } + +// RunTrustSelfCheck logs a loud WARN when the bridge CA is not present in the +// trust file the agent runtime is told to use (SSL_CERT_FILE / NODE_EXTRA_CA_CERTS +// / REQUESTS_CA_BUNDLE). A trust-miss is then a visible signal, not an opaque +// in-agent handshake error. Best-effort. In Phase 1 (test-only) no agent trust +// env is set, so this simply notes that egress will safely tunnel. +func RunTrustSelfCheck(caPEM []byte) { + want := strings.TrimSpace(string(caPEM)) + for _, env := range []string{"SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE"} { + p := os.Getenv(env) + if p == "" { + continue + } + if b, err := os.ReadFile(p); err == nil && strings.Contains(string(b), want) { + slog.Info("tls-bridge trust self-check OK", "env", env, "path", p) + return + } + } + slog.Warn("tls-bridge trust self-check: CA not found in any agent trust file " + + "(SSL_CERT_FILE/NODE_EXTRA_CA_CERTS/REQUESTS_CA_BUNDLE); agent will not trust " + + "minted leaves and egress will safely tunnel (expected in Phase 1 / test-only)") +} diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go index efef83d44..4bffa26de 100644 --- a/authbridge/cmd/authbridge-proxy/main.go +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -34,6 +34,7 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/shared" "github.com/kagenti/kagenti-extensions/authbridge/authlib/spiffe" authtls "github.com/kagenti/kagenti-extensions/authbridge/authlib/tls" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/tlsbridge" // Only HTTP listeners are compiled in: no extproc/extauthz // (no gRPC, no envoy types). @@ -247,6 +248,49 @@ func main() { slog.Info("mTLS disabled (no mtls block in config)") } + // TLS bridge: when enabled, the forward proxy terminates agent outbound + // TLS (MITM) so the outbound pipeline sees decrypted HTTPS. Constructed + // here and set on fpSrv below (mirroring fpSrv.SkipHosts / fpSrv.Shared). + // A nil *Engine leaves today's blind-tunnel behavior intact. + var bridge *tlsbridge.Engine + if cfg.TLSBridge != nil && cfg.TLSBridge.Enabled { + var src tlsbridge.CASource + if cfg.TLSBridge.CASource == "file" { + src, err = tlsbridge.NewFileSource(cfg.TLSBridge.CACertPath, cfg.TLSBridge.CAKeyPath) + } else { + src, err = tlsbridge.NewEphemeralSource() + } + if err != nil { + log.Fatalf("tls-bridge CA init failed: %v", err) + } + var extra []byte + if cfg.TLSBridge.UpstreamCABundle != "" { + if extra, err = os.ReadFile(cfg.TLSBridge.UpstreamCABundle); err != nil { + log.Fatalf("tls-bridge upstream_ca_bundle read failed: %v", err) + } + } + up, uerr := tlsbridge.NewUpstreamClient(extra) + if uerr != nil { + log.Fatalf("tls-bridge upstream client failed: %v", uerr) + } + minter := tlsbridge.NewMinter(src, tlsbridge.MinterOpts{}) + scope := tlsbridge.ScopeExternal + if cfg.TLSBridge.Scope == "all" { + scope = tlsbridge.ScopeAll + } + bridge = &tlsbridge.Engine{ + Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ + Scope: scope, InternalCIDRs: cfg.TLSBridge.InternalCIDRs, SkipHosts: cfg.TLSBridge.SkipHosts, + }), + Term: tlsbridge.NewTerminator(minter), + Skip: tlsbridge.NewSkipSet(), + Upstream: up, + CAPEM: src.CACertPEM(), + } + tlsbridge.RunTrustSelfCheck(bridge.CAPEM) + slog.Info("tls-bridge enabled", "scope", cfg.TLSBridge.Scope, "ca_source", cfg.TLSBridge.CASource) + } + // Proxy-sidecar: reverse proxy on the inbound path + forward proxy // on the outbound path. rpSrv, err := reverseproxy.NewServer(inboundH, sessions, cfg.Listener.ReverseProxyBackend, rpMTLS) @@ -266,6 +310,7 @@ func main() { log.Fatalf("listener.skip_hosts: %v", err) } fpSrv.SkipHosts = skipHosts + fpSrv.TLSBridge = bridge sharedStore := shared.New() defer sharedStore.Close() // stop the TTL janitor on normal main return rpSrv.Shared = sharedStore From e76eb80a1e98b4b1cb5158ef3bf7a5bbed471b5a Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 22:06:46 -0400 Subject: [PATCH 15/24] fix(tlsbridge): reject unrecognized tls_bridge.ca_source in config validation An unknown ca_source (e.g. typo'd 'vault') previously fell through to the ephemeral CA silently; fail fast so a misconfigured file-CA deployment is a loud startup error rather than silent ephemeral mode. Signed-off-by: Hai Huang --- authbridge/authlib/config/config.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/authbridge/authlib/config/config.go b/authbridge/authlib/config/config.go index adea89c3a..93c9a9713 100644 --- a/authbridge/authlib/config/config.go +++ b/authbridge/authlib/config/config.go @@ -60,6 +60,9 @@ func (b *TLSBridgeConfig) Validate() error { if b.Scope != "" && b.Scope != "external" && b.Scope != "all" { return fmt.Errorf("tls_bridge.scope must be 'external' or 'all', got %q", b.Scope) } + if b.CASource != "" && b.CASource != "file" && b.CASource != "ephemeral" { + return fmt.Errorf("tls_bridge.ca_source must be 'file' or 'ephemeral', got %q", b.CASource) + } if b.CASource == "file" && (b.CACertPath == "" || b.CAKeyPath == "") { return fmt.Errorf("tls_bridge.ca_source=file requires ca_cert_path and ca_key_path") } From 448cbec71972071f021462fd4cbcea2b22043132 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 22:25:16 -0400 Subject: [PATCH 16/24] fix(tlsbridge): go mod tidy cmd modules for bumped x/net (CI GOWORK=off) The tlsbridge work promoted golang.org/x/net to a direct authlib dep and bumped it v0.51.0 -> v0.56.0. go.work masks the resulting cmd-module go.mod staleness locally, but CI builds each cmd binary with GOWORK=off, where 'go fmt/vet ./...' aborts with 'updates to go.mod needed'. Tidy the three cmd modules (proxy/envoy/lite) so their go.mod/go.sum reflect x/net v0.56.0. Signed-off-by: Hai Huang --- authbridge/cmd/authbridge-envoy/go.mod | 10 ++++----- authbridge/cmd/authbridge-envoy/go.sum | 28 +++++++++++++------------- authbridge/cmd/authbridge-lite/go.mod | 10 ++++----- authbridge/cmd/authbridge-lite/go.sum | 28 +++++++++++++------------- authbridge/cmd/authbridge-proxy/go.mod | 10 ++++----- authbridge/cmd/authbridge-proxy/go.sum | 28 +++++++++++++------------- 6 files changed, 57 insertions(+), 57 deletions(-) diff --git a/authbridge/cmd/authbridge-envoy/go.mod b/authbridge/cmd/authbridge-envoy/go.mod index 18a5aa0cb..6a3daf27f 100644 --- a/authbridge/cmd/authbridge-envoy/go.mod +++ b/authbridge/cmd/authbridge-envoy/go.mod @@ -65,11 +65,11 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect - 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/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/authbridge/cmd/authbridge-envoy/go.sum b/authbridge/cmd/authbridge-envoy/go.sum index 0887457ec..480bd3f3f 100644 --- a/authbridge/cmd/authbridge-envoy/go.sum +++ b/authbridge/cmd/authbridge-envoy/go.sum @@ -185,23 +185,23 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg= diff --git a/authbridge/cmd/authbridge-lite/go.mod b/authbridge/cmd/authbridge-lite/go.mod index 28ef510c6..eb3842e3c 100644 --- a/authbridge/cmd/authbridge-lite/go.mod +++ b/authbridge/cmd/authbridge-lite/go.mod @@ -58,11 +58,11 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect - 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/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.81.1 // indirect diff --git a/authbridge/cmd/authbridge-lite/go.sum b/authbridge/cmd/authbridge-lite/go.sum index 9449da37e..561abbed9 100644 --- a/authbridge/cmd/authbridge-lite/go.sum +++ b/authbridge/cmd/authbridge-lite/go.sum @@ -177,23 +177,23 @@ go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg= diff --git a/authbridge/cmd/authbridge-proxy/go.mod b/authbridge/cmd/authbridge-proxy/go.mod index 5af43c6ea..692b39037 100644 --- a/authbridge/cmd/authbridge-proxy/go.mod +++ b/authbridge/cmd/authbridge-proxy/go.mod @@ -56,11 +56,11 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect - 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/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.81.1 // indirect diff --git a/authbridge/cmd/authbridge-proxy/go.sum b/authbridge/cmd/authbridge-proxy/go.sum index 9449da37e..561abbed9 100644 --- a/authbridge/cmd/authbridge-proxy/go.sum +++ b/authbridge/cmd/authbridge-proxy/go.sum @@ -177,23 +177,23 @@ go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg= From 0870f8c663b48ac1077dbdf1b7727191ae61a737 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 22:35:29 -0400 Subject: [PATCH 17/24] fix(tlsbridge): validate internal_cidrs at config load; doc rename (review #522) - TLSBridgeConfig.Validate now rejects malformed internal_cidrs entries so an operator CIDR typo fails loudly at load instead of being silently dropped by Decision (which would stop excluding an intended in-cluster range under scope=external). Adds TestTLSBridgeConfig_Validate covering scope/ca_source/ file-paths/CIDR cases. - Update the TLSBridgeConfig doc comment: 'MITM termination' -> 'TLS termination (formerly MITM)' for grep-consistency with the rename. Signed-off-by: Hai Huang --- authbridge/authlib/config/config.go | 11 +++++++++-- authbridge/authlib/config/config_test.go | 25 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/authbridge/authlib/config/config.go b/authbridge/authlib/config/config.go index 93c9a9713..983bc1db8 100644 --- a/authbridge/authlib/config/config.go +++ b/authbridge/authlib/config/config.go @@ -5,6 +5,7 @@ package config import ( "encoding/json" "fmt" + "net" "os" "strings" @@ -42,8 +43,9 @@ type Config struct { TLSBridge *TLSBridgeConfig `yaml:"tls_bridge,omitempty" json:"tls_bridge,omitempty"` } -// TLSBridgeConfig configures the outbound TLS bridge (MITM termination of -// agent egress so the outbound plugin pipeline sees decrypted HTTPS). +// TLSBridgeConfig configures the outbound TLS bridge (TLS termination of +// agent egress — formerly "MITM" — so the outbound plugin pipeline sees +// decrypted HTTPS). type TLSBridgeConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` Scope string `yaml:"scope" json:"scope"` // external | all @@ -66,6 +68,11 @@ func (b *TLSBridgeConfig) Validate() error { if b.CASource == "file" && (b.CACertPath == "" || b.CAKeyPath == "") { return fmt.Errorf("tls_bridge.ca_source=file requires ca_cert_path and ca_key_path") } + for _, c := range b.InternalCIDRs { + if _, _, err := net.ParseCIDR(c); err != nil { + return fmt.Errorf("tls_bridge.internal_cidrs: %q is not a valid CIDR: %w", c, err) + } + } return nil } diff --git a/authbridge/authlib/config/config_test.go b/authbridge/authlib/config/config_test.go index 697778b2b..b0d58ddda 100644 --- a/authbridge/authlib/config/config_test.go +++ b/authbridge/authlib/config/config_test.go @@ -750,6 +750,31 @@ func TestConfig_TLSBridgeBlockDecodes(t *testing.T) { } } +func TestTLSBridgeConfig_Validate(t *testing.T) { + cases := []struct { + name string + cfg TLSBridgeConfig + wantErr bool + }{ + {"valid empty", TLSBridgeConfig{}, false}, + {"valid full", TLSBridgeConfig{Scope: "all", CASource: "ephemeral", InternalCIDRs: []string{"10.0.0.0/8", "172.30.0.0/16"}}, false}, + {"bad scope", TLSBridgeConfig{Scope: "internal"}, true}, + {"bad ca_source", TLSBridgeConfig{CASource: "vault"}, true}, + {"file ca without paths", TLSBridgeConfig{CASource: "file"}, true}, + {"file ca with paths", TLSBridgeConfig{CASource: "file", CACertPath: "/c", CAKeyPath: "/k"}, false}, + {"bad cidr typo", TLSBridgeConfig{InternalCIDRs: []string{"10.0.0.0/8", "10.0.0.0./8"}}, true}, + {"bad cidr missing mask", TLSBridgeConfig{InternalCIDRs: []string{"10.0.0.0"}}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.cfg.Validate() + if (err != nil) != tc.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + // Absent mtls block leaves cfg.MTLS nil — today's behavior, no TLS. func TestLoad_MTLS_AbsentBlock(t *testing.T) { dir := t.TempDir() From 26b3f57788555f084d0b8b518a67adbf2e27f527 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 17 Jun 2026 22:49:18 -0400 Subject: [PATCH 18/24] fix(tlsbridge): bound handshake + upstream-verify; guard empty-CA self-check; fail-fast keygen (review #522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit findings on PR #522: - Terminator.Terminate now sets a 10s handshake deadline (cleared on success so it doesn't bound the kept-alive served conn). Nothing upstream bounded the client conn — sniffHost clears its read deadline and the CONNECT peek has none — so a stalled client could pin the serving goroutine indefinitely. - bridgeServe bounds the pre-forge HEAD reachability/cert probe with a 10s context timeout (on the probe ONLY — NOT the relay, which must stay unbounded for streaming responses; adding ResponseHeaderTimeout to the shared upstream client would reintroduce the streaming-MCP 502 the main client's comment warns against). CodeRabbit's 'Critical' on upstream.go was not a TLS-bypass — RootCAs/ MinVersion/no-skip-verify are correct; the real issue was an unbounded verify. - RunTrustSelfCheck returns early on empty CA PEM (strings.Contains(b,"") would otherwise log a false 'trust OK'). - NewMinter no longer discards the ecdsa.GenerateKey error (latent nil-deref in mint); panics at construction on the effectively-impossible failure. - Plan doc: correct the main-logging guidance (cmd entrypoint uses log.Fatalf, not slog+os.Exit). Deferred (per reviewer / Phase 2): bound SkipSet + per-host verify cache (LRU/TTL); NewFileSource CA IsCA/key-match validation (cert-manager path). Spec mitm-identifier drift is covered by the spec's existing terminology disclaimer. Signed-off-by: Hai Huang --- .../authlib/listener/forwardproxy/server.go | 19 +++++++++++++++++-- authbridge/authlib/tlsbridge/engine.go | 6 ++++++ authbridge/authlib/tlsbridge/minter.go | 7 ++++++- authbridge/authlib/tlsbridge/terminator.go | 9 +++++++++ .../2026-06-17-authbridge-tlsbridge-phase1.md | 6 +++--- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/authbridge/authlib/listener/forwardproxy/server.go b/authbridge/authlib/listener/forwardproxy/server.go index b5100e387..f4bd9ae19 100644 --- a/authbridge/authlib/listener/forwardproxy/server.go +++ b/authbridge/authlib/listener/forwardproxy/server.go @@ -42,6 +42,11 @@ const maxBodySize = 1 << 20 // 1MB — matches Envoy's default per_stream_buffer // are for. const streamReadIdleTimeout = 5 * time.Minute +// upstreamVerifyTimeout bounds the pre-forge HEAD reachability/cert probe in +// bridgeServe. It applies to that probe only — never to the relay, which must +// stay unbounded so streaming responses aren't cut off. +const upstreamVerifyTimeout = 10 * time.Second + // Server is an HTTP forward proxy that performs token exchange on outbound requests. // // OutboundPipeline is a holder so the bound pipeline can be hot-swapped @@ -451,8 +456,18 @@ func (s *Server) serveOutbound(w http.ResponseWriter, r *http.Request, isBridge func (s *Server) bridgeServe(client net.Conn, authority, host string) bool { // 1) Verify upstream reachability + cert via the dedicated client, BEFORE forging. // HEAD avoids GET side-effects; a non-2xx status still returns err==nil (cert - // verified), which is all we need. Only a transport/TLS error fails here. - resp, err := s.TLSBridge.Upstream.Head("https://" + authority) + // verified), which is all we need. Only a transport/TLS error fails here. The + // verify is bounded by its own context timeout so a slow/stalled origin can't + // pin the bridging goroutine — the timeout is on this probe ONLY, not on the + // relay (which must stay unbounded for streaming responses). + ctx, cancel := context.WithTimeout(context.Background(), upstreamVerifyTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodHead, "https://"+authority, nil) + if err != nil { + slog.Info("tls-bridge passthrough", "host", host, "reason", "upstream-verify", "error", err) + return false + } + resp, err := s.TLSBridge.Upstream.Do(req) if err != nil { slog.Info("tls-bridge passthrough", "host", host, "reason", "upstream-verify", "error", err) return false // fall back to plain tunnel — agent's own e2e TLS still reaches origin diff --git a/authbridge/authlib/tlsbridge/engine.go b/authbridge/authlib/tlsbridge/engine.go index 823ff3450..20430ae13 100644 --- a/authbridge/authlib/tlsbridge/engine.go +++ b/authbridge/authlib/tlsbridge/engine.go @@ -24,6 +24,12 @@ type Engine struct { // env is set, so this simply notes that egress will safely tunnel. func RunTrustSelfCheck(caPEM []byte) { want := strings.TrimSpace(string(caPEM)) + if want == "" { + // An empty CA would make strings.Contains below always true → a false + // "trust self-check OK". Guard so an empty/misconstructed CA is visible. + slog.Warn("tls-bridge trust self-check skipped: empty CA PEM") + return + } for _, env := range []string{"SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE"} { p := os.Getenv(env) if p == "" { diff --git a/authbridge/authlib/tlsbridge/minter.go b/authbridge/authlib/tlsbridge/minter.go index 218bff735..37d3aa6d6 100644 --- a/authbridge/authlib/tlsbridge/minter.go +++ b/authbridge/authlib/tlsbridge/minter.go @@ -45,7 +45,12 @@ func NewMinter(src CASource, o MinterOpts) *Minter { if o.LeafTTL <= 0 { o.LeafTTL = 24 * time.Hour } - key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + // P-256 keygen from crypto/rand effectively never fails; if it does, fail + // fast at construction rather than nil-deref later in mint(). + panic(fmt.Errorf("tlsbridge: generate leaf key: %w", err)) + } return &Minter{ src: src, max: o.CacheMax, ttl: o.LeafTTL, leafKey: key, ll: list.New(), items: make(map[string]*list.Element), diff --git a/authbridge/authlib/tlsbridge/terminator.go b/authbridge/authlib/tlsbridge/terminator.go index 58ea5f8c1..aeaa52285 100644 --- a/authbridge/authlib/tlsbridge/terminator.go +++ b/authbridge/authlib/tlsbridge/terminator.go @@ -3,8 +3,13 @@ package tlsbridge import ( "crypto/tls" "net" + "time" ) +// handshakeTimeout bounds the server-side TLS handshake against the agent so a +// stalled/malicious client cannot pin the serving goroutine indefinitely. +const handshakeTimeout = 10 * time.Second + // Terminator wraps a sniffed client conn as a tls.Server, using the Minter to // forge a per-SNI leaf. ALPN offers h2 + http/1.1. type Terminator struct { @@ -26,8 +31,12 @@ func (t *Terminator) Terminate(client net.Conn, host string) (*tls.Conn, error) }, } conn := tls.Server(client, cfg) + // Bound the handshake; clear the deadline on success so it does not leak + // into the served-connection (keep-alive) lifetime. + _ = conn.SetDeadline(time.Now().Add(handshakeTimeout)) if err := conn.Handshake(); err != nil { return nil, err } + _ = conn.SetDeadline(time.Time{}) return conn, nil } diff --git a/authbridge/docs/superpowers/plans/2026-06-17-authbridge-tlsbridge-phase1.md b/authbridge/docs/superpowers/plans/2026-06-17-authbridge-tlsbridge-phase1.md index 8cb727bce..7d0974487 100644 --- a/authbridge/docs/superpowers/plans/2026-06-17-authbridge-tlsbridge-phase1.md +++ b/authbridge/docs/superpowers/plans/2026-06-17-authbridge-tlsbridge-phase1.md @@ -25,7 +25,7 @@ - `(s *Server) HandleTransparentConn(clientConn net.Conn, dst string)` (`transparent.go:46`): `name, wrapped := sniffHost(clientConn); clientConn = wrapped` (`transparent.go:69-70`); local var `host` = `net.JoinHostPort(name, port)` or `dst` (`transparent.go:67-78`); dials `upstream` against `dst` with `defer upstream.Close()` (`transparent.go:115-120`); `tunnel(clientConn, upstream)` (`transparent.go:125`). - `sniffHost(conn net.Conn) (string, net.Conn)` (`sniff.go`): returns the recovered host string and a replayable `*peekedConn{net.Conn; r *bufio.Reader}` (`sniff.go:148-153`). It uses `br.Peek(...)` (non-consuming) internally but **discards the peeked bytes** and has **no exported peek method**. Task 6 adds `(c *peekedConn) Peek(n int) ([]byte, error)`. - `config.Config` (`config/config.go`): optional pointer blocks `MTLS *MTLSConfig \`yaml:"mtls,omitempty"\`` (`:33`), `SPIFFE *SPIFFEConfig \`yaml:"spiffe,omitempty"\`` (`:39`), each with a `Validate()` method (`:86`, `:133`) called from the top-level loader (`cfg.MTLS.Validate()` `:441`, `cfg.SPIFFE.Validate()` `:460`). The loader is **`func Load(path string) (*Config, error)`** (`:415`) — it takes a **file path, not bytes**. `config.go` already imports `fmt` and `os`. -- main wiring (`cmd/authbridge-proxy/main.go`): `fpMTLS = &forwardproxy.MTLSOptions{...}` (`:243`); `fpSrv, err := forwardproxy.NewServer(outboundH, sessions, fpMTLS)` (`:256`); then `fpSrv.SkipHosts = …` (`:268`), `fpSrv.Shared = sharedStore` (`:272`); `transparentproxy.NewServer(fp.HandleTransparentConn)` (`:424`). main uses **`log/slog` + `os.Exit`** (imports `log/slog` `:17`, `os` `:20`) — **not** `log.Fatalf`. +- main wiring (`cmd/authbridge-proxy/main.go`): `fpMTLS = &forwardproxy.MTLSOptions{...}` (`:243`); `fpSrv, err := forwardproxy.NewServer(outboundH, sessions, fpMTLS)` (`:256`); then `fpSrv.SkipHosts = …` (`:268`), `fpSrv.Shared = sharedStore` (`:272`); `transparentproxy.NewServer(fp.HandleTransparentConn)` (`:424`). NOTE: `cmd/authbridge-proxy/main.go` imports `"log"` and uses **`log.Fatalf`** for boot-fatal errors (no `os.Exit` in the boot path) — match that convention, not `slog`+`os.Exit`. - `pipeline`: `Action{Type ActionType, Violation *Violation}` with `Reject` const (`pipeline/action.go:11-24`); `SharedStore` is an interface (`pipeline/context.go:35`); `Context` (`pipeline/context.go:93`). The response phase (`RunResponse`/`RunResponseFrame`) lives inside the `handleRequest` body that Task 6 moves wholesale — no new response-phase code is authored. --- @@ -1436,7 +1436,7 @@ func RunTrustSelfCheck(caPEM []byte) { } ``` -- [ ] **Step 6: Wire `main.go`** — beside the `fpMTLS` construction (`main.go:243`), build the engine when enabled; set the field after `NewServer` (mirroring `fpSrv.SkipHosts`/`fpSrv.Shared`). main uses `slog`+`os.Exit`, not `log.Fatalf`: +- [ ] **Step 6: Wire `main.go`** — beside the `fpMTLS` construction (`main.go:243`), build the engine when enabled; set the field after `NewServer` (mirroring `fpSrv.SkipHosts`/`fpSrv.Shared`). main uses `log.Fatalf` for boot-fatal errors — match it (the sketch below shows `slog.Error`+`os.Exit`; use `log.Fatalf` to fit the file): ```go var bridge *tlsbridge.Engine @@ -1528,6 +1528,6 @@ Phase 2 (operator) is intentionally **not** bite-sized here. When Phase 1 lands, ## Self-review notes - **Spec coverage:** every Phase-1 spec item maps to a task — `tlsbridge/` units (T1–T5), serveOutbound + full-pipeline reuse (T6), reversible decision + upstream-verify-first (T7/T8 `bridgeServe`), auto-skip (T7/T9), interception scope gate (T3/T10), upstream client with injected roots (T4), h2 + keep-alive (T5), config + wiring + self-check (T10), no-broken-calls (T9). Operator items → Phase 2. -- **Fixes folded in from the end-to-end audit (2026-06-17):** `Load(path)` not `Load(bytes)` (T10 temp file); `slog`+`os.Exit` not `log.Fatalf` (T10); `(*peekedConn).Peek` added since `sniffHost` discards the ClientHello (T6) — replaces the undefined `peekFirstBytes`/`peek(5)`; `hostOnly`/`portOf`/`nameOrIP` helpers defined (T6/T7); CONNECT `peekedConn` constructed **with** its `bufio.Reader` (T8); `serveOutbound` selects `s.TLSBridge.Upstream` for bridge re-origination, never the mesh-mTLS `s.Client` (T6, **core correctness**); authority (`host:port`) carried so non-443 origins re-originate and verify on the right port (T7/T8); upstream verify uses `HEAD` to avoid `GET /` side-effects (T7); `FileSource` multi-format key parsing for 2c (T1); pre-dialed upstream closed and re-dialed only on fall-open (T7/T8). +- **Fixes folded in from the end-to-end audit (2026-06-17):** `Load(path)` not `Load(bytes)` (T10 temp file); main `log.Fatalf` for boot-fatal (T10 — corrected: the cmd entrypoint uses `log.Fatalf`, not `slog`+`os.Exit`); `(*peekedConn).Peek` added since `sniffHost` discards the ClientHello (T6) — replaces the undefined `peekFirstBytes`/`peek(5)`; `hostOnly`/`portOf`/`nameOrIP` helpers defined (T6/T7); CONNECT `peekedConn` constructed **with** its `bufio.Reader` (T8); `serveOutbound` selects `s.TLSBridge.Upstream` for bridge re-origination, never the mesh-mTLS `s.Client` (T6, **core correctness**); authority (`host:port`) carried so non-443 origins re-originate and verify on the right port (T7/T8); upstream verify uses `HEAD` to avoid `GET /` side-effects (T7); `FileSource` multi-format key parsing for 2c (T1); pre-dialed upstream closed and re-dialed only on fall-open (T7/T8). - **Known minor limitations (documented, not bugs):** CONNECT-path `scope=external` keys on a hostname (no IP) so it won't classify a hostname-addressed in-cluster service as internal — the transparent path (which has the SO_ORIGINAL_DST IP) does; the bridge pays one upstream HEAD per agent connection (reversibility cost) plus the relay handshake. - **Type consistency:** `GetCertificateForHost(host string)`, `Terminate(client net.Conn, host string)`, `Classify(host, ip string, port int, first []byte) (Verdict, string)`, `Engine{Decision,Term,Skip,Upstream,CAPEM}`, `bridgeServe(client net.Conn, authority, host string) bool`, `serveOutbound(w, r, isBridge bool)` are used consistently across tasks. From b9ca5af32b839161b97e9c0f7c0af98d90e40653 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 18 Jun 2026 08:42:20 -0400 Subject: [PATCH 19/24] Docs: Record TLS bridge protocol/port coverage + non-goals Add an explicit 'Protocol & port coverage' section to the design spec: the bridge is HTTPS-inspection (TLS + configured port + trusted CA), not general egress inspection. Tabulates the no-visibility categories (non-TLS TCP like SSH/DBs, TLS on non-standard ports, STARTTLS, non-TCP/QUIC, un-MITM-able pinned/mTLS/ECH), documents why the port gate is load-bearing (ServeConn assumes HTTP; bridging non-HTTP TLS like LDAPS/SMTPS would break the conn), and names connection-level egress policy as the backstop. Records envoy-sidecar bridging and non-HTTP protocols as non-goals. Signed-off-by: Hai Huang --- .../2026-06-12-authbridge-tlsbridge-design.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/authbridge/docs/superpowers/specs/2026-06-12-authbridge-tlsbridge-design.md b/authbridge/docs/superpowers/specs/2026-06-12-authbridge-tlsbridge-design.md index 12fe668c2..de3fe01d2 100644 --- a/authbridge/docs/superpowers/specs/2026-06-12-authbridge-tlsbridge-design.md +++ b/authbridge/docs/superpowers/specs/2026-06-12-authbridge-tlsbridge-design.md @@ -50,6 +50,46 @@ impersonate the remote origin so the agent's TLS terminates at AuthBridge. That > first cut may exist transiently during implementation, but h2 is a prerequisite for enabling MITM by > default — it is not a fast-follow.) +## Protocol & port coverage (scope — what the bridge does NOT see) + +The TLS bridge is an **HTTPS-inspection** mechanism, not a general egress-inspection guarantee. It yields +decrypted, pipeline-visible traffic only when **all** of these hold: the connection is **TCP**, on a +**configured bridge port** (default `{443, 8443}`), the first bytes are a **TLS ClientHello** record, and the +agent **trusts the forged CA**. Everything outside that envelope is either tunneled opaquely (still egresses; +the pipeline sees only `host:port` via the per-connection egress gate) or dropped. The categories with **no +content visibility**: + +| Category | Examples | Why blind | Outcome | +|---|---|---|---| +| **Non-TLS TCP** | SSH (22); plaintext DB (Postgres 5432, MySQL 3306, Redis 6379, Mongo 27017); SMTP/IMAP/FTP/LDAP; raw/custom TCP; h2c | first bytes ≠ TLS ClientHello → `non-tls`; usually non-bridge port → `port` | tunneled | +| **TLS on non-standard ports** | LDAPS 636, SMTPS 465, IMAPS 993, AMQPS 5671, MQTTS 8883, DB-over-TLS, custom HTTPS on `:9443` etc. | port not in the bridge set → `port` (configurable) | tunneled | +| **STARTTLS** | SMTP/IMAP/POP3/LDAP/XMPP/Postgres plaintext→TLS upgrade | connection opens plaintext; the 5-byte peek isn't a ClientHello → `non-tls` | tunneled | +| **Non-TCP** | QUIC / HTTP-3 (UDP 443), DTLS, WireGuard/IPsec/VPN | `enforce-redirect` DROPs external non-TCP | **dropped** (forces TCP fallback for QUIC; a no-fallback client fails) | +| **Un-MITM-able TLS** | cert-pinned clients; client-cert/mTLS to the origin; ECH / encrypted-SNI | leaf rejected / upstream-verify fails / SNI unreadable → fail open | tunneled (auto-skip) | + +**Why the port gate is load-bearing (not just perf).** The downstream serving layer (`ServeConn`) parses the +decrypted bytes as **HTTP/1.1 or h2**. TLS on `443`/`8443` is ~always HTTP(S); TLS on `636`/`465`/`5671`/DB +ports is **not** HTTP. Bridging those would terminate the connection and then fail to serve non-HTTP wire bytes +as HTTP → a **broken** agent connection (the upstream-verify `HEAD` probe usually rejects a non-HTTP origin and +makes us fall open, but that is a fragile net that also sprays `HEAD /` at non-HTTP services). The port gate +keeps interception **HTTP-only by operator intent**. To inspect HTTPS on an extra port, **widen the configured +port set** (`Decision.Ports` is arbitrary; expose a `ports:` config key) — do **not** bridge all ports. + +**Non-goals (coverage):** +- **Non-HTTP protocols** (SSH, databases, SMTP/LDAP/AMQP/MQTT, raw TCP) — out of scope; inspecting/controlling + them needs a protocol-aware proxy/bastion or, for *control without decryption*, connection-level egress + policy. +- **STARTTLS upgrade detection** — out of scope (the first-byte heuristic intentionally does not track + mid-connection upgrades). +- **envoy-sidecar bridging** — out of scope; it would need a separate Envoy-data-plane mechanism (dynamic + per-SNI leaf issuance via SDS/filter + terminate/originate config), not this Go-proxy bridge. The operator + rejects `tlsBridgeMode=enabled` with `envoy-sidecar` at admission. A future "Phase 3" if a real need arises. + +**The backstop is connection-level egress policy.** Allow/deny by `host:port` *without* decryption, enforced at +the per-connection egress gate that already runs on every captured connection (it sees the destination even +when it cannot read the payload). That layer covers SSH, databases, STARTTLS, odd-port TLS, and pinned/mTLS +traffic, and composes with the bridge: the bridge decrypts what it can (HTTPS), policy gates the rest. + ## Design decisions | Topic | Decision | From 2e765414c51210b8edbaf53411e68848a6dbd0dc Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 18 Jun 2026 09:08:57 -0400 Subject: [PATCH 20/24] feat(tlsbridge): make intercepted TLS ports configurable (default 443,8443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tls_bridge.ports to TLSBridgeConfig (empty => {443,8443}), threads it into Decision via main.go, and unifies the transparent listener's sniff gate with the bridge's port set: shouldSniff() keeps its host-recovery heuristic {80,443,8080,8443}, but the transparent path now ALSO sniffs whatever ports the bridge intercepts (Decision.HandlesPort), so a configured non-standard port (e.g. 9443) gets the peekable conn the bridge needs — no drift between the two lists. Validation rejects out-of-range ports. New TestTransparentBridge_CustomPort proves the bridge engages on a random ephemeral port (outside shouldSniff's set) when configured. Keep ports HTTP(S)-only: the bridge serves the decrypted stream as HTTP/1.1 or h2, so non-HTTP TLS (LDAPS/SMTPS/DB-over-TLS) must NOT be added here. Signed-off-by: Hai Huang --- authbridge/authlib/config/config.go | 10 ++ authbridge/authlib/config/config_test.go | 4 + .../tlsbridge_integration_test.go | 115 +++++++++++++++++- .../listener/forwardproxy/transparent.go | 7 +- authbridge/authlib/tlsbridge/decision.go | 6 + authbridge/authlib/tlsbridge/decision_test.go | 19 +++ authbridge/cmd/authbridge-proxy/main.go | 9 +- 7 files changed, 167 insertions(+), 3 deletions(-) diff --git a/authbridge/authlib/config/config.go b/authbridge/authlib/config/config.go index 983bc1db8..f2ca729f2 100644 --- a/authbridge/authlib/config/config.go +++ b/authbridge/authlib/config/config.go @@ -55,6 +55,11 @@ type TLSBridgeConfig struct { CAKeyPath string `yaml:"ca_key_path" json:"ca_key_path"` UpstreamCABundle string `yaml:"upstream_ca_bundle" json:"upstream_ca_bundle"` SkipHosts []string `yaml:"skip_hosts" json:"skip_hosts"` + // Ports is the set of TCP ports to intercept as TLS. Empty => {443, 8443}. + // Only HTTP(S)-bearing ports belong here: the bridge serves the decrypted + // stream as HTTP/1.1 or h2, so terminating a non-HTTP TLS protocol (LDAPS, + // SMTPS, DB-over-TLS, …) would break it. + Ports []int `yaml:"ports" json:"ports"` } // Validate is called from the loader when TLSBridge != nil. @@ -73,6 +78,11 @@ func (b *TLSBridgeConfig) Validate() error { return fmt.Errorf("tls_bridge.internal_cidrs: %q is not a valid CIDR: %w", c, err) } } + for _, p := range b.Ports { + if p < 1 || p > 65535 { + return fmt.Errorf("tls_bridge.ports: %d is out of range 1-65535", p) + } + } return nil } diff --git a/authbridge/authlib/config/config_test.go b/authbridge/authlib/config/config_test.go index b0d58ddda..140281417 100644 --- a/authbridge/authlib/config/config_test.go +++ b/authbridge/authlib/config/config_test.go @@ -764,6 +764,10 @@ func TestTLSBridgeConfig_Validate(t *testing.T) { {"file ca with paths", TLSBridgeConfig{CASource: "file", CACertPath: "/c", CAKeyPath: "/k"}, false}, {"bad cidr typo", TLSBridgeConfig{InternalCIDRs: []string{"10.0.0.0/8", "10.0.0.0./8"}}, true}, {"bad cidr missing mask", TLSBridgeConfig{InternalCIDRs: []string{"10.0.0.0"}}, true}, + {"valid ports", TLSBridgeConfig{Ports: []int{443, 8443, 9443}}, false}, + {"port zero", TLSBridgeConfig{Ports: []int{0}}, true}, + {"port too high", TLSBridgeConfig{Ports: []int{70000}}, true}, + {"port negative", TLSBridgeConfig{Ports: []int{-1}}, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go b/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go index 3dd3491b9..01a98e160 100644 --- a/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go +++ b/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go @@ -135,7 +135,7 @@ func TestTransparentBridge(t *testing.T) { // side believes it captured a connection whose SO_ORIGINAL_DST is the // origin's host:port. clientSide, serverSide := net.Pipe() - defer clientSide.Close() + defer func() { _ = clientSide.Close() }() done := make(chan struct{}) go func() { @@ -223,6 +223,119 @@ func TestTransparentBridge(t *testing.T) { } } +// TestTransparentBridge_CustomPort proves the configurable-ports path: the +// origin runs on a RANDOM ephemeral port (NOT in shouldSniff's hardcoded +// {80,443,8080,8443} set), and the bridge is configured (Decision.Ports) to +// intercept exactly that port. Before the shouldSniff↔Decision.Ports unification +// the transparent path would not sniff this port, so the bridge could never peek +// and the call would tunnel (agent handshake would fail against the origin's real +// cert). With the unification, HandlesPort(port) widens the sniff and the bridge +// engages. Asserting decryption here is the regression test for that wiring. +func TestTransparentBridge_CustomPort(t *testing.T) { + var gotOriginPath string + origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotOriginPath = r.URL.Path + if r.URL.Path == "/secret" { + _, _ = w.Write([]byte("OK-SECRET")) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer origin.Close() + + originCAPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: origin.Certificate().Raw}) + originURL, err := url.Parse(origin.URL) + if err != nil { + t.Fatalf("parse origin URL: %v", err) + } + originHostPort := originURL.Host // 127.0.0.1: + customPort := portOf(originHostPort) + if customPort == 443 || customPort == 8443 || customPort == 80 || customPort == 8080 { + t.Skipf("random origin port %d collided with a default sniff port; rerun", customPort) + } + + src, err := tlsbridge.NewEphemeralSource() + if err != nil { + t.Fatalf("NewEphemeralSource: %v", err) + } + minter := tlsbridge.NewMinter(src, tlsbridge.MinterOpts{}) + up, err := tlsbridge.NewUpstreamClient(originCAPEM) + if err != nil { + t.Fatalf("NewUpstreamClient: %v", err) + } + engine := &tlsbridge.Engine{ + // Only the custom port is configured — proves both that it IS bridged + // (despite shouldSniff not knowing it) and that the default set is replaced. + Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ + Scope: tlsbridge.ScopeAll, + Ports: map[int]bool{customPort: true}, + }), + Term: tlsbridge.NewTerminator(minter), + Skip: tlsbridge.NewSkipSet(), + Upstream: up, + CAPEM: src.CACertPEM(), + } + + probe := &bridgeProbePlugin{} + p, err := plugintesting.BuildPipeline([]pipeline.Plugin{probe}) + if err != nil { + t.Fatalf("BuildPipeline: %v", err) + } + srv := &Server{OutboundPipeline: pipeline.NewHolder(p), Client: http.DefaultClient, TLSBridge: engine} + + clientSide, serverSide := net.Pipe() + defer func() { _ = clientSide.Close() }() + done := make(chan struct{}) + go func() { + defer close(done) + srv.HandleTransparentConn(serverSide, originHostPort) + }() + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(engine.CAPEM) { + t.Fatalf("append bridge CA") + } + host := hostOnly(originHostPort) + tconn := tls.Client(clientSide, &tls.Config{ServerName: host, RootCAs: pool, NextProtos: []string{"http/1.1"}}) + _ = tconn.SetDeadline(time.Now().Add(10 * time.Second)) + if err := tconn.Handshake(); err != nil { + t.Fatalf("agent handshake through bridge on custom port failed: %v", err) + } + req, err := http.NewRequest(http.MethodGet, "https://"+host+"/secret", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + writeErr := make(chan error, 1) + go func() { writeErr <- req.Write(tconn) }() + resp, err := http.ReadResponse(bufio.NewReader(tconn), req) + if err != nil { + t.Fatalf("read response on custom-port bridge: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if werr := <-writeErr; werr != nil { + t.Fatalf("write request: %v", werr) + } + + if probe.gotPath != "/secret" { + t.Errorf("probe path = %q, want /secret (bridge did not engage on custom port)", probe.gotPath) + } + if gotOriginPath != "/secret" { + t.Errorf("origin path = %q, want /secret", gotOriginPath) + } + if got := strings.TrimSpace(string(body)); got != "OK-SECRET" { + t.Errorf("body = %q, want OK-SECRET", got) + } + + _ = clientSide.Close() + _ = serverSide.Close() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("HandleTransparentConn did not return") + } +} + // TestConnectBridge drives the full CONNECT → 200 → agent-TLS → decrypt → // pipeline → re-originate loop through the PUBLIC forward-proxy HTTP handler // (so the real net/http Hijack path runs). An httptest TLS origin stands in diff --git a/authbridge/authlib/listener/forwardproxy/transparent.go b/authbridge/authlib/listener/forwardproxy/transparent.go index a7f79cf20..79162e5a2 100644 --- a/authbridge/authlib/listener/forwardproxy/transparent.go +++ b/authbridge/authlib/listener/forwardproxy/transparent.go @@ -66,7 +66,12 @@ func (s *Server) HandleTransparentConn(clientConn net.Conn, dst string) { // HTTP/TLS ports so non-HTTP protocols are not delayed by the peek. The dial // target stays dst (the IP); only pctx.Host gets the recovered name. host := dst - if shouldSniff(dst) { + // Sniff on the standard HTTP/TLS ports, OR on whatever ports the TLS bridge + // is configured to intercept — so a configured non-standard bridge port + // (e.g. 9443) still gets the peekable conn the bridge branch needs. The + // bridge's own port set is the single source of truth (no drift with + // shouldSniff's heuristic list). + if shouldSniff(dst) || (s.TLSBridge != nil && s.TLSBridge.Decision.HandlesPort(portOf(dst))) { name, wrapped := sniffHost(clientConn) clientConn = wrapped if name != "" { diff --git a/authbridge/authlib/tlsbridge/decision.go b/authbridge/authlib/tlsbridge/decision.go index a15811111..e47113617 100644 --- a/authbridge/authlib/tlsbridge/decision.go +++ b/authbridge/authlib/tlsbridge/decision.go @@ -49,6 +49,12 @@ func NewDecision(o DecisionOpts) *Decision { return d } +// HandlesPort reports whether port is in the bridge's interception set. It is +// the single source of truth for "which ports the bridge cares about" — the +// transparent listener consults it so it sniffs (and thus can bridge) exactly +// the configured ports, never drifting from Classify's port gate. +func (d *Decision) HandlesPort(port int) bool { return d.ports[port] } + // Classify decides whether to bridge. first is the peeked client bytes. func (d *Decision) Classify(host, ip string, port int, first []byte) (Verdict, string) { if !d.ports[port] { diff --git a/authbridge/authlib/tlsbridge/decision_test.go b/authbridge/authlib/tlsbridge/decision_test.go index 52ea7221f..22b4b064c 100644 --- a/authbridge/authlib/tlsbridge/decision_test.go +++ b/authbridge/authlib/tlsbridge/decision_test.go @@ -78,3 +78,22 @@ func TestSkipSet_AutoSkip(t *testing.T) { t.Error("Add then Contains failed") } } + +func TestDecision_HandlesPort(t *testing.T) { + // Default set when Ports is nil. + d := NewDecision(DecisionOpts{}) + if !d.HandlesPort(443) || !d.HandlesPort(8443) { + t.Error("default set must handle 443 and 8443") + } + if d.HandlesPort(9443) { + t.Error("default set must not handle 9443") + } + // Custom set replaces the default. + c := NewDecision(DecisionOpts{Ports: map[int]bool{9443: true}}) + if !c.HandlesPort(9443) { + t.Error("custom set must handle 9443") + } + if c.HandlesPort(443) { + t.Error("custom set must not handle 443 (it replaces, not augments, the default)") + } +} diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go index 4bffa26de..0e207d8b3 100644 --- a/authbridge/cmd/authbridge-proxy/main.go +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -278,9 +278,16 @@ func main() { if cfg.TLSBridge.Scope == "all" { scope = tlsbridge.ScopeAll } + var ports map[int]bool // nil => NewDecision defaults to {443, 8443} + if len(cfg.TLSBridge.Ports) > 0 { + ports = make(map[int]bool, len(cfg.TLSBridge.Ports)) + for _, p := range cfg.TLSBridge.Ports { + ports[p] = true + } + } bridge = &tlsbridge.Engine{ Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ - Scope: scope, InternalCIDRs: cfg.TLSBridge.InternalCIDRs, SkipHosts: cfg.TLSBridge.SkipHosts, + Scope: scope, Ports: ports, InternalCIDRs: cfg.TLSBridge.InternalCIDRs, SkipHosts: cfg.TLSBridge.SkipHosts, }), Term: tlsbridge.NewTerminator(minter), Skip: tlsbridge.NewSkipSet(), From f9ddac3e2f525a16578d48293ca924b23f8dc013 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 18 Jun 2026 09:32:13 -0400 Subject: [PATCH 21/24] refactor(tlsbridge): consolidate config schema before release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tls_bridge schema is new/unreleased — tighten it now, before the operator renders it and an alpha pins it: - Collapse enabled+scope -> mode: disabled|enabled (matches operator tlsBridgeMode 1:1). Drop the external/all distinction and the whole Scope/isInternal/internal_cidrs machinery in Decision — the bridge intercepts everything eligible on the configured ports, period. Classify loses its now-unused ip param. - Drop ca_source + ca_cert_path + ca_key_path -> single ca_dir (cert-manager Secret key conventions tls.crt/tls.key/ca.crt). ephemeral was never usable in production (a real agent can't trust an unexported in-memory CA); it survives only as NewEphemeralSource for in-process tests, not a config knob. Production CA is always the operator-mounted file. - Rename skip_hosts -> passthrough_hosts to disambiguate from the existing listener.skip_hosts (full pipeline bypass vs bridge tunnel). Net: 6 fields -> mode + ca_dir + passthrough_hosts (+ existing ports, upstream_ca_bundle). No migration — nothing consumes the schema yet. Signed-off-by: Hai Huang --- authbridge/authlib/config/config.go | 40 +++++++-------- authbridge/authlib/config/config_test.go | 24 ++++----- .../authlib/listener/forwardproxy/server.go | 4 +- .../tlsbridge_integration_test.go | 15 ++---- .../listener/forwardproxy/transparent.go | 6 +-- authbridge/authlib/tlsbridge/decision.go | 50 ++++--------------- authbridge/authlib/tlsbridge/decision_test.go | 46 ++++------------- authbridge/cmd/authbridge-proxy/main.go | 23 +++------ 8 files changed, 67 insertions(+), 141 deletions(-) diff --git a/authbridge/authlib/config/config.go b/authbridge/authlib/config/config.go index f2ca729f2..f707c078e 100644 --- a/authbridge/authlib/config/config.go +++ b/authbridge/authlib/config/config.go @@ -5,7 +5,6 @@ package config import ( "encoding/json" "fmt" - "net" "os" "strings" @@ -47,14 +46,21 @@ type Config struct { // agent egress — formerly "MITM" — so the outbound plugin pipeline sees // decrypted HTTPS). type TLSBridgeConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Scope string `yaml:"scope" json:"scope"` // external | all - InternalCIDRs []string `yaml:"internal_cidrs" json:"internal_cidrs"` - CASource string `yaml:"ca_source" json:"ca_source"` // file | ephemeral - CACertPath string `yaml:"ca_cert_path" json:"ca_cert_path"` - CAKeyPath string `yaml:"ca_key_path" json:"ca_key_path"` - UpstreamCABundle string `yaml:"upstream_ca_bundle" json:"upstream_ca_bundle"` - SkipHosts []string `yaml:"skip_hosts" json:"skip_hosts"` + // Mode is the bridge posture: "disabled" (off) or "enabled" (intercept all + // eligible egress on the configured Ports). Empty == disabled. + Mode string `yaml:"mode" json:"mode"` // disabled | enabled + // CADir holds the per-agent signing CA, mounted from the operator's + // cert-manager Secret. The bridge reads tls.crt + tls.key (to sign leaves); + // ca.crt is the trust cert handed to the agent. cert-manager Secret key + // conventions, so only the directory is configured. + CADir string `yaml:"ca_dir" json:"ca_dir"` + // UpstreamCABundle is an extra-roots PEM file for re-origination (private-CA + // origins the agent trusts); empty == system roots only. + UpstreamCABundle string `yaml:"upstream_ca_bundle" json:"upstream_ca_bundle"` + // PassthroughHosts are hosts to tunnel (never intercept). Distinct from + // listener.skip_hosts, which bypasses the whole pipeline; these still run the + // egress gate, they just aren't TLS-terminated. + PassthroughHosts []string `yaml:"passthrough_hosts" json:"passthrough_hosts"` // Ports is the set of TCP ports to intercept as TLS. Empty => {443, 8443}. // Only HTTP(S)-bearing ports belong here: the bridge serves the decrypted // stream as HTTP/1.1 or h2, so terminating a non-HTTP TLS protocol (LDAPS, @@ -64,19 +70,11 @@ type TLSBridgeConfig struct { // Validate is called from the loader when TLSBridge != nil. func (b *TLSBridgeConfig) Validate() error { - if b.Scope != "" && b.Scope != "external" && b.Scope != "all" { - return fmt.Errorf("tls_bridge.scope must be 'external' or 'all', got %q", b.Scope) + if b.Mode != "" && b.Mode != "disabled" && b.Mode != "enabled" { + return fmt.Errorf("tls_bridge.mode must be 'disabled' or 'enabled', got %q", b.Mode) } - if b.CASource != "" && b.CASource != "file" && b.CASource != "ephemeral" { - return fmt.Errorf("tls_bridge.ca_source must be 'file' or 'ephemeral', got %q", b.CASource) - } - if b.CASource == "file" && (b.CACertPath == "" || b.CAKeyPath == "") { - return fmt.Errorf("tls_bridge.ca_source=file requires ca_cert_path and ca_key_path") - } - for _, c := range b.InternalCIDRs { - if _, _, err := net.ParseCIDR(c); err != nil { - return fmt.Errorf("tls_bridge.internal_cidrs: %q is not a valid CIDR: %w", c, err) - } + if b.Mode == "enabled" && b.CADir == "" { + return fmt.Errorf("tls_bridge.mode=enabled requires ca_dir") } for _, p := range b.Ports { if p < 1 || p > 65535 { diff --git a/authbridge/authlib/config/config_test.go b/authbridge/authlib/config/config_test.go index 140281417..62fa58787 100644 --- a/authbridge/authlib/config/config_test.go +++ b/authbridge/authlib/config/config_test.go @@ -733,10 +733,10 @@ spiffe: {} func TestConfig_TLSBridgeBlockDecodes(t *testing.T) { y := []byte("mode: proxy-sidecar\n" + "tls_bridge:\n" + - " enabled: true\n" + - " scope: external\n" + - " ca_source: ephemeral\n" + - " skip_hosts: [\"pinned.example.com\"]\n") + " mode: enabled\n" + + " ca_dir: /etc/authbridge/tls-bridge-ca\n" + + " passthrough_hosts: [\"pinned.example.com\"]\n" + + " ports: [443, 9443]\n") p := filepath.Join(t.TempDir(), "cfg.yaml") if err := os.WriteFile(p, y, 0o600); err != nil { t.Fatal(err) @@ -745,9 +745,12 @@ func TestConfig_TLSBridgeBlockDecodes(t *testing.T) { if err != nil { t.Fatalf("load: %v", err) } - if cfg.TLSBridge == nil || !cfg.TLSBridge.Enabled || cfg.TLSBridge.Scope != "external" { + if cfg.TLSBridge == nil || cfg.TLSBridge.Mode != "enabled" || cfg.TLSBridge.CADir != "/etc/authbridge/tls-bridge-ca" { t.Fatalf("tls_bridge block did not decode: %+v", cfg.TLSBridge) } + if len(cfg.TLSBridge.PassthroughHosts) != 1 || len(cfg.TLSBridge.Ports) != 2 { + t.Fatalf("passthrough_hosts/ports did not decode: %+v", cfg.TLSBridge) + } } func TestTLSBridgeConfig_Validate(t *testing.T) { @@ -757,13 +760,10 @@ func TestTLSBridgeConfig_Validate(t *testing.T) { wantErr bool }{ {"valid empty", TLSBridgeConfig{}, false}, - {"valid full", TLSBridgeConfig{Scope: "all", CASource: "ephemeral", InternalCIDRs: []string{"10.0.0.0/8", "172.30.0.0/16"}}, false}, - {"bad scope", TLSBridgeConfig{Scope: "internal"}, true}, - {"bad ca_source", TLSBridgeConfig{CASource: "vault"}, true}, - {"file ca without paths", TLSBridgeConfig{CASource: "file"}, true}, - {"file ca with paths", TLSBridgeConfig{CASource: "file", CACertPath: "/c", CAKeyPath: "/k"}, false}, - {"bad cidr typo", TLSBridgeConfig{InternalCIDRs: []string{"10.0.0.0/8", "10.0.0.0./8"}}, true}, - {"bad cidr missing mask", TLSBridgeConfig{InternalCIDRs: []string{"10.0.0.0"}}, true}, + {"valid disabled", TLSBridgeConfig{Mode: "disabled"}, false}, + {"valid enabled with ca_dir", TLSBridgeConfig{Mode: "enabled", CADir: "/etc/authbridge/tls-bridge-ca"}, false}, + {"bad mode", TLSBridgeConfig{Mode: "external"}, true}, + {"enabled without ca_dir", TLSBridgeConfig{Mode: "enabled"}, true}, {"valid ports", TLSBridgeConfig{Ports: []int{443, 8443, 9443}}, false}, {"port zero", TLSBridgeConfig{Ports: []int{0}}, true}, {"port too high", TLSBridgeConfig{Ports: []int{70000}}, true}, diff --git a/authbridge/authlib/listener/forwardproxy/server.go b/authbridge/authlib/listener/forwardproxy/server.go index f4bd9ae19..c792e4a8a 100644 --- a/authbridge/authlib/listener/forwardproxy/server.go +++ b/authbridge/authlib/listener/forwardproxy/server.go @@ -890,9 +890,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { authority := r.Host // CONNECT target is already host:port key := hostOnly(r.Host) if !s.TLSBridge.Skip.Contains(key) { - // ip is the hostname for a name CONNECT target (ParseIP→nil ⇒ never - // matched as in-cluster; the transparent path keys on the dialed IP). - if v, _ := s.TLSBridge.Decision.Classify(key, key, portOf(r.Host), first); v == tlsbridge.Terminate { + if v, _ := s.TLSBridge.Decision.Classify(key, portOf(r.Host), first); v == tlsbridge.Terminate { _ = upstream.Close() // bridgeServe dials its own verified upstream if s.bridgeServe(clientConn, authority, key) { return diff --git a/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go b/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go index 01a98e160..1e3b494b2 100644 --- a/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go +++ b/authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go @@ -95,8 +95,8 @@ func TestTransparentBridge(t *testing.T) { } originHostPort := originURL.Host // "127.0.0.1:port" - // 2) Build the bridge Engine. ScopeAll so the loopback origin isn't - // treated as in-cluster and skipped. + // 2) Build the bridge Engine. Ports is set to the origin's port so the + // bridge intercepts it (the bridge has no in-cluster vs external scope). src, err := tlsbridge.NewEphemeralSource() if err != nil { t.Fatalf("NewEphemeralSource: %v", err) @@ -108,7 +108,6 @@ func TestTransparentBridge(t *testing.T) { } engine := &tlsbridge.Engine{ Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ - Scope: tlsbridge.ScopeAll, Ports: map[int]bool{portOf(originHostPort): true}, }), Term: tlsbridge.NewTerminator(minter), @@ -267,7 +266,6 @@ func TestTransparentBridge_CustomPort(t *testing.T) { // Only the custom port is configured — proves both that it IS bridged // (despite shouldSniff not knowing it) and that the default set is replaced. Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ - Scope: tlsbridge.ScopeAll, Ports: map[int]bool{customPort: true}, }), Term: tlsbridge.NewTerminator(minter), @@ -369,9 +367,8 @@ func TestConnectBridge(t *testing.T) { } originHostPort := originURL.Host // "127.0.0.1:port" - // 2) Build the bridge Engine. ScopeAll so the loopback origin isn't - // treated as in-cluster and skipped. Ports must include the origin's - // random port (the CONNECT classify keys on portOf(r.Host)). + // 2) Build the bridge Engine. Ports must include the origin's random port + // (the CONNECT classify keys on portOf(r.Host)). src, err := tlsbridge.NewEphemeralSource() if err != nil { t.Fatalf("NewEphemeralSource: %v", err) @@ -383,7 +380,6 @@ func TestConnectBridge(t *testing.T) { } engine := &tlsbridge.Engine{ Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ - Scope: tlsbridge.ScopeAll, Ports: map[int]bool{portOf(originHostPort): true}, }), Term: tlsbridge.NewTerminator(minter), @@ -575,7 +571,6 @@ func TestBridge_UnverifiableUpstream_FallsOpenToTunnel(t *testing.T) { } engine := &tlsbridge.Engine{ Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ - Scope: tlsbridge.ScopeAll, Ports: map[int]bool{portOf(originHostPort): true}, }), Term: tlsbridge.NewTerminator(minter), @@ -714,7 +709,6 @@ func TestBridge_PinnedClient_AutoSkipsThenTunnels(t *testing.T) { } engine := &tlsbridge.Engine{ Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ - Scope: tlsbridge.ScopeAll, Ports: map[int]bool{portOf(originHostPort): true}, }), Term: tlsbridge.NewTerminator(minter), @@ -879,7 +873,6 @@ func TestBridge_NonTLS_Passthrough(t *testing.T) { } engine := &tlsbridge.Engine{ Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ - Scope: tlsbridge.ScopeAll, Ports: map[int]bool{portOf(originHostPort): true}, }), Term: tlsbridge.NewTerminator(minter), diff --git a/authbridge/authlib/listener/forwardproxy/transparent.go b/authbridge/authlib/listener/forwardproxy/transparent.go index 79162e5a2..9f5ec0e36 100644 --- a/authbridge/authlib/listener/forwardproxy/transparent.go +++ b/authbridge/authlib/listener/forwardproxy/transparent.go @@ -131,16 +131,14 @@ func (s *Server) HandleTransparentConn(clientConn net.Conn, dst string) { if s.TLSBridge != nil { // host is the policy authority: ":port" when a name was - // recovered, else dst (":port"). key is the SNI name or dial IP; - // ip is always the dialed IP (for the in-cluster CIDR gate). - ip := hostOnly(dst) + // recovered, else dst (":port"). key is the SNI name or dial IP. key := hostOnly(host) var first []byte if pc, ok := clientConn.(*peekedConn); ok { first, _ = pc.Peek(5) } if !s.TLSBridge.Skip.Contains(key) { - v, reason := s.TLSBridge.Decision.Classify(key, ip, portOf(dst), first) + v, reason := s.TLSBridge.Decision.Classify(key, portOf(dst), first) if v == tlsbridge.Terminate { _ = upstream.Close() // bridgeServe dials its own verified upstream; drop the pre-dial if s.bridgeServe(clientConn, host, key) { diff --git a/authbridge/authlib/tlsbridge/decision.go b/authbridge/authlib/tlsbridge/decision.go index e47113617..0efb4a9e2 100644 --- a/authbridge/authlib/tlsbridge/decision.go +++ b/authbridge/authlib/tlsbridge/decision.go @@ -1,7 +1,6 @@ package tlsbridge import ( - "net" "sync" ) @@ -12,37 +11,21 @@ const ( Terminate ) -type Scope int - -const ( - ScopeExternal Scope = iota // default: do not bridge internal/mesh destinations - ScopeAll // bridge everything eligible (no-mesh / standalone) -) - type DecisionOpts struct { - Ports map[int]bool - Scope Scope - InternalCIDRs []string - SkipHosts []string + Ports map[int]bool + SkipHosts []string } type Decision struct { - ports map[int]bool - scope Scope - internal []*net.IPNet - skip map[string]bool + ports map[int]bool + skip map[string]bool } func NewDecision(o DecisionOpts) *Decision { - d := &Decision{ports: o.Ports, scope: o.Scope, skip: map[string]bool{}} + d := &Decision{ports: o.Ports, skip: map[string]bool{}} if d.ports == nil { d.ports = map[int]bool{443: true, 8443: true} } - for _, c := range o.InternalCIDRs { - if _, n, err := net.ParseCIDR(c); err == nil { - d.internal = append(d.internal, n) - } - } for _, h := range o.SkipHosts { d.skip[h] = true } @@ -55,8 +38,11 @@ func NewDecision(o DecisionOpts) *Decision { // the configured ports, never drifting from Classify's port gate. func (d *Decision) HandlesPort(port int) bool { return d.ports[port] } -// Classify decides whether to bridge. first is the peeked client bytes. -func (d *Decision) Classify(host, ip string, port int, first []byte) (Verdict, string) { +// Classify decides whether to bridge. first is the peeked client bytes. The +// bridge intercepts everything eligible on the configured ports (no in-cluster +// vs external distinction): a port + valid-TLS-record + not-skip-listed +// connection is terminated; anything else passes through. +func (d *Decision) Classify(host string, port int, first []byte) (Verdict, string) { if !d.ports[port] { return Passthrough, "port" } @@ -66,25 +52,9 @@ func (d *Decision) Classify(host, ip string, port int, first []byte) (Verdict, s if d.skip[host] { return Passthrough, "skip" } - if d.scope == ScopeExternal && d.isInternal(ip) { - return Passthrough, "in-cluster" - } return Terminate, "" } -func (d *Decision) isInternal(ip string) bool { - parsed := net.ParseIP(ip) - if parsed == nil { - return false // a hostname (not an IP) is never matched as in-cluster — see CONNECT-path note - } - for _, n := range d.internal { - if n.Contains(parsed) { - return true - } - } - return false -} - // looksLikeTLSRecord validates the 5-byte TLS record header (not just 0x16): // content type 22 (handshake), legacy record version 0x03 with minor 0x01-0x04 // (TLS 1.0–1.3; SSLv3's 0x0300 is rejected). It checks the record layer, not the diff --git a/authbridge/authlib/tlsbridge/decision_test.go b/authbridge/authlib/tlsbridge/decision_test.go index 22b4b064c..7ffcb8db2 100644 --- a/authbridge/authlib/tlsbridge/decision_test.go +++ b/authbridge/authlib/tlsbridge/decision_test.go @@ -4,31 +4,28 @@ import "testing" func TestDecision_Classify(t *testing.T) { d := NewDecision(DecisionOpts{ - Ports: map[int]bool{443: true, 8443: true}, - Scope: ScopeExternal, - InternalCIDRs: []string{"10.0.0.0/8"}, - SkipHosts: []string{"pinned.example.com"}, + Ports: map[int]bool{443: true, 8443: true}, + SkipHosts: []string{"pinned.example.com"}, }) tlsHello := []byte{0x16, 0x03, 0x01, 0x00, 0x05} // handshake, TLS1.0 record, len cases := []struct { name string host string - ip string port int first []byte expect Verdict reason string }{ - {"happy external https", "api.example.com", "93.184.216.34", 443, tlsHello, Terminate, ""}, - {"non-tls first byte", "api.example.com", "93.184.216.34", 443, []byte("GET / "), Passthrough, "non-tls"}, - {"unlisted port", "api.example.com", "93.184.216.34", 9999, tlsHello, Passthrough, "port"}, - {"internal under external scope", "tool.svc", "10.96.1.2", 443, tlsHello, Passthrough, "in-cluster"}, - {"skip-listed host", "pinned.example.com", "1.2.3.4", 443, tlsHello, Passthrough, "skip"}, - {"short record (<5 bytes)", "api.example.com", "1.2.3.4", 443, []byte{0x16, 0x03}, Passthrough, "non-tls"}, + {"happy https", "api.example.com", 443, tlsHello, Terminate, ""}, + {"happy 8443", "api.example.com", 8443, tlsHello, Terminate, ""}, + {"non-tls first byte", "api.example.com", 443, []byte("GET / "), Passthrough, "non-tls"}, + {"unlisted port", "api.example.com", 9999, tlsHello, Passthrough, "port"}, + {"skip-listed host", "pinned.example.com", 443, tlsHello, Passthrough, "skip"}, + {"short record (<5 bytes)", "api.example.com", 443, []byte{0x16, 0x03}, Passthrough, "non-tls"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - v, reason := d.Classify(tc.host, tc.ip, tc.port, tc.first) + v, reason := d.Classify(tc.host, tc.port, tc.first) if v != tc.expect || reason != tc.reason { t.Errorf("got (%v,%q), want (%v,%q)", v, reason, tc.expect, tc.reason) } @@ -36,38 +33,17 @@ func TestDecision_Classify(t *testing.T) { } } -func TestDecision_ScopeAll(t *testing.T) { - d := NewDecision(DecisionOpts{Scope: ScopeAll, InternalCIDRs: []string{"10.0.0.0/8"}}) - tlsHello := []byte{0x16, 0x03, 0x01, 0x00, 0x05} - // scope=all does NOT passthrough internal destinations. - if v, reason := d.Classify("tool.svc", "10.96.1.2", 443, tlsHello); v != Terminate || reason != "" { - t.Errorf("got (%v,%q), want (%v,%q)", v, reason, Terminate, "") - } -} - func TestDecision_DefaultPortsWhenNil(t *testing.T) { d := NewDecision(DecisionOpts{}) // nil Ports -> {443,8443} tlsHello := []byte{0x16, 0x03, 0x01, 0x00, 0x05} - if v, reason := d.Classify("api.example.com", "1.2.3.4", 443, tlsHello); v != Terminate || reason != "" { + if v, reason := d.Classify("api.example.com", 443, tlsHello); v != Terminate || reason != "" { t.Errorf("port 443: got (%v,%q), want (%v,%q)", v, reason, Terminate, "") } - if v, reason := d.Classify("api.example.com", "1.2.3.4", 80, tlsHello); v != Passthrough || reason != "port" { + if v, reason := d.Classify("api.example.com", 80, tlsHello); v != Passthrough || reason != "port" { t.Errorf("port 80: got (%v,%q), want (%v,%q)", v, reason, Passthrough, "port") } } -func TestDecision_MultiCIDR(t *testing.T) { - d := NewDecision(DecisionOpts{ - Scope: ScopeExternal, - InternalCIDRs: []string{"10.0.0.0/8", "192.168.0.0/16"}, - }) - tlsHello := []byte{0x16, 0x03, 0x01, 0x00, 0x05} - // IP matching the SECOND cidr exercises the loop past the first entry. - if v, reason := d.Classify("tool.svc", "192.168.1.5", 443, tlsHello); v != Passthrough || reason != "in-cluster" { - t.Errorf("got (%v,%q), want (%v,%q)", v, reason, Passthrough, "in-cluster") - } -} - func TestSkipSet_AutoSkip(t *testing.T) { s := NewSkipSet() if s.Contains("h") { diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go index 0e207d8b3..c0082ce24 100644 --- a/authbridge/cmd/authbridge-proxy/main.go +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -253,15 +253,12 @@ func main() { // here and set on fpSrv below (mirroring fpSrv.SkipHosts / fpSrv.Shared). // A nil *Engine leaves today's blind-tunnel behavior intact. var bridge *tlsbridge.Engine - if cfg.TLSBridge != nil && cfg.TLSBridge.Enabled { - var src tlsbridge.CASource - if cfg.TLSBridge.CASource == "file" { - src, err = tlsbridge.NewFileSource(cfg.TLSBridge.CACertPath, cfg.TLSBridge.CAKeyPath) - } else { - src, err = tlsbridge.NewEphemeralSource() - } - if err != nil { - log.Fatalf("tls-bridge CA init failed: %v", err) + if cfg.TLSBridge != nil && cfg.TLSBridge.Mode == "enabled" { + // CA is always the operator-mounted cert-manager Secret (tls.crt/tls.key + // under ca_dir). EphemeralSource exists only for in-process tests. + src, cerr := tlsbridge.NewFileSource(cfg.TLSBridge.CADir+"/tls.crt", cfg.TLSBridge.CADir+"/tls.key") + if cerr != nil { + log.Fatalf("tls-bridge CA init failed: %v", cerr) } var extra []byte if cfg.TLSBridge.UpstreamCABundle != "" { @@ -274,10 +271,6 @@ func main() { log.Fatalf("tls-bridge upstream client failed: %v", uerr) } minter := tlsbridge.NewMinter(src, tlsbridge.MinterOpts{}) - scope := tlsbridge.ScopeExternal - if cfg.TLSBridge.Scope == "all" { - scope = tlsbridge.ScopeAll - } var ports map[int]bool // nil => NewDecision defaults to {443, 8443} if len(cfg.TLSBridge.Ports) > 0 { ports = make(map[int]bool, len(cfg.TLSBridge.Ports)) @@ -287,7 +280,7 @@ func main() { } bridge = &tlsbridge.Engine{ Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{ - Scope: scope, Ports: ports, InternalCIDRs: cfg.TLSBridge.InternalCIDRs, SkipHosts: cfg.TLSBridge.SkipHosts, + Ports: ports, SkipHosts: cfg.TLSBridge.PassthroughHosts, }), Term: tlsbridge.NewTerminator(minter), Skip: tlsbridge.NewSkipSet(), @@ -295,7 +288,7 @@ func main() { CAPEM: src.CACertPEM(), } tlsbridge.RunTrustSelfCheck(bridge.CAPEM) - slog.Info("tls-bridge enabled", "scope", cfg.TLSBridge.Scope, "ca_source", cfg.TLSBridge.CASource) + slog.Info("tls-bridge enabled", "ca_dir", cfg.TLSBridge.CADir) } // Proxy-sidecar: reverse proxy on the inbound path + forward proxy From 0138d2437fc83f35ee2ebf0b760f03e360af8449 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 18 Jun 2026 09:37:49 -0400 Subject: [PATCH 22/24] Docs: Update spec config block to consolidated tls_bridge schema As-built note + corrected YAML: mode|ca_dir|passthrough_hosts|ports| upstream_ca_bundle (dropped enabled/scope/internal_cidrs/ca_source/cert+key paths/Name-Constraints; ephemeral is test-only). Signed-off-by: Hai Huang --- .../2026-06-12-authbridge-tlsbridge-design.md | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/authbridge/docs/superpowers/specs/2026-06-12-authbridge-tlsbridge-design.md b/authbridge/docs/superpowers/specs/2026-06-12-authbridge-tlsbridge-design.md index de3fe01d2..3bd21b3c0 100644 --- a/authbridge/docs/superpowers/specs/2026-06-12-authbridge-tlsbridge-design.md +++ b/authbridge/docs/superpowers/specs/2026-06-12-authbridge-tlsbridge-design.md @@ -242,20 +242,31 @@ reason (`port|non-tls|skip|in-cluster|ech|upstream-verify|handshake-fail`). Beyo ## Config -A pointer block on `Config` mirroring the `MTLS`/`SPIFFE` idiom: +A pointer block on `Config` mirroring the `MTLS`/`SPIFFE` idiom. + +> **AS-BUILT schema (simplified from the original design narrative above).** The +> implemented `tls_bridge:` block is deliberately smaller than the early sketch. +> Decisions folded in: +> - **No `scope`/`internal_cidrs`.** The `external` vs `all` distinction was dropped: +> the bridge intercepts everything eligible on the configured `ports`, full stop +> (`mode: disabled|enabled`, matching the operator's `tlsBridgeMode` 1:1). No +> in-cluster gate in `Decision`. +> - **No `ca_source`/`ca_cert_path`/`ca_key_path`/`ca_export_path`.** A single +> `ca_dir` holds the operator-mounted cert-manager Secret (keys `tls.crt`/`tls.key`/`ca.crt` +> by convention). The ephemeral in-memory CA is **test-only** (`NewEphemeralSource`, +> not config-selectable) — a real agent can't trust an unexported in-memory CA. +> - **No X.509 Name Constraints** on the per-agent CA (unconstrained; containment = +> per-agent isolation + sidecar-only `0440` key + rotation). +> - **`skip_hosts` → `passthrough_hosts`** to disambiguate from `listener.skip_hosts` +> (which bypasses the whole pipeline; these still run the egress gate, just not TLS-terminated). ```yaml -mitm: - enabled: true - scope: external # external | all (which traffic to intercept) - internal_cidrs: [] # treated as in-cluster when scope=external (else discovered) - ca_source: file # file | ephemeral - ca_cert_path: /etc/authbridge/mitm-ca/tls.crt - ca_key_path: /etc/authbridge/mitm-ca/tls.key - ca_export_path: /var/run/authbridge/mitm-ca.pem # ephemeral mode only +tls_bridge: + mode: enabled # disabled | enabled (enabled = intercept all eligible on ports) + ca_dir: /etc/authbridge/tls-bridge-ca # operator-mounted cert-manager Secret: tls.crt/tls.key/ca.crt upstream_ca_bundle: "" # extra roots for re-origination (agent's private CAs); empty = system roots - skip_hosts: [] # static passthrough; auto-skip augments this at runtime - leaf_cache: { max: 1024, ttl: 24h } + passthrough_hosts: [] # static passthrough; the runtime auto-skip set augments this + ports: [] # TLS ports to intercept; empty => {443, 8443}. HTTP(S)-only. ``` Read in `main.go` beside the `fpMTLS` block; construct `CASource` + `Minter` + `Terminator` + the upstream From 4acd038e12908ca29010852a8322feddb6a6f94e Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 18 Jun 2026 11:07:22 -0400 Subject: [PATCH 23/24] feat(tlsbridge): localhost-bind :9094 when bridging; validate the CA Secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-path hardenings, both load-time: - When tls_bridge.mode=enabled, Load() rewrites listener.session_api_addr to 127.0.0.1 (preserving the port) so the session API — which now carries decrypted bodies — isn't reachable by other pods. kubectl port-forward (abctl) still works (it targets the pod's loopback). No auth, no redaction yet; this just shrinks the reachable surface. - NewFileSource now fails loud on a misissued cert-manager Secret: rejects a non-CA cert (IsCA=false / missing KeyUsageCertSign) and a cert/key public-key mismatch. Previously these loaded 'fine' and silently failed at signing time → every call tunnels with no error. Turns a misconfigured Secret into a clear startup failure. Tests: TestForceLocalhost, TestLoad_TLSBridgeHardensSessionAPI, TestFileSource_RejectsNonCAOrMismatch. Signed-off-by: Hai Huang --- authbridge/authlib/config/config.go | 21 +++++++++++ authbridge/authlib/config/config_test.go | 38 +++++++++++++++++++ authbridge/authlib/tlsbridge/ca.go | 15 ++++++++ authbridge/authlib/tlsbridge/ca_test.go | 47 ++++++++++++++++++++++++ 4 files changed, 121 insertions(+) diff --git a/authbridge/authlib/config/config.go b/authbridge/authlib/config/config.go index f707c078e..acf11ddd1 100644 --- a/authbridge/authlib/config/config.go +++ b/authbridge/authlib/config/config.go @@ -5,6 +5,7 @@ package config import ( "encoding/json" "fmt" + "net" "os" "strings" @@ -510,7 +511,27 @@ func Load(path string) (*Config, error) { if err := cfg.TLSBridge.Validate(); err != nil { return nil, err } + // With the bridge on, the session API may carry decrypted request/response + // bodies; restrict its bind to loopback so other pods can't scrape it. + // kubectl port-forward (abctl) still works — it targets the pod's loopback. + if cfg.TLSBridge.Mode == "enabled" { + cfg.Listener.SessionAPIAddr = forceLocalhost(cfg.Listener.SessionAPIAddr) + } } return &cfg, nil } + +// forceLocalhost rewrites a bind address to 127.0.0.1, preserving the port: +// ":9094" / "0.0.0.0:9094" / "[::]:9094" -> "127.0.0.1:9094". Empty stays empty; +// a malformed address is left as-is so the bind itself surfaces the error. +func forceLocalhost(addr string) string { + if addr == "" { + return "" + } + _, port, err := net.SplitHostPort(addr) + if err != nil { + return addr + } + return net.JoinHostPort("127.0.0.1", port) +} diff --git a/authbridge/authlib/config/config_test.go b/authbridge/authlib/config/config_test.go index 62fa58787..71b0cf024 100644 --- a/authbridge/authlib/config/config_test.go +++ b/authbridge/authlib/config/config_test.go @@ -779,6 +779,44 @@ func TestTLSBridgeConfig_Validate(t *testing.T) { } } +func TestForceLocalhost(t *testing.T) { + cases := map[string]string{ + ":9094": "127.0.0.1:9094", + "0.0.0.0:9094": "127.0.0.1:9094", + "[::]:9094": "127.0.0.1:9094", + "127.0.0.1:9094": "127.0.0.1:9094", + "": "", + "no-port-malformed": "no-port-malformed", // left as-is; bind surfaces the error + } + for in, want := range cases { + if got := forceLocalhost(in); got != want { + t.Errorf("forceLocalhost(%q) = %q, want %q", in, got, want) + } + } +} + +// With the bridge enabled, Load() restricts the session API to loopback so +// other pods can't scrape decrypted bodies. +func TestLoad_TLSBridgeHardensSessionAPI(t *testing.T) { + y := []byte("mode: proxy-sidecar\n" + + "listener:\n" + + " session_api_addr: \":9094\"\n" + + "tls_bridge:\n" + + " mode: enabled\n" + + " ca_dir: /etc/authbridge/tls-bridge-ca\n") + p := filepath.Join(t.TempDir(), "cfg.yaml") + if err := os.WriteFile(p, y, 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(p) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Listener.SessionAPIAddr != "127.0.0.1:9094" { + t.Errorf("session_api_addr = %q, want 127.0.0.1:9094 (bridge on => loopback)", cfg.Listener.SessionAPIAddr) + } +} + // Absent mtls block leaves cfg.MTLS nil — today's behavior, no TLS. func TestLoad_MTLS_AbsentBlock(t *testing.T) { dir := t.TempDir() diff --git a/authbridge/authlib/tlsbridge/ca.go b/authbridge/authlib/tlsbridge/ca.go index 462109fb9..2531b2252 100644 --- a/authbridge/authlib/tlsbridge/ca.go +++ b/authbridge/authlib/tlsbridge/ca.go @@ -90,6 +90,21 @@ func NewFileSource(certPath, keyPath string) (CASource, error) { if err != nil { return nil, err } + // Fail loud at load on a misissued Secret. Without these checks a non-CA or + // mismatched cert/key loads "fine" and then silently fails to sign minted + // leaves at request time → the agent rejects the chain → every call falls + // open to tunnel, with no error. A cert-manager Secret can be misconfigured + // (wrong issuerRef, leaf instead of CA, mid-rotation key mismatch), so verify. + if !cert.IsCA { + return nil, fmt.Errorf("tlsbridge: CA cert %s is not a CA (IsCA=false)", certPath) + } + if cert.KeyUsage != 0 && cert.KeyUsage&x509.KeyUsageCertSign == 0 { + return nil, fmt.Errorf("tlsbridge: CA cert %s lacks KeyUsageCertSign", certPath) + } + pub, ok := cert.PublicKey.(interface{ Equal(x crypto.PublicKey) bool }) + if !ok || !pub.Equal(key.Public()) { + return nil, fmt.Errorf("tlsbridge: CA cert %s and key %s do not match", certPath, keyPath) + } return &staticSource{cert: cert, key: key, certPEM: certPEM}, nil } diff --git a/authbridge/authlib/tlsbridge/ca_test.go b/authbridge/authlib/tlsbridge/ca_test.go index ebeda5fbf..03c271866 100644 --- a/authbridge/authlib/tlsbridge/ca_test.go +++ b/authbridge/authlib/tlsbridge/ca_test.go @@ -114,3 +114,50 @@ func TestFileSource_RejectsGarbage(t *testing.T) { }) } } + +func TestFileSource_RejectsNonCAOrMismatch(t *testing.T) { + // writePair builds a self-signed cert from certKey and writes it next to + // fileKey (PKCS#8). When certKey != fileKey the cert/key public keys differ. + writePair := func(t *testing.T, isCA bool, certKey, fileKey *ecdsa.PrivateKey) (string, string) { + t.Helper() + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "t"}, + NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), + IsCA: isCA, BasicConstraintsValid: true, + } + if isCA { + tmpl.KeyUsage = x509.KeyUsageCertSign + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, certKey.Public(), certKey) + if err != nil { + t.Fatalf("create cert: %v", err) + } + dir := t.TempDir() + certP := filepath.Join(dir, "tls.crt") + keyP := filepath.Join(dir, "tls.key") + kd, _ := x509.MarshalPKCS8PrivateKey(fileKey) + if err := os.WriteFile(certP, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil { + t.Fatalf("write cert: %v", err) + } + if err := os.WriteFile(keyP, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + return certP, keyP + } + + k1, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + k2, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + + t.Run("non-CA cert", func(t *testing.T) { + certP, keyP := writePair(t, false, k1, k1) // matching key, but IsCA=false + if _, err := NewFileSource(certP, keyP); err == nil { + t.Fatal("expected error for IsCA=false cert, got nil") + } + }) + t.Run("mismatched cert/key", func(t *testing.T) { + certP, keyP := writePair(t, true, k1, k2) // cert carries k1's pubkey; file holds k2 + if _, err := NewFileSource(certP, keyP); err == nil { + t.Fatal("expected error for cert/key mismatch, got nil") + } + }) +} From 82ded4c12efd0ba855111801d2117bb0d7434c5d Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 18 Jun 2026 11:32:32 -0400 Subject: [PATCH 24/24] feat(tlsbridge): bound the runtime SkipSet (TTL + size cap); drop residual MITM comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SkipSet was an unbounded map. With scope/CIDR removed, all eligible egress flows through the bridge, so a flood of distinct SNIs each rejecting the forged leaf could grow it without bound. Add a 10m TTL (self-healing: a transiently-pinned/rotated client gets re-attempted) and a 4096 cap with oldest-expiry eviction on the cold Add path. Contains stays an RLock read (expired entries read as absent; Add reclaims them). Tests cover TTL expiry and the size bound. - main.go: drop a residual '(MITM)' in a comment (rename completed elsewhere). Defers (per reviewer): caching upstream-verify success per host is a pure optimization that trades verify-freshness for fewer probes — left for Phase 2. Signed-off-by: Hai Huang --- authbridge/authlib/tlsbridge/decision.go | 53 ++++++++++++++++--- authbridge/authlib/tlsbridge/decision_test.go | 29 +++++++++- authbridge/cmd/authbridge-proxy/main.go | 2 +- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/authbridge/authlib/tlsbridge/decision.go b/authbridge/authlib/tlsbridge/decision.go index 0efb4a9e2..a8e1be497 100644 --- a/authbridge/authlib/tlsbridge/decision.go +++ b/authbridge/authlib/tlsbridge/decision.go @@ -2,6 +2,7 @@ package tlsbridge import ( "sync" + "time" ) type Verdict int @@ -66,21 +67,59 @@ func looksLikeTLSRecord(b []byte) bool { return b[0] == 0x16 && b[1] == 0x03 && b[2] >= 0x01 && b[2] <= 0x04 } +const ( + // skipTTL bounds how long an auto-skipped host stays skipped before the + // bridge re-attempts interception (self-healing: a transiently-pinned or + // rotated client gets another chance). skipMax caps the set so a flood of + // distinct SNIs (each rejecting the forged leaf) cannot grow it unbounded — + // since scope removal routes ALL eligible egress through here. + skipTTL = 10 * time.Minute + skipMax = 4096 +) + // SkipSet is the runtime auto-skip set (hosts whose minted leaf the client -// rejected). Concurrent-safe; augments the static skip list. +// rejected). Concurrent-safe; augments the static skip list. Entries expire +// after skipTTL and the set is bounded to skipMax (oldest-expiry eviction). type SkipSet struct { - mu sync.RWMutex - m map[string]bool + mu sync.RWMutex + ttl time.Duration + max int + m map[string]time.Time // host -> expiry +} + +func NewSkipSet() *SkipSet { + return &SkipSet{ttl: skipTTL, max: skipMax, m: map[string]time.Time{}} } -func NewSkipSet() *SkipSet { return &SkipSet{m: map[string]bool{}} } func (s *SkipSet) Add(host string) { s.mu.Lock() - s.m[host] = true - s.mu.Unlock() + defer s.mu.Unlock() + now := time.Now() + if len(s.m) >= s.max { + // Purge expired entries; if still full, drop the earliest-expiring one. + // Add is cold (only fires when a minted leaf is rejected), so an O(n) + // sweep here is cheap. + var oldestK string + var oldestT time.Time + for k, exp := range s.m { + if !exp.After(now) { + delete(s.m, k) + continue + } + if oldestK == "" || exp.Before(oldestT) { + oldestK, oldestT = k, exp + } + } + if len(s.m) >= s.max && oldestK != "" { + delete(s.m, oldestK) + } + } + s.m[host] = now.Add(s.ttl) } + func (s *SkipSet) Contains(host string) bool { s.mu.RLock() defer s.mu.RUnlock() - return s.m[host] + exp, ok := s.m[host] + return ok && time.Now().Before(exp) // expired entries read as absent; Add reclaims them } diff --git a/authbridge/authlib/tlsbridge/decision_test.go b/authbridge/authlib/tlsbridge/decision_test.go index 7ffcb8db2..aad46848a 100644 --- a/authbridge/authlib/tlsbridge/decision_test.go +++ b/authbridge/authlib/tlsbridge/decision_test.go @@ -1,6 +1,9 @@ package tlsbridge -import "testing" +import ( + "testing" + "time" +) func TestDecision_Classify(t *testing.T) { d := NewDecision(DecisionOpts{ @@ -55,6 +58,30 @@ func TestSkipSet_AutoSkip(t *testing.T) { } } +func TestSkipSet_TTLExpires(t *testing.T) { + s := NewSkipSet() + s.ttl = 20 * time.Millisecond // same-package test can tighten the TTL + s.Add("h") + if !s.Contains("h") { + t.Fatal("should contain immediately after Add") + } + time.Sleep(40 * time.Millisecond) + if s.Contains("h") { + t.Error("entry should have expired (self-healing re-attempt)") + } +} + +func TestSkipSet_Bounded(t *testing.T) { + s := NewSkipSet() + s.max = 2 // cap small; a flood of distinct SNIs must not grow it unbounded + s.Add("a") + s.Add("b") + s.Add("c") + if len(s.m) > 2 { + t.Errorf("SkipSet grew past max: len=%d, want <=2", len(s.m)) + } +} + func TestDecision_HandlesPort(t *testing.T) { // Default set when Ports is nil. d := NewDecision(DecisionOpts{}) diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go index c0082ce24..26c947051 100644 --- a/authbridge/cmd/authbridge-proxy/main.go +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -249,7 +249,7 @@ func main() { } // TLS bridge: when enabled, the forward proxy terminates agent outbound - // TLS (MITM) so the outbound pipeline sees decrypted HTTPS. Constructed + // TLS so the outbound pipeline sees decrypted HTTPS. Constructed // here and set on fpSrv below (mirroring fpSrv.SkipHosts / fpSrv.Shared). // A nil *Engine leaves today's blind-tunnel behavior intact. var bridge *tlsbridge.Engine