Skip to content

Commit d6eee5c

Browse files
authored
Merge pull request #522 from huang195/feat/tlsbridge-phase1
Feat: Outbound TLS bridge (Phase 1) — decrypt agent egress HTTPS into the pipeline
2 parents 449af49 + 82ded4c commit d6eee5c

30 files changed

Lines changed: 4366 additions & 79 deletions

authbridge/authlib/config/config.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package config
55
import (
66
"encoding/json"
77
"fmt"
8+
"net"
89
"os"
910
"strings"
1011

@@ -37,6 +38,51 @@ type Config struct {
3738
// = today's spiffe-helper-driven behavior (until the chart/operator
3839
// follow-ups land and start populating the block).
3940
SPIFFE *SPIFFEConfig `yaml:"spiffe,omitempty" json:"spiffe,omitempty"`
41+
// TLSBridge, when non-nil and Enabled, terminates agent outbound TLS so the
42+
// outbound pipeline sees decrypted HTTPS. See docs/.../tlsbridge-design.md.
43+
TLSBridge *TLSBridgeConfig `yaml:"tls_bridge,omitempty" json:"tls_bridge,omitempty"`
44+
}
45+
46+
// TLSBridgeConfig configures the outbound TLS bridge (TLS termination of
47+
// agent egress — formerly "MITM" — so the outbound plugin pipeline sees
48+
// decrypted HTTPS).
49+
type TLSBridgeConfig struct {
50+
// Mode is the bridge posture: "disabled" (off) or "enabled" (intercept all
51+
// eligible egress on the configured Ports). Empty == disabled.
52+
Mode string `yaml:"mode" json:"mode"` // disabled | enabled
53+
// CADir holds the per-agent signing CA, mounted from the operator's
54+
// cert-manager Secret. The bridge reads tls.crt + tls.key (to sign leaves);
55+
// ca.crt is the trust cert handed to the agent. cert-manager Secret key
56+
// conventions, so only the directory is configured.
57+
CADir string `yaml:"ca_dir" json:"ca_dir"`
58+
// UpstreamCABundle is an extra-roots PEM file for re-origination (private-CA
59+
// origins the agent trusts); empty == system roots only.
60+
UpstreamCABundle string `yaml:"upstream_ca_bundle" json:"upstream_ca_bundle"`
61+
// PassthroughHosts are hosts to tunnel (never intercept). Distinct from
62+
// listener.skip_hosts, which bypasses the whole pipeline; these still run the
63+
// egress gate, they just aren't TLS-terminated.
64+
PassthroughHosts []string `yaml:"passthrough_hosts" json:"passthrough_hosts"`
65+
// Ports is the set of TCP ports to intercept as TLS. Empty => {443, 8443}.
66+
// Only HTTP(S)-bearing ports belong here: the bridge serves the decrypted
67+
// stream as HTTP/1.1 or h2, so terminating a non-HTTP TLS protocol (LDAPS,
68+
// SMTPS, DB-over-TLS, …) would break it.
69+
Ports []int `yaml:"ports" json:"ports"`
70+
}
71+
72+
// Validate is called from the loader when TLSBridge != nil.
73+
func (b *TLSBridgeConfig) Validate() error {
74+
if b.Mode != "" && b.Mode != "disabled" && b.Mode != "enabled" {
75+
return fmt.Errorf("tls_bridge.mode must be 'disabled' or 'enabled', got %q", b.Mode)
76+
}
77+
if b.Mode == "enabled" && b.CADir == "" {
78+
return fmt.Errorf("tls_bridge.mode=enabled requires ca_dir")
79+
}
80+
for _, p := range b.Ports {
81+
if p < 1 || p > 65535 {
82+
return fmt.Errorf("tls_bridge.ports: %d is out of range 1-65535", p)
83+
}
84+
}
85+
return nil
4086
}
4187

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

510+
if cfg.TLSBridge != nil {
511+
if err := cfg.TLSBridge.Validate(); err != nil {
512+
return nil, err
513+
}
514+
// With the bridge on, the session API may carry decrypted request/response
515+
// bodies; restrict its bind to loopback so other pods can't scrape it.
516+
// kubectl port-forward (abctl) still works — it targets the pod's loopback.
517+
if cfg.TLSBridge.Mode == "enabled" {
518+
cfg.Listener.SessionAPIAddr = forceLocalhost(cfg.Listener.SessionAPIAddr)
519+
}
520+
}
521+
464522
return &cfg, nil
465523
}
524+
525+
// forceLocalhost rewrites a bind address to 127.0.0.1, preserving the port:
526+
// ":9094" / "0.0.0.0:9094" / "[::]:9094" -> "127.0.0.1:9094". Empty stays empty;
527+
// a malformed address is left as-is so the bind itself surfaces the error.
528+
func forceLocalhost(addr string) string {
529+
if addr == "" {
530+
return ""
531+
}
532+
_, port, err := net.SplitHostPort(addr)
533+
if err != nil {
534+
return addr
535+
}
536+
return net.JoinHostPort("127.0.0.1", port)
537+
}

