block / block/buzz

`BUZZ_RELAY_URL` serves three purposes at once, so an agent cannot reach the relay over a private network path

Open
#6,281 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

## Summary

`buzz-acp` derives three separate things from the single `BUZZ_RELAY_URL` value:

1. the TCP/TLS connection target
2. the HTTP `Host` header presented on the WebSocket upgrade
3. the `relay` tag signed into the NIP-42 AUTH event

The relay then constrains (2) and (3) independently, and the two constraints together mean the value cannot be changed at all — even when the *connection target* needs to differ from the *public identity* of the relay.

The practical result: an agent deployed **beside** the relay, in the same Kubernetes cluster, cannot connect to it by any in-cluster path. It must egress to the relay's public address and come back in, which is exactly the topology a co-located agent should not need.

## Environment

- Relay: `buzz-relay` 0.2.1, deployed from the published Helm chart `oci://ghcr.io/block/buzz/charts/buzz` 0.1.7
- Agent: `ghcr.io/block/buzz-sprig` (`main`), running `buzz-acp` → `buzz-agent`
- Both in the same Kubernetes namespace; relay reachable in-cluster as a `ClusterIP` Service on plain HTTP
- Relay configured with `RELAY_URL=wss://`, `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`

## Constraint 1 — `Host` must be the bare public authority

The relay resolves its community from the `Host` header before the WebSocket upgrade. `normalize_host` strips only `:443` and `:80`, so any other port remains part of the authority and fails to match.

Measured against a running relay, varying only the `Host` header on the same socket:

| Request | `Host` | Result |
|---|---|---|
| `http://:3000/` | `` | `200`, NIP-11 document |
| `http://:3000/` | `:3000` | `404 relay: no community is configured for this host` |
| `http://:3000/` | `:3000` | `404 relay: no community is configured for this host` |

Reasonable on its own — it fails closed rather than silently selecting a default tenant.

## Constraint 2 — the signed scheme is deployment-wide, not per-connection

`nip42_expected_relay_url` builds the expected AUTH tag as `{scheme}://{tenant.host()}`, where the scheme comes from the deployment's configured `RELAY_URL` rather than from the connection actually in use. The doc comment states this is deliberate, to preserve the deployment's TLS posture.

So a client connecting over plain `ws://` on a trusted internal network still has to sign `wss://…`, and `buzz-acp` signs `BUZZ_RELAY_URL` verbatim.

Measured with a real NIP-42 handshake — same socket, same `Host`, same challenge, varying only the signed `relay` tag:

```
relay tag ws:// -> ["OK", …, false, "auth-required: verification failed"]
relay tag wss:// -> ["OK", …, true, ""]
```

## Why the two together are the problem

Taken separately each is defensible. Together they mean `BUZZ_RELAY_URL` must be **exactly** the public `wss://` URL, so the connection target cannot be varied independently of the identity the client asserts.

Every in-cluster option is therefore closed:

| Attempt | Outcome |
|---|---|
| Point at the `ClusterIP` Service | Fails constraint 1 — wrong `Host`, and the port cannot be stripped |
| Service on port 80 so the port is elided, plus a host alias | Fails constraint 2 — still signs `ws://` |
| Terminate TLS in-cluster with a private CA | `buzz-acp` is built with `rustls-tls-webpki-roots`, so only compiled-in Mozilla roots are trusted. No env var or mounted bundle can add a CA |
| Register a second community for the internal host | Scheme is deployment-wide; and a second host is a separate tenant, so the agent would not see the existing channels |

The remaining option is to make the public hostname reachable from inside the network, which for us meant widening a load-balancer security group that was deliberately restricted. That is a real security trade forced by a client-side URL-parsing decision.

## Suggested direction

Any one of these would resolve it; the first seems smallest:

1. **Derive the expected AUTH scheme per-connection** rather than from the deployment-wide `RELAY_URL` — or accept either scheme for the same host when the connection is already trusted.
2. **Separate the concerns in `buzz-acp`**: an optional connect-target override (something like `BUZZ_RELAY_CONNECT_URL`) while `BUZZ_RELAY_URL` remains the identity used for `Host` and the signed tag. This mirrors how many clients separate "where to dial" from "who I am talking to".
3. **Allow additional accepted hosts for a community**, so an internal address maps to the same tenant rather than a new one.

Happy to test a patch against our deployment.

## Not a bug report about

Relay membership, NIP-42 itself, or the `Host`-based tenant lookup in isolation. Membership and auth both work correctly — a member key signing `wss://` authenticates cleanly. The issue is only that the three uses of one value cannot be varied independently.

Contributor guide

Open the contributing guide

Research direction

Start in buzz-acp by tracing how BUZZ_RELAY_URL supplies the connection target, Host header, and signed NIP-42 relay tag, then inspect nip42_expected_relay_url on the relay side. Reproduce the in-cluster connection cases described in the issue and choose a design that lets the dial target vary without changing the relay identity. Done means the existing public connection still authenticates and a private network path reaches the same relay community.

Written by the indexing model from the issue text.

Assessment

Tech stack
helm, kubernetes, rust
Domain
authentication, infrastructure, networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.