package main import ( "context" "errors" "os" "path/filepath" "strings" "testing" ) // The node-uplink seat's verbs (novox/hq ADR 0241), every holder's: the resolver file as it is, the mesh's // or another program's — named from its header, a backup beside it or a writer running — and the links // with their default route and what the manager knows of their names. const meshFile = "# Managed by the mesh, and written by the module holding this machine's uplink.\n" + "nameserver 10.77.0.2\nnameserver 10.77.0.1\noptions timeout:1 attempts:2 edns0\n" const vpnFile = "# Dynamic resolv.conf(5) file for glibc resolver(3) generated by forticlient\n" + "# The original file is backed up and will be restored after the VPN disconnects.\n" + "nameserver 192.0.2.53\nnameserver 192.0.2.54\nsearch corp.example lab.example\n" func aMachine(t *testing.T, content string) Machine { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "resolv.conf"), []byte(content), 0o644); err != nil { t.Fatal(err) } return Machine{ResolvPath: filepath.Join(dir, "resolv.conf"), Running: func() []string { return nil }} } func TestTheMeshsOwnFileIsSaidAsTheMeshs(t *testing.T) { r, err := aMachine(t, meshFile).ReadResolvers() if err != nil { t.Fatal(err) } if !r.WrittenByTheMesh || r.Writer != "" || len(r.Nameservers) != 2 || r.Nameservers[0] != "10.77.0.2" || strings.Join(r.Options, " ") != "timeout:1 attempts:2 edns0" { t.Fatalf("the mesh's file is read as %+v", r) } } func TestAVPNClientsFileIsReadAndItsWriterNamed(t *testing.T) { m := aMachine(t, vpnFile) r, err := m.ReadResolvers() if err != nil { t.Fatal(err) } if r.WrittenByTheMesh || r.Writer != "FortiClient" || strings.Join(r.Search, " ") != "corp.example lab.example" || len(r.Header) != 2 { t.Fatalf("the VPN's file is read as %+v", r) } } func TestABackupBesideTheFileNamesItsWriterAndIsSaidAsTheMeshs(t *testing.T) { m := aMachine(t, "nameserver 192.0.2.53\n") if err := os.WriteFile(m.ResolvPath+".forticlient.backup", []byte(meshFile), 0o644); err != nil { t.Fatal(err) } r, err := m.ReadResolvers() if err != nil { t.Fatal(err) } if r.Writer != "FortiClient" || len(r.Beside) != 1 || !r.Beside[0].Mesh { t.Fatalf("the backup is read as %+v", r) } } func TestAWriterRunningIsAGuessAndNothingFoundIsSaid(t *testing.T) { m := aMachine(t, "nameserver 192.0.2.53\n") r, _ := m.ReadResolvers() if r.Writer != "" || r.Why != "no program it could be is known" { t.Fatalf("nothing to name it by is read as %+v", r) } m.Running = func() []string { return []string{"bash", "openvpn"} } if r, _ := m.ReadResolvers(); r.Writer != "OpenVPN?" { t.Fatalf("a running client is read as %+v", r) } } func TestALinkInPlaceOfTheFileNamesWhatItPointsAt(t *testing.T) { m := aMachine(t, meshFile) dir := filepath.Join(filepath.Dir(m.ResolvPath), "systemd", "resolve") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "stub-resolv.conf"), []byte("nameserver 127.0.0.53\n"), 0o644); err != nil { t.Fatal(err) } os.Remove(m.ResolvPath) if err := os.Symlink(filepath.Join(dir, "stub-resolv.conf"), m.ResolvPath); err != nil { t.Fatal(err) } r, err := m.ReadResolvers() if err != nil || r.Writer != "systemd-resolved" || r.WrittenByTheMesh || r.Nameservers[0] != "127.0.0.53" { t.Fatalf("a link is read as %+v %v", r, err) } } func TestTheLinksCarryTheirDefaultRouteAndTheirNames(t *testing.T) { m := Machine{ Run: func(_ context.Context, name string, args ...string) (string, error) { joined := strings.Join(args, " ") switch { case joined == "-j address show": return `[{"ifname":"lo","operstate":"UNKNOWN","link_type":"loopback","addr_info":[{"local":"127.0.0.1","prefixlen":8}]}, {"ifname":"wlan0","operstate":"UP","link_type":"ether","addr_info":[{"local":"192.168.1.20","prefixlen":24}]}, {"ifname":"vpn0","operstate":"UNKNOWN","link_type":"none","addr_info":[{"local":"172.16.9.9","prefixlen":32}]}]`, nil case joined == "-j -4 route show default": return `[{"dst":"default","dev":"wlan0","metric":600}]`, nil case joined == "-j -6 route show default": return `[]`, nil } return "", errors.New("unexpected " + joined) }, LinkDNS: func(context.Context) (map[string]LinkDNS, error) { return map[string]LinkDNS{"wlan0": {Servers: []string{"192.168.1.1"}, Manager: "NetworkManager"}}, nil }, } answer, err := m.Links(context.Background()) if err != nil { t.Fatal(err) } links := answer["links"].([]Link) if len(links) != 3 || links[0].Name != "wlan0" || !links[0].Default || *links[0].Metric != 600 || links[0].DNS == nil || links[0].DNS.Servers[0] != "192.168.1.1" { t.Fatalf("the links are %+v", links) } for _, l := range links[1:] { if l.Default || (l.Name == "vpn0" && l.DNS != nil) { t.Fatalf("%+v", l) } } m.LinkDNS = func(context.Context) (map[string]LinkDNS, error) { return nil, errors.New("not running") } if answer, _ := m.Links(context.Background()); answer["dns_not_read"] != "not running" { t.Fatalf("a manager that does not answer is not said: %+v", answer) } }