diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 2ae7e79..8edd77c 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -2,7 +2,10 @@ "module": "dnsmasq", "version": "1", "provides": [ - "wildcard-resolution" + { + "name": "wildcard-resolution", + "scope": "mesh" + } ], "requires": [ "mesh-addressing" @@ -16,8 +19,8 @@ }, "claims": [ { - "name": "node-dns-resolver", - "scope": "node" + "name": "mesh-dns-resolver", + "scope": "mesh" } ], "listens": [ @@ -26,7 +29,7 @@ "port": 53, "protocol": "udp", "from": "mesh", - "why": "every name for this machine and what it runs — the mesh's own answered here, the rest forwarded", + "why": "the mesh's one resolver (ADR 0194, 0196): every node's and every container's first resolver — the mesh's own names answered here, a module's zone forwarded to it, the rest forwarded upstream", "fixed": true }, { @@ -55,24 +58,7 @@ "type": "file", "path": "/etc/dnsmasq.conf", "mode": "0644", - "content": "# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than\n# patched, because this module owns the file and a patch would leave whatever\n# was there before to be discovered later.\n\n# What the mesh computed: one wildcard per machine — its name and everything\n# under it — and the mesh's own suffix as a local domain, so a name under it is\n# answered here or not at all and is never asked upstream. Rewritten whenever a\n# machine joins or leaves, which is why the service below restarts on it: a\n# reload makes dnsmasq re-read hosts files, not its configuration, and a\n# wildcard is configuration.\n#\n# And the mesh's region of /etc/hosts, which dnsmasq answers from too: it is\n# read once, at start, so the service restarts on that region as well. Without\n# it a name withdrawn from the region was still answered until dnsmasq happened\n# to restart — public names kept their private answers on every machine after\n# the mesh stopped publishing them (novox/hq ADR 0191).\nconf-file=/etc/mesh-resolver/nodes.conf\n\n# Where it answers. Both are addresses the mesh chose, so this file needs to\n# know nothing about this particular machine beyond what the mesh fills in:\n#\n# the machine's private address\n# so anything on the private network can ask — and so this\n# machine's containers can. This module writes the runtime's\n# `dns` key into its own configuration file, beside whatever the\n# machine had there (novox/hq ADR 0102), naming this address: a\n# container cannot reach the machine's loopback, and a runtime\n# whose host resolves at loopback falls back to a public resolver\n# and never sees a mesh name. The runtime reads that key when it\n# starts and not on a reload, and a restart stops every container\n# on the machine. So the mesh RELOADS the runtime when this file\n# changes (novox/hq ADR 0102: reload, don't restart) and never\n# restarts it. A reload does not make `dns` take effect — the\n# runtime reads that key only when it starts — but it does turn on\n# `live-restore`, which this file also sets, and with that on a\n# restart keeps every container running. So the one restart the\n# `dns` key needs is the operator's to make, once, and harmless\n# from the second time on. Two machines sat for weeks with this\n# file written and a runtime that predated it, handing every\n# container a public resolver while everything read as fine\n# (novox/hq 04-ISSUES/110).\n#\n# Named as an ADDRESS and not as the interface that carries it,\n# on purpose. A container's query is addressed to this address\n# but arrives on the runtime's bridge, and dnsmasq checks every\n# query against what it was told to answer on: an `interface=`\n# line admits queries by the interface they arrive on, a\n# `listen-address=` line by the address they are sent to. With\n# `interface=mesh0` alone, a query from a container was received\n# — the socket was bound, the filter admitted it — and dropped\n# without a word, on every machine, and the container waited\n# until it gave up (novox/hq 04-ISSUES/110). The address admits\n# it whatever bridge it comes in on, which is the point: the\n# bridges are the runtime's, named by it, and this file should\n# not have to know them.\n# 127.0.0.1 this machine's own use. The predecessor's resolver answered\n# here, and the resolv.conf it wrote on every machine says so;\n# that file stays in force on an adopted machine until the mesh's\n# module for it is taken, so the resolver has to answer where the\n# machine already asks or the machine loses DNS the moment this\n# module is taken. Not .53 or .54: systemd-resolved holds BOTH —\n# .53 is its stub and .54 its proxy stub — and neither is .1, so\n# the two coexist on a machine that runs it. This module used to\n# answer on 127.0.0.55 instead: a convention of its own, beside\n# the one every machine already followed. One address, this one,\n# and the modules that point a machine at the mesh name the same.\n#\n# Whatever address it listens on, it takes the machine's DNS port.\n# That is why this module claims `node-dns-resolver`.\n#\n# bind-dynamic rather than bind-interfaces: the private address does not exist\n# until the machine is on the private network, and binding an address that is\n# not there yet fails to start rather than waiting for it.\nbind-dynamic\nlisten-address=${machine:address}\n# Loopback, unless this machine also answers its own LAN: then its settings add\n# the LAN address, and its DNS endpoints' reach opens the filter to match.\nlisten-address=${setting:listen-addresses}\n\n# **It must never read resolv.conf to find out where to forward.** Whatever\n# points this machine at the mesh writes this resolver's own address there — so\n# a resolver that read it for upstreams would find itself, and every query it\n# could not answer locally would loop until its receive queue filled. That is\n# not theoretical: it filled with 15KB of queries and every lookup on the\n# machine hung. no-resolv is what makes that loop impossible: the upstreams are\n# the two lines below, and nothing on the machine can redirect them.\n#\n# It forwards, because it is now asked for everything. The module that points\n# this machine at the mesh names this resolver alone — as the predecessor's\n# did — so the host and every container resolve the world through it. The\n# upstreams are the ones the predecessor's module shipped as its defaults. The\n# mesh's own names never reach them: the local= line in the file above stops\n# them here, answered or refused.\nno-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n\n# Both upstreams validate DNSSEC and say so with the Authenticated Data bit,\n# and this passes that bit down rather than dropping it, which dnsmasq does\n# unless told. It does not validate itself: an answer's trust is the upstream's\n# and the path to it, which is what a forwarding resolver's trust always was.\n# Without it a program that refuses to run behind a resolver that does not\n# validate — a mail system's admin does exactly that check at start — cannot\n# use this resolver, and the alternative it ships instead knows no mesh name\n# (novox/hq 04-ISSUES/171).\nproxy-dnssec\n\n# A name without a dot is never forwarded — a bare hostname is answered from\n# /etc/hosts or not at all — and reverse lookups of private ranges are answered\n# here rather than asking the world who 10.x is.\ndomain-needed\nbogus-priv\n\n# The operator's own names have a home the mesh never rewrites (novox/hq issue\n# 122: a workstation's job includes names — Mediahuis's 13, say — that are\n# neither a mesh machine nor a routed name). Two homes, because both shapes\n# exist in the wild and neither is the mesh's to own:\n#\n# /etc/dnsmasq.d/*.conf drop-in dnsmasq directives — an address=, a second\n# upstream for one domain, a cname. HAL's dnsmasq-app\n# carried exactly this line, so it is a proven shape\n# and the files a migrating workstation already has\n# land here untouched.\n# /etc/hosts.local plain ` ` lines, the /etc/hosts a person\n# kept — read as additional hosts, so the generated\n# /etc/hosts (which the mesh owns and rewrites) never\n# has to carry an operator entry to keep it resolving.\n#\n# Both are the operator's: the mesh creates neither and rewrites neither, and a\n# machine with no such file loses nothing. This is what lets mesh-wireguard take\n# /etc/hosts without taking the names a workstation needs down with it — they\n# were moved here first.\nconf-dir=/etc/dnsmasq.d/,*.conf\naddn-hosts=/etc/hosts.local\n" - }, - { - "id": "runtime-dns", - "type": "file", - "path": "/etc/docker/daemon.json", - "mode": "0644", - "into": "json", - "content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true}\n" - }, - { - "id": "runtime", - "type": "service", - "unit": "docker.service", - "state": "running", - "reload-on": [ - "runtime-dns" - ] + "content": "# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than\n# patched, because this module owns the file and a patch would leave whatever\n# was there before to be discovered later.\n#\n# This is the mesh's one resolver (novox/hq ADR 0194, 0196): it holds the\n# `mesh-dns-resolver` seat, every node and every container asks it first, and a\n# public resolver is asked only when it is silent. It holds the mesh's own names\n# and nothing else (ADR 0191) — no copy of them lives on any other machine.\n\n# What the mesh computed: one wildcard per machine — its name and everything\n# under it — and the mesh's own suffix as a local domain, so a name under it is\n# answered here or not at all and is never asked upstream. Rewritten whenever a\n# machine joins or leaves, which is why the service below restarts on it: a\n# reload makes dnsmasq re-read hosts files, not its configuration, and a\n# wildcard is configuration.\nconf-file=/etc/mesh-resolver/nodes.conf\n\n# Every zone a module answers itself (ADR 0199): one forwarding line per zone, to\n# the private address of the machine its module runs on and the port its\n# answerer is published on. Nothing in a zone is answered here; what names a zone\n# holds is the module's. Rewritten whenever a zone is declared or moves, and the\n# service restarts on it for the same reason as above.\nconf-file=/etc/mesh-resolver/zones.conf\n\n# Where it answers: this machine's private address, so every node — and every\n# container on every node, which copies its machine's resolvers — can ask; and\n# loopback, for this machine's own use. Never a LAN address: a device that is\n# not a member cannot reach what the mesh's names point at, and a LAN's resolver\n# is its router's (ADR 0194).\n#\n# Named as an ADDRESS and not as the interface that carries it, on purpose. A\n# container's query is addressed to this address but arrives on the runtime's\n# bridge, and dnsmasq checks every query against what it was told to answer on:\n# an `interface=` line admits queries by the interface they arrive on, a\n# `listen-address=` line by the address they are sent to. With `interface=mesh0`\n# alone, a query from a container was received and dropped without a word\n# (novox/hq 04-ISSUES/110). The address admits it whatever bridge it comes in on.\n#\n# bind-dynamic rather than bind-interfaces: the private address does not exist\n# until the machine is on the private network, and binding an address that is\n# not there yet fails to start rather than waiting for it.\nbind-dynamic\nlisten-address=${machine:address}\nlisten-address=127.0.0.1\n\n# **It must never read resolv.conf to find out where to forward.** This\n# machine's resolv.conf names this resolver — so a resolver that read it for\n# upstreams would find itself, and every query it could not answer locally would\n# loop until its receive queue filled. That is not theoretical: it filled with\n# 15KB of queries and every lookup on the machine hung. no-resolv makes that loop\n# impossible: the upstreams are the two lines below, and nothing on the machine\n# can redirect them. The mesh's own names never reach them: the local= line in\n# the file above stops them here, answered or refused.\nno-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n\n# Both upstreams validate DNSSEC and say so with the Authenticated Data bit,\n# and this passes that bit down rather than dropping it, which dnsmasq does\n# unless told. It does not validate itself: an answer's trust is the upstream's\n# and the path to it, which is what a forwarding resolver's trust always was.\n# Without it a program that refuses to run behind a resolver that does not\n# validate — a mail system's admin does exactly that check at start — cannot\n# use this resolver (novox/hq 04-ISSUES/171).\nproxy-dnssec\n\n# A name without a dot is never forwarded, and reverse lookups of private ranges\n# are answered here rather than asking the world who 10.x is.\ndomain-needed\nbogus-priv\n\n# **No hosts file, and no operator's files.** This machine's /etc/hosts is its\n# own — the hosts module's block and the operator's lines (ADR 0199) — and the\n# mesh's resolver answers every node, so a line written for one machine's own\n# programs must not become an answer for all of them. Every per-node resolver\n# went wrong exactly here: a hosts file read once at start, an operator's old\n# line beside the mesh's, a drop-in read from a directory nobody owned.\nno-hosts\n" }, { "id": "service", @@ -83,7 +69,7 @@ "restart-on": [ "config", "dnsmasq.fact-node-zones", - "mesh-wireguard.fact-node-names" + "dnsmasq.fact-zones" ] } ], @@ -91,6 +77,10 @@ "node-zones": { "path": "/etc/mesh-resolver/nodes.conf", "template": "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n# joins or leaves, and an edit would survive until then and vanish.\n\nlocal=/{{.Suffix}}/\n{{range .Machines}}address=/{{.FQDN}}/{{.Address}}\n{{end}}" + }, + "zones": { + "path": "/etc/mesh-resolver/zones.conf", + "template": "# Generated by the mesh. Do not edit — this file is replaced whenever a module declares a zone or\n# its machine moves (novox/hq ADR 0199). One forwarding line per zone, to the module answering it.\n\n{{range .Zones}}server=/{{.Zone}}/{{.Address}}#{{.Port}}\n{{end}}" } } } diff --git a/modules/docker/README.md b/modules/docker/README.md index 1838114..411ec2c 100644 --- a/modules/docker/README.md +++ b/modules/docker/README.md @@ -14,6 +14,8 @@ tools below are the module's own. | `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) | | `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module | | `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) | +| `daemon` | `live-restore` written into `/etc/docker/daemon.json`, beside other keys | the key given back as found when the module goes | +| `runtime` | `docker.service` running, enabled at boot; reloaded, never restarted, when `daemon` changes | given back as found (ADR 0118) | The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It takes no volume, no container and no image a container uses, so it never touches a container the mesh @@ -24,62 +26,47 @@ while the machine was off happens at the next boot. `container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and the module that installs the daemon cannot require it. +## The runtime's own file and service (issue 190, hq ADR 0196) + +This module writes one key into `/etc/docker/daemon.json` (`into: json`, ADR 0102): `live-restore`. +`dnsmasq` used to write it, beside `dns`; it no longer writes either. Under ADR 0196 a container +copies its machine's resolvers, so no module writes `dns`. + +- `daemon`: `{"live-restore": true}`, merged into the file beside the keys others write. +- `runtime`: `docker.service` running, enabled at boot, and **reloaded, never restarted**, when + `daemon` changes. A restart stops every container. A reload turns `live-restore` on, and with it on + a later restart keeps every container running. + +In the apply that moves the key, the host first gives back `dnsmasq`'s resources, then applies this +module's: `live-restore` is set again in the same apply, and the daemon is reloaded once. + +`dns` is removed from the file then, but the daemon reads it only at its next start, and a running +container keeps the resolvers it was created with. Each container pinned to a machine's own resolver +is restarted before that machine's `dnsmasq` goes (ADR 0194, step 4). + +Still elsewhere: + +- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes + `insecure-registries`. The collision check does not see generated resources. **Later:** the + controller hands the registry to this module as a value, and the overlay stops generating its two + resources (issue 190, steps 2 and 5). The host merges disjoint keys correctly; the mesh-host + `into.go` record is per resource. +- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand; they are left + as they are until a size is chosen for every machine. + ## What it does not declare yet, and why -Three things this module should own are already declared by other modules on every machine. The -controller refuses two modules on one node that declare the same `path`, `unit`, `name` or `package` -(`checkResources`, mesh-controller `internal/catalogue/resolve.go`). Declaring any of them here would -make the module unassignable everywhere. The refusals were checked against the controller's own +One thing this module should own is still declared elsewhere. The controller refuses two modules +on one node that declare the same `path`, `unit`, `name` or `package` (`checkResources`, +mesh-controller `internal/catalogue/resolve.go`), so declaring it here would make the module +unassignable everywhere. The refusals were checked against the controller's own check: ``` zsh and docker both declare the name "${machine:account}" -dnsmasq and docker both declare the path "/etc/docker/daemon.json" -dnsmasq and docker both declare the unit "docker.service" ``` -### 1. `/etc/docker/daemon.json` and `docker.service` (issue 190) - -Today the file has three writers. Each writes into it (`into: json`, ADR 0102) and reloads the -service: - -- **`dnsmasq`** writes `dns` and `live-restore`, through `dnsmasq.runtime-dns` and `dnsmasq.runtime`. -- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes - `insecure-registries`. The collision check does not see generated resources. -- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand. - -**The change proposed, in one merge:** - -1. `dnsmasq` drops its `runtime-dns` and `runtime` resources. -2. `docker` adds the two resources below: - - ```json - {"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "mode": "0644", "into": "json", - "content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true, \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"100m\", \"max-file\": \"5\"}}\n"}, - {"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", "boot": "enabled", "reload-on": ["daemon"]} - ``` - - The service is **reloaded, never restarted**: a restart stops every container. The daemon reads - `live-restore` on a reload. It reads `dns`, `log-driver` and `log-opts` only at its next start, so - they apply then (to containers created afterwards, for the log keys). With `live-restore` on, that - start keeps every container running. - -**Why one merge, and only after this module is on every machine:** - -- In one apply, the host first gives back the resources that are no longer declared, then applies - the new ones (mesh-host `apply.go`). -- `dnsmasq` gives back `dns` and `live-restore` to what they held before it, and `docker` sets them - again in the same apply. The daemon is reloaded once, after both steps. -- A machine pushed the new `dnsmasq` *without* this module would keep its pre-mesh values for both - keys. On one machine that is `live-restore: false`, and the next daemon restart there would stop - every container. - -**Later:** the controller hands the registry to this module as a value, and the overlay stops -generating its two resources (issue 190, steps 2 and 5). Until then the overlay keeps writing its one -key beside this module's. The host merges disjoint keys correctly; the mesh-host `into.go` record is -per resource. - -### 2. The operator account's membership of the `docker` group +### The operator account's membership of the `docker` group The right shape is the host's `user` shape. Its `groups` are additive: the host runs `usermod --append` and never takes a group away. @@ -111,9 +98,8 @@ On the machine the mesh was first installed on, the foundation bundle declared ` never sees them (mesh-host `store.go`). - So `docker.package` here is a **second record of the same package**. The apply says "already installed", and neither record ever uninstalls it. -- This module does not declare `docker.service` today, so nothing overlaps there. The proposed step - 1 would add a second record of that unit. Its found state is *running*, because genesis started - it, so undeclaring this module would leave the daemon running. +- `docker.runtime` is likewise a second record of `docker.service`. Its found state is *running*, + because genesis started it, so undeclaring this module leaves the daemon running. ## Tools diff --git a/modules/docker/module.json b/modules/docker/module.json index 56d12a2..cdf7056 100644 --- a/modules/docker/module.json +++ b/modules/docker/module.json @@ -50,6 +50,24 @@ "state": "running", "boot": "enabled" }, + { + "id": "daemon", + "type": "file", + "path": "/etc/docker/daemon.json", + "mode": "0644", + "into": "json", + "content": "{\"live-restore\": true}\n" + }, + { + "id": "runtime", + "type": "service", + "unit": "docker.service", + "state": "running", + "boot": "enabled", + "reload-on": [ + "daemon" + ] + }, { "id": "prune-service", "type": "file", diff --git a/modules/hosts/cmd/hosts-tools/hosts.go b/modules/hosts/cmd/hosts-tools/hosts.go new file mode 100644 index 0000000..d25507e --- /dev/null +++ b/modules/hosts/cmd/hosts-tools/hosts.go @@ -0,0 +1,331 @@ +// The hosts file's own code (novox/hq ADR 0199): read /etc/hosts as the machine has it, and change the +// operator's lines — every line outside a `# BEGIN … / # END …` block — leaving every block, the mesh's +// and any other tool's, byte for byte. The mesh writes this module's block; these verbs never touch it. +// +// Root is the module's concern (ADR 0175 §4): the runtime launching this binary runs as the operator's +// account, so the file is written through sudo without a prompt where the account is not root, as the +// packet filter's is. +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/netip" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + "time" +) + +// HostsPath is where the file is. The manifest's resource names the same path; a test holds the two +// together. +const HostsPath = "/etc/hosts" + +// Operator is the owner of every line outside a block. +const Operator = "operator" + +// Runner runs one command as root and answers what it printed, so the writes can be tested without a +// machine. +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, else through sudo +// without a prompt. +func escalated(uid int, name string, args []string) (string, []string) { + if uid == 0 { + return name, args + } + return "sudo", append([]string{"-n", name}, args...) +} + +func execRunner(ctx context.Context, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + 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) +} + +// Line is one line of the file, as a reader sees it. +type Line struct { + // Text is the line exactly as it is in the file. + Text string `json:"text"` + // Owner is whose it is: the block's id (`mesh hosts.own`, or another tool's) or "operator". + Owner string `json:"owner"` + // Address and Names are an entry's; absent for a comment or a blank line. + Address string `json:"address,omitempty"` + Names []string `json:"names,omitempty"` +} + +var ( + begin = regexp.MustCompile(`^#\s*BEGIN\s+(.+?)\s*$`) + end = regexp.MustCompile(`^#\s*END\s+(.+?)\s*$`) +) + +// isAddress is whether s is an IPv4 or IPv6 address, as a hosts file's first field must be. +func isAddress(s string) bool { + _, err := netip.ParseAddr(s) + return err == nil +} + +// Parse is every line of a hosts file, each marked whose it is. +func Parse(text string) []Line { + out := []Line{} + block := "" + for _, raw := range strings.Split(text, "\n") { + if block == "" { + if m := begin.FindStringSubmatch(raw); m != nil { + block = m[1] + out = append(out, Line{Text: raw, Owner: block}) + continue + } + } + owner := block + if owner == "" { + owner = Operator + } + line := Line{Text: raw, Owner: owner} + entry, _, _ := strings.Cut(raw, "#") + if fields := strings.Fields(entry); len(fields) >= 2 && isAddress(fields[0]) { + line.Address, line.Names = fields[0], fields[1:] + } + out = append(out, line) + if m := end.FindStringSubmatch(raw); block != "" && m != nil && m[1] == block { + block = "" + } + } + // A trailing newline splits into one empty last element; it is the file's ending, not a line. + if n := len(out); n > 0 && out[n-1].Text == "" && strings.HasSuffix(text, "\n") { + out = out[:n-1] + } + return out +} + +var label = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`) + +// Refused input says why, so a caller is one edit from right. +func checkAddress(address string) error { + if !isAddress(address) { + return fmt.Errorf("%q is not an IPv4 or IPv6 address", address) + } + return nil +} + +func checkName(name string) error { + bad := fmt.Errorf("%q is not a host name", name) + if len(name) < 1 || len(name) > 253 { + return bad + } + for _, l := range strings.Split(strings.TrimSuffix(name, "."), ".") { + if !label.MatchString(l) { + return bad + } + } + return nil +} + +// WithAdded is the file with one address and its names added to the operator's lines; unchanged when +// they are already there. +func WithAdded(text, address string, names []string) (string, error) { + if err := checkAddress(address); err != nil { + return "", err + } + if len(names) == 0 { + return "", errors.New("add names at least one name for the address") + } + for _, n := range names { + if err := checkName(n); err != nil { + return "", err + } + } + have := map[string]bool{} + for _, l := range Parse(text) { + if l.Owner == Operator && l.Address == address { + for _, n := range l.Names { + have[n] = true + } + } + } + var missing []string + for _, n := range names { + if !have[n] && !slices.Contains(missing, n) { + missing = append(missing, n) + } + } + if len(missing) == 0 { + return text, nil + } + body := text + if body != "" && !strings.HasSuffix(body, "\n") { + body += "\n" + } + return body + address + "\t" + strings.Join(missing, " ") + "\n", nil +} + +// WithRemoved is the file with one name, or every line of one address, taken out of the operator's +// lines, and how many lines it touched. Blocks are never touched: a name only the mesh or another tool +// writes is refused, naming whose it is. +func WithRemoved(text, what string) (string, int, error) { + byAddress := isAddress(what) + if !byAddress { + if err := checkName(what); err != nil { + return "", 0, err + } + } + matches := func(l Line) bool { + if byAddress { + return l.Address == what + } + return slices.Contains(l.Names, what) + } + lines := Parse(text) + removed := 0 + kept := []string{} + for _, l := range lines { + if l.Owner != Operator || l.Address == "" || !matches(l) { + kept = append(kept, l.Text) + continue + } + removed++ + if byAddress { + continue + } + var rest []string + for _, n := range l.Names { + if n != what { + rest = append(rest, n) + } + } + if len(rest) > 0 { + kept = append(kept, l.Address+"\t"+strings.Join(rest, " ")) + } + } + if removed == 0 { + for _, l := range lines { + if l.Owner != Operator && matches(l) { + return "", 0, fmt.Errorf("%s is written by %s, not the operator; it is not this verb's to remove", what, l.Owner) + } + } + } + return strings.Join(kept, "\n") + "\n", removed, nil +} + +// HostsFile is the machine's hosts file. +type HostsFile struct { + Path string + Run Runner +} + +// Entries is the file's lines, each marked whose. +type Entries struct { + Path string `json:"path"` + Lines []Line `json:"lines"` +} + +func (h HostsFile) read() (string, error) { + b, err := os.ReadFile(h.Path) + return string(b), err +} + +// Entries is every line of the file, each marked whose it is. +func (h HostsFile) Entries() (*Entries, error) { + text, err := h.read() + if err != nil { + return nil, err + } + return &Entries{Path: h.Path, Lines: Parse(text)}, nil +} + +// Added is whether add changed the file, and the line it wrote. +type Added struct { + Added bool `json:"added"` + Line string `json:"line,omitempty"` +} + +// Add adds one address and its names to the operator's lines. +func (h HostsFile) Add(ctx context.Context, address string, names []string) (*Added, error) { + before, err := h.read() + if err != nil { + return nil, err + } + after, err := WithAdded(before, address, names) + if err != nil { + return nil, err + } + if after == before { + return &Added{Added: false}, nil + } + if err := h.write(ctx, after); err != nil { + return nil, err + } + return &Added{Added: true, Line: strings.TrimSpace(after[len(before):])}, nil +} + +// Removed is how many of the operator's lines remove touched. +type Removed struct { + Removed int `json:"removed"` +} + +// Remove takes one name, or every line of one address, out of the operator's lines. +func (h HostsFile) Remove(ctx context.Context, what string) (*Removed, error) { + before, err := h.read() + if err != nil { + return nil, err + } + after, removed, err := WithRemoved(before, what) + if err != nil { + return nil, err + } + if removed > 0 { + if err := h.write(ctx, after); err != nil { + return nil, err + } + } + return &Removed{Removed: removed}, nil +} + +// write puts the file in place whole, so a reader never sees half of it: the content is staged in a +// private copy, installed as root beside the file — the same directory, so the same filesystem — and +// renamed over it. +func (h HostsFile) write(ctx context.Context, content string) error { + dir, err := os.MkdirTemp("", "hosts-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + staged := filepath.Join(dir, "hosts") + if err := os.WriteFile(staged, []byte(content), 0o644); err != nil { + return err + } + beside := filepath.Join(filepath.Dir(h.Path), "."+filepath.Base(h.Path)+".hosts-tools") + if _, err := h.Run(ctx, "install", "-m", "0644", staged, beside); err != nil { + return err + } + if _, err := h.Run(ctx, "mv", "-f", beside, h.Path); err != nil { + _, _ = h.Run(ctx, "rm", "-f", beside) + return err + } + return nil +} diff --git a/modules/hosts/cmd/hosts-tools/hosts_test.go b/modules/hosts/cmd/hosts-tools/hosts_test.go new file mode 100644 index 0000000..03fea54 --- /dev/null +++ b/modules/hosts/cmd/hosts-tools/hosts_test.go @@ -0,0 +1,286 @@ +package main + +// The hosts file's verbs over files shaped like the workstation's on 2026-10-03 (novox/hq ADR 0199): +// distribution lines, an operator's development names, the mesh's block and another tool's. + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +const file = "# Static table lookup for hostnames.\n" + + "127.0.0.1\tlocaldev.example.com\n" + + "127.0.0.1 a.example.com b.example.com\n" + + "# BEGIN mesh hosts.own\n" + + "127.0.0.1\tlocalhost\n" + + "::1\tlocalhost\n" + + "# END mesh hosts.own\n" + + "# BEGIN other-tool\n" + + "192.0.2.7\tproject.test\n" + + "# END other-tool\n" + +func blocks(text string) []string { + var out []string + for _, l := range Parse(text) { + if l.Owner != Operator { + out = append(out, l.Text) + } + } + return out +} + +func TestEveryLineSaysWhoseItIs(t *testing.T) { + lines := Parse(file) + if len(lines) != 10 { + t.Fatalf("%d lines: %+v", len(lines), lines) + } + want := Line{Text: "127.0.0.1\tlocaldev.example.com", Owner: Operator, Address: "127.0.0.1", Names: []string{"localdev.example.com"}} + if !reflect.DeepEqual(lines[1], want) { + t.Errorf("%+v", lines[1]) + } + if lines[0].Address != "" || lines[0].Owner != Operator { + t.Errorf("a comment is the operator's and no entry: %+v", lines[0]) + } + if lines[3].Owner != "mesh hosts.own" || lines[4].Owner != "mesh hosts.own" || lines[6].Owner != "mesh hosts.own" { + t.Errorf("the mesh's block, its markers included: %+v", lines[3:7]) + } + if lines[8].Owner != "other-tool" || !reflect.DeepEqual(lines[8].Names, []string{"project.test"}) { + t.Errorf("%+v", lines[8]) + } + if lines[5].Address != "::1" { + t.Errorf("an IPv6 entry: %+v", lines[5]) + } +} + +func TestAnEntryWithATrailingCommentKeepsItsNames(t *testing.T) { + l := Parse("10.0.0.1 nas.lan # the box upstairs")[0] + if l.Address != "10.0.0.1" || !reflect.DeepEqual(l.Names, []string{"nas.lan"}) { + t.Errorf("%+v", l) + } +} + +func TestAnUnclosedBlockHoldsTheRestOfTheFile(t *testing.T) { + lines := Parse("# BEGIN x\n10.0.0.1 a.test\n# END y\n10.0.0.2 b.test\n") + for _, l := range lines { + if l.Owner != "x" { + t.Errorf("%+v", l) + } + } +} + +func TestAddAppendsAnOperatorLineAndIsANoOpWhenTheNamesAreThere(t *testing.T) { + after, err := WithAdded(file, "192.0.2.9", []string{"lab.test", "www.lab.test"}) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(after, "192.0.2.9\tlab.test www.lab.test\n") { + t.Errorf("%q", after) + } + if !reflect.DeepEqual(blocks(after), blocks(file)) { + t.Errorf("blocks changed") + } + if same, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com"}); same != file { + t.Errorf("a name already there changed the file") + } + if some, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com", "c.example.com"}); !strings.HasSuffix(some, "127.0.0.1\tc.example.com\n") { + t.Errorf("%q", some) + } + if ended, _ := WithAdded("127.0.0.1 localhost", "192.0.2.1", []string{"x.test"}); ended != "127.0.0.1 localhost\n192.0.2.1\tx.test\n" { + t.Errorf("a file without a last newline: %q", ended) + } + if empty, _ := WithAdded("", "192.0.2.1", []string{"x.test"}); empty != "192.0.2.1\tx.test\n" { + t.Errorf("an empty file: %q", empty) + } +} + +func TestAddDoesNotCountANameOnlyABlockHasAsTheOperators(t *testing.T) { + after, err := WithAdded(file, "127.0.0.1", []string{"localhost"}) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(after, "# END other-tool\n127.0.0.1\tlocalhost\n") { + t.Errorf("%q", after) + } +} + +func TestAddRefusesWhatIsNotAnAddressOrAHostName(t *testing.T) { + for _, c := range []struct { + address string + names []string + says string + }{ + {"not-an-ip", []string{"x.test"}, "not an IPv4 or IPv6 address"}, + {"192.0.2.9", []string{"bad name\n10.0.0.1 evil"}, "not a host name"}, + {"192.0.2.9", []string{"-lead.test"}, "not a host name"}, + {"192.0.2.9", []string{strings.Repeat("a", 64) + ".test"}, "not a host name"}, + {"192.0.2.9", []string{strings.Repeat("abcdefgh.", 30)}, "not a host name"}, + {"192.0.2.9", []string{}, "at least one name"}, + } { + if _, err := WithAdded(file, c.address, c.names); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%q %q: %v", c.address, c.names, err) + } + } + if _, err := WithAdded(file, "2001:db8::1", []string{"v6.test."}); err != nil { + t.Errorf("an IPv6 address and a rooted name: %v", err) + } +} + +func TestRemoveTakesOneNameOrOneAddressAndBlocksStayByteForByte(t *testing.T) { + one, n, err := WithRemoved(file, "a.example.com") + if err != nil || n != 1 { + t.Fatalf("%d %v", n, err) + } + if !strings.Contains(one, "127.0.0.1\tb.example.com\n") || strings.Contains(one, "a.example.com") { + t.Errorf("%q", one) + } + if !reflect.DeepEqual(blocks(one), blocks(file)) { + t.Errorf("blocks changed") + } + all, n, err := WithRemoved(file, "127.0.0.1") + if err != nil || n != 2 { + t.Fatalf("%d %v", n, err) + } + if !strings.Contains(all, "# BEGIN mesh hosts.own\n127.0.0.1\tlocalhost\n") { + t.Errorf("the mesh's own localhost is not the operator's to remove: %q", all) + } + if !reflect.DeepEqual(blocks(all), blocks(file)) { + t.Errorf("blocks changed") + } +} + +func TestRemoveRefusesANameOnlyABlockWritesNamingWhose(t *testing.T) { + if _, _, err := WithRemoved(file, "project.test"); err == nil || !strings.Contains(err.Error(), "written by other-tool") { + t.Errorf("%v", err) + } + if _, _, err := WithRemoved(file, "::1"); err == nil || !strings.Contains(err.Error(), "written by mesh hosts.own") { + t.Errorf("%v", err) + } + if _, n, err := WithRemoved(file, "nowhere.test"); err != nil || n != 0 { + t.Errorf("%d %v", n, err) + } + if _, _, err := WithRemoved(file, "bad name"); err == nil { + t.Errorf("a name that is neither an address nor a host name is refused") + } +} + +func TestTheFileIsWrittenAsRootThroughSudoWhereTheAccountIsNotRoot(t *testing.T) { + if p, a := escalated(1000, "install", []string{"x"}); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "install", "x"}) { + t.Errorf("%s %v", p, a) + } + if p, a := escalated(0, "install", []string{"x"}); p != "install" || !reflect.DeepEqual(a, []string{"x"}) { + t.Errorf("%s %v", p, a) + } +} + +// runPlain runs a command as given, unescalated: the test's file is the test's own. +func runPlain(ctx context.Context, name string, args ...string) (string, error) { + out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() + return string(out), err +} + +func TestTheVerbsWriteTheFileWholeBesideItAndRenameItOver(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hosts") + if err := os.WriteFile(path, []byte(file), 0o644); err != nil { + t.Fatal(err) + } + var calls [][]string + h := HostsFile{Path: path, Run: func(ctx context.Context, name string, args ...string) (string, error) { + calls = append(calls, append([]string{name}, args...)) + return runPlain(ctx, name, args...) + }} + ctx := context.Background() + added, err := h.Add(ctx, "192.0.2.9", []string{"lab.test"}) + if err != nil || !added.Added || added.Line != "192.0.2.9\tlab.test" { + t.Fatalf("%+v %v", added, err) + } + beside := filepath.Join(dir, ".hosts.hosts-tools") + if len(calls) != 2 || calls[0][0] != "install" || calls[0][len(calls[0])-1] != beside || + !reflect.DeepEqual(calls[1], []string{"mv", "-f", beside, path}) { + t.Errorf("%v", calls) + } + if again, _ := h.Add(ctx, "192.0.2.9", []string{"lab.test"}); again.Added || len(calls) != 2 { + t.Errorf("an add already there wrote the file: %+v %v", again, calls) + } + got, err := h.Entries() + if err != nil { + t.Fatal(err) + } + last := got.Lines[len(got.Lines)-1] + if last.Owner != Operator || last.Address != "192.0.2.9" { + t.Errorf("add then entries shows the line as the operator's: %+v", last) + } + removed, err := h.Remove(ctx, "lab.test") + if err != nil || removed.Removed != 1 { + t.Fatalf("%+v %v", removed, err) + } + if b, _ := os.ReadFile(path); string(b) != file { + t.Errorf("add then remove gives the file back: %q", b) + } + if _, err := os.Stat(beside); !os.IsNotExist(err) { + t.Errorf("the staged copy beside the file is left: %v", err) + } + if info, _ := os.Stat(path); info.Mode().Perm() != 0o644 { + t.Errorf("mode %v", info.Mode()) + } +} + +func TestThePathTheCodeWritesIsThePathTheManifestsResourceDeclares(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Claims []struct { + Name string `json:"name"` + Serves []string `json:"serves"` + } `json:"claims"` + Resources []struct { + ID string `json:"id"` + Path string `json:"path"` + } `json:"resources"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + found := false + for _, r := range m.Resources { + if r.ID == "own" { + found = true + if r.Path != HostsPath { + t.Errorf("the manifest writes %s, the code %s", r.Path, HostsPath) + } + } + } + if !found { + t.Errorf("no resource own") + } + var served []string + for _, tool := range tools(HostsFile{}) { + served = append(served, strings.TrimPrefix(tool.Name, Seat+".")) + if tool.Description == "" || tool.Run == nil { + t.Errorf("%s", tool.Name) + } + } + if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !reflect.DeepEqual(m.Claims[0].Serves, served) { + t.Errorf("the manifest serves %+v, the binary %v", m.Claims, served) + } +} + +func TestNamesAreSplitOnSpacesAndCommasOrTakenAsAList(t *testing.T) { + if got := namesArg(map[string]any{"names": " a.test, b.test c.test"}); !reflect.DeepEqual(got, []string{"a.test", "b.test", "c.test"}) { + t.Errorf("%v", got) + } + if got := namesArg(map[string]any{"names": []any{"a.test", "b.test"}}); !reflect.DeepEqual(got, []string{"a.test", "b.test"}) { + t.Errorf("%v", got) + } + if got := namesArg(map[string]any{}); len(got) != 0 { + t.Errorf("%v", got) + } +} diff --git a/modules/hosts/cmd/hosts-tools/main.go b/modules/hosts/cmd/hosts-tools/main.go new file mode 100644 index 0000000..b93f13e --- /dev/null +++ b/modules/hosts/cmd/hosts-tools/main.go @@ -0,0 +1,81 @@ +// hosts-tools (novox/hq ADR 0199): the hosts file's tools. One binary, launched by the machine's tool +// runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198): the +// node-hosts-file seat's three verbs — the file's lines with whose each is, add an operator's line, +// remove one. They change the machine's file and nothing else; the controller holds none of it. +// +// stdout is the MCP channel; everything this module says, it says on stderr. +package main + +import ( + "context" + "fmt" + "os" + "regexp" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Seat is the role this module holds. +const Seat = "node-hosts-file" + +func main() { + if err := stdio.Serve("", tools(HostsFile{Path: HostsPath, Run: execRunner})); err != nil { + fmt.Fprintf(os.Stderr, "[hosts] %v\n", err) + os.Exit(1) + } +} + +func str(description string) map[string]any { + return map[string]any{"type": "string", "description": description} +} + +func arg(a map[string]any, k string) string { + v, _ := a[k].(string) + return strings.TrimSpace(v) +} + +var separators = regexp.MustCompile(`[\s,]+`) + +// namesArg is the names given, separated by spaces or commas — or, from a caller that sends a list, +// the list. +func namesArg(a map[string]any) []string { + var raw []string + switch v := a["names"].(type) { + case string: + raw = separators.Split(v, -1) + case []any: + for _, n := range v { + if s, ok := n.(string); ok { + raw = append(raw, separators.Split(s, -1)...) + } + } + } + names := []string{} + for _, n := range raw { + if n != "" { + names = append(names, n) + } + } + return names +} + +// verb is one of the seat's verbs: listed as `.`, so the runtime serves it on the seat's +// subject, as /node-hosts-file.. +func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool { + return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run} +} + +func tools(h HostsFile) []stdio.Tool { + ctx := context.Background() + return []stdio.Tool{ + verb("entries", "Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.", + nil, func(map[string]any) (any, error) { return h.Entries() }), + verb("add", "Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's. Nothing changes when they are already there.", + map[string]any{"address": str("the IPv4 or IPv6 address"), "names": str("the names for it, separated by spaces")}, + func(a map[string]any) (any, error) { return h.Add(ctx, arg(a, "address"), namesArg(a)) }), + verb("remove", "Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.", + map[string]any{"name": str("a host name, or an address to remove every line of")}, + func(a map[string]any) (any, error) { return h.Remove(ctx, arg(a, "name")) }), + } +} diff --git a/modules/hosts/go.mod b/modules/hosts/go.mod new file mode 100644 index 0000000..0b34892 --- /dev/null +++ b/modules/hosts/go.mod @@ -0,0 +1,5 @@ +module hosts + +go 1.25.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/hosts/go.sum b/modules/hosts/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/hosts/go.sum @@ -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= diff --git a/modules/hosts/module.json b/modules/hosts/module.json new file mode 100644 index 0000000..0319dd5 --- /dev/null +++ b/modules/hosts/module.json @@ -0,0 +1,41 @@ +{ + "module": "hosts", + "version": "1", + "claims": [ + { + "name": "node-hosts-file", + "scope": "node", + "serves": [ + "entries", + "add", + "remove" + ] + } + ], + "resources": [ + { + "id": "own", + "type": "file", + "path": "/etc/hosts", + "mode": "0644", + "into": "block", + "at": "start", + "content": "# The machine's own names (module hosts, novox/hq ADR 0199). Every line outside this block is the\n# operator's: kept across every push, changed through the node-hosts-file verbs add and remove, and\n# given back when this module goes. The mesh's names are not here: the mesh's resolver answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t${machine:name}\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/hosts-tools", + "binary": "hosts-tools", + "loads": [ + "hosts-tools" + ] + } + ] + } +} diff --git a/modules/resolv-conf/module.json b/modules/resolv-conf/module.json index dd71fbc..7fb0acb 100644 --- a/modules/resolv-conf/module.json +++ b/modules/resolv-conf/module.json @@ -17,7 +17,7 @@ "type": "file", "path": "/etc/resolv.conf", "mode": "0644", - "content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead \u2014 this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it \u2014 the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know \u2014 a resolver's second line is asked only when the first does not\n# answer at all \u2014 and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n" + "content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead — this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's one resolver first (novox/hq ADR 0194, 0196), by address — a machine\n# cannot resolve the name of the thing it resolves names with. It answers the\n# mesh's names itself and forwards every other name. A public resolver second,\n# asked only when the first does not answer at all — its machine or the tunnel\n# down, a captive portal holding the tunnel back — so public names keep\n# resolving then. An answer from the first, \"no such name\" included, is final,\n# so a mesh name is never asked of the public one while the mesh's answers. One\n# second and one attempt, so the wait before the fallback is short. Containers\n# copy these two lines from their machine.\nnameserver ${bound:wildcard-resolution:address}\nnameserver 1.1.1.1\noptions timeout:1 attempts:1 edns0\n" } ] } diff --git a/modules/resolved-split-dns/module.json b/modules/resolved-split-dns/module.json index c79d759..b875d8e 100644 --- a/modules/resolved-split-dns/module.json +++ b/modules/resolved-split-dns/module.json @@ -23,7 +23,7 @@ "type": "file", "path": "/etc/systemd/resolved.conf.d/mesh.conf", "mode": "0644", - "content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine \u2014 a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n" + "content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# The mesh's one resolver (novox/hq ADR 0194), by its private address — a\n# machine cannot resolve the name of the thing it resolves names with.\n[Resolve]\nDNS=${bound:wildcard-resolution:address}\nDomains=~internal\n" }, { "id": "resolved",