From cc6fbefc91a8cd287e1dae78f68455fc213ddea3 Mon Sep 17 00:00:00 2001 From: Derek Ross Date: Wed, 12 Aug 2026 14:57:06 -0400 Subject: [PATCH 1/4] deploy(compose): ship the NIP-AB pairing relay sidecar 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 --- deploy/compose/.env.example | 9 +++++++++ deploy/compose/Caddyfile | 4 ++++ deploy/compose/compose.caddy.yml | 5 +++++ deploy/compose/compose.yml | 26 ++++++++++++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index f6ab4fcab97..d3957e7d7eb 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -39,6 +39,15 @@ BUZZ_S3_ADDRESSING_STYLE=path # Optional host ports. Base compose publishes the relay directly on BUZZ_HTTP_PORT. BUZZ_HTTP_PORT=3000 +# Device pairing (NIP-AB). The pairing-relay sidecar is loopback-only by +# default; your reverse proxy must route /pair to it with a WebSocket upgrade. +# Set the advertised URL so the relay publishes it in NIP-11 — without it, +# clients fall back to the legacy /pair convention. +# Left commented so it does not trip run.sh's CHANGE_ME check; uncomment and +# set it to your own domain. +BUZZ_PAIR_RELAY_PORT=127.0.0.1:5000 +#BUZZ_PAIRING_RELAY_URL=wss://your.domain/pair + # Caddy host ports. Only used with compose.caddy.yml. CADDY_HTTP_PORT=80 CADDY_HTTPS_PORT=443 diff --git a/deploy/compose/Caddyfile b/deploy/compose/Caddyfile index 205cf4c5bc9..068c04370e4 100644 --- a/deploy/compose/Caddyfile +++ b/deploy/compose/Caddyfile @@ -1,5 +1,9 @@ {$BUZZ_DOMAIN} { encode zstd gzip + # Device pairing (NIP-AB) goes to the auth-less sidecar, not the main relay: + # a device mid-pairing is not a relay member yet and would be refused. + reverse_proxy /pair* pairing-relay:5000 + reverse_proxy relay:3000 } diff --git a/deploy/compose/compose.caddy.yml b/deploy/compose/compose.caddy.yml index c7dcbf106cc..4dde79bb66e 100644 --- a/deploy/compose/compose.caddy.yml +++ b/deploy/compose/compose.caddy.yml @@ -2,11 +2,16 @@ services: relay: ports: !reset [] + pairing-relay: + ports: !reset [] + caddy: image: caddy:2-alpine depends_on: relay: condition: service_healthy + pairing-relay: + condition: service_healthy environment: BUZZ_DOMAIN: ${BUZZ_DOMAIN:?set BUZZ_DOMAIN} ports: diff --git a/deploy/compose/compose.yml b/deploy/compose/compose.yml index 15337c92a27..a26aafcd676 100644 --- a/deploy/compose/compose.yml +++ b/deploy/compose/compose.yml @@ -102,6 +102,32 @@ services: networks: - buzz-net + # Ephemeral NIP-AB device-pairing relay (kind 24134). Separate from the main + # relay on purpose: a device mid-pairing holds a fresh ephemeral key that is + # not a relay member yet, so it cannot pass BUZZ_REQUIRE_RELAY_MEMBERSHIP. + # No persistence, no auth, no history. Published on loopback only; the reverse + # proxy terminates TLS and routes /pair here (see Caddyfile). + pairing-relay: + image: ${BUZZ_IMAGE:-ghcr.io/block/buzz:main} + # The image ENTRYPOINT is buzz-relay, so this must override entrypoint, not + # command. Compose `command:` maps to CMD and would pass args to buzz-relay. + # (Helm can use command: because k8s command maps to ENTRYPOINT.) + entrypoint: ["/usr/local/bin/buzz-pair-relay"] + environment: + BUZZ_PAIR_RELAY_BIND_ADDR: 0.0.0.0:5000 + ports: + - "${BUZZ_PAIR_RELAY_PORT:-127.0.0.1:5000}:5000" + # Same /dev/tcp trick as the relay: the runtime image has bash but no curl. + healthcheck: + test: ["CMD-SHELL", "bash -ec 'exec 3<>/dev/tcp/127.0.0.1/5000'"] + interval: 10s + timeout: 3s + retries: 6 + start_period: 5s + restart: unless-stopped + networks: + - buzz-net + minio-init: image: minio/mc:RELEASE.2025-08-13T08-35-41Z depends_on: From 03ebcfbc813d71d1b7a3e723a186a4dacb92b9eb Mon Sep 17 00:00:00 2001 From: Derek Ross Date: Wed, 12 Aug 2026 17:11:17 -0400 Subject: [PATCH 2/4] docs(compose): document device pairing and harden the sidecar defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- deploy/compose/.env.example | 17 ++++++----- deploy/compose/Caddyfile | 17 +++++++++-- deploy/compose/README.md | 61 +++++++++++++++++++++++++++++++++++++ deploy/compose/compose.yml | 11 +++++-- deploy/compose/run.sh | 13 +++++++- 5 files changed, 106 insertions(+), 13 deletions(-) diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index d3957e7d7eb..76187e1e9f8 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -8,6 +8,11 @@ BUZZ_IMAGE=ghcr.io/block/buzz:main # Public host name. Used by compose.caddy.yml and URL-derived settings below. BUZZ_DOMAIN=buzz.example.com RELAY_URL=wss://buzz.example.com +# Where devices run the NIP-AB pairing handshake. Advertised in the relay's +# NIP-11 document; must be ws:// or wss:// or the relay refuses to start. This +# default matches the /pair route in the Caddyfile. See README.md § Device +# pairing for the bring-your-own-proxy case. +BUZZ_PAIRING_RELAY_URL=wss://buzz.example.com/pair BUZZ_MEDIA_BASE_URL=https://buzz.example.com/media BUZZ_MEDIA_SERVER_DOMAIN=buzz.example.com BUZZ_CORS_ORIGINS=https://buzz.example.com @@ -39,14 +44,12 @@ BUZZ_S3_ADDRESSING_STYLE=path # Optional host ports. Base compose publishes the relay directly on BUZZ_HTTP_PORT. BUZZ_HTTP_PORT=3000 -# Device pairing (NIP-AB). The pairing-relay sidecar is loopback-only by -# default; your reverse proxy must route /pair to it with a WebSocket upgrade. -# Set the advertised URL so the relay publishes it in NIP-11 — without it, -# clients fall back to the legacy /pair convention. -# Left commented so it does not trip run.sh's CHANGE_ME check; uncomment and -# set it to your own domain. +# Pairing sidecar host port. Loopback by default: buzz-pair-relay has no auth +# and no membership check by design, so it must not face the internet directly. +# A reverse proxy on this host reaches it at 127.0.0.1:5000. compose.caddy.yml +# unpublishes it entirely. Change to 0.0.0.0:5000 only if something off-box has +# to reach it without a proxy, and read README.md § Device pairing first. BUZZ_PAIR_RELAY_PORT=127.0.0.1:5000 -#BUZZ_PAIRING_RELAY_URL=wss://your.domain/pair # Caddy host ports. Only used with compose.caddy.yml. CADDY_HTTP_PORT=80 diff --git a/deploy/compose/Caddyfile b/deploy/compose/Caddyfile index 068c04370e4..a546fb3849d 100644 --- a/deploy/compose/Caddyfile +++ b/deploy/compose/Caddyfile @@ -2,8 +2,19 @@ encode zstd gzip # Device pairing (NIP-AB) goes to the auth-less sidecar, not the main relay: - # a device mid-pairing is not a relay member yet and would be refused. - reverse_proxy /pair* pairing-relay:5000 + # a device mid-pairing holds a fresh ephemeral key that is not a relay member + # yet, so BUZZ_REQUIRE_RELAY_MEMBERSHIP would refuse it. + # + # Match the two paths a client actually uses and nothing else. A `/pair*` + # prefix match, or `handle_path /pair*`, also swallows `/pairfoo` and any + # future relay route starting with "pair"; `handle /pair` on its own misses + # the trailing slash. Caddy proxies the WebSocket upgrade transparently. + @pairing path /pair /pair/* + handle @pairing { + reverse_proxy pairing-relay:5000 + } - reverse_proxy relay:3000 + handle { + reverse_proxy relay:3000 + } } diff --git a/deploy/compose/README.md b/deploy/compose/README.md index bb0e63fe15d..a7af4aea826 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -46,6 +46,67 @@ keypair. Run `./run.sh backup-hint` for the backup checklist. +## Device pairing + +Mobile QR pairing (NIP-AB, kind 24134) runs through a **separate** relay, and it +has to. A device mid-pairing holds a freshly generated ephemeral key that is not +a relay member yet, so `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true` — the production +default in `.env.example` — makes the main relay reject it outright. The +`buzz-pair-relay` sidecar exists for exactly this: no auth, no persistence, no +history, just an in-flight match between two kind-24134 subscriptions. + +The binary already ships inside the relay image, so this bundle runs it as the +`pairing-relay` service. Two things have to line up: + +1. **A route to the sidecar.** With `BUZZ_COMPOSE_TLS=true` the bundled + `Caddyfile` routes `/pair` and `/pair/*` to `pairing-relay:5000`; everything + else still goes to the relay. +2. **An advertised URL.** `BUZZ_PAIRING_RELAY_URL` is published in the relay's + NIP-11 document, and clients use it directly. Leave it at + `wss:///pair` to match the Caddy route. The value must be + `ws://` or `wss://`; the relay refuses to start otherwise. + +### Bringing your own reverse proxy + +The sidecar is published on **loopback only** (`BUZZ_PAIR_RELAY_PORT`, default +`127.0.0.1:5000`). It performs no authentication and enforces no membership, so +putting it on a public interface would expose an unauthenticated WebSocket +endpoint to the internet — don't. A proxy running on the same host reaches it at +`127.0.0.1:5000`; a proxy in another container can join `buzz-net` and use +`pairing-relay:5000` instead. + +Whatever proxy you use, it must terminate TLS, route only `/pair`, pass the +WebSocket upgrade headers, and keep read timeouts tight — the sidecar caps each +connection at 120 seconds itself and delegates slowloris protection to the proxy +(see `crates/buzz-pair-relay/src/lib.rs`). Then point `BUZZ_PAIRING_RELAY_URL` at +whatever public URL that proxy serves. + +If something genuinely off-box must reach the sidecar without a proxy, set +`BUZZ_PAIR_RELAY_PORT=0.0.0.0:5000` — but note that pairing then runs +unencrypted over `ws://`, which iOS will refuse, and the endpoint is open to +anyone who can reach the port. + +### Checking it works + +```bash +# Is the URL advertised? +curl -sS -H 'Accept: application/nostr+json' "https://" \ + | grep -o '"pairing_relay_url":"[^"]*"' + +# Is something answering on /pair? +curl -sS -o /dev/null -w "%{http_code}\n" "https:///pair" +``` + +A bare **400** is the healthy answer: the sidecar serves no NIP-11 document and +rejects any request that is not a WebSocket upgrade. **404** means no route or +no sidecar — pairing will fail. **401/403**, or a socket that closes while the +desktop waits for EOSE, means `/pair` is reaching the *main* relay, which is +refusing the not-yet-member device. + +Note that the sidecar requires a `#p` filter: a `REQ` for `kinds:[24134]` +without one is closed with `#p filter required`. Real clients always send it; +this only surprises people probing by hand. + ## Validation Before sharing an install link publicly, verify a fresh install with: diff --git a/deploy/compose/compose.yml b/deploy/compose/compose.yml index a26aafcd676..42609f0f16b 100644 --- a/deploy/compose/compose.yml +++ b/deploy/compose/compose.yml @@ -105,8 +105,15 @@ services: # Ephemeral NIP-AB device-pairing relay (kind 24134). Separate from the main # relay on purpose: a device mid-pairing holds a fresh ephemeral key that is # not a relay member yet, so it cannot pass BUZZ_REQUIRE_RELAY_MEMBERSHIP. - # No persistence, no auth, no history. Published on loopback only; the reverse - # proxy terminates TLS and routes /pair here (see Caddyfile). + # No persistence, no auth, no history. + # + # Published on loopback only. It has no auth by design, so exposing it on a + # public interface is not an acceptable default; a reverse proxy on this host + # reaches it at 127.0.0.1:5000 and compose.caddy.yml unpublishes it entirely. + # Override with BUZZ_PAIR_RELAY_PORT — see README.md § Device pairing. + # + # The relay advertises BUZZ_PAIRING_RELAY_URL from .env via its env_file, so + # this service needs no wiring into the relay's environment block. pairing-relay: image: ${BUZZ_IMAGE:-ghcr.io/block/buzz:main} # The image ENTRYPOINT is buzz-relay, so this must override entrypoint, not diff --git a/deploy/compose/run.sh b/deploy/compose/run.sh index d5465ea1f5d..e25883c8bae 100755 --- a/deploy/compose/run.sh +++ b/deploy/compose/run.sh @@ -60,7 +60,9 @@ case "${1:-help}" in ;; restart) require_env - compose up -d --wait --force-recreate relay + # pairing-relay too: it shares BUZZ_IMAGE with the relay, so restarting only + # the relay after an image or .env change silently leaves the sidecar stale. + compose up -d --wait --force-recreate relay pairing-relay ;; pull) require_env @@ -122,7 +124,16 @@ Commands: Environment switches: BUZZ_COMPOSE_TLS=true Include compose.caddy.yml for automatic HTTPS + (also routes /pair to the device-pairing sidecar) BUZZ_COMPOSE_DEV=true Include compose.dev.yml for local admin ports/tools + +Device pairing: + The pairing-relay sidecar always runs and is published on loopback only. + Set BUZZ_PAIRING_RELAY_URL in .env to the public /pair URL your proxy + serves. Check it with: + curl -sS -o /dev/null -w "%{http_code}\n" https:///pair + 400 = the sidecar is answering, 404 = no route or no sidecar. + See README.md § Device pairing. MSG ;; *) From 06cabb326bfcb06ccd9d3d404583020facb62b91 Mon Sep 17 00:00:00 2001 From: Derek Ross Date: Wed, 12 Aug 2026 17:21:00 -0400 Subject: [PATCH 3/4] fix(compose): safer pairing defaults after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /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 --- deploy/compose/.env.example | 28 +++++++++++++++---------- deploy/compose/README.md | 36 +++++++++++++++++++------------- deploy/compose/compose.caddy.yml | 6 +++++- deploy/compose/compose.yml | 10 +++++---- deploy/compose/run.sh | 6 ++++-- 5 files changed, 53 insertions(+), 33 deletions(-) diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 76187e1e9f8..2d8326cbe51 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -8,11 +8,14 @@ BUZZ_IMAGE=ghcr.io/block/buzz:main # Public host name. Used by compose.caddy.yml and URL-derived settings below. BUZZ_DOMAIN=buzz.example.com RELAY_URL=wss://buzz.example.com -# Where devices run the NIP-AB pairing handshake. Advertised in the relay's -# NIP-11 document; must be ws:// or wss:// or the relay refuses to start. This -# default matches the /pair route in the Caddyfile. See README.md § Device -# pairing for the bring-your-own-proxy case. -BUZZ_PAIRING_RELAY_URL=wss://buzz.example.com/pair +# Where devices run the NIP-AB pairing handshake. Deliberately left unset: +# with no value, clients fall back to /pair, which is exactly what +# the bundled Caddyfile serves — so the default install needs no edit here. +# Set it only when pairing lives somewhere else, e.g. a dedicated host name. +# Must be ws:// or wss:// or the relay refuses to start. Getting it wrong is +# quiet and expensive: a stale value advertises a pairing endpoint on another +# domain, and the handshake payload is an nsec. See README.md § Device pairing. +#BUZZ_PAIRING_RELAY_URL=wss://buzz.example.com/pair BUZZ_MEDIA_BASE_URL=https://buzz.example.com/media BUZZ_MEDIA_SERVER_DOMAIN=buzz.example.com BUZZ_CORS_ORIGINS=https://buzz.example.com @@ -44,12 +47,15 @@ BUZZ_S3_ADDRESSING_STYLE=path # Optional host ports. Base compose publishes the relay directly on BUZZ_HTTP_PORT. BUZZ_HTTP_PORT=3000 -# Pairing sidecar host port. Loopback by default: buzz-pair-relay has no auth -# and no membership check by design, so it must not face the internet directly. -# A reverse proxy on this host reaches it at 127.0.0.1:5000. compose.caddy.yml -# unpublishes it entirely. Change to 0.0.0.0:5000 only if something off-box has -# to reach it without a proxy, and read README.md § Device pairing first. -BUZZ_PAIR_RELAY_PORT=127.0.0.1:5000 +# Pairing sidecar bind address. The host IP is a separate variable on purpose: +# buzz-pair-relay has no auth and no membership check by design, so it must not +# face the internet directly, and a bare port number would silently bind +# 0.0.0.0. A reverse proxy on this host reaches it at 127.0.0.1:5000; +# compose.caddy.yml unpublishes it entirely. Widen the host IP only if +# something off-box must reach it without a proxy — README.md § Device pairing +# covers what that costs. +BUZZ_PAIR_RELAY_HOST_IP=127.0.0.1 +BUZZ_PAIR_RELAY_PORT=5000 # Caddy host ports. Only used with compose.caddy.yml. CADDY_HTTP_PORT=80 diff --git a/deploy/compose/README.md b/deploy/compose/README.md index a7af4aea826..10fbe78b7d9 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -56,20 +56,25 @@ default in `.env.example` — makes the main relay reject it outright. The history, just an in-flight match between two kind-24134 subscriptions. The binary already ships inside the relay image, so this bundle runs it as the -`pairing-relay` service. Two things have to line up: +`pairing-relay` service. With `BUZZ_COMPOSE_TLS=true` the bundled `Caddyfile` +routes `/pair` and `/pair/*` to `pairing-relay:5000`; everything else still goes +to the relay. That is the whole setup — there is nothing to configure. -1. **A route to the sidecar.** With `BUZZ_COMPOSE_TLS=true` the bundled - `Caddyfile` routes `/pair` and `/pair/*` to `pairing-relay:5000`; everything - else still goes to the relay. -2. **An advertised URL.** `BUZZ_PAIRING_RELAY_URL` is published in the relay's - NIP-11 document, and clients use it directly. Leave it at - `wss:///pair` to match the Caddy route. The value must be - `ws://` or `wss://`; the relay refuses to start otherwise. +`BUZZ_PAIRING_RELAY_URL` is left **unset** deliberately. A client reads the +relay's NIP-11 document and uses `pairing_relay_url` if it is there; with no +value, it falls back to `/pair`, which is precisely the route above. +Set it only when pairing lives somewhere else — a dedicated host name, or a +proxy that exposes a different path. The value must be `ws://` or `wss://` or +the relay refuses to start. + +Be careful with it. A stale value is quiet: the relay starts, the site works, +and pairing sends devices to whatever host name is in that string. The handshake +payload is a private key. Change it only alongside the route that serves it. ### Bringing your own reverse proxy -The sidecar is published on **loopback only** (`BUZZ_PAIR_RELAY_PORT`, default -`127.0.0.1:5000`). It performs no authentication and enforces no membership, so +The sidecar is published on **loopback only** (`BUZZ_PAIR_RELAY_HOST_IP`, default +`127.0.0.1`). It performs no authentication and enforces no membership, so putting it on a public interface would expose an unauthenticated WebSocket endpoint to the internet — don't. A proxy running on the same host reaches it at `127.0.0.1:5000`; a proxy in another container can join `buzz-net` and use @@ -78,13 +83,14 @@ endpoint to the internet — don't. A proxy running on the same host reaches it Whatever proxy you use, it must terminate TLS, route only `/pair`, pass the WebSocket upgrade headers, and keep read timeouts tight — the sidecar caps each connection at 120 seconds itself and delegates slowloris protection to the proxy -(see `crates/buzz-pair-relay/src/lib.rs`). Then point `BUZZ_PAIRING_RELAY_URL` at -whatever public URL that proxy serves. +(see `crates/buzz-pair-relay/src/lib.rs`). If it serves `/pair` on the same host +name as the relay, you still need no `BUZZ_PAIRING_RELAY_URL`; set it only if the +public URL differs. If something genuinely off-box must reach the sidecar without a proxy, set -`BUZZ_PAIR_RELAY_PORT=0.0.0.0:5000` — but note that pairing then runs -unencrypted over `ws://`, which iOS will refuse, and the endpoint is open to -anyone who can reach the port. +`BUZZ_PAIR_RELAY_HOST_IP=0.0.0.0` — but note that pairing then runs unencrypted +over `ws://`, which iOS will refuse, and the endpoint is open to anyone who can +reach the port. ### Checking it works diff --git a/deploy/compose/compose.caddy.yml b/deploy/compose/compose.caddy.yml index 4dde79bb66e..909f1f9ca7a 100644 --- a/deploy/compose/compose.caddy.yml +++ b/deploy/compose/compose.caddy.yml @@ -10,8 +10,12 @@ services: depends_on: relay: condition: service_healthy + # service_started, not service_healthy: pairing is one route. Gating the + # proxy on the sidecar's health would let an unhealthy sidecar keep the + # whole site — messaging, media, git — from coming up. Pairing degrades + # on its own; a stopped sidecar just makes /pair a 502. pairing-relay: - condition: service_healthy + condition: service_started environment: BUZZ_DOMAIN: ${BUZZ_DOMAIN:?set BUZZ_DOMAIN} ports: diff --git a/deploy/compose/compose.yml b/deploy/compose/compose.yml index 42609f0f16b..534136ca497 100644 --- a/deploy/compose/compose.yml +++ b/deploy/compose/compose.yml @@ -110,10 +110,12 @@ services: # Published on loopback only. It has no auth by design, so exposing it on a # public interface is not an acceptable default; a reverse proxy on this host # reaches it at 127.0.0.1:5000 and compose.caddy.yml unpublishes it entirely. - # Override with BUZZ_PAIR_RELAY_PORT — see README.md § Device pairing. + # BUZZ_PAIR_RELAY_HOST_IP widens it — see README.md § Device pairing. # - # The relay advertises BUZZ_PAIRING_RELAY_URL from .env via its env_file, so - # this service needs no wiring into the relay's environment block. + # Nothing needs to be added to the relay's environment: with + # BUZZ_PAIRING_RELAY_URL unset, clients fall back to /pair, which + # is the route the Caddyfile serves. Setting it is for split-host setups, and + # the relay reads it from .env through its env_file. pairing-relay: image: ${BUZZ_IMAGE:-ghcr.io/block/buzz:main} # The image ENTRYPOINT is buzz-relay, so this must override entrypoint, not @@ -123,7 +125,7 @@ services: environment: BUZZ_PAIR_RELAY_BIND_ADDR: 0.0.0.0:5000 ports: - - "${BUZZ_PAIR_RELAY_PORT:-127.0.0.1:5000}:5000" + - "${BUZZ_PAIR_RELAY_HOST_IP:-127.0.0.1}:${BUZZ_PAIR_RELAY_PORT:-5000}:5000" # Same /dev/tcp trick as the relay: the runtime image has bash but no curl. healthcheck: test: ["CMD-SHELL", "bash -ec 'exec 3<>/dev/tcp/127.0.0.1/5000'"] diff --git a/deploy/compose/run.sh b/deploy/compose/run.sh index e25883c8bae..8208acead29 100755 --- a/deploy/compose/run.sh +++ b/deploy/compose/run.sh @@ -129,8 +129,10 @@ Environment switches: Device pairing: The pairing-relay sidecar always runs and is published on loopback only. - Set BUZZ_PAIRING_RELAY_URL in .env to the public /pair URL your proxy - serves. Check it with: + With BUZZ_COMPOSE_TLS=true it needs no configuration: clients fall back + to /pair, which the bundled Caddyfile serves. Set + BUZZ_PAIRING_RELAY_URL only if pairing lives on another host name. + Check it with: curl -sS -o /dev/null -w "%{http_code}\n" https:///pair 400 = the sidecar is answering, 404 = no route or no sidecar. See README.md § Device pairing. From 8be23c8afb6c7eeb9cac9960a69302ef82e5e8a4 Mon Sep 17 00:00:00 2001 From: Derek Ross Date: Wed, 12 Aug 2026 17:26:06 -0400 Subject: [PATCH 4/4] docs(compose): fix the pairing check order and state the NIP-43 precondition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /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 --- deploy/compose/README.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/deploy/compose/README.md b/deploy/compose/README.md index 10fbe78b7d9..94d750c923e 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -71,6 +71,13 @@ Be careful with it. A stale value is quiet: the relay starts, the site works, and pairing sends devices to whatever host name is in that string. The handshake payload is a private key. Change it only alongside the route that serves it. +One precondition on that fallback: clients only try `/pair` when the +relay advertises NIP-43, which it does when it has a stable +`BUZZ_RELAY_PRIVATE_KEY` **and** `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true` — both +defaults here. On an open relay (`BUZZ_REQUIRE_RELAY_MEMBERSHIP=false`) clients +pair against the main relay directly, which works because there is no membership +gate to fail; the sidecar is then unused. + ### Bringing your own reverse proxy The sidecar is published on **loopback only** (`BUZZ_PAIR_RELAY_HOST_IP`, default @@ -95,11 +102,6 @@ reach the port. ### Checking it works ```bash -# Is the URL advertised? -curl -sS -H 'Accept: application/nostr+json' "https://" \ - | grep -o '"pairing_relay_url":"[^"]*"' - -# Is something answering on /pair? curl -sS -o /dev/null -w "%{http_code}\n" "https:///pair" ``` @@ -109,6 +111,16 @@ no sidecar — pairing will fail. **401/403**, or a socket that closes while the desktop waits for EOSE, means `/pair` is reaching the *main* relay, which is refusing the not-yet-member device. +Only if you set `BUZZ_PAIRING_RELAY_URL`, check that it is advertised: + +```bash +curl -sS -H 'Accept: application/nostr+json' "https://" \ + | grep -o '"pairing_relay_url":"[^"]*"' +``` + +Empty output is correct on a default install — the field is omitted entirely +when unset, and clients use the `/pair` fallback. + Note that the sidecar requires a `#p` filter: a `REQ` for `kinds:[24134]` without one is closed with `#p filter required`. Real clients always send it; this only surprises people probing by hand.