ALL

For reading and verification only. You may not copy, use, modify or redistribute this code. Full notice on the main page.

tunnel/nftables_test.go

SHA-256: 01d33feb6f88072f915d1b350ed9457df2c051c26dceb7bc3ead6a230f4d4e6c
144 lines · 5601 bytes · View on GitHub

1package main
2
3import (
4 "os"
5 "regexp"
6 "strconv"
7 "strings"
8 "testing"
9)
10
11// deploy/nftables.conf and deploy/sysctl.conf cannot be run on the machines the tests run on (no nftables, no root), so what is tested is what can be:
12// that the kernel's limits are never stricter than the node's own (a retuned policy must not leave the kernel refusing what the node would serve), that
13// the file only adds drops, and that it touches no port but the node's two.
14
15func readDeploy(t *testing.T, name string) string {
16 t.Helper()
17 raw, err := os.ReadFile("deploy/" + name)
18 if err != nil {
19 t.Fatal(err)
20 }
21 return string(raw)
22}
23
24func nftLines(conf string) []string {
25 var out []string
26 for _, line := range strings.Split(conf, "\n") {
27 if line = strings.TrimSpace(line); line != "" && !strings.HasPrefix(line, "#") {
28 out = append(out, line)
29 }
30 }
31 return out
32}
33
34func TestNftables_TheKernelNeverRefusesWhatTheNodeWouldServe(t *testing.T) {
35 conf := readDeploy(t, "nftables.conf")
36 l := policyData.NodeLimits
37 factor := max(l.OpenAIPerIPFactor, l.DevicePerIPFactor)
38 perSourceHeld := l.PerIPConcurrent * factor
39 perSourceRate := l.PerIPNewPerS * factor
40 numbers := func(re string) []int {
41 var out []int
42 for _, m := range regexp.MustCompile(re).FindAllStringSubmatch(conf, -1) {
43 n, _ := strconv.Atoi(m[1])
44 out = append(out, n)
45 }
46 return out
47 }
48 held := numbers(`tcp dport 443 ct state new add @held[46] \{[^}]*ct count over (\d+)`)
49 rate := numbers(`tcp dport 443 ct state new add @rate[46] \{[^}]*limit rate over (\d+)/second`)
50 burst := numbers(`tcp dport 443 ct state new add @rate[46] \{[^}]*burst (\d+) packets`)
51 if len(held) != 2 || len(rate) != 2 || len(burst) != 2 {
52 t.Fatalf("the rules of :443 per source (IPv4 and IPv6 each): held %v rate %v burst %v", held, rate, burst)
53 }
54 for _, n := range held {
55 // the kernel counts a device's data connections too (one per open stream, the node frees its own slot at attach): room for one device at its cap
56 if n < l.LabelStreamsMax+1 {
57 t.Errorf("the kernel holds a source to %d connections, a device at its cap of %d streams holds %d", n, l.LabelStreamsMax, l.LabelStreamsMax+1)
58 }
59 if n < perSourceHeld {
60 t.Errorf("the kernel holds a source to %d connections, the node allows an OpenAI address %d", n, perSourceHeld)
61 }
62 }
63 for _, n := range rate {
64 if n < perSourceRate {
65 t.Errorf("the kernel allows %d new connections a second per source, the node allows an OpenAI address %d", n, perSourceRate)
66 }
67 }
68 for i, n := range burst {
69 if n < rate[i] {
70 t.Errorf("a burst of %d is smaller than the rate %d", n, rate[i])
71 }
72 }
73 // an IPv6 source is the policy's prefix: the mask of the rules is that many bits
74 mask := regexp.MustCompile(`ip6 saddr & ((?:[0-9a-f]{4}:)+:)`).FindStringSubmatch(conf)
75 if mask == nil || strings.Count(mask[1], "ffff") != l.IPv6PrefixBits/16 || l.IPv6PrefixBits%16 != 0 {
76 t.Errorf("the IPv6 mask %v is not the policy's %d bits", mask, l.IPv6PrefixBits)
77 }
78 // the whole node: the global flood limit is above everything the node could serve at once
79 if all := numbers(`tcp dport \{ 80, 443 \} ct state new limit rate over (\d+)/second`); len(all) != 1 || all[0] < l.GlobalStreamsMax {
80 t.Errorf("the global limit %v must exceed the node's %d streams", all, l.GlobalStreamsMax)
81 }
82 // port 80 serves one webroot: its limits are small, and the rules for it exist for IPv4 and IPv6
83 if h80 := numbers(`tcp dport 80 ct state new add @held80_[46] \{[^}]*ct count over (\d+)`); len(h80) != 2 || h80[0] > held[0] {
84 t.Errorf("port 80 holds %v", h80)
85 }
86}
87
88func TestNftables_LoadingTheFileTwiceDoesNotStackItsRules(t *testing.T) {
89 lines := nftLines(readDeploy(t, "nftables.conf"))
90 var ensure, drop, body = -1, -1, -1
91 for i, line := range lines {
92 switch {
93 case line == "table inet all_tunnel" && ensure < 0:
94 ensure = i
95 case line == "delete table inet all_tunnel":
96 drop = i
97 case line == "table inet all_tunnel {":
98 body = i
99 }
100 }
101 if ensure < 0 || drop != ensure+1 || body != drop+1 {
102 t.Fatalf("want 'table', 'delete table' and the table's body in a row, got lines %d, %d, %d", ensure, drop, body)
103 }
104}
105
106func TestNftables_OnlyAddsDropsAndTouchesOnlyThePortsOfTheNode(t *testing.T) {
107 conf := readDeploy(t, "nftables.conf")
108 if !strings.Contains(conf, "policy accept") || strings.Contains(conf, "policy drop") {
109 t.Fatal("the chain must accept by default: loading the fragment must never lock the operator out")
110 }
111 for _, line := range nftLines(conf) {
112 if !strings.Contains(line, "dport") {
113 continue
114 }
115 if !regexp.MustCompile(`^tcp dport (443|80|\{ 80, 443 \}) `).MatchString(line) {
116 t.Errorf("a rule on a port that is not the node's: %s", line)
117 }
118 if !strings.HasSuffix(line, "drop") {
119 t.Errorf("a rule that is not a drop: %s", line)
120 }
121 }
122 // every set the rules name is declared, and every set declared is used
123 declared := regexp.MustCompile(`set (\w+) \{`).FindAllStringSubmatch(conf, -1)
124 for _, m := range declared {
125 if strings.Count(conf, "@"+m[1]+" ") == 0 {
126 t.Errorf("set %s is declared and never used", m[1])
127 }
128 }
129 for _, m := range regexp.MustCompile(`@(\w+) `).FindAllStringSubmatch(conf, -1) {
130 if !strings.Contains(conf, "set "+m[1]+" {") {
131 t.Errorf("set %s is used and never declared", m[1])
132 }
133 }
134 if strings.Count(conf, "{")-strings.Count(conf, "}") != 0 {
135 t.Error("unbalanced braces")
136 }
137}
138
139func TestSysctl_SYNCookiesAreOn(t *testing.T) {
140 conf := readDeploy(t, "sysctl.conf")
141 if !regexp.MustCompile(`(?m)^net\.ipv4\.tcp_syncookies\s*=\s*1\s*$`).MatchString(conf) {
142 t.Fatal("net.ipv4.tcp_syncookies = 1 is not set")
143 }
144}