Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions deploy/compose/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <RELAY_URL>/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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion deploy/compose/Caddyfile
Original file line number Diff line number Diff line change
@@ -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
}
}
79 changes: 79 additions & 0 deletions deploy/compose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<RELAY_URL>/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 `<RELAY_URL>/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://<your-domain>/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://<your-domain>" \
| 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:
Expand Down
9 changes: 9 additions & 0 deletions deploy/compose/compose.caddy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 35 additions & 0 deletions deploy/compose/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <RELAY_URL>/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:
Expand Down
15 changes: 14 additions & 1 deletion deploy/compose/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <RELAY_URL>/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://<domain>/pair
400 = the sidecar is answering, 404 = no route or no sidecar.
See README.md § Device pairing.
MSG
;;
*)
Expand Down