mesh/merge-gate pass: builds networkmanager, systemd-networkd, sent nowhere; no bus step; 4 wait(s) for a person; every machine composes with the change as…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/node-uplink-verbs delivered: every member is delivered
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.
346 lines
11 KiB
Go
346 lines
11 KiB
Go
// The node-uplink seat's verbs (novox/hq ADR 0241), as every holder serves them: what the machine resolves
|
|
// through and over which links. Each holder builds alone, so each carries this file; copies_test.go holds
|
|
// the copies to one text. What differs between holders — which resolvers the network manager knows for a
|
|
// link — is the holder's own manager.go.
|
|
//
|
|
// **Read only.** Nothing here writes the resolver file, changes a link or asks a manager to. The answers
|
|
// stay inside the mesh: the resolver file's servers and search domains are said as they are, a VPN's
|
|
// included; a credential, a profile or a gateway's secret is never read.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
const seat = "node-uplink"
|
|
|
|
// ResolvConf is the file the uplink's holder writes (ADR 0223).
|
|
const ResolvConf = "/etc/resolv.conf"
|
|
|
|
// meshHeader is how the mesh's own resolver file begins: what every uplink holder declares.
|
|
const meshHeader = "# Managed by the mesh"
|
|
|
|
// Machine is what the verbs read, replaced in tests.
|
|
type Machine struct {
|
|
ResolvPath string
|
|
Run func(ctx context.Context, name string, args ...string) (string, error)
|
|
Running func() []string
|
|
// LinkDNS is the resolvers and search domains the machine's network manager knows per link.
|
|
LinkDNS func(ctx context.Context) (map[string]LinkDNS, error)
|
|
}
|
|
|
|
// LinkDNS is what the network manager knows of one link's names.
|
|
type LinkDNS struct {
|
|
Servers []string `json:"servers,omitempty"`
|
|
Domains []string `json:"domains,omitempty"`
|
|
// Manager is the program that said so, and State its word for the link.
|
|
Manager string `json:"manager,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
}
|
|
|
|
// Resolvers is the resolver file as it is now.
|
|
type Resolvers struct {
|
|
Path string `json:"path"`
|
|
Link string `json:"link,omitempty"`
|
|
Nameservers []string `json:"nameservers"`
|
|
Search []string `json:"search,omitempty"`
|
|
Options []string `json:"options,omitempty"`
|
|
// Header is the file's leading comment lines, the first four.
|
|
Header []string `json:"header,omitempty"`
|
|
// WrittenByTheMesh says the file is the one the uplink holder declares, by its header.
|
|
WrittenByTheMesh bool `json:"written_by_the_mesh"`
|
|
Changed time.Time `json:"changed"`
|
|
Owner string `json:"owner,omitempty"`
|
|
// Writer is who wrote it when the mesh did not, as far as the machine shows, and Why that name.
|
|
Writer string `json:"writer,omitempty"`
|
|
Why string `json:"why,omitempty"`
|
|
// Beside is every file next to it whose name starts with its own: a backup a writer kept.
|
|
Beside []BesideFile `json:"beside,omitempty"`
|
|
}
|
|
|
|
// BesideFile is one file beside the resolver file.
|
|
type BesideFile struct {
|
|
Path string `json:"path"`
|
|
Changed time.Time `json:"changed"`
|
|
// Mesh says it begins as the mesh's own file does: the file a writer moved aside.
|
|
Mesh bool `json:"mesh,omitempty"`
|
|
}
|
|
|
|
// ReadResolvers answers the resolvers verb.
|
|
func (m Machine) ReadResolvers() (Resolvers, error) {
|
|
path := m.ResolvPath
|
|
r := Resolvers{Path: path, Nameservers: []string{}}
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return r, fmt.Errorf("the resolver file cannot be read: %w", err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
r.Link, _ = os.Readlink(path)
|
|
r.Writer, r.Why = writerOfLink(r.Link), "it is a link to "+r.Link
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return r, fmt.Errorf("the resolver file cannot be read: %w", err)
|
|
}
|
|
if real, err := os.Stat(path); err == nil {
|
|
r.Changed = real.ModTime().UTC()
|
|
r.Owner = ownerOf(real)
|
|
}
|
|
content := string(raw)
|
|
for _, line := range strings.Split(content, "\n") {
|
|
f := strings.Fields(line)
|
|
trimmed := strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";"):
|
|
// The leading comment, up to four lines: a writer names itself in its first.
|
|
if len(r.Nameservers) == 0 && len(r.Search) == 0 && len(r.Options) == 0 && len(r.Header) < 4 {
|
|
r.Header = append(r.Header, trimmed)
|
|
}
|
|
case len(f) >= 2 && f[0] == "nameserver":
|
|
r.Nameservers = append(r.Nameservers, f[1])
|
|
case len(f) >= 2 && (f[0] == "search" || f[0] == "domain"):
|
|
r.Search = append(r.Search, f[1:]...)
|
|
case len(f) >= 2 && f[0] == "options":
|
|
r.Options = append(r.Options, f[1:]...)
|
|
}
|
|
}
|
|
r.WrittenByTheMesh = r.Link == "" && strings.HasPrefix(strings.TrimSpace(content), meshHeader)
|
|
matches, _ := filepath.Glob(path + "*")
|
|
for _, p := range matches {
|
|
if p == path {
|
|
continue
|
|
}
|
|
bi, err := os.Stat(p)
|
|
if err != nil || bi.IsDir() {
|
|
continue
|
|
}
|
|
b := BesideFile{Path: p, Changed: bi.ModTime().UTC()}
|
|
if head, err := os.ReadFile(p); err == nil {
|
|
b.Mesh = strings.HasPrefix(strings.TrimSpace(string(head)), meshHeader)
|
|
}
|
|
r.Beside = append(r.Beside, b)
|
|
}
|
|
if !r.WrittenByTheMesh && r.Writer == "" {
|
|
r.Writer, r.Why = m.writerOf(r)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// signs are the words a writer leaves in the file's comments or a backup's name, and its name.
|
|
var signs = []struct{ word, name string }{
|
|
{"forti", "FortiClient"},
|
|
{"openfortivpn", "openfortivpn"},
|
|
{"networkmanager", "NetworkManager"},
|
|
{"systemd-resolved", "systemd-resolved"},
|
|
{"resolvconf", "resolvconf"},
|
|
{"dhcpcd", "dhcpcd"},
|
|
{"dhclient", "dhclient"},
|
|
{"netconfig", "netconfig"},
|
|
{"openvpn", "OpenVPN"},
|
|
{"openconnect", "OpenConnect"},
|
|
{"vpnc", "vpnc"},
|
|
{"tailscale", "Tailscale"},
|
|
{"connman", "ConnMan"},
|
|
}
|
|
|
|
// writers are the programs known to rewrite the file, as they run, and their name.
|
|
var writers = []struct{ comm, name string }{
|
|
{"fortivpn", "FortiClient"},
|
|
{"forticlient", "FortiClient"},
|
|
{"fctsched", "FortiClient"},
|
|
{"openfortivpn", "openfortivpn"},
|
|
{"openvpn", "OpenVPN"},
|
|
{"openconnect", "OpenConnect"},
|
|
{"vpnc", "vpnc"},
|
|
{"charon", "strongSwan"},
|
|
{"tailscaled", "Tailscale"},
|
|
{"dhclient", "dhclient"},
|
|
{"resolvconf", "resolvconf"},
|
|
}
|
|
|
|
// writerOf names who wrote a file the mesh did not: its header, a backup named for its writer, a writer
|
|
// running (said as a guess). Nothing found is said as nothing found.
|
|
func (m Machine) writerOf(r Resolvers) (string, string) {
|
|
for _, line := range r.Header {
|
|
lower := strings.ToLower(line)
|
|
for _, s := range signs {
|
|
if strings.Contains(lower, s.word) {
|
|
return s.name, "its own header names " + s.name
|
|
}
|
|
}
|
|
}
|
|
for _, b := range r.Beside {
|
|
lower := strings.ToLower(filepath.Base(b.Path))
|
|
for _, s := range signs {
|
|
if strings.Contains(lower, s.word) {
|
|
return s.name, "it left " + b.Path + " beside it"
|
|
}
|
|
}
|
|
}
|
|
running := map[string]bool{}
|
|
if m.Running != nil {
|
|
for _, n := range m.Running() {
|
|
running[strings.ToLower(n)] = true
|
|
}
|
|
}
|
|
for _, w := range writers {
|
|
if running[w.comm] {
|
|
return w.name + "?", w.comm + " is running"
|
|
}
|
|
}
|
|
return "", "no program it could be is known"
|
|
}
|
|
|
|
func writerOfLink(target string) string {
|
|
lower := strings.ToLower(target)
|
|
switch {
|
|
case strings.Contains(lower, "systemd/resolve"):
|
|
return "systemd-resolved"
|
|
case strings.Contains(lower, "resolvconf"):
|
|
return "resolvconf"
|
|
case strings.Contains(lower, "networkmanager"):
|
|
return "NetworkManager"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Link is one network link, as the links verb says it.
|
|
type Link struct {
|
|
Name string `json:"name"`
|
|
State string `json:"state"`
|
|
Kind string `json:"kind,omitempty"`
|
|
Addresses []string `json:"addresses,omitempty"`
|
|
// Default says the default route leaves through it, and Metric that route's metric.
|
|
Default bool `json:"default_route,omitempty"`
|
|
Metric *int `json:"metric,omitempty"`
|
|
DNS *LinkDNS `json:"dns,omitempty"`
|
|
}
|
|
|
|
// Links answers the links verb: every link from the kernel, its default route, and what the manager
|
|
// knows of its names. A manager that cannot be asked is said, never read as no resolvers.
|
|
func (m Machine) Links(ctx context.Context) (map[string]any, error) {
|
|
rawAddrs, err := m.Run(ctx, "ip", "-j", "address", "show")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the links cannot be read: %w", err)
|
|
}
|
|
var addrs []struct {
|
|
Name string `json:"ifname"`
|
|
State string `json:"operstate"`
|
|
Kind string `json:"link_type"`
|
|
AddrInfo []struct {
|
|
Local string `json:"local"`
|
|
Prefix int `json:"prefixlen"`
|
|
Scope string `json:"scope"`
|
|
} `json:"addr_info"`
|
|
}
|
|
if err := json.Unmarshal([]byte(rawAddrs), &addrs); err != nil {
|
|
return nil, fmt.Errorf("the links cannot be read: %w", err)
|
|
}
|
|
defaults := map[string]int{}
|
|
for _, family := range []string{"-4", "-6"} {
|
|
out, err := m.Run(ctx, "ip", "-j", family, "route", "show", "default")
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var routes []struct {
|
|
Dev string `json:"dev"`
|
|
Metric int `json:"metric"`
|
|
}
|
|
if json.Unmarshal([]byte(out), &routes) == nil {
|
|
for _, r := range routes {
|
|
if r.Dev != "" && r.Dev != "lo" {
|
|
if have, ok := defaults[r.Dev]; !ok || r.Metric < have {
|
|
defaults[r.Dev] = r.Metric
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
answer := map[string]any{}
|
|
var dns map[string]LinkDNS
|
|
if m.LinkDNS != nil {
|
|
if dns, err = m.LinkDNS(ctx); err != nil {
|
|
answer["dns_not_read"] = err.Error()
|
|
}
|
|
}
|
|
links := []Link{}
|
|
for _, a := range addrs {
|
|
l := Link{Name: a.Name, State: a.State, Kind: a.Kind}
|
|
for _, ai := range a.AddrInfo {
|
|
l.Addresses = append(l.Addresses, a2s(ai.Local, ai.Prefix))
|
|
}
|
|
if metric, ok := defaults[a.Name]; ok {
|
|
l.Default, l.Metric = true, &metric
|
|
}
|
|
if d, ok := dns[a.Name]; ok {
|
|
l.DNS = &d
|
|
}
|
|
links = append(links, l)
|
|
}
|
|
sort.SliceStable(links, func(i, j int) bool { return links[i].Default && !links[j].Default })
|
|
answer["links"] = links
|
|
return answer, nil
|
|
}
|
|
|
|
// ownerOf is the account that owns a file, by name where it has one.
|
|
func ownerOf(fi os.FileInfo) string {
|
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
id := strconv.FormatUint(uint64(st.Uid), 10)
|
|
if u, err := user.LookupId(id); err == nil {
|
|
return u.Username
|
|
}
|
|
return id
|
|
}
|
|
|
|
func a2s(addr string, prefix int) string { return addr + "/" + strconv.Itoa(prefix) }
|
|
|
|
// running is the names of the programs running, from /proc.
|
|
func running() []string {
|
|
entries, err := os.ReadDir("/proc")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, e := range entries {
|
|
if _, err := strconv.Atoi(e.Name()); err != nil {
|
|
continue
|
|
}
|
|
if comm, err := os.ReadFile(filepath.Join("/proc", e.Name(), "comm")); err == nil {
|
|
seen[strings.TrimSpace(string(comm))] = true
|
|
}
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for n := range seen {
|
|
out = append(out, n)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, name, args...).Output()
|
|
if err != nil {
|
|
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
|
return "", fmt.Errorf("%s: %s", name, strings.TrimSpace(string(ee.Stderr)))
|
|
}
|
|
return "", err
|
|
}
|
|
return string(out), nil
|
|
}
|