Skip to content

Commit 5f28571

Browse files
committed
fix(traffic): populate u32 selector for DSCP/protocol filters
translateFilter built netlink.U32 with ClassId but no TcU32Sel match selector. DSCP/protocol filters configured via traffic-control reached the kernel matching nothing, silently failing to classify. Fix: populate TcU32Sel with per-family keys (IPv4 TOS byte + IPv6 traffic class for DSCP; IPv4 protocol byte + IPv6 next-header for protocol). Each DSCP/protocol filter now produces two u32 filters (ETH_P_IP + ETH_P_IPV6). Also: extract shared DSCP name map to internal/core/dscp (used by both firewall and traffic config parsers), add named DSCP support (cs6, ef, etc.) to traffic match config, add protocol value validation (0-255).
1 parent c09e967 commit 5f28571

12 files changed

Lines changed: 1017 additions & 46 deletions

File tree

‎internal/component/firewall/config.go‎

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"strconv"
1515
"strings"
1616

17+
"codeberg.org/thomas-mangin/ze/internal/core/dscp"
1718
"codeberg.org/thomas-mangin/ze/internal/core/textbuf"
1819
)
1920

@@ -787,29 +788,8 @@ func parseUint32(s string) (uint32, error) {
787788
return uint32(n), nil
788789
}
789790

790-
// dscpNames maps symbolic DSCP names to numeric values.
791-
var dscpNames = map[string]uint8{
792-
"ef": 46,
793-
"af11": 10, "af12": 12, "af13": 14,
794-
"af21": 18, "af22": 20, "af23": 22,
795-
"af31": 26, "af32": 28, "af33": 30,
796-
"af41": 34, "af42": 36, "af43": 38,
797-
"cs0": 0, "cs1": 8, "cs2": 16, "cs3": 24,
798-
"cs4": 32, "cs5": 40, "cs6": 48, "cs7": 56,
799-
}
800-
801791
func parseDSCP(v string) (uint8, error) {
802-
if n, ok := dscpNames[strings.ToLower(v)]; ok {
803-
return n, nil
804-
}
805-
n, err := strconv.ParseUint(v, 10, 8)
806-
if err != nil {
807-
return 0, fmt.Errorf("invalid dscp %q", v)
808-
}
809-
if n > 63 {
810-
return 0, fmt.Errorf("dscp value %d out of range (0-63)", n)
811-
}
812-
return uint8(n), nil
792+
return dscp.Parse(v)
813793
}
814794

815795
// byteRateMultiplier encodes the {value-less} prefix multiplier for the

‎internal/component/traffic/config.go‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"fmt"
1111
"strconv"
1212
"strings"
13+
14+
"codeberg.org/thomas-mangin/ze/internal/core/dscp"
1315
)
1416

