{ "module": "dnsmasq", "version": "1", "upgrade": { "policy": "record", "why": "the mesh's resolver: a build that breaks it can stop a machine resolving the bus, and then neither the gate's rollback nor a push reaches it (hq ADR 0236, issue 260)" }, "provides": [ { "name": "wildcard-resolution", "scope": "mesh", "identity": false } ], "requires": [ "mesh-addressing" ], "emits": [ "name.added", "name.removed", "restarted", "restart-judged" ], "own-secrets": { "broker": "${dir:mesh-state}/broker" }, "claims": [ { "name": "mesh-dns-resolver", "scope": "mesh" } ], "tools": [ "dnsmasq_health", "dnsmasq_names", "dnsmasq_resolve" ], "listens": [ { "name": "dns-udp", "port": 53, "protocol": "udp", "from": "mesh", "why": "one of the mesh's resolvers (ADR 0194, 0223): every node's and every container's resolver, beside any other holder of the seat — the mesh's own names answered here, a module's zone forwarded to it, the rest forwarded upstream", "fixed": true }, { "name": "dns-tcp", "port": 53, "protocol": "tcp", "from": "mesh", "why": "the same names over tcp, which a resolver answers on as well and is asked for whenever an answer will not fit in a datagram. Declared because the daemon serves it: a declaration that covers one of the two protocols its own service listens on leaves the other closed while everything reports success", "fixed": true } ], "resources": [ { "id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh" }, { "id": "package", "type": "package", "package": "dnsmasq" }, { "id": "config", "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# This is one of the mesh's resolvers (novox/hq ADR 0194, 0223): it holds the\n# `mesh-dns-resolver` seat, which more than one machine may hold, each answering\n# the same names from the same roster. Every node and every container lists every\n# holder and no public resolver, so whichever answers first gives the one answer.\n# It holds the mesh's own names and nothing else (ADR 0191) — the roster is the\n# mesh's, rendered into each holder; no other copy lives on any 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-interfaces, and never bind-dynamic (novox/hq issue 348). bind-dynamic\n# re-reads the machine's addresses on every change of any link and closes each\n# listener whose interface that reading did not find; a reading made while the\n# container runtime deletes a bridge's virtual links fails (\"index gone\"), and\n# then every listener is closed until a later change reads cleanly. On\n# 2026-10-09 the private address was left unbound for twenty minutes, the daemon\n# running and silent. bind-interfaces binds each address once, at start, and\n# never closes it. It cannot bind an address that is not there yet, so the unit\n# waits for it (the drop-in below); a new private address restarts the unit, as\n# this file changing always did.\nbind-interfaces\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": "drop-ins", "type": "directory", "path": "/etc/systemd/system/dnsmasq.service.d", "mode": "0755" }, { "id": "wait-for-address", "type": "file", "path": "/etc/systemd/system/dnsmasq.service.d/mesh.conf", "mode": "0644", "content": "# Written by the mesh (module dnsmasq, novox/hq issue 348). Replaced on every push.\n#\n# The resolver binds its addresses once, at start (bind-interfaces), so it starts only once this\n# machine's private address is there: it waits up to 80 seconds for it, and a start that found it\n# missing is tried again every ten seconds rather than left failed.\n[Unit]\nAfter=network-online.target\nWants=network-online.target\nStartLimitIntervalSec=0\n\n[Service]\nExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 80); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo \"${machine:address} is not on this machine\" >&2; exit 1'\nRestart=on-failure\nRestartSec=10s\n" }, { "id": "service", "type": "service", "unit": "dnsmasq.service", "state": "running", "boot": "enabled", "restart-on": [ "config", "wait-for-address", "dnsmasq.fact-node-zones", "dnsmasq.fact-zones" ], "health": { "kind": "unit" } }, { "id": "watch-dir", "type": "directory", "path": "/var/lib/dnsmasq-watch", "mode": "0755" }, { "id": "watch", "type": "process", "name": "dnsmasq-watch", "artifact": "tools", "run": [ "./dnsmasq-tools", "watch" ], "health": { "kind": "tool", "tool": "dnsmasq_health", "interval": "20s", "timeout": "10s", "looks": 2, "grace": "60s" } } ], "facts": { "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# A machine's own name is a host record as well as a wildcard: asked for an IPv6 address, the\n# resolver then says the name exists and has none, where the wildcard alone said there is no such\n# name — and a resolver that reads that as final (musl, so every Alpine container) failed to find\n# the machine at all (novox/hq issue 262).\n{{range .Machines}}address=/{{.FQDN}}/{{.Address}}\nhost-record={{.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}}" } }, "build": { "artifacts": [ { "name": "tools", "kind": "bundle", "language": "go", "system": "arch", "from": "cmd/dnsmasq-tools", "binary": "dnsmasq-tools", "loads": [ "dnsmasq-tools" ] } ] } }