v0.4.1 122e784

netpolicy_test.go RawHistoryFolder

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}
295
296// The cache is not on the network: the policy opens nothing for it, with or
297// without the guest forge exception. A guest only ever gets DNS (and the forge
298// port when asked for); the cache port of the guest's loopback (9089) and the
299// old gateway port (8089) appear nowhere, and the only accepts of the input
300// chain are DNS and the forge port.
301func TestNoCachePortInThePolicy(t *testing.T) {
302	for name, gs := range map[string][]Guest{
303		"plain": {guest(t, "ertap0", 0), guest(t, "erbr1", 1)},
304		"forge": {forgeGuest(t, "ertap0", 0, 8088), guest(t, "erbr1", 1)},
305	} {
306		rs := Ruleset(gs)
307		for _, port := range []string{"8089", "9089"} {
308			if strings.Contains(rs, port) {
309				t.Errorf("%s: the ruleset mentions port %s:\n%s", name, port, rs)
310			}
311		}
312		in := rs[strings.Index(rs, "chain input"):strings.Index(rs, "chain forward")]
313		for _, l := range strings.Split(in, "\n") {
314			if strings.HasSuffix(l, "accept") && !strings.Contains(l, "dport 53 accept") && !strings.Contains(l, "tcp dport 8088 accept") {
315				t.Errorf("%s: unexpected accept: %s", name, l)
316			}
317		}
318	}
319}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close