1// SPDX-License-Identifier: MIT
2
3package netsetup
4
5import (
6 "context"
7 "errors"
8 "net"
9 "net/netip"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14
15 "bnema.dev/ermite-runner/internal/cmdrun"
16 "bnema.dev/ermite-runner/internal/netpolicy"
17 "bnema.dev/ermite-runner/internal/tcprelay"
18)
19
20// freePort returns a TCP port of 127.0.0.1 that was free when probed.
21func freePort(t *testing.T) uint16 {
22 t.Helper()
23 ln, err := net.Listen("tcp", "127.0.0.1:0")
24 if err != nil {
25 t.Skip("cannot bind loopback:", err)
26 }
27 defer func() { _ = ln.Close() }()
28 return uint16(ln.Addr().(*net.TCPAddr).Port)
29}
30
31func guest() netpolicy.Guest {
32 return netpolicy.Guest{Iface: "ertap0", Gateway: netip.MustParseAddr("127.0.0.1"), Addr: netip.MustParseAddr("10.200.0.2")}
33}
34
35func TestAttachDetach(t *testing.T) {
36 f := &cmdrun.Fake{}
37 n := New(f, 0, "")
38 t.Cleanup(n.Close)
39 ctx := context.Background()
40 if err := n.Attach(ctx, guest()); err != nil {
41 t.Skip("cannot bind loopback:", err)
42 }
43 if _, ok := n.DNS.Addr(guest().Gateway); !ok {
44 t.Fatal("DNS forwarder not listening on the gateway")
45 }
46 if err := n.Detach(ctx, "ertap0", guest().Gateway); err != nil {
47 t.Fatal(err)
48 }
49 if _, ok := n.DNS.Addr(guest().Gateway); ok {
50 t.Fatal("DNS forwarder still listening after Detach")
51 }
52 if len(f.Calls) != 2 || f.Calls[1].Stdin != netpolicy.Ruleset(nil) {
53 t.Fatalf("calls: %v", f.Lines())
54 }
55 if err := n.Detach(ctx, "ertap0", guest().Gateway); err != nil {
56 t.Fatalf("Detach must be idempotent: %v", err)
57 }
58}
59
60// The host network surface of a guest is DNS and the firewall policy, nothing
61// else: Attach opens no cache port, neither in the policy nor as a listener.
62func TestAttachOpensNoCachePort(t *testing.T) {
63 f := &cmdrun.Fake{}
64 n := New(f, 0, "")
65 t.Cleanup(n.Close)
66 if err := n.Attach(context.Background(), guest()); err != nil {
67 t.Skip("cannot bind loopback:", err)
68 }
69 rs := f.Calls[0].Stdin
70 for _, port := range []string{"8089", "9089"} {
71 if strings.Contains(rs, port) {
72 t.Fatalf("the policy mentions the cache port %s:\n%s", port, rs)
73 }
74 }
75 if !strings.Contains(rs, "dport 53 accept") {
76 t.Fatalf("the policy lost DNS:\n%s", rs)
77 }
78}
79
80func TestAttachRollsBackPolicyWhenDNSFails(t *testing.T) {
81 f := &cmdrun.Fake{}
82 n := New(f, 0, "")
83 t.Cleanup(n.Close)
84 ctx := context.Background()
85 if err := n.Attach(ctx, guest()); err != nil {
86 t.Skip(err)
87 }
88 before := len(f.Calls)
89 if err := n.Attach(ctx, guest()); err == nil { // already listening
90 t.Fatal("second Attach on the same gateway must fail")
91 }
92 if len(f.Calls) != before+2 {
93 t.Fatalf("expected add then rollback remove, got %v", f.Lines())
94 }
95}
96
97// With a relay, Attach relays the guest forge port of the gateway, Detach stops
98// it, and a guest without a forge port gets no relay listener.
99func TestAttachStartsAndStopsTheForgeRelay(t *testing.T) {
100 f := &cmdrun.Fake{}
101 n := New(f, 0, "")
102 n.Relay = tcprelay.New("127.0.0.1:1")
103 t.Cleanup(n.Close)
104 ctx := context.Background()
105 g := guest()
106 if err := n.Attach(ctx, g); err != nil {
107 t.Skip("cannot bind loopback:", err)
108 }
109 if _, ok := n.Relay.Addr(g.Gateway); ok {
110 t.Fatal("a relay was started for a guest without a forge port")
111 }
112 if err := n.Detach(ctx, g.Iface, g.Gateway); err != nil {
113 t.Fatal(err)
114 }
115 // A port that was free a moment ago: Attach itself starts the relay.
116 g.ForgePort = freePort(t)
117 if err := n.Attach(ctx, g); err != nil {
118 t.Fatalf("Attach with a forge port: %v", err)
119 }
120 if a, ok := n.Relay.Addr(g.Gateway); !ok || a.(*net.TCPAddr).Port != int(g.ForgePort) {
121 t.Fatalf("relay not listening on the forge port: %v %v", a, ok)
122 }
123 if err := n.Detach(ctx, g.Iface, g.Gateway); err != nil {
124 t.Fatal(err)
125 }
126 if _, ok := n.Relay.Addr(g.Gateway); ok {
127 t.Fatal("relay still listening after Detach")
128 }
129}
130
131// A relay that cannot bind rolls back the DNS listener and the policy.
132func TestAttachRollsBackWhenTheRelayFails(t *testing.T) {
133 f := &cmdrun.Fake{}
134 n := New(f, 0, "")
135 n.Relay = tcprelay.New("127.0.0.1:1")
136 t.Cleanup(n.Close)
137 ctx := context.Background()
138 g := guest()
139 g.ForgePort = 1 // privileged port: binding fails without CAP_NET_BIND_SERVICE
140 if err := n.Attach(ctx, g); err == nil {
141 t.Skip("port 1 could be bound here")
142 }
143 if _, ok := n.DNS.Addr(g.Gateway); ok {
144 t.Fatal("DNS still listening after a failed Attach")
145 }
146 if len(f.Calls) != 2 {
147 t.Fatalf("expected add then rollback remove, got %v", f.Lines())
148 }
149}
150
151func TestAttachPolicyError(t *testing.T) {
152 boom := errors.New("nft failed")
153 n := New(&cmdrun.Fake{Handle: func(cmdrun.Call) ([]byte, error) { return nil, boom }}, 0, "")
154 if err := n.Attach(context.Background(), guest()); !errors.Is(err, boom) {
155 t.Fatalf("got %v", err)
156 }
157 if _, ok := n.DNS.Addr(guest().Gateway); ok {
158 t.Fatal("DNS must not start when the policy failed")
159 }
160}
161
162func TestEnableForwarding(t *testing.T) {
163 n := New(&cmdrun.Fake{}, 0, "")
164 n.ForwardFile = filepath.Join(t.TempDir(), "ip_forward")
165 _ = os.WriteFile(n.ForwardFile, []byte("0\n"), 0o644)
166 if err := n.EnableForwarding(); err != nil {
167 t.Fatal(err)
168 }
169 if b, _ := os.ReadFile(n.ForwardFile); strings.TrimSpace(string(b)) != "1" {
170 t.Fatalf("not enabled: %q", b)
171 }
172 n.ForwardFile = filepath.Join(t.TempDir(), "missing", "ip_forward")
173 if err := n.EnableForwarding(); err == nil || !strings.Contains(err.Error(), "ip_forward") {
174 t.Fatalf("want a clear error, got %v", err)
175 }
176}