Files
mesh-catalog/modules/networkmanager/cmd/uplink-tools/uplink.go
T
jochen c619a672a3
mesh/merge-gate pass: builds networkmanager, systemd-networkd, sent nowhere; no bus step; 4 wait(s) for a person; every machine composes with the change as…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/node-uplink-verbs delivered: every member is delivered
Serve the uplink seat's verbs from both of its holders (hq ADR 0241)
What a machine resolves through had no tool: the resolver file and who
wrote it, and every link with its default route and the resolvers its
manager knows. Each holder serves the same two node-uplink verbs; the
reading is one text carried by both, held to it by a test, and only
asking the manager for a link's names is each holder's own.
2026-10-07 19:19:52 +02:00

346 lines
11 KiB
Go

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