authbridge/authlib/config/config_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,96 @@ spiffe: {}
727727
}
728728
}
729729

730+
// --- TLS bridge config ---
731+
732+
// The tls_bridge block decodes into TLSBridgeConfig and Load surfaces it.
733+
func TestConfig_TLSBridgeBlockDecodes(t *testing.T) {
734+
y := []byte("mode: proxy-sidecar\n" +
735+
"tls_bridge:\n" +
736+
" mode: enabled\n" +
737+
" ca_dir: /etc/authbridge/tls-bridge-ca\n" +
738+
" passthrough_hosts: [\"pinned.example.com\"]\n" +
739+
" ports: [443, 9443]\n")
740+
p := filepath.Join(t.TempDir(), "cfg.yaml")
741+
if err := os.WriteFile(p, y, 0o600); err != nil {
742+
t.Fatal(err)
743+
}
744+
cfg, err := Load(p)
745+
if err != nil {
746+
t.Fatalf("load: %v", err)
747+
}
748+
if cfg.TLSBridge == nil || cfg.TLSBridge.Mode != "enabled" || cfg.TLSBridge.CADir != "/etc/authbridge/tls-bridge-ca" {
749+
t.Fatalf("tls_bridge block did not decode: %+v", cfg.TLSBridge)
750+
}
751+
if len(cfg.TLSBridge.PassthroughHosts) != 1 || len(cfg.TLSBridge.Ports) != 2 {
752+
t.Fatalf("passthrough_hosts/ports did not decode: %+v", cfg.TLSBridge)
753+
}
754+
}
755+
756+
func TestTLSBridgeConfig_Validate(t *testing.T) {
757+
cases := []struct {
758+
name string
759+
cfg TLSBridgeConfig
760+
wantErr bool
761+
}{
762+
{"valid empty", TLSBridgeConfig{}, false},
763+
{"valid disabled", TLSBridgeConfig{Mode: "disabled"}, false},
764+
{"valid enabled with ca_dir", TLSBridgeConfig{Mode: "enabled", CADir: "/etc/authbridge/tls-bridge-ca"}, false},
765+
{"bad mode", TLSBridgeConfig{Mode: "external"}, true},
766+
{"enabled without ca_dir", TLSBridgeConfig{Mode: "enabled"}, true},
767+
{"valid ports", TLSBridgeConfig{Ports: []int{443, 8443, 9443}}, false},
768+
{"port zero", TLSBridgeConfig{Ports: []int{0}}, true},
769+
{"port too high", TLSBridgeConfig{Ports: []int{70000}}, true},
770+
{"port negative", TLSBridgeConfig{Ports: []int{-1}}, true},
771+
}
772+
for _, tc := range cases {
773+
t.Run(tc.name, func(t *testing.T) {
774+
err := tc.cfg.Validate()
775+
if (err != nil) != tc.wantErr {
776+
t.Errorf("Validate() error = %v, wantErr %v", err, tc.wantErr)
777+
}
778+
})
779+
}
780+
}
781+
782+
func TestForceLocalhost(t *testing.T) {
783+
cases := map[string]string{
784+
":9094": "127.0.0.1:9094",
785+
"0.0.0.0:9094": "127.0.0.1:9094",
786+
"[::]:9094": "127.0.0.1:9094",
787+
"127.0.0.1:9094": "127.0.0.1:9094",
788+
"": "",
789+
"no-port-malformed": "no-port-malformed", // left as-is; bind surfaces the error
790+
}
791+
for in, want := range cases {
792+
if got := forceLocalhost(in); got != want {
793+
t.Errorf("forceLocalhost(%q) = %q, want %q", in, got, want)
794+
}
795+
}
796+
}
797+
798+
// With the bridge enabled, Load() restricts the session API to loopback so
799+
// other pods can't scrape decrypted bodies.
800+
func TestLoad_TLSBridgeHardensSessionAPI(t *testing.T) {
801+
y := []byte("mode: proxy-sidecar\n" +
802+
"listener:\n" +
803+
" session_api_addr: \":9094\"\n" +
804+
"tls_bridge:\n" +
805+
" mode: enabled\n" +
806+
" ca_dir: /etc/authbridge/tls-bridge-ca\n")
807+
p := filepath.Join(t.TempDir(), "cfg.yaml")
808+
if err := os.WriteFile(p, y, 0o600); err != nil {
809+
t.Fatal(err)
810+
}
811+
cfg, err := Load(p)
812+
if err != nil {
813+
t.Fatalf("load: %v", err)
814+
}
815+
if cfg.Listener.SessionAPIAddr != "127.0.0.1:9094" {
816+
t.Errorf("session_api_addr = %q, want 127.0.0.1:9094 (bridge on => loopback)", cfg.Listener.SessionAPIAddr)
817+
}
818+
}
819+
730820
// Absent mtls block leaves cfg.MTLS nil — today's behavior, no TLS.
731821
func TestLoad_MTLS_AbsentBlock(t *testing.T) {
732822
dir := t.TempDir()

authbridge/authlib/go.mod

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ require (
99
github.com/lestrrat-go/jwx/v2 v2.1.6
1010
github.com/open-policy-agent/opa v1.4.2
1111
github.com/spiffe/go-spiffe/v2 v2.6.0
12-
golang.org/x/sys v0.42.0
12+
golang.org/x/net v0.56.0
13+
golang.org/x/sys v0.46.0
1314
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171
1415
google.golang.org/grpc v1.81.1
1516
gopkg.in/yaml.v3 v3.0.1
@@ -66,10 +67,9 @@ require (
6667
go.opentelemetry.io/otel/metric v1.43.0 // indirect
6768
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
6869
go.opentelemetry.io/otel/trace v1.43.0 // indirect
69-
golang.org/x/crypto v0.48.0 // indirect
70-
golang.org/x/net v0.51.0 // indirect
71-
golang.org/x/sync v0.20.0 // indirect
72-
golang.org/x/text v0.34.0 // indirect
70+
golang.org/x/crypto v0.53.0 // indirect
71+
golang.org/x/sync v0.21.0 // indirect
72+
golang.org/x/text v0.38.0 // indirect
7373
golang.org/x/time v0.12.0 // indirect
7474
google.golang.org/protobuf v1.36.11 // indirect
7575
oras.land/oras-go/v2 v2.5.0 // indirect

authbridge/authlib/go.sum

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -185,23 +185,23 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce
185185
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
186186
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
187187
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
188-
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
189-
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
190-
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
191-
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
192-
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
193-
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
194-
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
195-
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
188+
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
189+
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
190+
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
191+
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
192+
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
193+
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
194+
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
195+
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
196196
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
197-
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
198-
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
199-
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
200-
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
197+
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
198+
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
199+
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
200+
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
201201
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
202202
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
203-
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
204-
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
203+
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
204+
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
205205
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
206206
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
207207
google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg=

0 commit comments

Comments
 (0)