Skip to content

Commit f9ddac3

Browse files
committed
refactor(tlsbridge): consolidate config schema before release
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 <huang195@gmail.com>
1 parent 2e76541 commit f9ddac3

8 files changed

Lines changed: 67 additions & 141 deletions

File tree

authbridge/authlib/config/config.go

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ package config
55
import (
66
"encoding/json"
77
"fmt"
8-
"net"
98
"os"
109
"strings"
1110

@@ -47,14 +46,21 @@ type Config struct {
4746
// agent egress — formerly "MITM" — so the outbound plugin pipeline sees
4847
// decrypted HTTPS).
4948
type TLSBridgeConfig struct {
50-
Enabled bool `yaml:"enabled" json:"enabled"`
51-
Scope string `yaml:"scope" json:"scope"` // external | all
52-
InternalCIDRs []string `yaml:"internal_cidrs" json:"internal_cidrs"`
53-
CASource string `yaml:"ca_source" json:"ca_source"` // file | ephemeral
54-
CACertPath string `yaml:"ca_cert_path" json:"ca_cert_path"`
55-
CAKeyPath string `yaml:"ca_key_path" json:"ca_key_path"`
56-
UpstreamCABundle string `yaml:"upstream_ca_bundle" json:"upstream_ca_bundle"`
57-
SkipHosts []string `yaml:"skip_hosts" json:"skip_hosts"`
49+
// Mode is the bridge posture: "disabled" (off) or "enabled" (intercept all
50+
// eligible egress on the configured Ports). Empty == disabled.
51+
Mode string `yaml:"mode" json:"mode"` // disabled | enabled
52+
// CADir holds the per-agent signing CA, mounted from the operator's
53+
// cert-manager Secret. The bridge reads tls.crt + tls.key (to sign leaves);
54+
// ca.crt is the trust cert handed to the agent. cert-manager Secret key
55+
// conventions, so only the directory is configured.
56+
CADir string `yaml:"ca_dir" json:"ca_dir"`
57+
// UpstreamCABundle is an extra-roots PEM file for re-origination (private-CA
58+
// origins the agent trusts); empty == system roots only.
59+
UpstreamCABundle string `yaml:"upstream_ca_bundle" json:"upstream_ca_bundle"`
60+
// PassthroughHosts are hosts to tunnel (never intercept). Distinct from
61+
// listener.skip_hosts, which bypasses the whole pipeline; these still run the
62+
// egress gate, they just aren't TLS-terminated.
63+
PassthroughHosts []string `yaml:"passthrough_hosts" json:"passthrough_hosts"`
5864
// Ports is the set of TCP ports to intercept as TLS. Empty => {443, 8443}.
5965
// Only HTTP(S)-bearing ports belong here: the bridge serves the decrypted
6066
// stream as HTTP/1.1 or h2, so terminating a non-HTTP TLS protocol (LDAPS,
@@ -64,19 +70,11 @@ type TLSBridgeConfig struct {
6470

6571
// Validate is called from the loader when TLSBridge != nil.
6672
func (b *TLSBridgeConfig) Validate() error {
67-
if b.Scope != "" && b.Scope != "external" && b.Scope != "all" {
68-
return fmt.Errorf("tls_bridge.scope must be 'external' or 'all', got %q", b.Scope)
73+
if b.Mode != "" && b.Mode != "disabled" && b.Mode != "enabled" {
74+
return fmt.Errorf("tls_bridge.mode must be 'disabled' or 'enabled', got %q", b.Mode)
6975
}
70-
if b.CASource != "" && b.CASource != "file" && b.CASource != "ephemeral" {
71-
return fmt.Errorf("tls_bridge.ca_source must be 'file' or 'ephemeral', got %q", b.CASource)
72-
}
73-
if b.CASource == "file" && (b.CACertPath == "" || b.CAKeyPath == "") {
74-
return fmt.Errorf("tls_bridge.ca_source=file requires ca_cert_path and ca_key_path")
75-
}
76-
for _, c := range b.InternalCIDRs {
77-
if _, _, err := net.ParseCIDR(c); err != nil {
78-
return fmt.Errorf("tls_bridge.internal_cidrs: %q is not a valid CIDR: %w", c, err)
79-
}
76+
if b.Mode == "enabled" && b.CADir == "" {
77+
return fmt.Errorf("tls_bridge.mode=enabled requires ca_dir")
8078
}
8179
for _, p := range b.Ports {
8280
if p < 1 || p > 65535 {

authbridge/authlib/config/config_test.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -733,10 +733,10 @@ spiffe: {}
733733
func TestConfig_TLSBridgeBlockDecodes(t *testing.T) {
734734
y := []byte("mode: proxy-sidecar\n" +
735735
"tls_bridge:\n" +
736-
" enabled: true\n" +
737-
" scope: external\n" +
738-
" ca_source: ephemeral\n" +
739-
" skip_hosts: [\"pinned.example.com\"]\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")
740740
p := filepath.Join(t.TempDir(), "cfg.yaml")
741741
if err := os.WriteFile(p, y, 0o600); err != nil {
742742
t.Fatal(err)
@@ -745,9 +745,12 @@ func TestConfig_TLSBridgeBlockDecodes(t *testing.T) {
745745
if err != nil {
746746
t.Fatalf("load: %v", err)
747747
}
748-
if cfg.TLSBridge == nil || !cfg.TLSBridge.Enabled || cfg.TLSBridge.Scope != "external" {
748+
if cfg.TLSBridge == nil || cfg.TLSBridge.Mode != "enabled" || cfg.TLSBridge.CADir != "/etc/authbridge/tls-bridge-ca" {
749749
t.Fatalf("tls_bridge block did not decode: %+v", cfg.TLSBridge)
750750
}
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+
}
751754
}
752755

753756
func TestTLSBridgeConfig_Validate(t *testing.T) {
@@ -757,13 +760,10 @@ func TestTLSBridgeConfig_Validate(t *testing.T) {
757760
wantErr bool
758761
}{
759762
{"valid empty", TLSBridgeConfig{}, false},
760-
{"valid full", TLSBridgeConfig{Scope: "all", CASource: "ephemeral", InternalCIDRs: []string{"10.0.0.0/8", "172.30.0.0/16"}}, false},
761-
{"bad scope", TLSBridgeConfig{Scope: "internal"}, true},
762-
{"bad ca_source", TLSBridgeConfig{CASource: "vault"}, true},
763-
{"file ca without paths", TLSBridgeConfig{CASource: "file"}, true},
764-
{"file ca with paths", TLSBridgeConfig{CASource: "file", CACertPath: "/c", CAKeyPath: "/k"}, false},
765-
{"bad cidr typo", TLSBridgeConfig{InternalCIDRs: []string{"10.0.0.0/8", "10.0.0.0./8"}}, true},
766-
{"bad cidr missing mask", TLSBridgeConfig{InternalCIDRs: []string{"10.0.0.0"}}, true},
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},
767767
{"valid ports", TLSBridgeConfig{Ports: []int{443, 8443, 9443}}, false},
768768
{"port zero", TLSBridgeConfig{Ports: []int{0}}, true},
769769
{"port too high", TLSBridgeConfig{Ports: []int{70000}}, true},

authbridge/authlib/listener/forwardproxy/server.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -890,9 +890,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
890890
authority := r.Host // CONNECT target is already host:port
891891
key := hostOnly(r.Host)
892892
if !s.TLSBridge.Skip.Contains(key) {
893-
// ip is the hostname for a name CONNECT target (ParseIP→nil ⇒ never
894-
// matched as in-cluster; the transparent path keys on the dialed IP).
895-
if v, _ := s.TLSBridge.Decision.Classify(key, key, portOf(r.Host), first); v == tlsbridge.Terminate {
893+
if v, _ := s.TLSBridge.Decision.Classify(key, portOf(r.Host), first); v == tlsbridge.Terminate {
896894
_ = upstream.Close() // bridgeServe dials its own verified upstream
897895
if s.bridgeServe(clientConn, authority, key) {
898896
return

authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ func TestTransparentBridge(t *testing.T) {
9595
}
9696
originHostPort := originURL.Host // "127.0.0.1:port"
9797

98-
// 2) Build the bridge Engine. ScopeAll so the loopback origin isn't
99-
// treated as in-cluster and skipped.
98+
// 2) Build the bridge Engine. Ports is set to the origin's port so the
99+
// bridge intercepts it (the bridge has no in-cluster vs external scope).
100100
src, err := tlsbridge.NewEphemeralSource()
101101
if err != nil {
102102
t.Fatalf("NewEphemeralSource: %v", err)
@@ -108,7 +108,6 @@ func TestTransparentBridge(t *testing.T) {
108108
}
109109
engine := &tlsbridge.Engine{
110110
Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{
111-
Scope: tlsbridge.ScopeAll,
112111
Ports: map[int]bool{portOf(originHostPort): true},
113112
}),
114113
Term: tlsbridge.NewTerminator(minter),
@@ -267,7 +266,6 @@ func TestTransparentBridge_CustomPort(t *testing.T) {
267266
// Only the custom port is configured — proves both that it IS bridged
268267
// (despite shouldSniff not knowing it) and that the default set is replaced.
269268
Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{
270-
Scope: tlsbridge.ScopeAll,
271269
Ports: map[int]bool{customPort: true},
272270
}),
273271
Term: tlsbridge.NewTerminator(minter),
@@ -369,9 +367,8 @@ func TestConnectBridge(t *testing.T) {
369367
}
370368
originHostPort := originURL.Host // "127.0.0.1:port"
371369

372-
// 2) Build the bridge Engine. ScopeAll so the loopback origin isn't
373-
// treated as in-cluster and skipped. Ports must include the origin's
374-
// random port (the CONNECT classify keys on portOf(r.Host)).
370+
// 2) Build the bridge Engine. Ports must include the origin's random port
371+
// (the CONNECT classify keys on portOf(r.Host)).
375372
src, err := tlsbridge.NewEphemeralSource()
376373
if err != nil {
377374
t.Fatalf("NewEphemeralSource: %v", err)
@@ -383,7 +380,6 @@ func TestConnectBridge(t *testing.T) {
383380
}
384381
engine := &tlsbridge.Engine{
385382
Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{
386-
Scope: tlsbridge.ScopeAll,
387383
Ports: map[int]bool{portOf(originHostPort): true},
388384
}),
389385
Term: tlsbridge.NewTerminator(minter),
@@ -575,7 +571,6 @@ func TestBridge_UnverifiableUpstream_FallsOpenToTunnel(t *testing.T) {
575571
}
576572
engine := &tlsbridge.Engine{
577573
Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{
578-
Scope: tlsbridge.ScopeAll,
579574
Ports: map[int]bool{portOf(originHostPort): true},
580575
}),
581576
Term: tlsbridge.NewTerminator(minter),
@@ -714,7 +709,6 @@ func TestBridge_PinnedClient_AutoSkipsThenTunnels(t *testing.T) {
714709
}
715710
engine := &tlsbridge.Engine{
716711
Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{
717-
Scope: tlsbridge.ScopeAll,
718712
Ports: map[int]bool{portOf(originHostPort): true},
719713
}),
720714
Term: tlsbridge.NewTerminator(minter),
@@ -879,7 +873,6 @@ func TestBridge_NonTLS_Passthrough(t *testing.T) {
879873
}
880874
engine := &tlsbridge.Engine{
881875
Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{
882-
Scope: tlsbridge.ScopeAll,
883876
Ports: map[int]bool{portOf(originHostPort): true},
884877
}),
885878
Term: tlsbridge.NewTerminator(minter),

authbridge/authlib/listener/forwardproxy/transparent.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,14 @@ func (s *Server) HandleTransparentConn(clientConn net.Conn, dst string) {
131131

132132
if s.TLSBridge != nil {
133133
// host is the policy authority: "<sniffed-SNI>:port" when a name was
134-
// recovered, else dst ("<dial-IP>:port"). key is the SNI name or dial IP;
135-
// ip is always the dialed IP (for the in-cluster CIDR gate).
136-
ip := hostOnly(dst)
134+
// recovered, else dst ("<dial-IP>:port"). key is the SNI name or dial IP.
137135
key := hostOnly(host)
138136
var first []byte
139137
if pc, ok := clientConn.(*peekedConn); ok {
140138
first, _ = pc.Peek(5)
141139
}
142140
if !s.TLSBridge.Skip.Contains(key) {
143-
v, reason := s.TLSBridge.Decision.Classify(key, ip, portOf(dst), first)
141+
v, reason := s.TLSBridge.Decision.Classify(key, portOf(dst), first)
144142
if v == tlsbridge.Terminate {
145143
_ = upstream.Close() // bridgeServe dials its own verified upstream; drop the pre-dial
146144
if s.bridgeServe(clientConn, host, key) {

authbridge/authlib/tlsbridge/decision.go

Lines changed: 10 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package tlsbridge
22

33
import (
4-
"net"
54
"sync"
65
)
76

@@ -12,37 +11,21 @@ const (
1211
Terminate
1312
)
1413

15-
type Scope int
16-
17-
const (
18-
ScopeExternal Scope = iota // default: do not bridge internal/mesh destinations
19-
ScopeAll // bridge everything eligible (no-mesh / standalone)
20-
)
21-
2214
type DecisionOpts struct {
23-
Ports map[int]bool
24-
Scope Scope
25-
InternalCIDRs []string
26-
SkipHosts []string
15+
Ports map[int]bool
16+
SkipHosts []string
2717
}
2818

2919
type Decision struct {
30-
ports map[int]bool
31-
scope Scope
32-
internal []*net.IPNet
33-
skip map[string]bool
20+
ports map[int]bool
21+
skip map[string]bool
3422
}
3523

3624
func NewDecision(o DecisionOpts) *Decision {
37-
d := &Decision{ports: o.Ports, scope: o.Scope, skip: map[string]bool{}}
25+
d := &Decision{ports: o.Ports, skip: map[string]bool{}}
3826
if d.ports == nil {
3927
d.ports = map[int]bool{443: true, 8443: true}
4028
}
41-
for _, c := range o.InternalCIDRs {
42-
if _, n, err := net.ParseCIDR(c); err == nil {
43-
d.internal = append(d.internal, n)
44-
}
45-
}
4629
for _, h := range o.SkipHosts {
4730
d.skip[h] = true
4831
}
@@ -55,8 +38,11 @@ func NewDecision(o DecisionOpts) *Decision {
5538
// the configured ports, never drifting from Classify's port gate.
5639
func (d *Decision) HandlesPort(port int) bool { return d.ports[port] }
5740

58-
// Classify decides whether to bridge. first is the peeked client bytes.
59-
func (d *Decision) Classify(host, ip string, port int, first []byte) (Verdict, string) {
41+
// Classify decides whether to bridge. first is the peeked client bytes. The
42+
// bridge intercepts everything eligible on the configured ports (no in-cluster
43+
// vs external distinction): a port + valid-TLS-record + not-skip-listed
44+
// connection is terminated; anything else passes through.
45+
func (d *Decision) Classify(host string, port int, first []byte) (Verdict, string) {
6046
if !d.ports[port] {
6147
return Passthrough, "port"
6248
}
@@ -66,25 +52,9 @@ func (d *Decision) Classify(host, ip string, port int, first []byte) (Verdict, s
6652
if d.skip[host] {
6753
return Passthrough, "skip"
6854
}
69-
if d.scope == ScopeExternal && d.isInternal(ip) {
70-
return Passthrough, "in-cluster"
71-
}
7255
return Terminate, ""
7356
}
7457

75-
func (d *Decision) isInternal(ip string) bool {
76-
parsed := net.ParseIP(ip)
77-
if parsed == nil {
78-
return false // a hostname (not an IP) is never matched as in-cluster — see CONNECT-path note
79-
}
80-
for _, n := range d.internal {
81-
if n.Contains(parsed) {
82-
return true
83-
}
84-
}
85-
return false
86-
}
87-
8858
// looksLikeTLSRecord validates the 5-byte TLS record header (not just 0x16):
8959
// content type 22 (handshake), legacy record version 0x03 with minor 0x01-0x04
9060
// (TLS 1.0–1.3; SSLv3's 0x0300 is rejected). It checks the record layer, not the

authbridge/authlib/tlsbridge/decision_test.go

Lines changed: 11 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,70 +4,46 @@ import "testing"
44

55
func TestDecision_Classify(t *testing.T) {
66
d := NewDecision(DecisionOpts{
7-
Ports: map[int]bool{443: true, 8443: true},
8-
Scope: ScopeExternal,
9-
InternalCIDRs: []string{"10.0.0.0/8"},
10-
SkipHosts: []string{"pinned.example.com"},
7+
Ports: map[int]bool{443: true, 8443: true},
8+
SkipHosts: []string{"pinned.example.com"},
119
})
1210
tlsHello := []byte{0x16, 0x03, 0x01, 0x00, 0x05} // handshake, TLS1.0 record, len
1311
cases := []struct {
1412
name string
1513
host string
16-
ip string
1714
port int
1815
first []byte
1916
expect Verdict
2017
reason string
2118
}{
22-
{"happy external https", "api.example.com", "93.184.216.34", 443, tlsHello, Terminate, ""},
23-
{"non-tls first byte", "api.example.com", "93.184.216.34", 443, []byte("GET / "), Passthrough, "non-tls"},
24-
{"unlisted port", "api.example.com", "93.184.216.34", 9999, tlsHello, Passthrough, "port"},
25-
{"internal under external scope", "tool.svc", "10.96.1.2", 443, tlsHello, Passthrough, "in-cluster"},
26-
{"skip-listed host", "pinned.example.com", "1.2.3.4", 443, tlsHello, Passthrough, "skip"},
27-
{"short record (<5 bytes)", "api.example.com", "1.2.3.4", 443, []byte{0x16, 0x03}, Passthrough, "non-tls"},
19+
{"happy https", "api.example.com", 443, tlsHello, Terminate, ""},
20+
{"happy 8443", "api.example.com", 8443, tlsHello, Terminate, ""},
21+
{"non-tls first byte", "api.example.com", 443, []byte("GET / "), Passthrough, "non-tls"},
22+
{"unlisted port", "api.example.com", 9999, tlsHello, Passthrough, "port"},
23+
{"skip-listed host", "pinned.example.com", 443, tlsHello, Passthrough, "skip"},
24+
{"short record (<5 bytes)", "api.example.com", 443, []byte{0x16, 0x03}, Passthrough, "non-tls"},
2825
}
2926
for _, tc := range cases {
3027
t.Run(tc.name, func(t *testing.T) {
31-
v, reason := d.Classify(tc.host, tc.ip, tc.port, tc.first)
28+
v, reason := d.Classify(tc.host, tc.port, tc.first)
3229
if v != tc.expect || reason != tc.reason {
3330
t.Errorf("got (%v,%q), want (%v,%q)", v, reason, tc.expect, tc.reason)
3431
}
3532
})
3633
}
3734
}
3835

39-
func TestDecision_ScopeAll(t *testing.T) {
40-
d := NewDecision(DecisionOpts{Scope: ScopeAll, InternalCIDRs: []string{"10.0.0.0/8"}})
41-
tlsHello := []byte{0x16, 0x03, 0x01, 0x00, 0x05}
42-
// scope=all does NOT passthrough internal destinations.
43-
if v, reason := d.Classify("tool.svc", "10.96.1.2", 443, tlsHello); v != Terminate || reason != "" {
44-
t.Errorf("got (%v,%q), want (%v,%q)", v, reason, Terminate, "")
45-
}
46-
}
47-
4836
func TestDecision_DefaultPortsWhenNil(t *testing.T) {
4937
d := NewDecision(DecisionOpts{}) // nil Ports -> {443,8443}
5038
tlsHello := []byte{0x16, 0x03, 0x01, 0x00, 0x05}
51-
if v, reason := d.Classify("api.example.com", "1.2.3.4", 443, tlsHello); v != Terminate || reason != "" {
39+
if v, reason := d.Classify("api.example.com", 443, tlsHello); v != Terminate || reason != "" {
5240
t.Errorf("port 443: got (%v,%q), want (%v,%q)", v, reason, Terminate, "")
5341
}
54-
if v, reason := d.Classify("api.example.com", "1.2.3.4", 80, tlsHello); v != Passthrough || reason != "port" {
42+
if v, reason := d.Classify("api.example.com", 80, tlsHello); v != Passthrough || reason != "port" {
5543
t.Errorf("port 80: got (%v,%q), want (%v,%q)", v, reason, Passthrough, "port")
5644
}
5745
}
5846

59-
func TestDecision_MultiCIDR(t *testing.T) {
60-
d := NewDecision(DecisionOpts{
61-
Scope: ScopeExternal,
62-
InternalCIDRs: []string{"10.0.0.0/8", "192.168.0.0/16"},
63-
})
64-
tlsHello := []byte{0x16, 0x03, 0x01, 0x00, 0x05}
65-
// IP matching the SECOND cidr exercises the loop past the first entry.
66-
if v, reason := d.Classify("tool.svc", "192.168.1.5", 443, tlsHello); v != Passthrough || reason != "in-cluster" {
67-
t.Errorf("got (%v,%q), want (%v,%q)", v, reason, Passthrough, "in-cluster")
68-
}
69-
}
70-
7147
func TestSkipSet_AutoSkip(t *testing.T) {
7248
s := NewSkipSet()
7349
if s.Contains("h") {

0 commit comments

Comments
 (0)