route-proxy: a route carries the policy applied to a request #58

Merged
jschoubben merged 2 commits from issue/116-route-proxy-has-no-auth-or-ip-restriction into main 2026-09-25 12:21:46 +00:00
Owner

Implements novox/hq ADR 0108, closing issue 116. The proxy's request path was a host lookup and a forward, so it applied nothing — while the ingress it replaces relies on four things it had none of.

Path scoping came first, because it is a prerequisite

The table mapped a host to one target, so a host could not be routed two ways. The refusal this issue turns on matches a path on a host that is already routed to a workload — so no amount of authentication or source filtering would have made it expressible. The table is now host → an ordered list of rules, matched on path prefix.

The order is total, not just by priority

Sorting on priority alone leaves rules sharing one in whatever order the map produced, so the same declaration would serve differently between restarts — a fault that works, and works differently each time, which is the hardest kind to believe when it's reported. Within a priority the longer path wins, which is also the intuitive reading.

auth names a secret and never holds one

  • A declaration carrying a credential is refused whole rather than served unprotected, so the option ADR 0108 rejected cannot return by accident.
  • A secret that cannot be read makes the route refuse and say why, rather than serve the workload unprotected. A gate that cannot check is not a gate that opens, and the alternative turns a missing file into a silently public admin surface.
  • Authentication costs one bcrypt comparison on every path, including an unknown user — so an unknown user is not measurably faster than a known one with a wrong password. That difference is a way to enumerate a route's users from outside it.

Redirects keep the request's own path and query, or canonicalising one name onto another would land every deep link on the front page and raise no error doing it.

The contract, documented in the header

path       the path prefix this rule is scoped to; absent means every path
priority   which rule wins where two match; higher first, and the order is total
deny       refuse the request outright
redirect   answer with a permanent redirect, keeping the path and query
auth       the path of a secret holding user:hash lines, never the credential

Verification

go build ./... clean, go vet clean, gofmt clean on everything touched, 20 tests pass in this package and the whole repo's suite is green.

Eleven of those tests are new — four for the capabilities, and two for the failure modes that rot quietly: the credential-in-a-declaration refusal, and the unreadable secret failing closed. Nothing else breaks if either stops working, so nothing else would report it.

No new dependency — bcrypt comes from the x/crypto module already required. go.mod and go.sum unchanged.

Unrelated, noted not touched: cmd/mesh-builder/stdout_test.go fails gofmt -l, and does so on main too.

Not in this change

The mesh side — teaching a module's manifest to declare these values and the controller to mint the secret auth names. This is the reference implementation of the contract, and per the module's own README the contract is the file, not this program.

