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