Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6eb2b32
Docs: Add TLS bridge design spec + Phase 1 plan; add package skeleton
huang195 Jun 17, 2026
147cb1c
feat(tlsbridge): CASource (ephemeral + file, multi-format key)
huang195 Jun 17, 2026
bf93538
feat(tlsbridge): per-host leaf Minter (shared P-256 key) with LRU/TTL…
huang195 Jun 17, 2026
60fdb0b
feat(tlsbridge): Decision (record-header + scope gate) and SkipSet
huang195 Jun 17, 2026
ba1be47
test(tlsbridge): drop empty-branch (SA9003) in minter IP-SAN test
huang195 Jun 17, 2026
c52a347
test(tlsbridge): tighten TLS-record check (reject SSLv3) and cover De…
huang195 Jun 17, 2026
1c25fa6
feat(tlsbridge): upstream client with system + injected roots
huang195 Jun 17, 2026
54e7597
feat(tlsbridge): Terminator + one-conn keep-alive ServeConn (h2)
huang195 Jun 17, 2026
8e52e20
refactor(forwardproxy): extract serveOutbound; add tlsbridge.Engine f…
huang195 Jun 17, 2026
50a123f
fix(tlsbridge): ServeConn blocks until the served conn closes
huang195 Jun 18, 2026
85ed852
feat(forwardproxy): reversible TLS bridge on transparent path
huang195 Jun 18, 2026
2f6faa2
feat(forwardproxy): reversible TLS bridge on CONNECT path
huang195 Jun 18, 2026
fe93eec
test(tlsbridge): no-broken-calls guarantees (fall-open + auto-skip)
huang195 Jun 18, 2026
54a6859
feat(tlsbridge): config block + main wiring + trust self-check
huang195 Jun 18, 2026
e76eb80
fix(tlsbridge): reject unrecognized tls_bridge.ca_source in config va…
huang195 Jun 18, 2026
448cbec
fix(tlsbridge): go mod tidy cmd modules for bumped x/net (CI GOWORK=off)
huang195 Jun 18, 2026
0870f8c
fix(tlsbridge): validate internal_cidrs at config load; doc rename (r…
huang195 Jun 18, 2026
26b3f57
fix(tlsbridge): bound handshake + upstream-verify; guard empty-CA sel…
huang195 Jun 18, 2026
b9ca5af
Docs: Record TLS bridge protocol/port coverage + non-goals
huang195 Jun 18, 2026
2e76541
feat(tlsbridge): make intercepted TLS ports configurable (default 443…
huang195 Jun 18, 2026
f9ddac3
refactor(tlsbridge): consolidate config schema before release
huang195 Jun 18, 2026
0138d24
Docs: Update spec config block to consolidated tls_bridge schema
huang195 Jun 18, 2026
4acd038
feat(tlsbridge): localhost-bind :9094 when bridging; validate the CA …
huang195 Jun 18, 2026
82ded4c
feat(tlsbridge): bound the runtime SkipSet (TTL + size cap); drop res…
huang195 Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions authbridge/authlib/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package config
import (
"encoding/json"
"fmt"
"net"
"os"
"strings"

Expand Down Expand Up @@ -37,6 +38,51 @@ 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 (TLS termination of
// agent egress — formerly "MITM" — so the outbound plugin pipeline sees
// decrypted HTTPS).
type TLSBridgeConfig struct {
// 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,
// SMTPS, DB-over-TLS, …) would break it.
Ports []int `yaml:"ports" json:"ports"`
}

// Validate is called from the loader when TLSBridge != nil.
func (b *TLSBridgeConfig) Validate() error {
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.Mode == "enabled" && b.CADir == "" {
return fmt.Errorf("tls_bridge.mode=enabled requires ca_dir")
}
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
}

// MTLSMode names the inbound + outbound TLS posture. Vocabulary
Expand Down Expand Up @@ -461,5 +507,31 @@ func Load(path string) (*Config, error) {
return nil, err
}

if cfg.TLSBridge != nil {
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)
}
90 changes: 90 additions & 0 deletions authbridge/authlib/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,96 @@ 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" +
" 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)
}
cfg, err := Load(p)
if err != nil {
t.Fatalf("load: %v", err)
}
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) {
cases := []struct {
name string
cfg TLSBridgeConfig
wantErr bool
}{
{"valid empty", TLSBridgeConfig{}, false},
{"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},
{"port negative", TLSBridgeConfig{Ports: []int{-1}}, 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)
}
})
}
}

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()
Expand Down
10 changes: 5 additions & 5 deletions authbridge/authlib/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 14 additions & 14 deletions authbridge/authlib/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading
Loading