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.
180 lines
12 KiB
Markdown
180 lines
12 KiB
Markdown
---
|
|
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.
|
|
|
|
## 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](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) 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](../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)
|