package main import ( "context" "os" "path/filepath" "strings" "testing" ) // fakeResolved answers resolvectl as systemd-resolved would, and records what was asked of it. type fakeResolved struct { dns, domain, defaults string changed [][]string } func (f *fakeResolved) read(_ context.Context, name string, args ...string) (string, error) { switch args[0] { case "dns": return f.dns, nil case "domain": return f.domain, nil case "default-route": return f.defaults, nil } return "", nil } func (f *fakeResolved) change(_ context.Context, name string, args ...string) (string, error) { f.changed = append(f.changed, append([]string{name}, args...)) return "", nil } // aMachine is a resolver over a fake resolved and a /sys/class/net holding the links named. func aMachine(t *testing.T, f *fakeResolved, links ...string) *Resolver { t.Helper() dir := t.TempDir() for _, l := range links { if err := os.MkdirAll(filepath.Join(dir, "net", l), 0o755); err != nil { t.Fatal(err) } } suffix := filepath.Join(dir, "suffix") if err := os.WriteFile(suffix, []byte("internal\n"), 0o644); err != nil { t.Fatal(err) } return &Resolver{Change: f.change, Read: f.read, NetDir: filepath.Join(dir, "net"), SuffixFile: suffix} } // A route sends the domains to the servers over the link, and only them: the link is first told it is // no default route, then given its domains as routing domains, then its servers. func TestARouteSendsOnlyItsDomainsOverItsLink(t *testing.T) { f := &fakeResolved{} r := aMachine(t, f, "tun0") got, err := r.Route(context.Background(), "tun0", Split("Corp.Example. ~cloud.example, corp.example"), Split("10.9.0.1 10.9.0.2")) if err != nil { t.Fatal(err) } want := []string{ "resolvectl default-route tun0 false", "resolvectl domain tun0 ~corp.example ~cloud.example", "resolvectl dns tun0 10.9.0.1 10.9.0.2", } if len(f.changed) != len(want) { t.Fatalf("resolved was asked %v", f.changed) } for i, w := range want { if strings.Join(f.changed[i], " ") != w { t.Errorf("step %d was %v, not %s", i, f.changed[i], w) } } if len(got.Domains) != 2 || len(got.Servers) != 2 { t.Errorf("the route says %+v", got) } } // The mesh's own domain, the root, a link that is not here and loopback are refused, and resolved is // asked nothing. func TestARouteThatWouldTakeTheMeshsNamesIsRefused(t *testing.T) { for name, c := range map[string]struct{ link, domains, servers, says string }{ "the mesh's domain": {"tun0", "corp.example internal", "10.9.0.1", "mesh's own domain"}, "under it": {"tun0", "anchor.internal", "10.9.0.1", "mesh's own domain"}, "the root": {"tun0", "~.", "10.9.0.1", "every name"}, "no domain": {"tun0", "", "10.9.0.1", "no domain"}, "not a domain": {"tun0", "a b/c", "10.9.0.1", "not a domain"}, "no server": {"tun0", "corp.example", "", "no server"}, "not an address": {"tun0", "corp.example", "dns.corp.example", "not an address"}, "a link not here": {"tun9", "corp.example", "10.9.0.1", "no link"}, "loopback": {"lo", "corp.example", "10.9.0.1", "loopback"}, "not a link's name": {"../etc", "corp.example", "10.9.0.1", "not a link"}, "an unspecified one": {"tun0", "corp.example", "0.0.0.0", "cannot answer"}, "a multicast address": {"tun0", "corp.example", "224.0.0.251", "cannot answer"}, } { f := &fakeResolved{} r := aMachine(t, f, "tun0", "lo") _, err := r.Route(context.Background(), c.link, Split(c.domains), Split(c.servers)) if err == nil || !strings.Contains(err.Error(), c.says) { t.Errorf("%s: %v", name, err) } if len(f.changed) != 0 { t.Errorf("%s: resolved was changed anyway: %v", name, f.changed) } } } // Routes reads resolved: the mesh's resolvers as the global scope, and each link with servers of its // own, its routing domains without the `~` and whether it is a default route. func TestRoutesReadWhatResolvedSendsWhere(t *testing.T) { f := &fakeResolved{ dns: "Global: 10.42.0.1 10.42.0.3\nLink 2 (wlan0): 192.168.1.1\nLink 3 (mesh0):\nLink 7 (tun0): 10.9.0.1 10.9.0.2\n", domain: "Global: ~.\nLink 2 (wlan0):\nLink 3 (mesh0):\nLink 7 (tun0): ~corp.example ~cloud.example\n", defaults: "Link 2 (wlan0): yes\nLink 3 (mesh0): yes\nLink 7 (tun0): no\n", } got, err := aMachine(t, f).Routes(context.Background()) if err != nil { t.Fatal(err) } if strings.Join(got.Mesh.Servers, " ") != "10.42.0.1 10.42.0.3" || strings.Join(got.Mesh.Domains, " ") != "." { t.Errorf("the mesh's scope is %+v", got.Mesh) } if len(got.Links) != 2 || got.Links[0].Link != "tun0" && got.Links[1].Link != "tun0" { t.Fatalf("the links with servers are %+v", got.Links) } for _, l := range got.Links { if l.Link == "tun0" && (strings.Join(l.Domains, " ") != "corp.example cloud.example" || *l.DefaultRoute) { t.Errorf("tun0 is %+v", l) } } } // A link a network manager gave servers is kept from answering every name: its default route goes, its // own domains stay; a link without servers, and one already routed, are left alone. func TestOnlyTheMeshsResolversAnswerEveryName(t *testing.T) { f := &fakeResolved{ dns: "Global: 10.42.0.1\nLink 2 (wlan0): 192.168.1.1\nLink 3 (mesh0):\nLink 7 (tun0): 10.9.0.1\n", domain: "Global: ~.\nLink 2 (wlan0): lan\nLink 7 (tun0): ~corp.example\n", defaults: "Link 2 (wlan0): yes\nLink 3 (mesh0): yes\nLink 7 (tun0): no\n", } changed, err := aMachine(t, f).OnlyTheMeshIsADefaultRoute(context.Background()) if err != nil { t.Fatal(err) } if strings.Join(changed, " ") != "wlan0" || len(f.changed) != 1 || strings.Join(f.changed[0], " ") != "resolvectl default-route wlan0 false" { t.Errorf("changed %v by %v", changed, f.changed) } } // Taking a route away reverts the link; a link that went has nothing to take away. func TestUnrouteRevertsTheLink(t *testing.T) { f := &fakeResolved{} r := aMachine(t, f, "tun0") if _, err := r.Unroute(context.Background(), "tun0"); err != nil { t.Fatal(err) } if _, err := r.Unroute(context.Background(), "tun1"); err != nil { t.Fatal(err) } if len(f.changed) != 1 || strings.Join(f.changed[0], " ") != "resolvectl revert tun0" { t.Errorf("resolved was asked %v", f.changed) } } // Escalation: root runs the command as given; the runtime's account through sudo without a prompt. func TestChangesAreEscalatedOnlyWhenNotRoot(t *testing.T) { if p, a := escalated(0, "resolvectl", []string{"revert", "tun0"}); p != "resolvectl" || len(a) != 2 { t.Errorf("as root: %s %v", p, a) } if p, a := escalated(1000, "resolvectl", []string{"revert", "tun0"}); p != "sudo" || strings.Join(a, " ") != "-n resolvectl revert tun0" { t.Errorf("as the account: %s %v", p, a) } }