Serve the uplink seat's verbs from both of its holders (hq ADR 0241)
mesh/delivery-group group feat/node-uplink-verbs delivering: 0 of 2 delivered
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

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.
This commit is contained in:
jochen
2026-10-07 19:19:52 +02:00
parent c74f407abd
commit c619a672a3
19 changed files with 1322 additions and 0 deletions
@@ -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")
}
}
@@ -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)
}
}
@@ -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
}
@@ -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")
}
}
@@ -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
}
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module networkmanager
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -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=
+15
View File
@@ -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",
Binary file not shown.
@@ -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")
}
}
@@ -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)
}
}
@@ -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 "● <index>: <name>", 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
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module systemd-networkd
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -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=
+15
View File
@@ -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",