The machine's own resolver is a node seat, and the uplink steps back from the resolver file where it is held (hq ADR 0247)
A machine with a VPN client that writes /etc/resolv.conf needs its domains routed to the VPN's servers while every other name still goes to the mesh's resolvers. node-resolver's holder does that on the machine, owns the resolver file there, and serves routes, route and unroute. The uplink's holder steps back from the file only where the resolver is held; every other machine composes as before.
This commit is contained in:
@@ -1035,7 +1035,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// plane's; making a name resolve is the module's software. Emitted as ordinary files under
|
||||
// this module's name, so they are applied, reported and removed exactly as anything else
|
||||
// it declares.
|
||||
given, err := FactsFrom(m, r, with)
|
||||
// The resolver file is the node-resolver holder's where one is held here, and the uplink's
|
||||
// holder steps back from it (novox/hq ADR 0247).
|
||||
given, err := FactsFrom(stepsBack(m, r.Modules), r, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package catalogue
|
||||
|
||||
// The machine's own resolver (novox/hq ADR 0247).
|
||||
//
|
||||
// **Most machines have none.** Every machine lists the mesh's resolvers in /etc/resolv.conf and nothing
|
||||
// else, and the module holding its uplink writes that file (ADR 0223). A machine with a VPN client that
|
||||
// pushes its own resolvers for its own domains needs a third answer: those domains to the VPN's servers,
|
||||
// over the VPN's link, and every other name to the mesh's resolvers as before. One file cannot list both
|
||||
// sets of servers — musl takes the first reply, glibc the first server's "no such name" — so the domains
|
||||
// are routed by a resolver on the machine itself: the `node-resolver` seat's holder.
|
||||
//
|
||||
// **Where it is held, it owns the resolver file.** The file then names the machine's own resolver, which
|
||||
// routes; the uplink's holder steps back from writing it on that machine, by the rule below and not by
|
||||
// two modules writing one path. There are two uplink holders (NetworkManager's and systemd-networkd's),
|
||||
// and the resolver is its own module so it is written once, not once in each.
|
||||
//
|
||||
// **It knows nothing of any VPN.** Its verbs route a set of domains to a set of servers over one link,
|
||||
// list what is routed and remove a route. A module wrapping a VPN client that writes /etc/resolv.conf
|
||||
// itself carries its own adapter and calls those verbs; a VPN that tells systemd-resolved its link's DNS
|
||||
// itself needs none.
|
||||
|
||||
// ResolverSeat is the machine's own resolver (novox/hq ADR 0247).
|
||||
const ResolverSeat = "node-resolver"
|
||||
|
||||
// ResolverFile is the machine's resolver file: the uplink holder's, or the node-resolver holder's where
|
||||
// one is held.
|
||||
const ResolverFile = "/etc/resolv.conf"
|
||||
|
||||
// resolverVerbs is the contract every holder of node-resolver serves (novox/hq ADR 0247): what is routed
|
||||
// where, route a set of domains, and take a route away. The same verbs are served on the machine itself to
|
||||
// the modules there, so what a VPN pushed never crosses the bus to be routed; on the mesh they are the
|
||||
// operator's way to read and correct the same.
|
||||
func resolverVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "routes", Description: "What this machine's own resolver sends where: the mesh's resolvers, " +
|
||||
"which answer every name not routed elsewhere, and each link given servers of its own with the " +
|
||||
"domains routed to them. Also the resolver file's outside writes it kept, newest first: when, who " +
|
||||
"wrote it as far as the file says, whether a module took it, and when the resolver's own file was " +
|
||||
"put back.",
|
||||
Input: schema(map[string]string{}, nil),
|
||||
Replaces: []string{"resolvectl status", "resolvectl dns", "resolvectl domain"}},
|
||||
{Name: "route", Description: "Send these domains, and every name under them, to these servers over " +
|
||||
"this link — and only them: the link is never the machine's default route for names, and the " +
|
||||
"mesh's own domain is refused. Replaces whatever the link was given before. A link that goes away " +
|
||||
"takes its route with it.",
|
||||
Input: schema(map[string]string{
|
||||
"link": "the network link the servers are reached over, by name (a VPN's tunnel interface)",
|
||||
"domains": "the domains to route there, separated by spaces or commas",
|
||||
"servers": "the servers' addresses, separated by spaces or commas",
|
||||
}, []string{"link", "domains", "servers"}),
|
||||
Replaces: []string{"resolvectl dns", "resolvectl domain", "resolvectl default-route"}},
|
||||
{Name: "unroute", Description: "Take one link's route away: its domains go back to the mesh's " +
|
||||
"resolvers. Nothing changes when the link has none.",
|
||||
Input: schema(map[string]string{"link": "the network link, by name"}, []string{"link"}),
|
||||
Replaces: []string{"resolvectl revert"}},
|
||||
}
|
||||
}
|
||||
|
||||
// holdsSeat says whether a module claims a seat, by its current name.
|
||||
func holdsSeat(m Manifest, seat string) bool {
|
||||
for _, c := range m.Claims {
|
||||
if canonicalSeat(c.Name) == seat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rendersResolverFile says whether a module asks the mesh to render the machine's resolver file.
|
||||
func rendersResolverFile(m Manifest) bool {
|
||||
for _, f := range m.Facts {
|
||||
if !f.Home && f.Path == ResolverFile {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolverFileOwner is the module that writes the machine's resolver file among the modules on one
|
||||
// machine (novox/hq ADR 0247): the holder of node-resolver when one renders it, else nobody is named here
|
||||
// and the file is whoever's it always was — the uplink holder's (ADR 0223).
|
||||
func ResolverFileOwner(modules []Manifest) string {
|
||||
for _, m := range modules {
|
||||
if holdsSeat(m, ResolverSeat) && rendersResolverFile(m) {
|
||||
return m.Module
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stepsBack is the module as it composes on a machine whose resolver file is the node-resolver holder's:
|
||||
// **the uplink holder's rendering of that file is left out**, and nothing else of it changes. Only the
|
||||
// uplink's holder steps back — any other module rendering the file beside the resolver's is still two
|
||||
// owners of one path, and is refused as before.
|
||||
func stepsBack(m Manifest, modules []Manifest) Manifest {
|
||||
if !holdsSeat(m, "node-uplink") || !rendersResolverFile(m) {
|
||||
return m
|
||||
}
|
||||
owner := ResolverFileOwner(modules)
|
||||
if owner == "" || owner == m.Module {
|
||||
return m
|
||||
}
|
||||
facts := make(map[string]RosterFile, len(m.Facts))
|
||||
for name, f := range m.Facts {
|
||||
if !f.Home && f.Path == ResolverFile {
|
||||
continue
|
||||
}
|
||||
facts[name] = f
|
||||
}
|
||||
m.Facts = facts
|
||||
return m
|
||||
}
|
||||
|
||||
// steppedBack is every module of one machine as it composes there (stepsBack, each).
|
||||
func steppedBack(modules []Manifest) []Manifest {
|
||||
if ResolverFileOwner(modules) == "" {
|
||||
return modules
|
||||
}
|
||||
out := make([]Manifest, len(modules))
|
||||
for i, m := range modules {
|
||||
out[i] = stepsBack(m, modules)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0247: a machine's own resolver is a node seat, held only where something requires
|
||||
// `split-dns`; where it is held it writes the resolver file and the uplink's holder steps back from it.
|
||||
|
||||
// The seat: node-scoped, decided by ADR 0247, and its three verbs required of every holder — a holder
|
||||
// exists only once the module serving them does, so nothing has to be optional while it catches up.
|
||||
func TestTheMachinesOwnResolverIsANodeSeatWithItsVerbs(t *testing.T) {
|
||||
s, ok := SeatNamed(ResolverSeat)
|
||||
if !ok {
|
||||
t.Fatalf("%s is not in the mesh's set", ResolverSeat)
|
||||
}
|
||||
if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0247" || s.Delivers != "" || s.Replicated {
|
||||
t.Errorf("%s is %+v; a node seat under ADR 0247 that delivers nothing", ResolverSeat, s)
|
||||
}
|
||||
var got []string
|
||||
for _, v := range s.Serves {
|
||||
got = append(got, v.Name)
|
||||
if v.Optional {
|
||||
t.Errorf("%s.%s is optional; its first holder serves it", ResolverSeat, v.Name)
|
||||
}
|
||||
if v.Description == "" || v.Input["type"] != "object" || len(v.Replaces) == 0 {
|
||||
t.Errorf("%s.%s has no description, no object schema or says it replaces nothing", ResolverSeat, v.Name)
|
||||
}
|
||||
}
|
||||
if strings.Join(got, " ") != "routes route unroute" {
|
||||
t.Errorf("%s serves %v, not routes, route and unroute", ResolverSeat, got)
|
||||
}
|
||||
// The verbs name a link, domains and servers, and never a VPN: the resolver knows nothing of one.
|
||||
for _, v := range s.Serves {
|
||||
if strings.Contains(strings.ToLower(v.Description), "forti") {
|
||||
t.Errorf("%s.%s names a VPN client: %s", ResolverSeat, v.Name, v.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// localResolver is a stand-in holder: it claims the seat and renders the resolver file naming the
|
||||
// machine's own address. What is under test is the controller's rule, not the catalogue's module.
|
||||
func localResolver() Manifest {
|
||||
return Manifest{Module: "local-resolver", Version: "1",
|
||||
Claims: []Claim{{Name: ResolverSeat, Scope: ScopeNode}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile,
|
||||
Template: "# Managed by the mesh\n{{range .Machines}}{{if eq .Name $.Node}}nameserver {{.Address}}\n{{end}}{{end}}"}}}
|
||||
}
|
||||
|
||||
func splitDNSLaptop() Node {
|
||||
return Node{Name: "laptop", At: "laptop.internal", Capabilities: map[string]bool{
|
||||
"package-manager": true, "service-manager": true, "uplink-systemd-networkd": true}}
|
||||
}
|
||||
|
||||
// Where a module holds node-resolver, the machine is composed one resolver file, the holder's, naming
|
||||
// the machine's own address; the uplink's holder composes everything else it declares, and not that file.
|
||||
func TestWhereTheResolverIsHeldItWritesTheFileAndTheUplinkStepsBack(t *testing.T) {
|
||||
shelf := resolverShelf(t)
|
||||
shelf["local-resolver"] = localResolver()
|
||||
got, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "local-resolver"}, splitDNSLaptop(), World{})
|
||||
if err != nil {
|
||||
t.Fatalf("the resolver's holder and the uplink's were refused together: %v", err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
||||
Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}},
|
||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var files []string
|
||||
for _, r := range out {
|
||||
if r["path"] == ResolverFile {
|
||||
files = append(files, r["id"].(string))
|
||||
}
|
||||
}
|
||||
if strings.Join(files, " ") != "local-resolver.fact-resolvers" {
|
||||
t.Fatalf("the resolver file is composed as %v; once, the resolver's", files)
|
||||
}
|
||||
content := byID(out)["local-resolver.fact-resolvers"]["content"].(string)
|
||||
if !strings.Contains(content, "nameserver 10.42.0.2\n") || strings.Contains(content, "10.42.0.1") {
|
||||
t.Errorf("the resolver file does not name this machine's own resolver alone:\n%s", content)
|
||||
}
|
||||
// The uplink's holder is still composed: only the one file moved.
|
||||
uplinkComposed := false
|
||||
for _, r := range out {
|
||||
if id, _ := r["id"].(string); strings.HasPrefix(id, "systemd-networkd.") {
|
||||
uplinkComposed = true
|
||||
}
|
||||
}
|
||||
if !uplinkComposed {
|
||||
t.Errorf("the uplink's holder composed nothing once the resolver was held")
|
||||
}
|
||||
}
|
||||
|
||||
// Where nobody holds it, nothing changes: the uplink's holder writes the file, listing the mesh's
|
||||
// resolvers (ADR 0223) — every machine but the one that requires split-dns.
|
||||
func TestWithoutTheResolverTheUplinkWritesTheFileAsBefore(t *testing.T) {
|
||||
got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "systemd-networkd"}, splitDNSLaptop(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
||||
Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}},
|
||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := byID(out)["systemd-networkd.fact-resolvers"]; r == nil || r["path"] != ResolverFile {
|
||||
t.Fatalf("without the resolver, the uplink's holder no longer writes the resolver file: %v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the uplink's holder steps back. A third module rendering or declaring the file beside the
|
||||
// resolver's is two owners of one path, refused as before; and a module rendering it without holding
|
||||
// the seat is not the resolver, so the uplink's holder does not step back for it.
|
||||
func TestOnlyTheUplinkStepsBackForTheResolver(t *testing.T) {
|
||||
uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}}
|
||||
if p := checkResources([]Manifest{uplink, localResolver()}); len(p) != 0 {
|
||||
t.Errorf("the uplink's holder and the resolver's were refused together: %v", p)
|
||||
}
|
||||
other := Manifest{Module: "other", Version: "1",
|
||||
Facts: map[string]RosterFile{"mine": {Path: ResolverFile, Template: "nameserver 10.42.0.9\n"}}}
|
||||
if p := checkResources([]Manifest{uplink, localResolver(), other}); len(p) == 0 {
|
||||
t.Error("a third module wrote the resolver file beside the resolver's")
|
||||
}
|
||||
if p := checkResources([]Manifest{uplink, other}); len(p) == 0 {
|
||||
t.Error("a module that does not hold node-resolver took the resolver file from the uplink's holder")
|
||||
}
|
||||
declared := Manifest{Module: "declared", Version: "1", Resources: []map[string]any{
|
||||
{"id": "mine", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.9\n"}}}
|
||||
if p := checkResources([]Manifest{uplink, localResolver(), declared}); len(p) == 0 {
|
||||
t.Error("a module declaring the resolver file was let beside the resolver's")
|
||||
}
|
||||
// What steps back is the one file: the uplink's other facts stay.
|
||||
uplink.Facts["hosts"] = RosterFile{Path: "/etc/elsewhere", Template: "x"}
|
||||
if got := stepsBack(uplink, []Manifest{uplink, localResolver()}); len(got.Facts) != 1 || got.Facts["hosts"].Path == "" {
|
||||
t.Errorf("the uplink's holder lost more than the resolver file: %v", got.Facts)
|
||||
}
|
||||
if got := stepsBack(uplink, []Manifest{uplink}); len(got.Facts) != 2 {
|
||||
t.Errorf("the uplink's holder stepped back with no resolver held: %v", got.Facts)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue as it is: every holder of node-resolver renders the resolver file as the mesh's own
|
||||
// (its header is how the uplink's verb and the node-engine read a file as the mesh's), and provides
|
||||
// split-dns at the machine's reach — a requirement is answered only on the same machine and never pulls
|
||||
// the resolver in. Skipped while the catalogue has no holder.
|
||||
func TestTheCataloguesResolverHoldersWriteTheFileAndProvideSplitDNS(t *testing.T) {
|
||||
held := 0
|
||||
for _, m := range theCatalogue(t) {
|
||||
if !holdsSeat(m, ResolverSeat) {
|
||||
continue
|
||||
}
|
||||
held++
|
||||
f, ok := m.Facts["resolvers"]
|
||||
if !ok || f.Path != ResolverFile || !strings.HasPrefix(f.Template, "# Managed by the mesh") {
|
||||
t.Errorf("%s holds %s and does not render the resolver file as the mesh's: %+v", m.Module, ResolverSeat, f)
|
||||
}
|
||||
provides := false
|
||||
for _, o := range m.Provides {
|
||||
if o.Name == "split-dns" && o.Reach == ReachMachine {
|
||||
provides = true
|
||||
}
|
||||
}
|
||||
if !provides {
|
||||
t.Errorf("%s holds %s and does not provide split-dns at the machine's reach", m.Module, ResolverSeat)
|
||||
}
|
||||
}
|
||||
if held == 0 {
|
||||
t.Skip("no module of the catalogue holds node-resolver yet")
|
||||
}
|
||||
}
|
||||
@@ -930,6 +930,9 @@ func canonicalSeat(name string) string {
|
||||
// resolves with no refusal. What is refused is the contradiction — a path one module owns and
|
||||
// another merely accesses — because shared data is the operator's and nobody's to own.
|
||||
func checkResources(modules []Manifest) []string {
|
||||
// Judged as the machine composes them: where the node-resolver holder writes the resolver file, the
|
||||
// uplink holder's rendering of it is not composed, so it is not a second owner (novox/hq ADR 0247).
|
||||
modules = steppedBack(modules)
|
||||
var problems []string
|
||||
owner := map[string]string{}
|
||||
ownedPath := map[string]string{} // path → owning module, for the access check below
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestTheResolverConfigSeatIsGone(t *testing.T) {
|
||||
}
|
||||
|
||||
// The catalogue as it is: nothing claims the retired seat, and every manager the mesh knows holds the
|
||||
// uplink and writes the resolver file.
|
||||
// uplink and writes the resolver file; beside them only a holder of node-resolver does (ADR 0247).
|
||||
func TestTheCataloguesUplinksWriteTheResolverFileAndNothingElseDoes(t *testing.T) {
|
||||
cat := map[string]Manifest{}
|
||||
for _, m := range theCatalogue(t) {
|
||||
@@ -41,8 +41,10 @@ func TestTheCataloguesUplinksWriteTheResolverFileAndNothingElseDoes(t *testing.T
|
||||
isUplink = isUplink || u == name
|
||||
}
|
||||
for _, f := range m.Facts {
|
||||
if f.Path == "/etc/resolv.conf" && !isUplink {
|
||||
t.Errorf("%s writes /etc/resolv.conf and does not hold the uplink", name)
|
||||
// Or the machine's own resolver's, where it is held (novox/hq ADR 0247): the uplink's holder
|
||||
// steps back from the file there, and nowhere else.
|
||||
if f.Path == "/etc/resolv.conf" && !isUplink && !holdsSeat(m, ResolverSeat) {
|
||||
t.Errorf("%s writes /etc/resolv.conf and holds neither the uplink nor the machine's resolver", name)
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
|
||||
@@ -292,6 +292,11 @@ var defaultSeats = append([]Seat{
|
||||
// the manager knows for it — the same for every holder, so a question about a machine's names is asked
|
||||
// the same way whatever manages its network. Optional until both holders serve them.
|
||||
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs()},
|
||||
// The machine's own resolver (novox/hq ADR 0247): held only where something requires `split-dns` — a
|
||||
// VPN client whose domains must go to its own servers while every other name goes to the mesh's.
|
||||
// Where it is held, its holder writes the resolver file and the uplink's holder steps back from it
|
||||
// (node_resolver.go). It knows nothing of any VPN: its verbs route domains to servers over a link.
|
||||
{Name: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs()},
|
||||
},
|
||||
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
|
||||
graphicalSessionSeats()...)
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
delivered[s.Delivers] = s.Name
|
||||
}
|
||||
}
|
||||
// Thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||
// Thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||
// into node-uplink (novox/hq ADR 0223); thirty-seven
|
||||
// since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with
|
||||
// node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215);
|
||||
@@ -57,8 +57,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
||||
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
|
||||
// mesh-build-machine row goes, when no registered manifest claims it.
|
||||
if len(Seats()) != 37 {
|
||||
t.Errorf("the mesh defines %d seats rather than 37; the set is closed, so a change here is "+
|
||||
if len(Seats()) != 38 {
|
||||
t.Errorf("the mesh defines %d seats rather than 38; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user