ADR 0226: assign the private network by its own name, let the proxy name its public issuer

Two modules existed only to say one thing each: networking required mesh-wireguard and nothing else,
and public-acme pointed the one proxy at Let's Encrypt. dhcpcd and cloudflare-dns are assigned
nowhere. Retire all four, keeping every machine's network and every certificate as they are.
This commit is contained in:
jochen
2026-10-06 02:21:15 +02:00
parent 202f2aa144
commit edad6acf49
8 changed files with 207 additions and 12 deletions
@@ -38,6 +38,11 @@ hand.
### What a domain module turns out to be, and why it is not the one refused above
> **Narrowed, not replaced — 2026-10-06, by [ADR 0226](0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md).** The catalogue no longer
> holds a domain module: `networking` was retired, and every machine is assigned the private network's
> own module. A module with requirements and no files stays a thing a module may be; "why it is worth
> having" below describes what this record decided for `networking`, not what runs.
*Written 2026-08-29, from building it. The heading above reads as a contradiction of what now
exists and is not one — but only if the difference is stated, so it is stated here.*
@@ -9,6 +9,10 @@ extends: 0027-a-provision-names-what-the-consumer-is-coupled-to.md
# 44. A public name is provisioned, not registered by hand
> **The mechanism changed — 2026-10-06, by [ADR 0226](0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md).** The interface stands; its one provider,
> `cloudflare-dns`, left the catalogue, assigned nowhere and required by nothing. A mesh that needs a
> public name made for it adds a provider of `public-dns` again.
## Context
The mesh names and resolves its own machines internally: the overlay generates
@@ -9,6 +9,10 @@ extends: 0110-a-seat-is-a-module-assignment-from-a-closed-set.md
# 117. A machine's uplink is a seat: the mesh configures the manager, never the link
> **The mechanism changed — 2026-10-06, by [ADR 0226](0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md).** The seat stands; the catalogue holds two
> of its three modules. `dhcpcd`, held by no machine, left it. What this record says of dhcpcd is what
> a module for it must do, if one is written again.
## Context
The mesh installs on top of a machine's own networking. The private network's generator says
@@ -0,0 +1,155 @@
---
topic: the tiers
status: accepted
date: 2026-10-06
deciders: jochen
reconstructed: false
supersedes-in-part:
- 0009-modules-and-the-graph.md
extends: 02-DECISIONS/0007-connectivity.md
---
# 226. The private network is assigned by its own name, and the proxy names its public issuer
## Context
**The operator, 2026-10-06: "too many network-related modules … we simply have a private network,
some docker stuff, a firewall and a public and internal certificate resolver."** Counted on the
production mesh that day, from the controller's module listing and each machine's plan:
| module | on | what it is |
|---|---|---|
| `networking` | all four machines | requirements only: `private-network`, nothing else. Ships nothing |
| `mesh-wireguard` | on none directly; on all four through `networking` | the private network; its resources are computed by the controller |
| `public-acme` | the anchor and the home server | runs nothing; offers `acme-ca`, pointing at Let's Encrypt's production directory |
| `route-proxy` | the anchor and the home server | the public front door; the only consumer of `acme-ca` |
| `dhcpcd` | none | a third holder of `node-uplink`, for a machine whose link is dhcpcd's |
| `cloudflare-dns` | none | the only provider of `public-dns`; nothing in the catalogue requires `public-dns` |
**`networking` is the domain module [ADR 0009](0009-modules-and-the-graph.md) argued for in its
"what a domain module turns out to be" section**: a module holding nothing, so `assign networking`
finds one VPN and takes it, and choosing another is assigning that instead. It has never been more
than a second name for one module: one VPN exists, every machine runs it, and the name resolution it
once also required became a fact (ADR 0199). Its cost is real: every machine carries two modules for
one network, genesis assigns a module that is not the network, and the resolver's hint for a machine
off the network names the bundle rather than what it installs.
**`public-acme` is a provision with one consumer and one answer.** It was introduced so a lab without
a public issuer could answer `acme-ca` from `step-ca` instead (ADR 0066); `step-ca` now offers only
`internal-acme-ca`, and the mesh itself is the test bed (ADR 0149). What remained was a module
assigned beside every proxy to say "Let's Encrypt", and a pin when two answers stood on one machine
(issue 258).
**The proxy's account directory is named after the issuer as rendered.** `route-proxy` keeps each
authority's ACME account and certificates under `/var/lib/route-proxy/acme`, in a directory named by
a digest of the directory URL and of the root bundle its `trust` step copies in (the proxy's
`forThisAuthority`). The binding rendered the URL as `https://acme-v02.api.letsencrypt.org:443/directory`
— with the port — and the root as the system bundle of the pinned `trust` image. Spelled any other
way, the proxy sees a new authority, registers a new account and orders every routed name again. On
2026-10-06 that is 25 names under one registered domain on the home server, all issued on 2026-10-01,
and 26 on the anchor: a reissue of the home server's would reach Let's Encrypt's 50-per-week limit for that
domain inside the week.
## Considered Options
1. **The private network a default of every machine**, with no assignment. Rejected: "a machine is
on the private network because it was assigned the module" is what made the network a module at
all (connectivity §1, 2026-08-29); a default is a second path to the same state, and a machine
that should stay off would need an exception mechanism that does not exist.
2. **Keep `networking`, document it better.** Rejected: it answers a question — which VPN — that has
had one answer since it was written, and costs a module on every machine to do so. Choosing
another VPN is still assigning it; the bundle never added anything to that.
3. **Assign `mesh-wireguard` directly on every machine and retire the bundle.** Chosen.
4. **`route-proxy` provides `acme-ca` itself**, keeping the provision. Rejected: nothing else
consumes it, and a provision whose only consumer is its provider is a field, not an edge.
5. **The public issuer as a setting of `route-proxy`.** Rejected: a setting has no default (ADR
0112), so every mesh would need it set before the proxy resolves, and the one value that must not
change — the URL as rendered, port included — would become a value anybody can change with
`settings set`. Changing the public issuer of a live proxy is a reissue of every certificate it
holds; it should take an edit of the module and a decision, not a command.
6. **`route-proxy` states Let's Encrypt in its own `acme.env`, byte for byte what the binding
rendered, and `public-acme` retires.** Chosen.
## Decision
**1. The private network is assigned by its own name.** Every machine is assigned `mesh-wireguard`
directly. The `networking` module is retired: the controller no longer ships it, genesis assigns
`mesh-wireguard`, and the resolver's refusal for two machines that share no private network names
`mesh-wireguard`. Another VPN is still chosen by assigning it instead, and the node-scoped claim
`the-private-network` still refuses two. This supersedes ADR 0009's section *what a domain module
turns out to be*: the mechanism — a module with requirements and no files — stands, and the
catalogue no longer holds one.
**2. A module the controller stops shipping is retired at its next start, never from under a
machine.** `module forget` refuses a module the controller ships, because the next start would put
it back; so a retired one could be removed by nothing. At start the controller removes every module
it recorded as its own and no longer ships — unless a machine is still assigned it, or the mesh still
holds settings, secrets or ports for it, in which case it is kept and the start says why.
**3. The proxy names its public issuer.** `route-proxy` no longer requires `acme-ca`; its `acme.env`
states Let's Encrypt's production directory exactly as the binding rendered it. `public-acme` leaves
the catalogue and the mesh. The internal issuer stays a provision (`internal-acme-ca`, from
`step-ca`): it is a module that runs, held on one machine and consumed by every proxy and by
`ca-trust`. The proxy binary's own default stays Let's Encrypt *staging*, for anything that runs it
without the module.
**4. `dhcpcd` and `cloudflare-dns` leave the catalogue.** Neither is assigned anywhere; nothing
requires `public-dns`. `node-uplink` keeps two holders (NetworkManager, systemd-networkd), and the
`public-dns` interface of [ADR 0044](0044-a-public-name-is-provisioned-like-any-capability.md) has no
provider until a mesh needs one.
`avahi` and `netcheck` are not decided here.
## Consequences
- **The rollout changes no machine's network or certificates.** Assigning `mesh-wireguard` beside
`networking` and then unassigning `networking` leaves every machine's declaration of the private
network as it was; the proxy's `acme.env` is unchanged, so its `trust` step does not run again and
its account directory is the one it has. The one file that goes is the proxy's binding to
`acme-ca`, which nothing reads.
- **The order is assign, then unassign.** Unassigning `networking` first takes `mesh-wireguard` off
every machine that does not also run `dnsmasq` (which requires the mesh's addressing) at the next
push — the private network down. The controller's retirement refuses nothing here; it only keeps
the bundle while it is assigned.
- **The public issuer moves only with a plan for the account.** The proxy's account directory depends
on the URL as spelled and on the pinned `trust` image's root bundle. Moving that image's digest
reorders every certificate on both proxies; a test holds both values and says so.
- **The lab's beds that assigned `networking`, or pointed the proxy at `step-ca` for public names,
need changing before they run again.** A lab proxy now orders public names from Let's Encrypt's
production directory, so a bed that routes public names must not assign the module as it stands.
- **Genesis from an older host binary assigns `networking`, which a newer controller refuses.** The
host's change is merged before the controller's.
- **A mesh that wants another public issuer edits `route-proxy`**, rather than assigning a different
provider. That is deliberate (option 5).
## How it is checked
| Rule | Checked by |
|---|---|
| The controller ships one network module and no bundle; it resolves on its own | mesh-controller `internal/catalogue/provided_test.go` |
| A refusal for want of the private network names `mesh-wireguard` | the same file, `TestARefusalForWantOfThePrivateNetworkNamesItsModule` |
| Another VPN is chosen by assigning it, and WireGuard is not dragged in; two VPNs collide | the same file |
| A module no longer shipped is retired at start; kept while assigned or holding anything; a registered module is never touched | mesh-controller `internal/inventory/retire_test.go`, against a live database |
| Genesis assigns `mesh-wireguard`, never `networking` | mesh-host `internal/bootstrap/network_module_test.go` |
| `route-proxy` requires no `acme-ca`; its `acme.env` is byte for byte what the binding rendered; its `trust` image is the pinned one | mesh-controller `internal/catalogue/public_issuer_test.go` against the sibling catalogue |
| `public-acme`, `dhcpcd`, `cloudflare-dns` are not in the catalogue; nothing in it requires `acme-ca` or `public-dns` | the same file |
| Live: every machine still on the private network; every public name's certificate serial and expiry unchanged; no new file in a proxy's account directory | the rollout's checks, before and after each step: the controller's network view, `wg show` through each machine's tools, `openssl s_client` against every routed public name, and a listing of `/var/lib/route-proxy/acme` |
## References
- [ADR 0009](0009-modules-and-the-graph.md) — its section on a domain module is superseded; the rest
stands.
- [ADR 0007](0007-connectivity.md), [connectivity](../03-DESIGN/01-to-be/08-connectivity.md) §1 and §5,
amended alongside.
- [ADR 0066](0066-public-routing-is-name-agnostic.md) — the proxy requiring an issuer; the public one
is now its own fact.
- [ADR 0044](0044-a-public-name-is-provisioned-like-any-capability.md) — `public-dns`, without a
provider in the catalogue.
- [ADR 0117](0117-a-machines-uplink-is-a-seat.md) — `node-uplink`, now with two holders.
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0149](0149-the-live-mesh-is-the-test-bed.md).
- [Issue 258](../04-ISSUES/258-every-machine-bound-the-resolver-to-itself/00-report.md) — the pin two
issuers on one machine needed.
- mesh-controller `internal/overlay/generator.go`, `cmd/mesh-controller/modules.go`, `stores.go`,
`internal/inventory/catalogue.go`, `internal/catalogue/resolve.go`; mesh-host
`internal/bootstrap/phase2.go`; mesh-catalog `modules/route-proxy`, and the removal of
`modules/public-acme`, `modules/dhcpcd`, `modules/cloudflare-dns`.
+1
View File
@@ -237,6 +237,7 @@ python3 00-META/checks/index.py fail if stale
- **0196** — [A node asks the mesh's resolver first, and a public one only when it is silent](0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md)
- **0199** — [A module that answers names declares its zone, and a node's hosts file is one module's](0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)
- **0223** — [The mesh has two resolvers, and a machine lists only them](0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md)
- **0226** — [The private network is assigned by its own name, and the proxy names its public issuer](0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md)
### What runs on them, and how it gets there
+29 -6
View File
@@ -12,12 +12,15 @@ code:
- mesh-controller cmd/mesh-controller/holdings.go (the holders of a replicated seat, ADR 0223)
- mesh-controller internal/catalogue/roster.go (each replicated seat's holders, for a template)
- mesh-catalog modules/dnsmasq (the mesh's resolvers)
- mesh-catalog modules/networkmanager, modules/systemd-networkd, modules/dhcpcd (what a node asks, written by its uplink's holder)
- mesh-catalog modules/networkmanager, modules/systemd-networkd (what a node asks, written by its uplink's holder)
- mesh-catalog modules/route-proxy (the public issuer named by the proxy, ADR 0226)
- mesh-controller internal/overlay/generator.go (the private network's module, assigned by its own name, ADR 0226)
- mesh-catalog modules/hostname (a node's /etc/hostname and /etc/hosts)
- mesh-host internal/identity/serving.go
- mesh-host internal/apply (the service that reflects a rule set; a whole file handed to its new owner)
updated: 2026-10-05
updated: 2026-10-06
decisions:
- 02-DECISIONS/0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md
- 02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md
- 02-DECISIONS/0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md
- 02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md
@@ -121,7 +124,15 @@ settings, and absent from a machine nobody gave it to.
|---|---|---|---|
| the WireGuard one | a private network, **and the mesh's own addressing** | | *the* private network, one per node |
| the names one | name resolution | the mesh's own addressing | |
| `networking` | | both of the above | |
| ~~`networking`~~ | | both of the above | |
> **Amended 2026-10-06, by [ADR 0226](../../02-DECISIONS/0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md).** The names
> became a fact (ADR 0199), leaving `networking` a module that required one other and nothing else,
> assigned on every machine. It is retired: every machine is assigned the WireGuard module —
> `mesh-wireguard` — by its own name, and genesis does the same. Choosing another VPN is still
> assigning it instead; the claim still refuses two. A module the controller stops shipping is
> retired at its next start, and kept while any machine is assigned it. The paragraphs below record
> why the bundle was built.
**Three rather than one, because WireGuard is one VPN of several.** Naming the module after the
job — `networking` — and putting WireGuard inside it is the retired *flavor* idea wearing a
@@ -399,8 +410,9 @@ time.*
A network manager rewrites `/etc/resolv.conf` on every connectivity change unless it is told not to
([ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md)), so the module holding
`node-uplink` — the one telling it — writes the file itself, and there is one owner for it, the one
whose program would otherwise overwrite it. Each of the catalogue's three managers' modules
(NetworkManager, systemd-networkd, dhcpcd) renders the same template from the resolver's holders and
whose program would otherwise overwrite it. Each of the catalogue's managers' modules
(NetworkManager, systemd-networkd; dhcpcd's left the catalogue with
[ADR 0226](../../02-DECISIONS/0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md), held by no machine) renders the same template from the resolver's holders and
requires the mesh's resolver, so a machine is refused when nothing in the mesh resolves rather than
given a file listing nothing. The managers' own mechanisms were weighed and not used: NetworkManager's
global DNS and dhcpcd's static nameservers each write the file in their own form — their own header,
@@ -412,7 +424,7 @@ node declaring one path are refused. The module that wrote the file before, its
([ADR 0220](../../02-DECISIONS/0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md))
retire. The file changes owner in one apply on each machine: the node-engine hands a whole file to the
resource declaring its path now rather than removing it first, so a machine is never without it.
*Checked by the controller's tests that the three modules carry one identical template and that
*Checked by the controller's tests that the uplink modules carry one identical template and that
nothing else in the catalogue writes the path, a resolution test refusing a second writer, and the
node-engine's handover test, in which the file is present at every step of the apply.*
@@ -923,6 +935,17 @@ defaults to the public authority's *production* endpoint. Two consequences, and
worse than the lab problem that found it — every certificate experiment on a real node consumes
production issuance quota, and a retry loop can exhaust it for a week.
> **Amended 2026-10-06, by [ADR 0226](../../02-DECISIONS/0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md).** Configurable in the
> proxy binary, which defaults to the authority's *staging* endpoint; fixed in the mesh. The public
> issuer was a provision, `acme-ca`, answered by a module that ran nothing and was assigned beside
> every proxy; it is the proxy module's own `acme.env` now, Let's Encrypt's production directory
> spelled exactly as the binding rendered it. The proxy names each authority's account directory after
> that spelling and the root it trusts, so changing either is a new account and every certificate
> ordered again: moving the public issuer is an edit of the module with a plan for the account, never
> an assignment. The internal issuer stays a provision, `internal-acme-ca`.
> *How it is checked:* mesh-controller `internal/catalogue/public_issuer_test.go` holds the rendered
> file byte for byte and the `trust` image's digest, against the catalogue.
**The mesh CA is not a bootstrap concern.** A joining node verifies the controller against the
fingerprint in its token ([ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md)),
so nothing needs the CA before membership. It certifies internal names afterwards, and that is
+5 -3
View File
@@ -7,8 +7,9 @@ code:
- mesh-controller internal/catalogue/build.go
- mesh-controller internal/inventory/secrets.go
- mesh-controller cmd/mesh-builder
updated: 2026-09-30
updated: 2026-10-06
decisions:
- 02-DECISIONS/0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md
- 02-DECISIONS/0069-a-module-is-a-repository-and-a-path.md
- 02-DECISIONS/0037-where-a-module-lives.md
- 02-DECISIONS/0009-modules-and-the-graph.md
@@ -37,8 +38,9 @@ three things the mesh now does separately:
| turning one on for one node | **assignment**, which is per node already |
| keeping related things together | **`requires`**, and a module with requirements and no files of its own |
`networking` is exactly that last row: it ships nothing, requires a private network and name
resolution, and assigning it brings both. So the module count does not return, because the thing
`networking` was exactly that last row: it shipped nothing, required a private network, and
assigning it brought one — retired by [ADR 0226](../../02-DECISIONS/0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md) once one VPN was the
only answer it ever gave, and every machine is assigned that module by its own name. So the module count does not return, because the thing
that made it return — *a module is expensive, so put several things in one* — is gone. A module
here is cheap: a manifest and, usually, nothing else.
@@ -5,8 +5,9 @@ code:
- mesh-host internal/bootstrap
- mesh-host cmd/mesh-bootstrap
- mesh-lab test/integration/one-node-mesh.test.ts
updated: 2026-09-21
updated: 2026-10-06
decisions:
- 02-DECISIONS/0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md
- 02-DECISIONS/0067-genesis-is-a-pivot.md
- 02-DECISIONS/0073-the-installer-carries-a-builder.md
- 02-DECISIONS/0071-where-genesis-gets-its-source.md
@@ -72,7 +73,7 @@ catalogue be missing from a test for weeks without anything complaining.
| 15 | the catalogue is built and run | the module graph | without it the mesh cannot say what it holds, what a change reaches, or what must be rebuilt |
| 16 | the catalogue asks for what it missed | the builds made before it existed are replayed | on a fresh mesh those are always the base, the store and the catalogue itself ([issue 050](../../04-ISSUES/050-the-catalogue-knows-nothing-built-before-it/00-report.md)) |
| 17 | the controller is rebuilt from its own repository | and rolled out through the module path | the moment the mesh stops depending on the installer for anything |
| 18 | `networking` is assigned **and the node placed** | a private network, and names | assigning installs the module; placing says where this machine is on it. Both, or the names file is written empty |
| 18 | `mesh-wireguard` is assigned **and the node placed** | a private network | assigning installs the module; placing says where this machine is on it. Both, or the machine has no peers. Assigned by its own name since [ADR 0226](../../02-DECISIONS/0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md) retired the `networking` bundle |
| 19 | the packet filter is assigned | rules generated from what modules declared | until this, every rule the mesh computes has never been applied to anything |
## Phase three — machines arrive
@@ -147,7 +148,7 @@ two.
| 5 | `builder` | — | turns source into artifacts |
| 6 | `mesh-tools` | build inputs | the base everything with code compiles against. **Runs nowhere** |
| 7 | `mesh-catalog` | the module graph | what is held, what a change reaches, what must be rebuilt |
| 8 | `networking` | `private-network`, naming | requirements only — assigning it brings `mesh-wireguard` and `mesh-names` |
| 8 | `mesh-wireguard` | `private-network` | the private network, computed per machine by the controller; assigned directly ([ADR 0226](../../02-DECISIONS/0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md)) |
| 9 | `dnsmasq` + one of `resolved-split-dns` / `resolv-conf` | `wildcard-resolution` | names that actually resolve, on top of `mesh-resolver`'s data |
| 10 | `step-ca` | `acme-ca` | certificates for `.internal` |
| 11 | `firewall` | *claims* `the-packet-filter` | rules generated from what modules declared |