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
| 1 | package main |
| 2 | |
| 3 | import ( |
| 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 | |
| 15 | func 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 | |
| 24 | func 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 | |
| 34 | func 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 | |
| 88 | func 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 | |
| 106 | func 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 | |
| 139 | func 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 | } |