diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index f6ab4fcab97..2d8326cbe51 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -8,6 +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. 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 @@ -39,6 +47,16 @@ BUZZ_S3_ADDRESSING_STYLE=path # Optional host ports. Base compose publishes the relay directly on BUZZ_HTTP_PORT. BUZZ_HTTP_PORT=3000 +# 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 CADDY_HTTPS_PORT=443 diff --git a/deploy/compose/Caddyfile b/deploy/compose/Caddyfile index 205cf4c5bc9..a546fb3849d 100644 --- a/deploy/compose/Caddyfile +++ b/deploy/compose/Caddyfile @@ -1,5 +1,20 @@ {$BUZZ_DOMAIN} { encode zstd gzip - reverse_proxy relay:3000 + # Device pairing (NIP-AB) goes to the auth-less sidecar, not the main relay: + # 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 + } + + handle { + reverse_proxy relay:3000 + } } diff --git a/deploy/compose/README.md b/deploy/compose/README.md index bb0e63fe15d..94d750c923e 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -46,6 +46,85 @@ 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. 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. + +`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. + +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 +`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 +`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`). 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_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 + +```bash +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. + +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. + ## Validation Before sharing an install link publicly, verify a fresh install with: diff --git a/deploy/compose/compose.caddy.yml b/deploy/compose/compose.caddy.yml index c7dcbf106cc..909f1f9ca7a 100644 --- a/deploy/compose/compose.caddy.yml +++ b/deploy/compose/compose.caddy.yml @@ -2,11 +2,20 @@ services: relay: ports: !reset [] + pairing-relay: + ports: !reset [] + caddy: image: caddy:2-alpine 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_started environment: BUZZ_DOMAIN: ${BUZZ_DOMAIN:?set BUZZ_DOMAIN} ports: diff --git a/deploy/compose/compose.yml b/deploy/compose/compose.yml index 15337c92a27..534136ca497 100644 --- a/deploy/compose/compose.yml +++ b/deploy/compose/compose.yml @@ -102,6 +102,41 @@ 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. 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. + # BUZZ_PAIR_RELAY_HOST_IP widens it — see README.md § Device pairing. + # + # 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 + # 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_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'"] + 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: diff --git a/deploy/compose/run.sh b/deploy/compose/run.sh index d5465ea1f5d..8208acead29 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,18 @@ 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. + 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. MSG ;; *)