Skip to content

deploy(compose): serve device pairing via the buzz-pair-relay sidecar - #5715

Open
derekross wants to merge 4 commits into
block:mainfrom
derekross:deploy/pairing-relay-compose
Open

deploy(compose): serve device pairing via the buzz-pair-relay sidecar#5715
derekross wants to merge 4 commits into
block:mainfrom
derekross:deploy/pairing-relay-compose

Conversation

@derekross

Copy link
Copy Markdown

Summary

Mobile QR pairing fails on compose deployments: the desktop resolves the pairing relay from the main relay's NIP-11 document, or falls back to <relay>/pair, and the compose bundle serves neither — so the phone gets a 404.

Pairing cannot run on the main relay. A device mid-pairing holds a freshly generated ephemeral key that is not a relay member yet, so BUZZ_REQUIRE_RELAY_MEMBERSHIP — the production default in .env.example — rejects it. Pointing BUZZ_PAIRING_RELAY_URL at the main relay only converts the 404 into an auth rejection. The buzz-pair-relay sidecar exists for this: no auth, no persistence, no history.

The binary already ships in the image (Dockerfile:178) and the Helm chart already deploys it (deploy/charts/buzz/templates/pairing-relay.yaml). Helm is the only shipped deployment config that does. This adds the same thing to the compose bundle.

  • compose.ymlpairing-relay service. entrypoint:, not command:: the image ENTRYPOINT is buzz-relay, so command: would pass the path as an argument to the wrong binary. Helm can use command: because k8s command maps to ENTRYPOINT.
  • Published on loopback only, via a separate BUZZ_PAIR_RELAY_HOST_IP. A same-host reverse proxy reaches it at 127.0.0.1:5000; compose.caddy.yml unpublishes it entirely.
  • Caddyfile@pairing path /pair /pair/* ahead of the catch-all.
  • compose.caddy.yml — Caddy waits on the sidecar with service_started, not service_healthy: pairing is one route and should not be able to hold the whole site down.
  • .env.exampleBUZZ_PAIRING_RELAY_URL documented but left unset. With no value the client falls back to <RELAY_URL>/pair, which is exactly what the Caddyfile serves, so the default install needs no edit.
  • run.shrestart also recreates the sidecar, which shares BUZZ_IMAGE with the relay; help text carries the diagnostic.
  • README.md — a Device pairing section: bring-your-own-proxy requirements and how to read curl /pair (400 healthy, 404 no route, 401 hitting the main relay).

Related issue

Diagnoses #5631. Same root cause as #3779, #2734, #3842, #3291.

Six open PRs already fix this, and they are all correct about the fix: #2736, #3627, #3875, #4082, #4656, #5589. The oldest has been open since 2026-07-24; none has been reviewed. This is a seventh entry in that queue, offered as a consolidation rather than a new idea, and it should be closed in favour of any of them a maintainer prefers.

Nothing in the shape of this PR is new. #2736 and #4082 already route with path /pair /pair/*#2736 as @pair + handle blocks, which is this PR's routing block character for character, #4082 the same matcher inside a route {}. #3627 already publishes the sidecar on loopback only. #2736 and #3627 already leave BUZZ_PAIRING_RELAY_URL commented out. Every individual decision here was already made correctly by someone in this queue; what is new is that they have not been made together, and that some of them are now checked on the wire.

Where the six differ, and what this PR picks:

Two things worth fixing wherever this lands, offered as help rather than as a comparison:

  • fix(compose): wire buzz-pair-relay so mobile QR pairing works #3875's healthcheck is bash -ec exec 3<>/dev/tcp/127.0.0.1:5000 — a colon where bash needs a slash. Run verbatim it gives No such file or directory, so the container never reports healthy; combined with that PR's condition: service_healthy, Caddy never starts. One character. Separately, its BUZZ_PAIRING_RELAY_URL: ${BUZZ_PAIRING_RELAY_URL:?set BUZZ_PAIRING_RELAY_URL} makes the variable mandatory, so docker compose config fails for every existing .env.
  • fix(compose): restore mobile pairing sidecar #3627 keeps the service behind --profile pairing, so a default deploy still 404s — worth reconsidering if that PR is the one that lands, since the bug is that pairing does not work out of the box.

Testing

Verified on a live compose deployment (Docker 24.0.5, Compose 2.20.2) and a throwaway stack on the same host.

In production. This sidecar shape has served device pairing on a real relay since 2026-08-12 — behind nginx rather than Caddy — with a phone paired successfully through it.

Handshake through this PR's Caddyfile, throwaway stack, buzz-pair source and buzz-pair target from crates/buzz-pairing-cli:

SOURCE                        TARGET
Offer received from target.   Offer sent. Waiting for source...
SAS code: 849190              SAS code: 849190
Sending identity...           Received nsec payload!
Transfer complete! ✓          Transfer complete! ✓

Routing, same stack, with a 404-returning stub behind the catch-all:

                                  /pair  /pair/  /pairfoo   /
@pairing path /pair /pair/*        400    400      404     404
handle_path /pair*                 400    400      400     404
handle /pair                       400    404      404     404

400 = reached the sidecar, 404 = reached the stub. 400 is the correct answer to a non-WebSocket request: the sidecar serves no NIP-11 document and rejects anything that is not an upgrade (crates/buzz-pair-relay/src/lib.rs).

Config. docker compose config rc=0 for base, +compose.caddy.yml and +compose.dev.yml; caddy validate returns Valid configuration; bash -n run.sh clean. Rendered output confirms entrypoint: [/usr/local/bin/buzz-pair-relay], the sidecar bound to 127.0.0.1:5000, its port removed under the Caddy overlay, and no BUZZ_PAIRING_RELAY_URL on the relay.

Not tested: TLS with a real certificate — the throwaway Caddy ran on plain HTTP with no DNS. Caddy terminates TLS above the routing layer, so this should not affect /pair matching or the upgrade, but it is unproven here.

No UI change.

The Helm chart deploys buzz-pair-relay (deploy/charts/buzz/templates/
pairing-relay.yaml); the compose bundle does not. Compose deployments
therefore have nothing listening on /pair, so device pairing 404s out of
the box even though the binary is already in the image (Dockerfile:171).

Pairing cannot be served by the main relay: a device mid-pairing holds a
fresh ephemeral key that is not a relay member yet, so it is refused by
the BUZZ_REQUIRE_RELAY_MEMBERSHIP auth gate. The sidecar exists because
it has no auth, no persistence and no history.

- compose.yml: pairing-relay service, loopback-published, TCP healthcheck.
  entrypoint (not command) because the image ENTRYPOINT is buzz-relay.
- Caddyfile: route /pair* to the sidecar ahead of the catch-all.
- compose.caddy.yml: reset the published port when Caddy fronts it.
- .env.example: document BUZZ_PAIR_RELAY_PORT and BUZZ_PAIRING_RELAY_URL.

Verified: `docker compose config` renders for the base and Caddy overlay,
`caddy validate` passes, and a full buzz-pair source/target handshake
(SAS match + payload transfer) completes against this sidecar running on
a live compose deployment.

Signed-off-by: Derek Ross <derekross@gmail.com>
Follow-up to the pairing-relay service, addressing the gaps found by
comparing against the other open submissions for this problem.

README.md gains a Device pairing section: why the handshake cannot run on
the membership-gated main relay, what to set BUZZ_PAIRING_RELAY_URL to,
what a bring-your-own-proxy deployment has to provide, and how to read the
diagnostic — a bare 400 on /pair means the sidecar is answering, since it
serves no NIP-11 document, while 404 means no route and 401 means /pair is
reaching the main relay.

The Caddy route becomes an explicit `path /pair /pair/*` matcher. A
`/pair*` prefix (what this branch had) and `handle_path /pair*` both also
capture /pairfoo, and a bare `handle /pair` misses the trailing slash;
all four shapes were driven with real handshakes to confirm.

BUZZ_PAIR_RELAY_PORT stays loopback-only and now says why: the sidecar has
no auth and no membership check by design, so a public bind is not an
acceptable default. The README documents the override and its cost.

BUZZ_PAIRING_RELAY_URL is set in .env.example alongside RELAY_URL rather
than commented out, matching the file's existing convention of shipping
buzz.example.com placeholders. The relay picks it up through its env_file,
so no entry in the relay's environment block is needed.

run.sh restart also recreates pairing-relay — it shares BUZZ_IMAGE with the
relay, so restarting only the relay left the sidecar on a stale image — and
the help text carries the pairing summary and the curl check.

Signed-off-by: Derek Ross <derekross@gmail.com>
Three corrections to the pairing sidecar defaults.

BUZZ_PAIRING_RELAY_URL goes back to commented-out. A client reads the
relay's NIP-11 document and prefers pairing_relay_url when present; with
no value it falls back to <RELAY_URL>/pair, which is exactly the route the
bundled Caddyfile serves (desktop/src-tauri/src/commands/pairing.rs,
pairing_relay_from_nip11 / resolve_pairing_relay_url). NIP-43 is
advertised whenever the relay has a stable key and enforces membership —
both defaults here — so the fallback is reached by construction and the
default install needs no edit. Shipping the value uncommented meant an
operator who changes BUZZ_DOMAIN and misses this line advertises a
pairing endpoint on someone else's domain, silently, where the handshake
payload is a private key.

BUZZ_PAIR_RELAY_PORT splits into BUZZ_PAIR_RELAY_HOST_IP + a bare port.
Every other *_PORT in .env.example is a number, so an operator moving the
port would have written 5001 and silently published on 0.0.0.0, losing
the loopback property the sidecar depends on for safety.

compose.caddy.yml gates Caddy on the sidecar with service_started rather
than service_healthy. Pairing is one route; an unhealthy sidecar should
not keep messaging, media and git from coming up.

Signed-off-by: Derek Ross <derekross@gmail.com>
…ndition

The "Checking it works" section led with a grep for pairing_relay_url in
NIP-11. That field is skip_serializing_if = "Option::is_none", so on the
default install — where BUZZ_PAIRING_RELAY_URL is deliberately unset — it
is absent, and the first check in the README prints nothing and exits 1 on
a correctly configured stack. The /pair status probe is now the primary
check; the NIP-11 grep is demoted to the case where the variable is set,
with empty output called out as correct otherwise.

Also state the precondition behind "nothing to configure": clients only
fall back to <RELAY_URL>/pair when the relay advertises NIP-43, which
needs a stable relay key and BUZZ_REQUIRE_RELAY_MEMBERSHIP=true. Both are
defaults here, but an operator running an open relay gets a different
resolution path and does not need the sidecar at all.

Signed-off-by: Derek Ross <derekross@gmail.com>
@derekross
derekross requested a review from a team as a code owner August 12, 2026 23:24
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.

1 participant