1// SPDX-License-Identifier: MIT
2
3package netpolicy
4
5import (
6 "context"
7 "errors"
8 "flag"
9 "io"
10 "net/netip"
11 "os"
12 "path/filepath"
13 "strings"
14 "testing"
15
16 "bnema.dev/ermite-runner/internal/cmdrun"
17)
18
19var update = flag.Bool("update", false, "rewrite golden files")
20
21func golden(t *testing.T, name, got string) {
22 t.Helper()
23 p := filepath.Join("testdata", name)
24 if *update {
25 if err := os.MkdirAll("testdata", 0o755); err != nil {
26 t.Fatal(err)
27 }
28 if err := os.WriteFile(p, []byte(got), 0o644); err != nil {
29 t.Fatal(err)
30 }
31 }
32 want, err := os.ReadFile(p)
33 if err != nil {
34 t.Fatal(err)
35 }
36 if string(want) != got {
37 t.Fatalf("%s differs from golden (run with -update to refresh):\n%s", name, got)
38 }
39}
40
41func guest(t *testing.T, iface string, slot int) Guest {
42 t.Helper()
43 n, err := Subnet(netip.MustParsePrefix("10.200.0.0/16"), slot)
44 if err != nil {
45 t.Fatal(err)
46 }
47 return Guest{Iface: iface, Gateway: n.Gateway, Addr: n.Guest}
48}
49
50func TestRulesetGolden(t *testing.T) {
51 golden(t, "empty.nft", Ruleset(nil))
52 golden(t, "one.nft", Ruleset([]Guest{guest(t, "ertap0", 0)}))
53 // Order of input must not matter.
54 two := Ruleset([]Guest{guest(t, "erbr1", 1), guest(t, "ertap0", 0)})
55 if two != Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "erbr1", 1)}) {
56 t.Fatal("ruleset depends on guest order")
57 }
58 golden(t, "two.nft", two)
59}
60
61func TestRulesetPolicyInvariants(t *testing.T) {
62 rs := Ruleset([]Guest{guest(t, "ertap0", 0)})
63 for _, want := range []string{
64 "169.254.0.0/16", "100.64.0.0/10", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
65 `iifname "ertap0" drop`, "dport 53 accept", "masquerade", `iifname "ertap*" meta nfproto ipv6 drop`,
66 } {
67 if !strings.Contains(rs, want) {
68 t.Errorf("ruleset lacks %q", want)
69 }
70 }
71 // The drop for the host must come after the DNS accepts and the guest
72 // table must never use a drop policy (it would break other tables' traffic).
73 if strings.Contains(rs, "policy drop") {
74 t.Error("base chains must keep policy accept")
75 }
76 if strings.Index(rs, "dport 53 accept") > strings.Index(rs, `iifname "ertap0" drop`) {
77 t.Error("DNS accept must precede the catch-all drop")
78 }
79}
80
81// S1: nothing a guest sends may be accepted by a conntrack rule before the
82// per-guest checks: a forged "reply" from guest A to guest B's flow would
83// otherwise be accepted as established traffic.
84func TestForwardChainChecksGuestTrafficBeforeEstablished(t *testing.T) {
85 rs := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "erbr1", 1)})
86 fwd := rs[strings.Index(rs, "chain forward"):strings.Index(rs, "chain postrouting")]
87 lines := strings.Split(fwd, "\n")
88 idx := func(sub string) int {
89 for i, l := range lines {
90 if strings.Contains(l, sub) {
91 return i
92 }
93 }
94 t.Fatalf("forward chain lacks %q:\n%s", sub, fwd)
95 return -1
96 }
97 est := idx("ct state established,related accept")
98 for _, must := range []string{
99 `iifname "ertap*" meta nfproto ipv6 drop`, `iifname "erbr*" meta nfproto ipv6 drop`,
100 `iifname "ertap0" ip saddr != 10.200.0.2 drop`, `iifname "ertap0" ip daddr @blocked_v4 drop`,
101 `iifname "erbr1" ip saddr != 10.200.0.6 drop`, `iifname "erbr1" ip daddr @blocked_v4 drop`,
102 } {
103 if idx(must) > est {
104 t.Errorf("%q must come before the established accept", must)
105 }
106 }
107 // And the established accept itself never applies to packets that enter
108 // through a sandbox interface.
109 if !strings.Contains(lines[est], `iifname != { "ertap*", "erbr*" }`) {
110 t.Errorf("established accept must exclude sandbox ingress: %s", lines[est])
111 }
112}
113
114// S6: port 53 of a gateway address is closed to every interface but its guest's.
115func TestInputChainClosesGatewayDNSToOtherInterfaces(t *testing.T) {
116 rs := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "ertap1", 1)})
117 for _, want := range []string{
118 `ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop`,
119 `ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop`,
120 `ip daddr 10.200.0.5 udp dport 53 iifname != "ertap1" drop`,
121 `ip daddr 10.200.0.5 tcp dport 53 iifname != "ertap1" drop`,
122 } {
123 if !strings.Contains(rs, want) {
124 t.Errorf("lacks %q", want)
125 }
126 }
127}
128
129// S10: documentation, test-net and benchmarking ranges are blocked too.
130func TestBlockedSetCoversSpecialUseRanges(t *testing.T) {
131 rs := Ruleset([]Guest{guest(t, "ertap0", 0)})
132 for _, r := range []string{"192.0.0.0/24", "192.0.2.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4"} {
133 if !strings.Contains(rs, r) {
134 t.Errorf("blocked set lacks %s", r)
135 }
136 }
137}
138
139func TestSubnet(t *testing.T) {
140 base := netip.MustParsePrefix("10.200.0.0/16")
141 n, err := Subnet(base, 3)
142 if err != nil {
143 t.Fatal(err)
144 }
145 if n.Prefix.String() != "10.200.0.12/30" || n.Gateway.String() != "10.200.0.13" || n.Guest.String() != "10.200.0.14" {
146 t.Fatalf("got %+v", n)
147 }
148 n, _ = Subnet(base, 64)
149 if n.Gateway.String() != "10.200.1.1" {
150 t.Fatalf("carry: %+v", n)
151 }
152 if _, err := Subnet(base, 1<<14); err == nil {
153 t.Fatal("slot beyond the prefix accepted")
154 }
155 if _, err := Subnet(netip.MustParsePrefix("10.0.0.0/31"), 0); err == nil {
156 t.Fatal("prefix longer than /30 accepted")
157 }
158 if _, err := Subnet(base, -1); err == nil {
159 t.Fatal("negative slot accepted")
160 }
161}
162
163func TestGuestValidate(t *testing.T) {
164 g := guest(t, "ertap0", 0)
165 if err := g.Validate(); err != nil {
166 t.Fatal(err)
167 }
168 for _, bad := range []string{"", "a b", `x"y`, "a;b", "waytoolonginterfacename", "x\ny"} {
169 g.Iface = bad
170 if g.Validate() == nil {
171 t.Errorf("iface %q accepted", bad)
172 }
173 }
174 g.Iface = "ok"
175 g.Addr = netip.MustParseAddr("::1")
176 if g.Validate() == nil {
177 t.Error("IPv6 guest accepted")
178 }
179}
180
181func TestManager(t *testing.T) {
182 f := &cmdrun.Fake{}
183 m := NewManager(f, "nft")
184 ctx := context.Background()
185 if err := m.Add(ctx, guest(t, "ertap0", 0)); err != nil {
186 t.Fatal(err)
187 }
188 if err := m.Add(ctx, guest(t, "ertap1", 1)); err != nil {
189 t.Fatal(err)
190 }
191 if err := m.Remove(ctx, "ertap0"); err != nil {
192 t.Fatal(err)
193 }
194 if err := m.Remove(ctx, "unknown"); err != nil {
195 t.Fatal(err)
196 }
197 if len(f.Calls) != 3 {
198 t.Fatalf("want 3 nft calls (unknown iface is a no-op), got %v", f.Lines())
199 }
200 last := f.Calls[2]
201 if last.Name != "nft" || strings.Join(last.Args, " ") != "-f -" {
202 t.Fatalf("bad command %v", last)
203 }
204 if strings.Contains(last.Stdin, "ertap0") || !strings.Contains(last.Stdin, "ertap1") {
205 t.Fatalf("after removing ertap0 only ertap1 must remain:\n%s", last.Stdin)
206 }
207 if err := m.Remove(ctx, "ertap1"); err != nil {
208 t.Fatal(err)
209 }
210 if got := f.Calls[3].Stdin; got != Ruleset(nil) {
211 t.Fatalf("last removal must delete the table, got:\n%s", got)
212 }
213 if err := m.Reset(ctx); err != nil {
214 t.Fatal(err)
215 }
216}
217
218func TestManagerRollsBackOnError(t *testing.T) {
219 boom := errors.New("boom")
220 f := &cmdrun.Fake{Handle: func(cmdrun.Call) ([]byte, error) { return nil, boom }}
221 m := NewManager(f, "")
222 if err := m.Add(context.Background(), guest(t, "ertap0", 0)); !errors.Is(err, boom) {
223 t.Fatalf("got %v", err)
224 }
225 f.Handle = nil
226 if err := m.Remove(context.Background(), "ertap0"); err != nil {
227 t.Fatal(err)
228 }
229 if len(f.Calls) != 1 {
230 t.Fatalf("a guest whose Add failed must not be tracked: %v", f.Lines())
231 }
232}
233
234var _ io.Reader = strings.NewReader("")
235
236func forgeGuest(t *testing.T, iface string, slot int, port uint16) Guest {
237 t.Helper()
238 g := guest(t, iface, slot)
239 g.ForgePort = port
240 return g
241}
242
243func TestRulesetGuestForgeGolden(t *testing.T) {
244 golden(t, "guest_forge.nft", Ruleset([]Guest{forgeGuest(t, "ertap0", 0, 8088)}))
245}
246
247// Without ForgePort the ruleset has no TCP rule beyond DNS: the exception is
248// opt-in (and one.nft, the golden file of that case, is unchanged).
249func TestRulesetWithoutForgePortHasNoForgeRule(t *testing.T) {
250 rs := Ruleset([]Guest{guest(t, "ertap0", 0)})
251 if n, dns := strings.Count(rs, "tcp dport"), strings.Count(rs, "tcp dport 53 "); n != dns {
252 t.Fatalf("%d tcp port rules, %d of them for DNS:\n%s", n, dns, rs)
253 }
254}
255
256// The exception is one TCP port towards the guest's own gateway, from its own
257// address and interface, placed before the guest's catch-all drop; a second
258// guest gets its own, never the other's gateway; nothing else opens, and the
259// forward chain (private ranges, source checks, sandbox-to-sandbox) is the
260// same with and without it.
261func TestGuestForgeRuleScopeAndOrder(t *testing.T) {
262 gs := []Guest{forgeGuest(t, "ertap0", 0, 8088), forgeGuest(t, "erbr1", 1, 8088), guest(t, "ertap2", 2)}
263 rs := Ruleset(gs)
264 in := rs[strings.Index(rs, "chain input"):strings.Index(rs, "chain forward")]
265 r0 := `iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 8088 accept`
266 r1 := `iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 tcp dport 8088 accept`
267 for _, want := range []string{r0, r1} {
268 if strings.Count(in, want) != 1 {
269 t.Errorf("want exactly one %q in the input chain:\n%s", want, in)
270 }
271 }
272 if strings.Count(rs, "8088") != 2 {
273 t.Errorf("the port appears %d times, want 2 (one per guest with the exception):\n%s", strings.Count(rs, "8088"), rs)
274 }
275 if strings.Index(in, r0) > strings.Index(in, `iifname "ertap0" drop`) || strings.Index(in, r1) > strings.Index(in, `iifname "erbr1" drop`) {
276 t.Error("the forge accept must precede the guest's catch-all drop")
277 }
278 // The guest without the exception has none.
279 if strings.Contains(in, `iifname "ertap2" ip saddr 10.200.0.10 ip daddr 10.200.0.9 tcp dport 8088`) {
280 t.Error("ertap2 got a forge rule")
281 }
282 // The forward and postrouting chains do not depend on the exception.
283 plain := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "erbr1", 1), guest(t, "ertap2", 2)})
284 tail := func(s string) string { return s[strings.Index(s, "chain forward"):] }
285 if tail(rs) != tail(plain) {
286 t.Error("the forward/postrouting chains changed with the exception")
287 }
288 // Every accept of the input chain that is not DNS is one of the two.
289 for _, l := range strings.Split(in, "\n") {
290 if strings.HasSuffix(l, "accept") && !strings.Contains(l, "dport 53 accept") && !strings.Contains(l, "tcp dport 8088 accept") {
291 t.Errorf("unexpected accept: %s", l)
292 }
293 }
294}