diff --git a/modules/networkmanager/cmd/uplink-tools/copies_test.go b/modules/networkmanager/cmd/uplink-tools/copies_test.go new file mode 100644 index 0000000..f782359 --- /dev/null +++ b/modules/networkmanager/cmd/uplink-tools/copies_test.go @@ -0,0 +1,38 @@ +package main + +// The holders of node-uplink whose bundles carry uplink.go (novox/hq ADR 0241). Each builds alone, so each +// has its own copy; this test, itself one of the copied files, holds them to one text wherever the siblings +// are present. + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +var holders = []string{"networkmanager", "systemd-networkd"} + +func TestEveryUplinkHolderCarriesTheSameCopy(t *testing.T) { + compared := 0 + for _, module := range holders { + dir := filepath.Join("..", "..", "..", module, "cmd", "uplink-tools") + if _, err := os.Stat(dir); err != nil { + continue + } + for _, f := range []string{"uplink.go", "uplink_test.go", "main.go", "copies_test.go"} { + mine, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + theirs, err := os.ReadFile(filepath.Join(dir, f)) + if err != nil || !bytes.Equal(mine, theirs) { + t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f) + } + } + compared++ + } + if compared == 0 { + t.Log("no sibling copies beside this module") + } +} diff --git a/modules/networkmanager/cmd/uplink-tools/main.go b/modules/networkmanager/cmd/uplink-tools/main.go new file mode 100644 index 0000000..d2aed1d --- /dev/null +++ b/modules/networkmanager/cmd/uplink-tools/main.go @@ -0,0 +1,35 @@ +// The uplink holder's tools bundle: the node-uplink seat's verbs (novox/hq ADR 0241), served by the node's +// runtime as the operator account. uplink.go is every holder's; manager.go is this holder's own. stdout is +// the MCP channel; this says nothing else. +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func tools(m Machine) []stdio.Tool { + return []stdio.Tool{ + {Name: seat + ".resolvers", + Description: "The machine's resolver file as it is now: the resolvers, search domains and options it lists, " + + "whether it is the file the mesh declares, and when it is not, who wrote it as far as the machine shows " + + "— its header, a backup a VPN client left beside it, a link in its place, a known writer running. (r)", + Run: func(map[string]any) (any, error) { return m.ReadResolvers() }}, + {Name: seat + ".links", + Description: "Every network link on the machine: its state, its addresses, whether the default route " + + "leaves through it, and the resolvers and search domains the network manager knows for it — a VPN's " + + "tunnel included. (r)", + Run: func(map[string]any) (any, error) { return m.Links(context.Background()) }}, + } +} + +func main() { + m := Machine{ResolvPath: ResolvConf, Run: execRunner, Running: running, LinkDNS: managerDNS} + if err := stdio.Serve("", tools(m)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/modules/networkmanager/cmd/uplink-tools/manager.go b/modules/networkmanager/cmd/uplink-tools/manager.go new file mode 100644 index 0000000..ad35dfc --- /dev/null +++ b/modules/networkmanager/cmd/uplink-tools/manager.go @@ -0,0 +1,52 @@ +package main + +// NetworkManager's word on each link's names: `nmcli device show`, read in its terse form. A link +// NetworkManager does not manage — a tunnel a VPN client raised with `ip`, the mesh's own — is not in it, +// and the links verb says the link without what the manager does not know. + +import ( + "context" + "strings" +) + +func managerDNS(ctx context.Context) (map[string]LinkDNS, error) { + out, err := execRunner(ctx, "nmcli", "-t", "-f", "GENERAL.DEVICE,GENERAL.STATE,IP4.DNS,IP6.DNS,IP4.DOMAIN,IP6.DOMAIN", + "device", "show") + if err != nil { + return nil, err + } + return parseNmcli(out), nil +} + +// parseNmcli reads `nmcli -t device show`: blocks of KEY:value lines, one block per device. +func parseNmcli(out string) map[string]LinkDNS { + links := map[string]LinkDNS{} + device := "" + var cur LinkDNS + flush := func() { + if device != "" && (len(cur.Servers) > 0 || len(cur.Domains) > 0) { + cur.Manager = "NetworkManager" + links[device] = cur + } + device, cur = "", LinkDNS{} + } + for _, line := range strings.Split(out, "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), ":") + if !ok { + continue + } + switch { + case key == "GENERAL.DEVICE": + flush() + device = value + case key == "GENERAL.STATE": + cur.State = value + case strings.HasPrefix(key, "IP4.DNS"), strings.HasPrefix(key, "IP6.DNS"): + cur.Servers = append(cur.Servers, value) + case strings.HasPrefix(key, "IP4.DOMAIN"), strings.HasPrefix(key, "IP6.DOMAIN"): + cur.Domains = append(cur.Domains, value) + } + } + flush() + return links +} diff --git a/modules/networkmanager/cmd/uplink-tools/manager_test.go b/modules/networkmanager/cmd/uplink-tools/manager_test.go new file mode 100644 index 0000000..2782c34 --- /dev/null +++ b/modules/networkmanager/cmd/uplink-tools/manager_test.go @@ -0,0 +1,18 @@ +package main + +import "testing" + +// NetworkManager's word on each link's names, read from its terse output. +func TestNmcliIsReadPerDevice(t *testing.T) { + links := parseNmcli("GENERAL.DEVICE:wlp3s0\nGENERAL.STATE:100 (connected)\nIP4.DNS[1]:192.168.1.1\n" + + "IP4.DOMAIN[1]:localdomain\n\nGENERAL.DEVICE:docker0\nGENERAL.STATE:100 (connected (externally))\n\n" + + "GENERAL.DEVICE:vpn0\nGENERAL.STATE:100 (connected)\nIP4.DNS[1]:192.0.2.53\nIP4.DNS[2]:192.0.2.54\n" + + "IP4.DOMAIN[1]:corp.example\n") + if len(links) != 2 || links["wlp3s0"].Servers[0] != "192.168.1.1" || links["wlp3s0"].Domains[0] != "localdomain" || + len(links["vpn0"].Servers) != 2 || links["vpn0"].Manager != "NetworkManager" { + t.Fatalf("read %+v", links) + } + if _, said := links["docker0"]; said { + t.Fatal("a link with no names was said") + } +} diff --git a/modules/networkmanager/cmd/uplink-tools/uplink.go b/modules/networkmanager/cmd/uplink-tools/uplink.go new file mode 100644 index 0000000..d5e85ed --- /dev/null +++ b/modules/networkmanager/cmd/uplink-tools/uplink.go @@ -0,0 +1,345 @@ +// The node-uplink seat's verbs (novox/hq ADR 0241), as every holder serves them: what the machine resolves +// through and over which links. Each holder builds alone, so each carries this file; copies_test.go holds +// the copies to one text. What differs between holders — which resolvers the network manager knows for a +// link — is the holder's own manager.go. +// +// **Read only.** Nothing here writes the resolver file, changes a link or asks a manager to. The answers +// stay inside the mesh: the resolver file's servers and search domains are said as they are, a VPN's +// included; a credential, a profile or a gateway's secret is never read. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +const seat = "node-uplink" + +// ResolvConf is the file the uplink's holder writes (ADR 0223). +const ResolvConf = "/etc/resolv.conf" + +// meshHeader is how the mesh's own resolver file begins: what every uplink holder declares. +const meshHeader = "# Managed by the mesh" + +// Machine is what the verbs read, replaced in tests. +type Machine struct { + ResolvPath string + Run func(ctx context.Context, name string, args ...string) (string, error) + Running func() []string + // LinkDNS is the resolvers and search domains the machine's network manager knows per link. + LinkDNS func(ctx context.Context) (map[string]LinkDNS, error) +} + +// LinkDNS is what the network manager knows of one link's names. +type LinkDNS struct { + Servers []string `json:"servers,omitempty"` + Domains []string `json:"domains,omitempty"` + // Manager is the program that said so, and State its word for the link. + Manager string `json:"manager,omitempty"` + State string `json:"state,omitempty"` +} + +// Resolvers is the resolver file as it is now. +type Resolvers struct { + Path string `json:"path"` + Link string `json:"link,omitempty"` + Nameservers []string `json:"nameservers"` + Search []string `json:"search,omitempty"` + Options []string `json:"options,omitempty"` + // Header is the file's leading comment lines, the first four. + Header []string `json:"header,omitempty"` + // WrittenByTheMesh says the file is the one the uplink holder declares, by its header. + WrittenByTheMesh bool `json:"written_by_the_mesh"` + Changed time.Time `json:"changed"` + Owner string `json:"owner,omitempty"` + // Writer is who wrote it when the mesh did not, as far as the machine shows, and Why that name. + Writer string `json:"writer,omitempty"` + Why string `json:"why,omitempty"` + // Beside is every file next to it whose name starts with its own: a backup a writer kept. + Beside []BesideFile `json:"beside,omitempty"` +} + +// BesideFile is one file beside the resolver file. +type BesideFile struct { + Path string `json:"path"` + Changed time.Time `json:"changed"` + // Mesh says it begins as the mesh's own file does: the file a writer moved aside. + Mesh bool `json:"mesh,omitempty"` +} + +// ReadResolvers answers the resolvers verb. +func (m Machine) ReadResolvers() (Resolvers, error) { + path := m.ResolvPath + r := Resolvers{Path: path, Nameservers: []string{}} + info, err := os.Lstat(path) + if err != nil { + return r, fmt.Errorf("the resolver file cannot be read: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + r.Link, _ = os.Readlink(path) + r.Writer, r.Why = writerOfLink(r.Link), "it is a link to "+r.Link + } + raw, err := os.ReadFile(path) + if err != nil { + return r, fmt.Errorf("the resolver file cannot be read: %w", err) + } + if real, err := os.Stat(path); err == nil { + r.Changed = real.ModTime().UTC() + r.Owner = ownerOf(real) + } + content := string(raw) + for _, line := range strings.Split(content, "\n") { + f := strings.Fields(line) + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";"): + // The leading comment, up to four lines: a writer names itself in its first. + if len(r.Nameservers) == 0 && len(r.Search) == 0 && len(r.Options) == 0 && len(r.Header) < 4 { + r.Header = append(r.Header, trimmed) + } + case len(f) >= 2 && f[0] == "nameserver": + r.Nameservers = append(r.Nameservers, f[1]) + case len(f) >= 2 && (f[0] == "search" || f[0] == "domain"): + r.Search = append(r.Search, f[1:]...) + case len(f) >= 2 && f[0] == "options": + r.Options = append(r.Options, f[1:]...) + } + } + r.WrittenByTheMesh = r.Link == "" && strings.HasPrefix(strings.TrimSpace(content), meshHeader) + matches, _ := filepath.Glob(path + "*") + for _, p := range matches { + if p == path { + continue + } + bi, err := os.Stat(p) + if err != nil || bi.IsDir() { + continue + } + b := BesideFile{Path: p, Changed: bi.ModTime().UTC()} + if head, err := os.ReadFile(p); err == nil { + b.Mesh = strings.HasPrefix(strings.TrimSpace(string(head)), meshHeader) + } + r.Beside = append(r.Beside, b) + } + if !r.WrittenByTheMesh && r.Writer == "" { + r.Writer, r.Why = m.writerOf(r) + } + return r, nil +} + +// signs are the words a writer leaves in the file's comments or a backup's name, and its name. +var signs = []struct{ word, name string }{ + {"forti", "FortiClient"}, + {"openfortivpn", "openfortivpn"}, + {"networkmanager", "NetworkManager"}, + {"systemd-resolved", "systemd-resolved"}, + {"resolvconf", "resolvconf"}, + {"dhcpcd", "dhcpcd"}, + {"dhclient", "dhclient"}, + {"netconfig", "netconfig"}, + {"openvpn", "OpenVPN"}, + {"openconnect", "OpenConnect"}, + {"vpnc", "vpnc"}, + {"tailscale", "Tailscale"}, + {"connman", "ConnMan"}, +} + +// writers are the programs known to rewrite the file, as they run, and their name. +var writers = []struct{ comm, name string }{ + {"fortivpn", "FortiClient"}, + {"forticlient", "FortiClient"}, + {"fctsched", "FortiClient"}, + {"openfortivpn", "openfortivpn"}, + {"openvpn", "OpenVPN"}, + {"openconnect", "OpenConnect"}, + {"vpnc", "vpnc"}, + {"charon", "strongSwan"}, + {"tailscaled", "Tailscale"}, + {"dhclient", "dhclient"}, + {"resolvconf", "resolvconf"}, +} + +// writerOf names who wrote a file the mesh did not: its header, a backup named for its writer, a writer +// running (said as a guess). Nothing found is said as nothing found. +func (m Machine) writerOf(r Resolvers) (string, string) { + for _, line := range r.Header { + lower := strings.ToLower(line) + for _, s := range signs { + if strings.Contains(lower, s.word) { + return s.name, "its own header names " + s.name + } + } + } + for _, b := range r.Beside { + lower := strings.ToLower(filepath.Base(b.Path)) + for _, s := range signs { + if strings.Contains(lower, s.word) { + return s.name, "it left " + b.Path + " beside it" + } + } + } + running := map[string]bool{} + if m.Running != nil { + for _, n := range m.Running() { + running[strings.ToLower(n)] = true + } + } + for _, w := range writers { + if running[w.comm] { + return w.name + "?", w.comm + " is running" + } + } + return "", "no program it could be is known" +} + +func writerOfLink(target string) string { + lower := strings.ToLower(target) + switch { + case strings.Contains(lower, "systemd/resolve"): + return "systemd-resolved" + case strings.Contains(lower, "resolvconf"): + return "resolvconf" + case strings.Contains(lower, "networkmanager"): + return "NetworkManager" + } + return "" +} + +// Link is one network link, as the links verb says it. +type Link struct { + Name string `json:"name"` + State string `json:"state"` + Kind string `json:"kind,omitempty"` + Addresses []string `json:"addresses,omitempty"` + // Default says the default route leaves through it, and Metric that route's metric. + Default bool `json:"default_route,omitempty"` + Metric *int `json:"metric,omitempty"` + DNS *LinkDNS `json:"dns,omitempty"` +} + +// Links answers the links verb: every link from the kernel, its default route, and what the manager +// knows of its names. A manager that cannot be asked is said, never read as no resolvers. +func (m Machine) Links(ctx context.Context) (map[string]any, error) { + rawAddrs, err := m.Run(ctx, "ip", "-j", "address", "show") + if err != nil { + return nil, fmt.Errorf("the links cannot be read: %w", err) + } + var addrs []struct { + Name string `json:"ifname"` + State string `json:"operstate"` + Kind string `json:"link_type"` + AddrInfo []struct { + Local string `json:"local"` + Prefix int `json:"prefixlen"` + Scope string `json:"scope"` + } `json:"addr_info"` + } + if err := json.Unmarshal([]byte(rawAddrs), &addrs); err != nil { + return nil, fmt.Errorf("the links cannot be read: %w", err) + } + defaults := map[string]int{} + for _, family := range []string{"-4", "-6"} { + out, err := m.Run(ctx, "ip", "-j", family, "route", "show", "default") + if err != nil { + continue + } + var routes []struct { + Dev string `json:"dev"` + Metric int `json:"metric"` + } + if json.Unmarshal([]byte(out), &routes) == nil { + for _, r := range routes { + if r.Dev != "" && r.Dev != "lo" { + if have, ok := defaults[r.Dev]; !ok || r.Metric < have { + defaults[r.Dev] = r.Metric + } + } + } + } + } + answer := map[string]any{} + var dns map[string]LinkDNS + if m.LinkDNS != nil { + if dns, err = m.LinkDNS(ctx); err != nil { + answer["dns_not_read"] = err.Error() + } + } + links := []Link{} + for _, a := range addrs { + l := Link{Name: a.Name, State: a.State, Kind: a.Kind} + for _, ai := range a.AddrInfo { + l.Addresses = append(l.Addresses, a2s(ai.Local, ai.Prefix)) + } + if metric, ok := defaults[a.Name]; ok { + l.Default, l.Metric = true, &metric + } + if d, ok := dns[a.Name]; ok { + l.DNS = &d + } + links = append(links, l) + } + sort.SliceStable(links, func(i, j int) bool { return links[i].Default && !links[j].Default }) + answer["links"] = links + return answer, nil +} + +// ownerOf is the account that owns a file, by name where it has one. +func ownerOf(fi os.FileInfo) string { + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return "" + } + id := strconv.FormatUint(uint64(st.Uid), 10) + if u, err := user.LookupId(id); err == nil { + return u.Username + } + return id +} + +func a2s(addr string, prefix int) string { return addr + "/" + strconv.Itoa(prefix) } + +// running is the names of the programs running, from /proc. +func running() []string { + entries, err := os.ReadDir("/proc") + if err != nil { + return nil + } + seen := map[string]bool{} + for _, e := range entries { + if _, err := strconv.Atoi(e.Name()); err != nil { + continue + } + if comm, err := os.ReadFile(filepath.Join("/proc", e.Name(), "comm")); err == nil { + seen[strings.TrimSpace(string(comm))] = true + } + } + out := make([]string, 0, len(seen)) + for n := range seen { + out = append(out, n) + } + sort.Strings(out) + return out +} + +func execRunner(ctx context.Context, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { + return "", fmt.Errorf("%s: %s", name, strings.TrimSpace(string(ee.Stderr))) + } + return "", err + } + return string(out), nil +} diff --git a/modules/networkmanager/cmd/uplink-tools/uplink_test.go b/modules/networkmanager/cmd/uplink-tools/uplink_test.go new file mode 100644 index 0000000..376e3d8 --- /dev/null +++ b/modules/networkmanager/cmd/uplink-tools/uplink_test.go @@ -0,0 +1,138 @@ +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) + } +} diff --git a/modules/networkmanager/go.mod b/modules/networkmanager/go.mod new file mode 100644 index 0000000..064cb31 --- /dev/null +++ b/modules/networkmanager/go.mod @@ -0,0 +1,5 @@ +module networkmanager + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/networkmanager/go.sum b/modules/networkmanager/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/networkmanager/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/networkmanager/module.json b/modules/networkmanager/module.json index d039a49..6d0cbae 100644 --- a/modules/networkmanager/module.json +++ b/modules/networkmanager/module.json @@ -20,6 +20,21 @@ "scope": "node" } ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/uplink-tools", + "binary": "uplink-tools", + "loads": [ + "uplink-tools" + ] + } + ] + }, "facts": { "resolvers": { "path": "/etc/resolv.conf", diff --git a/modules/networkmanager/uplink-tools b/modules/networkmanager/uplink-tools new file mode 100755 index 0000000..cc19767 Binary files /dev/null and b/modules/networkmanager/uplink-tools differ diff --git a/modules/systemd-networkd/cmd/uplink-tools/copies_test.go b/modules/systemd-networkd/cmd/uplink-tools/copies_test.go new file mode 100644 index 0000000..f782359 --- /dev/null +++ b/modules/systemd-networkd/cmd/uplink-tools/copies_test.go @@ -0,0 +1,38 @@ +package main + +// The holders of node-uplink whose bundles carry uplink.go (novox/hq ADR 0241). Each builds alone, so each +// has its own copy; this test, itself one of the copied files, holds them to one text wherever the siblings +// are present. + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +var holders = []string{"networkmanager", "systemd-networkd"} + +func TestEveryUplinkHolderCarriesTheSameCopy(t *testing.T) { + compared := 0 + for _, module := range holders { + dir := filepath.Join("..", "..", "..", module, "cmd", "uplink-tools") + if _, err := os.Stat(dir); err != nil { + continue + } + for _, f := range []string{"uplink.go", "uplink_test.go", "main.go", "copies_test.go"} { + mine, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + theirs, err := os.ReadFile(filepath.Join(dir, f)) + if err != nil || !bytes.Equal(mine, theirs) { + t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f) + } + } + compared++ + } + if compared == 0 { + t.Log("no sibling copies beside this module") + } +} diff --git a/modules/systemd-networkd/cmd/uplink-tools/main.go b/modules/systemd-networkd/cmd/uplink-tools/main.go new file mode 100644 index 0000000..d2aed1d --- /dev/null +++ b/modules/systemd-networkd/cmd/uplink-tools/main.go @@ -0,0 +1,35 @@ +// The uplink holder's tools bundle: the node-uplink seat's verbs (novox/hq ADR 0241), served by the node's +// runtime as the operator account. uplink.go is every holder's; manager.go is this holder's own. stdout is +// the MCP channel; this says nothing else. +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func tools(m Machine) []stdio.Tool { + return []stdio.Tool{ + {Name: seat + ".resolvers", + Description: "The machine's resolver file as it is now: the resolvers, search domains and options it lists, " + + "whether it is the file the mesh declares, and when it is not, who wrote it as far as the machine shows " + + "— its header, a backup a VPN client left beside it, a link in its place, a known writer running. (r)", + Run: func(map[string]any) (any, error) { return m.ReadResolvers() }}, + {Name: seat + ".links", + Description: "Every network link on the machine: its state, its addresses, whether the default route " + + "leaves through it, and the resolvers and search domains the network manager knows for it — a VPN's " + + "tunnel included. (r)", + Run: func(map[string]any) (any, error) { return m.Links(context.Background()) }}, + } +} + +func main() { + m := Machine{ResolvPath: ResolvConf, Run: execRunner, Running: running, LinkDNS: managerDNS} + if err := stdio.Serve("", tools(m)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/modules/systemd-networkd/cmd/uplink-tools/manager.go b/modules/systemd-networkd/cmd/uplink-tools/manager.go new file mode 100644 index 0000000..2710ecb --- /dev/null +++ b/modules/systemd-networkd/cmd/uplink-tools/manager.go @@ -0,0 +1,67 @@ +package main + +// systemd-networkd's word on each link's names: `networkctl status --all`, each link's block read for its +// DNS servers and search domains, which networkd prints whether or not systemd-resolved runs. A link +// networkd does not manage — a tunnel a VPN client raised with `ip`, the mesh's own — says none, and the +// links verb says the link without them. + +import ( + "context" + "regexp" + "strings" +) + +// keyed is a line that names its key: a capitalised name of words, a colon, the value. +var keyed = regexp.MustCompile(`^([A-Z][A-Za-z0-9 ]*): (.*)$`) + +func managerDNS(ctx context.Context) (map[string]LinkDNS, error) { + out, err := execRunner(ctx, "networkctl", "status", "--all", "--no-pager", "--no-legend") + if err != nil { + return nil, err + } + return parseNetworkctl(out), nil +} + +// parseNetworkctl reads `networkctl status --all`: a block per link, headed "● : ", whose +// "Key: value" lines continue on indented lines with no key. +func parseNetworkctl(out string) map[string]LinkDNS { + links := map[string]LinkDNS{} + device, key := "", "" + var cur LinkDNS + flush := func() { + if device != "" && (len(cur.Servers) > 0 || len(cur.Domains) > 0) { + cur.Manager = "systemd-networkd" + links[device] = cur + } + device, key, cur = "", "", LinkDNS{} + } + for _, line := range strings.Split(out, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "●") { + flush() + if _, name, ok := strings.Cut(strings.TrimSpace(strings.TrimPrefix(trimmed, "●")), ": "); ok { + device = strings.TrimSpace(name) + } + continue + } + if trimmed == "" { + key = "" + continue + } + value := trimmed + if m := keyed.FindStringSubmatch(trimmed); m != nil { + key, value = m[1], strings.TrimSpace(m[2]) + } + switch key { + case "DNS": + cur.Servers = append(cur.Servers, value) + case "Search Domains": + cur.Domains = append(cur.Domains, strings.Fields(value)...) + case "State": + cur.State = value + key = "" + } + } + flush() + return links +} diff --git a/modules/systemd-networkd/cmd/uplink-tools/manager_test.go b/modules/systemd-networkd/cmd/uplink-tools/manager_test.go new file mode 100644 index 0000000..c41fa0a --- /dev/null +++ b/modules/systemd-networkd/cmd/uplink-tools/manager_test.go @@ -0,0 +1,29 @@ +package main + +import "testing" + +// systemd-networkd's word on each link's names, read from `networkctl status --all`. +func TestNetworkctlIsReadPerLink(t *testing.T) { + out := `● 1: lo + Link File: n/a + Network File: n/a + State: carrier (unmanaged) + +● 2: eth0 + Link File: /usr/lib/systemd/network/99-default.link + Network File: /etc/systemd/network/20-wired.network + State: routable (configured) + Address: 198.51.100.7 + Gateway: 198.51.100.1 + DNS: 198.51.100.53 + 198.51.100.54 + Search Domains: example.net lab.example + Activation Policy: up +` + links := parseNetworkctl(out) + eth := links["eth0"] + if len(links) != 1 || len(eth.Servers) != 2 || eth.Servers[1] != "198.51.100.54" || len(eth.Domains) != 2 || + eth.State != "routable (configured)" || eth.Manager != "systemd-networkd" { + t.Fatalf("read %+v", links) + } +} diff --git a/modules/systemd-networkd/cmd/uplink-tools/uplink.go b/modules/systemd-networkd/cmd/uplink-tools/uplink.go new file mode 100644 index 0000000..d5e85ed --- /dev/null +++ b/modules/systemd-networkd/cmd/uplink-tools/uplink.go @@ -0,0 +1,345 @@ +// The node-uplink seat's verbs (novox/hq ADR 0241), as every holder serves them: what the machine resolves +// through and over which links. Each holder builds alone, so each carries this file; copies_test.go holds +// the copies to one text. What differs between holders — which resolvers the network manager knows for a +// link — is the holder's own manager.go. +// +// **Read only.** Nothing here writes the resolver file, changes a link or asks a manager to. The answers +// stay inside the mesh: the resolver file's servers and search domains are said as they are, a VPN's +// included; a credential, a profile or a gateway's secret is never read. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +const seat = "node-uplink" + +// ResolvConf is the file the uplink's holder writes (ADR 0223). +const ResolvConf = "/etc/resolv.conf" + +// meshHeader is how the mesh's own resolver file begins: what every uplink holder declares. +const meshHeader = "# Managed by the mesh" + +// Machine is what the verbs read, replaced in tests. +type Machine struct { + ResolvPath string + Run func(ctx context.Context, name string, args ...string) (string, error) + Running func() []string + // LinkDNS is the resolvers and search domains the machine's network manager knows per link. + LinkDNS func(ctx context.Context) (map[string]LinkDNS, error) +} + +// LinkDNS is what the network manager knows of one link's names. +type LinkDNS struct { + Servers []string `json:"servers,omitempty"` + Domains []string `json:"domains,omitempty"` + // Manager is the program that said so, and State its word for the link. + Manager string `json:"manager,omitempty"` + State string `json:"state,omitempty"` +} + +// Resolvers is the resolver file as it is now. +type Resolvers struct { + Path string `json:"path"` + Link string `json:"link,omitempty"` + Nameservers []string `json:"nameservers"` + Search []string `json:"search,omitempty"` + Options []string `json:"options,omitempty"` + // Header is the file's leading comment lines, the first four. + Header []string `json:"header,omitempty"` + // WrittenByTheMesh says the file is the one the uplink holder declares, by its header. + WrittenByTheMesh bool `json:"written_by_the_mesh"` + Changed time.Time `json:"changed"` + Owner string `json:"owner,omitempty"` + // Writer is who wrote it when the mesh did not, as far as the machine shows, and Why that name. + Writer string `json:"writer,omitempty"` + Why string `json:"why,omitempty"` + // Beside is every file next to it whose name starts with its own: a backup a writer kept. + Beside []BesideFile `json:"beside,omitempty"` +} + +// BesideFile is one file beside the resolver file. +type BesideFile struct { + Path string `json:"path"` + Changed time.Time `json:"changed"` + // Mesh says it begins as the mesh's own file does: the file a writer moved aside. + Mesh bool `json:"mesh,omitempty"` +} + +// ReadResolvers answers the resolvers verb. +func (m Machine) ReadResolvers() (Resolvers, error) { + path := m.ResolvPath + r := Resolvers{Path: path, Nameservers: []string{}} + info, err := os.Lstat(path) + if err != nil { + return r, fmt.Errorf("the resolver file cannot be read: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + r.Link, _ = os.Readlink(path) + r.Writer, r.Why = writerOfLink(r.Link), "it is a link to "+r.Link + } + raw, err := os.ReadFile(path) + if err != nil { + return r, fmt.Errorf("the resolver file cannot be read: %w", err) + } + if real, err := os.Stat(path); err == nil { + r.Changed = real.ModTime().UTC() + r.Owner = ownerOf(real) + } + content := string(raw) + for _, line := range strings.Split(content, "\n") { + f := strings.Fields(line) + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";"): + // The leading comment, up to four lines: a writer names itself in its first. + if len(r.Nameservers) == 0 && len(r.Search) == 0 && len(r.Options) == 0 && len(r.Header) < 4 { + r.Header = append(r.Header, trimmed) + } + case len(f) >= 2 && f[0] == "nameserver": + r.Nameservers = append(r.Nameservers, f[1]) + case len(f) >= 2 && (f[0] == "search" || f[0] == "domain"): + r.Search = append(r.Search, f[1:]...) + case len(f) >= 2 && f[0] == "options": + r.Options = append(r.Options, f[1:]...) + } + } + r.WrittenByTheMesh = r.Link == "" && strings.HasPrefix(strings.TrimSpace(content), meshHeader) + matches, _ := filepath.Glob(path + "*") + for _, p := range matches { + if p == path { + continue + } + bi, err := os.Stat(p) + if err != nil || bi.IsDir() { + continue + } + b := BesideFile{Path: p, Changed: bi.ModTime().UTC()} + if head, err := os.ReadFile(p); err == nil { + b.Mesh = strings.HasPrefix(strings.TrimSpace(string(head)), meshHeader) + } + r.Beside = append(r.Beside, b) + } + if !r.WrittenByTheMesh && r.Writer == "" { + r.Writer, r.Why = m.writerOf(r) + } + return r, nil +} + +// signs are the words a writer leaves in the file's comments or a backup's name, and its name. +var signs = []struct{ word, name string }{ + {"forti", "FortiClient"}, + {"openfortivpn", "openfortivpn"}, + {"networkmanager", "NetworkManager"}, + {"systemd-resolved", "systemd-resolved"}, + {"resolvconf", "resolvconf"}, + {"dhcpcd", "dhcpcd"}, + {"dhclient", "dhclient"}, + {"netconfig", "netconfig"}, + {"openvpn", "OpenVPN"}, + {"openconnect", "OpenConnect"}, + {"vpnc", "vpnc"}, + {"tailscale", "Tailscale"}, + {"connman", "ConnMan"}, +} + +// writers are the programs known to rewrite the file, as they run, and their name. +var writers = []struct{ comm, name string }{ + {"fortivpn", "FortiClient"}, + {"forticlient", "FortiClient"}, + {"fctsched", "FortiClient"}, + {"openfortivpn", "openfortivpn"}, + {"openvpn", "OpenVPN"}, + {"openconnect", "OpenConnect"}, + {"vpnc", "vpnc"}, + {"charon", "strongSwan"}, + {"tailscaled", "Tailscale"}, + {"dhclient", "dhclient"}, + {"resolvconf", "resolvconf"}, +} + +// writerOf names who wrote a file the mesh did not: its header, a backup named for its writer, a writer +// running (said as a guess). Nothing found is said as nothing found. +func (m Machine) writerOf(r Resolvers) (string, string) { + for _, line := range r.Header { + lower := strings.ToLower(line) + for _, s := range signs { + if strings.Contains(lower, s.word) { + return s.name, "its own header names " + s.name + } + } + } + for _, b := range r.Beside { + lower := strings.ToLower(filepath.Base(b.Path)) + for _, s := range signs { + if strings.Contains(lower, s.word) { + return s.name, "it left " + b.Path + " beside it" + } + } + } + running := map[string]bool{} + if m.Running != nil { + for _, n := range m.Running() { + running[strings.ToLower(n)] = true + } + } + for _, w := range writers { + if running[w.comm] { + return w.name + "?", w.comm + " is running" + } + } + return "", "no program it could be is known" +} + +func writerOfLink(target string) string { + lower := strings.ToLower(target) + switch { + case strings.Contains(lower, "systemd/resolve"): + return "systemd-resolved" + case strings.Contains(lower, "resolvconf"): + return "resolvconf" + case strings.Contains(lower, "networkmanager"): + return "NetworkManager" + } + return "" +} + +// Link is one network link, as the links verb says it. +type Link struct { + Name string `json:"name"` + State string `json:"state"` + Kind string `json:"kind,omitempty"` + Addresses []string `json:"addresses,omitempty"` + // Default says the default route leaves through it, and Metric that route's metric. + Default bool `json:"default_route,omitempty"` + Metric *int `json:"metric,omitempty"` + DNS *LinkDNS `json:"dns,omitempty"` +} + +// Links answers the links verb: every link from the kernel, its default route, and what the manager +// knows of its names. A manager that cannot be asked is said, never read as no resolvers. +func (m Machine) Links(ctx context.Context) (map[string]any, error) { + rawAddrs, err := m.Run(ctx, "ip", "-j", "address", "show") + if err != nil { + return nil, fmt.Errorf("the links cannot be read: %w", err) + } + var addrs []struct { + Name string `json:"ifname"` + State string `json:"operstate"` + Kind string `json:"link_type"` + AddrInfo []struct { + Local string `json:"local"` + Prefix int `json:"prefixlen"` + Scope string `json:"scope"` + } `json:"addr_info"` + } + if err := json.Unmarshal([]byte(rawAddrs), &addrs); err != nil { + return nil, fmt.Errorf("the links cannot be read: %w", err) + } + defaults := map[string]int{} + for _, family := range []string{"-4", "-6"} { + out, err := m.Run(ctx, "ip", "-j", family, "route", "show", "default") + if err != nil { + continue + } + var routes []struct { + Dev string `json:"dev"` + Metric int `json:"metric"` + } + if json.Unmarshal([]byte(out), &routes) == nil { + for _, r := range routes { + if r.Dev != "" && r.Dev != "lo" { + if have, ok := defaults[r.Dev]; !ok || r.Metric < have { + defaults[r.Dev] = r.Metric + } + } + } + } + } + answer := map[string]any{} + var dns map[string]LinkDNS + if m.LinkDNS != nil { + if dns, err = m.LinkDNS(ctx); err != nil { + answer["dns_not_read"] = err.Error() + } + } + links := []Link{} + for _, a := range addrs { + l := Link{Name: a.Name, State: a.State, Kind: a.Kind} + for _, ai := range a.AddrInfo { + l.Addresses = append(l.Addresses, a2s(ai.Local, ai.Prefix)) + } + if metric, ok := defaults[a.Name]; ok { + l.Default, l.Metric = true, &metric + } + if d, ok := dns[a.Name]; ok { + l.DNS = &d + } + links = append(links, l) + } + sort.SliceStable(links, func(i, j int) bool { return links[i].Default && !links[j].Default }) + answer["links"] = links + return answer, nil +} + +// ownerOf is the account that owns a file, by name where it has one. +func ownerOf(fi os.FileInfo) string { + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return "" + } + id := strconv.FormatUint(uint64(st.Uid), 10) + if u, err := user.LookupId(id); err == nil { + return u.Username + } + return id +} + +func a2s(addr string, prefix int) string { return addr + "/" + strconv.Itoa(prefix) } + +// running is the names of the programs running, from /proc. +func running() []string { + entries, err := os.ReadDir("/proc") + if err != nil { + return nil + } + seen := map[string]bool{} + for _, e := range entries { + if _, err := strconv.Atoi(e.Name()); err != nil { + continue + } + if comm, err := os.ReadFile(filepath.Join("/proc", e.Name(), "comm")); err == nil { + seen[strings.TrimSpace(string(comm))] = true + } + } + out := make([]string, 0, len(seen)) + for n := range seen { + out = append(out, n) + } + sort.Strings(out) + return out +} + +func execRunner(ctx context.Context, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { + return "", fmt.Errorf("%s: %s", name, strings.TrimSpace(string(ee.Stderr))) + } + return "", err + } + return string(out), nil +} diff --git a/modules/systemd-networkd/cmd/uplink-tools/uplink_test.go b/modules/systemd-networkd/cmd/uplink-tools/uplink_test.go new file mode 100644 index 0000000..376e3d8 --- /dev/null +++ b/modules/systemd-networkd/cmd/uplink-tools/uplink_test.go @@ -0,0 +1,138 @@ +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) + } +} diff --git a/modules/systemd-networkd/go.mod b/modules/systemd-networkd/go.mod new file mode 100644 index 0000000..ff279a7 --- /dev/null +++ b/modules/systemd-networkd/go.mod @@ -0,0 +1,5 @@ +module systemd-networkd + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/systemd-networkd/go.sum b/modules/systemd-networkd/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/systemd-networkd/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/systemd-networkd/module.json b/modules/systemd-networkd/module.json index 46d4dd2..80c783f 100644 --- a/modules/systemd-networkd/module.json +++ b/modules/systemd-networkd/module.json @@ -19,6 +19,21 @@ "scope": "node" } ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/uplink-tools", + "binary": "uplink-tools", + "loads": [ + "uplink-tools" + ] + } + ] + }, "facts": { "resolvers": { "path": "/etc/resolv.conf",