1517
var errEmptyFilterValue = errors.New("empty filter value")
@@ -171,10 +173,19 @@ func parseFilterValue(ft FilterType, v string) (uint32, error) {
171173
switch ft { //nolint:exhaustive // filterUnknown rejected by ParseFilterType before reaching here
172174
case FilterMark:
173175
return parseHexOrDec(v)
174-
case FilterDSCP, FilterProtocol:
175-
n, err := strconv.ParseUint(v, 10, 32)
176+
case FilterDSCP:
177+
n, err := dscp.Parse(v)
176178
if err != nil {
177-
return 0, fmt.Errorf("invalid value %q: %w", v, err)
179+
return 0, fmt.Errorf("filter dscp: %w", err)
180+
}
181+
return uint32(n), nil
182+
case FilterProtocol:
183+
n, err := strconv.ParseUint(v, 10, 16)
184+
if err != nil {
185+
return 0, fmt.Errorf("invalid protocol %q: %w", v, err)
186+
}
187+
if n > 255 {
188+
return 0, fmt.Errorf("protocol value %d out of range (0-255)", n)
178189
}
179190
return uint32(n), nil
180191
case filterUnknown:

‎internal/component/traffic/config_test.go‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,45 @@ func TestParseTrafficInvalidRate(t *testing.T) {
8484
t.Fatal("expected error for invalid rate")
8585
}
8686
}
87+
88+
func TestParseTrafficNamedDSCP(t *testing.T) {
89+
data := `{"traffic-control":{"interface":{"eth0":{"qdisc":{"type":"htb","default-class":"default","class":{"control":{"rate":"10mbit","ceil":"100mbit","priority":"0","match":{"dscp":{"value":"cs6"}}},"default":{"rate":"90mbit","ceil":"100mbit","priority":"1"}}}}}}}`
90+
qosMap, err := ParseTrafficConfig(data)
91+
if err != nil {
92+
t.Fatalf("ParseTrafficConfig: %v", err)
93+
}
94+
qos := qosMap["eth0"]
95+
for _, cls := range qos.Qdisc.Classes {
96+
if cls.Name != "control" {
97+
continue
98+
}
99+
if len(cls.Filters) != 1 {
100+
t.Fatalf("control class: got %d filters, want 1", len(cls.Filters))
101+
}
102+
f := cls.Filters[0]
103+
if f.Type != FilterDSCP {
104+
t.Fatalf("filter type = %v, want FilterDSCP", f.Type)
105+
}
106+
if f.Value != 48 {
107+
t.Errorf("filter value = %d, want 48 (cs6)", f.Value)
108+
}
109+
return
110+
}
111+
t.Fatal("control class not found")
112+
}
113+
114+
func TestParseTrafficDSCPRejectsOutOfRange(t *testing.T) {
115+
data := `{"traffic-control":{"interface":{"eth0":{"qdisc":{"type":"htb","class":{"c":{"rate":"10mbit","match":{"dscp":{"value":"64"}}}}}}}}}`
116+
_, err := ParseTrafficConfig(data)
117+
if err == nil {
118+
t.Fatal("expected error for dscp value 64")
119+
}
120+
}
121+
122+
func TestParseTrafficProtocolRejectsOutOfRange(t *testing.T) {
123+
data := `{"traffic-control":{"interface":{"eth0":{"qdisc":{"type":"htb","class":{"c":{"rate":"10mbit","match":{"protocol":{"value":"256"}}}}}}}}}`
124+
_, err := ParseTrafficConfig(data)
125+
if err == nil {
126+
t.Fatal("expected error for protocol value 256")
127+
}
128+
}

‎internal/core/dscp/dscp.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Design: docs/architecture/core-design.md -- DSCP name-to-value map
2+
3+
package dscp
4+
5+
import (
6+
"fmt"
7+
"strconv"
8+
"strings"
9+
)
10+
11+
const MaxValue = 63
12+
13+
var byName = map[string]uint8{
14+
"ef": 46,
15+
"af11": 10, "af12": 12, "af13": 14,
16+
"af21": 18, "af22": 20, "af23": 22,
17+
"af31": 26, "af32": 28, "af33": 30,
18+
"af41": 34, "af42": 36, "af43": 38,
19+
"cs0": 0, "cs1": 8, "cs2": 16, "cs3": 24,
20+
"cs4": 32, "cs5": 40, "cs6": 48, "cs7": 56,
21+
}
22+
23+
// Parse accepts a named DSCP value (e.g. "cs6", "ef") or a decimal
24+
// integer and returns the numeric DSCP value (0-63).
25+
func Parse(v string) (uint8, error) {
26+
if n, ok := byName[strings.ToLower(v)]; ok {
27+
return n, nil
28+
}
29+
n, err := strconv.ParseUint(v, 10, 8)
30+
if err != nil {
31+
return 0, fmt.Errorf("invalid dscp %q", v)
32+
}
33+
if n > MaxValue {
34+
return 0, fmt.Errorf("dscp value %d out of range (0-%d)", n, MaxValue)
35+
}
36+
return uint8(n), nil
37+
}

‎internal/core/dscp/dscp_test.go‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package dscp
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestParseNamedDSCP(t *testing.T) {
8+
tests := []struct {
9+
input string
10+
want uint8
11+
}{
12+
{"cs6", 48},
13+
{"CS6", 48},
14+
{"ef", 46},
15+
{"EF", 46},
16+
{"cs0", 0},
17+
{"af11", 10},
18+
{"af43", 38},
19+
{"cs7", 56},
20+
{"48", 48},
21+
{"0", 0},
22+
{"63", 63},
23+
}
24+
for _, tt := range tests {
25+
t.Run(tt.input, func(t *testing.T) {
26+
got, err := Parse(tt.input)
27+
if err != nil {
28+
t.Fatalf("Parse(%q): %v", tt.input, err)
29+
}
30+
if got != tt.want {
31+
t.Errorf("Parse(%q) = %d, want %d", tt.input, got, tt.want)
32+
}
33+
})
34+
}
35+
}
36+
37+
func TestParseRejectsInvalid(t *testing.T) {
38+
tests := []string{"64", "255", "bogus", ""}
39+
for _, input := range tests {
40+
t.Run(input, func(t *testing.T) {
41+
_, err := Parse(input)
42+
if err == nil {
43+
t.Fatalf("Parse(%q): want error, got nil", input)
44+
}
45+
})
46+
}
47+
}

‎internal/plugins/traffic/netlink/backend_linux.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,14 @@ func (b *backend) applyInterface(link netlink.Link, qos *traffic.InterfaceQoS) e
126126
// Add filters for this class.
127127
classHandle := class.Attrs().Handle
128128
for _, f := range tc.Filters {
129-
filter, err := translateFilter(f, linkIdx, rootHandle, classHandle)
129+
filters, err := translateFilter(f, linkIdx, rootHandle, classHandle)
130130
if err != nil {
131131
return fmt.Errorf("class %q filter: %w", tc.Name, err)
132132
}
133-
if err := b.ops.filterAdd(filter); err != nil {
134-
return fmt.Errorf("class %q filter add: %w", tc.Name, err)
133+
for _, filter := range filters {
134+
if err := b.ops.filterAdd(filter); err != nil {
135+
return fmt.Errorf("class %q filter add: %w", tc.Name, err)
136+
}
135137
}
136138
}
137139
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Design: plan/spec-cp-survival-3-egress-cs6-sched.md -- CS6 classification integration test
2+
3+
//go:build integration && linux
4+
5+
package trafficnetlink
6+
7+
import (
8+
"context"
9+
"net"
10+
"path/filepath"
11+
"syscall"
12+
"testing"
13+
14+
"github.com/vishvananda/netlink"
15+
16+
"codeberg.org/thomas-mangin/ze/internal/component/traffic"
17+
)
18+
19+
func setTOS(fd uintptr, tos int) error {
20+
return syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IP, syscall.IP_TOS, tos)
21+
}
22+
23+
// VALIDATES: spec-cp-survival-3 AC-2 -- CS6-marked packets hit the control
24+
// class counter (classification works after the translateFilter fix).
25+
// PREVENTS: regression to the broken state where U32 had no Sel and matched nothing.
26+
func TestCS6ClassifyNetns(t *testing.T) {
27+
withTrafficNetNS(t, func() {
28+
const ifaceName = "ze_cs0"
29+
link := addTrafficVeth(t, ifaceName, "ze_cs1")
30+
31+
path := filepath.Join(t.TempDir(), "state", "traffic-tc-snapshots.json")
32+
b := newBackendWithOps(netlinkOps{}, path, nil, "boot-1", nil)
33+
34+
desired := map[string]traffic.InterfaceQoS{
35+
ifaceName: {
36+
Interface: ifaceName,
37+
Qdisc: traffic.Qdisc{
38+
Type: traffic.QdiscHTB,
39+
DefaultClass: "default",
40+
Classes: []traffic.TrafficClass{
41+
{
42+
Name: "control",
43+
Rate: 1_000_000,
44+
Ceil: 10_000_000,
45+
Priority: 0,
46+
Filters: []traffic.TrafficFilter{
47+
{Type: traffic.FilterDSCP, Value: 48}, // CS6
48+
},
49+
},
50+
{
51+
Name: "default",
52+
Rate: 1_000_000,
53+
Ceil: 10_000_000,
54+
Priority: 1,
55+
},
56+
},
57+
},
58+
},
59+
}
60+
if err := b.Apply(context.Background(), desired); err != nil {
61+
t.Fatalf("Apply: %v", err)
62+
}
63+
64+
if got := rootQdiscTypeInKernel(t, ifaceName); got != "htb" {
65+
t.Fatalf("root qdisc = %q, want htb", got)
66+
}
67+
68+
filters, err := netlink.FilterList(link, netlink.HANDLE_ROOT)
69+
if err != nil {
70+
t.Fatalf("FilterList: %v", err)
71+
}
72+
73+
var u32Count int
74+
for _, f := range filters {
75+
if _, ok := f.(*netlink.U32); ok {
76+
u32Count++
77+
}
78+
}
79+
if u32Count == 0 {
80+
t.Fatal("no u32 filters installed (the bug: translateFilter produced U32 with no Sel)")
81+
}
82+
if u32Count < 2 {
83+
t.Errorf("u32 filter count = %d, want >= 2 (IPv4 + IPv6)", u32Count)
84+
}
85+
86+
peerLink, err := netlink.LinkByName("ze_cs1")
87+
if err != nil {
88+
t.Fatalf("link ze_cs1: %v", err)
89+
}
90+
91+
addr := &netlink.Addr{IPNet: &net.IPNet{
92+
IP: net.IPv4(10, 99, 0, 1),
93+
Mask: net.CIDRMask(24, 32),
94+
}}
95+
if err := netlink.AddrAdd(link, addr); err != nil {
96+
t.Fatalf("addr add ze_cs0: %v", err)
97+
}
98+
peerAddr := &netlink.Addr{IPNet: &net.IPNet{
99+
IP: net.IPv4(10, 99, 0, 2),
100+
Mask: net.CIDRMask(24, 32),
101+
}}
102+
if err := netlink.AddrAdd(peerLink, peerAddr); err != nil {
103+
t.Fatalf("addr add ze_cs1: %v", err)
104+
}
105+
106+
conn, err := net.DialUDP("udp4", &net.UDPAddr{IP: net.IPv4(10, 99, 0, 1)}, &net.UDPAddr{IP: net.IPv4(10, 99, 0, 2), Port: 9999})
107+
if err != nil {
108+
t.Fatalf("dial: %v", err)
109+
}
110+
defer conn.Close()
111+
112+
rawConn, err := conn.SyscallConn()
113+
if err != nil {
114+
t.Fatalf("SyscallConn: %v", err)
115+
}
116+
var setErr error
117+
rawConn.Control(func(fd uintptr) {
118+
setErr = setTOS(fd, 0xC0) // CS6 = DSCP 48, TOS = 0xC0
119+
})
120+
if setErr != nil {
121+
t.Fatalf("setsockopt IP_TOS: %v", setErr)
122+
}
123+
124+
payload := []byte("cs6test")
125+
for i := 0; i < 50; i++ {
126+
conn.Write(payload) //nolint:errcheck // best-effort: some may fail (no listener)
127+
}
128+
129+
classes, err := netlink.ClassList(link, netlink.HANDLE_ROOT)
130+
if err != nil {
131+
t.Fatalf("ClassList: %v", err)
132+
}
133+
134+
var controlStats *netlink.ClassStatistics
135+
for _, cls := range classes {
136+
htb, ok := cls.(*netlink.HtbClass)
137+
if !ok {
138+
continue
139+
}
140+
if htb.Handle == makeHandle(1, 1) {
141+
controlStats = htb.Statistics
142+
}
143+
}
144+
if controlStats == nil {
145+
t.Fatal("control class (1:1) not found in kernel")
146+
}
147+
if controlStats.Basic.Packets == 0 {
148+
t.Error("control class packet count = 0; CS6-marked packets were not classified (AC-2 fail)")
149+
}
150+
})
151+
}

0 commit comments

Comments
 (0)