diff --git a/02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md b/02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md index 199f947..8169fad 100644 --- a/02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md +++ b/02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md @@ -124,6 +124,30 @@ This corrects a fact, not the decision: one statement per endpoint, three things 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](../04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md)), 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 a request from the private network — the mesh's range, the machine itself, or one of its own +container networks ([ADR 0144](0144-anything-on-a-machine-may-call-anything-on-it.md)). 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 the mesh +range, to loopback and to a container bridge, and refused, unlisted and uncertified for a request +from outside; with no range given, it is served to the machine alone. + ## Consequences - **A manifest gains endpoint names, and a route contribution names an endpoint instead of a port.** diff --git a/03-DESIGN/01-to-be/08-connectivity.md b/03-DESIGN/01-to-be/08-connectivity.md index 69226e8..44baaef 100644 --- a/03-DESIGN/01-to-be/08-connectivity.md +++ b/03-DESIGN/01-to-be/08-connectivity.md @@ -7,7 +7,7 @@ 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-30 +updated: 2026-10-02 decisions: - 02-DECISIONS/0148-the-meshs-names-are-resolved-not-copied-into-containers.md - 02-DECISIONS/0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md @@ -842,6 +842,15 @@ One value, three readers: | `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 | +*2026-10-02.* **The proxy serves an internal name to the private network only** +([ADR 0138](../../02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md), +its insight of this date). It answers public and internal names on the same listeners, so the name a +request carries is the request's own claim, not where the request came from. An internal name is +served to the mesh's range, to the machine itself and to its own container networks; to anyone else +it is answered as a name never routed, in the handshake as well as the request. Without this, an +endpoint with reach `internal` would be public under a name that is easy to guess +([issue 191](../../04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md)). + **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. diff --git a/04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md b/04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md index 748c1ce..04044b7 100644 --- a/04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md +++ b/04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md @@ -1,9 +1,9 @@ --- status: located opened: 2026-10-01 -located-in: [mesh-controller examples/route-proxy/main.go (routesFrom requires a route's public `name` and treats `internal-name` only as an alias of it)] +located-in: [mesh-controller examples/route-proxy/main.go (routesFrom requires a route's public `name` and treats `internal-name` only as an alias of it; the handler serves every routed name to any source), mesh-catalog modules/route-proxy/module.json (the proxy is not told the private network's range)] fixed-by: -amended-design: [] +amended-design: [03-DESIGN/01-to-be/08-connectivity.md] --- # 191 — A route with only an internal name is dropped as naming nothing diff --git a/04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/01-diagnosis.md b/04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/01-diagnosis.md index f8bf153..d2b1321 100644 --- a/04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/01-diagnosis.md +++ b/04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/01-diagnosis.md @@ -23,9 +23,43 @@ mesh before ADR 0138, when both names were always composed. It has been wrong si The other half of the proxy already handles the case. Certificates for a host are split by whether it is in the public set: hosts outside it go to the internal authority, and only hosts inside it are eligible for ACME. A host that is only ever an internal name falls on the correct side of both checks -without change. Only reading the route was wrong. +without change. For certificates, only reading the route was wrong; who may reach the route is the next section. **The fix.** `routesFrom` takes a route that names either host, serves each name it carries, and marks only the public one as public. It still skips a route that names neither, with the same log line. A test proves an internal-only route is served, certified by the internal authority, and refused by the public one. That test fails against the code before the change. + +## The first fix would have made the name public — 2026-10-02, from review + +Serving the dropped route was not enough. The proxy picks a route from the name a request carries +and never from where the request came from, and it answers public and internal names on the same +listeners. Its public names resolve to an address the internet reaches. So once the internal-only +route was served, any request from the internet carrying `unifi.home-server.internal` — a name of a +fixed, guessable shape — would have reached an administration interface that reach `internal` was +chosen to keep private. Before the fix the route was unreachable from everywhere. After it, it would +have been reachable from everywhere. Two more leaks came with it: the proxy's answer for an unrouted +name listed every name it serves, internal ones included, and the handshake handed a certificate +naming the internal host to any client. + +Nothing showed this while every routed endpoint also had a public name: its internal name exposed +nothing the public one did not. It is a gap in the decision's wording, not only in the proxy — ADR +0138 says the proxy *serves* the internal name without saying to whom — so it is recorded there as a +progressive insight and in the to-be connectivity design. + +**Where "inside" is decided.** The mesh's guard recognises the private network by the interface a +packet arrives on, never by source address, because a source can be claimed. The proxy cannot see the +interface, so it reads the source: the mesh's range, loopback, and the ranges of the machine's own +container bridges, which are the same interfaces the guard names. A claimed source does not carry +here, as a connection needs its replies, and replies to those addresses leave by the tunnel or a +local bridge. The range reaches the proxy from the catalog as the machine's `mesh-range`, the way the +intrusion filter already receives it. With none given, the proxy serves internal names to the machine +alone: refused, not opened. + +**What changed with it.** The internal name of a route that also has a public one is now served to +the private network only, like any other internal name. Outsiders have the public name, so nothing +they could reach is lost. + +**Order of release.** The catalog change comes first. A proxy built from the change but started +without the range would serve internal names to its own machine alone, and every other member of the +mesh would lose them until the range arrived.