Skip to content

feat(relay): serve the relay under an optional BUZZ_BASE_PATH prefix - #3372

Open
kcao-gss wants to merge 1 commit into
block:mainfrom
kcao-gss:feat/relay-base-path
Open

feat(relay): serve the relay under an optional BUZZ_BASE_PATH prefix#3372
kcao-gss wants to merge 1 commit into
block:mainfrom
kcao-gss:feat/relay-base-path

Conversation

@kcao-gss

@kcao-gss kcao-gss commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Closes #3354.

Why

buzz-relay mounted every route at the root, so it could not be deployed behind a gateway that routes to apps by path rather than by hostname. That shape is common in shared-ingress Kubernetes setups: each app is assigned a path prefix, auth policy is applied per prefix, and the root belongs to the gateway rather than to any one app. A relay that can only serve at / has nowhere to go.

Compose behind a dedicated hostname works, so this is not a blocker for every self-hoster — but a path-routed ingress previously left no option except a fork.

What

BUZZ_BASE_PATH, empty by default. Unset, the router is byte-identical to before. When set, the whole surface nests under the prefix — the WebSocket and NIP-11 handshake answer on wss://host/<prefix>, the HTTP routes live beneath it. nest strips the prefix before the inner router sees the request, so route matches and the SPA fallback's own path checks keep working unchanged.

Health probes stay mounted at the root as well: Kubernetes reaches the pod directly rather than through the gateway that needs the prefix, and deploy/compose curls /_liveness on the published port.

The prefix is normalized at load time (one leading slash, no trailing slash — relay, /relay, /relay/ all become /relay). Values that would make the server's URL reconstruction differ from the client's connect URL — query, fragment, whitespace, dot segments, empty interior segments — fail startup rather than surfacing later as an unexplainable 401.

The part that needed care

The prefix has to reach every place a URL is rebuilt from the request host, because each one is signed and compared byte-for-byte:

Site Signed value
nip98_expected_url the u tag for bridge, invite, and moderation calls — one choke point for all of them
nip42_expected_relay_url the WebSocket AUTH relay tag
authorize_operator_request the operator API's expected URL
NIP-11 push.origin dialed verbatim by push clients

Miss one and it reads as a signature bug, not a routing bug.

Client-side sweep

Three places assumed the relay owned the origin root:

  • buzz-cli rejected a prefixed media URL as "not a /media/ path"; it now strips the relay URL's own path before the check. The sha-shorthand form was already correct.
  • Desktop keyed the Download affordance on a root-anchored /media/ pathname. Now matches /media/ as a full path segment anywhere, still origin-pinned, so /media-evil/<hash> is still rejected.
  • Mobile used Uri.resolve('/query') in three places, which discards a base path outright. These now go through a shared relayEndpoint helper.

One existing mobile test had codified the prefix-dropping behavior against a base URL of https://relay.example/base. Updated — and its NIP-98 u tag assertion now reads https://relay.example/base/query, which is the cross-check that the client and the relay agree on the prefixed URL.

Verified clean: relayHttpFromWs (desktop) is a pure scheme swap and normalize_relay_url (CLI) is a scheme swap plus trailing-slash strip, so both carry a prefix through already.

Known limitation

The browser web bundle (BUZZ_WEB_DIR) references its assets at absolute /assets/... URLs baked in at build time, so serving that SPA under a prefix additionally requires rebuilding it with a matching Vite base. Documented on the config field. The protocol surface — WebSocket, bridge, media, git — is unaffected.

Testing

17 new tests: prefix normalization and rejection, the default-empty path, router resolution under single- and multi-segment prefixes, root passthrough for probes, 404 for unprefixed application paths, both signed-URL builders, the push descriptor, the CLI media URL, and the mobile endpoint helper.

Suites run: buzz-relay 811 lib tests, buzz-cli 253, desktop 3733, mobile 874. cargo fmt --check, clippy on both crates, flutter analyze, biome lint, and cargo check --workspace --all-targets all clean.

Nine relay tests and one mobile test fail in my environment; I confirmed each one fails identically on a clean tree. The relay ones are Sqlx(PoolTimedOut) — they need Postgres, which I did not have running.

Compatibility

BUZZ_BASE_PATH unset is the default and changes nothing: same routes, same signed-URL strings, same NIP-11 document. Operators opting in must set the same prefix on RELAY_URL and BUZZ_MEDIA_BASE_URL.

The relay mounted every route at the root, so it could not be deployed
behind a gateway that routes to apps by path rather than by hostname.
Serving a WebSocket at `/` is not an option on such a platform, which
left self-hosters with a path-routing ingress no choice but a fork.

Add `BUZZ_BASE_PATH`, empty by default. When set, the whole surface nests
under the prefix: the WebSocket and NIP-11 handshake answer on
`wss://host/<prefix>` and the HTTP routes live beneath it. Health probes
stay mounted at the root as well, since Kubernetes reaches the pod
directly rather than through the gateway.

The prefix is normalized at load time (one leading slash, no trailing
slash) and values that would make the server's URL reconstruction differ
from the client's connect URL — query, fragment, whitespace, dot or empty
segments — fail startup rather than surfacing later as an unexplainable
401.

Threaded through every place a URL is rebuilt from the request host, since
each one is signed and compared:

- `nip98_expected_url` — the single choke point for bridge, invite,
  and moderation `u` tags
- `nip42_expected_relay_url` — the WebSocket AUTH `relay` tag
- the operator API's expected URL
- the NIP-11 `push.origin` descriptor, which push clients dial verbatim

