Skip to content

Commit 2e76541

Browse files
committed
feat(tlsbridge): make intercepted TLS ports configurable (default 443,8443)
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 <huang195@gmail.com>
1 parent b9ca5af commit 2e76541

7 files changed

Lines changed: 167 additions & 3 deletions

File tree

authbridge/authlib/config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ type TLSBridgeConfig struct {
5555
CAKeyPath string `yaml:"ca_key_path" json:"ca_key_path"`
5656
UpstreamCABundle string `yaml:"upstream_ca_bundle" json:"upstream_ca_bundle"`
5757
SkipHosts []string `yaml:"skip_hosts" json:"skip_hosts"`
58+
// Ports is the set of TCP ports to intercept as TLS. Empty => {443, 8443}.
59+
// Only HTTP(S)-bearing ports belong here: the bridge serves the decrypted
60+
// stream as HTTP/1.1 or h2, so terminating a non-HTTP TLS protocol (LDAPS,
61+
// SMTPS, DB-over-TLS, …) would break it.
62+
Ports []int `yaml:"ports" json:"ports"`
5863
}
5964

6065
// Validate is called from the loader when TLSBridge != nil.
@@ -73,6 +78,11 @@ func (b *TLSBridgeConfig) Validate() error {
7378
return fmt.Errorf("tls_bridge.internal_cidrs: %q is not a valid CIDR: %w", c, err)
7479
}
7580
}
81+
for _, p := range b.Ports {
82+
if p < 1 || p > 65535 {
83+
return fmt.Errorf("tls_bridge.ports: %d is out of range 1-65535", p)
84+
}
85+
}
7686
return nil
7787
}
7888

