diff --git a/04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md b/04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md new file mode 100644 index 0000000..7fa3da1 --- /dev/null +++ b/04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md @@ -0,0 +1,74 @@ +--- +status: open +opened: 2026-09-28 +located-in: [] +fixed-by: +amended-design: +--- + +# 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? diff --git a/04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md b/04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md new file mode 100644 index 0000000..17c36d9 --- /dev/null +++ b/04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md @@ -0,0 +1,74 @@ +--- +status: open +opened: 2026-09-28 +located-in: [] +fixed-by: +amended-design: +--- + +# 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. + +## 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?