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