Files
hq/02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md
T
jschoubben e6b638e63c ADR 0167: a membership carries what its module receives, and who the mesh is
Issue 191's route proxy needs to know who the mesh is to serve an
internal name correctly, and the first fix had it work that out alone.
The membership on the bus now carries it, from the same list the filter
uses. ADR 0138 gains an insight that the proxy is where internal reach
is kept; designs 08 and 25 say how.
2026-10-02 01:48:03 +02:00

14 KiB

topic, status, date, deciders, reconstructed, extends
topic status date deciders reconstructed extends
what runs on it accepted 2026-09-28 jochen false 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 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):

  • 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 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 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 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.

Progressive insight — 2026-09-29, from building it

Reach does not mean the same thing to the filter for an endpoint the proxy serves. The decision above says internal means "the filter opens the machine port to the private network" and public means "the filter opens it to anywhere". For a routed endpoint the second half is wrong, and ADR 0045 already said so before this record was written: a public service is exposed through the proxy, not by opening its own port — it listens from: mesh, only the proxy reaches it, and it is exposed by name.

Found by trying to express one real module, not by review. Its routed name must be public, because browsers post to it; its machine-side port must not be, because that port serves the dashboard in cleartext. Under one value driving both, saying "public" would have reopened a port an operator had just closed. Measured the same evening: that module's routed name answered from the internet over TLS while its machine-side port was refused from the same place. The port is not the path.

So the reach of a routed endpoint asks for names, and its port keeps what the manifest said. The reach of an unrouted endpoint — git over ssh, a mail port, the bus — governs the port, because there is no name and the port is the only way in. That is the same split this record already draws in an endpoint that is not routed is reached but never named; what it got wrong was carrying the filter across it.

This corrects a fact, not the decision: one statement per endpoint, three things derived from it and none of them deciding on its own, all stand. The table in the decision should be read with the filter column applying to an unrouted endpoint.

Progressive insight — 2026-10-02, from issue 191

For a routed endpoint, "the proxy serves the internal name" has to mean "serves it to the private network", and only the proxy can make it mean that. The decision says internal means the proxy serves the internal name and not the public one. It does not say to whom, and the proxy answered every name it routes to any request that carried it, on the same listeners as its public names. A name being internal kept nobody out: a request from the internet only had to send it. While every routed endpoint also had a public name, nothing showed it. Once an endpoint could be internal alone (issue 191), serving its name to everyone would have published exactly what internal was chosen to keep private.

The earlier insight above says the port is not the path for a routed endpoint. This is its other half: the proxy is the path, so the proxy is where internal is enforced. It serves an internal name only to the machines of the mesh and to the machine itself (ADR 0144). Who the mesh is, it is told, not left to work out: its membership carries the same list of machine addresses the filter's "from the mesh" is rendered from (ADR 0167). To anyone else, the name is answered as one never routed, in the handshake and in the request, and not listed among the names it serves. This holds for the internal name of a both endpoint too, whose outsiders have its public name.

The decision, the options and the consequences stand: one statement per endpoint, three things derived from it. Checked in the proxy's own tests: an internal-only name is served to a machine the membership names and to loopback, and refused, unlisted and uncertified for any other request; until the mesh is issued, it is served to the machine alone.

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 a declared answer to read: which endpoints are internal is what says whose root must be installed where.
  • Issue 139 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. 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