From 81592a3b2cb683d14d6bdd9784288444a71a8e02 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 19:26:53 +0200 Subject: [PATCH 01/11] mailu: the smtp provision serves the name its certificate answers to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consumer connecting by the binding's address meets a certificate for mail.novox.be and refuses it — found live by the forwarder's cutover proof, one send before production would have. The TLS name is mailu's own fact (HOSTNAMES), so the binding carries it; consumers say ${bound:smtp:name} and verification holds. --- modules/mailu/module.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 7ee64b3..7b3aa0f 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -547,7 +547,8 @@ "serves": { "smtp": { "port": 587, - "domain": "novox.be" + "domain": "novox.be", + "name": "mail.novox.be" } }, "receives": { From 870a5410725fcef8390081e78c04d06e83755f74 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 20:12:06 +0200 Subject: [PATCH 02/11] dnsmasq: the runtime's DNS is written into daemon.json, never over it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file is shared — the operator's insecure-registries for the mesh's own store live there — and replacing it whole would break every pull from that store the moment the module is taken (the 098 class, caught in the pre-take diff this time). ADR 0102's verb is merge. --- modules/dnsmasq/module.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 3442b37..8320f01 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -25,7 +25,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": "every name for this machine and what it runs \u2014 the mesh's own answered here, the rest forwarded", "fixed": true } ], @@ -46,7 +46,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.\nconf-file=/etc/mesh-resolver/nodes.conf\n\n# Where it answers. Both are names the mesh chose, so this file needs to know\n# nothing about this particular machine:\n#\n# mesh0 the private network, so anything on it can ask — including\n# this machine's containers. 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 this module orders neither: the key holds for\n# every container created after the runtime next starts. On the\n# machine this replaces the predecessor wrote the same value, so\n# nothing there is waiting on it.\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 `the-dns-port`.\n#\n# bind-dynamic rather than bind-interfaces: mesh0 does not exist until the\n# machine is on the private network, and binding an interface that is not there\n# yet fails to start rather than waiting for it.\nbind-dynamic\ninterface=mesh0\nlisten-address=127.0.0.1\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# 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" + "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 \u2014 its name and everything\n# under it \u2014 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# Where it answers. Both are names the mesh chose, so this file needs to know\n# nothing about this particular machine:\n#\n# mesh0 the private network, so anything on it can ask \u2014 including\n# this machine's containers. 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 this module orders neither: the key holds for\n# every container created after the runtime next starts. On the\n# machine this replaces the predecessor wrote the same value, so\n# nothing there is waiting on it.\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 \u2014\n# .53 is its stub and .54 its proxy stub \u2014 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 `the-dns-port`.\n#\n# bind-dynamic rather than bind-interfaces: mesh0 does not exist until the\n# machine is on the private network, and binding an interface that is not there\n# yet fails to start rather than waiting for it.\nbind-dynamic\ninterface=mesh0\nlisten-address=127.0.0.1\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 \u2014 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 \u2014 as the predecessor's\n# did \u2014 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# A name without a dot is never forwarded \u2014 a bare hostname is answered from\n# /etc/hosts or not at all \u2014 and reverse lookups of private ranges are answered\n# here rather than asking the world who 10.x is.\ndomain-needed\nbogus-priv\n" }, { "id": "runtime-dns", From a85b0ee34611191f8e29387794682723c5aebf10 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 20:14:54 +0200 Subject: [PATCH 03/11] sshd: the operator's door is a module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec is the working system: HAL's 99-hal.conf, restated as 10-mesh.conf so lexical include order makes the mesh's answer the one that wins while the predecessor's file is still on disk. Subsystem stays the stock config's — first-set wins and it sits before the Include. Port 22 from anywhere, said in listens with its reason: the machines that need the door are exactly the ones not on the mesh yet, and locking the operator out is the one failure a firewall must never arrange. --- modules/sshd/module.json | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 modules/sshd/module.json diff --git a/modules/sshd/module.json b/modules/sshd/module.json new file mode 100644 index 0000000..6901f80 --- /dev/null +++ b/modules/sshd/module.json @@ -0,0 +1,39 @@ +{ + "module": "sshd", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "listens": [ + { + "port": 22, + "protocol": "tcp", + "from": "anywhere", + "why": "the operator's own door. From anywhere because the machines that need it are exactly the ones not on the mesh yet \u2014 and locking the operator out is the one failure a firewall must never arrange" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "openssh" + }, + { + "id": "config", + "type": "file", + "path": "/etc/ssh/sshd_config.d/10-mesh.conf", + "mode": "0644", + "content": "# Managed by the mesh (module sshd). Replaced on every push; edit the catalogue instead.\nPort 22\nPermitRootLogin no\nPasswordAuthentication no\nPubkeyAuthentication yes\nKbdInteractiveAuthentication no\nUsePAM yes\nX11Forwarding no\nPrintMotd no\nAcceptEnv LANG LC_*\n" + }, + { + "id": "run", + "type": "service", + "unit": "sshd.service", + "state": "running", + "restart-on": [ + "config" + ] + } + ] +} From f4e4e12c991a2a00c46fa3a8f2e52ca6bd3f2f28 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 20:36:03 +0200 Subject: [PATCH 04/11] =?UTF-8?q?mssql:=20its=20data=20is=20placed=20?= =?UTF-8?q?=E2=80=94=20the=20last=20/services=20placement=20retires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stated path was the adopted-data exception; with the take done and the placement vocabulary live, the exception has no reason left. The landing window renames the directory and recreates the container, since a changed volume path does not do that by itself (hq 126). --- modules/mssql/module.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/mssql/module.json b/modules/mssql/module.json index 5fa2b01..e7b2416 100644 --- a/modules/mssql/module.json +++ b/modules/mssql/module.json @@ -68,7 +68,6 @@ { "id": "data", "type": "directory", - "path": "/services/mssql/data", "mode": "0700", "owner": "10001:0" }, @@ -90,7 +89,7 @@ "1433" ], "volumes": [ - "/services/mssql/data:/var/opt/mssql" + "${dir:data}:/var/opt/mssql" ], "secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint" }, From bb8f2e76a92c9abf637946a7badb4e512df45a27 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 22:43:29 +0200 Subject: [PATCH 05/11] dnsmasq: the operator's own names have a home the mesh never rewrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workstation's job includes names that are neither a mesh machine nor a routed name (novox/hq 122): shanks carries 13 Mediahuis entries in /etc/hosts, and mesh-wireguard replaces /etc/hosts whole when taken — so without this they vanish, and the take gates the node. Two homes, neither the mesh's to own: conf-dir=/etc/dnsmasq.d/,*.conf (drop-in directives, HAL's dnsmasq-app used exactly this) and addn-hosts=/etc/hosts.local (plain host lines). The mesh creates and rewrites neither; a machine with none loses nothing. The migration moves such names here BEFORE the /etc/hosts take, closing the window. --- modules/dnsmasq/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 8320f01..9cda1c5 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -46,7 +46,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 \u2014 its name and everything\n# under it \u2014 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# Where it answers. Both are names the mesh chose, so this file needs to know\n# nothing about this particular machine:\n#\n# mesh0 the private network, so anything on it can ask \u2014 including\n# this machine's containers. 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 this module orders neither: the key holds for\n# every container created after the runtime next starts. On the\n# machine this replaces the predecessor wrote the same value, so\n# nothing there is waiting on it.\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 \u2014\n# .53 is its stub and .54 its proxy stub \u2014 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 `the-dns-port`.\n#\n# bind-dynamic rather than bind-interfaces: mesh0 does not exist until the\n# machine is on the private network, and binding an interface that is not there\n# yet fails to start rather than waiting for it.\nbind-dynamic\ninterface=mesh0\nlisten-address=127.0.0.1\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 \u2014 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 \u2014 as the predecessor's\n# did \u2014 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# A name without a dot is never forwarded \u2014 a bare hostname is answered from\n# /etc/hosts or not at all \u2014 and reverse lookups of private ranges are answered\n# here rather than asking the world who 10.x is.\ndomain-needed\nbogus-priv\n" + "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 \u2014 its name and everything\n# under it \u2014 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# Where it answers. Both are names the mesh chose, so this file needs to know\n# nothing about this particular machine:\n#\n# mesh0 the private network, so anything on it can ask \u2014 including\n# this machine's containers. 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 this module orders neither: the key holds for\n# every container created after the runtime next starts. On the\n# machine this replaces the predecessor wrote the same value, so\n# nothing there is waiting on it.\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 \u2014\n# .53 is its stub and .54 its proxy stub \u2014 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 `the-dns-port`.\n#\n# bind-dynamic rather than bind-interfaces: mesh0 does not exist until the\n# machine is on the private network, and binding an interface that is not there\n# yet fails to start rather than waiting for it.\nbind-dynamic\ninterface=mesh0\nlisten-address=127.0.0.1\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 \u2014 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 \u2014 as the predecessor's\n# did \u2014 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# A name without a dot is never forwarded \u2014 a bare hostname is answered from\n# /etc/hosts or not at all \u2014 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 \u2014 Mediahuis's 13, say \u2014 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 \u2014 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 \u2014 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 \u2014 they\n# were moved here first.\nconf-dir=/etc/dnsmasq.d/,*.conf\naddn-hosts=/etc/hosts.local\n" }, { "id": "runtime-dns", From 23d735a0bf1c934b5548ed05a650f16ae100804a Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:47:31 +0200 Subject: [PATCH 06/11] The uplink's managers are modules (hq ADR 0117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit networkmanager, systemd-networkd and dhcpcd each claim the-uplink and declare only what keeps the machine's own network manager from contradicting the mesh: the resolver file left to resolv-conf, mesh0 left alone. Never a link, profile or credential — the link is the mesh's only channel to the machine, so NetworkManager and networkd are reloaded on a change, never restarted, and dhcpcd (no reload; a restart drops the address) takes its block at its next start. --- modules/dhcpcd/module.json | 36 ++++++++++++++++++++++++++ modules/networkmanager/module.json | 38 ++++++++++++++++++++++++++++ modules/systemd-networkd/module.json | 38 ++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 modules/dhcpcd/module.json create mode 100644 modules/networkmanager/module.json create mode 100644 modules/systemd-networkd/module.json diff --git a/modules/dhcpcd/module.json b/modules/dhcpcd/module.json new file mode 100644 index 0000000..6a4d6f4 --- /dev/null +++ b/modules/dhcpcd/module.json @@ -0,0 +1,36 @@ +{ + "module": "dhcpcd", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "the-uplink", + "scope": "node" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "dhcpcd" + }, + { + "id": "config", + "type": "file", + "path": "/etc/dhcpcd.conf", + "mode": "0644", + "into": "block", + "content": "# Managed by the mesh (module dhcpcd). Only the lines between these markers are\n# the mesh's; the rest of this file is the operator's and is kept as it is.\n# dhcpcd reads no drop-in directory, so the mesh writes into its one file\n# rather than over it (novox/hq ADR 0102).\n#\n# This machine's uplink is dhcpcd's, and these two lines are the whole of what\n# the mesh asks of it (novox/hq ADR 0117). The mesh never declares an\n# interface, an address, a route, a wireless network or its credentials \u2014\n# the link dhcpcd keeps is the only channel the mesh reaches this machine over.\n#\n# nohook resolv.conf: the resolver file is the mesh's (resolv-conf). dhcpcd's\n# resolv.conf hook rewrites /etc/resolv.conf on every lease it takes or renews,\n# which would silently replace the mesh's resolver at the next renewal.\n#\n# denyinterfaces mesh0: the private network's interface is the mesh's. dhcpcd\n# never asks for a lease on it, and never takes it down.\n#\n# Both are global options. dhcpcd reads every line after an interface or ssid\n# line as that interface's own, so this block belongs above any such line; a\n# stock dhcpcd.conf has none. Both are as documented in dhcpcd.conf(5), which\n# was not installed on the machine this was written on.\n#\n# Applied at dhcpcd's next start, not now. dhcpcd.service cannot be reloaded\n# (CanReload=no, measured), and restarting it drops the address this machine is\n# reached at \u2014 its channel to the mesh. So nothing here restarts or reloads\n# it. On an adopted machine the predecessor's identical nohook line is normally\n# already in force, so nothing is waiting on that start.\nnohook resolv.conf\ndenyinterfaces mesh0\n" + }, + { + "id": "service", + "type": "service", + "unit": "dhcpcd.service", + "state": "running", + "boot": "enabled" + } + ] +} diff --git a/modules/networkmanager/module.json b/modules/networkmanager/module.json new file mode 100644 index 0000000..df96740 --- /dev/null +++ b/modules/networkmanager/module.json @@ -0,0 +1,38 @@ +{ + "module": "networkmanager", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "the-uplink", + "scope": "node" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "networkmanager" + }, + { + "id": "config", + "type": "file", + "path": "/etc/NetworkManager/conf.d/50-mesh.conf", + "mode": "0644", + "content": "# Managed by the mesh (module networkmanager). Replaced on every push; edit the\n# catalogue instead.\n#\n# This machine's uplink is NetworkManager's, and this file is the whole of what\n# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh,\n# and leave the private network's interface alone. Nothing more. The mesh never\n# declares a connection profile, an address, a route, a wireless network or its\n# credentials \u2014 those are joined at the machine, by the person using it, and\n# the link they make is the only channel the mesh reaches this machine over. A\n# push that got a link wrong could not be undone by the next one.\n#\n# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the\n# operator keeps in this directory: NetworkManager reads every file here in\n# order, so this one needs to know nothing about the others. The service is\n# reloaded when this file changes, never restarted \u2014 a restart takes every\n# link down with it, this machine's channel to the mesh included. A reload is\n# NetworkManager's D-Bus Reload call, which re-reads its configuration\n# (NetworkManager(8)).\n\n[main]\n# The resolver file is the mesh's: resolv-conf writes /etc/resolv.conf and names\n# the mesh's resolver. Without this line NetworkManager rewrites that file on\n# every connectivity change \u2014 every network joined, every lease renewed \u2014\n# and the mesh's resolver is silently replaced while every surface of the mesh\n# still reads green. none: \"NetworkManager will not modify resolv.conf. This\n# implies rc-manager unmanaged\" (NetworkManager.conf(5), 1.58). On an adopted\n# machine the predecessor wrote the same line in a file of its own; both say one\n# thing, and the predecessor's is retired by hand after the take.\ndns=none\n\n[keyfile]\n# mesh0 is the private network's interface: the mesh brings it up and the mesh\n# alone configures it. A manager that considers every interface its own could\n# try to configure it, or tear it down on a profile change.\n#\n# unmanaged-devices rather than a [device-mesh0] section with managed=0, because\n# NetworkManager.conf(5) says a device unmanaged by this key \"is strictly\n# unmanaged and cannot be overruled by using the API like nmcli device set\n# $IFNAME managed yes\", while device*.managed \"can be overruled at runtime via\n# D-Bus\". For the mesh's own interface, strict is the point.\n#\n# += rather than =: the same page documents appending to a list-valued key set\n# earlier (\"plugins+=another-plugin\") as an extension of its key file format,\n# and unmanaged-devices is a device list. = would replace whatever devices the\n# operator already keeps NetworkManager away from; += adds this one to them\n# (novox/hq ADR 0102: a list is added to, never replaced). A file of the\n# operator's read after this one that sets the key with = replaces it again;\n# that is the operator's to decide.\nunmanaged-devices+=interface-name:mesh0\n" + }, + { + "id": "service", + "type": "service", + "unit": "NetworkManager.service", + "state": "running", + "boot": "enabled", + "reload-on": [ + "config" + ] + } + ] +} diff --git a/modules/systemd-networkd/module.json b/modules/systemd-networkd/module.json new file mode 100644 index 0000000..7c40663 --- /dev/null +++ b/modules/systemd-networkd/module.json @@ -0,0 +1,38 @@ +{ + "module": "systemd-networkd", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "the-uplink", + "scope": "node" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "systemd" + }, + { + "id": "config", + "type": "file", + "path": "/etc/systemd/network/00-mesh0.network", + "mode": "0644", + "content": "# Managed by the mesh (module systemd-networkd). Replaced on every push; edit\n# the catalogue instead.\n#\n# This machine's uplink is systemd-networkd's, and the mesh asks one thing of it\n# here (novox/hq ADR 0117): leave the private network's interface alone. mesh0\n# is the mesh's; the mesh brings it up and configures it itself. The mesh never\n# declares a link, an address, a route, a wireless network or its credentials,\n# nor a network file for any of this machine's own interfaces \u2014 those are\n# the operator's, and the link they make is the only channel the mesh reaches\n# this machine over.\n#\n# 00-: networkd applies the first .network file, in alphanumeric order across\n# every directory, that matches an interface, and ignores every later one even\n# if it matches too (systemd.network(5), [Match]). A catch-all of the operator's\n# \u2014 Name=*, Type=ether, a file with no [Match] at all \u2014 sorted before\n# this one would claim mesh0 first. 00 sorts before every numbered prefix the\n# man page recommends.\n#\n# Unmanaged=yes: \"no attempts are made to bring up or configure matching links,\n# equivalent to when there are no matching network files\" (systemd.network(5),\n# [Link], since 233). A match that ends the search, and does nothing else.\n#\n# No DNS setting, because none is needed: networkd never writes\n# /etc/resolv.conf. What it learns from a lease it hands only to\n# systemd-resolved, and the resolver file stays whatever resolv-conf wrote.\n# Whether resolved runs, and what it does with that, is the resolver\n# configuration's question, not the uplink's.\n#\n# The service is reloaded when this file changes, never restarted: a restart\n# drops the links networkd holds, this machine's channel to the mesh among them.\n[Match]\nName=mesh0\n\n[Link]\nUnmanaged=yes\n" + }, + { + "id": "service", + "type": "service", + "unit": "systemd-networkd.service", + "state": "running", + "boot": "enabled", + "reload-on": [ + "config" + ] + } + ] +} From 7aea08d6c3080baf9573d518873b34fdd4f2b275 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:48:20 +0200 Subject: [PATCH 07/11] dhcpcd's mesh block goes at the start: lines after an interface line are that interface's --- modules/dhcpcd/module.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/dhcpcd/module.json b/modules/dhcpcd/module.json index 6a4d6f4..2a129d6 100644 --- a/modules/dhcpcd/module.json +++ b/modules/dhcpcd/module.json @@ -23,7 +23,8 @@ "path": "/etc/dhcpcd.conf", "mode": "0644", "into": "block", - "content": "# Managed by the mesh (module dhcpcd). Only the lines between these markers are\n# the mesh's; the rest of this file is the operator's and is kept as it is.\n# dhcpcd reads no drop-in directory, so the mesh writes into its one file\n# rather than over it (novox/hq ADR 0102).\n#\n# This machine's uplink is dhcpcd's, and these two lines are the whole of what\n# the mesh asks of it (novox/hq ADR 0117). The mesh never declares an\n# interface, an address, a route, a wireless network or its credentials \u2014\n# the link dhcpcd keeps is the only channel the mesh reaches this machine over.\n#\n# nohook resolv.conf: the resolver file is the mesh's (resolv-conf). dhcpcd's\n# resolv.conf hook rewrites /etc/resolv.conf on every lease it takes or renews,\n# which would silently replace the mesh's resolver at the next renewal.\n#\n# denyinterfaces mesh0: the private network's interface is the mesh's. dhcpcd\n# never asks for a lease on it, and never takes it down.\n#\n# Both are global options. dhcpcd reads every line after an interface or ssid\n# line as that interface's own, so this block belongs above any such line; a\n# stock dhcpcd.conf has none. Both are as documented in dhcpcd.conf(5), which\n# was not installed on the machine this was written on.\n#\n# Applied at dhcpcd's next start, not now. dhcpcd.service cannot be reloaded\n# (CanReload=no, measured), and restarting it drops the address this machine is\n# reached at \u2014 its channel to the mesh. So nothing here restarts or reloads\n# it. On an adopted machine the predecessor's identical nohook line is normally\n# already in force, so nothing is waiting on that start.\nnohook resolv.conf\ndenyinterfaces mesh0\n" + "at": "start", + "content": "# Managed by the mesh (module dhcpcd). Only the lines between these markers are\n# the mesh's; the rest of this file is the operator's and is kept as it is.\n# dhcpcd reads no drop-in directory, so the mesh writes into its one file\n# rather than over it (novox/hq ADR 0102).\n#\n# This machine's uplink is dhcpcd's, and these two lines are the whole of what\n# the mesh asks of it (novox/hq ADR 0117). The mesh never declares an\n# interface, an address, a route, a wireless network or its credentials \u2014\n# the link dhcpcd keeps is the only channel the mesh reaches this machine over.\n#\n# nohook resolv.conf: the resolver file is the mesh's (resolv-conf). dhcpcd's\n# resolv.conf hook rewrites /etc/resolv.conf on every lease it takes or renews,\n# which would silently replace the mesh's resolver at the next renewal.\n#\n# denyinterfaces mesh0: the private network's interface is the mesh's. dhcpcd\n# never asks for a lease on it, and never takes it down.\n#\n# Both are global options. dhcpcd reads every line after an interface or ssid\n# line as that interface's own, and a configured machine's file ends in exactly\n# such a block (interface , its static address). Appended at the end, these\n# two would quietly apply to one interface only \u2014 so the region is placed at the\n# start of the file (at: start), above anything interface-scoped. Both are as\n# documented in dhcpcd.conf(5), which was not installed on the machine this was\n# written on.\n#\n# Applied at dhcpcd's next start, not now. dhcpcd.service cannot be reloaded\n# (CanReload=no, measured), and restarting it drops the address this machine is\n# reached at \u2014 its channel to the mesh. So nothing here restarts or reloads\n# it. On an adopted machine the predecessor's identical nohook line is normally\n# already in force, so nothing is waiting on that start.\nnohook resolv.conf\ndenyinterfaces mesh0\n" }, { "id": "service", From fd09b1a50e4d997c5b034920199f5135ab65fb70 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 00:07:27 +0200 Subject: [PATCH 08/11] =?UTF-8?q?review:=20the=20uplink=20managers=20are?= =?UTF-8?q?=20the=20machine's=20=E2=80=94=20no=20state=20on=20their=20serv?= =?UTF-8?q?ices,=20none=20for=20dhcpcd;=20comments=20corrected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/dhcpcd/README.md | 43 ++++++++++++++++++++++++++++ modules/dhcpcd/module.json | 9 +----- modules/networkmanager/module.json | 4 +-- modules/systemd-networkd/module.json | 2 -- 4 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 modules/dhcpcd/README.md diff --git a/modules/dhcpcd/README.md b/modules/dhcpcd/README.md new file mode 100644 index 0000000..77948be --- /dev/null +++ b/modules/dhcpcd/README.md @@ -0,0 +1,43 @@ +# dhcpcd + +The uplink seat's module for a machine whose own network is dhcpcd's (novox/hq ADR 0117). It +asks two things of dhcpcd, and nothing else: leave the resolver file to the mesh, and leave the +private network's interface alone. It never declares an interface, an address, a route, a +wireless network or its credentials — the link dhcpcd keeps is the only channel the mesh reaches +the machine over. + +## What it writes + +Two lines into `/etc/dhcpcd.conf`, as the mesh's marked region (`into: block`) — dhcpcd reads no +drop-in directory, so the mesh writes into its one file rather than over it (ADR 0102): + +- `nohook resolv.conf` — dhcpcd's resolv.conf hook rewrites `/etc/resolv.conf` on every lease it + takes or renews, which would silently replace the resolver `resolv-conf` names. +- `denyinterfaces mesh0` — dhcpcd never asks for a lease on the private network's interface, and + never takes it down. dhcpcd leaves a point-to-point interface alone by default; this says so + rather than relying on it. + +**At the start of the file** (`at: start`). Both are global options, and dhcpcd reads every line +after an `interface` or `ssid` line as that interface's own. A configured machine's file ends in +exactly such a block (the interface, its static address), so appended at the end these two would +quietly apply to one interface only. + +## Why it declares no service + +dhcpcd is the machine's, not the mesh's. The mesh never starts, stops or enables it: stopping it +drops the address the machine is reached at, and a module unassigned by mistake must not be able +to do that. And there is nothing to reload it with — `dhcpcd.service` reports `CanReload=no`, and +a restart drops the lease. So the two lines take effect at **dhcpcd's next start**. + +On an adopted machine that is normally no gap: the predecessor wrote the same `nohook` line, and +it is already in force. **On a machine that was not adopted, it is one:** until dhcpcd next +starts (a reboot, or the operator restarting it in a window of their choosing), a lease renewal +still rewrites `/etc/resolv.conf`, and `resolv-conf` puts it back at the next push. Assign this +module before `resolv-conf` on such a machine, and restart dhcpcd once, by hand, when losing the +link for a moment is acceptable. + +## One manager per machine + +It claims `the-uplink`: a machine runs one network manager, and assigning a second module that +claims the seat is refused. Assigning this one to a machine whose network is NetworkManager's +installs the package and writes the two lines, and starts nothing. diff --git a/modules/dhcpcd/module.json b/modules/dhcpcd/module.json index 2a129d6..1e7aa67 100644 --- a/modules/dhcpcd/module.json +++ b/modules/dhcpcd/module.json @@ -24,14 +24,7 @@ "mode": "0644", "into": "block", "at": "start", - "content": "# Managed by the mesh (module dhcpcd). Only the lines between these markers are\n# the mesh's; the rest of this file is the operator's and is kept as it is.\n# dhcpcd reads no drop-in directory, so the mesh writes into its one file\n# rather than over it (novox/hq ADR 0102).\n#\n# This machine's uplink is dhcpcd's, and these two lines are the whole of what\n# the mesh asks of it (novox/hq ADR 0117). The mesh never declares an\n# interface, an address, a route, a wireless network or its credentials \u2014\n# the link dhcpcd keeps is the only channel the mesh reaches this machine over.\n#\n# nohook resolv.conf: the resolver file is the mesh's (resolv-conf). dhcpcd's\n# resolv.conf hook rewrites /etc/resolv.conf on every lease it takes or renews,\n# which would silently replace the mesh's resolver at the next renewal.\n#\n# denyinterfaces mesh0: the private network's interface is the mesh's. dhcpcd\n# never asks for a lease on it, and never takes it down.\n#\n# Both are global options. dhcpcd reads every line after an interface or ssid\n# line as that interface's own, and a configured machine's file ends in exactly\n# such a block (interface , its static address). Appended at the end, these\n# two would quietly apply to one interface only \u2014 so the region is placed at the\n# start of the file (at: start), above anything interface-scoped. Both are as\n# documented in dhcpcd.conf(5), which was not installed on the machine this was\n# written on.\n#\n# Applied at dhcpcd's next start, not now. dhcpcd.service cannot be reloaded\n# (CanReload=no, measured), and restarting it drops the address this machine is\n# reached at \u2014 its channel to the mesh. So nothing here restarts or reloads\n# it. On an adopted machine the predecessor's identical nohook line is normally\n# already in force, so nothing is waiting on that start.\nnohook resolv.conf\ndenyinterfaces mesh0\n" - }, - { - "id": "service", - "type": "service", - "unit": "dhcpcd.service", - "state": "running", - "boot": "enabled" + "content": "# The mesh's two lines (module dhcpcd, novox/hq ADR 0117). Global options, so\n# kept above any interface line; read at dhcpcd's next start.\nnohook resolv.conf\ndenyinterfaces mesh0\n" } ] } diff --git a/modules/networkmanager/module.json b/modules/networkmanager/module.json index df96740..6bf00f1 100644 --- a/modules/networkmanager/module.json +++ b/modules/networkmanager/module.json @@ -22,14 +22,12 @@ "type": "file", "path": "/etc/NetworkManager/conf.d/50-mesh.conf", "mode": "0644", - "content": "# Managed by the mesh (module networkmanager). Replaced on every push; edit the\n# catalogue instead.\n#\n# This machine's uplink is NetworkManager's, and this file is the whole of what\n# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh,\n# and leave the private network's interface alone. Nothing more. The mesh never\n# declares a connection profile, an address, a route, a wireless network or its\n# credentials \u2014 those are joined at the machine, by the person using it, and\n# the link they make is the only channel the mesh reaches this machine over. A\n# push that got a link wrong could not be undone by the next one.\n#\n# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the\n# operator keeps in this directory: NetworkManager reads every file here in\n# order, so this one needs to know nothing about the others. The service is\n# reloaded when this file changes, never restarted \u2014 a restart takes every\n# link down with it, this machine's channel to the mesh included. A reload is\n# NetworkManager's D-Bus Reload call, which re-reads its configuration\n# (NetworkManager(8)).\n\n[main]\n# The resolver file is the mesh's: resolv-conf writes /etc/resolv.conf and names\n# the mesh's resolver. Without this line NetworkManager rewrites that file on\n# every connectivity change \u2014 every network joined, every lease renewed \u2014\n# and the mesh's resolver is silently replaced while every surface of the mesh\n# still reads green. none: \"NetworkManager will not modify resolv.conf. This\n# implies rc-manager unmanaged\" (NetworkManager.conf(5), 1.58). On an adopted\n# machine the predecessor wrote the same line in a file of its own; both say one\n# thing, and the predecessor's is retired by hand after the take.\ndns=none\n\n[keyfile]\n# mesh0 is the private network's interface: the mesh brings it up and the mesh\n# alone configures it. A manager that considers every interface its own could\n# try to configure it, or tear it down on a profile change.\n#\n# unmanaged-devices rather than a [device-mesh0] section with managed=0, because\n# NetworkManager.conf(5) says a device unmanaged by this key \"is strictly\n# unmanaged and cannot be overruled by using the API like nmcli device set\n# $IFNAME managed yes\", while device*.managed \"can be overruled at runtime via\n# D-Bus\". For the mesh's own interface, strict is the point.\n#\n# += rather than =: the same page documents appending to a list-valued key set\n# earlier (\"plugins+=another-plugin\") as an extension of its key file format,\n# and unmanaged-devices is a device list. = would replace whatever devices the\n# operator already keeps NetworkManager away from; += adds this one to them\n# (novox/hq ADR 0102: a list is added to, never replaced). A file of the\n# operator's read after this one that sets the key with = replaces it again;\n# that is the operator's to decide.\nunmanaged-devices+=interface-name:mesh0\n" + "content": "# Managed by the mesh (module networkmanager). Replaced on every push; edit the\n# catalogue instead.\n#\n# This machine's uplink is NetworkManager's, and this file is the whole of what\n# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh,\n# and leave the private network's interface alone. Nothing more. The mesh never\n# declares a connection profile, an address, a route, a wireless network or its\n# credentials \u2014 those are joined at the machine, by the person using it, and\n# the link they make is the only channel the mesh reaches this machine over. A\n# push that got a link wrong could not be undone by the next one.\n#\n# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the\n# operator keeps in this directory. NetworkManager reads the files here sorted by\n# name and a later one wins a key it sets again \u2014 so a file of the operator's\n# that sorts after this one (any name starting with a letter does) and sets dns=\n# or unmanaged-devices= overrides it. That is the operator's to decide, and the\n# reason this file sets nothing but the two keys it must.\n#\n# NetworkManager itself is the machine's: the mesh never starts, stops, enables\n# or disables it (its service is declared with no state), because stopping it\n# takes every link down, this machine's channel to the mesh included \u2014 and a\n# module unassigned by mistake must not be able to do that. When this file\n# changes, a running NetworkManager is reloaded (its D-Bus Reload call, which\n# re-reads its configuration \u2014 NetworkManager(8)), never restarted.\n\n[main]\n# The resolver file is the mesh's: resolv-conf writes /etc/resolv.conf and names\n# the mesh's resolver. Without this line NetworkManager rewrites that file on\n# every connectivity change \u2014 every network joined, every lease renewed \u2014\n# and the mesh's resolver is silently replaced while every surface of the mesh\n# still reads green. none: \"NetworkManager will not modify resolv.conf. This\n# implies rc-manager unmanaged\" (NetworkManager.conf(5), 1.58). On an adopted\n# machine the predecessor wrote the same line in a file of its own; both say one\n# thing, and the predecessor's is retired by hand after the take.\ndns=none\n\n[keyfile]\n# mesh0 is the private network's interface: the mesh brings it up and the mesh\n# alone configures it. A manager that considers every interface its own could\n# try to configure it, or tear it down on a profile change.\n#\n# unmanaged-devices rather than a [device-mesh0] section with managed=0, because\n# NetworkManager.conf(5) says a device unmanaged by this key \"is strictly\n# unmanaged and cannot be overruled by using the API like nmcli device set\n# $IFNAME managed yes\", while device*.managed \"can be overruled at runtime via\n# D-Bus\". The same page adds that device*.managed \"may be a better choice\" for\n# exactly those reasons \u2014 for an interface the operator might want to hand back\n# at runtime. For the mesh's own interface, strict is the point.\n#\n# += rather than =: the same page documents appending to a list-valued key set\n# earlier (\"plugins+=another-plugin\") as an extension of its key file format,\n# and unmanaged-devices is a device list. = would replace whatever devices the\n# operator already keeps NetworkManager away from; += adds this one to them\n# (novox/hq ADR 0102: a list is added to, never replaced). A file of the\n# operator's read after this one that sets the key with = replaces it again;\n# that is the operator's to decide.\nunmanaged-devices+=interface-name:mesh0\n" }, { "id": "service", "type": "service", "unit": "NetworkManager.service", - "state": "running", - "boot": "enabled", "reload-on": [ "config" ] diff --git a/modules/systemd-networkd/module.json b/modules/systemd-networkd/module.json index 7c40663..c4f6b51 100644 --- a/modules/systemd-networkd/module.json +++ b/modules/systemd-networkd/module.json @@ -28,8 +28,6 @@ "id": "service", "type": "service", "unit": "systemd-networkd.service", - "state": "running", - "boot": "enabled", "reload-on": [ "config" ] From 968473219a41355c8ba8a5161a1e566b527f6807 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 01:33:28 +0200 Subject: [PATCH 09/11] dnsmasq owns its resolver format: node-zones is a template, not a controller formatter (ADR 0120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node-zones fact was a path; the local=/address= syntax lived in the control plane. It is dnsmasq's configuration language, so it moves into dnsmasq's manifest as a template over the roster. The mesh renders it; it reads none of it. Output is unchanged. Lands with mesh-controller's ADR 0120 change — the two are one schema step. --- modules/dnsmasq/module.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 9cda1c5..58f3050 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -70,6 +70,9 @@ } ], "facts": { - "node-zones": "/etc/mesh-resolver/nodes.conf" + "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}}" + } } } From 6bedcd3f21d5818ad1ba2c9992a970e64e426bdf Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 14:30:56 +0200 Subject: [PATCH 10/11] Rename seat claims to the mesh-*/node-* convention; retire verdaccio (ADR 0121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claims renamed to match the controller's seat set: node-dns-resolver (dnsmasq), node-intrusion-prevention (fail2ban), node-packet-filter (nftables), node-resolver-config (resolv-conf, resolved-split-dns), node-uplink (networkmanager, systemd-networkd, dhcpcd), mesh-build-machine (builder, +mesh scope), mesh-catalog (mesh-catalog). showcase now declares its own seat and claims it. verdaccio removed — the mesh keeps distribution as its registry and gitea already serves npm, so a second npm registry is redundant. --- modules/builder/module.json | 4 +- modules/dhcpcd/module.json | 2 +- modules/dnsmasq/module.json | 6 +- modules/fail2ban/module.json | 2 +- modules/mesh-catalog/module.json | 2 +- modules/networkmanager/module.json | 2 +- modules/nftables/module.json | 4 +- modules/resolv-conf/module.json | 22 ++- modules/resolved-split-dns/module.json | 44 +++-- modules/showcase/module.json | 241 +++++++++++++++++++------ modules/systemd-networkd/module.json | 2 +- modules/verdaccio/Dockerfile | 30 --- modules/verdaccio/client.ts | 91 ---------- modules/verdaccio/index.ts | 45 ----- modules/verdaccio/module.json | 130 ------------- modules/verdaccio/package.json | 14 -- modules/verdaccio/tools/index.ts | 35 ---- modules/verdaccio/tsconfig.json | 12 -- 18 files changed, 242 insertions(+), 446 deletions(-) delete mode 100644 modules/verdaccio/Dockerfile delete mode 100644 modules/verdaccio/client.ts delete mode 100644 modules/verdaccio/index.ts delete mode 100644 modules/verdaccio/module.json delete mode 100644 modules/verdaccio/package.json delete mode 100644 modules/verdaccio/tools/index.ts delete mode 100644 modules/verdaccio/tsconfig.json diff --git a/modules/builder/module.json b/modules/builder/module.json index 9d38701..165c84b 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -6,8 +6,8 @@ ], "claims": [ { - "name": "the-build-machine", - "scope": "node" + "name": "mesh-build-machine", + "scope": "mesh" } ], "requires": [ diff --git a/modules/dhcpcd/module.json b/modules/dhcpcd/module.json index 1e7aa67..89ffe07 100644 --- a/modules/dhcpcd/module.json +++ b/modules/dhcpcd/module.json @@ -7,7 +7,7 @@ ], "claims": [ { - "name": "the-uplink", + "name": "node-uplink", "scope": "node" } ], diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 58f3050..d972161 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -16,7 +16,7 @@ }, "claims": [ { - "name": "the-dns-port", + "name": "node-dns-resolver", "scope": "node" } ], @@ -46,7 +46,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 \u2014 its name and everything\n# under it \u2014 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# Where it answers. Both are names the mesh chose, so this file needs to know\n# nothing about this particular machine:\n#\n# mesh0 the private network, so anything on it can ask \u2014 including\n# this machine's containers. 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 this module orders neither: the key holds for\n# every container created after the runtime next starts. On the\n# machine this replaces the predecessor wrote the same value, so\n# nothing there is waiting on it.\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 \u2014\n# .53 is its stub and .54 its proxy stub \u2014 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 `the-dns-port`.\n#\n# bind-dynamic rather than bind-interfaces: mesh0 does not exist until the\n# machine is on the private network, and binding an interface that is not there\n# yet fails to start rather than waiting for it.\nbind-dynamic\ninterface=mesh0\nlisten-address=127.0.0.1\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 \u2014 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 \u2014 as the predecessor's\n# did \u2014 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# A name without a dot is never forwarded \u2014 a bare hostname is answered from\n# /etc/hosts or not at all \u2014 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 \u2014 Mediahuis's 13, say \u2014 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 \u2014 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 \u2014 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 \u2014 they\n# were moved here first.\nconf-dir=/etc/dnsmasq.d/,*.conf\naddn-hosts=/etc/hosts.local\n" + "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 \u2014 its name and everything\n# under it \u2014 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# Where it answers. Both are names the mesh chose, so this file needs to know\n# nothing about this particular machine:\n#\n# mesh0 the private network, so anything on it can ask \u2014 including\n# this machine's containers. 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 this module orders neither: the key holds for\n# every container created after the runtime next starts. On the\n# machine this replaces the predecessor wrote the same value, so\n# nothing there is waiting on it.\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 \u2014\n# .53 is its stub and .54 its proxy stub \u2014 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: mesh0 does not exist until the\n# machine is on the private network, and binding an interface that is not there\n# yet fails to start rather than waiting for it.\nbind-dynamic\ninterface=mesh0\nlisten-address=127.0.0.1\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 \u2014 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 \u2014 as the predecessor's\n# did \u2014 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# A name without a dot is never forwarded \u2014 a bare hostname is answered from\n# /etc/hosts or not at all \u2014 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 \u2014 Mediahuis's 13, say \u2014 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 \u2014 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 \u2014 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 \u2014 they\n# were moved here first.\nconf-dir=/etc/dnsmasq.d/,*.conf\naddn-hosts=/etc/hosts.local\n" }, { "id": "runtime-dns", @@ -72,7 +72,7 @@ "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{{range .Machines}}address=/{{.FQDN}}/{{.Address}}\n{{end}}" + "template": "# Generated by the mesh. Do not edit \u2014 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}}" } } } diff --git a/modules/fail2ban/module.json b/modules/fail2ban/module.json index 8b94092..9876432 100644 --- a/modules/fail2ban/module.json +++ b/modules/fail2ban/module.json @@ -6,7 +6,7 @@ ], "claims": [ { - "name": "the-intrusion-prevention", + "name": "node-intrusion-prevention", "scope": "node" } ], diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index 097368a..4c52d90 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -7,7 +7,7 @@ ], "claims": [ { - "name": "the-catalogue", + "name": "mesh-catalog", "scope": "mesh" } ], diff --git a/modules/networkmanager/module.json b/modules/networkmanager/module.json index 6bf00f1..5aaa8f6 100644 --- a/modules/networkmanager/module.json +++ b/modules/networkmanager/module.json @@ -7,7 +7,7 @@ ], "claims": [ { - "name": "the-uplink", + "name": "node-uplink", "scope": "node" } ], diff --git a/modules/nftables/module.json b/modules/nftables/module.json index 11a6be6..dea15e0 100644 --- a/modules/nftables/module.json +++ b/modules/nftables/module.json @@ -6,7 +6,7 @@ ], "claims": [ { - "name": "the-packet-filter", + "name": "node-packet-filter", "scope": "node" } ], @@ -30,7 +30,7 @@ "id": "stock-unit-stop", "type": "file", "path": "/etc/systemd/system/nftables.service.d/mesh.conf", - "content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n", + "content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) \u2014 a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n", "mode": "0644" }, { diff --git a/modules/resolv-conf/module.json b/modules/resolv-conf/module.json index 520e864..dd71fbc 100644 --- a/modules/resolv-conf/module.json +++ b/modules/resolv-conf/module.json @@ -2,12 +2,22 @@ "module": "resolv-conf", "version": "1", "slug": "resolv", - - "requires": ["wildcard-resolution"], - "claims": [{"name": "the-resolver-configuration", "scope": "node"}], - + "requires": [ + "wildcard-resolution" + ], + "claims": [ + { + "name": "node-resolver-config", + "scope": "node" + } + ], "resources": [ - {"id": "resolv", "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 — 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 — 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 — a resolver's second line is asked only when the first does not\n# answer at all — and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n"} + { + "id": "resolv", + "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" + } ] } diff --git a/modules/resolved-split-dns/module.json b/modules/resolved-split-dns/module.json index 246ba83..c79d759 100644 --- a/modules/resolved-split-dns/module.json +++ b/modules/resolved-split-dns/module.json @@ -2,18 +2,38 @@ "module": "resolved-split-dns", "version": "1", "slug": "splitdns", - - "requires": ["wildcard-resolution"], - "claims": [{"name": "the-resolver-configuration", "scope": "node"}], - + "requires": [ + "wildcard-resolution" + ], + "claims": [ + { + "name": "node-resolver-config", + "scope": "node" + } + ], "resources": [ - {"id": "drop-in", "type": "directory", "path": "/etc/systemd/resolved.conf.d", "mode": "0755"}, - - {"id": "route", "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 — 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"}, - - {"id": "resolved", "type": "service", "unit": "systemd-resolved.service", - "state": "running", "boot": "enabled", "restart-on": ["route"]} + { + "id": "drop-in", + "type": "directory", + "path": "/etc/systemd/resolved.conf.d", + "mode": "0755" + }, + { + "id": "route", + "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" + }, + { + "id": "resolved", + "type": "service", + "unit": "systemd-resolved.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "route" + ] + } ] } diff --git a/modules/showcase/module.json b/modules/showcase/module.json index 289cf76..f17d0c5 100644 --- a/modules/showcase/module.json +++ b/modules/showcase/module.json @@ -2,72 +2,195 @@ "module": "showcase", "version": "1", "slug": "show", - - "capabilities": ["container-runtime"], - - "provides": [{ "name": "greeting", "scope": "mesh" }], - "serves": { "greeting": { "path": "/greeting" } }, - "requires": ["postgres-database"], - "binds": { "postgres-database": "/var/lib/showcase/database.json" }, - "secrets": { "postgres-database": "/var/lib/showcase/database.secret" }, - "own-secrets": { "broker": "/var/lib/mesh/showcase/broker" }, - - "claims": [{ "name": "the-showcase", "scope": "node" }], - - "emits": ["module.showcase.acknowledged"], - "consumes": ["module.showcase.greeted"], - + "capabilities": [ + "container-runtime" + ], + "provides": [ + { + "name": "greeting", + "scope": "mesh" + } + ], + "serves": { + "greeting": { + "path": "/greeting" + } + }, + "requires": [ + "postgres-database" + ], + "binds": { + "postgres-database": "/var/lib/showcase/database.json" + }, + "secrets": { + "postgres-database": "/var/lib/showcase/database.secret" + }, + "own-secrets": { + "broker": "/var/lib/mesh/showcase/broker" + }, + "claims": [ + { + "name": "the-showcase", + "scope": "node" + } + ], + "emits": [ + "module.showcase.acknowledged" + ], + "consumes": [ + "module.showcase.greeted" + ], "listens": [ - { "port": 8080, "protocol": "tcp", "from": "mesh", - "why": "the port the daemon itself listens on. The mesh assigns the machine-side number and tells consumers that one (ADR 0038)" } + { + "port": 8080, + "protocol": "tcp", + "from": "mesh", + "why": "the port the daemon itself listens on. The mesh assigns the machine-side number and tells consumers that one (ADR 0038)" + } ], - "build": { "artifacts": [ - { "name": "code", "kind": "bundle", "language": "typescript", - "entrypoints": ["index.js", "tools/index.js", "provisioner/index.js", - "daemon/index.js", "step/index.js", "report/index.js"] }, - { "name": "files", "kind": "archive", "from": "files" }, - { "name": "helper", "kind": "upstream", - "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" } + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js", + "daemon/index.js", + "step/index.js", + "report/index.js" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + }, + { + "name": "helper", + "kind": "upstream", + "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + } ] }, - "resources": [ - { "id": "account", "type": "user", "name": "showcase", "shell": "/usr/bin/nologin", - "home": "/var/lib/showcase" }, - - { "id": "logs", "type": "access", "path": "/var/log", "mode": "0755" }, - - { "id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/showcase", "mode": "0700" }, - { "id": "state", "type": "directory", "path": "/var/lib/showcase", "mode": "0755" }, - - { "id": "settings", "type": "file", "path": "/var/lib/showcase/showcase.env", "mode": "0600", - "content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\nSHOWCASE_LISTEN=${port:8080}\n" }, - - { "id": "packed", "type": "archive", "path": "/opt/showcase", "artifact": "files" }, - - { "id": "net", "type": "network", "name": "showcase" }, - - { "id": "tooling", "type": "package", "package": "jq" }, - - { "id": "migrate", "type": "process", "name": "showcase-migrate", "artifact": "code", - "run": ["node", "step/index.js"], "run-once": true, - "env-file": ["/var/lib/showcase/showcase.env"] }, - - { "id": "server", "type": "process", "name": "showcase", "artifact": "code", - "run": ["node", "daemon/index.js"], "user": "showcase", - "env-file": ["/var/lib/showcase/showcase.env"], - "restart-on": ["settings"] }, - - { "id": "reporting", "type": "process", "name": "showcase-report", "artifact": "code", - "run": ["node", "report/index.js"], "schedule": "0 3 * * *", - "env-file": ["/var/lib/showcase/showcase.env"] }, - - { "id": "tools", "type": "container", "name": "mesh-showcase", "artifact": "helper", + { + "id": "account", + "type": "user", + "name": "showcase", + "shell": "/usr/bin/nologin", + "home": "/var/lib/showcase" + }, + { + "id": "logs", + "type": "access", + "path": "/var/log", + "mode": "0755" + }, + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/showcase", + "mode": "0700" + }, + { + "id": "state", + "type": "directory", + "path": "/var/lib/showcase", + "mode": "0755" + }, + { + "id": "settings", + "type": "file", + "path": "/var/lib/showcase/showcase.env", + "mode": "0600", + "content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\nSHOWCASE_LISTEN=${port:8080}\n" + }, + { + "id": "packed", + "type": "archive", + "path": "/opt/showcase", + "artifact": "files" + }, + { + "id": "net", + "type": "network", + "name": "showcase" + }, + { + "id": "tooling", + "type": "package", + "package": "jq" + }, + { + "id": "migrate", + "type": "process", + "name": "showcase-migrate", + "artifact": "code", + "run": [ + "node", + "step/index.js" + ], + "run-once": true, + "env-file": [ + "/var/lib/showcase/showcase.env" + ] + }, + { + "id": "server", + "type": "process", + "name": "showcase", + "artifact": "code", + "run": [ + "node", + "daemon/index.js" + ], + "user": "showcase", + "env-file": [ + "/var/lib/showcase/showcase.env" + ], + "restart-on": [ + "settings" + ] + }, + { + "id": "reporting", + "type": "process", + "name": "showcase-report", + "artifact": "code", + "run": [ + "node", + "report/index.js" + ], + "schedule": "0 3 * * *", + "env-file": [ + "/var/lib/showcase/showcase.env" + ] + }, + { + "id": "tools", + "type": "container", + "name": "mesh-showcase", + "artifact": "helper", "network": "showcase", - "volumes": ["/var/lib/mesh/showcase/broker:/run/secrets/broker:ro"], - "env": { "MESH_BROKER_FILE": "/run/secrets/broker" }, - "args": ["sleep", "infinity"] } + "volumes": [ + "/var/lib/mesh/showcase/broker:/run/secrets/broker:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker" + }, + "args": [ + "sleep", + "infinity" + ] + } + ], + "seats": [ + { + "name": "the-showcase", + "scope": "node" + } ] } diff --git a/modules/systemd-networkd/module.json b/modules/systemd-networkd/module.json index c4f6b51..040b67e 100644 --- a/modules/systemd-networkd/module.json +++ b/modules/systemd-networkd/module.json @@ -7,7 +7,7 @@ ], "claims": [ { - "name": "the-uplink", + "name": "node-uplink", "scope": "node" } ], diff --git a/modules/verdaccio/Dockerfile b/modules/verdaccio/Dockerfile deleted file mode 100644 index ee4fec8..0000000 --- a/modules/verdaccio/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -# verdaccio's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own -# node_modules — the module is compiled against exactly the sdk it will run against. The compiler -# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image -# resolved away. -WORKDIR /app/modules/verdaccio -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -FROM ${RUNTIME_BASE} -COPY --from=build /app/modules/verdaccio/dist /app/modules/verdaccio/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. A container that instead ran only its -# provisioner (`run`) served no tools and emitted no events; a container that named no command -# ran no provisioner at all. -ENV MESH_TOOL_MODULES=/app/modules/verdaccio/dist/index.js,/app/modules/verdaccio/dist/tools/index.js diff --git a/modules/verdaccio/client.ts b/modules/verdaccio/client.ts deleted file mode 100644 index 9c5feb1..0000000 --- a/modules/verdaccio/client.ts +++ /dev/null @@ -1,91 +0,0 @@ -// The Verdaccio (npm registry) client — verdaccio's own code, living in the module (novox/hq -// ADR 0039). Both this module's tools and its events entrypoint import it, and nothing outside -// verdaccio does. - -import { readFileSync } from "node:fs"; - -export interface VerdaccioPackage { - name: string; - version?: string; - description?: string; - time?: string; -} - -export interface PackageInfo { - name: string; - latest?: string; - versions: string[]; - description?: string; - modified?: string; -} - -/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ -function meshConfig(file?: string): Record { - if (!file) return {}; - try { return JSON.parse(readFileSync(file, "utf8")) as Record; } - catch { return {}; } -} - -export class VerdaccioClient { - readonly baseUrl: string; - - // A bearer token is optional: package listing and reading are public on most registries, so the - // token is sent only when configured, for a registry that gates reads behind auth. - constructor( - url: string, - private readonly token?: string, - ) { - this.baseUrl = url.replace(/\/+$/, ""); - } - - /** - * Build from the module's resolved environment. The URL is MESH_VERDACCIO_URL (or the local - * port); an optional MESH_VERDACCIO_TOKEN authenticates. Throws when no URL is configured. - */ - static fromEnv(env: NodeJS.ProcessEnv = process.env): VerdaccioClient { - const cfg = meshConfig(env.MESH_VERDACCIO_CONFIG_FILE); - const url = cfg.url ?? (env.MESH_VERDACCIO_URL ?? `http://127.0.0.1:${env.VERDACCIO_PORT ?? "4873"}`); - if (!url) throw new Error("no verdaccio URL — set MESH_VERDACCIO_URL"); - return new VerdaccioClient(url, cfg.token ?? env.MESH_VERDACCIO_TOKEN); - } - - private async getJson(path: string): Promise { - const res = await fetch(`${this.baseUrl}${path}`, { - headers: { - Accept: "application/json", - ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), - }, - }); - if (!res.ok) throw new Error(`Verdaccio ${path}: ${res.status} ${await res.text()}`); - return res.json() as Promise; - } - - /** - * Every package the registry hosts, from Verdaccio's own web API — the same list its UI shows. - * Each entry carries the latest version and the time it was last published. - */ - async listPackages(): Promise { - const raw = await this.getJson("/-/verdaccio/data/packages"); - return (raw ?? []).map((p) => ({ - name: p.name, - version: p.version ?? p["dist-tags"]?.latest, - description: p.description, - time: p.time?.modified ?? p.time, - })); - } - - /** - * The full detail of one package — its dist-tags, every published version, and timestamps — - * from the standard npm packument endpoint (`GET /`). - */ - async getPackageInfo(name: string): Promise { - const doc = await this.getJson(`/${encodeURIComponent(name).replace(/%2F/g, "/")}`); - return { - name: doc.name ?? name, - latest: doc["dist-tags"]?.latest, - versions: Object.keys(doc.versions ?? {}), - description: doc.description, - modified: doc.time?.modified, - }; - } -} diff --git a/modules/verdaccio/index.ts b/modules/verdaccio/index.ts deleted file mode 100644 index 0c23d9e..0000000 --- a/modules/verdaccio/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -// verdaccio's events. The tool runtime imports this once the broker is bound. -// -// Emits (novox/hq ADR 0041/0042): -// module.verdaccio.package.published — a new package version was published to the registry -// -// A genuinely useful signal: a package was just published, so anything on the mesh that pins, -// mirrors or announces dependency releases can react without polling the registry. Verdaccio has -// no publish webhook, so the module discovers it by diffing the package list's latest versions. -// -// The polling is deliberately unhurried: a publish a minute late is still the event, whereas -// hammering the registry for immediacy nobody asked for is not. - -import { emit } from "@novox/mesh-sdk/events"; -import { VerdaccioClient } from "./client.js"; - -const verdaccio = VerdaccioClient.fromEnv(); - -// The latest version we have seen per package name. Primed silently on the first look so a registry -// that was already populated when this started does not announce its whole catalog as freshly -// published. -const latest = new Map(); -let primed = false; - -async function pollPackages(): Promise { - const packages = await verdaccio.listPackages(); - for (const pkg of packages) { - if (!pkg.version) continue; - const known = latest.get(pkg.name); - if (known !== pkg.version) { - // A name we have not seen, or a name whose latest version moved — both are a publish. - if (primed) await emit("module.verdaccio.package.published", { name: pkg.name, version: pkg.version }); - latest.set(pkg.name, pkg.version); - } - } - primed = true; -} - -const tick = (fn: () => Promise, everyMs: number): void => { - const run = (): void => void fn().catch((err) => console.error(`[verdaccio] ${err}`)); - setInterval(run, everyMs); - run(); -}; -tick(pollPackages, 60_000); - -console.log("[verdaccio] watching the registry for newly published packages"); diff --git a/modules/verdaccio/module.json b/modules/verdaccio/module.json deleted file mode 100644 index 5a161d4..0000000 --- a/modules/verdaccio/module.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "module": "verdaccio", - "version": "1", - "slug": "verdacc", - "capabilities": [ - "container-runtime" - ], - "emits": [ - "module.verdaccio.package.published" - ], - "own-secrets": { - "broker": "/var/lib/mesh/verdaccio/broker" - }, - "listens": [ - { - "port": 4873, - "protocol": "tcp", - "from": "mesh", - "why": "the package registry, for installs and publishes" - } - ], - "resources": [ - { - "id": "mesh-state", - "type": "directory", - "path": "/var/lib/mesh/verdaccio", - "mode": "0700" - }, - { - "id": "conf", - "type": "directory", - "path": "/services/verdaccio/conf", - "mode": "0755", - "owner": "10001:10001" - }, - { - "id": "storage", - "type": "directory", - "path": "/services/verdaccio/storage", - "mode": "0700", - "owner": "10001:10001" - }, - { - "id": "config", - "type": "file", - "path": "/services/verdaccio/conf/config.yaml", - "mode": "0644", - "content": "storage: /verdaccio/storage\nauth:\n htpasswd:\n file: /verdaccio/conf/htpasswd\n max_users: 10\nuplinks:\n npmjs:\n url: https://registry.npmjs.org/\npackages:\n \"**\":\n access: $all\n publish: $authenticated\n proxy: npmjs\nserver:\n keepAliveTimeout: 60\n maxBodySize: 10mb\nmiddlewares:\n audit:\n enabled: true\nlog:\n type: stdout\n format: pretty\n level: http\n" - }, - { - "id": "server", - "type": "container", - "name": "verdaccio", - "image": "verdaccio/verdaccio@sha256:7b067a47ae51fb9dff3dcdce60ec0a2cbd7650c208cb4b9f6d37cb1b09b39d43", - "ports": [ - "4873" - ], - "volumes": [ - "/services/verdaccio/storage:/verdaccio/storage", - "/services/verdaccio/conf:/verdaccio/conf" - ] - }, - { - "id": "runtime-config", - "type": "file", - "path": "/var/lib/mesh/verdaccio/config.json", - "mode": "0600", - "content": "{}\n", - "merge": "json" - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-verdaccio", - "network": "host", - "volumes": [ - "/var/lib/mesh/verdaccio/broker:/run/secrets/broker:ro", - "/var/lib/mesh/verdaccio/config.json:/run/config/config.json:ro" - ], - "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_VERDACCIO_URL": "http://127.0.0.1:4873", - "MESH_VERDACCIO_CONFIG_FILE": "/run/config/config.json" - }, - "restart-on": [ - "runtime-config" - ], - "artifact": "runtime" - } - ], - "requires": [ - "route" - ], - "contributes": { - "route": { - "label": "npm", - "port": 4873 - } - }, - "binds": { - "route": "/var/lib/mesh/verdaccio/route.json" - }, - "provides": [ - { - "name": "npm-package-registry", - "scope": "mesh" - } - ], - "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], - "artifacts": [ - { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" - } - ] - } -} diff --git a/modules/verdaccio/package.json b/modules/verdaccio/package.json deleted file mode 100644 index a3c21eb..0000000 --- a/modules/verdaccio/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@novox/module-verdaccio", - "version": "0.1.0", - "description": "verdaccio — private npm registry. Its API client, tools and events live here (novox/hq ADR 0039).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/verdaccio/tools/index.ts b/modules/verdaccio/tools/index.ts deleted file mode 100644 index 5632bef..0000000 --- a/modules/verdaccio/tools/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -// verdaccio's tools — its own code (novox/hq ADR 0039), importing verdaccio's own client. They -// return structured data; the mesh serves them through the sdk's tool harness. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { VerdaccioClient } from "../client.js"; - -export function getVerdaccioTools(verdaccio: VerdaccioClient): ToolDefinition[] { - return [ - { - name: "verdaccio_list_packages", - description: "List every package hosted on the private npm registry, with each one's latest version.", - input: {}, - run: async () => { - const packages = await verdaccio.listPackages(); - return { count: packages.length, packages }; - }, - }, - { - name: "verdaccio_package_info", - description: "Details of one package on the registry: its latest tag, all published versions, and description.", - input: { name: { type: "string", description: "the package name, e.g. '@novox/mesh-sdk'" } }, - run: async (args) => verdaccio.getPackageInfo(String(args.name)), - }, - ]; -} - -// The tools exist only when a registry URL is configured; otherwise verdaccio contributes none -// rather than failing the whole runtime. -registerModuleTools("verdaccio", (env) => { - try { - return getVerdaccioTools(VerdaccioClient.fromEnv(env)); - } catch { - return []; - } -}); diff --git a/modules/verdaccio/tsconfig.json b/modules/verdaccio/tsconfig.json deleted file mode 100644 index 3677859..0000000 --- a/modules/verdaccio/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["client.ts", "index.ts", "tools/index.ts"] -} From 278610c0c3357776518f30725fe4bbd5a2810cf3 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 16:55:34 +0200 Subject: [PATCH 11/11] fail2ban never bans a tunnel peer: ignoreip names the mesh range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jail.local [DEFAULT] gains ignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range} — localhost plus the mesh's own private range, named through the placeholder rather than hardcoded (data is the mesh's, ADR 0112). Without it fail2ban could ban the mesh's own nodes on 10.10.0.0/24; on novox that rule survived only in memory from a now-deleted HAL file and would be lost on the next restart. --- modules/fail2ban/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/fail2ban/module.json b/modules/fail2ban/module.json index 9876432..d24a075 100644 --- a/modules/fail2ban/module.json +++ b/modules/fail2ban/module.json @@ -33,7 +33,7 @@ "type": "file", "path": "/etc/fail2ban/jail.local", "mode": "0644", - "content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n" + "content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n" }, { "id": "jail-sshd",