Skip to content

feat(gateway): report Accepted status on each attached HTTPRoute - #7

Merged
terraboops merged 2 commits into
fix/reject-nontailnet-remoteaddrfrom
fix/route-status-conditions
Sep 27, 2026
Merged

terraboops merged 2 commits into
fix/reject-nontailnet-remoteaddrfrom
fix/route-status-conditions

Conversation

@terraboops

Copy link
Copy Markdown
Contributor

What

The controller logged a rejected:N count but never wrote status onto the HTTPRoute objects — the routeStatusCondition helper existed but was unused. So a route author couldn't tell from kubectl describe httproute whether their route was accepted or why it was refused (this made the audit's #3 verification harder, too).

Fix

After translating a Gateway's routes, set this controller's parent status on every attached route:

  • Accepted=True (reason Accepted) when the route serves;
  • Accepted=False with the translation error's reason (RefNotPermitted, UnsupportedValue, BackendNotFound, …) otherwise.

Idempotent: an unchanged condition keeps its LastTransitionTime and skips the update (no reconcile churn); a status write failure is logged, never fatal.

Test (fails without the fix)

gateway.TestReconcileWritesRouteStatus drives the real Reconcile through the fake client and asserts a good route → Accepted=True and a cross-namespace-backendRef route → Accepted=False/RefNotPermitted. Fails without the wiring (route has no Accepted condition).

Scope

Additive observability/spec-compliance; no change to which routes are served. Gateway suite (incl. the existing reconcile tests) green.

No merge — review only.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SDfMmeRXGsanqzDDSjL1uU

The controller logged a rejected-route count but never wrote status back to the
HTTPRoute, so an author could not tell from `kubectl describe httproute`
whether their route was accepted or why it was refused — the existing
routeStatusCondition helper was unused.

After translating a Gateway's routes, set this controller's parent status on
every attached route: Accepted=True when it serves, or Accepted=False with the
translation error's reason (e.g. RefNotPermitted, UnsupportedValue) otherwise.
Status writes are idempotent (unchanged conditions keep their transition time
and skip the update) and never fail the reconcile.

Regression test drives the real Reconcile through the fake client and asserts a
good route gets Accepted=True and a cross-namespace-backendRef route gets
Accepted=False/RefNotPermitted; it fails without the status wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDfMmeRXGsanqzDDSjL1uU
@terraboops
terraboops force-pushed the fix/route-status-conditions branch from 0d1f3d1 to 076c823 Compare September 27, 2026 08:52
@terraboops
terraboops changed the base branch from main to fix/reject-nontailnet-remoteaddr September 27, 2026 08:52
The allowedRoutes namespace check was per-Gateway: attachedTo admitted a route
if *any* listener's allowedRoutes permitted its namespace, while hostnamesFor
intersected the route's hostnames against *all* listeners. On a Gateway with a
From=All listener and a From=Same listener owning different hostnames, a route
from another namespace — admitted only by the From=All listener — could still
serve the From=Same listener's hostname, borrowing a name that listener would
never have admitted it for.

Evaluate the namespace policy per listener: admittingListeners returns the
listeners whose allowedRoutes admit the route's namespace, attachedTo requires
at least one, and hostnamesFor restricts the route's servable hostnames to
exactly those listeners. A route on a From=All listener still serves that
listener's hostname; it can no longer reach a stricter listener's.

Regression test builds a mixed-listener Gateway and asserts a foreign-namespace
route may serve the open listener's hostname but not the strict listener's,
while a same-namespace route may serve the strict one; it fails without the
change (the foreign route serves the strict hostname).

Closes adversarial finding B.


Claude-Session: https://claude.ai/code/session_01SDfMmeRXGsanqzDDSjL1uU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@terraboops
terraboops merged commit 87f3cce into fix/reject-nontailnet-remoteaddr Sep 27, 2026
5 checks passed
terraboops added a commit that referenced this pull request Sep 28, 2026
* fix(verifier): bound how long RemoteKeys serves a stale key while the endpoint is down

