Files
hq/02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md
T
jschoubben a619022c35 ADR 0138: a progressive insight — reach asks for names on a routed endpoint
The record says internal and public each mean something to the filter. For an
endpoint the proxy serves, the second half is wrong, and ADR 0045 said so first:
a public service is exposed through the proxy, listening from the mesh, not by
opening its own port.

Found by trying to express one real module, not by review — routed name public
because browsers post to it, machine port private because it serves a dashboard in
cleartext. Under one value for both, saying public would have reopened a port an
operator had just closed. Measured the same evening: the routed name answered from
the internet over TLS while the port was refused from the same place.

Corrects a fact. One statement per endpoint with three things derived from it
stands; the filter column applies to an unrouted endpoint.
2026-09-29 02:50:25 +02:00

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

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