mesh/merge-gate fail: builds new: modules/systemd-resolved, sent nowhere; no bus step; a manifest the change touches fails the module check: modules/system…
mesh/repo-check fail: its merge-check.sh failed: long-running resources without health: 70
mesh/delivery superseded: a newer head of the same pull request
Holds node-resolver and provides split-dns at the machine's reach, for a machine whose VPN client pushes resolvers of its own. It writes the resolver file naming the machine's private address, gives resolved the mesh's resolvers as the default route, and serves routes, route and unroute on the mesh and, over a root-only socket, on the machine. Its guard keeps an outside write of the file for the module that handles it and puts the module's file back: at once when taken, after 90 s otherwise, so a write nothing declared to handle is still raised by the node-engine.
379 lines
13 KiB
Go
379 lines
13 KiB
Go
// The node-resolver seat's verbs, done by systemd-resolved (novox/hq ADR 0247): what is routed where,
|
|
// route a set of domains to a set of servers over one link, and take a link's route away.
|
|
//
|
|
// **resolved holds the routes, not this code.** A link's servers and routing domains are resolved's own
|
|
// per-link state, set through resolvectl and forgotten by resolved when the link goes. So nothing here
|
|
// keeps a table that could disagree with what resolved does: `routes` reads resolved, and the two
|
|
// transports that serve these verbs — the mesh, through the node's runtime as the operator account, and
|
|
// the machine, through the guard's socket as root — run the same code against the same daemon.
|
|
//
|
|
// **It knows nothing of any VPN.** A link, domains and servers. What a VPN client pushed is its own
|
|
// module's to read and hand over.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/netip"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Runner runs one command — as root when it changes something — and answers what it printed.
|
|
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
|
|
|
// escalated is the command as it is run: as given when this process is root (the guard), else through
|
|
// sudo without a prompt (the runtime's account), as the hosts file's and the packet filter's verbs do.
|
|
func escalated(uid int, name string, args []string) (string, []string) {
|
|
if uid == 0 {
|
|
return name, args
|
|
}
|
|
return "sudo", append([]string{"-n", name}, args...)
|
|
}
|
|
|
|
// execRunner runs a command that changes resolved's state, escalated.
|
|
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
|
|
return run(ctx, true, name, args...)
|
|
}
|
|
|
|
// readRunner runs a command that only reads, as whoever this process is.
|
|
func readRunner(ctx context.Context, name string, args ...string) (string, error) {
|
|
return run(ctx, false, name, args...)
|
|
}
|
|
|
|
func run(ctx context.Context, escalate bool, name string, args ...string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
program, argv := name, args
|
|
if escalate {
|
|
program, argv = escalated(os.Getuid(), name, args)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
cmd := exec.CommandContext(ctx, program, argv...)
|
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
|
err := cmd.Run()
|
|
if err == nil {
|
|
return stdout.String(), nil
|
|
}
|
|
said := strings.TrimSpace(stdout.String() + stderr.String())
|
|
if program == "sudo" {
|
|
if errors.Is(err, exec.ErrNotFound) {
|
|
return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name)
|
|
}
|
|
if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) {
|
|
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
|
}
|
|
}
|
|
if said != "" {
|
|
return "", fmt.Errorf("%s: %s", name, said)
|
|
}
|
|
return "", fmt.Errorf("%s failed: %v", name, err)
|
|
}
|
|
|
|
// Resolver is systemd-resolved on this machine, as the seat's verbs see it.
|
|
type Resolver struct {
|
|
// Change runs what changes resolved (escalated); Read what only reads it.
|
|
Change, Read Runner
|
|
// NetDir is where the machine's links are listed (/sys/class/net).
|
|
NetDir string
|
|
// SuffixFile holds the mesh's own domain, which is never routed elsewhere.
|
|
SuffixFile string
|
|
}
|
|
|
|
// ThisResolver is the machine's.
|
|
func ThisResolver() *Resolver {
|
|
return &Resolver{Change: execRunner, Read: readRunner, NetDir: "/sys/class/net", SuffixFile: SuffixPath}
|
|
}
|
|
|
|
// SuffixPath is the file the mesh renders the mesh's own domain into (the manifest's fact `suffix`).
|
|
const SuffixPath = "/etc/node-resolver/suffix"
|
|
|
|
// Scope is one place resolved sends names: the machine's global servers (the mesh's resolvers), or a link.
|
|
type Scope struct {
|
|
Link string `json:"link,omitempty"`
|
|
Servers []string `json:"servers"`
|
|
// Domains are the routing domains, without resolved's `~`: every name under one goes to these servers.
|
|
Domains []string `json:"domains"`
|
|
// DefaultRoute is whether names no domain routes may also go here. Only the mesh's resolvers are.
|
|
DefaultRoute *bool `json:"default_route,omitempty"`
|
|
}
|
|
|
|
// Routes is what resolved sends where.
|
|
type Routes struct {
|
|
// Mesh is the global scope: the mesh's resolvers, which answer every name nothing routes elsewhere.
|
|
Mesh Scope `json:"mesh"`
|
|
// Links is every link given servers of its own.
|
|
Links []Scope `json:"links"`
|
|
}
|
|
|
|
var linkLine = regexp.MustCompile(`^Link\s+\d+\s+\(([^)]+)\):\s*(.*)$`)
|
|
|
|
// perScope reads one resolvectl listing (`dns`, `domain`, `default-route`) into the global line and one
|
|
// line per link.
|
|
func perScope(out string) (global []string, links map[string][]string) {
|
|
links = map[string][]string{}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if rest, ok := strings.CutPrefix(line, "Global:"); ok {
|
|
global = strings.Fields(rest)
|
|
continue
|
|
}
|
|
if m := linkLine.FindStringSubmatch(line); m != nil {
|
|
links[m[1]] = strings.Fields(m[2])
|
|
}
|
|
}
|
|
return global, links
|
|
}
|
|
|
|
func unrouted(domains []string) []string {
|
|
out := make([]string, 0, len(domains))
|
|
for _, d := range domains {
|
|
out = append(out, strings.TrimPrefix(d, "~"))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Routes reads what resolved sends where. It changes nothing and needs no root.
|
|
func (r *Resolver) Routes(ctx context.Context) (*Routes, error) {
|
|
dns, err := r.Read(ctx, "resolvectl", "dns")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("systemd-resolved does not answer: %w", err)
|
|
}
|
|
domain, err := r.Read(ctx, "resolvectl", "domain")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("systemd-resolved does not answer: %w", err)
|
|
}
|
|
defaults, _ := r.Read(ctx, "resolvectl", "default-route")
|
|
gServers, lServers := perScope(dns)
|
|
gDomains, lDomains := perScope(domain)
|
|
_, lDefault := perScope(defaults)
|
|
out := &Routes{Mesh: Scope{Servers: orEmpty(gServers), Domains: orEmpty(unrouted(gDomains))}, Links: []Scope{}}
|
|
names := make([]string, 0, len(lServers))
|
|
for name, servers := range lServers {
|
|
if len(servers) > 0 {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
s := Scope{Link: name, Servers: lServers[name], Domains: orEmpty(unrouted(lDomains[name]))}
|
|
if d, ok := lDefault[name]; ok && len(d) > 0 {
|
|
yes := d[0] == "yes"
|
|
s.DefaultRoute = &yes
|
|
}
|
|
out.Links = append(out.Links, s)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func orEmpty(s []string) []string {
|
|
if s == nil {
|
|
return []string{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Routed is what a route did.
|
|
type Routed struct {
|
|
Link string `json:"link"`
|
|
Servers []string `json:"servers"`
|
|
Domains []string `json:"domains"`
|
|
Said string `json:"said"`
|
|
}
|
|
|
|
var (
|
|
linkName = regexp.MustCompile(`^[A-Za-z0-9_.:@-]{1,15}$`)
|
|
domainName = regexp.MustCompile(`^([a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?\.)*[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$`)
|
|
separators = regexp.MustCompile(`[\s,]+`)
|
|
)
|
|
|
|
// Split reads a list given as one string, separated by spaces or commas, or as a list.
|
|
func Split(v any) []string {
|
|
var raw []string
|
|
switch v := v.(type) {
|
|
case string:
|
|
raw = separators.Split(v, -1)
|
|
case []any:
|
|
for _, x := range v {
|
|
if s, ok := x.(string); ok {
|
|
raw = append(raw, separators.Split(s, -1)...)
|
|
}
|
|
}
|
|
case []string:
|
|
for _, s := range v {
|
|
raw = append(raw, separators.Split(s, -1)...)
|
|
}
|
|
}
|
|
out := []string{}
|
|
for _, s := range raw {
|
|
if s = strings.TrimSpace(s); s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// suffix is the mesh's own domain, as the mesh rendered it; "internal" when it has not been yet.
|
|
func (r *Resolver) suffix() string {
|
|
raw, err := os.ReadFile(r.SuffixFile)
|
|
if s := strings.Trim(strings.TrimSpace(string(raw)), "."); err == nil && s != "" {
|
|
return strings.ToLower(s)
|
|
}
|
|
return "internal"
|
|
}
|
|
|
|
// checkLink refuses a link that is not one, loopback, and one that is not on this machine now.
|
|
func (r *Resolver) checkLink(link string) error {
|
|
if !linkName.MatchString(link) {
|
|
return fmt.Errorf("%q is not a link's name", link)
|
|
}
|
|
if link == "lo" {
|
|
return errors.New("loopback carries no servers of its own")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(r.NetDir, link)); err != nil {
|
|
return fmt.Errorf("there is no link %q on this machine now", link)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Domains reads the domains to route: lower-cased, without resolved's `~` or a final dot, each once. The
|
|
// root and the mesh's own domain are refused: routing either away would send the mesh's names, or every
|
|
// name, to servers that are not the mesh's (ADR 0223, ADR 0247).
|
|
func (r *Resolver) Domains(given []string) ([]string, error) {
|
|
suffix := r.suffix()
|
|
seen := map[string]bool{}
|
|
out := []string{}
|
|
for _, d := range given {
|
|
d = strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(d, "~"), "."))
|
|
if d == "" {
|
|
return nil, errors.New("the root domain is every name: only the mesh's resolvers answer every name")
|
|
}
|
|
if !domainName.MatchString(d) || len(d) > 253 {
|
|
return nil, fmt.Errorf("%q is not a domain", d)
|
|
}
|
|
if d == suffix || strings.HasSuffix(d, "."+suffix) {
|
|
return nil, fmt.Errorf("%q is the mesh's own domain: the mesh's names are answered by the mesh's resolvers alone", d)
|
|
}
|
|
if !seen[d] {
|
|
seen[d] = true
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, errors.New("no domain given: a link's servers answer only the domains routed to them")
|
|
}
|
|
if len(out) > 64 {
|
|
return nil, fmt.Errorf("%d domains; at most 64", len(out))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Servers reads the servers: addresses, each once, at most eight.
|
|
func Servers(given []string) ([]string, error) {
|
|
seen := map[string]bool{}
|
|
out := []string{}
|
|
for _, s := range given {
|
|
a, err := netip.ParseAddr(s)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%q is not an address", s)
|
|
}
|
|
if a.IsUnspecified() || a.IsMulticast() {
|
|
return nil, fmt.Errorf("%s cannot answer names", s)
|
|
}
|
|
if !seen[a.String()] {
|
|
seen[a.String()] = true
|
|
out = append(out, a.String())
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, errors.New("no server given")
|
|
}
|
|
if len(out) > 8 {
|
|
return nil, fmt.Errorf("%d servers; at most 8", len(out))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Route sends these domains, and every name under them, to these servers over this link — and only them.
|
|
// Whatever the link was given before is replaced. resolved forgets it when the link goes.
|
|
func (r *Resolver) Route(ctx context.Context, link string, domains, servers []string) (*Routed, error) {
|
|
if err := r.checkLink(link); err != nil {
|
|
return nil, err
|
|
}
|
|
ds, err := r.Domains(domains)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ss, err := Servers(servers)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
routing := make([]string, len(ds))
|
|
for i, d := range ds {
|
|
routing[i] = "~" + d
|
|
}
|
|
// The link is never a default route: names no domain routes go to the mesh's resolvers, so set
|
|
// first, before the servers, that no question but these domains' ever reaches it.
|
|
steps := [][]string{
|
|
{"default-route", link, "false"},
|
|
append([]string{"domain", link}, routing...),
|
|
append([]string{"dns", link}, ss...),
|
|
}
|
|
for _, s := range steps {
|
|
if _, err := r.Change(ctx, "resolvectl", s...); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &Routed{Link: link, Servers: ss, Domains: ds,
|
|
Said: fmt.Sprintf("%d domains go to %d servers over %s; every other name to the mesh's resolvers", len(ds), len(ss), link)}, nil
|
|
}
|
|
|
|
// Unrouted is what taking a route away did.
|
|
type Unrouted struct {
|
|
Link string `json:"link"`
|
|
Said string `json:"said"`
|
|
}
|
|
|
|
// Unroute takes one link's route away. A link that has gone has nothing to take away.
|
|
func (r *Resolver) Unroute(ctx context.Context, link string) (*Unrouted, error) {
|
|
if !linkName.MatchString(link) || link == "lo" {
|
|
return nil, fmt.Errorf("%q is not a link's name", link)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(r.NetDir, link)); err != nil {
|
|
return &Unrouted{Link: link, Said: link + " is not on this machine; resolved forgot its route with it"}, nil
|
|
}
|
|
if _, err := r.Change(ctx, "resolvectl", "revert", link); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Unrouted{Link: link, Said: link + "'s domains go to the mesh's resolvers again"}, nil
|
|
}
|
|
|
|
// OnlyTheMeshIsADefaultRoute keeps every link that has servers of its own from answering names nothing
|
|
// routes to it: a network manager telling resolved a network's servers makes them a default route, and
|
|
// then resolved asks them every name beside the mesh's resolvers. Their routing domains are kept — a
|
|
// link's own domains still go to it. Answers the links it changed.
|
|
func (r *Resolver) OnlyTheMeshIsADefaultRoute(ctx context.Context) ([]string, error) {
|
|
routes, err := r.Routes(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var changed []string
|
|
for _, l := range routes.Links {
|
|
if l.DefaultRoute == nil || !*l.DefaultRoute {
|
|
continue
|
|
}
|
|
if _, err := r.Change(ctx, "resolvectl", "default-route", l.Link, "false"); err != nil {
|
|
return changed, err
|
|
}
|
|
changed = append(changed, l.Link)
|
|
}
|
|
return changed, nil
|
|
}
|