Implements **novox/hq ADR 0108**, closing **issue 116**. The proxy's request path was a host lookup and a forward, so it applied nothing — while the ingress it replaces relies on four things it had none of. ## Path scoping came first, because it is a prerequisite The table mapped a host to **one** target, so a host could not be routed two ways. The refusal this issue turns on matches a path on a host that is *already* routed to a workload — so **no amount of authentication or source filtering would have made it expressible.** The table is now host → an ordered list of rules, matched on path prefix. ## The order is total, not just by priority Sorting on priority alone leaves rules sharing one in whatever order the map produced, so the same declaration would serve **differently between restarts** — a fault that works, and works differently each time, which is the hardest kind to believe when it's reported. Within a priority the longer path wins, which is also the intuitive reading. ## `auth` names a secret and never holds one - A declaration **carrying** a credential is refused **whole** rather than served unprotected, so the option ADR 0108 rejected cannot return by accident. - A secret that **cannot be read** makes the route refuse and say why, rather than serve the workload unprotected. A gate that cannot check is not a gate that opens, and the alternative turns a missing file into a silently public admin surface. - Authentication costs one bcrypt comparison on **every** path, including an unknown user — so an unknown user is not measurably faster than a known one with a wrong password. That difference is a way to enumerate a route's users from outside it. Redirects keep the request's own path and query, or canonicalising one name onto another would land every deep link on the front page and raise no error doing it. ## The contract, documented in the header ``` path the path prefix this rule is scoped to; absent means every path priority which rule wins where two match; higher first, and the order is total deny refuse the request outright redirect answer with a permanent redirect, keeping the path and query auth the path of a secret holding user:hash lines, never the credential ``` ## Verification `go build ./...` clean, `go vet` clean, `gofmt` clean on everything touched, **20 tests pass** in this package and the whole repo's suite is green. Eleven of those tests are new — four for the capabilities, and **two for the failure modes that rot quietly**: the credential-in-a-declaration refusal, and the unreadable secret failing closed. Nothing else breaks if either stops working, so nothing else would report it. **No new dependency** — bcrypt comes from the `x/crypto` module already required. `go.mod` and `go.sum` unchanged. Unrelated, noted not touched: `cmd/mesh-builder/stdout_test.go` fails `gofmt -l`, and does so on `main` too. ## Not in this change The mesh side — teaching a module's manifest to declare these values and the controller to mint the secret `auth` names. This is the reference implementation of the contract, and per the module's own README the contract is the file, not this program.
jschoubben added 1 commit 2026-09-25 12:01:15 +00:00
Implements novox/hq ADR 0108, closing issue 116. The proxy's request path was a host lookup
and a forward, so it applied nothing — while the ingress it replaces relies on four things it
had none of.

Path scoping came first because it is a prerequisite, not a sibling. The table mapped a host
to one target, so a host could not be routed two ways, and the refusal this issue turns on
matches a path on a host already routed to a workload. No amount of authentication or source
filtering would have made it expressible. The table is now host to an ordered list of rules,
matched on path prefix.

The order is total, not just by priority. Sorting on priority alone leaves rules that share
one in whatever order the map produced, so the same declaration would serve differently
between restarts — a fault that works, and works differently each time, which is the hardest
kind to believe when reported. Within a priority the longer path wins, which is also the
intuitive reading.

auth names a secret and never holds one. A declaration carrying a credential is refused
whole rather than served unprotected, so the option ADR 0108 rejected cannot return by
accident. A secret that cannot be read makes the route refuse and say so, rather than serve
the workload unprotected — a gate that cannot check is not a gate that opens, and the
alternative turns a missing file into a silently public admin surface.

Authentication costs one bcrypt comparison on every path including an unknown user, so an
unknown user is not measurably faster than a known one with a wrong password. That difference
is a way to enumerate a route's users from outside it.

Redirects keep the request's own path and query, or canonicalising one name onto another
would land every deep link on the front page and raise no error doing it.

Eleven tests, four of them for the capabilities and two for the failure modes that rot
quietly: the credential-in-a-declaration refusal, and the unreadable secret failing closed.
Nothing else breaks if those stop working, so nothing else would report it.

No new dependency: bcrypt comes from the x/crypto module already required.
jschoubben added 1 commit 2026-09-25 12:20:13 +00:00
Found reviewing my own change before merging it, and it was load-bearing rather than cosmetic.

Priority was read with asPort, which caps at 65535. A rule declared above that silently became
priority 0 and stopped shadowing the route it exists to shadow. The one real rule this has to
reproduce is declared at 100000 — so path scoping and refusal would both have shipped looking
complete, passing their tests, and doing nothing on the only case that motivated them.

A priority is an ordering and has no range. asWhole takes any whole number the mesh wrote and
rejects a non-integral one, which was not meant as a priority.

Also: a host may now be routed on some paths and not others, which made the 404 dishonest — it
said "no route for this name" while listing that very name as served, a contradiction an
operator has to disbelieve the proxy to get past. An uncovered path now says so, and a name
that is genuinely not served still lists what is.

Two regression tests, both through the proxy rather than against the parser, because the parser
was where the bug looked fine.
Author
Owner

Review — one real bug found and fixed (e11e137)

Reviewed this adversarially rather than re-reading it, and it was worth doing.