Client-side, three places assumed the relay owned the origin root:
`buzz-cli` rejected prefixed media URLs as non-media, the desktop
Download affordance keyed on a root-anchored `/media/` path, and mobile
used `Uri.resolve('/query')`, which discards a base path. Mobile now
joins endpoints through a shared `relayEndpoint` helper.

Known limitation, documented on the config field: the browser web bundle
bakes absolute `/assets/...` URLs at build time, so serving that SPA under
a prefix also needs a matching Vite `base`. The protocol surface is
unaffected.

Closes block#3354

Signed-off-by: Kyler Cao <kcao@gssmail.com>
@kcao-gss

Copy link
Copy Markdown
Contributor Author

CI here needs a maintainer to approve the workflow run (first-time fork contributor). To save you that round trip, I ran the same workflows on my fork: kcao-gss#1 — 22 of 23 CI jobs green.

Green there, including everything I couldn't run locally:

  • Relay E2E, Backend Integration (relay e2e) — the Postgres + Redis suites
  • Desktop E2E Relay, both Desktop E2E Integration shards, all 4 Desktop Smoke E2E
  • Rust Lint, Unit Tests, Security, Windows Rust, Mobile, Desktop, Desktop Core, Web
  • Both musl server cross-compiles

Two jobs fail on the fork for permission reasons that won't reproduce here:

  1. Desktop Build (macOS)mesh-llm checkout for 43103c5 not found after cargo fetch. The private dep isn't fetchable with a fork token.
  2. Docker image — the image builds, then exporting cache to registry hits denied: permission_denied writing ghcr.io/block/buzz-buildcache. cache-to is gated to same-repo, but the export still targets the org registry.

Neither touches code paths in this PR.

Locally I also ran the relay lib suite against a live Postgres: 778 passed, 0 failed.

Happy to rebase or split this if the shape isn't what you want before you spend CI minutes on it.

@kcao-gss

Copy link
Copy Markdown
Contributor Author

Per CONTRIBUTING, the closest existing work I could find — I'd missed this line when I opened the PR.

Closest open PR: #2527 ("relay: support host aliases so in-cluster clients can bind to a community"). Same family of problem — making the relay reachable in a deployment where the public hostname doesn't apply — but the orthogonal axis. #2527 widens which hosts map to a community; this PR changes which path the relay answers on. No functional overlap, and no file overlap except crates/buzz-relay/src/api/operator.rs, where the two changes sit in different places: #2527 extends alias resolution, this adds the base-path prefix to the operator API's expected NIP-98 URL. A trivial rebase either way.

Worth noting the two compose: a deployment could use aliases to reach a community by internal DNS and a base path to sit behind a path-routing gateway.

Also adjacent but not overlapping: #2736 (bundles the pair-relay sidecar for deploy) and #2617 (documents BUZZ_CORS_ORIGINS in .env.example) — both deploy-config work that touches neither routing nor signed-URL construction.

No duplicate of this change found in open PRs or issues.

wpfleger96 pushed a commit that referenced this pull request Jul 28, 2026
…res (#3396)

## Problem

The prerequisites table lists language toolchains (Rust, Node, pnpm,
Flutter, Docker, `just`) but no system libraries. Hermit pins the former
and not the latter, so following the setup section exactly on Linux
still leaves `just ci` unable to run: it fails partway through its first
dependency, `just check`, at `desktop-tauri-clippy`.

```
The system library `gdk-pixbuf-2.0` required by crate `gdk-pixbuf-sys` was not found.
The file `gdk-pixbuf-2.0.pc` needs to be installed and the PKG_CONFIG_PATH environment variable must contain its parent directory.
```

The desktop crates link against GTK and WebKitGTK. CI installs those
packages explicitly, so it never sees this — which is exactly why the
gap is invisible from the maintainer side. Since `check` runs first in
the `ci` chain, the failure also masks everything after it (`test-unit`,
`desktop-test`, `web-build`, `mobile-test` never run), which makes it
read as a broken repo rather than a missing dependency.

## Change

Adds a `#### Linux: Tauri system libraries` subsection under
Prerequisites with:

- The apt list copied from `.github/workflows/ci.yml`, so a local run
matches CI rather than drifting from it
- A pointer to [Tauri's
prerequisites](https://tauri.app/start/prerequisites/) for non-Debian
distributions
- A note that server-side contributors can skip it — `just fmt-check`,
`just clippy`, `just test-unit`, and `just test` need no GTK

Docs only. No TOC entry needed, since the TOC lists `##` headings and
this is a `####` subsection.

## How I hit it

Running `just ci` before pushing #3372, on Ubuntu under WSL2 with the
Hermit toolchain active and all Docker services healthy. Everything the
guide asks for was in place. The four `check` steps before
`desktop-tauri-clippy` (`fmt-check`, `clippy`, `desktop-check`,
`desktop-tauri-fmt-check`) passed, which is what makes the failure point
specific rather than a general build problem.

## Closest existing work

None found. I searched open and closed issues and PRs for `gdk-pixbuf`,
`libgtk`, `webkit2gtk`, `system dependencies`, `prerequisites`, `just
ci`, and `linux setup`. The Linux/GTK issues that exist (#2604, #2643,
#2982, #2811, #2562) are all runtime bugs in shipped builds, not
setup-path failures.

## Verification

The package list is transcribed from `.github/workflows/ci.yml:152-163`;
the same list appears in `release.yml` and `linux-canary.yml`. I have
not installed the packages on my machine, so I can confirm the failure
and the source of the fix but not that the list is exhaustive on a clean
box — worth a second pair of eyes from anyone who has done a fresh Linux
setup recently.

Signed-off-by: Kyler Cao <kcao@gssmail.com>
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.

Relay cannot be served under a path prefix — hardcoded root routes block deployment behind a path-routing gateway

1 participant