Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7cf5c0c1b | ||
|
|
c6f86ae935 | ||
|
|
1aeb4fe8d8 | ||
|
|
08108b569d | ||
|
|
14ff89fa40 | ||
|
|
2126e7b2cb | ||
|
|
dcdfcf104e | ||
|
|
d23ace1646 | ||
|
|
5dbde0b13a | ||
|
|
db3868e2b0 | ||
|
|
05039c4f10 | ||
|
|
b7bf601ca4 |
@@ -164,6 +164,12 @@ def check_rests_on(failures, records):
|
||||
# decision is exactly what as-is is for."
|
||||
if rel(path).startswith("03-DESIGN/00-as-is/"):
|
||||
continue
|
||||
# A withdrawn record's citations are history. It instructs nobody -- every reader
|
||||
# is sent to its superseder -- so what it was built on may itself be withdrawn.
|
||||
# Refusing that would mean rewriting the lineage of a record whose reasoning is
|
||||
# the thing the immutability rule protects.
|
||||
if frontmatter(read(path)).get("status") == "superseded":
|
||||
continue
|
||||
# An extension that supersedes legitimately names what it replaced.
|
||||
this = ADR_FILE.match(os.path.basename(path))
|
||||
supersedes = records[number]["front"].get("superseded-by", "")
|
||||
@@ -241,13 +247,20 @@ def check_supersession_symmetry(failures, records):
|
||||
failures.add("supersession", rel(record["path"]), f"superseder does not exist: {by}")
|
||||
continue
|
||||
other = records[match.group(1)]
|
||||
claims = os.path.basename(str(other["front"].get("supersedes", "")))
|
||||
if claims != record["name"]:
|
||||
# `supersedes:` may name one record or several. One decision replacing two is a real
|
||||
# situation -- two records that built and refined the same wrong mechanism are withdrawn
|
||||
# by the one record that removes it -- and a check that allows only one would force
|
||||
# either a chain of pro-forma records or an unmarked supersession.
|
||||
claimed = other["front"].get("supersedes", "")
|
||||
if isinstance(claimed, str):
|
||||
claimed = [claimed] if claimed else []
|
||||
claims = [os.path.basename(str(entry)) for entry in claimed]
|
||||
if record["name"] not in claims:
|
||||
failures.add(
|
||||
"supersession",
|
||||
rel(other["path"]),
|
||||
f"ADR {number} says this supersedes it; this record does not say so "
|
||||
f"(supersedes: {claims or 'absent'})",
|
||||
f"(supersedes: {', '.join(claims) or 'absent'})",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: superseded
|
||||
date: 2026-09-28
|
||||
deciders: jochen
|
||||
extends: 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
|
||||
reconstructed: false
|
||||
superseded-by: 02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md
|
||||
---
|
||||
|
||||
# 137. A machine says which networks it routes
|
||||
|
||||
## Context
|
||||
|
||||
The filter the mesh derives denies forwarding by default, because without a forward chain it says
|
||||
nothing about a container's published port
|
||||
([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md), [issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md)).
|
||||
To keep a machine's own containers working it then allows two ranges: the container runtime's
|
||||
default bridge pool, and the pool its compose files are given. Those two are named in the
|
||||
controller's code, with a comment saying what the gap is:
|
||||
|
||||
> A machine whose runtime is configured with something else needs this to say so — which is a thing
|
||||
> the mesh cannot derive and a reason this list is named here rather than computed.
|
||||
|
||||
**There was no way to say so.** The list was a constant. A machine whose guests live anywhere else
|
||||
was filtered by a rule that looked deliberate and was a guess.
|
||||
|
||||
**Measured, on the day a workstation was converged.** Flipping it cut egress for five of its
|
||||
container networks at once, and for every network its test beds create — the beds allocate a fresh
|
||||
range per run, from a pool neither default covers. Nothing reported a fault. The containers could
|
||||
not reach anything, the machine went on reporting that it had applied what it was told, and the
|
||||
converge preview had said nothing about it either, because the preview lists what *listens* and
|
||||
routing is not a listener.
|
||||
|
||||
**And two questions, not one.** A guest also asks its host for an address and for names. Both arrive
|
||||
at the input chain, where nothing declared them, so denying by default left the guests of a routed
|
||||
network with no address and no resolution — which is not a closed port but a network that does not
|
||||
function, asked for by this machine's own guest.
|
||||
|
||||
**Why the machine cannot simply be read.** A test bed creates its bridge while it runs, between one
|
||||
declaration and the next, so a filter derived from what the machine last reported would be correct
|
||||
only for the networks that already existed when it was composed. A declared range covers the ones
|
||||
that do not exist yet.
|
||||
|
||||
## Decision
|
||||
|
||||
**A machine says which networks it routes for what it hosts, and the filter forwards them.** A
|
||||
node-level fact, beside the node's public domain
|
||||
([ADR 0066](0066-public-routing-is-name-agnostic.md)) and for the same reason: the
|
||||
machine routes them, and the module that loads the filter holds a seat and may be replaced.
|
||||
|
||||
**Added to the runtime's defaults, never replacing them.** A machine that names one range has not
|
||||
stopped hosting whatever was already on the runtime's own pools, and replacing would trade one
|
||||
silent breakage for another.
|
||||
|
||||
**Their guests keep address and name service.** For a network that was named, the input chain admits
|
||||
that network's own DHCP and DNS, and nothing else: everything else a guest might want from its host
|
||||
is a port somebody declares, like every other port on this machine.
|
||||
|
||||
**Said in CIDR form and checked when it is said.** An entry that does not parse is a line nftables
|
||||
refuses, and a refused ruleset is a machine filtering nothing while its unit reports a fault — so
|
||||
the refusal happens where a person can read it, not on the machine.
|
||||
|
||||
**A machine that says nothing is filtered exactly as before.** Every machine already converged is
|
||||
untouched by this.
|
||||
|
||||
## Options considered
|
||||
|
||||
1. **Leave it constant and edit the code per installation.** Rejected: the value is a property of
|
||||
one machine, the code is the whole mesh's, and the two ranges as they stand describe a machine
|
||||
whose runtime was left at its defaults. It is also how this got here.
|
||||
2. **Derive it from what the machine reports.** Rejected as insufficient, not as wrong: it cannot
|
||||
cover a network created between two declarations, which is precisely the case that was broken. It
|
||||
would also make the filter follow whatever appeared on the machine, which is a firewall that
|
||||
widens itself.
|
||||
3. **A per-node setting on the module that loads the filter.** Rejected: the machine routes the
|
||||
networks. The filter module holds a node-scoped seat and is meant to be replaceable, and a
|
||||
replacement must not lose the machine's own truth.
|
||||
4. **Replace the defaults with what is said.** Rejected: see the decision. The first machine to name
|
||||
its bed range would lose its containers.
|
||||
5. **Admit all input from a routed network, not only address and name service.** Rejected: that is
|
||||
every port on the machine open to anything it hosts, which is the derivation abandoned.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The converge preview says what a machine routes**, including when it routes nothing but the
|
||||
defaults, with the command that changes it. The preview's own sentence about traffic it cannot
|
||||
preview stays, because a tunnel and the found firewall's NAT are still not previewable.
|
||||
|
||||
**A machine whose guests are already broken by an earlier flip is fixed by saying its networks and
|
||||
pushing**, with no flip to undo.
|
||||
|
||||
**The list is one more thing that can be wrong and stale.** A range removed from the machine and
|
||||
left here keeps forwarding for a network that no longer exists, which admits nothing, because there
|
||||
is no guest on it to admit. That is the safe direction of being out of date.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- **What a machine says it routes is forwarded, and its guests keep address and name service.** A
|
||||
test renders a ruleset for a machine that names one range and asserts both chains, per chain body
|
||||
so a line in the wrong chain cannot pass it. It fails against the previous behaviour, which is how
|
||||
it was written.
|
||||
- **The runtime's own defaults survive naming a range.** Asserted in the same test.
|
||||
- **A machine that names nothing renders byte-identically to one that names nil**, so every machine
|
||||
already behind this filter is untouched.
|
||||
- **Each family is matched in its own syntax.** A test with one v4 and one v6 network asserts
|
||||
`ip saddr` and `ip6 saddr`, because one set holding both is a syntax error and a ruleset that does
|
||||
not load is a machine filtering nothing.
|
||||
- **An entry that is not a network is refused where it is said**, by the parse in the setter.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) — the derived filter this completes
|
||||
- [ADR 0066](0066-public-routing-is-name-agnostic.md) — the precedent for a node-level fact
|
||||
- [issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md) — why there is a forward chain at all
|
||||
- [issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md) — the measurement that produced this
|
||||
- mesh-controller `internal/catalogue/filtering.go` — the constant whose own comment named this gap
|
||||
@@ -0,0 +1,154 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md
|
||||
---
|
||||
|
||||
# 138. An assignment binds an endpoint and says how far it reaches
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) settled that a machine's
|
||||
packet filter is derived from what its modules declare they listen on, and that the `from` of a
|
||||
listen "is the whole of public-versus-internal". That was true of the packet filter, and it turned
|
||||
out to be true of nothing else.
|
||||
|
||||
Reachability is now settled three times, in three places, by three mechanisms that cannot disagree
|
||||
out loud ([issue 140](../04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md)):
|
||||
|
||||
- **The filter** reads a listen's source, and a per-node setting may override it. That setting has
|
||||
exactly one caller in the control plane — the function that builds the node's rules.
|
||||
- **The names** come from a route contribution, which names a label and a port and says nothing
|
||||
about reach. The reverse proxy composes a **public** name and an **internal** name for every route
|
||||
it is given, because it can.
|
||||
- **The certificate authority** follows from which names exist. Measured on the control-node: an
|
||||
identity provider carries a public certificate valid 90 days and an internal one valid 24 hours and
|
||||
renewed daily. No assignment asked for either.
|
||||
|
||||
So *this endpoint must not be public* cannot be written. It is therefore enforced by nothing, while a
|
||||
public certificate for that very name is obtained automatically — the fault
|
||||
[how-we-build.md](../00-META/how-we-build.md) names, an unenforced rule being indistinguishable from
|
||||
a wrong one, with the additional cost that the wrong thing is done eagerly.
|
||||
|
||||
And a port that is not routed cannot be spoken about at all beyond the filter. The forge serves git
|
||||
over ssh; that endpoint has no name, no certificate and no way to be called public except a key only
|
||||
the filter reads.
|
||||
|
||||
**Two per-node settings already exist and are half of this.** One gives a module's declared port a
|
||||
machine port. One overrides a declared port's source. They key on port numbers, so nothing ties a
|
||||
port to the route that serves it: a route contribution names a port too, and the two are equal only
|
||||
by coincidence.
|
||||
|
||||
**Where this belongs is already decided.** [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md)
|
||||
says a module's configuration is its assignments. Whether the forge answers git-over-ssh from the
|
||||
public internet is a fact about one installation and one machine, not a property of the software —
|
||||
and [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) already refuses an
|
||||
installation's decisions in a definition.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Leave reach in the manifest, as `from` today.** Rejected: it is an installation's decision
|
||||
written into the definition, and it cannot differ between two machines running the same module —
|
||||
which is exactly the case the forge presents.
|
||||
2. **Extend the existing source override to the names and the certificate, without naming
|
||||
endpoints.** Rejected: it keys on a port number. A module's route contribution names a port as
|
||||
well, and nothing says the two are the same thing, so one statement cannot be made to reach all
|
||||
three mechanisms. Naming the endpoint is what makes that possible.
|
||||
3. **Derive reach from whether the node has a public domain recorded.** Rejected: that is a property
|
||||
of the machine, and two endpoints on one machine differ — a database and a web front end on the
|
||||
same host.
|
||||
4. **A fourth reach for "public name, internal authority"** — a name that resolves publicly and must
|
||||
not appear in a public issuance log, obtained by DNS-01. Deferred, not rejected: it is a real case
|
||||
and it is a question about which challenge an authority uses, not about how far an endpoint
|
||||
reaches. Left to the certificate work as an open question.
|
||||
5. **Make the manifest silent on reach and require every assignment to state it.** Rejected for the
|
||||
transition: every endpoint reachable today would close until an assignment named it, which is a
|
||||
flag day across the whole catalogue.
|
||||
|
||||
## Decision
|
||||
|
||||
**A module declares named endpoints.** An endpoint is one port the module serves, with a name the
|
||||
module chooses, its protocol, and what it is for. A route contribution **names the endpoint it
|
||||
routes** rather than repeating a port number. The manifest says what the module serves and what it
|
||||
would serve it to by default; it does not say what this installation does with it.
|
||||
|
||||
**An assignment binds each endpoint and says how far it reaches.** Per node: the machine port the
|
||||
endpoint is published on, and its **reach** — one of `internal`, `public` or `both`. An assignment
|
||||
that states nothing keeps the manifest's default, so no machine changes until an assignment says so.
|
||||
|
||||
**Reach means all three mechanisms at once, and is the only thing that decides them.**
|
||||
|
||||
- `internal` — the filter opens the machine port to the private network; the proxy serves the
|
||||
internal name and not the public one; the certificate comes from the mesh's own authority.
|
||||
- `public` — the filter opens it to anywhere; the proxy serves the public name; the certificate
|
||||
comes from the public authority.
|
||||
- `both` — both names, each from its own authority, and the filter opens to anywhere.
|
||||
|
||||
**An endpoint that is not routed is reached but never named.** An endpoint with no route contribution
|
||||
yields filter rules and nothing else: no name is composed and no certificate is requested. Git over
|
||||
ssh is that case, and it is the case the model could not express.
|
||||
|
||||
**The authority stops being chosen by which names happen to exist.** The proxy composes the names the
|
||||
assignments asked for, and asks each name's own authority for it. A name nobody asked for is not
|
||||
composed, so it is not certified.
|
||||
|
||||
**The two existing settings are this, completed.** The per-node port mapping becomes the endpoint's
|
||||
binding. The per-node source override becomes its reach, widened from the filter alone to the names
|
||||
and the certificate as well.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A manifest gains endpoint names, and a route contribution names an endpoint instead of a port.**
|
||||
Every routed module's manifest changes. The word ships one release before any manifest uses it, and
|
||||
reaches the build machine and the control plane first.
|
||||
- **One derived value is read by three things** — the filter's rules, the proxy's contributions, the
|
||||
certificate request — so they can no longer disagree, and a disagreement becomes a refusal at the
|
||||
assignment rather than a surprise on a machine.
|
||||
- **A name that must not be public becomes writable, and therefore checkable.** It also gives
|
||||
[issue 129](../04-ISSUES/129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md) a
|
||||
declared answer to read: which endpoints are internal is what says whose root must be installed
|
||||
where.
|
||||
- **[Issue 139](../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md)
|
||||
becomes answerable**: the endpoint's assignment names the machine that serves it, which is the fact
|
||||
the internal name should be composed from.
|
||||
- **Reach becomes reportable.** The mesh can say, per endpoint, where it is reachable from and which
|
||||
authority holds its certificate — neither of which `status` can say today.
|
||||
- **This narrows [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md).** Its
|
||||
decision stands: the firewall is derived and host-applied, not a provider. What no longer holds is
|
||||
that a listen's `from` is the whole of public-versus-internal; it is the filter's share of a
|
||||
statement that also governs names and certificates.
|
||||
- **What got harder:** every endpoint needs a name, including a module that serves exactly one port
|
||||
and had no reason to name it. And an installation that wants a module public must now say so on the
|
||||
assignment rather than inheriting it from the definition, which is more to say and the reason it is
|
||||
right.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **One module, two endpoints, different reach.** A module declaring an internal endpoint and a
|
||||
public one renders a filter opening one to the private network and one to anywhere, asserted per
|
||||
chain body so a rule in the wrong chain cannot pass.
|
||||
- **The names follow the reach.** The same module's routed endpoint composes the internal name only
|
||||
when internal, the public name only when public, and both when both — and a certificate is
|
||||
requested from the matching authority for each name composed and for no other. This fails against
|
||||
the previous behaviour, where both names and both certificates are always composed, which is how
|
||||
it is written.
|
||||
- **An unrouted endpoint is filtered and never named.** Asserted for an endpoint with reach and no
|
||||
route contribution: rules rendered, no contribution, no certificate request.
|
||||
- **An assignment naming an endpoint the module does not declare is refused where it is said**, as is
|
||||
a reach that is not one of the three — before it reaches a machine, because a ruleset that does not
|
||||
load is a machine filtering nothing.
|
||||
- **An assignment that states nothing renders byte-identically to today**, so every machine already
|
||||
converged is untouched until its assignment says otherwise.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — narrowed here
|
||||
- [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md) — where reach belongs
|
||||
- [ADR 0066](0066-public-routing-is-name-agnostic.md) — the public name this composes
|
||||
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — why reach is not a definition's
|
||||
- [issue 140](../04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md) — the measurement
|
||||
- [issue 139](../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md),
|
||||
[issue 129](../04-ISSUES/129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md)
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: superseded
|
||||
date: 2026-09-28
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md
|
||||
superseded-by: 02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md
|
||||
---
|
||||
|
||||
# 139. A network is forwarded because a module declared it
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0137](0137-a-machine-says-which-networks-it-routes.md), decided the same week, gave a machine a
|
||||
way to say which networks it routes for its guests. It was written because the derived filter's
|
||||
forward chain allowed two ranges named as constants in the control plane's source — the container
|
||||
runtime's bridge pool, and part of the pool its compose files are given — with a comment admitting
|
||||
the gap: *a machine whose runtime is configured with something else needs this to say so, which is a
|
||||
thing the mesh cannot derive.*
|
||||
|
||||
**It can be derived, and from the right place.** Measured on the last machine still to be converged
|
||||
([issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)): twenty-one
|
||||
container networks, nine inside the runtime's bridge pool, twelve in the other private range, and six
|
||||
of those outside the constant's lower bound — so the flip would have cut their guests off exactly as
|
||||
it did on the workstation that produced 0137.
|
||||
|
||||
Naming a range to cover the six is what 0137 provides for, and it is the wrong instrument. Of those
|
||||
six networks, **four are networks the mesh's own modules declare**, present as network resources in
|
||||
the node's plan and created by the host because a module asked for them. **Two are the predecessor's
|
||||
leftovers** — compose networks of services the mesh does not run. Any range wide enough to keep the
|
||||
four forwards the two as well: a firewall widened by hand to protect networks that should not exist.
|
||||
|
||||
The mesh already knows which of the twenty-one are its own, because it made them.
|
||||
|
||||
**And the node's configuration is meant to follow the modules assigned to it.** That is the mesh's
|
||||
founding shape — the machine runs modules, and its files, its filter and its accounts are composed
|
||||
from what runs there ([ADR 0005](0005-the-node-host.md),
|
||||
[ADR 0010](0010-delivery.md),
|
||||
[ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md)). The forward chain is the
|
||||
one derived thing that consults a constant and a list a person types.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep 0137 as it stands** — two constants plus a named list. Rejected: the list is written in
|
||||
addresses, and addresses are what the runtime allocates, so the only entry safe enough to keep a
|
||||
machine working is wider than the truth. It cannot distinguish a network the mesh made from one
|
||||
left behind, which is the distinction that decides whether forwarding it is correct.
|
||||
2. **Derive it from what the machine reports.** Still rejected, on 0137's own grounds: a test bed
|
||||
creates its bridge between one declaration and the next, and a filter that follows whatever
|
||||
appeared on a machine is a firewall that widens itself. **This decision is not that** — see below.
|
||||
3. **Have the control plane allocate each module network's range from a pool it owns,** so it can
|
||||
render the address itself. Rejected: more machinery for no gain. The runtime already allocates and
|
||||
the host already knows, and taking allocation over means the mesh owning an address space it has no
|
||||
other reason to own.
|
||||
4. **Have each module declare its network's range.** Rejected by
|
||||
[ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md): a definition names no address,
|
||||
and the same definition runs on machines whose runtimes have allocated differently.
|
||||
|
||||
## Decision
|
||||
|
||||
**A network is forwarded because a module declared it.** Per node, the forward chain forwards the
|
||||
networks of the modules assigned there, and by default nothing else. A module unassigned stops being
|
||||
forwarded at the next reconcile.
|
||||
|
||||
**The host resolves a declared network to its addresses.** A network resource carries a name; the
|
||||
runtime allocates the subnet when the network is created. So the control plane declares *forward the
|
||||
networks these modules asked for* and the host — which made them, and already resolves a container by
|
||||
its name — renders the addresses. [ADR 0005](0005-the-node-host.md) holds: the host applies, it does
|
||||
not decide.
|
||||
|
||||
**Deriving from the declaration is not deriving from the machine.** Both of 0137's objections fall
|
||||
away. The set is known before the network exists, because a module declared it, so a network created
|
||||
between two declarations is already in the one that asked for it. And it cannot widen itself: a
|
||||
network nobody declared is never forwarded, however it appeared on the machine.
|
||||
|
||||
**The runtime's own default bridge is forwarded, from what the runtime reports.** Containers that name
|
||||
no module network attach to it, and it belongs to the runtime rather than to any module — so the host
|
||||
renders it from what the runtime says, not from a range named in the control plane. The constants go.
|
||||
|
||||
**What a machine says is for guests no module declares.** A test bed is not a module and its range is
|
||||
not a module's; that is the case 0137's mechanism is for, and it keeps it — added to the derived set,
|
||||
never replacing it, as 0137 decided. Narrowed to that, it is named for it.
|
||||
|
||||
**Their guests keep address and name service**, per declared network, unchanged from
|
||||
[ADR 0137](0137-a-machine-says-which-networks-it-routes.md): the input chain admits that network's own
|
||||
DHCP and DNS and nothing else.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The two constants are removed**, and with them the class of fault that a machine's guests depend
|
||||
on a range that describes some other machine.
|
||||
- **This is a behaviour change, not a refactor.** On the machine measured, the derived set and the
|
||||
constant do not cover the same ground — that is the whole reason for the record. A machine whose
|
||||
module networks happen to fall inside the old ranges renders the same rules.
|
||||
- **A range that exists only to keep a leftover alive becomes visible as such**, because it will not
|
||||
be in the derived set and has to be said out loud to survive.
|
||||
- **`node networks` narrows** to guests no module declares, and the preview says which of a machine's
|
||||
networks are the mesh's and which are not, so the difference is readable before a flip rather than
|
||||
after.
|
||||
- **A module's declaration gains nothing.** It already declares its network; what changes is that the
|
||||
filter reads it.
|
||||
- **What got harder:** the host renders part of the forward chain from what it created, so the
|
||||
control plane no longer holds the whole rule set as text. The rule the mesh states is the set of
|
||||
networks; the addresses are the machine's.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **Only declared networks are forwarded.** A node with two modules that declare networks renders
|
||||
forward rules for exactly those two, and none for a third network present on the machine that no
|
||||
module declared. This fails against the previous behaviour, which forwards by range and cannot tell
|
||||
them apart, and that is how it is written.
|
||||
- **Unassigning a module removes its network's rule** at the next reconcile, asserted on the rendered
|
||||
chain rather than on the intent.
|
||||
- **Guests of a declared network keep address and name service**, asserted per chain body so a line in
|
||||
the wrong chain cannot pass — carried from 0137.
|
||||
- **The runtime's own default bridge comes from the runtime**, asserted by rendering for a runtime
|
||||
whose default bridge is somewhere other than the range the constant named.
|
||||
- **A machine that names a range for guests no module declares still gets it**, added to the derived
|
||||
set and not replacing it.
|
||||
- **Each family is matched in its own syntax**, carried from 0137: one set holding both is a syntax
|
||||
error, and a ruleset that does not load is a machine filtering nothing while its unit reports a
|
||||
fault.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0137](0137-a-machine-says-which-networks-it-routes.md) — narrowed here; its mechanism keeps the
|
||||
case it is right for
|
||||
- [ADR 0005](0005-the-node-host.md) — the host applies; the addresses are the machine's
|
||||
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — the filter is derived from
|
||||
what runs there
|
||||
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — why a module does not name its
|
||||
range
|
||||
- [issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md) — the
|
||||
measurement
|
||||
- [issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md) — the
|
||||
breakage that produced 0137
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md
|
||||
supersedes:
|
||||
- 02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md
|
||||
- 02-DECISIONS/0139-a-network-is-forwarded-because-a-module-declared-it.md
|
||||
---
|
||||
|
||||
# 140. The filter constrains what arrives from outside, and says nothing about a machine's own guests
|
||||
|
||||
## Context
|
||||
|
||||
The filter the mesh derives blocks traffic passing *through* a machine unless something allows it,
|
||||
because a container's published port is traffic passing through rather than traffic arriving at the
|
||||
machine itself ([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md),
|
||||
[issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md)).
|
||||
Having blocked all of it, the filter then had to let the machine's own containers reach outward again.
|
||||
It does that by listing the address ranges those containers sit on.
|
||||
|
||||
As rendered on a converged workstation today:
|
||||
|
||||
```
|
||||
policy drop
|
||||
ct state established,related accept
|
||||
ip saddr 172.16.0.0/12 accept
|
||||
ip saddr 192.168.128.0/17 accept
|
||||
ip saddr 10.0.0.0/8 accept
|
||||
ip saddr 192.168.16.0/20 accept
|
||||
... four more
|
||||
```
|
||||
|
||||
Two of those ranges were constants in the control plane's source. The rest were typed by the operator
|
||||
after [ADR 0137](0137-a-machine-says-which-networks-it-routes.md), which existed to make the typing
|
||||
possible, because converging that workstation had cut every one of its containers off from the
|
||||
internet and nothing reported a fault
|
||||
([issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md)).
|
||||
|
||||
**The list is the mistake, not its contents.** Every attempt to make it correct fails the same way.
|
||||
A constant describes one machine. A typed range goes stale, and cannot tell a network the mesh made
|
||||
from one a predecessor left behind — measured on the control-node, where six such ranges fall outside
|
||||
the constants and two of the six belong to services the mesh does not run
|
||||
([issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)).
|
||||
[ADR 0139](0139-a-network-is-forwarded-because-a-module-declared-it.md) tried to generate the same
|
||||
list from the modules and put half the rule set on the machine to do it. Three records, one list, and
|
||||
the list should not exist.
|
||||
|
||||
**Because the mesh has no policy about a container reaching outward.** What the filter is for is
|
||||
stated in [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md): which port is open,
|
||||
and to whom. That is about what arrives. A container of this machine's own opening a connection to
|
||||
something else is not a port being opened to anybody, and enumerating the addresses it might do so
|
||||
from is bookkeeping about the machine's internal plumbing, which the mesh neither owns nor can know.
|
||||
|
||||
**The system being replaced never had this fault, and its rule says why.** The chain still protecting
|
||||
the control-node applies only to traffic arriving on that machine's outward link, and leaves
|
||||
everything else alone. The mesh's filter dropped that distinction and replaced it with a list of
|
||||
addresses.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep the list and generate it better** — from the modules' declared networks, or from what the
|
||||
machine reports. Rejected: [ADR 0139](0139-a-network-is-forwarded-because-a-module-declared-it.md)
|
||||
is that, and it puts part of the rule set on the machine, which makes the rule set partly the
|
||||
machine's and the derivation advisory.
|
||||
2. **Name the guest links instead of their addresses, and allow only those.** Rejected as more than is
|
||||
needed: it fails in the safe direction, but it is still a list that has to keep up with the
|
||||
machine, and the thing it protects against — a container reaching outward — is not a thing the mesh
|
||||
has a position on.
|
||||
3. **Do not block traffic passing through at all.** Rejected: that is
|
||||
[issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md),
|
||||
where a published port was reachable from anywhere because no rule mentioned it.
|
||||
4. **Constrain what arrives from outside, and nothing else.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**The filter constrains traffic arriving from outside the machine, and says nothing about traffic that
|
||||
did not.** Traffic passing through the machine is allowed unless it arrived on one of the machine's
|
||||
outward links, in which case it is allowed only where a declared endpoint's reach admits it
|
||||
([ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md)). A container of this
|
||||
machine's own reaching anywhere is not filtered, because the mesh has no position on it.
|
||||
|
||||
**A machine says which of its links face outside.** One node-level fact, reported by the machine the
|
||||
way it already reports the kind of firewall it found and the tunnel it carries — not a setting, not a
|
||||
list of addresses, and not something anybody types. It does not change when a module is added or
|
||||
removed, which is what separates it from the list it replaces.
|
||||
|
||||
**A machine that has reported no outward link is sent no filter.** Rendering a rule around a link
|
||||
whose name is not known produces a rule set that does not load, which is a machine filtering nothing
|
||||
while its unit reports success. The refusal happens in the control plane, where a person reads it, and
|
||||
the machine keeps the filter it already has.
|
||||
|
||||
**No addresses of the machine's own networks appear in the filter.** The two constants are removed and
|
||||
`node networks` is removed with them, along with everything any machine was told to say through it.
|
||||
Ports continue to follow the modules exactly as before: a module assigned to a machine opens the port
|
||||
its assignment says it reaches on, and nothing about a network is said anywhere.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Three records collapse into one rule.** 0137 and 0139 are superseded. What 0137 was right about —
|
||||
that converging a machine had silently cut off its own containers, and that nothing previewed it — is
|
||||
answered by removing the cause rather than by giving the operator a way to compensate for it.
|
||||
- **Every machine already converged loses its declared ranges and keeps working**, because the traffic
|
||||
those ranges allowed is now allowed by not having arrived from outside. The workstation's five ranges
|
||||
and the laptop's one are deleted rather than migrated.
|
||||
- **A machine's test beds stop being a special case.** A bed's network is created while the machine
|
||||
runs and was the case no list could cover; it is now covered by not being mentioned.
|
||||
- **A new fact travels in the report**, and the control plane refuses to compose a filter without it,
|
||||
so the order of the roll-out matters: the machines report before the control plane depends on it.
|
||||
- **A machine with more than one outward link says so**, and a machine that acquires one while the mesh
|
||||
is not looking is treated as internal until its next report. That window is the cost of this shape;
|
||||
it is bounded by the report interval, and it exists on machines whose outward link changes, which
|
||||
are the machines with nothing published to the outside.
|
||||
- **What got harder:** nothing in the declaration, and one more thing a machine must be able to work
|
||||
out about itself. A machine that cannot say which link faces outside cannot be given a filter.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **A machine's own container reaches outward with no network named anywhere.** A bed converges a
|
||||
machine carrying containers on several networks, none of them mentioned in any setting, and each
|
||||
reaches out afterwards. This fails against the previous behaviour, where the same flip cut them off,
|
||||
and that is how it is written.
|
||||
- **A port declared reachable from outside is reachable; one that is not, is not.** Probed from off the
|
||||
machine's private network, for a published port and for an undeclared one, before and after the flip.
|
||||
- **A network created after the filter was composed needs no new filter.** A network is made on the
|
||||
machine after its last declaration and a container on it reaches out, with nothing re-sent.
|
||||
- **No address of a machine's own networks appears in a rendered filter**, asserted on the text so a
|
||||
range cannot creep back in.
|
||||
- **A machine that reports no outward link is sent no filter, and the refusal names it** — asserted in
|
||||
the control plane, and that the machine's existing filter is left alone.
|
||||
- **A machine reporting two outward links has both constrained**, asserted per chain body so a rule
|
||||
covering one and not the other cannot pass.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — what the filter is for
|
||||
- [ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md) — what admits traffic
|
||||
arriving from outside
|
||||
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) — why traffic passing through is
|
||||
filtered at all
|
||||
- [ADR 0137](0137-a-machine-says-which-networks-it-routes.md),
|
||||
[ADR 0139](0139-a-network-is-forwarded-because-a-module-declared-it.md) — superseded here
|
||||
- [issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md),
|
||||
[issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)
|
||||
@@ -217,6 +217,10 @@ python3 00-META/checks/index.py fail if stale
|
||||
- **0133** — [A module owns its migrations, and the mesh owns when they run](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) *(superseded)*
|
||||
- **0135** — [A module version prepares its state before it runs](0135-a-module-version-prepares-its-state-before-it-runs.md)
|
||||
- **0136** — [A step gates its module, not the machine](0136-a-step-gates-its-module-not-the-machine.md)
|
||||
- **0137** — [A machine says which networks it routes](0137-a-machine-says-which-networks-it-routes.md) *(superseded)*
|
||||
- **0138** — [An assignment binds an endpoint and says how far it reaches](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md)
|
||||
- **0139** — [A network is forwarded because a module declared it](0139-a-network-is-forwarded-because-a-module-declared-it.md) *(superseded)*
|
||||
- **0140** — [The filter constrains what arrives from outside, and says nothing about a machine's own guests](0140-the-filter-constrains-what-arrives-from-outside.md)
|
||||
|
||||
### How it is built
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ code:
|
||||
- mesh-controller internal/identity/authority.go
|
||||
- mesh-host internal/identity/serving.go
|
||||
- mesh-host internal/apply (the service that reflects a rule set)
|
||||
updated: 2026-09-27
|
||||
updated: 2026-09-28
|
||||
decisions:
|
||||
- 02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md
|
||||
- 02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md
|
||||
- 02-DECISIONS/0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.md
|
||||
- 02-DECISIONS/0106-the-bus-is-nats.md
|
||||
- 02-DECISIONS/0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md
|
||||
@@ -625,6 +627,50 @@ the found firewall reloads and reachable from a container on the node, that a ma
|
||||
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](../../02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md), which replaces
|
||||
[ADR 0137](../../02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md) and
|
||||
[ADR 0139](../../02-DECISIONS/0139-a-network-is-forwarded-because-a-module-declared-it.md).*
|
||||
|
||||
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 control plane with its existing filter left alone.
|
||||
|
||||
## 5 — Certificates
|
||||
|
||||
**Two authorities, kept separate on purpose.**
|
||||
@@ -710,6 +756,60 @@ that verifies against the internal root and nothing else — which cannot succee
|
||||
first reached the name to certify it*
|
||||
([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)).
|
||||
|
||||
## 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](../../02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md).*
|
||||
|
||||
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](../../02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md))
|
||||
— 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 |
|
||||
|
||||
**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.
|
||||
|
||||
## What this removes
|
||||
|
||||
The list is worth having in one place, because it is most of the argument:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-09-28
|
||||
located-in: [mesh-controller internal/catalogue]
|
||||
fixed-by: mesh-controller — a machine says which networks it routes and the derived filter forwards them, their guests keeping address and name service ([ADR 0137](../../02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md)).
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 137 — Converging a machine cut off its own guests, and nothing said so
|
||||
|
||||
## What was observed
|
||||
|
||||
A workstation was flipped from adopted to converged, so the mesh's derived filter replaced what was
|
||||
there. The flip reported success, the machine reported that it had applied its declaration, and every
|
||||
surface of the mesh read green.
|
||||
|
||||
A container on one of that machine's networks could no longer reach anything:
|
||||
|
||||
```
|
||||
192.168.64.2/20
|
||||
OUTBOUND BLOCKED
|
||||
```
|
||||
|
||||
Five of the machine's container networks were affected, and every network its test beds create. The
|
||||
reason is in the filter's forward chain, which denies by default and then allows two ranges:
|
||||
|
||||
```
|
||||
ip saddr 172.16.0.0/12 accept # the container runtime's bridge networks
|
||||
ip saddr 192.168.128.0/17 accept # the networks its compose files are given
|
||||
```
|
||||
|
||||
Those two are constants in the controller. The machine's guests were allocated from neither: its
|
||||
compose networks from other parts of `192.168/16`, and each test bed a fresh `10.x/24`. So the rules
|
||||
were correct for a machine whose runtime was left at its defaults, and a guess on this one.
|
||||
|
||||
Two further things were closed by the same flip, and for the same reason nobody saw them: a guest asks
|
||||
its host for an address over DHCP and for names over DNS, both of which arrive at the input chain,
|
||||
where no module had declared them.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
**The preview could not have warned.** It lists what the machine reported as *listening*, and says so
|
||||
honestly: it ends with a line that traffic the machine routes is "not previewed". What it did not say
|
||||
is that routing was about to be denied by default, or which ranges would survive. An operator reading
|
||||
a 350-line preview approves what it shows.
|
||||
|
||||
**It is the second time today that a constant stood in for something the mesh cannot know.** The
|
||||
intrusion-prevention module named a firewall front-end two machines do not have
|
||||
([issue 136](../136-a-module-may-name-a-program-the-machine-does-not-have/00-report.md)), and the
|
||||
filter names the address ranges one runtime happens to use. Both were true where they were written and
|
||||
silently false elsewhere.
|
||||
|
||||
**And the code already knew.** The comment above those two lines says a machine configured otherwise
|
||||
"needs this to say so — which is a thing the mesh cannot derive and a reason this list is named here
|
||||
rather than computed". The gap was documented at the point where it was introduced, and the way to say
|
||||
it was never built. A comment naming a missing mechanism is a rule that is not enforced.
|
||||
|
||||
## What was done
|
||||
|
||||
A machine says which networks it routes; the filter forwards them and admits their guests' address and
|
||||
name service. Added to the runtime's defaults rather than replacing them, so a machine that names one
|
||||
range keeps the others. Node-level, because the machine routes them and the module that loads the
|
||||
filter may be replaced. The converge preview now says what a machine routes, and what it will keep
|
||||
forwarding if it says nothing.
|
||||
|
||||
## What is still true
|
||||
|
||||
**The flip is still the moment a machine's unmanaged services close.** That is what converging means
|
||||
and the preview names each one. This issue is not about the ports that were meant to close; it is about
|
||||
the ones nothing could name.
|
||||
|
||||
**Egress is still not previewed per network.** The preview says which ranges will be forwarded, not
|
||||
which of the machine's guests sit inside them. Deriving that would need the machine to report its
|
||||
bridges, and a bed's bridge does not exist until the bed runs.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-09-28
|
||||
located-in: [mesh-controller internal/catalogue, mesh-catalog]
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 138 — Two modules claim one seat and are not interchangeable, and nothing says so
|
||||
|
||||
## What was observed
|
||||
|
||||
Three modules claim the node-scoped uplink seat: one for each network manager a machine here might
|
||||
run. [ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) gives each of them the same
|
||||
job — ask the manager the machine already runs to leave the resolver file alone and to leave the mesh's
|
||||
interface alone — and deliberately keeps the machine's own links out of the mesh's hands.
|
||||
|
||||
A seat means one holder and an interchangeable holder. These are interchangeable in what they *ask*
|
||||
and not in what they *do*:
|
||||
|
||||
- None installs, enables, starts or stops the manager. That is on purpose: stopping it takes every
|
||||
link down, including the mesh's own way in.
|
||||
- None carries an address, a route or a wireless credential, for the same reason.
|
||||
- **Nothing checks that the module holding the seat names the manager the machine is actually
|
||||
running.** Assigning the systemd-networkd holder to a machine running NetworkManager writes a file
|
||||
for a daemon that is inactive and disabled, the seat reports held, and the two things the seat
|
||||
exists to arrange are arranged for nobody. NetworkManager goes back to rewriting the resolver file
|
||||
on every lease, which is the failure the module's own comment describes.
|
||||
|
||||
The machine reports which service manager and which units are active, so the fact needed to catch this
|
||||
is already in the report the mesh holds.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
**A seat is the mesh's promise that a role is filled.** If the holder can be a module for software
|
||||
that is not running, the seat says a role is filled while nothing fills it — and the surface that
|
||||
would tell an operator says "held".
|
||||
|
||||
**It is the same shape as two faults found the same day.** A module named a firewall front-end the
|
||||
machine does not have ([issue 136](../136-a-module-may-name-a-program-the-machine-does-not-have/00-report.md)),
|
||||
and the filter named address ranges one runtime happens to use
|
||||
([issue 137](../137-converging-a-machine-cut-off-its-own-guests/00-report.md)). Each is a claim about
|
||||
the machine that nothing on the machine checks.
|
||||
|
||||
**And it decides whether the seat is worth having.** Either the holder must match what the machine
|
||||
runs, which is a condition the mesh can check from the report it already has, or the holders must be
|
||||
able to switch the manager, which ADR 0117 refuses for a reason that has not changed.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should a seat's conditions of holding include a capability the machine reports, so a holder naming
|
||||
absent or inactive software is refused rather than recorded?
|
||||
- Is "the uplink" one seat at all, if its holders are three dialects of the same two requests? The
|
||||
alternative is one module that speaks whichever dialect the machine needs, chosen from the report.
|
||||
- What should happen on a machine that switches manager afterwards? The seat would then be held by the
|
||||
wrong module, and the machine is the only place that knows.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
status: open
|
||||
opened: 2026-09-28
|
||||
located-in: [mesh-controller internal/catalogue]
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 139 — An internal route name resolves to the consumer's node, not the one that serves it
|
||||
|
||||
## What was observed
|
||||
|
||||
A module that requires a route is given two names: a public one composed under the serving node's
|
||||
domain, and an internal one composed under the consumer's own machine — `<label>.<node>.internal`.
|
||||
|
||||
The two are published differently:
|
||||
|
||||
- The **public** name is written into every machine's hosts file at the address of the node whose
|
||||
proxy answers it. The mesh computes that deliberately, so any container resolving a routed name
|
||||
reaches the proxy.
|
||||
- The **internal** name is resolved by the machine's own resolver, which answers every name under
|
||||
`<node>.internal` with that node's address — the consumer's, because the name was composed from it.
|
||||
|
||||
Where the proxy runs beside the consumer these are the same machine, which is every case on this mesh
|
||||
today, and both names work. Measured on 2026-09-28: the internal name of a service on the control node
|
||||
answers with a certificate from the mesh's internal authority, and the public name with one from the
|
||||
public authority.
|
||||
|
||||
Where the proxy is on another machine they disagree. The internal name sends the client to a machine
|
||||
that runs no proxy and has nothing listening on the port, while the public name sends it to the one
|
||||
that does.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
**It is latent exactly where the mesh is heading.** `route` is provided mesh-wide precisely so a
|
||||
module can be routed by a proxy on another machine. The first module assigned that way gets an
|
||||
internal name that does not work, and the public one that does — with no error anywhere, because both
|
||||
names resolve.
|
||||
|
||||
**A per-machine name is what an operator will reach for.** `<service>.<machine>.internal` reads like a
|
||||
promise that the service on that machine is reachable there, and the wildcard makes every such name
|
||||
resolve whether or not anything answers.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should the internal name be composed under the serving node, like the public one, or should it stay
|
||||
the consumer's and be published at the serving node's address like the public name is?
|
||||
- Is a per-node route holder the real answer — a proxy on every machine that serves its own names —
|
||||
and if so, is `route` still one mesh-wide provision or a node-scoped seat with a mesh-wide fallback?
|
||||
- What certifies the name in either case? The certificate is obtained by whoever terminates TLS, and
|
||||
that is the question above in another form.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
status: located
|
||||
opened: 2026-09-28
|
||||
located-in:
|
||||
- mesh-controller internal/catalogue/manifest.go
|
||||
- mesh-controller internal/catalogue/filtering.go
|
||||
- mesh-controller internal/catalogue/declaration.go
|
||||
- mesh-controller examples/route-proxy
|
||||
- mesh-catalog (every routed module manifest)
|
||||
fixed-by:
|
||||
amended-design: 03-DESIGN/01-to-be/08-connectivity.md
|
||||
---
|
||||
|
||||
# 140 — An endpoint's reach is not declared, so three mechanisms each decide it separately
|
||||
|
||||
## What was observed
|
||||
|
||||
Preparing to converge the mesh's control-node — the last machine still running the firewall it
|
||||
had before the mesh — the question came up for one module: the forge serves git over ssh, and that
|
||||
port must stay reachable from outside the private network. Where is that said?
|
||||
|
||||
The manifest declares the port with a source of `mesh`, so the derived filter would close it to
|
||||
everything but the private network. Looking for the place an assignment says otherwise, there are
|
||||
two per-node settings keys: one that gives a module's declared port a machine port, and one that
|
||||
overrides a declared port's source. The second has exactly one caller — the function that builds
|
||||
the node's filter rules. Nothing else in the control plane reads it.
|
||||
|
||||
A module's routed endpoint is declared somewhere else entirely: a route contribution naming a label
|
||||
and a port. It says nothing about reach. The proxy composes a **public** name and an **internal**
|
||||
name for every route it is given, and obtains a certificate for each from a different authority.
|
||||
Measured on that machine the same day: an identity provider's public name signed by the public
|
||||
authority for 90 days, its internal name signed by the mesh's own intermediate for 24 hours and
|
||||
renewed daily. Both names exist, and both certificates, because the proxy makes every name it can.
|
||||
No assignment asked for either.
|
||||
|
||||
So the forge's ssh endpoint has a firewall source and nothing else — no name, no certificate, and no
|
||||
way to say it should be public other than a key the filter alone reads. And the forge's web endpoint
|
||||
has two names and two certificates that nobody requested.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
**Reach is stated twice, in two vocabularies, in two places that cannot disagree out loud.** A port
|
||||
may be exposed to anywhere while the module contributes no public route; a public route may be served
|
||||
for a module whose own listen is private. Nothing reconciles the pair or refuses it. Each mechanism
|
||||
is separately defensible and the combination is unstated.
|
||||
|
||||
**The vocabulary belongs to the filter, not to reachability.** *Public, internal, or both* cannot be
|
||||
expressed. A source of `anywhere` is one rule on one chain; it says nothing about which names should
|
||||
exist or which authority should sign them. So "this endpoint must not be public" has no way to be
|
||||
written, and is therefore enforced by nothing — while a public certificate for that very name is
|
||||
obtained automatically.
|
||||
|
||||
**An endpoint is not a thing in the model.** A module has ports, and separately it has routes.
|
||||
Nothing binds a port to a name to a certificate, which is why three mechanisms each decide reach on
|
||||
their own and none of them is wrong. This is
|
||||
[ADR 0045](../../02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md)'s fault
|
||||
one level up: that record closed "a declaration that reads as a restriction and restricts nothing"
|
||||
for the packet filter. Here the declaration is absent altogether and the mechanisms guess.
|
||||
|
||||
**It blocks the certificate work.** The open question recorded for certificates — a name that must
|
||||
not be public needs either DNS-01 or the internal authority only — cannot be answered while no
|
||||
assignment states whether a name should be public. Neither can expiry reporting, revocation, or what
|
||||
happens to a name when a machine leaves: all of them need to know which names were *meant*.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should an assignment name each of a module's endpoints, bind it to a node-level port, and state
|
||||
whether it is reachable publicly, internally or both — with the filter, the proxy's names and the
|
||||
certificate authority all derived from that one statement?
|
||||
- What is an endpoint that is neither routed nor certified? Git over ssh is public reach with no name
|
||||
and no certificate; the model has to hold that without inventing one.
|
||||
- Are the two existing settings keys the same statement, half-built? If so, is this a new declaration
|
||||
or the completion of theirs?
|
||||
- Does an internal-only endpoint get a certificate at all, and from which authority — and does that
|
||||
settle [issue 129](../129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md), where nothing
|
||||
installs the mesh's own root?
|
||||
- Does declaring reach per assignment also settle
|
||||
[issue 139](../139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md), where an
|
||||
internal name resolves to the consumer's machine instead of the one serving the endpoint?
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
status: located
|
||||
opened: 2026-09-28
|
||||
located-in:
|
||||
- mesh-controller internal/catalogue/filtering.go
|
||||
- mesh-host internal/apply
|
||||
fixed-by:
|
||||
amended-design: 03-DESIGN/01-to-be/08-connectivity.md
|
||||
---
|
||||
|
||||
# 141 — The forward chain does not follow the modules, though the modules declare their networks
|
||||
|
||||
## What was observed
|
||||
|
||||
[ADR 0137](../../02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md), decided the same
|
||||
week, gave a machine a way to say which networks it routes for its guests, because the derived
|
||||
filter's forward chain had until then allowed two ranges named as constants in the control plane's
|
||||
own source — the container runtime's default bridge pool, and half of the pool its compose files
|
||||
are given.
|
||||
|
||||
Checking the last machine still to be converged, the same fault was found to be live there, and the
|
||||
declaration needed to work around it turned out to be wrong in kind.
|
||||
|
||||
That machine hosts twenty-one container networks. Nine fall inside the runtime's bridge pool and
|
||||
are forwarded. Twelve sit in the other private range, and **six of those fall below the lower bound
|
||||
of the constant**, so the flip would have cut their guests off exactly as it did on the workstation
|
||||
that produced 0137.
|
||||
|
||||
Naming a range to cover the six was the obvious move, and is what 0137 provides for. But of those
|
||||
six networks, **four are networks the mesh's own modules declare** — they appear as network
|
||||
resources in the node's plan, created by the host because a module asked for them — and **two are
|
||||
leftovers of the predecessor**, compose networks of services the mesh does not run. A range wide
|
||||
enough to keep the four would have forwarded the two as well: a firewall widened by hand to protect
|
||||
networks that should not exist.
|
||||
|
||||
The mesh already knows which of the twenty-one are its own. It made them.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
**The node's configuration is supposed to follow the modules assigned to it.** That is the mesh's
|
||||
founding shape — the machine runs modules, and its files, its filter and its accounts are composed
|
||||
from what runs there. The forward chain is the one derived thing that does not: it consults two
|
||||
constants and, since 0137, a list a person types. A module added tomorrow brings a network the filter
|
||||
will not forward; a module deprecated leaves a range in the list that outlives it.
|
||||
|
||||
**A typed range cannot distinguish the mesh's networks from what was left behind.** It is stated in
|
||||
addresses, and addresses are what the runtime allocates, so the only honest declaration is one wide
|
||||
enough to include whatever else the runtime has handed out. The derivation is narrower than anything
|
||||
a person can safely write, because it names networks rather than ranges.
|
||||
|
||||
**0137 rejected deriving this, and was right about what it rejected.** It considered deriving the
|
||||
list from *what the machine reports* and refused, on two grounds: a test bed creates its bridge
|
||||
between one declaration and the next, and a filter that follows whatever appeared on the machine is a
|
||||
firewall that widens itself. Deriving from the **declaration** is neither. The set is known before
|
||||
the network exists, because a module declared it; and it cannot widen itself, because only a network
|
||||
some module asked for is ever forwarded. What remains genuinely for a machine to say is guests no
|
||||
module declares — a test bed's pool — which is a much smaller residue than the list as it stands.
|
||||
|
||||
**The gap is invisible in the one place that should show it.** The converge preview lists what
|
||||
*listens*, and routing is not a listener. It says in one line what the machine routes, and a reader
|
||||
has to know the runtime's allocations to tell whether that line is sufficient. On the machine
|
||||
measured here it read as though nothing needed saying.
|
||||
|
||||
## What was decided
|
||||
|
||||
*2026-09-28, later the same day.* The answer is not a better list. The question in the first open
|
||||
item below — should the chain be derived from the networks the modules declare — was answered *no*,
|
||||
after a converged machine's rendered rules were read: the chain blocks everything passing through the
|
||||
machine and then allows its own guests back by listing their addresses. Every route to a correct list
|
||||
fails, because the mesh has no position on a container reaching outward in the first place. The filter
|
||||
now constrains what arrives from **outside** the machine and says nothing about what did not, and a
|
||||
machine says which of its links face outside — one reported fact instead of a list. See
|
||||
[ADR 0140](../../02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md), which
|
||||
supersedes both 0137 and the first attempt at answering this.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should the forward chain be derived from the network resources the node's modules declare, with the
|
||||
host resolving each declared network to its address the way it already resolves a container by name?
|
||||
The controller cannot render the address itself: a module's network resource carries a name, and the
|
||||
runtime allocates the subnet at creation.
|
||||
- What remains of `node networks` once that exists — only guests no module declares, such as a test
|
||||
bed's pool? And should it then be named for that, rather than for all routing?
|
||||
- The runtime's own default bridge, which containers attach to when no module network is named, is
|
||||
not a module's network. Is it derived from the machine, declared by the module that owns the
|
||||
runtime, or left as the one constant?
|
||||
- Should the preview say which of a machine's networks are the mesh's and which are not, so a range
|
||||
that exists to protect a leftover is visible as such?
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
status: located
|
||||
opened: 2026-09-29
|
||||
located-in:
|
||||
- mesh-host internal/upgrade
|
||||
- mesh-host cmd/mesh-host
|
||||
- mesh-controller (no build source for the host; no resource delivers it)
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 142 — The host is the one thing the mesh does not deliver
|
||||
|
||||
## What was observed
|
||||
|
||||
A change to the host was merged and could not reach any machine without a person copying a file.
|
||||
|
||||
Checked on the mesh of four machines, 2026-09-29:
|
||||
|
||||
- **The host is not a build target.** Asked what had been built for it, the control plane answered
|
||||
`nothing has been built for mesh-host`. A merge on the forge builds every changed module and the
|
||||
control plane itself, because the control plane is a module. The host is not one, and nothing
|
||||
builds it.
|
||||
- **No declaration delivers it.** No resource kind names an executable to place on a machine, and
|
||||
nothing on a machine fetches one.
|
||||
- **The half that recovers from a bad host exists and is unused.** `internal/upgrade` can report that
|
||||
the executable this process started from has been replaced on disk, and records which version last
|
||||
completed a reconcile so a shell script can roll back a host that will not start. The launcher reads
|
||||
that record and rolls back. But `Replaced()` is called by nothing except its own tests — the
|
||||
recovery is wired and the delivery was never built.
|
||||
- **Every machine runs a byte-identical binary, stamped by hand.** All four carry the same size and
|
||||
the same timestamp, from the last time somebody built it on a workstation and copied it out. No
|
||||
package owns the file.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
**The component that implements updating is the one thing not updated.** The mesh's stated shape is
|
||||
that a push produces the right builds and they reach the machines running them with nobody asking. It
|
||||
is true of every module and of the control plane. It is false for the host, which is what applies all
|
||||
of them.
|
||||
|
||||
**It is a bootstrap problem being answered by a person.** The host cannot be an ordinary module
|
||||
because the host is what applies modules; a module that replaces the thing applying it has to survive
|
||||
its own replacement. That is a real difficulty, and the work already done — noticing that the
|
||||
executable changed, recording a known-good version, a launcher that rolls back — is the hard half of
|
||||
solving it. What is missing is the easy half, and its absence makes the hard half dead code.
|
||||
|
||||
**A hand-copied binary has no record anywhere.** Nothing says which version a machine runs, so
|
||||
nothing can say a machine is behind, and the mesh's own account of itself — every machine current with
|
||||
its source — cannot include the host. Four machines agreeing today is luck, not a property.
|
||||
|
||||
**And it silently gates any change that starts in the host.** A change that needs the host to report
|
||||
something new cannot be rolled out by merging it: the control plane must wait for a person, and until
|
||||
then it either refuses what depends on the new report or renders something wrong. That cost is paid by
|
||||
every future change of this shape, and it was paid today.
|
||||
|
||||
## Open questions
|
||||
|
||||
- How is the host delivered without being applied by itself? A candidate shape: the host is built like
|
||||
anything else, published as an artifact, and the *running* host fetches and stages the next one, then
|
||||
stands aside — which is what `Replaced()` was written for and what the launcher's rollback already
|
||||
covers.
|
||||
- **Should this ride the bus, rather than becoming a mechanism of its own?** Everything else that
|
||||
reaches a machine already does: a declaration is sent over it, a report comes back over it, and a
|
||||
build announces what it produced on it, which is how a module's new version reaches the machines
|
||||
running it. A host build announcing itself the same way, consumed by the host already running,
|
||||
would make this the existing mechanism pointed at one more artifact rather than a second way of
|
||||
delivering things. It would also give the machine somewhere to say which host it is running, on the
|
||||
report it already sends.
|
||||
- What records which version of the host a machine runs, so "behind" is answerable? Nothing does now.
|
||||
- Does the host's version belong in its report, beside the other facts a machine states about itself?
|
||||
- Who decides when a machine takes a new host — the mesh, on a build, or an operator per machine as
|
||||
with converging? The rollback path means a bad host costs a reconcile rather than a machine, which
|
||||
argues for the former.
|
||||
- Does the same gap apply to the launcher and the units beside the binary, which are also files no
|
||||
declaration names?
|
||||
Reference in New Issue
Block a user