RemoteKeys served a cached key indefinitely whenever a refresh failed ("stale
but usable beats failing closed"), so a rotated or revoked key stayed trusted
for as long as the JWKS endpoint was unreachable — the cache never gave up.

Add MaxStale, an absolute staleness ceiling (default 1h, and it must not exceed
the signer's key overlap window). Within TTL a set is served freely; past TTL it
is served only until it reaches MaxStale, after which the key is refused even
though the endpoint is still down. A successful refresh resets the clock.

Regression test drives an httptest JWKS endpoint, takes it down, and asserts the
stale key still serves within the ceiling but is refused (ErrNoKey) past it, and
serves again after recovery; it fails without the ceiling (the past-ceiling key
is still served).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDfMmeRXGsanqzDDSjL1uU

* test(fuzz): fuzz the claims, key-store, route-file, and JWKS parsers

Adds no-panic fuzz targets for the untrusted-input parsers beyond the existing
token-parser fuzzer:
- FuzzClaimsUnmarshal — Claims.UnmarshalJSON (custom Extra collection) + re-marshal
- FuzzUnmarshalEntries — the shared key store a peer replica controls
- FuzzRouteFileParse — the rendered route file the data plane mounts
- FuzzJWKSToPublic — a JWKS a backend fetches from a signer, per-key jwkToPublic

Each asserts only that no input panics. Ran 20-60s per target (millions of
execs, hundreds of interesting inputs); all clean, no crashers. Seeds cover the
shapes that trip hand-written parsers (wrong types, null, truncated, non-base64,
int64 extremes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDfMmeRXGsanqzDDSjL1uU

* ci: run the parser fuzzers on every change

Wire the claims, key-store, route-file, and JWKS fuzz targets into the ci `test`
job next to the token-parser fuzzer, 20s each, so a parser that starts panicking
on hostile input is caught in CI rather than in production.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDfMmeRXGsanqzDDSjL1uU

* fix(tsnetid): refuse a request whose source is not a tailnet address

Identity is the WireGuard peer read via WhoIs on r.RemoteAddr, so the whole
model assumes the connection arrives directly from a tailnet peer. If something
terminates the connection in front of this node — an L7 proxy, or Tailscale
Funnel — r.RemoteAddr is that fronting layer, not the caller, and the request
would be attributed to it.

WhoIs already fails closed for an address that is not a proven peer, but make
the fronting case explicit and early: reject a source outside Tailscale's
address space (100.64.0.0/10 and fd7a:115c:a1e0::/48) before calling WhoIs,
with an error that names the likely cause.

This does not (and cannot) catch a fronting layer that is itself a tailnet
node; the design's rule that the proxy must be its own node, not sit behind
one, still stands (SECURITY.md). Test covers the address classification;
without the guard the helper does not exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDfMmeRXGsanqzDDSjL1uU

* ci(secrets): exclude tsnetid from the tailnet-address check

tsnetid now defines the CGNAT range for the source-address guard and uses
address literals in its tests, so the "no tailnet addresses" grep flags its own
legitimate code. Exclude the tsnetid package from the scan; the check still
guards examples, manifests and docs where a real node address does not belong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDfMmeRXGsanqzDDSjL1uU

* feat(gateway): report Accepted status on each attached HTTPRoute

The controller logged a rejected-route count but never wrote status back to the
HTTPRoute, so an author could not tell from `kubectl describe httproute`
whether their route was accepted or why it was refused — the existing
routeStatusCondition helper was unused.

After translating a Gateway's routes, set this controller's parent status on
every attached route: Accepted=True when it serves, or Accepted=False with the
translation error's reason (e.g. RefNotPermitted, UnsupportedValue) otherwise.
Status writes are idempotent (unchanged conditions keep their transition time
and skip the update) and never fail the reconcile.

Regression test drives the real Reconcile through the fake client and asserts a
good route gets Accepted=True and a cross-namespace-backendRef route gets
Accepted=False/RefNotPermitted; it fails without the status wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDfMmeRXGsanqzDDSjL1uU

* fix(gateway): evaluate allowedRoutes per matching listener

The allowedRoutes namespace check was per-Gateway: attachedTo admitted a route
if *any* listener's allowedRoutes permitted its namespace, while hostnamesFor
intersected the route's hostnames against *all* listeners. On a Gateway with a
From=All listener and a From=Same listener owning different hostnames, a route
from another namespace — admitted only by the From=All listener — could still
serve the From=Same listener's hostname, borrowing a name that listener would
never have admitted it for.

Evaluate the namespace policy per listener: admittingListeners returns the
listeners whose allowedRoutes admit the route's namespace, attachedTo requires
at least one, and hostnamesFor restricts the route's servable hostnames to
exactly those listeners. A route on a From=All listener still serves that
listener's hostname; it can no longer reach a stricter listener's.

Regression test builds a mixed-listener Gateway and asserts a foreign-namespace
route may serve the open listener's hostname but not the strict listener's,
while a same-namespace route may serve the strict one; it fails without the
change (the foreign route serves the strict hostname).

Closes adversarial finding B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDfMmeRXGsanqzDDSjL1uU

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@terraboops
terraboops deleted the fix/route-status-conditions branch September 28, 2026 02:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant