Skip to content

fix(deploy): bundle buzz-pair-relay sidecar so mobile pairing works out of the box - #2736

Open
webdevtodayjason wants to merge 2 commits into
block:mainfrom
webdevtodayjason:fix/compose-pairing-relay
Open

fix(deploy): bundle buzz-pair-relay sidecar so mobile pairing works out of the box#2736
webdevtodayjason wants to merge 2 commits into
block:mainfrom
webdevtodayjason:fix/compose-pairing-relay

Conversation

@webdevtodayjason

@webdevtodayjason webdevtodayjason commented Jul 24, 2026

Copy link
Copy Markdown

Problem

Closes #2734.

On a self-hosted relay deployed from the official deploy/compose bundle,
mobile pairing is broken for every user out of the box: the desktop generates a
QR, the phone scans it, and the connection errors out.

Root cause is a missing sidecar plus dead-endpoint fallback:

  1. start_pairing (desktop) probes the main relay's NIP-11 for
    pairing_relay_url. The compose bundle sets no BUZZ_PAIRING_RELAY_URL, so
    the field is absent (crates/buzz-relay/src/config.rs:430nip11.rs:243).
  2. Because the relay advertises NIP-43, pairing_relay_from_nip11
    (desktop/src-tauri/src/commands/pairing.rs:469) resolves to the legacy
    /pair path
    on the main relay:
    resolve_pairing_relay_url turns wss://<domain> into wss://<domain>/pair.
  3. buzz-relay does not serve /pair — no such route — so the QR encodes a
    dead endpoint (GET /pair → 404, WS upgrade /pair → 404) and the phone
    fails to connect.

The relay image already ships the fix: /usr/local/bin/buzz-pair-relay is
present in ghcr.io/block/buzz:main; it simply was never wired into the bundle.

Fix

