diff --git a/02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md b/02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md new file mode 100644 index 0000000..d7e46d3 --- /dev/null +++ b/02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md @@ -0,0 +1,102 @@ +--- +topic: the tiers +status: accepted +date: 2026-09-25 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0007-connectivity.md +--- + +# 108. A route carries the policy applied to a request, and names a secret rather than holding one + +## Context + +[ADR 0007](0007-connectivity.md) settled that **a route is a grant**: a module contributes the name +it wants and the port it listens on, and the proxy hands back the public name. That governs the +**grant**. It says nothing about what a request arriving at the name is permitted to do, and the +mesh's proxy currently permits everything: its request path is a host lookup and a forward, with no +authentication, no source check, no redirect handling and no middleware anywhere in it. + +The standing requirement is that the mesh does **at minimum** what the system it replaces already +does. Measured against the predecessor's live configuration and its module catalogue +([issue 116](../04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md)), four +capabilities are relied on and absent: + +| Capability | Dependents, counted | +|---|---| +| Authentication | **three** modules, each gating a credential-less admin surface — a key-value browser UI, a **database web UI**, and the replaced ingress's own dashboard | +| Refusal scoped to a path | **one**, and it is a live **incident mitigation** closing a write primitive that was abused | +| Path-scoped routing with priority | **two** — the refusal above, and a certificate-challenge path on a host that otherwise routes elsewhere | +| Redirect | **two** live routes canonicalising a `www` name onto its apex | + +Counting needed two sources and neither alone is complete: the catalogue cannot see what was +hand-written on a node, and a node's configuration directory cannot see what modules declare as +container labels. An earlier count read one source and undercounted authentication by two. + +**The third row is a prerequisite, not a sibling.** The proxy's table maps a host to exactly one +target, so a host cannot be routed two ways. Adding authentication and a source filter would not +make the refusal rule expressible. + +## Considered Options + +1. **Leave policy out of the mesh; keep the affected routes on the adopted ingress.** *Rejected.* + The mesh would run two reverse proxies indefinitely with no principled division between them, and + one of the routes held back is a live mitigation — leaving it on a component being decommissioned + means its removal date is whenever somebody forgets. +2. **A separate, proxy-side settings layer keyed by route name.** *Rejected.* It keeps the grant + literally clean, but answering *"what protects this route"* then requires reading two files that + nothing keeps in step. A route's protection is part of what a route is. +3. **The grant carries a reference; the detail lives in a second layer.** *Rejected.* Both costs of + option 2, plus a naming indirection to maintain. +4. **Carry the password hash in the declaration.** *Rejected.* It would be the first credential + value in a declaration, and a precedent is easier to set than to withdraw. A hash is not a + plaintext password, but it is sufficient to pass the gate it protects. +5. **An open middleware surface the proxy applies.** *Rejected.* It recreates the thing being + replaced, makes the proxy's behaviour unbounded, and an open surface is far harder to narrow later + than a closed one is to widen. + +## Decision + +**A route contribution carries the policy applied to requests arriving at its name**, alongside the +name, the port and the location it already carries. + +**The set is closed, and it is these four:** authentication; refusal scoped to a path; path-scoped +routing with priority; redirect. A fifth is an amendment to this record, deliberately — each +addition should be earned by a dependent that exists. + +**Where policy needs a credential, the declaration names a secret the mesh mints and holds. It never +carries the value.** This keeps the existing secret machinery as the only thing that holds +credentials, and keeps hashes out of anything regenerated, synced or committed. + +**Consequently the routing table is keyed by host and path, with priority** — not by host alone. +This follows from the decision rather than being a separate one: two of the four capabilities need a +single host routed more than one way. + +## Consequences + +**What this makes possible.** The affected routes can leave the adopted ingress, and "at minimum" +becomes a satisfiable claim rather than a standing exception. The incident mitigation gets a durable +home in the mesh, which its own note already asked for. + +**What got harder.** The contribution shape grows, and every provider of `route` must understand +more of it. The table is no longer a flat map, and priority introduces ordering that has to be +deterministic rather than incidental — equal priorities must resolve the same way every time or the +proxy becomes non-reproducible. A closed set means a new need is a decision, not a patch. + +**What does not change.** The proxy remains a reference implementation: the contract is the file the +mesh writes, not the program that reads it. Another proxy may implement the same file. + +**How this is checked.** A rule states how it is verified, so this one does. A lab bed must show, +against a mesh that declared them: a route with authentication refusing an unauthenticated request +and admitting an authenticated one; a path-scoped refusal shadowing an ordinary route on the same +host while that ordinary route still serves every other path; a redirect answering with the +redirect; and — the negative case, which is the one that rots quietly — **a declaration carrying a +credential value rather than a reference being refused**, so option 4 cannot return by accident. + +## References + +- [Issue 116](../04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md) — the count, + the evidence, and the structural finding about the table. +- [ADR 0007](0007-connectivity.md) — a route is a grant. This record extends it to the request. +- [ADR 0009](0009-modules-and-the-graph.md) — the provision vocabulary a contribution belongs to. +- [To-be 08 §3](../03-DESIGN/01-to-be/08-connectivity.md) — where exposure is specified. diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index e0ae9bd..f23c044 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -123,6 +123,7 @@ python3 00-META/checks/index.py fail if stale - **0094** — [A module may hold several secrets from one provider, each a pair of its own](0094-a-module-may-hold-several-secrets-from-one-provider.md) - **0095** — [The control plane is the way to ask a module](0095-the-control-plane-is-the-way-to-ask-a-module.md) - **0098** — [A fact a provider makes at first start is fetched from it, not carried in its manifest](0098-a-fact-a-provider-makes-at-first-start-is-fetched-from-it.md) +- **0108** — [A route carries the policy applied to a request, and names a secret rather than holding one](0108-a-route-carries-the-policy-applied-to-a-request.md) ### What runs on them, and how it gets there diff --git a/03-DESIGN/01-to-be/08-connectivity.md b/03-DESIGN/01-to-be/08-connectivity.md index fad77e3..ae7801f 100644 --- a/03-DESIGN/01-to-be/08-connectivity.md +++ b/03-DESIGN/01-to-be/08-connectivity.md @@ -7,11 +7,12 @@ 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-23 +updated: 2026-09-25 decisions: - 02-DECISIONS/0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.md - 02-DECISIONS/0106-the-bus-is-nats.md - 02-DECISIONS/0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md + - 02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md - 02-DECISIONS/0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md - 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md - 02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md @@ -442,6 +443,39 @@ exactly the per-module cost it is meant to remove. The design is the composition stopgap until the manifest layer can carry a label and a domain separately ([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)). +### A route also carries what a request arriving at it may do + +*2026-09-25, from comparing the mesh's proxy against the ingress it would replace +([issue 116](../../04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md)), +decided in [ADR 0108](../../02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md).* + +A grant hands back a name. It did not say what the name admits, and the proxy admitted everything — +its request path was a host lookup and a forward. Measured against what the replaced ingress +actually relies on, four things were missing: **authentication**, **refusal scoped to a path**, +**path-scoped routing with priority**, and **redirect**. Three modules depend on the first, each to +gate an admin surface that has no login of its own; one dependent of the second is a live incident +mitigation. + +**Policy belongs to the route, not beside it.** A contribution carries it along with the name, the +port and the location. The alternative — a proxy-side settings layer keyed by route name — keeps the +grant literally clean but makes *"what protects this route"* a question answered from two files that +nothing keeps in step. A route's protection is part of what a route is. + +**The set is closed at those four.** A fifth is an amendment, so each addition is earned by a +dependent that exists rather than added because a middleware surface was open. An open surface would +recreate the thing being replaced, and is far harder to narrow later than a closed one is to widen. + +**Where policy needs a credential, the declaration names a secret; it never carries one.** The mesh +already mints and holds credentials, and that machinery stays the only thing that does — so a hash +never reaches anything regenerated, synced or committed. + +**This re-keys the table.** Two of the four need one host routed more than one way, so the proxy +matches on host **and path**, with priority, rather than mapping a host to a single target. Equal +priorities must resolve identically every time, or the proxy stops being reproducible. + +The proxy remains a reference implementation: the contract is the file the mesh writes, not the +program that reads it, and another proxy may implement the same file. + ## 4 — Filtering **Derived from what is assigned here, and from the overlay's shape** — a node's open ports are a diff --git a/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md new file mode 100644 index 0000000..8390ad2 --- /dev/null +++ b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md @@ -0,0 +1,152 @@ +--- +status: resolved +opened: 2026-09-25 +located-in: [mesh-controller examples/route-proxy] +fixed-by: mesh-controller PR #58 — the four capabilities implemented in the reference proxy; the table re-keyed by host and path with a total ordering; a declaration carrying a credential refused rather than served, and an unreadable secret failing closed +amended-design: 03-DESIGN/01-to-be/08-connectivity.md +--- + +# 116 — The mesh's proxy applies no policy to a request: no authentication, no source restriction, no path scoping, no redirects + +## What was observed + +On 2026-09-25, deciding whether the mesh's own reverse proxy +([to-be 08](../../03-DESIGN/01-to-be/08-connectivity.md)) can replace the ingress the mesh adopted +from the predecessor, as the permanent public entry point. The standing requirement is that the mesh +does **at minimum** what the system it replaces already does, so the comparison is against the +predecessor's real, live configuration and its module catalogue — not against a feature list. + +**The proxy's entire request path is a host lookup and a forward.** Its handler takes the request's +host, finds a target in a table, and either answers a named 404 or hands the request to the reverse +proxy. There is no authentication check, no source-address check, no redirect handling and no +middleware chain anywhere in the program. + +The table is the reason this is structural rather than a missing feature: it maps **host → one +target**, and the lookup strips the port and lowercases the host. **A host cannot be routed two ways.** + +The design is deliberate as far as it goes — *"a route hands back a name, not a credential"* — but +that governs the **route grant**. It says nothing about what a request arriving at that name is +allowed to do, which is what the live configuration relies on. + +## What the predecessor actually relies on, counted + +Two sources, because neither alone is complete: the **module catalogue**, and the **ingress's own +dynamic configuration** on the node. The catalogue misses what was hand-written on the node; the +node's directory misses what modules declare as container labels. An earlier version of this report +read only the latter and undercounted as a result. + +### 1. Basic authentication — three dependents in the catalogue + +| Module | What it is the only gate on | +|---|---| +| the key-value store | its browser UI, which has no login of its own | +| the relational store | a **database web UI** | +| the ingress itself | its own dashboard — twice, counting a desktop flavour | + +Every one is a credential-less admin surface whose sole protection is a middleware the mesh's proxy +does not have. The database web UI is the worst of the three, and the ingress dashboard means the +ingress is currently protecting itself with a mechanism its replacement lacks. + +### 2. Outright refusal on a path — an incident mitigation + +One hand-written rule on the node blocks external access to an internal API path on the forge. Its +own header records it as **incident response to a compromise**, closing the write primitive that was +abused. It is expressed as an allow-list containing a single documentation-range address — that is, +a deny-everyone — and the middleware is named accordingly. + +It is **not** an address-scoped allow-list in any useful sense, and reading it as one points at the +wrong fix. What it needs is the ability to refuse a request outright, scoped to a path. + +The same header already records where this belongs: *"not mesh-managed. Durable home is the +route-proxy module; re-home when convenient."* + +### 3. Path-scoped routing with priority — and this one gates the others + +That rule matches a **path prefix** on a host that is **already routed elsewhere**, and carries an +explicit high priority so it shadows the ordinary route. The mail module needs the same shape for a +different reason: it routes a certificate-challenge path on a host that otherwise goes to the mail +front end. + +Because the table maps a host to exactly one target, **neither is expressible today, and adding +authentication and a source filter would not make them so.** Path scoping with priority is a +prerequisite for the refusal rule, not a feature beside it. + +### 4. Redirect rules — live, and they fail quietly + +Two routes canonicalise a `www` name onto its apex with a rewriting redirect. They appear in the +node's configuration and **not** in the catalogue, so a catalogue-only survey misses them. They are +the easiest of the four to lose, because losing them produces no error — just two public names that +quietly stop redirecting. + +## What compared cleanly, and is not part of this issue + +- **Large and streaming request bodies.** Four modules raise or remove the body cap — the object + store, the file-sync application, the image registry. The standard library's reverse proxy streams + with no default cap, so this needs nothing added. +- **Connection upgrades**, used by at least one console route: native to the standard library's + reverse proxy. Not yet verified live against this proxy, but not absent by design the way the four + gaps above are. +- **Certificate issuance.** The proxy refuses to certify any name the mesh did not route, and + defaults to a staging issuer until a node opts in to production + ([issue 004](../004-certificate-issuance-targets-production/00-report.md)) — stricter than the + hand-maintained configuration it would replace. +- **Several public names for one module.** Already solved by the `contributes` many-shape; needs + nothing from the proxy. + +## Why it matters + +Under the "at minimum" rule, **nothing can be called a replacement for the predecessor's ingress +while any of the four is missing** — and one of them is a live mitigation for an exploited +vulnerability. The two outcomes if it is left unfixed are both bad: + +- the affected routes stay on the adopted ingress indefinitely, leaving the mesh running two + reverse proxies side by side with no principled division between them; or +- they are migrated anyway, and three credential-less admin surfaces become reachable by anyone who + can resolve a name, while a known-exploited path loses the block that was put in front of it + during an incident. + +## Open questions — answered + +These were design questions, not implementation details, and the fix was not written before they +were answered. All three were settled in +[ADR 0108](../../02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md): policy goes +**on the route**, the set is **closed at the four**, and a declaration **names a secret and never +carries one**. Kept as asked, because what was rejected and why is the half worth having. + +- **Where does request-level policy come from?** Today a contribution carries a name and a port. + Extending it to carry policy keeps the mesh as the source of truth, consistent with everything + else a route already does. A separate proxy-side settings layer keyed by route name decouples + policy from the grant but adds a second place to look. The module's own README notes *"the contract + is the file, not this program"*, so this is a contract decision and not a property of one + reference implementation. +- **May a declaration carry a credential?** A password hash in a contribution puts a secret in a + declaration. That cuts across how the mesh mints and holds secrets, and it should be settled + deliberately rather than as a side effect of whichever option is less code. +- **Is the right general shape "these four", or something narrower?** Authentication, refusal, path + scoping and redirects are what the predecessor uses *today*. Whether route-level policy should be + an open middleware surface, or exactly these four and no more, is worth deciding before any of it + is written — an open surface is far harder to withdraw than to add. + +## What the fix covers, and what it does not yet allow + +*2026-09-25, on resolution.* The gap this issue reports is closed: the proxy applies policy, the +four capabilities exist, the table is keyed by host and path with a total ordering, and the two +failure modes that would rot quietly are held by tests — a declaration carrying a credential is +refused rather than served, and an unreadable secret makes the route refuse rather than open. + +**It does not yet let an operator move the affected routes.** That needs the mesh side: a manifest +able to declare these values, and the controller minting the secret that `auth` names. Until both +exist the capability is reachable only by writing the routes file by hand, so the routes held back +on the adopted ingress stay there. + +Resolved rather than left open because the issue reports a gap **in the proxy**, and that gap is +gone. The remaining work is not this fault persisting; it is the ordinary build-out of a contract +this record's decision created, and it belongs to +[to-be 08](../../03-DESIGN/01-to-be/08-connectivity.md) rather than here. + +**One thing found while fixing it, worth keeping.** Priority was first read with the reader for +ports, which caps at 65535 — and the one real rule this has to reproduce is declared at 100000. It +parsed to zero, so refusal and path scoping would both have shipped looking complete, passing their +own tests, and doing nothing on the only case that motivated them. *A validator borrowed from a +neighbouring field is a silent default*, and the test that now guards it goes through the proxy, +because at the parser the value looked fine.