Skip to content

Support path-based HTTP routing and redirects in nullnet-proxy - #152

Open
antoncxx wants to merge 14 commits into
NullNet-ai:mainfrom
antoncxx:feature/proxy-redirects-137
Open

Support path-based HTTP routing and redirects in nullnet-proxy#152
antoncxx wants to merge 14 commits into
NullNet-ai:mainfrom
antoncxx:feature/proxy-redirects-137

Conversation

@antoncxx

@antoncxx antoncxx commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

nullnet-proxy could only route a Host to exactly one backend, keyed by the
Host header itself — no way to send example.com/api to one service and
example.com/grafana to another, and no way to configure a redirect beyond
the 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 from
service definitions — distributed to the proxy the same way certs/port-
mappings already are (WatchHttpRoutes, mirroring WatchCertificates/
WatchPortMappings: full table on subscribe, one push per config change).
A target is either an existing service name (resolved through the exact
same Proxy RPC / container-discovery / VXLAN-edge path a Host-routed
service goes through today) or a literal redirect. Within a host, the
longest matching path_prefix wins; "/" is the catch-all.

Backward compatible with no migration: every proxy-reachable http service
with no explicit [[route]] of its own gets an implicit
{host = name, path = "/"} fallback synthesized server-side, so an install
with zero [[route]] entries behaves exactly as it does today. An explicit
route 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-all location /.

[[route]]
host = "ops.example.com"
path = "/api"
service = "api"
strip_prefix = true        # backend sees /users, not /api/users

[[route]]
host = "old.example.com"
path = "/old"
redirect_to = "/new"
redirect_status = 301      # default
preserve_path = true       # /old/x/y -> /new/x/y
preserve_query = true      # ?foo=bar carried through

strip_prefix/preserve_path/preserve_query all default to false and
are rejected on the wrong target kind (strip_prefix needs service,
preserve_path/preserve_query need redirect_to). (host, path) must be
globally unique across every stack, checked the same way
(protocol, listen_port) already is for tcp/udp — a conflict drops the
offending stacks rather than bricking the control plane, same tolerance
detect_port_conflicts already has.

Also ships an admin UI page (Routes.tsx, under a new /routes nav 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 is
raw-TOML editing. Saves go through GET/POST /api/routes/{stack}, which
validates 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 a
full-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.

antoncxx added 14 commits August 7, 2026 12:54
)

Proposes a host+path route table, decoupled from service definitions,
distributed to nullnet-proxy the same way certs/port-mappings already are.
Covers proto, server config/validation, proxy dispatch, and an admin UI
editor. No code changes yet — design for review before implementation.
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.
@antoncxx antoncxx changed the title Feature/proxy redirects 137 Support path-based HTTP routing and redirects in nullnet-proxy Aug 7, 2026
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.

Proxy should support configuring redirects

1 participant