Compare commits

...
2 changed files with 92 additions and 0 deletions
@@ -0,0 +1,61 @@
---
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)]
fixed-by:
amended-design: []
---
# 191 — A route with only an internal name is dropped as naming nothing
## What was observed
A module whose endpoint reaches only the private network could not be reached by its internal name.
The module ran and answered on its own port. Its route's internal name resolved to the serving node.
The request failed during the TLS handshake:
```
http: TLS handshake error from …: no public route for "unifi.home-server.internal" in this mesh,
so no certificate is asked for
```
The proxy's own log said why, every time it re-read its routes:
```
unifi on home-server asked for a route and named nothing; skipped
```
The route it skipped was not empty. The mesh had given it an endpoint, a port, a scheme and an internal
name, and no public name:
| route | `name` | `internal-name` | served |
|---|---|---|---|
| home-assistant | a public name | `home-assistant.home-server.internal` | under both |
| unifi | — | `unifi.home-server.internal` | under neither |
Three other modules on the same node were skipped with the same line on the same pass.
## Why it matters
**Reach is decided in one place, and the proxy reads the old shape of the decision.**
[ADR 0138](../../02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md)
made an endpoint's reach decide which names exist. The controller composes the public name, the
internal name, or both, and composes no name that nobody asked for
([issue 140](../140-an-endpoints-reach-is-not-declared/01-resolution.md)). An endpoint that reaches
only the private network is the ordinary case for anything that should not face the internet. It is
exactly the case the proxy drops.
**The failure is quiet and points the wrong way.** Nothing marks the module unhealthy. The handshake
error says *no public route*, which reads as a certificate fault on the reader's side. The line that
gives the real cause is one of four identical lines repeated every few seconds in a log nobody reads
until they already suspect the proxy.
**The opposite move is not a workaround.** Giving the endpoint public reach makes the proxy serve it.
It also publishes an administration interface to the internet to get a name on the private network.
## Open questions
- Should the proxy refuse a route it cannot serve in a way the controller or an operator sees, not
only in its own log? The same silent skip covers a route with no usable port or an unknown scheme.
- What checks that what the controller composes and what the proxy serves stay the same shape? ADR
0138 changed one side and nothing failed on the other.
@@ -0,0 +1,31 @@
# Diagnosis
*2026-10-01.*
**Ruled out first: the module itself.** Its container was up and had not restarted. The controller's
status endpoint answered on its own port with `"up": true`. The tool wrapper beside it was serving
all its tools.
**Ruled out: name resolution.** The internal name resolved to the serving node's private-network
address, which is where the proxy listens. Plain HTTP to the name reached the proxy and got a 404.
HTTPS failed in the handshake, and the proxy logged that it had no route for the name.
**The route as the proxy received it.** The mesh-written route file held a complete contribution
for the module: endpoint `web`, port, scheme `https`, `insecure`, a label, and `internal-name`. It had
no `name`. That is what the controller composes for an endpoint whose reach stops at the private
network (ADR 0138, `composeName`). The contribution was correct.
**Located: `routesFrom` in the proxy.** It reads `name` first and skips the contribution if `name`
is empty. It reads `internal-name` only at the end, as a second host for a rule that already has a
public one. So the proxy can serve an internal name only next to a public one. That matched the
mesh before ADR 0138, when both names were always composed. It has been wrong since then.
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.
**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.