authbridge/authlib/config/config_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,10 @@ func TestTLSBridgeConfig_Validate(t *testing.T) {
764764
{"file ca with paths", TLSBridgeConfig{CASource: "file", CACertPath: "/c", CAKeyPath: "/k"}, false},
765765
{"bad cidr typo", TLSBridgeConfig{InternalCIDRs: []string{"10.0.0.0/8", "10.0.0.0./8"}}, true},
766766
{"bad cidr missing mask", TLSBridgeConfig{InternalCIDRs: []string{"10.0.0.0"}}, 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},
767771
}
768772
for _, tc := range cases {
769773
t.Run(tc.name, func(t *testing.T) {

authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ func TestTransparentBridge(t *testing.T) {
135135
// side believes it captured a connection whose SO_ORIGINAL_DST is the
136136
// origin's host:port.
137137
clientSide, serverSide := net.Pipe()
138-
defer clientSide.Close()
138+
defer func() { _ = clientSide.Close() }()
139139

140140
done := make(chan struct{})
141141
go func() {
@@ -223,6 +223,119 @@ func TestTransparentBridge(t *testing.T) {
223223
}
224224
}
225225

226+
// TestTransparentBridge_CustomPort proves the configurable-ports path: the
227+
// origin runs on a RANDOM ephemeral port (NOT in shouldSniff's hardcoded
228+
// {80,443,8080,8443} set), and the bridge is configured (Decision.Ports) to
229+
// intercept exactly that port. Before the shouldSniff↔Decision.Ports unification
230+
// the transparent path would not sniff this port, so the bridge could never peek
231+
// and the call would tunnel (agent handshake would fail against the origin's real
232+
// cert). With the unification, HandlesPort(port) widens the sniff and the bridge
233+
// engages. Asserting decryption here is the regression test for that wiring.
234+
func TestTransparentBridge_CustomPort(t *testing.T) {
235+
var gotOriginPath string
236+
origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
237+
gotOriginPath = r.URL.Path
238+
if r.URL.Path == "/secret" {
239+
_, _ = w.Write([]byte("OK-SECRET"))
240+
return
241+
}
242+
w.WriteHeader(http.StatusNotFound)
243+
}))
244+
defer origin.Close()
245+
246+
originCAPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: origin.Certificate().Raw})
247+
originURL, err := url.Parse(origin.URL)
248+
if err != nil {
249+
t.Fatalf("parse origin URL: %v", err)
250+
}
251+
originHostPort := originURL.Host // 127.0.0.1:<random ephemeral port>
252+
customPort := portOf(originHostPort)
253+
if customPort == 443 || customPort == 8443 || customPort == 80 || customPort == 8080 {
254+
t.Skipf("random origin port %d collided with a default sniff port; rerun", customPort)
255+
}
256+
257+
src, err := tlsbridge.NewEphemeralSource()
258+
if err != nil {
259+
t.Fatalf("NewEphemeralSource: %v", err)
260+
}
261+
minter := tlsbridge.NewMinter(src, tlsbridge.MinterOpts{})
262+
up, err := tlsbridge.NewUpstreamClient(originCAPEM)
263+
if err != nil {
264+
t.Fatalf("NewUpstreamClient: %v", err)
265+
}
266+
engine := &tlsbridge.Engine{
267+
// Only the custom port is configured — proves both that it IS bridged
268+
// (despite shouldSniff not knowing it) and that the default set is replaced.
269+
Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{
270+
Scope: tlsbridge.ScopeAll,
271+
Ports: map[int]bool{customPort: true},
272+
}),
273+
Term: tlsbridge.NewTerminator(minter),
274+
Skip: tlsbridge.NewSkipSet(),
275+
Upstream: up,
276+
CAPEM: src.CACertPEM(),
277+
}
278+
279+
probe := &bridgeProbePlugin{}
280+
p, err := plugintesting.BuildPipeline([]pipeline.Plugin{probe})
281+
if err != nil {
282+
t.Fatalf("BuildPipeline: %v", err)
283+
}
284+
srv := &Server{OutboundPipeline: pipeline.NewHolder(p), Client: http.DefaultClient, TLSBridge: engine}
285+
286+
clientSide, serverSide := net.Pipe()
287+
defer func() { _ = clientSide.Close() }()
288+
done := make(chan struct{})
289+
go func() {
290+
defer close(done)
291+
srv.HandleTransparentConn(serverSide, originHostPort)
292+
}()
293+
294+
pool := x509.NewCertPool()
295+
if !pool.AppendCertsFromPEM(engine.CAPEM) {
296+
t.Fatalf("append bridge CA")
297+
}
298+
host := hostOnly(originHostPort)
299+
tconn := tls.Client(clientSide, &tls.Config{ServerName: host, RootCAs: pool, NextProtos: []string{"http/1.1"}})
300+
_ = tconn.SetDeadline(time.Now().Add(10 * time.Second))
301+
if err := tconn.Handshake(); err != nil {
302+
t.Fatalf("agent handshake through bridge on custom port failed: %v", err)
303+
}
304+
req, err := http.NewRequest(http.MethodGet, "https://"+host+"/secret", nil)
305+
if err != nil {
306+
t.Fatalf("new request: %v", err)
307+
}
308+
writeErr := make(chan error, 1)
309+
go func() { writeErr <- req.Write(tconn) }()
310+
resp, err := http.ReadResponse(bufio.NewReader(tconn), req)
311+
if err != nil {
312+
t.Fatalf("read response on custom-port bridge: %v", err)
313+
}
314+
defer resp.Body.Close()
315+
body, _ := io.ReadAll(resp.Body)
316+
if werr := <-writeErr; werr != nil {
317+
t.Fatalf("write request: %v", werr)
318+
}
319+
320+
if probe.gotPath != "/secret" {
321+
t.Errorf("probe path = %q, want /secret (bridge did not engage on custom port)", probe.gotPath)
322+
}
323+
if gotOriginPath != "/secret" {
324+
t.Errorf("origin path = %q, want /secret", gotOriginPath)
325+
}
326+
if got := strings.TrimSpace(string(body)); got != "OK-SECRET" {
327+
t.Errorf("body = %q, want OK-SECRET", got)
328+
}
329+
330+
_ = clientSide.Close()
331+
_ = serverSide.Close()
332+
select {
333+
case <-done:
334+
case <-time.After(5 * time.Second):
335+
t.Fatalf("HandleTransparentConn did not return")
336+
}
337+
}
338+
226339
// TestConnectBridge drives the full CONNECT → 200 → agent-TLS → decrypt →
227340
// pipeline → re-originate loop through the PUBLIC forward-proxy HTTP handler
228341
// (so the real net/http Hijack path runs). An httptest TLS origin stands in