The bug: a priority above 65535 silently became 0

Priority was read with asPort, which caps at 65535. The one real rule this exists to reproduce is declared at priority: 100000 — so it parsed to 0, stopped shadowing the route it exists to shadow, and path scoping plus refusal would both have shipped looking complete, passing their tests, and doing nothing on the only case that motivated them.

Proved it before fixing it:

priority parsed as 0 (declared 100000)
--- FAIL: TestPriorityAboveAPortNumberSurvives

A priority is an ordering and has no range. asWhole now takes any whole number and rejects a non-integral one, which was not meant as a priority. The regression test goes through the proxy, not the parser — the parser is where the bug looked fine.

And a dishonesty path scoping made reachable

A host can now be routed on some paths and not others, which made the 404 self-contradictory: it said "no route for this name" while listing that very name as served. An operator has to disbelieve the proxy to get past that. An uncovered path now says <name> is served here, but no route covers <path>; a genuinely unserved name still lists what is.

Verification after the fix

22 tests pass, 0 fail. go build ./... clean, go test ./... green across every package, gofmt clean on everything touched, go.mod/go.sum unchanged.

What I checked and found sound

  • set() can't leave a nil proxy reachable — a rule that neither denies nor redirects and whose URL won't parse is dropped, so matched.to is never nil at ServeHTTP.
  • routed() vs find() are correctly split: certificate issuance is a question about the name, so a host with only path-scoped rules is still certifiable.
  • sealed is checked before the users branch, so an unreadable secret can't fall through to an empty credential set.
  • inOrder is a total order — ties break on path length, then path, then target — so restarts serve identically.

Still not in scope

The mesh side: a manifest declaring these values, and the controller minting the secret auth names. This is the reference implementation of the contract.

Merging.

## Review — one real bug found and fixed (`e11e137`) Reviewed this adversarially rather than re-reading it, and it was worth doing. ### The bug: a priority above 65535 silently became 0 Priority was read with `asPort`, which caps at 65535. **The one real rule this exists to reproduce is declared at `priority: 100000`** — so it parsed to 0, stopped shadowing the route it exists to shadow, and path scoping plus refusal would both have shipped *looking complete, passing their tests, and doing nothing on the only case that motivated them*. Proved it before fixing it: ``` priority parsed as 0 (declared 100000) --- FAIL: TestPriorityAboveAPortNumberSurvives ``` A priority is an ordering and has no range. `asWhole` now takes any whole number and rejects a non-integral one, which was not meant as a priority. The regression test goes through the proxy, not the parser — the parser is where the bug looked fine. ### And a dishonesty path scoping made reachable A host can now be routed on some paths and not others, which made the 404 self-contradictory: it said *"no route for this name"* while listing that very name as served. An operator has to disbelieve the proxy to get past that. An uncovered path now says `<name> is served here, but no route covers <path>`; a genuinely unserved name still lists what is. ### Verification after the fix **22 tests pass, 0 fail.** `go build ./...` clean, `go test ./...` green across every package, `gofmt` clean on everything touched, `go.mod`/`go.sum` unchanged. ### What I checked and found sound - `set()` can't leave a nil proxy reachable — a rule that neither denies nor redirects and whose URL won't parse is dropped, so `matched.to` is never nil at `ServeHTTP`. - `routed()` vs `find()` are correctly split: certificate issuance is a question about the *name*, so a host with only path-scoped rules is still certifiable. - `sealed` is checked before the `users` branch, so an unreadable secret can't fall through to an empty credential set. - `inOrder` is a total order — ties break on path length, then path, then target — so restarts serve identically. ### Still not in scope The mesh side: a manifest declaring these values, and the controller minting the secret `auth` names. This is the reference implementation of the contract. Merging.
jschoubben merged commit 506426cf94 into main 2026-09-25 12:21:46 +00:00
jschoubben deleted branch issue/116-route-proxy-has-no-auth-or-ip-restriction 2026-09-25 12:21:53 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: novox/mesh-controller#58