Skip to content

Commit c81c70f

Browse files
committed
BUG/MINOR: extract TLS passthrough rules to a helper function
Extract the construction of TCP request rules, backend-switching rules, and ACLs for TLS passthrough from `newFrontend` into a new dedicated helper function `tlsPassthroughRules`. This change improves the readability and maintainability of the `newFrontend` function by reducing its length and isolating the TLS passthrough configuration logic.
1 parent e4ec195 commit c81c70f

2 files changed

Lines changed: 219 additions & 114 deletions

File tree

k8s/gate/haproxy/frontends.go

Lines changed: 127 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -208,120 +208,13 @@ func (b *HaproxyConfMgrImpl) newFrontend(vListenerName string, vListener *tree.V
208208
var aclList []*models.ACL
209209
switch {
210210
case vListener.ProtocolCategory == protocols.ProtocolCategoryTLS:
211-
// TLS Passthrough
212-
tcpRules = []*models.TCPRequestRule{
213-
{ // tcp-request content reject if !{ req.ssl_hello_type 1 }
214-
Type: "content",
215-
Action: "reject",
216-
Cond: "if",
217-
CondTest: "!{ req.ssl_hello_type 1 }",
218-
},
219-
{
220-
// tcp-request inspect-delay 50000
221-
Type: "inspect-delay",
222-
Timeout: new(int64(50000)),
223-
},
224-
{
225-
// tcp-request content set-var(sess.sni) req.ssl_sni
226-
Type: "content",
227-
Action: "set-var",
228-
VarName: "sni",
229-
VarScope: "sess",
230-
Expr: "req.ssl_sni",
231-
},
232-
{
233-
// tcp-request content set-var(sess.snireversed) var(sess.sni),lua.reverse_host
234-
Type: "content",
235-
Action: "set-var",
236-
VarName: "snireversed",
237-
VarScope: "sess",
238-
Expr: "var(sess.sni),lua.reverse_host",
239-
},
240-
// -------------------
241-
// Look for listener name: selected_listener_name
242-
{
243-
// tcp-request content set-var(sess.selected_listener_name) var(sess.sni),map(listener_exact_match)
244-
Type: "content",
245-
Action: "set-var",
246-
VarName: "selected_listener_name",
247-
VarScope: "sess",
248-
Expr: "var(sess.sni),map(" + listenerExactMatchMap.Path.FullPath() + ")",
249-
Metadata: map[string]any{"hug": "listener exact match selection"},
250-
},
251-
{
252-
// tcp-request content set-var(sess.selected_listener_name,ifnotexists) var(sess.snireversed),map_beg(listener_wildcard_match)
253-
Type: "content",
254-
Action: "set-var",
255-
VarName: "selected_listener_name,ifnotexists",
256-
VarScope: "sess",
257-
Expr: "var(sess.snireversed),map_beg(" + listenerWildcardMatchMap.Path.FullPath() + ")",
258-
Metadata: map[string]any{"hug": "listener wildcard match selection"},
259-
},
260-
// -------------------
261-
// Look for route name: selected_listener_route
262-
{
263-
// tcp-request content set-var(txn.TMP) var(txn.selected_listener_name),concat("/",sess.sni)
264-
Type: "content",
265-
Action: "set-var",
266-
VarName: "TMP",
267-
VarScope: "sess",
268-
Expr: "var(sess.selected_listener_name),concat(\"/\",sess.sni)",
269-
},
270-
{
271-
Type: "content",
272-
Action: "set-var",
273-
VarName: "selected_listener_route",
274-
VarScope: "sess",
275-
Expr: "var(sess.TMP),map(" + listenerRouteExactMatchMap.Path.FullPath() + ")",
276-
Metadata: map[string]any{"hug": "listener-route exact match selection"},
277-
},
278-
{
279-
// tcp-request content set-var(txn.TMP) var(txn.selected_listener_name),concat("/",txn.snireversed)
280-
Type: "content",
281-
Action: "set-var",
282-
VarName: "TMP",
283-
VarScope: "sess",
284-
Expr: "var(sess.selected_listener_name),concat(\"/\",sess.snireversed)",
285-
},
286-
{
287-
Type: "content",
288-
Action: "set-var",
289-
VarName: "selected_listener_route,ifnotexists",
290-
VarScope: "sess",
291-
Expr: "var(sess.TMP),map_beg(" + listenerRouteWildcardMatchMap.Path.FullPath() + ")",
292-
Metadata: map[string]any{"hug": "listener-route wildcard match selection"},
293-
},
294-
// -------------------
295-
// Look for backend: sni_match
296-
{
297-
// tcp-request content set-var(txn.sni_match) var(txn.selected_listener_route),map(sni.map)
298-
Type: "content",
299-
Action: "set-var",
300-
VarName: "sni_match",
301-
VarScope: "sess",
302-
Expr: "var(sess.selected_listener_route),map(" + sniMap.Path.FullPath() + ")",
303-
},
304-
}
305-
backendSwitchingRules = []*models.BackendSwitchingRule{
306-
{
307-
Name: "%[var(txn.backend)]",
308-
Cond: "if",
309-
CondTest: "route_is_json",
310-
},
311-
{
312-
Name: "%[var(sess.sni_match),field(1,.)]",
313-
},
314-
}
315-
aclList = []*models.ACL{
316-
{ // acl route_is_json var(txn.sni_match),bytes(0,1) -m str
317-
ACLName: "route_is_json",
318-
Criterion: "var(txn.sni_match),bytes(0,1)",
319-
Value: "-m str {",
320-
Metadata: map[string]any{
321-
"hug": "for lua routing",
322-
},
323-
},
324-
}
211+
tcpRules, backendSwitchingRules, aclList = tlsPassthroughRules(
212+
listenerExactMatchMap.Path.FullPath(),
213+
listenerWildcardMatchMap.Path.FullPath(),
214+
listenerRouteExactMatchMap.Path.FullPath(),
215+
listenerRouteWildcardMatchMap.Path.FullPath(),
216+
sniMap.Path.FullPath(),
217+
)
325218

326219
default:
327220
httpRules = []*models.HTTPRequestRule{
@@ -598,3 +491,123 @@ func (b *HaproxyConfMgrImpl) bindParams(_, bindName string, vListenerName string
598491
}
599492
return params
600493
}
494+
495+
// tlsPassthroughRules returns the TCP request rules, backend-switching rules
496+
// and ACLs used by the TLS-passthrough frontend. All variables referenced in
497+
// this rule set live in the sess scope — keep it that way: the route_is_json
498+
// ACL reads var(sess.sni_match), matching the scope in which sni_match is
499+
// written. Mixing sess and txn leaves the ACL branch permanently dead.
500+
func tlsPassthroughRules(
501+
listenerExactMatch, listenerWildcardMatch,
502+
listenerRouteExactMatch, listenerRouteWildcardMatch,
503+
sniMapPath string,
504+
) ([]*models.TCPRequestRule, []*models.BackendSwitchingRule, []*models.ACL) {
505+
tcpRules := []*models.TCPRequestRule{
506+
{ // tcp-request content reject if !{ req.ssl_hello_type 1 }
507+
Type: "content",
508+
Action: "reject",
509+
Cond: "if",
510+
CondTest: "!{ req.ssl_hello_type 1 }",
511+
},
512+
{
513+
// tcp-request inspect-delay 50000
514+
Type: "inspect-delay",
515+
Timeout: new(int64(50000)),
516+
},
517+
{
518+
// tcp-request content set-var(sess.sni) req.ssl_sni
519+
Type: "content",
520+
Action: "set-var",
521+
VarName: "sni",
522+
VarScope: "sess",
523+
Expr: "req.ssl_sni",
524+
},
525+
{
526+
// tcp-request content set-var(sess.snireversed) var(sess.sni),lua.reverse_host
527+
Type: "content",
528+
Action: "set-var",
529+
VarName: "snireversed",
530+
VarScope: "sess",
531+
Expr: "var(sess.sni),lua.reverse_host",
532+
},
533+
{
534+
// tcp-request content set-var(sess.selected_listener_name) var(sess.sni),map(listener_exact_match)
535+
Type: "content",
536+
Action: "set-var",
537+
VarName: "selected_listener_name",
538+
VarScope: "sess",
539+
Expr: "var(sess.sni),map(" + listenerExactMatch + ")",
540+
Metadata: map[string]any{"hug": "listener exact match selection"},
541+
},
542+
{
543+
// tcp-request content set-var(sess.selected_listener_name,ifnotexists) var(sess.snireversed),map_beg(listener_wildcard_match)
544+
Type: "content",
545+
Action: "set-var",
546+
VarName: "selected_listener_name,ifnotexists",
547+
VarScope: "sess",
548+
Expr: "var(sess.snireversed),map_beg(" + listenerWildcardMatch + ")",
549+
Metadata: map[string]any{"hug": "listener wildcard match selection"},
550+
},
551+
{
552+
// tcp-request content set-var(sess.TMP) var(sess.selected_listener_name),concat("/",sess.sni)
553+
Type: "content",
554+
Action: "set-var",
555+
VarName: "TMP",
556+
VarScope: "sess",
557+
Expr: "var(sess.selected_listener_name),concat(\"/\",sess.sni)",
558+
},
559+
{
560+
Type: "content",
561+
Action: "set-var",
562+
VarName: "selected_listener_route",
563+
VarScope: "sess",
564+
Expr: "var(sess.TMP),map(" + listenerRouteExactMatch + ")",
565+
Metadata: map[string]any{"hug": "listener-route exact match selection"},
566+
},
567+
{
568+
// tcp-request content set-var(sess.TMP) var(sess.selected_listener_name),concat("/",sess.snireversed)
569+
Type: "content",
570+
Action: "set-var",
571+
VarName: "TMP",
572+
VarScope: "sess",
573+
Expr: "var(sess.selected_listener_name),concat(\"/\",sess.snireversed)",
574+
},
575+
{
576+
Type: "content",
577+
Action: "set-var",
578+
VarName: "selected_listener_route,ifnotexists",
579+
VarScope: "sess",
580+
Expr: "var(sess.TMP),map_beg(" + listenerRouteWildcardMatch + ")",
581+
Metadata: map[string]any{"hug": "listener-route wildcard match selection"},
582+
},
583+
{
584+
// tcp-request content set-var(sess.sni_match) var(sess.selected_listener_route),map(sni.map)
585+
Type: "content",
586+
Action: "set-var",
587+
VarName: "sni_match",
588+
VarScope: "sess",
589+
Expr: "var(sess.selected_listener_route),map(" + sniMapPath + ")",
590+
},
591+
}
592+
backendSwitchingRules := []*models.BackendSwitchingRule{
593+
{
594+
Name: "%[var(txn.backend)]",
595+
Cond: "if",
596+
CondTest: "route_is_json",
597+
},
598+
{
599+
Name: "%[var(sess.sni_match),field(1,.)]",
600+
},
601+
}
602+
aclList := []*models.ACL{
603+
{ // acl route_is_json var(sess.sni_match),bytes(0,1) -m str {
604+
ACLName: "route_is_json",
605+
Criterion: "var(sess.sni_match),bytes(0,1)",
606+
Value: "-m str {",
607+
Metadata: map[string]any{
608+
"hug": "for lua routing",
609+
},
610+
},
611+
}
612+
return tcpRules, backendSwitchingRules, aclList
613+
}

k8s/gate/haproxy/frontends_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright 2025 HAProxy Technologies LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package haproxy
15+
16+
import (
17+
"regexp"
18+
"strings"
19+
"testing"
20+
)
21+
22+
// TestTLSPassthroughRulesScopeConsistency guards against scope drift between
23+
// the TCP set-var rules and the ACL that reads those variables. A mismatch
24+
// (e.g. writing sess.sni_match but reading var(txn.sni_match)) leaves the
25+
// route_is_json ACL permanently dead, silently disabling the lua-JSON
26+
// backend routing branch.
27+
func TestTLSPassthroughRulesScopeConsistency(t *testing.T) {
28+
tcpRules, _, aclList := tlsPassthroughRules(
29+
"/etc/haproxy/listener_exact_match.map",
30+
"/etc/haproxy/listener_wildcard_match.map",
31+
"/etc/haproxy/listener_route_exact_match.map",
32+
"/etc/haproxy/listener_route_wildcard_match.map",
33+
"/etc/haproxy/sni.map",
34+
)
35+
36+
// Collect the scope each variable is written in by the set-var rules.
37+
writeScope := map[string]string{}
38+
for _, r := range tcpRules {
39+
if r.Action != "set-var" {
40+
continue
41+
}
42+
// Strip the ",ifnotexists" flag from names like "selected_listener_route,ifnotexists".
43+
name, _, _ := strings.Cut(r.VarName, ",")
44+
writeScope[name] = r.VarScope
45+
}
46+
47+
if got := writeScope["sni_match"]; got != "sess" {
48+
t.Fatalf("sni_match must be written in sess scope, got %q", got)
49+
}
50+
51+
// Every var(scope.name) reference in every ACL criterion must match the
52+
// scope the variable was set in.
53+
varRef := regexp.MustCompile(`var\(([a-z]+)\.([A-Za-z_][A-Za-z0-9_]*)\)`)
54+
for _, acl := range aclList {
55+
for _, m := range varRef.FindAllStringSubmatch(acl.Criterion, -1) {
56+
refScope, varName := m[1], m[2]
57+
wantScope, ok := writeScope[varName]
58+
if !ok {
59+
// Variable not written by this rule set (e.g. txn.backend set
60+
// by lua). Skip — invariant only covers locally-written vars.
61+
continue
62+
}
63+
if refScope != wantScope {
64+
t.Errorf("ACL %q criterion %q references var(%s.%s) but the variable is written in %s scope",
65+
acl.ACLName, acl.Criterion, refScope, varName, wantScope)
66+
}
67+
}
68+
}
69+
}
70+
71+
// TestTLSPassthroughRulesRouteIsJSONReadsSessScope is an explicit regression
72+
// test for the blocker where the route_is_json ACL read var(txn.sni_match)
73+
// while the rule set writes sess.sni_match.
74+
func TestTLSPassthroughRulesRouteIsJSONReadsSessScope(t *testing.T) {
75+
_, _, aclList := tlsPassthroughRules("a", "b", "c", "d", "e")
76+
var found bool
77+
for _, acl := range aclList {
78+
if acl.ACLName != "route_is_json" {
79+
continue
80+
}
81+
found = true
82+
if !regexp.MustCompile(`var\(sess\.sni_match\)`).MatchString(acl.Criterion) {
83+
t.Errorf("route_is_json must read var(sess.sni_match); got criterion %q", acl.Criterion)
84+
}
85+
if regexp.MustCompile(`var\(txn\.sni_match\)`).MatchString(acl.Criterion) {
86+
t.Errorf("route_is_json still reads var(txn.sni_match); criterion %q", acl.Criterion)
87+
}
88+
}
89+
if !found {
90+
t.Fatal("route_is_json ACL not found in TLS passthrough rule set")
91+
}
92+
}

0 commit comments

Comments
 (0)