Bundle- and docs-only. No relay/desktop source changes.

  • Add the pair service to compose.yml using the same relay image with
    entrypoint: ["/usr/local/bin/buzz-pair-relay"] and
    BUZZ_PAIR_RELAY_BIND_ADDR=0.0.0.0:5000. The base bundle publishes it on
    BUZZ_PAIR_RELAY_PORT (default 5000) — the same convention as the relay's
    BUZZ_HTTP_PORT — and compose.caddy.yml resets the mapping
    (ports: !reset []) exactly as it already does for the relay, so in TLS mode
    Caddy's /pair route is the only way in.
    Healthcheck is a raw-TCP /dev/tcp probe: the sidecar speaks only WebSocket
    upgrades and answers a plain GET with 400 (lib.rs:943), so an HTTP
    healthcheck would never see a 200.

  • Route /pair to the sidecar in the Caddy TLS overlay (Caddyfile), via a
    named path matcher @pair path /pair /pair/*reverse_proxy pair:5000,
    with everything else falling through to relay:3000. This is the key design
    decision: the desktop's NIP-43 legacy fallback already targets
    wss://<domain>/pair, so a path proxy on the main domain makes pairing
    work with zero extra DNS and nothing for the operator to set. Verified in
    the pair-relay source that the sidecar ignores the request path entirely
    (http_service only inspects the WS-upgrade headers — lib.rs:913), so a
    path proxy needs no prefix stripping. compose.caddy.yml also gains a
    depends_on: pair (service_healthy) so Caddy waits for the sidecar.

  • Document BUZZ_PAIRING_RELAY_URL in .env.example (commented) and a new
    README "Device pairing" section, covering three operator cases:

    • TLS (compose.caddy.yml) — works out of the box via the /pair path
      proxy; nothing to set.
    • Split-domain / own proxy — set BUZZ_PAIRING_RELAY_URL=wss://pair.<domain>
      and route that name at the sidecar's published port (or, for a proxy running
      outside this compose project that keeps the port unpublished, attach it to
      the buzz-net network and target pair:5000); the relay then advertises it
      in NIP-11 and the desktop uses it directly, skipping the /pair fallback.
      Semantics match config.rs:430: must be ws:// or wss://, else the relay
      rejects it at startup.
    • Non-TLS / direct — the sidecar is already published on
      BUZZ_PAIR_RELAY_PORT; set BUZZ_PAIRING_RELAY_URL=ws://<host>:5000.

    (The first revision of this PR kept the sidecar network-internal by default.
    Operator reports below showed that made the split-domain and direct paths
    un-followable without hand-editing compose.yml, so 7a933968 switched it
    to the relay's published-by-default posture, reset by the Caddy overlay.)

Why path-proxy over a dedicated pair.<domain>

The dedicated-subdomain approach (what we ran in production tonight,
wss://pair.<domain> + BUZZ_PAIRING_RELAY_URL) works but forces every
operator to add a second DNS record and a cert. Routing /pair on the existing
domain reuses the NIP-43 legacy fallback the desktop already emits, so the
common single-domain deploy needs no extra config at all. The split-domain path
remains fully supported via the documented env var for operators who want it.

Tests / Validation

Production-verified tonight (per #2734): the equivalent pair service +
BUZZ_PAIRING_RELAY_URL=wss://pair.<domain> made NIP-11 advertise
pairing_relay_url, the desktop QR encode the working endpoint, and pairing
proceed end to end on a live self-hosted relay.

Bundle validation with a stub .env (.env.example copied, CHANGE_MEs filled
with dummies):

a. Base compose renders clean, sidecar present:

$ docker compose --env-file .env -f compose.yml config
OK base rendered
  pair:
    entrypoint: [/usr/local/bin/buzz-pair-relay]
    environment: { BUZZ_PAIR_RELAY_BIND_ADDR: 0.0.0.0:5000 }
    healthcheck: test [CMD-SHELL, bash -ec 'exec 3<>/dev/tcp/127.0.0.1/5000']
    image: ghcr.io/block/buzz:main
    restart: unless-stopped

b. Caddy overlay combo renders clean, ports reset + depends_on wired:

$ docker compose --env-file .env -f compose.yml -f compose.caddy.yml config
OK caddy overlay rendered
  caddy.depends_on: { pair: service_healthy, relay: service_healthy }
  relay.ports: (reset to empty via !reset — OK)

Dev overlay (-f compose.dev.yml) and the full three-file combo also render clean.

b2. (7a933968) port-publish follow-up: base render shows
pair.ports: 5000:5000 and honors a BUZZ_PAIR_RELAY_PORT=6789 override;
the Caddy overlay render resets both relay.ports and pair.ports to empty.
Base, Caddy, and dev combos all re-rendered clean.

c. Caddyfile validates and routes correctly (via caddy:2-alpine):

$ caddy validate --config Caddyfile --adapter caddyfile
Valid configuration
$ caddy adapt ... | routes
  /pair, /pair/*  -> pair:5000
  (default)       -> relay:3000

d. Pre-push gate (all green — the branch touches no Rust/TS, but hooks ran the
full suite anyway):

✔️ branch-skew        ✔️ desktop-check   ✔️ desktop-test
✔️ rust-tests (1626 passed)             ✔️ desktop-tauri-test

Follow-up (out of scope for this PR)

The Helm chart (squareup/block-coder-tf-stacks) has the same pairing gap — it
also ships no pair sidecar and sets no BUZZ_PAIRING_RELAY_URL. Worth a
separate change on the chart side; not bundled here to keep this diff surgical
(compose bundle + docs only).

Checklist

  • Deploy-bundle + docs only — no relay/desktop source changed
  • docker compose config renders clean for base, Caddy overlay, and dev overlay
  • caddy validate passes; /pair routes to the sidecar, default to the relay
  • New config variable (BUZZ_PAIRING_RELAY_URL) documented in .env.example + README
  • Pre-push hooks pass (clippy + unit tests + desktop + tauri)
  • No new unwrap() / unsafe (no code changes)

@webdevtodayjason
webdevtodayjason requested a review from a team as a code owner July 24, 2026 17:37
…ut of the box

The self-hosted deploy/compose bundle ran no pairing relay and advertised
none, so the desktop mobile-pairing QR fell back to the legacy /pair path on
the main relay — which does not serve /pair and returns 404, breaking pairing
on every fresh deploy. The relay image already ships /usr/local/bin/buzz-pair-relay;
it just was not wired into the bundle.

- Add a pair service to compose.yml using the same image, bound inside the
  compose network (BUZZ_PAIR_RELAY_BIND_ADDR=0.0.0.0:5000) with a TCP healthcheck
  (the sidecar speaks only WS upgrades, so a plain GET can't return 200).
- Route /pair on the main domain to the sidecar in the Caddy TLS overlay. The
  sidecar ignores the request path, so the desktop's NIP-43 legacy fallback
  (wss://<domain>/pair) works with zero extra DNS.
- Document BUZZ_PAIRING_RELAY_URL in .env.example and README.md for split-domain
  and non-TLS setups.

Closes block#2734

Signed-off-by: webdevtodayjason <jason@webdevtoday.com>
@webdevtodayjason
webdevtodayjason force-pushed the fix/compose-pairing-relay branch from eec233a to 10474f5 Compare July 24, 2026 19:28
@genesis1tech

Copy link
Copy Markdown

Independent verification — this fixes it on a real self-hosted deployment.

I hit #2734 on a self-hosted relay (ghcr.io/block/buzz:main, external reverse proxy rather than the bundled compose.caddy.yml) and worked out the same root cause and effectively the same patch before finding this PR. Deploying the equivalent config today resolved it end to end.

Before (no sidecar):

GET  /pair                -> 404
WS   upgrade /pair        -> 404
WS   upgrade /            -> 101
NIP-11 pairing_relay_url  -> absent   (43 present in supported_nips)

Desktop "pair mobile device" failed with WebSocket connection failed, HTTP error 404 — exactly the resolve_pairing_relay_urlPairingRelay::LegacyPath route described in the PR body.

After (pair sidecar + /pair routed):

WS   upgrade /pair        -> 101 Switching Protocols
WS   upgrade /            -> 101      (main relay unaffected)
NIP-11 pairing_relay_url  -> wss://<domain>/pair

Desktop pairing then completed successfully.

Two details in this PR are load-bearing, and I can confirm both the hard way:

  1. entrypoint:, not command:. I first wrote command: ["/usr/local/bin/buzz-pair-relay"], mirroring the Helm chart, where command maps to the entrypoint. In Compose it only overrides CMD, so the container silently started a second full buzz-relay against the same Postgres (its log opens with Starting buzz-relay, then a pool timeout) and the proxy got connection-refused on 5000. This PR gets it right.

  2. The /dev/tcp healthcheck. The image's own HEALTHCHECK probes the relay readiness port 8080, which the sidecar never serves, so an inherited healthcheck marks the container unhealthy forever. The raw TCP probe on 5000 is necessary, not stylistic.

One small doc gap. The README's "Split domain or your own reverse proxy" bullet is the right home for operators who don't use compose.caddy.yml, but it says to point the proxy at "the pair service (port 5000)" without noting that a proxy running outside this compose project must first be attached to the buzz network — the service publishes no host port, so there's nothing for an external proxy to reach otherwise. That's the step that actually bites. In my case the proxy is a separate Caddy shared with unrelated stacks, joined to the buzz network and routing to the sidecar's container name:

handle /pair {
    reverse_proxy <project>-pair-1:5000
}
handle {
    reverse_proxy <project>-relay-1:3000
}

One sentence would cover it: if your reverse proxy runs outside this compose project, attach it to the buzz-net network (or publish port 5000).

Not a blocker — the PR is correct as written. Worth landing: as of today every Compose-based deployment ships with mobile pairing broken.

@danvernon danvernon left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent current-main validation from a Compose self-hoster:

  • Cherry-picked 10474f5b946cfb6a4de82dfde8472f46741e4b58 cleanly onto current main at 485d03a35 (135 upstream commits ahead of the PR base).
  • docker compose config rendered successfully for the base bundle, the Caddy overlay, and the dev overlay.
  • The rendered base service uses entrypoint: [/usr/local/bin/buzz-pair-relay], binds 0.0.0.0:5000, retains the TCP healthcheck, and joins buzz-net.
  • The rendered TLS overlay makes Caddy depend on both healthy services and removes the relay's direct published port as expected.
  • git diff --check passed.

This also matches the failure I encountered on a real self-hosted deployment: the desktop selected /pair, which returned a WebSocket 404 until the pairing sidecar and scoped proxy route were added.

Limitation: Docker Desktop was not running during this fresh current-main check, so I did not rerun the Caddy container validation or a new live WebSocket smoke test here. The Compose render validation itself does not require the daemon.

No blocking issue found in the patch.

@quentindecock

Copy link
Copy Markdown

Deployed this pattern on a self-hosted instance today and hit one gap worth catching before merge.

The pair service declares neither ports: nor expose:, so it's only reachable from inside buzz-net. That works for the Caddy path, but the split-domain scenario documented in .env.example can't work as written — when TLS is terminated by an external nginx reverse proxy on another host, there's nothing for it to connect to. The non-TLS note says to "publish the pair service's port 5000", but the compose file provides no mapping or env knob, so operators have to hand-edit compose.yml.

Suggested fix, matching the BUZZ_HTTP_PORT convention already in the bundle:

    ports:
      - "${BUZZ_PAIR_RELAY_PORT:-5000}:5000"

Validation: running this (plus the port mapping) in production behind an nginx reverse proxy, TLS terminated externally, BUZZ_PAIRING_RELAY_URL=wss://pairing.<domain> routed to :5000. NIP-11 advertises the URL correctly, the WebSocket upgrade returns 101 Switching Protocols end-to-end, and mobile pairing succeeds. Without the port mapping the desktop fails at connect.

Also confirms the underlying bug in #2734: with no pairing_relay_url in NIP-11, the desktop takes the LegacyPath branch (because supported_nips includes 43) and requests /pair, which the relay never serves → bare 404.

The base bundle publishes the relay directly on BUZZ_HTTP_PORT, but kept
the pairing sidecar internal-only, so split-domain and non-TLS
deployments had to hand-edit compose.yml to route anything to it — and
those edits conflict on the next pull of the bundle. Publish the sidecar
the same way the relay is published: a BUZZ_PAIR_RELAY_PORT mapping
(default 5000) in the base compose, unpublished by compose.caddy.yml
where Caddy routes /pair internally.

Also document the network-attach option for reverse proxies running
outside this compose project, which otherwise have no way to reach the
sidecar.

Signed-off-by: webdevtodayjason <jason@webdevtoday.com>
@webdevtodayjason
webdevtodayjason force-pushed the fix/compose-pairing-relay branch from 7df5b3d to 7a93396 Compare July 29, 2026 21:57
@webdevtodayjason

Copy link
Copy Markdown
Author

Good catches, all three of you. You're right that the split domain and direct paths couldn't work as documented. The docs told you to publish a port the compose file gave you no way to publish, and hand edits to a git managed bundle get clobbered on the next pull.

Pushed 7a93396. It takes quentindecock's suggestion with one addition: the base compose publishes the sidecar on BUZZ_PAIR_RELAY_PORT (default 5000), same convention as BUZZ_HTTP_PORT, and compose.caddy.yml now resets the pair port the same way it already resets the relay's. So direct and split domain deployments get a real port out of the box and TLS mode still exposes nothing but Caddy.

Also added genesis1tech's README note. If your reverse proxy runs outside this compose project you can either hit the published port or attach the proxy to buzz-net and target pair:5000 directly.

Re-validated all three renders (base, caddy, dev), the BUZZ_PAIR_RELAY_PORT override, and that the caddy overlay resets both ports to empty. Thanks for the production reports, that's exactly the signal this needed.

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.

deploy/compose: mobile pairing broken out of the box — bundle ships no buzz-pair-relay and relay 404s the legacy /pair fallback

4 participants