Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c619a672a3 |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module networkmanager
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -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",
|
||||
|
||||
Executable
BIN
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module systemd-networkd
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user