Files
mesh-catalog/modules/systemd-resolved/cmd/resolver-tools/resolver_test.go
T
jochen d53571213c
mesh/merge-gate fail: builds new: modules/systemd-resolved, sent nowhere; no bus step; a manifest the change touches fails the module check: modules/system…
mesh/repo-check fail: its merge-check.sh failed: long-running resources without health: 70
mesh/delivery superseded: a newer head of the same pull request
systemd-resolved: a machine's own resolver, routing a VPN's domains by link (hq ADR 0247)
Holds node-resolver and provides split-dns at the machine's reach, for a
machine whose VPN client pushes resolvers of its own. It writes the
resolver file naming the machine's private address, gives resolved the
mesh's resolvers as the default route, and serves routes, route and unroute
on the mesh and, over a root-only socket, on the machine. Its guard keeps an
outside write of the file for the module that handles it and puts the
module's file back: at once when taken, after 90 s otherwise, so a write
nothing declared to handle is still raised by the node-engine.
2026-10-07 21:28:11 +02:00

173 lines
6.6 KiB
Go

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)
}
}