Skip to content

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

Open
webdevtodayjason wants to merge 1 commit 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 1 commit into
block:mainfrom
webdevtodayjason:fix/compose-pairing-relay

Conversation

@webdevtodayjason

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. It binds inside the compose network
    only — matching the sidecar's own security model, which documents it as
    loopback-only behind a reverse proxy (crates/buzz-pair-relay/src/lib.rs:9).
    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 pair service; 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 stays network-internal by default;
      publish port 5000 and set BUZZ_PAIRING_RELAY_URL=ws://<host>:5000, or use
      TLS mode. Documented rather than exposing an unauthenticated port by default,
      consistent with the sidecar's loopback-only posture.

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.

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
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

1 participant