authbridge/authlib/listener/forwardproxy/transparent.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,12 @@ func (s *Server) HandleTransparentConn(clientConn net.Conn, dst string) {
6666
// HTTP/TLS ports so non-HTTP protocols are not delayed by the peek. The dial
6767
// target stays dst (the IP); only pctx.Host gets the recovered name.
6868
host := dst
69-
if shouldSniff(dst) {
69+
// Sniff on the standard HTTP/TLS ports, OR on whatever ports the TLS bridge
70+
// is configured to intercept — so a configured non-standard bridge port
71+
// (e.g. 9443) still gets the peekable conn the bridge branch needs. The
72+
// bridge's own port set is the single source of truth (no drift with
73+
// shouldSniff's heuristic list).
74+
if shouldSniff(dst) || (s.TLSBridge != nil && s.TLSBridge.Decision.HandlesPort(portOf(dst))) {
7075
name, wrapped := sniffHost(clientConn)
7176
clientConn = wrapped
7277
if name != "" {

authbridge/authlib/tlsbridge/decision.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ func NewDecision(o DecisionOpts) *Decision {
4949
return d
5050
}
5151

52+
// HandlesPort reports whether port is in the bridge's interception set. It is
53+
// the single source of truth for "which ports the bridge cares about" — the
54+
// transparent listener consults it so it sniffs (and thus can bridge) exactly
55+
// the configured ports, never drifting from Classify's port gate.
56+
func (d *Decision) HandlesPort(port int) bool { return d.ports[port] }
57+
5258
// Classify decides whether to bridge. first is the peeked client bytes.
5359
func (d *Decision) Classify(host, ip string, port int, first []byte) (Verdict, string) {
5460
if !d.ports[port] {

authbridge/authlib/tlsbridge/decision_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,22 @@ func TestSkipSet_AutoSkip(t *testing.T) {
7878
t.Error("Add then Contains failed")
7979
}
8080
}
81+
82+
func TestDecision_HandlesPort(t *testing.T) {
83+
// Default set when Ports is nil.
84+
d := NewDecision(DecisionOpts{})
85+
if !d.HandlesPort(443) || !d.HandlesPort(8443) {
86+
t.Error("default set must handle 443 and 8443")
87+
}
88+
if d.HandlesPort(9443) {
89+
t.Error("default set must not handle 9443")
90+
}
91+
// Custom set replaces the default.
92+
c := NewDecision(DecisionOpts{Ports: map[int]bool{9443: true}})
93+
if !c.HandlesPort(9443) {
94+
t.Error("custom set must handle 9443")
95+
}
96+
if c.HandlesPort(443) {
97+
t.Error("custom set must not handle 443 (it replaces, not augments, the default)")
98+
}
99+
}

authbridge/cmd/authbridge-proxy/main.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,16 @@ func main() {
278278
if cfg.TLSBridge.Scope == "all" {
279279
scope = tlsbridge.ScopeAll
280280
}
281+
var ports map[int]bool // nil => NewDecision defaults to {443, 8443}
282+
if len(cfg.TLSBridge.Ports) > 0 {
283+
ports = make(map[int]bool, len(cfg.TLSBridge.Ports))
284+
for _, p := range cfg.TLSBridge.Ports {
285+
ports[p] = true
286+
}
287+
}
281288
bridge = &tlsbridge.Engine{
282289
Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{
283-
Scope: scope, InternalCIDRs: cfg.TLSBridge.InternalCIDRs, SkipHosts: cfg.TLSBridge.SkipHosts,
290+
Scope: scope, Ports: ports, InternalCIDRs: cfg.TLSBridge.InternalCIDRs, SkipHosts: cfg.TLSBridge.SkipHosts,
284291
}),
285292
Term: tlsbridge.NewTerminator(minter),
286293
Skip: tlsbridge.NewSkipSet(),

0 commit comments

Comments
 (0)