Support path-based HTTP routing and redirects in nullnet-proxy - #152
Open
antoncxx wants to merge 14 commits into
Open
Support path-based HTTP routing and redirects in nullnet-proxy#152antoncxx wants to merge 14 commits into
antoncxx wants to merge 14 commits into
Conversation
Contract stage for NullNet-ai#137 (docs/http-path-routing-design.md). No behavior change yet — nothing produces or consumes the new stream.
…ullNet-ai#137) Adds [[route]] TOML entries (host + path prefix -> service or literal redirect), validated the same way [[services]] already is (mutual exclusion, redirect status code, in-stack/http/proxy-reachable service checks), plus a cross-stack (host, path) conflict check mirroring detect_port_conflicts. build_http_route_bundle folds in an implicit {host=name, path="/"} fallback for every proxy-reachable http service with no explicit route, so installs with zero [[route]] entries keep today's plain Host-header routing unchanged. Distributed to the proxy via a new WatchHttpRoutes stream, following the existing WatchCertificates/WatchPortMappings pattern exactly (its own watch::Receiver + Notify, pushed on every services.toml change). Adds GET/POST /api/routes/{stack} for the admin UI: POST re-runs the same validation and merges the new route list into the stack's TOML file via toml_edit (new dependency) so services/comments/formatting are preserved, instead of round-tripping the whole document through serde. [[services]] is now #[serde(default)] so a stack can be routes-only (a bare redirect needs no backend at all). See docs/http-path-routing-design.md.
…ullNet-ai#137) New routes::RouteTable, kept in sync via WatchHttpRoutes exactly like tls::CertStore is kept in sync via WatchCertificates (ArcSwap, hot-swap on every push, exit-for-restart if the stream drops). request_filter resolves (host, path) before anything else: a matched redirect writes the response directly; an explicitly-uncovered path 404s; a resolved backend is stashed in a new ProxyCtx and used by upstream_peer in place of recomputing the service name from the Host header. A host with no route-table entry at all (unknown host, or the startup race before the first push arrives) falls back to today's Host-as-service-name behavior unchanged. Ingress country policy is now checked against the resolved backend rather than the raw Host header, since path routing can send a host to a different service than its own name.
…llNet-ai#137) New RoutesPage.tsx follows the Users.tsx CRUD+modal pattern (the one existing precedent for structured forms in this UI, which otherwise only offers raw-TOML editing): a table of routes with Edit/Delete per row, an Add-route modal with a Backend-service/Redirect target toggle. The service dropdown is populated from the new /api/routes/{stack} response's http_services field rather than adding protocol to the existing services list endpoint, so services.rs/Services.tsx stay untouched. Every add/edit/delete recomputes the full route array client-side and POSTs it (the API is whole-list replace, like the raw-TOML config save) so the server re-validates it exactly as a hand-edited [[route]] block would be. New nav entry under Ops, alongside Config.
Confirms end-to-end, not just by inspection: a stack file with zero [[route]] blocks (both [[services]] and [[route]] are #[serde(default)]) parses with an empty route list, and build_http_route_bundle reconstructs exactly the old Host-only dispatch for every proxy-reachable http service -- nothing for tcp/udp (unrelated port_mappings) or backend-only services.
…ullNet-ai#137) Three gaps identified after the initial routing feature landed: - backend targets never rewrote the forwarded path (no NGINX proxy_pass-trailing-slash equivalent) -- HttpRoute.strip_prefix - redirects never preserved the request's query string at all, contrary to an earlier (incorrect) doc claim that this was already implicit - redirects never preserved the matched path suffix -- HttpRedirect.preserve_path/preserve_query All three default to false, reproducing exactly today's behavior.
…ullNet-ai#137) RouteTarget::Service/Redirect carry the new fields; build_route_entries rejects each on the wrong target kind (strip_prefix on a redirect_to route, preserve_path/preserve_query on a service route) -- same style as the existing mutual-exclusion checks. build_http_route_bundle threads them through to the wire HttpRoute/HttpRedirect. Admin API (routes.rs) gains the matching JSON fields and TOML merge support. New tests: parsing + validation for all three fields, plus updates to every existing RouteTarget construction site for the new struct-variant shapes.
RouteTable::resolve now returns RouteMatch::Backend{service_name,
forward_path} and RouteMatch::Redirect{..., matched_suffix} -- routes.rs
computes the path pieces it has access to (the matched prefix), main.rs
combines them with what only it has (the request's Host header, query
string, TLS flag):
- ProxyCtx gains forward_path; upstream_request_filter applies it via a new
rewrite_uri_path helper (RequestHeader::set_uri, query string preserved)
-- only the upstream-bound request changes, not the client-facing session
or logs.
- resolve_redirect_target gained matched_suffix/preserve_path/preserve_query
params: splits any query already in the configured "to", optionally
appends the matched suffix, optionally appends the request's own query
(merged with '&', not overwritten).
Adds http = "1" as a direct dependency (RequestHeader::set_uri takes
http::Uri; previously only reachable transitively through pingora-http).
14 new unit tests across routes.rs and main.rs.
Also corrects an inaccurate earlier claim that redirect query-string passthrough was 'already implicit' -- it wasn't; resolve_redirect_target never touched the request path/query in either direction before this.
Checkboxes on the Add/Edit modal (service target gets 'strip matched prefix', redirect target gets 'preserve path suffix'/'preserve query string'), each with a one-line explainer of what it does. Routes table's target label now shows which flags are set. RouteTargetJson in types.ts updated to match the server's always-present boolean fields.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
nullnet-proxycould only route a Host to exactly one backend, keyed by theHost header itself — no way to send
example.com/apito one service andexample.com/grafanato another, and no way to configure a redirect beyondthe hardcoded HTTP→HTTPS 301. Kevin's ask was NGINX
location-block parity:route by path prefix to different backends, redirects included, so multiple
apps can share one Host without a separate NGINX in front of the proxy.
This adds a route table —
(host, path_prefix) → target, decoupled fromservice definitions — distributed to the proxy the same way certs/port-
mappings already are (
WatchHttpRoutes, mirroringWatchCertificates/WatchPortMappings: full table on subscribe, one push per config change).A target is either an existing service name (resolved through the exact
same
ProxyRPC / container-discovery / VXLAN-edge path a Host-routedservice goes through today) or a literal redirect. Within a host, the
longest matching
path_prefixwins;"/"is the catch-all.Backward compatible with no migration: every proxy-reachable
httpservicewith no explicit
[[route]]of its own gets an implicit{host = name, path = "/"}fallback synthesized server-side, so an installwith zero
[[route]]entries behaves exactly as it does today. An explicitroute for a host takes over that host's dispatch entirely — paths outside
its declared prefixes 404 instead of falling back, same as an NGINX
server{}block with no catch-alllocation /.strip_prefix/preserve_path/preserve_queryall default tofalseandare rejected on the wrong target kind (
strip_prefixneedsservice,preserve_path/preserve_queryneedredirect_to).(host, path)must beglobally unique across every stack, checked the same way
(protocol, listen_port)already is for tcp/udp — a conflict drops theoffending stacks rather than bricking the control plane, same tolerance
detect_port_conflictsalready has.Also ships an admin UI page (
Routes.tsx, under a new/routesnav entry):a table of routes with an Add/Edit modal, following the one existing
CRUD+modal precedent in this UI (
Users.tsx) since everything else here israw-TOML editing. Saves go through
GET/POST /api/routes/{stack}, whichvalidates a route the same way the TOML loader does and merges the result
back into the stack's TOML file via
toml_edit— structural editing, so[[services]]entries, comments, and formatting are untouched, unlike afull-document serde round-trip.
Ingress country policy is now checked against the resolved backend rather
than the raw Host header, since path routing can send a host to a different
service than its own name.
Manually verified end-to-end (server + proxy + a real client, real backend
containers) in addition to the unit tests below — hot-reload of route
changes propagates correctly; one "not working" symptom hit along the way
turned out to be a stale proxy binary (right commit, not rebuilt), not a
code bug.
Fixes #137.