mesh/merge-gate head 6be8f6eada67 merged into trunk b7e22be7f55f — pass: [release feat/57-config-and-provisioning, judged by its controller cf897e89fe31] t…
mesh/repo-check head 6be8f6eada67 merged into trunk b7e22be7f55f — pass: [release feat/57-config-and-provisioning, judged by its controller cf897e89fe31] i…
mesh/delivery delivered
1311 lines
82 KiB
Markdown
1311 lines
82 KiB
Markdown
---
|
||
layer: to-be
|
||
status: in-progress
|
||
code:
|
||
- mesh-controller internal/catalogue/filtering.go
|
||
- mesh-controller examples/route-proxy
|
||
- mesh-controller internal/identity/authority.go
|
||
- mesh-controller cmd/mesh-controller/plan.go (the names the roster publishes)
|
||
- mesh-controller internal/catalogue/zones.go (the zones a module answers, ADR 0199)
|
||
- mesh-controller internal/catalogue/seats.go (mesh-dns-resolver, node-hostname, node-uplink)
|
||
- mesh-controller internal/catalogue/resolve.go (one owner per path, a rendered fact's included, ADR 0223)
|
||
- 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 (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-controller internal/catalogue/node_resolver.go (a machine's own resolver, ADR 0247)
|
||
- mesh-catalog modules/systemd-resolved (a machine's own resolver, ADR 0247)
|
||
- 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)
|
||
- mesh-controller cmd/mesh-controller/nodes.go (a token issued for a machine's tunnel key, ADR 0169)
|
||
- mesh-host cmd/mesh-host/join_tunnel.go (a machine joins through the tunnel, ADR 0169)
|
||
updated: 2026-10-14
|
||
decisions:
|
||
- "ADR 0241"
|
||
- "ADR 0117"
|
||
- "ADR 0340"
|
||
- "ADR 0247"
|
||
- "ADR 0226"
|
||
- "ADR 0223"
|
||
- "ADR 0220"
|
||
- "ADR 0199"
|
||
- "ADR 0196"
|
||
- "ADR 0194"
|
||
- "ADR 0191"
|
||
- "ADR 0180"
|
||
- "ADR 0170"
|
||
- "ADR 0169"
|
||
- "ADR 0168"
|
||
- "ADR 0167"
|
||
- "ADR 0148"
|
||
- "ADR 0151"
|
||
- "ADR 0147"
|
||
- "ADR 0138"
|
||
- "ADR 0140"
|
||
- "ADR 0104"
|
||
- "ADR 0106"
|
||
- "ADR 0105"
|
||
- "ADR 0119"
|
||
- "ADR 0108"
|
||
- "ADR 0103"
|
||
- "ADR 0100"
|
||
- "ADR 0099"
|
||
- "ADR 0098"
|
||
- "ADR 0005"
|
||
- "ADR 0004"
|
||
- "ADR 0007"
|
||
- "ADR 0007"
|
||
- "ADR 0004"
|
||
- "ADR 0007"
|
||
- "ADR 0006"
|
||
- "ADR 0066"
|
||
- "ADR 0029"
|
||
- "ADR 0044"
|
||
- "ADR 0045"
|
||
---
|
||
|
||
# Connectivity
|
||
|
||
One of [the controller's](06-the-controller.md) ten contexts, and the one with the most
|
||
moving parts: **private network, resolution, exposure, filtering, certificates.**
|
||
|
||
It is written as a whole because the five are one design. They share inputs, they must agree, and
|
||
every one of them today is computed in a different place by a different module from a different
|
||
copy of the same facts.
|
||
|
||
## Why it is controller work
|
||
|
||
Apply [the test](06-the-controller.md) — *everything that needs to know about more than one
|
||
node* — to each responsibility:
|
||
|
||
| | needs to know | whose |
|
||
|---|---|---|
|
||
| **private network** — who peers with whom, at what address | **every node**, and which of them can be dialled | controller |
|
||
| **resolution** — which name is which node | **every node** | controller |
|
||
| **exposure** — which public name reaches which container | **which node is publicly reachable** (ADR 0007) | controller |
|
||
| **filtering** — which port is open, to whom | what is assigned here, and the private network's shape | controller decides, host applies |
|
||
| **certificates** — who may present which name | which name belongs to which node | controller |
|
||
|
||
**Not one of the five can be answered by a machine on its own.** That is the whole reason this is
|
||
a context rather than a set of node-local modules — and it is exactly what the current
|
||
arrangement gets wrong, by computing all five on the node from a direct database connection.
|
||
|
||
## The shape: decided centrally, delivered as files
|
||
|
||
Every one of the five resolves the same way, and it is worth stating once rather than five times:
|
||
|
||
> **The connectivity context computes the configuration. It arrives over the link as `file`
|
||
> resources. The service reads files and knows nothing about the mesh.**
|
||
|
||
This costs **no new host vocabulary**. `file`, `directory`, `service` and `container` already
|
||
exist; WireGuard, the resolver and the proxy are all *a container or a package, plus files*.
|
||
|
||
It is also what removes the last two upward dependencies.
|
||
Research 006 counted exactly two modules
|
||
opening a direct connection to the controller's database — `wireguard` and `traefik` — and
|
||
they are the reason every node permanently holds a credential to it
|
||
(ADR 0004). Both are connectivity
|
||
modules. **Closing this context closes that set.**
|
||
|
||
### And they are modules, not a second mechanism beside the module system
|
||
|
||
*Written 2026-08-29, from building it. The first version was code beside the module system doing
|
||
the module system's job, and the fault it produced is the point of writing this down.*
|
||
|
||
**A machine was on the private network because it had an address.** Every node that had been
|
||
placed got a peer list, whether or not anybody wanted it there, and there was no way to say a
|
||
machine should stay off. That is what "special-cased" cost, and it was invisible until somebody
|
||
wanted the exception.
|
||
|
||
**What made it look unavoidable:** a peer list cannot be written in a manifest. It is derived from
|
||
every other machine, so it differs on each one and changes when any of them changes. So the
|
||
manifest says its resources are **computed** — it names something in the controller that works
|
||
them out per node — and it is a module in every other respect: assigned, resolved, configured by
|
||
settings, and absent from a machine nobody gave it to.
|
||
|
||
**What that made possible immediately** is the arrangement below, which the code has:
|
||
|
||
| module | provides | requires | claims |
|
||
|---|---|---|---|
|
||
| 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 | |
|
||
|
||
> **Amended 2026-10-06, by ADR 0226.** 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
|
||
generic name: the second VPN has nowhere to go. So a module is named for what it *is* and declares
|
||
what it *does*, and `networking` is the third row — requirements and no files
|
||
(ADR 0009).
|
||
|
||
**Names left the WireGuard module for their own.** They had been delivered inside it, on the
|
||
argument that a machine with peers and no names is half on the network. True, and the wrong place
|
||
to fix it — names are identical over a *different* private network, so bundling them made one
|
||
module out of two things. They require the mesh's **addressing** rather than a private network in
|
||
general, because that is what they are computed from: over a VPN that hands out its own addresses
|
||
the mesh has nothing to write, and refusing is what stops a machine being given a hosts file that
|
||
means nothing on it.
|
||
|
||
**And the claim is not decoration.** Choosing a different VPN still installed WireGuard — dragged
|
||
back in by the names, which needed addresses only WireGuard hands out — and nobody was told.
|
||
Running two VPNs is not always wrong; being *the* one the mesh runs over is singular. So it is a
|
||
claim, and the collision is refused by name.
|
||
|
||
**And the proxy's half, which was the other module reaching into the database.** A web application
|
||
requiring a reverse proxy has to say *which name, which port*, and there was nowhere to put it —
|
||
`requires` says a thing must exist and never said what to do with it. A module now contributes to
|
||
a requirement, the controller collects every contribution on a node, and the provider is given
|
||
them as a file at a path it named. It reloads when that file changes, by the same `restart-on` the
|
||
private network needed when a peer list changed under a running interface.
|
||
|
||
**The proxy's configuration is not written by the mesh.** It is given the facts and turns them
|
||
into whatever it runs, which is why swapping Traefik for something else touches nothing that
|
||
publishes through it. See ADR 0009 for the
|
||
other direction — handing a credential *back* — which is the larger half and is not built.
|
||
|
||
**What is still not a module, and why that is correct.** The node-engine needs none of this. It has an
|
||
address and a route before the mesh exists — that is the machine's own networking — and the
|
||
broker's address is carried in the token rather than resolved
|
||
(ADR 0004). **The one connection that
|
||
carries modules cannot itself be one.** Everything above it can be, and now is.
|
||
|
||
## The order it comes up in
|
||
|
||
The one thing to get right, because everything else depends on it:
|
||
|
||
```
|
||
0 the node has an underlay address the machine's own — DHCP, or a provider gave it one
|
||
1 the node dials the mesh OVER THE UNDERLAY, at the address in its token
|
||
2 it proves itself, and is proved to the link exists (ADR 0004, ADR 0004)
|
||
3 the mesh grants it an identity and an overlay address
|
||
4 the overlay comes up peer graph delivered as files
|
||
5 names resolve resolver config delivered as files
|
||
6 filtering is applied derived from what is assigned here
|
||
7 routes and certificates once this node has something to expose
|
||
```
|
||
|
||
**Step 1 runs on the underlay and never on the private network.** This is the circularity that must not
|
||
be created: the private network is configured by the mesh, so a link that required the private network could
|
||
never be established on a new node. The link stays on the underlay permanently — it is
|
||
outbound-only and carries its own identity, so it needs nothing the private network provides.
|
||
|
||
**Nothing before step 3 can resolve a mesh name**, which is why the token carries an *address*
|
||
(ADR 0004). Today this is
|
||
patched with an `/etc/hosts` floor written underneath the resolver; under this design there is
|
||
nothing to patch.
|
||
|
||
**Step 1 has a precondition this document treated as a fact to record rather than a requirement:
|
||
the broker's node must be dialable by every node, at a stable address, and so must the hub**
|
||
(ADR 0007). Across the internet that means publicly
|
||
reachable; on one network it does not. A mesh whose nodes are all behind NAT cannot be raised, and
|
||
a broker node whose address moves invalidates every token issued for it.
|
||
|
||
*2026-10-02.* **The order changes at step 1: the tunnel comes first, from the token**
|
||
(ADR 0169).
|
||
The circularity above is real, and it is broken differently. The private network is configured by the mesh,
|
||
except for the one peer a joining machine needs, and the token carries that peer. So the sequence
|
||
becomes:
|
||
|
||
```
|
||
0 the node has an underlay address the machine's own
|
||
1 the node makes its tunnel key before any token; it prints the public half
|
||
2 a token is issued for that key its address assigned, and the hub sent it as a peer
|
||
3 the tunnel comes up to the hub from the token alone: the hub's endpoint and key, its address
|
||
4 the node dials the bus OVER THE TUNNEL, at the bus's private address
|
||
5 it proves itself, and is proved to enrolment, checking the key is the one the token named
|
||
6 the rest of the overlay the whole peer set, delivered as files
|
||
7 names, filtering, routes as before
|
||
```
|
||
|
||
The link no longer stays on the underlay. The bus is reached over the tunnel by every machine,
|
||
including one that is joining, so it is never opened to the internet. The precondition becomes: **the
|
||
hub's tunnel must be dialable by every node, at a stable address.** That port answers nothing to a
|
||
key it does not know.
|
||
|
||
**Whether the link should later move onto the private network, with the underlay as fallback, is
|
||
open (ADR 0007).** It is a decision rather than a derivation: the
|
||
gain is which network carries bytes, not what an attacker can reach, since the link is already
|
||
encrypted against a pinned fingerprint.
|
||
|
||
## 1 — The private network
|
||
|
||
**What is decided:** the peer graph. For every node: its private network address, which peers it holds,
|
||
which of those it may dial, and which must dial it.
|
||
|
||
**Inputs, all declared:**
|
||
|
||
- **reachability** — an endpoint, or none
|
||
(ADR 0007). Not
|
||
inferred from the address shape, which is wrong for carrier-grade NAT, wrong for IPv6, and
|
||
wrong for a routable address behind a closed firewall.
|
||
- **site** — where the machine physically is, or nothing if it roams.
|
||
- **role** — hub or not, **declared**. Today it is inferred from an address prefix, which means a
|
||
renumbering is an outage and nothing can be asked which node is the hub.
|
||
|
||
**Keys.** Each node generates its own keypair. **The private key never leaves the machine**; the
|
||
public key is published to the mesh. This is already true and it is already right — it is
|
||
ADR 0004's *a node holds its own
|
||
identity* applied to the private network, and it means the controller computes a graph it cannot
|
||
itself impersonate.
|
||
|
||
**Shape: a hub, with direct peering between co-located nodes.**
|
||
|
||
| | |
|
||
|---|---|
|
||
| two nodes at the same site | peer **directly**, host-routed, with a keepalive |
|
||
| everything else | routes through the **hub** |
|
||
| a node with no site — it roams | **hub only** |
|
||
|
||
**Roaming is hub-only deliberately, and the reason is a property of WireGuard rather than a
|
||
preference: there is no failover.** A more specific route to a dead endpoint blackholes; it does
|
||
not fall back to the general one. So a node whose location changes gets exactly one path, because
|
||
two paths would mean one of them silently swallowing traffic.
|
||
|
||
**What the node-engine receives:** an interface configuration and a peer list, as files. It does not
|
||
compute them, and after this it holds no credential to the mesh's database.
|
||
|
||
### Four things the lab found, none of them visible from the mesh's own state
|
||
|
||
*Written 2026-08-29, on the first three machines to actually run this.*
|
||
|
||
Each looked like a working network from every angle the mesh can see: the graph was right, the
|
||
files were right, the services were up, and every node reported success.
|
||
|
||
- **A running interface does not re-read its configuration.** A node joins, every existing node's
|
||
peer list changes, each file is replaced — and the service is already running, so nothing
|
||
reloads it. Every existing node keeps a network that no longer exists. The declaration has to
|
||
say the service must *reflect* the file, which is declared state; a command to restart would be
|
||
an action, and the link may not carry one
|
||
(ADR 0005).
|
||
- **A hub that shares a site with a spoke was emitted twice** — once as a direct peer and once as
|
||
the route of last resort. WireGuard takes one entry per public key, so the interface refuses the
|
||
file. The ordinary shape of a small mesh, and in none of the tests written before it ran.
|
||
- **Two nodes at one site that neither can be dialled must not peer directly.** Nobody opens the
|
||
path, and the direct route is more specific than the hub's, so it wins and blackholes. This
|
||
document's own warning, arriving in its implementation: *a more specific route to a dead
|
||
endpoint blackholes; it does not fall back to the general one.*
|
||
- **The container runtime closes the door the private network needs.** Docker sets the FORWARD policy to
|
||
DROP, so a hub with `ip_forward` enabled still carries nothing between its spokes. The foundation
|
||
at tier 1 silently breaks the network at tier 2, and nothing in either tier's state says so. The
|
||
hub inserts its own rule above those chains and removes it on the way down.
|
||
|
||
**The pattern in all four:** the mesh's picture of the network was correct and the network did not
|
||
work. That is the argument for the lab in one line — none of these is reachable by reasoning, and
|
||
each was found within minutes of a real machine trying it.
|
||
|
||
## 2 — Resolution
|
||
|
||
**Two name spaces, and they do not mix:**
|
||
|
||
| | resolves to | certified by |
|
||
|---|---|---|
|
||
| **internal names** | private network addresses | the **mesh CA** |
|
||
| **public names** | whatever the outside world must reach | a **public authority** |
|
||
|
||
A node's mesh name is its private network address. Its public name, if it has one, is a separate fact
|
||
used by things outside the mesh — and the separation carries two lessons that were learned
|
||
expensively enough to be worth restating:
|
||
|
||
- **Mesh names are not multicast names.** A name resolved by local multicast discovery introduces
|
||
a delay and a failure mode that appears on one node and not others — the worst shape a fault
|
||
can have.
|
||
- **A node must not pin its own public name locally.** The duplicate record breaks resolution of
|
||
that name for everything else that needs it.
|
||
|
||
**What the node-engine receives:** what to ask, not what to answer. The mesh has **two resolvers**, each
|
||
holding every node's internal domain; a node lists both and nothing else
|
||
(ADR 0194,
|
||
ADR 0223).
|
||
|
||
**A machine with a VPN client that writes the resolver file itself runs a resolver of its own**
|
||
(ADR 0247,
|
||
[to-be 50](50-split-dns-on-a-machine-with-a-vpn-client.md)). Its file names that resolver, on the
|
||
machine's private address so its containers reach it, and the resolver asks the mesh's two resolvers for
|
||
every name except the VPN's own domains, which it sends to the VPN's servers over the VPN's link. One
|
||
server is listed, so there is still one answer per name. It is installed only where something requires
|
||
`split-dns`; everywhere else the file is as above.
|
||
|
||
**What goes away:** the `/etc/hosts` floor. It exists because a node had to reach the mesh
|
||
database before its own DNS existed; with ADR 0004
|
||
nothing needs a name before the link, and a fallback nothing needs is a path nothing tests.
|
||
|
||
### Names, and what a container can see
|
||
|
||
*2026-08-31, from a container that could not resolve a name every machine could.*
|
||
|
||
Internal names are `<node>.internal` — the suffix is the one IANA reserved in 2024, so a name that
|
||
leaks into a public resolver fails rather than reaching a stranger's machine. They are computed
|
||
centrally, because a name set needs every node at once, and written to each machine's hosts file.
|
||
|
||
**A file rather than a resolver**, and the reasoning holds: it works on every Linux, needs no
|
||
package, and has no failure mode of its own. The stated trigger for a daemon was *names that are
|
||
not one-per-node* — service names, wildcards.
|
||
|
||
**But a container does not inherit the machine's names.** It gets its own hosts file holding only
|
||
its own hostname. So every name the mesh wrote was invisible to the majority of things that need
|
||
one — and *on the machine it always worked*, which is exactly what made it easy to miss. It was
|
||
found by a database client on one node failing to resolve another node, on a mesh where both names
|
||
were correct and present on both machines.
|
||
|
||
**So the mesh gives its names to the containers it declares**, written into each container's own
|
||
hosts file by the runtime. That extends the file decision rather than overturning it. Given by the
|
||
mesh and not chosen by a module: a module that listed the machines would go stale the day one
|
||
joins, and a module that did not would be one whose containers cannot reach anything by name.
|
||
|
||
**Superseded for containers by ADR 0148
|
||
(2026-09-30).** Everything above still describes the machine's own roster file, which is how it works
|
||
and how it will keep working. It no longer describes containers.
|
||
|
||
Copying the roster into each container made the roster part of each container's identity, so one name
|
||
moving replaced every container in the mesh — a module assigned on one machine restarted the store, the
|
||
registry, the edge and mail on another
|
||
([issue 151](../../04-ISSUES/151-a-new-name-recreates-every-container-in-the-mesh/00-report.md)). And it
|
||
did not stop the staleness it was meant to: a copy taken at creation is stale the moment the roster
|
||
moves, twice found as a container holding an address that had not existed for days
|
||
([issues 109](../../04-ISSUES/109-a-container-keeps-the-address-it-was-made-with/00-report.md)
|
||
and [135](../../04-ISSUES/135-a-containers-mesh-names-are-not-compared/00-report.md)).
|
||
|
||
**A container resolves the mesh's names through its machine's resolver, at the moment it asks, and
|
||
nothing is copied.** The resolver is a machine-level process rather than a container, so nothing
|
||
circular is being asked for. It was gated on a container being able to reach the resolver from any of
|
||
the runtime's networks
|
||
([issue 110](../../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/00-report.md)),
|
||
and landed the day that did, 2026-09-30: the controller writes no mesh name into a container and the node-engine's digest carries only what the module declared for itself.
|
||
|
||
The paragraph below states the old boundary, and 0148 deliberately gives it up: a container somebody
|
||
started by hand resolves the same names as everything else, because the resolver answers the machine,
|
||
not a list of containers.
|
||
|
||
**The boundary, which is deliberate and worth stating:** *declared* containers. A container
|
||
somebody starts by hand is not the mesh's to configure, and reaching into every container on a
|
||
machine — declared or not — is what a nameserver in `resolv.conf` would be for.
|
||
|
||
### The mesh's resolvers
|
||
|
||
*2026-10-03, revised 2026-10-05.* **The mesh's names live in one module, held on two machines: the
|
||
holders of `mesh-dns-resolver`**, a mesh-scoped seat that is *replicated* — held on the anchor and on
|
||
the home server, each running the same module with the same machine list and the same zones, rendered
|
||
by the controller into each. Each holds one wildcard per node — `<node>.internal` and everything
|
||
under it — and one host record per node, listens on its private address and loopback only, answers
|
||
the mesh's names from what it holds and forwards every other name, giving the public answer
|
||
(ADR 0194,
|
||
ADR 0223).
|
||
Each holder is on record, added by `seat mesh-dns-resolver --add <node>/<module>`; an assignment of
|
||
the module not on record stands beside them, eligible and silent, and a seat held once stays held
|
||
once. *Checked by the controller's resolution tests with two holders on record, with two claimants
|
||
and nothing on record, and on a seat held once.*
|
||
|
||
**Every node asks them for everything, and nothing else.** `/etc/resolv.conf` lists every holder's
|
||
private address — the holder on the machine itself first if it is one, then the rest by name — with a
|
||
short timeout and two attempts, and **no public resolver**. ADR 0196 listed a public resolver second,
|
||
for the anchor being unreachable; a C library that asks every listed server at once and takes the
|
||
first reply — musl, so every Alpine container — took the public resolver's "no such name" for a mesh
|
||
name, and every build on the home server failed. With only the mesh's resolvers listed, whichever
|
||
answers first gives the one answer. The cost is stated: a machine that reaches no mesh resolver has no
|
||
names until it does, and with the anchor down the second resolver is reachable only on its own machine
|
||
and its own LAN. Containers take the same lines from their machine, the runtime copying non-loopback
|
||
resolvers into every container, so the runtime is given no `dns` of its own
|
||
(ADR 0196,
|
||
replacing ADR 0194's per-node `systemd-resolved` stub). *Checked by the controller's composition tests
|
||
on both holders and on a third machine, for every uplink module, and live by each machine's
|
||
`/etc/resolv.conf` and an Alpine container on the home server resolving the anchor's name every
|
||
time.*
|
||
|
||
**The file is the uplink's holder's** (ADR 0223).
|
||
A network manager rewrites `/etc/resolv.conf` on every connectivity change unless it is told not to
|
||
(ADR 0117), 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 managers' modules
|
||
(NetworkManager, systemd-networkd; dhcpcd's left the catalogue with
|
||
ADR 0226, 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,
|
||
their own options line — so neither can write the mesh's file byte for byte, and dhcpcd reads its
|
||
configuration only at its next start; each manager is told to keep off the file and the module
|
||
declares it. No other module may write that path, as a file or as a rendered fact: two modules on one
|
||
node declaring one path are refused. The module that wrote the file before, its seat
|
||
`node-resolver-config` and that seat's need of the uplink beside it
|
||
(ADR 0220)
|
||
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 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.*
|
||
|
||
**A machine's names are one seat's** (ADR 0223).
|
||
The `hosts` module is renamed `hostname` and holds `node-hostname`, the seat once named
|
||
`node-hosts-file`, whose former name resolves to it. It writes `/etc/hostname` from its `hostname`
|
||
setting — with no default: what a machine calls itself is the operator's, and the mesh's name for a
|
||
machine and its own need not agree — and the machine's `127.0.1.1` line, keeping the operator's lines.
|
||
A new name takes effect at the next boot; nothing sets it live, because a graphical session's X
|
||
authority is keyed by the name the session started under. *Checked by a resolution test that a
|
||
module claiming the old name and one claiming the new are one seat on one machine, and a composition
|
||
test that `/etc/hostname` is the setting, left out naming the key when nothing sets it.*
|
||
|
||
**No node holds a copy of its own.** The two holders hold the same rendering of one roster, never a
|
||
list anyone edits. The per-node resolver, its zones file and the mesh's region of `/etc/hosts`
|
||
go, and the per-node resolver's seat with them, deleted from the set once nothing claimed it
|
||
(ADR 0220): every resolution fault found on 2026-10-03 was a copy disagreeing with the truth — a hosts file
|
||
read once at start, an operator's old line beside the mesh's, a node's resolver lent to a LAN. No
|
||
member's resolver answers a LAN; a router pointing at one is moved first. *Checked by each node's
|
||
`/etc/resolv.conf` listing the resolver's holders and nothing else, by no node but a holder answering
|
||
DNS on any address, and by the router's DHCP DNS option naming the router.*
|
||
|
||
**Names that are neither a node nor a route.** A module that answers names declares a zone (a
|
||
setting) and the listen that answers it; the controller hands the `mesh-dns-resolver` holder every
|
||
zone with its module's node address and published port, and each holder forwards that zone there and
|
||
answers nothing in it itself — the lab answers `<machine>.incus` for its running scenarios this way.
|
||
An operator's own names, unrelated to the mesh, live in `/etc/hosts`'s kept region, held per node by
|
||
the `node-hostname` seat's holder and changed through its tools; the controller holds none of them
|
||
(ADR 0199).
|
||
*Checked by the holder's configuration carrying one forwarding rule per declared zone, and by a push
|
||
leaving the `hosts` file's operator region byte for byte.*
|
||
|
||
*What follows describes the per-node resolver this replaces — how it was built and why the roles were
|
||
split. The split stands; the serving role's scope is what moved.*
|
||
|
||
### The resolver, built
|
||
|
||
*2026-08-31.* **A service is reached at `<service>.<node>.internal`** — the first label is the
|
||
service, the rest is the node — so what resolves is *anything under a node's name*, going to that
|
||
node. What routes it once it arrives is a proxy's, and stays separate.
|
||
|
||
*2026-09-30.* **So the node in a route's internal name is the one whose proxy answers it**
|
||
(ADR 0151).
|
||
Composed from the node the module ran on, the name sent a client to a machine with nothing listening
|
||
whenever the proxy ran elsewhere
|
||
([issue 139](../../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md));
|
||
composed from the serving node, the rule above holds without exception. The public name stays the
|
||
module's node's, which is where the operator put it.
|
||
|
||
**The mesh writes the data and runs no daemon.** One wildcard per machine, from the same set that
|
||
writes the `hosts` file. A resolver is third-party software and runs *on* the mesh rather than being
|
||
*of* it: the mesh has no business shipping one, choosing which one, or knowing its configuration
|
||
language. Swapping dnsmasq for unbound changes that module and nothing in the controller.
|
||
|
||
**Two roles, two claims, because they are different things.** systemd-resolved cannot answer a
|
||
wildcard at all — it routes the mesh's suffix to something that can. Treating serving and asking
|
||
as one role produces a module that cannot work.
|
||
|
||
| | claims | |
|
||
|---|---|---|
|
||
| serving | `the-dns-port` | answers the wildcards |
|
||
| asking | `the-resolver-configuration` | decides what the machine asks |
|
||
|
||
So *which* resolver is not a mesh-wide decision. One machine can use what systemd already owns and
|
||
another can run dnsmasq, and two of either on one machine is refused rather than fought over.
|
||
|
||
**Two things a resolver must not do**, both found by a machine rather than by reasoning:
|
||
|
||
- **Take an address something else holds.** systemd-resolved holds `127.0.0.53` *and* `127.0.0.54`.
|
||
- **Read `resolv.conf` for its upstreams.** Whatever points a machine at the mesh writes the
|
||
resolver's own address there, so it becomes its own upstream and every query it cannot answer
|
||
loops until its receive queue fills. It needs no upstream: only the mesh's suffix is routed to
|
||
it.
|
||
|
||
*Checked on two machines, through the path an application takes — nsswitch, files, then DNS —
|
||
because the module deciding what the machine asks is half of what is being tested and only that
|
||
path goes through it.*
|
||
|
||
**That is now the second reason to want a resolver**, and it is a different one from the trigger
|
||
above:
|
||
|
||
| | |
|
||
|---|---|
|
||
| names that are not one-per-node | a service named under a machine — `postgres.novox.internal` |
|
||
| containers the mesh did not declare | anything a person or another tool starts on a node |
|
||
|
||
### Which resolver is not a question the mesh answers
|
||
|
||
*Written 2026-08-31, after treating it as open when it had been decided two days earlier.*
|
||
|
||
**A resolver takes over `/etc/resolv.conf`, which is a singular resource, so it is a claim** —
|
||
ADR 0009 lists it in the table beside the seat
|
||
and pid 1. Choosing between resolved, dnsmasq and unbound is **assigning a module**, per machine,
|
||
and two of them cannot both be assigned there:
|
||
|
||
> `resolved-config and dnsmasq both claim "/etc/resolv.conf", and only one thing may hold it per node`
|
||
|
||
So there is nothing global to settle and nothing for the mesh to guess. One machine can use what
|
||
systemd already owns and another can run dnsmasq, and neither has to know about the other.
|
||
|
||
**What the mesh contributes is the part only it can know**: which machines exist and where. That
|
||
is `mesh-resolver`, which writes one file and holds no claim, because writing a file takes nothing
|
||
over. A daemon module requires that data and claims the resolver — so swapping the daemon changes
|
||
that module and nothing else.
|
||
|
||
**This was recorded on 2026-08-29 and reopened as an unanswered question on the 31st.** Which is
|
||
the argument for the table in ADR 0009 being a table: the pattern is only obvious once seen, and
|
||
the cost of not seeing it is inventing a mechanism that already exists.
|
||
|
||
### The mesh resolves only its own names; a public name resolves publicly
|
||
|
||
**The mesh's resolver holds each node's internal domain and nothing else** — `<node>.internal` and
|
||
everything under it, so every route's internal name `<label>.<node>.internal` with no line of its own
|
||
(ADR 0151).
|
||
**A node's public domains — one or more — are never given a private answer**: it is forwarded and resolves to the
|
||
public address, from a member and from anything else the resolver answers — a resolver may serve a
|
||
machine's LAN, and a phone on that LAN must get the address it can reach
|
||
(ADR 0191). Inside the
|
||
mesh, a routed service is reached, and certified by the internal authority, under its internal name.
|
||
*Checked by the controller's tests — the roster names the machines and no routed name —
|
||
and on a machine by asking its resolver for a public name the mesh serves: the answer is the public
|
||
address.*
|
||
|
||
*What follows is how the mesh got here, kept because the reasoning it rejects is the expensive half to
|
||
rediscover.*
|
||
|
||
*2026-09-09, found by an internal certificate authority that could not issue.* The mesh writes every
|
||
`<node>.internal` name into every declared container and treats the public names a proxy serves as a
|
||
separate matter — *what routes it once it arrives is a proxy's, and stays separate*, above. That
|
||
holds for a client dialling by internal name. It does not hold for anything **inside** the mesh that
|
||
must reach a public name, and the first such thing to appear was the internal issuer of §5.
|
||
|
||
**An issuer validates by connecting to the name it is certifying.** Asked for a certificate for a
|
||
routed public name, the internal authority accepted the order, offered a challenge, and then could
|
||
not connect: nothing in the mesh resolved that name, so the challenge had no target. A name the mesh
|
||
can reach from the outside but cannot resolve from the inside is a name it cannot certify with an
|
||
authority of its own.
|
||
|
||
**So a granted route is published into internal resolution as well** — the routed name to the node
|
||
that serves it, mesh-wide, by the same mechanism that writes the node names — and as itself: a routed
|
||
name has no mesh form, and the suffixed alias the roster once added beside it resolved to a refusal
|
||
([issue 157](../../04-ISSUES/157-a-routed-names-internal-alias-is-served-by-nothing/00-report.md)). It is *given by the
|
||
mesh, not chosen by a module*, for the same reason the node names are: a module listing the routes
|
||
would go stale the day one changes. The mesh propagates the names it was told to serve and still
|
||
knows nothing about what they mean
|
||
(ADR 0066).
|
||
|
||
*2026-10-03, withdrawn.* Publishing public names with private answers turned every resolver that also
|
||
serves a LAN into an outage for that LAN's non-members — a phone was handed the control-node's tunnel
|
||
address for the mail server — while every check, run from a member, passed. Its reason had gone: routes
|
||
have internal names since ADR 0151, and the proxy certifies public names from a public authority and
|
||
internal names from the internal one. Superseded by the rule at the head of this section
|
||
(ADR 0191).
|
||
|
||
## 3 — Exposure
|
||
|
||
Settled by ADR 0007; summarised here because
|
||
this is where it belongs.
|
||
|
||
**A route is a grant.** A module that must be reachable declares it needs one; the proxy provides
|
||
it and hands back the public name. Ordinary
|
||
ADR 0009
|
||
vocabulary — the mirror of a database grant, where the consumer supplies a target and receives a
|
||
name rather than supplying nothing and receiving credentials.
|
||
|
||
**A workload on an unreachable node is proxied by a reachable one, across the private network.** Which is
|
||
the case is a mesh-level fact, which is the fourth reason exposure is controller work.
|
||
|
||
### What was built
|
||
|
||
*2026-08-31.* Nothing new in the vocabulary, which was the claim and is now the fact: a route is a
|
||
provision, a proxy provides it, and a module that must be reachable requires it. The consumer
|
||
contributes the name it wants and the port it listens on; the proxy receives every consumer that
|
||
asked; the consumer is told what the provider serves, which is how it knows its own name.
|
||
|
||
**One field was missing, and it is the one anything reaching back needs.** A contribution now
|
||
carries **where the mesh says that machine is**. A database is reached *by* its consumer, so the
|
||
mesh never had to tell a provider where anybody was; a proxy is the other direction — it is told
|
||
to send traffic to a consumer and has to open a connection. Without it every provider implementing
|
||
a provision would have to know how the mesh names machines, which is a convention leaking into
|
||
every module.
|
||
|
||
**Exposure and filtering are different questions and a module answers both.** A workload says what
|
||
it listens on and who may reach it; separately, it says it wants a route. A module that asked for a
|
||
route and not for the port is unreachable by the proxy it just asked for — which the lab
|
||
demonstrates, because the machine is already filtering by the time this runs.
|
||
|
||
**Withdrawal, which was open above.** The file the proxy is given is the whole truth about who has
|
||
a route, so a proxy replaces its table rather than merging. Merging would keep serving a name whose
|
||
module was unassigned — and *a stale public name pointing at nothing fails more visibly than a
|
||
stale grant* is the reason it must not survive, not a reason to tolerate it.
|
||
|
||
**A name a proxy does not serve is refused by saying which it does.** A route that was withdrawn
|
||
and a name that never existed are different things, and a bare 404 makes an operator go and read
|
||
the mesh to tell them apart.
|
||
|
||
*Checked in the lab by a request to the name reaching the workload across the private network and
|
||
returning the workload's own answer, then by unassigning the module and requiring the same request
|
||
to stop working.*
|
||
|
||
### The name is a label, not a domain
|
||
|
||
*2026-09-09, from running the whole mesh in the lab.* A route contribution carried its public name
|
||
in full — the forge as `git.example.tld`, spelt out in the module. Pointing the same catalogue at a
|
||
different domain — a lab standing in for production, or a second operator's mesh — meant rewriting
|
||
that name on every routed module. The mesh was holding a **map of names to services**, which is the
|
||
one thing it must not: a public name is two facts owned by two different places, and neither is the
|
||
module's manifest.
|
||
|
||
**A module contributes a label; the node contributes its public domain; the mesh composes.** The
|
||
operator chooses where the forge lives — `git`, or `code` — and that is the module's to say. The
|
||
domain is the node's, set once. The mesh joins them and grants `<label>.<public-domain>`,
|
||
interpreting neither half. Moving a mesh to another domain is one node setting, not an edit per
|
||
module.
|
||
|
||
**Today the name is still a literal, and that is the gap.** There is no interpolation of a node's
|
||
domain into a module's label, so the composition is done by a per-node override — which reproduces
|
||
exactly the per-module cost it is meant to remove. The design is the composition; the override is a
|
||
stopgap until the manifest layer can carry a label and a domain separately
|
||
(ADR 0066).
|
||
|
||
### A route also carries what a request arriving at it may do
|
||
|
||
*2026-09-25, from comparing the mesh's proxy against the ingress it would replace
|
||
([issue 116](../../04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md)),
|
||
decided in ADR 0108.*
|
||
|
||
A grant hands back a name. It did not say what the name admits, and the proxy admitted everything —
|
||
its request path was a host lookup and a forward. Measured against what the replaced ingress
|
||
actually relies on, four things were missing: **authentication**, **refusal scoped to a path**,
|
||
**path-scoped routing with priority**, and **redirect**. Three modules depend on the first, each to
|
||
gate an admin surface that has no login of its own; one dependent of the second is a live incident
|
||
mitigation.
|
||
|
||
**Policy belongs to the route, not beside it.** A contribution carries it along with the name, the
|
||
port and the location. The alternative — a proxy-side settings layer keyed by route name — keeps the
|
||
grant literally clean but makes *"what protects this route"* a question answered from two files that
|
||
nothing keeps in step. A route's protection is part of what a route is.
|
||
|
||
**The set is closed at those four.** A fifth is an amendment, so each addition is earned by a
|
||
dependent that exists rather than added because a middleware surface was open. An open surface would
|
||
recreate the thing being replaced, and is far harder to narrow later than a closed one is to widen.
|
||
|
||
**Where policy needs a credential, the declaration names a secret; it never carries one.** The mesh
|
||
already mints and holds credentials, and that machinery stays the only thing that does — so a hash
|
||
never reaches anything regenerated, synced or committed.
|
||
|
||
**This re-keys the table.** Two of the four need one host routed more than one way, so the proxy
|
||
matches on host **and path**, with priority, rather than mapping a host to a single target. Equal
|
||
priorities must resolve identically every time, or the proxy stops being reproducible.
|
||
|
||
The proxy remains a reference implementation: the contract is the file the mesh writes, not the
|
||
program that reads it, and another proxy may implement the same file.
|
||
|
||
## 4 — Filtering
|
||
|
||
**Derived from what is assigned here, and from the private network's shape** — a node's open ports are a
|
||
consequence of what runs on it and who must reach it, not an independent declaration to keep in
|
||
step by hand. That is true of a converged node; an adopted one keeps the firewall it was found
|
||
with until it converges (below).
|
||
|
||
**A rule names its source** (ADR 0007).
|
||
A rule with no source is open, and must say so rather than appear to restrict something. `scope:`
|
||
is removed rather than implemented: five manifests carry it today, it is referenced by no code,
|
||
and it is the clearest instance in the repository of *an unenforced rule is indistinguishable
|
||
from a wrong one, and costs more, because people believe it.*
|
||
|
||
**Unknown keys are refused** — the discipline the node-engine's declaration parser already has
|
||
(ADR 0005), and
|
||
the one manifests lack. `scope:` survived because nothing rejected it.
|
||
|
||
### What was built
|
||
|
||
*2026-08-31. Everything above was the intention; this is what exists, and how each part is
|
||
checked. [04-ISSUES/003](../../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md) is
|
||
resolved by it.*
|
||
|
||
**A module says what it listens on**, as a port, a protocol and a source — `mesh`, `anywhere`, or
|
||
`machine`. The source is required and there is no default, which is the whole of *a rule names its
|
||
source*: a manifest that omitted it would read as a restriction and be none. *Checked by a manifest
|
||
with a port and no source being refused, and by one naming a source the mesh cannot render being
|
||
refused as well — the second is what stops a source becoming a comment.*
|
||
|
||
**The set is derived per node**, from every module assigned to it, not from the module asking for
|
||
it. Where two modules want the same port, the wider source wins and both are still named, because
|
||
removing one of them must not read as a reason to close a port the other needs. *Checked by
|
||
rendering a node whose firewall module has no ports of its own and asserting another module's port
|
||
is in the result; and by giving one port two modules and one source each, and asserting the
|
||
narrower rule disappears while both names survive.*
|
||
|
||
**What is not declared is closed.** The rule set drops by default. *Checked by naming the input
|
||
chain in the assertion rather than the policy alone — the first version of that test passed while
|
||
input accepted everything, because another chain in the same file also said `policy drop`.*
|
||
|
||
**From the mesh means the machines the mesh has**, as their addresses on the private network, not
|
||
as a subnet. A subnet is a guess that stays wrong quietly; the address set shrinks when a node
|
||
leaves and nobody edits anything. A machine that asks for `mesh` where the mesh knows no addresses
|
||
is **closed and told so in the file** — widening it would open a port nobody asked to open, and
|
||
dropping it silently would close one somebody did.
|
||
|
||
**Three things it deliberately does not do**, each of which looked right and would have broken
|
||
something:
|
||
|
||
| | why not |
|
||
|---|---|
|
||
| decide what the machine **forwards** | the container runtime writes its own forwarding rules and a second policy is consulted alongside them, so a drop here stops every container on the node — the controller included. Nothing in a manifest says what a machine routes, so there is nothing to derive it from either |
|
||
| **flush the ruleset** when loading | that empties every table on the machine, the runtime's among them. Only the mesh's own table is replaced, and it is declared empty first so the replacement works on a machine loading one for the first time |
|
||
| carry a **command to load itself** | the link may not carry an action (ADR 0005). A service is declared to reflect the file instead, so replacing it restarts what loads it — the shape that rule leaves, used here for the first time for its real purpose |
|
||
|
||
**A module that wants a rule set brings the unit that loads it.** Found the hard way: the unit a
|
||
distribution packages for nftables runs, applies the rules and exits, so it is neither running nor
|
||
stopped — and a host asked for a service that is "running" reports, quite correctly, that it is
|
||
stopped. Every packet was filtered exactly as declared and the machine was marked as not doing what
|
||
it was told.
|
||
|
||
**The vocabulary has no word for "ran, did its job, and exited"**, and that is a real gap rather
|
||
than a wording problem: the whole class of configuration-applying units — packet filters, sysctl,
|
||
tmpfiles — is shaped that way. Until there is one, a module ships a unit that stays, which is also
|
||
the better shape: how a machine enforces rules is a fact about the machine, and the mesh has no
|
||
business depending on what a distribution happens to package.
|
||
|
||
**One rule is derived from the private network's shape rather than from what is assigned: a hub's own
|
||
listening port.** A hub accepts inbound connections from every node at other sites; a machine that
|
||
is not a hub dials out and needs nothing open, because a reply to a flow it started is already
|
||
accepted. The two want different rules on an *identical module*, so `listens` — a static field —
|
||
cannot say it. The machine a static answer gets wrong is the one facing the public internet, which
|
||
is the machine that most needs filtering.
|
||
|
||
*Recorded as a gap on 2026-08-31 and closed the same day.* **A computed module now contributes
|
||
listens the way it contributes resources.** The port comes from the endpoint, which is where the
|
||
interface takes its `ListenPort` from — one source, so a rule set cannot open a port the interface
|
||
is not on. It is open to *everywhere* deliberately: a node at another site is not on the private
|
||
network until this port lets it on, so restricting it to the mesh would be a rule that can never
|
||
be satisfied by the thing it exists for.
|
||
|
||
**A generator that cannot say what a machine opens is refused, not read as silence.** Closing a
|
||
port on the evidence of a failure to look is how a machine is severed by a fault somewhere else —
|
||
and the machine it would sever is the hub, whose only route to being repaired is the network it
|
||
just closed.
|
||
|
||
*Checked by filtering the hub and then requiring the mesh to keep working: a declaration still
|
||
reaches the other machine, and the other machine still reaches the hub. A rule file that looks
|
||
right and a mesh that has stopped are exactly what that guards against.*
|
||
|
||
**And it is enforced, which is what separates this from `scope:`.** Checked on two real machines:
|
||
two ports opened, one declared, and from the other machine the declared one answers and the
|
||
undeclared one does not — then the module is removed and the port closes with nobody editing a
|
||
rule. *A rule set that is written but never loaded passes every check that reads the file, which
|
||
is why the check reads packets.*
|
||
|
||
### While a predecessor still serves the same thing
|
||
|
||
*2026-09-22, ADR 0104.*
|
||
The mesh's proxy owns the two public ports and derives its routes from what modules contributed, so
|
||
it cannot stand beside a predecessor's proxy and cannot serve the predecessor's names. During a
|
||
migration a provision of this shape — one provider, a scarce machine-wide resource — may instead be
|
||
answered by an **adapter**: a module that provides `route` and writes each contribution into the
|
||
predecessor's own configuration, pointing at the machine port the contributing module publishes.
|
||
The predecessor's proxy goes on serving every name and renewing every certificate; the mesh's proxy
|
||
is taken last, when every route is one the mesh contributed. The adapter is refused on a converged
|
||
node, and removed when the predecessor's proxy retires. *How it is checked:* the adoption bed takes
|
||
a module behind the adapter and asserts the name answers and the predecessor's own names still do.
|
||
|
||
### On an adopted node
|
||
|
||
*2026-09-22, ADR 0100.* **The firewall found on the machine stays in force**, and the mesh
|
||
loads no table there that drops by default or holds an accept — neither genesis's base ruleset nor
|
||
the derived one. Every base chain at a hook runs and a drop in any is final, whatever the other
|
||
firewall is written in, so a second, stricter table would close every port the machine serves, and
|
||
an accept in one would open nothing the found firewall drops. What the mesh needs reachable it
|
||
declares as **openings**: a port, from where, on the incoming path or the forwarded path — a
|
||
published container port is forwarded, and a firewall that filters only incoming traffic never
|
||
sees it. The controller derives them from what the filter would be derived from, each from where
|
||
the filter would admit it: the assigned modules' `listens`, the hub's port, the bus and the registry
|
||
from anywhere; the store's port and the broker's management port from the private network. The node-engine converges each opening through
|
||
the found firewall in that firewall's own terms, marks it as the mesh's, removes only what it
|
||
marked, and re-checks every opening on each reconcile so a reload or a reboot does not lose it for
|
||
longer than one reconcile. An opening is state, not a command, so it travels over the link like any
|
||
other resource. **The mesh guards its own ports in a table of its own that only refuses** —
|
||
passing everything by default and holding nothing but refusals, and the found firewall's reload
|
||
does not touch it. Its ports are derived (ADR 0103): every machine port a
|
||
*taken* module publishes that the filter admits from the private network only, with the store's
|
||
port and the broker's management port. A port of a module not yet taken may still be the
|
||
predecessor's, so it is not guarded; and only packets addressed to this machine are matched, so
|
||
traffic it routes for others passes. An opening a found rule already answers is not added, so
|
||
removing it never removes the operator's rule. It refuses those ports except from
|
||
the private network and the machine itself — loopback and the container runtime's networks, known by the interface a packet arrives on, never by
|
||
its source address alone — at
|
||
the prerouting hook, before the container runtime redirects the packet, so it matches the port the
|
||
packet was sent to, for both address families; the
|
||
bus, the registry and the hub's port stay reachable from anywhere, as a node enrols and pulls
|
||
before it has a private-network address. One kind of found firewall is spoken; a machine with none
|
||
needs no openings, and a machine with another kind is refused adoption. Converging the node refuses
|
||
while a found container is still held; otherwise it previews what is reachable now — listening
|
||
sockets and published ports — what will close and which modules it will take, then loads the
|
||
derived filter in place of the refusal-only table and disables the found firewall without flushing
|
||
it. *How it is checked:* the adoption bed asserts the found firewall's rules differ only by the
|
||
mesh's marked rules, that the store is unreachable from off the private network before and after
|
||
the found firewall reloads and reachable from a container on the node, that a machine not yet enrolled reaches the bus, that a second machine
|
||
enrols through the openings before and after a reload and a reboot, and that after the flip the
|
||
declared port is open and the undeclared one closed.
|
||
|
||
### It filters what arrives from outside, and not what the machine's own guests send
|
||
|
||
*2026-09-28, preparing the control-node's convergence —
|
||
[issue 141](../../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md), settled by
|
||
ADR 0140, which replaces
|
||
ADR 0137 and
|
||
ADR 0139.*
|
||
|
||
Traffic passing through a machine is filtered, because a container's published port is traffic passing
|
||
through rather than traffic arriving at the machine itself. Having blocked it, the filter then had to
|
||
let the machine's own containers reach outward again — and it did that by listing the address ranges
|
||
they sit on. Two of those ranges were constants in this repository's code, and the rest were typed by an
|
||
operator after the flip had already cut a workstation's containers off from everything.
|
||
|
||
**The list was the mistake, not its contents.** A constant describes one machine. A typed range goes
|
||
stale and cannot tell a network the mesh made from one a predecessor left behind — on the control-node,
|
||
six ranges fall outside the constants and two of the six belong to services the mesh does not run. The
|
||
attempt to generate the list from the modules put half the rule set on the machine and made the
|
||
derivation advisory. Three records, one list.
|
||
|
||
**And the mesh has no position on a container reaching outward.** §4 exists to say which port is open
|
||
and to whom, which is about what arrives. A container of this machine's own opening a connection
|
||
somewhere is not a port opened to anybody, and the addresses it might do that from are the machine's
|
||
internal plumbing, which the mesh neither owns nor can know.
|
||
|
||
So the filter constrains what arrives from **outside** the machine and says nothing about what did not.
|
||
Traffic passing through is allowed unless it came in on one of the machine's outward links, and then
|
||
only where a declared endpoint's reach admits it (§6). The machine says which of its links face
|
||
outside — one fact it reports, like the kind of firewall it found and the tunnel it carries, not a
|
||
setting and not a list of addresses. It does not change when a module is added or removed, which is the
|
||
whole difference from what it replaces. A machine that has reported no outward link is sent no filter
|
||
at all, and keeps the one it has, because a rule written around a link with no name is a rule set that
|
||
does not load — a machine filtering nothing while its unit reports success.
|
||
|
||
Ports go on following the modules exactly as before: assign a module to a machine and the port its
|
||
assignment says it reaches on opens. Nothing about a network is said anywhere, by anybody.
|
||
|
||
*How it is checked:* a bed converges a machine carrying containers on several networks, none of them
|
||
named in any setting, and each reaches outward afterwards — which fails against the previous behaviour,
|
||
where the same flip cut them off, and is how it was written; a network made *after* the last declaration
|
||
needs no new filter; a declared port is reachable from off the private network and an undeclared one is
|
||
not; no address of a machine's own networks appears in a rendered filter, asserted on the text; and a
|
||
machine reporting no outward link is refused in the controller with its existing filter left alone.
|
||
|
||
### A converged machine is filtered by the mesh alone, and the node-engine says what else refuses
|
||
|
||
*2026-10-02, ADR 0168,
|
||
from [issues 143](../../04-ISSUES/143-converging-does-not-retire-the-firewall-it-found/00-report.md) and
|
||
[144](../../04-ISSUES/144-the-predecessors-rules-outlive-the-firewall-it-was-found-as/00-report.md).*
|
||
|
||
Retiring the found firewall was a step the flip took once, and said it had taken whatever happened;
|
||
on the first machine with one it did not take, and a hand's work fifty minutes later was recorded as
|
||
the mesh's. And "the firewall found" named one front end while a predecessor's chain in the container
|
||
runtime's user hook — legacy iptables on one machine, invisible to a reader of nftables — filtered the
|
||
forwarded path, refused ports the mesh declared open, and carried an allowance every module reaching
|
||
another by the machine's own name relied on.
|
||
|
||
**Convergence is a state the node-engine keeps.** Every converged apply reads whether the found firewall is in
|
||
force; enabled again, it is retired again and said; the record says whether the mesh disabled it or
|
||
found it inactive, and a skipped step is said. **The node-engine reports what filters the machine**, every
|
||
apply, adopted or converged: every table and legacy chain that refuses, with an owner — the mesh's,
|
||
the found firewall's, the runtime's own plumbing, a ban, or *other*, which is where the runtime's user
|
||
chain's refusals go. **The mesh says which:** `node show` lists them; `status` names a converged machine
|
||
anything *other* filters and is not well; the converge preview lists what filters the machine and the
|
||
fate of each — retired with the front end, left as the runtime's, left as a ban, or *left in force and
|
||
not the mesh's*. The mesh removes none of it; adoption's threshold does not move.
|
||
|
||
*How it is checked:* host tests over rulesets captured from three machines of this mesh classify every
|
||
refusing chain (a predecessor's chain in the legacy filter as *other*, a ban list reached through the
|
||
user chain as a ban, a leftover front-end chain as *other*); a fake front end enabled again on a
|
||
converged machine is retired again and said, found inactive is recorded as found; the report carries
|
||
the filters and the found firewall's state and a change in them is worth an unasked report; controller
|
||
tests over a fixture report check the recording, the preview's fates, the status JSON and the well
|
||
predicate. Live: the home server's record names the predecessor's chain as *other* and `status`
|
||
names the machine until the chain is removed by hand.
|
||
|
||
*2026-10-02, ADR 0170:* removing what
|
||
the node-engine reports as *other* is reached through the packet filter seat's `remove` verb, an operator's act
|
||
by name on the bus; the seat also serves `rules` and `reload`, and its holder's runtime declares the
|
||
`NET_ADMIN` capability on the machine's network. See design 33.
|
||
|
||
*2026-10-02, ADR 0180:*
|
||
once a machine is converged, the front end it was found with is uninstalled, not merely disabled — the
|
||
packet filter's holder declares its package absent after the mesh's filter is loaded, and a return to
|
||
adopted then enables nothing. The rollback path ADR 0100 kept on disk is given up on purpose.
|
||
|
||
## 5 — Certificates
|
||
|
||
**Two authorities, kept separate on purpose.**
|
||
|
||
| | issued by | for |
|
||
|---|---|---|
|
||
| **public names** | a public ACME authority | anything outside the mesh reaches |
|
||
| **internal names** | the **mesh CA** | node-to-node, over the private network |
|
||
|
||
**The split is not collapsed, including in the lab.** A single-CA lab would hide any bug living
|
||
in the split, so the lab runs its own ACME issuer on its public segment and keeps the mesh CA
|
||
unchanged (research 004).
|
||
|
||
**Public issuance requires genuine public reachability.** The HTTP-01 challenge must be answered
|
||
at the name being certified, so issuance happens through a publicly reachable node regardless of
|
||
where the workload runs — the same asymmetry as exposure, for the same reason.
|
||
|
||
**The issuer must be configurable.** Today it is not: the proxy sets no `caServer` and therefore
|
||
defaults to the public authority's *production* endpoint. Two consequences, and the second is
|
||
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.** 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),
|
||
so nothing needs the CA before membership. It certifies internal names afterwards, and that is
|
||
all it does.
|
||
|
||
**The internal authority makes its own root at first start, and a consumer fetches it**
|
||
(ADR 0098).
|
||
The mesh mints the authority's password and nothing else of its: a root certificate and its key
|
||
are things only the authority can make, and a served fact written in a manifest cannot carry what
|
||
does not exist until the authority has run. So the authority serves its root at a path beside its
|
||
ACME directory, and the proxy that requires it fetches that root over the mesh network in a
|
||
run-once step before it starts. The step names the binding it reads and the proxy names the
|
||
step, so when the authority moves the root is fetched again and the proxy is recreated with it
|
||
(ADR 0099).
|
||
*How it is checked:* the route-forwarding bed installs the authority, the proxy and a consumer
|
||
from the catalogue and asserts the routed name is served.
|
||
|
||
**And a machine trusts that authority because a module put its root in its trust store**
|
||
(ADR 0147). The proxy's fetch
|
||
answers for the proxy and for nothing else: a browser, `git` over HTTPS, a package manager and
|
||
every module calling another by an internal name read the machine's own trust store, and the mesh
|
||
had never written anything there. A module requiring the authority does the whole of it — fetch
|
||
the root over the mesh network, place it where this machine's TLS clients look, refresh the
|
||
extracted bundles — and stopping it, which is what being unassigned does, takes the anchor away
|
||
and refreshes them again. Not the controller's business, because being on the private network is
|
||
what makes the authority *reachable* and is not the same fact as having a reason to *verify* a
|
||
mesh name; and because where anchors live and which command refreshes them is one operating
|
||
system's difference, which is the node-engine's half of the mesh
|
||
(ADR 0005).
|
||
*How it is checked:* on a machine holding the module a plain client verifies an internal HTTPS
|
||
name with no bundle argument, and on one without it the same fetch fails to find an issuer — both
|
||
halves, because only the pair tells the anchor apart from something that already trusted it.
|
||
|
||
### What was built
|
||
|
||
*2026-08-31.*
|
||
|
||
**A node generates a fourth key**, and the reason is the one the other three already give: *a key
|
||
used for two purposes is one rotation away from breaking the other.* The identity key would work
|
||
for TLS and reusing it would mean rotating a node's identity every time its certificate is
|
||
replaced. The private half never leaves the machine; the mesh is told the public half at
|
||
enrolment.
|
||
|
||
**So there is no certificate request and nothing to seal.** The mesh signs a statement binding a
|
||
public key to a name it alone assigns, which is the whole of what a certificate authority does.
|
||
It issues rather than stores: the node's key does not change, so signing again produces an equally
|
||
valid certificate and there is nothing to keep in step.
|
||
|
||
**A machine with no name inside the mesh is refused**, not given a certificate for nothing. A
|
||
certificate for a name nothing resolves is a certificate nothing can check.
|
||
|
||
**And the key is stored in the format a server reads** — PKCS#8 PEM, not the node-engine's own encoding.
|
||
That is not an implementation detail of whoever writes the file: the file exists *because
|
||
something else reads it*, so the format is the interface
|
||
([04-ISSUES/014](../../04-ISSUES/014-a-key-that-is-present-and-unusable/00-report.md)).
|
||
|
||
*Checked by a real handshake between two machines: one serves on its internal name with the key it
|
||
generated, the other verifies against the mesh's authority and nothing else. Every cheaper check
|
||
passed while the server could not start — the key was present, the certificate was valid, and
|
||
nothing read either the way a server would.*
|
||
|
||
### An internal issuer, pointed at and trusted
|
||
|
||
*2026-09-09, from wiring one to the proxy in the lab.* §5 above asks for two things this build
|
||
leaned on: the issuer must be configurable, and the lab runs its own ACME authority rather than
|
||
collapsing the split. Wiring the proxy to that authority is the whole of it — the proxy is told
|
||
**which** issuer to use and given that issuer's **root** to trust, and every other step of issuance
|
||
is unchanged. **The same code path certifies against an internal authority as against a public one;
|
||
only the issuer differs.** That is what makes trusted certificates possible for a mesh whose names
|
||
the public internet cannot resolve.
|
||
|
||
**The internal authority certifies internal names; a public one certifies public names.** The
|
||
authority's challenge reaches the name it certifies, so each certifies what it can resolve: the
|
||
internal authority a route's `<label>.<node>.internal`, which the mesh resolves, and a public authority
|
||
the public name, which public DNS resolves. A proxy holds both, and a public name is never certified
|
||
by the internal authority. *Checked by a handshake to a route's internal name that verifies against
|
||
the internal root and nothing else, and one to its public name that verifies against the public
|
||
roots* (ADR 0191).
|
||
Until 2026-10-03 this paragraph had the internal authority certify public names, which needed them
|
||
resolved inside the mesh (ADR 0066).
|
||
|
||
## 6 — One statement behind exposure, filtering and certificates
|
||
|
||
*2026-09-28, preparing the control-node's convergence —
|
||
[issue 140](../../04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md), settled by
|
||
ADR 0138.*
|
||
|
||
The three sections above each decide, independently, how far a service reaches. §3 composes a name
|
||
from a label and the node's domain. §4 opens a port to the source a listen named. §5 certifies the
|
||
names that exist, from whichever authority the proxy holds. Each is coherent on its own, and together
|
||
they mean **reachability is never stated anywhere** — it is the sum of three derivations, and a sum is
|
||
not something anyone can review or refuse.
|
||
|
||
What that costs, measured: an identity provider holding a public certificate valid 90 days and an
|
||
internal one valid 24 hours, neither asked for by any assignment, because both names existed and a
|
||
proxy certifies what it serves. And an endpoint that is not routed — git over ssh — which can be
|
||
spoken about only in the filter's vocabulary, so *this must be reachable from outside* is a setting
|
||
exactly one mechanism reads.
|
||
|
||
**An endpoint is the thing that was missing.** A module declares named endpoints: one port it serves,
|
||
what it is for, and what it would serve that to absent any instruction. A route contribution names an
|
||
endpoint rather than repeating a port number. An assignment — which is where a module's configuration
|
||
lives (ADR 0046)
|
||
— then binds each endpoint to a machine port and says how far it reaches.
|
||
|
||
One value, three readers:
|
||
|
||
| reach | the filter opens | the proxy serves | the certificate comes from |
|
||
|---|---|---|---|
|
||
| `internal` | the machine port, to the private network | the internal name | the mesh's own authority |
|
||
| `public` | the machine port, to anywhere | the public name | the public authority |
|
||
| `both` | the machine port, to anywhere | both names | each name's own authority |
|
||
| `local-network` | the machine port, to the private network and the machine's local networks | the internal name | the mesh's own authority |
|
||
|
||
*2026-10-13.* **A port may be reached from the machine's own local network, and only its private
|
||
part** (ADR 0340). A home server serves devices on its own network that will never be nodes, and until
|
||
now the only value that reached them was `anywhere`, which says the internet. A new source,
|
||
`local-network`, and the reach of the same name mean the networks the machine is attached to through
|
||
its uplink, as the node-engine reports them among its facts. Only networks wholly in a private range
|
||
count (RFC 1918, and unique local addresses for IPv6); a public subnet the machine sits on is never
|
||
counted, so the source cannot open a port to the internet. The filter renders it on that port's rule
|
||
alone, as the subnets it resolved to. A machine that reports no private network has a `local-network`
|
||
port admitted from no local network, and the rule set and the plan say so. The plan shows what each
|
||
such port resolved to, and `node` shows which attached networks counted and why the others did not.
|
||
A laptop that travels admits whichever local network it is on, which the plan makes visible.
|
||
|
||
*2026-10-02.* **The proxy serves an internal name to the private network only**
|
||
(ADR 0138,
|
||
its insight of this date). It answers public and internal names on the same listeners, so the name a
|
||
request carries is the request's own claim, not where the request came from. An internal name is
|
||
served to the machines of the mesh and to the machine itself; to anyone else it is answered as a name
|
||
never routed, in the handshake as well as the request. Without this, an endpoint with reach `internal`
|
||
would be public under a name that is easy to guess
|
||
([issue 191](../../04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md)).
|
||
The proxy is told who the mesh is, and its routes, in its membership on the bus — the same machine
|
||
addresses the filter's "from the mesh" is rendered from
|
||
(ADR 0167).
|
||
|
||
**An endpoint that is not routed is reached and never named.** No route contribution means no name is
|
||
composed and no certificate requested, while the filter still acts on it. That is the case the model
|
||
could not express at all, and it is the ordinary case for anything that is not HTTP.
|
||
|
||
**Nothing moves until an assignment says so.** An endpoint whose assignment is silent keeps the
|
||
default its manifest states, so every machine already converged renders exactly as it does today —
|
||
the same property §4 needed when a machine gained a way to say which networks it routes.
|
||
|
||
This is what the certificate questions were waiting for. Which authority signs a name, whether a name
|
||
may appear in a public issuance log, and what must be trusted where are all answerable once an
|
||
endpoint says whether it is internal — and unanswerable while the proxy decides by composing every
|
||
name it can. It is also the fact
|
||
[issue 139](../../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md)
|
||
needs: an internal name should be composed from the machine serving the endpoint, which is the
|
||
assignment that bound it.
|
||
|
||
**How it is checked** is stated with the decision: one module with two endpoints of differing reach
|
||
asserted per chain body; the names and the certificate requests following the reach and failing
|
||
against today's behaviour, where both are always composed; an unrouted endpoint filtered and never
|
||
named; an assignment naming an endpoint the module does not declare refused where it is said; and a
|
||
silent assignment rendering byte-identically to today.
|
||
|
||
## The five through the network layers
|
||
|
||
*2026-10-09. A reading aid: it decides nothing and states no rule. Each claim names the section or the
|
||
record that decided it.*
|
||
|
||
The OSI model is the standard seven-layer picture of a network: every layer wraps what the layer above
|
||
hands it, and the receiving side unwraps in reverse. The five contexts of this document act at
|
||
different layers, and seeing which explains why they fail independently. A resolver that stopped
|
||
answering leaves the private network working, and a tunnel that cannot handshake leaves every name
|
||
resolving to an address nothing can reach.
|
||
|
||
| Layer | What it carries | Where the mesh acts there |
|
||
|---|---|---|
|
||
| 7 Application | the message itself | the bus (ADR 0106); resolution (§2); exposure, where the proxy chooses a route by the name a request asks for (§3) |
|
||
| 6 Presentation | how what is said is encoded and encrypted | certificates (§5): TLS, which is usually drawn between 4 and 7 and placed here by the book |
|
||
| 5 Session | a conversation kept open | a node's link to the bus, opened once and reopened after the machine sleeps |
|
||
| 4 Transport | ports, TCP and UDP | filtering (§4), which opens a port to a source; the private network itself travels as UDP |
|
||
| 3 Network | addresses and the path between them | the private network (§1): every node's address, the peer graph, the hub relaying |
|
||
| 2 Data link | frames on one network segment | the machine's own uplink (ADR 0117), on the underlay |
|
||
| 1 Physical | cable, fibre, radio | nothing of the mesh's |
|
||
|
||
### Two networks, one inside the other
|
||
|
||
**The private network is a layer 3 network carried at layer 4 of the underlay.** A packet for another
|
||
node's private address is encrypted whole and becomes the payload of a UDP datagram addressed to a
|
||
peer's underlay address. Every hop on the underlay reads only the outer envelope, up to its layer 3,
|
||
and never sees inside.
|
||
|
||
```
|
||
underlay │ L1 radio │ L2 frame │ L3 to the hub's public address │ L4 UDP │ ▓▓ encrypted ▓▓ │
|
||
│
|
||
decrypted at the hub │
|
||
┌────────────────────────────────────────────────┘
|
||
▼
|
||
private network │ L3 to a private address │ L4 TCP │ L5–L7 a message on the bus │
|
||
```
|
||
|
||
### The shape the layers are drawn on
|
||
|
||
The shape is §1's: a hub, direct peering between nodes at the same site, and a roaming node that goes
|
||
through the hub only. In a small mesh one node is both the anchor and the hub, and holds the bus.
|
||
|
||
```
|
||
┌──────────────────────────────────────────┐
|
||
│ hub and anchor — reachable from outside │
|
||
│ holds the bus │
|
||
└─────▲──────────────────▲─────────────▲───┘
|
||
│ │ │
|
||
│ each tunnel is dialled out by the node below it
|
||
│ │ │
|
||
┌─────┴─────┐ ┌──────┴──────┐ ┌────┴────────┐
|
||
│ laptop │ │ home-server │ │ home-server │
|
||
│ no site: │ └──────▲──────┘ └──────▲──────┘
|
||
│ hub only │ └──── direct ───┘
|
||
└───────────┘ same site, peered
|
||
```
|
||
|
||
**Every arrow points at the hub.** A node with no public address still has a tunnel, because it dials
|
||
out and the hub answers; only the hub's tunnel must be dialable, at a stable address
|
||
(ADR 0169).
|
||
|
||
### One tool call, from the laptop to a home-server
|
||
|
||
The laptop wraps the call one layer at a time:
|
||
|
||
| Layer | On the laptop |
|
||
|---|---|
|
||
| 7 | a message on the bus naming the tool and its arguments |
|
||
| 6 | the message encoded for the wire |
|
||
| 5 | the laptop's link to the bus, already open |
|
||
| 4 | TCP to the bus at its private address |
|
||
| 3 | a packet from the laptop's private address to the bus's |
|
||
| 3 inside 4 | the tunnel: that packet encrypted, in a UDP datagram to the hub's public address |
|
||
| 2 | a frame to the gateway of whatever network the laptop is on |
|
||
| 1 | radio |
|
||
|
||
**At the hub the outer envelope comes off** — frame, packet, datagram — and the tunnel decrypts what was
|
||
inside. The inner packet is addressed to the bus, which is on this node, so the hub keeps unwrapping up
|
||
to layer 7. The bus finds the home-server's subscription and sends the message down the home-server's
|
||
own link, which that node opened earlier through its own tunnel. Neither spoke ever accepted a
|
||
connection; both dialled the hub.
|
||
|
||
**How far the hub unwraps depends on what the packet is for.** A packet addressed to a home-server's
|
||
private address, not to the bus, is opened only to layer 3: the hub relays it, encrypted again for the
|
||
home-server's tunnel. That is the whole of the glossary's *a hub relays for nodes that cannot reach
|
||
each other directly*. Two home-servers at one site skip the hub: their tunnel crosses the home network
|
||
at layer 2, and nothing of theirs reaches the hub at all.
|
||
|
||
**Why a roaming node has one path is a layer 3 fact.** A more specific route to an endpoint that is
|
||
gone takes the packets and drops them; it does not fall back (§1). A laptop that moves between networks
|
||
therefore has exactly one route, the hub's.
|
||
|
||
### Reading a failure by layer
|
||
|
||
The machine's own network checks
|
||
(ADR 0241)
|
||
each look at one layer:
|
||
|
||
| Check | Layer | Failing alone means |
|
||
|---|---|---|
|
||
| **route** — a default route | 3, underlay | the machine has no way out; nothing above can work |
|
||
| **tunnel** — a handshake with the hub within five minutes | 3 carried at 4 | the underlay works and the private network does not: a key, the hub's address, or a filter on the way |
|
||
| **bus** — the link is open | 5 to 7 | the private network works and the bus, or the node's account on it, does not |
|
||
| **resolv-conf** and **names** | 7 | addresses work and names do not, as when a VPN client rewrites the machine's resolver file |
|
||
|
||
Read them from the bottom. A layer that fails explains every failure above it, and never one below it.
|
||
|
||
## What this removes
|
||
|
||
The list is worth having in one place, because it is most of the argument:
|
||
|
||
- **The last two direct database connections from nodes** — `wireguard` and `traefik`, the only
|
||
two, both connectivity.
|
||
- **Therefore the database credential on every node**, and the object-store credential beside it.
|
||
ADR 0004's central claim becomes
|
||
true rather than aspirational.
|
||
- **The `/etc/hosts` floor**, and the bootstrap circularity it patched.
|
||
- **Hub election by address prefix**, and the silent no-hub failure when nobody knew the
|
||
convention.
|
||
- **The RFC1918 inference**, and the lab substitution that existed to satisfy it.
|
||
- **`scope:`**, and the class of manifest key that means nothing.
|
||
|
||
## Open
|
||
|
||
- **One resolver (ADR 0194).** *Correction of fact,
|
||
2026-10-05:* no node holds `node-dns-resolver` any more, and
|
||
ADR 0220 deletes the seat. What stood here before: *"Not built: every node still runs
|
||
`node-dns-resolver`. The migration's four steps are in the record, in order."* Nor did it
|
||
stay true that *"the workstation moves to the one resolver only once"* zones and the `hosts` file's
|
||
holder (ADR 0199)
|
||
exist: every node, the workstation included, asks the one resolver, and every node holds
|
||
`node-hosts-file`.
|
||
|
||
- ~~**What happens when the hub is down.**~~ **Resolved** by
|
||
ADR 0006, together with `06`'s
|
||
matching question — they were one question. Nothing takes over. WireGuard has no failover, the
|
||
hub is declared rather than elected, and non-co-located paths stop while co-located direct peers
|
||
and every already-assigned workload keep running. The recovery path is restore, and its deadline
|
||
is certificate renewal.
|
||
- **Renumbering the private network.** Made *possible* by declaring the hub rather than inferring it from
|
||
an address, but no procedure exists, and a graph delivered node by node has an ordering problem
|
||
while it is half-applied.
|
||
- ~~**Revoking a route** when a module is unassigned.~~ **Resolved** 2026-08-31 — see §3. The file
|
||
a proxy is given is the whole truth about who has a route, so a route does not outlive the module
|
||
that asked for it.
|
||
- **IPv6.** ADR 0007 makes
|
||
it expressible; nothing here says the private network or the resolver handle it.
|
||
- **Reporting declared-versus-observed.** ADR 0007 makes the disagreement detectable and does not
|
||
say who looks or what they are told.
|
||
- **Composing a route name from a label and a node's domain.**
|
||
ADR 0066 makes the public name the
|
||
operator's to move between meshes, but the manifest layer still stores it as a literal — so today
|
||
the composition is a per-node override rather than the design. The interpolation that would let a
|
||
module carry a label and a node carry the domain, and the mesh join them, does not yet exist.
|
||
|
||
## The hub adopts the predecessor's tunnel
|
||
|
||
*2026-09-23, ADR 0105.*
|
||
|
||
On an adopted node that is the hub, the private network is not raised beside the tunnel it finds;
|
||
it **takes it over**: the found interface's private key, its port, its address and range, and every
|
||
peer it had, carried as peers not yet enrolled. The found interface is stopped, its configuration
|
||
kept on disk. A predecessor machine sees the same server key at the same endpoint and cannot tell
|
||
the tunnel changed hands; when it enrols, it keeps the address the tunnel already held for its key.
|
||
|
||
The mesh's own addresses are the adopted range's — every binding, hosts entry and endpoint the
|
||
controller composes follows it, as readers of a setting. ADR 0100's non-overlap rule applies only
|
||
where a found tunnel is left running beside the mesh's; where it is adopted there is one tunnel.
|
||
The guard admits the mesh's ports from that one interface, and the predecessor's peers arrive on
|
||
it.
|
||
|
||
*2026-09-27, ADR 0119.* The
|
||
found configuration is kept only until the take is proven — the found unit down, the mesh's
|
||
interface up and handshaking with a peer. Then it is removed from where the found unit reads it
|
||
(its original stays kept), the hold ends, and the predecessor's tunnel cannot be raised again by
|
||
anything but a person restoring it by hand. Undeclaring the private network does not bring it back.
|
||
|
||
## The bus is NATS
|
||
|
||
*2026-09-23, ADR 0106. Architecture to be written
|
||
before code; this section names the shape only.*
|
||
|
||
Every link the mesh has rides NATS subjects; durability is JetStream's; a module's account is a
|
||
NATS account with permissions derived from `emits`/`consumes`, declared as configuration the node-engine
|
||
writes and the server reloads. The sdk's contract is unchanged. The adopted AMQP broker stays as
|
||
the predecessor's compatibility broker until its last client is gone. Built in the lab beside the
|
||
migration; cut over in one switch-over after the core; the person's client is designed on it.
|