diff --git a/modules/builder/module.json b/modules/builder/module.json index a6cf4a1..cb34be1 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -7,7 +7,7 @@ "claims": [ { "name": "mesh-build-machine", - "scope": "node" + "scope": "mesh" } ], "requires": [ 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 new file mode 100644 index 0000000..89ffe07 --- /dev/null +++ b/modules/dhcpcd/module.json @@ -0,0 +1,30 @@ +{ + "module": "dhcpcd", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-uplink", + "scope": "node" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "dhcpcd" + }, + { + "id": "config", + "type": "file", + "path": "/etc/dhcpcd.conf", + "mode": "0644", + "into": "block", + "at": "start", + "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/distribution/module.json b/modules/distribution/module.json index ea28f11..52a9da3 100644 --- a/modules/distribution/module.json +++ b/modules/distribution/module.json @@ -9,7 +9,7 @@ ], "claims": [ { - "name": "mesh-artifact-store", + "name": "the-artifact-store", "scope": "mesh" } ], diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 8f8af5e..d43b201 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -16,7 +16,7 @@ }, "claims": [ { - "name": "mesh-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" + "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", @@ -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 \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 208e204..d24a075 100644 --- a/modules/fail2ban/module.json +++ b/modules/fail2ban/module.json @@ -6,7 +6,7 @@ ], "claims": [ { - "name": "mesh-intrusion-prevention", + "name": "node-intrusion-prevention", "scope": "node" } ], @@ -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", diff --git a/modules/gitea/module.json b/modules/gitea/module.json index c44cabe..edb5e1d 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -75,11 +75,11 @@ }, "claims": [ { - "name": "mesh-npm-package-registry", + "name": "npm-package-registry", "scope": "mesh" }, { - "name": "mesh-git", + "name": "git", "scope": "mesh" } ], diff --git a/modules/mailu/module.json b/modules/mailu/module.json index d4808fb..3df5469 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": { diff --git a/modules/mssql/module.json b/modules/mssql/module.json index 2de6678..68909ec 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" }, diff --git a/modules/networkmanager/module.json b/modules/networkmanager/module.json new file mode 100644 index 0000000..5aaa8f6 --- /dev/null +++ b/modules/networkmanager/module.json @@ -0,0 +1,36 @@ +{ + "module": "networkmanager", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-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 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", + "reload-on": [ + "config" + ] + } + ] +} diff --git a/modules/nftables/module.json b/modules/nftables/module.json index 2eadabb..dea15e0 100644 --- a/modules/nftables/module.json +++ b/modules/nftables/module.json @@ -6,7 +6,7 @@ ], "claims": [ { - "name": "mesh-packet-filter", + "name": "node-packet-filter", "scope": "node" } ], diff --git a/modules/resolv-conf/module.json b/modules/resolv-conf/module.json index 4a4a7e9..dd71fbc 100644 --- a/modules/resolv-conf/module.json +++ b/modules/resolv-conf/module.json @@ -7,7 +7,7 @@ ], "claims": [ { - "name": "mesh-resolver-configuration", + "name": "node-resolver-config", "scope": "node" } ], diff --git a/modules/resolved-split-dns/module.json b/modules/resolved-split-dns/module.json index b8efa28..c79d759 100644 --- a/modules/resolved-split-dns/module.json +++ b/modules/resolved-split-dns/module.json @@ -7,7 +7,7 @@ ], "claims": [ { - "name": "mesh-resolver-configuration", + "name": "node-resolver-config", "scope": "node" } ], diff --git a/modules/showcase/module.json b/modules/showcase/module.json index 8d9412f..82af1eb 100644 --- a/modules/showcase/module.json +++ b/modules/showcase/module.json @@ -30,7 +30,7 @@ }, "claims": [ { - "name": "mesh-showcase", + "name": "the-showcase", "scope": "node" } ], @@ -173,7 +173,7 @@ { "id": "tools", "type": "container", - "name": "mesh-showcase", + "name": "the-showcase", "artifact": "helper", "network": "showcase", "volumes": [ @@ -187,5 +187,11 @@ "infinity" ] } + ], + "seats": [ + { + "name": "the-showcase", + "scope": "node" + } ] } 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" + ] + } + ] +} diff --git a/modules/systemd-networkd/module.json b/modules/systemd-networkd/module.json new file mode 100644 index 0000000..040b67e --- /dev/null +++ b/modules/systemd-networkd/module.json @@ -0,0 +1,36 @@ +{ + "module": "systemd-networkd", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-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", + "reload-on": [ + "config" + ] + } + ] +} 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 7a13da0..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("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 b9d12bf..0000000 --- a/modules/verdaccio/module.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "module": "verdaccio", - "version": "1", - "slug": "verdacc", - "capabilities": [ - "container-runtime" - ], - "emits": [ - "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"] -}