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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ TYPESENSE_URL=http://localhost:8108
BUZZ_BIND_ADDR=0.0.0.0:3000
# Public WebSocket URL — used in NIP-42 auth challenges
RELAY_URL=ws://localhost:3000
# Optional alias→canonical host map for a deployment legitimately reachable on
# more than one address (e.g. a public CDN hostname plus an internal tailnet
# name for the same community) — comma-separated alias=canonical pairs, both
# sides already normalized (lowercase, no default port, no trailing dot).
# Unset/blank keeps today's exact single-host-per-community behavior.
# BUZZ_HOST_ALIASES=internal.tailnet.example=chat.example.com
# Stable relay signing key. Set this in dev if you want REST-created forum posts
# to keep resolving to the original author across relay restarts.
# BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key>
Expand Down
10 changes: 7 additions & 3 deletions crates/buzz-relay/src/api/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,13 @@ async fn feedback_attachment(
// Resolve the tenant from server-owned feedback provenance, then assert the
// resolved row still agrees with the feedback FK. Client input never names
// a community, host, object key, extension, or upstream URL.
let tenant = crate::tenant::bind_community(&state.db, &feedback.community_host)
.await
.map_err(|_| ApiError::not_found())?;
let tenant = crate::tenant::bind_community(
&state.db,
&feedback.community_host,
&state.config.host_aliases,
)
.await
.map_err(|_| ApiError::not_found())?;
if tenant.community().as_uuid() != &feedback.community_id {
tracing::warn!(
feedback_id = %feedback.id,
Expand Down
82 changes: 77 additions & 5 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,7 @@ pub async fn submit_event(
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let tenant = crate::tenant::bind_community(&state.db, raw_host)
let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases)
.await
.map_err(|_| {
api_error(
Expand Down Expand Up @@ -895,7 +895,7 @@ pub async fn query_events(
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let tenant = crate::tenant::bind_community(&state.db, raw_host)
let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases)
.await
.map_err(|_| {
api_error(
Expand Down Expand Up @@ -1338,7 +1338,7 @@ pub async fn count_events(
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let tenant = crate::tenant::bind_community(&state.db, raw_host)
let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases)
.await
.map_err(|_| {
api_error(
Expand Down Expand Up @@ -1818,7 +1818,7 @@ pub async fn workflow_webhook(
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let tenant = crate::tenant::bind_community(&state.db, raw_host)
let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases)
.await
.map_err(|_| not_found("workflow not found"))?;
let community_id = tenant.community();
Expand Down Expand Up @@ -2073,7 +2073,7 @@ async fn authorize_moderation_read(
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let tenant = crate::tenant::bind_community(&state.db, raw_host)
let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases)
.await
.map_err(|_| {
api_error(
Expand Down Expand Up @@ -2560,6 +2560,78 @@ mod tests {
);
}

/// T12 (`BUZZ_HOST_ALIASES` row-zero composition): once `bind_community`
/// has bound a request through an alias, `tenant.host()` IS the alias
/// (see `crate::tenant::bind_community`'s doc — the context always
/// carries the arrival host). A NIP-98 event the client signed against
/// that alias address must verify. This proves the alias design composes
/// with NIP-98 verification with ZERO changes needed to
/// `nip98_expected_url` or `verify_bridge_auth`: both already read
/// `tenant.host()`, which is the alias regardless of whether resolution
/// went through `communities.host` directly or through `host_aliases`.
#[test]
fn verify_bridge_auth_accepts_nip98_event_signed_for_alias_bound_tenant() {
let keys = Keys::generate();
// Client reaches the relay on the alias address and signs against it.
let signed_url = "https://internal.tailnet.example/events";
let event_json = build_nip98_event_json(&keys, signed_url, "POST");
let headers = nip98_auth_headers(&event_json);

// Stands in for what `bind_community` returns when a request arrives
// on the alias and resolves through `host_aliases` to the canonical
// community: `TenantContext::resolved` carries the ARRIVAL host, not
// the canonical `chat.example.com`.
let config_relay_url = "wss://chat.example.com"; // scheme source only.
let alias_bound_tenant = fresh_tenant("internal.tailnet.example");
let expected_url = nip98_expected_url(config_relay_url, &alias_bound_tenant, "/events");

let (pubkey, _event_id_bytes) =
verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true).expect(
"NIP-98 event signed for the alias must verify against an alias-bound tenant",
);
assert_eq!(
pubkey,
keys.public_key(),
"returned pubkey must be the signer's"
);
}

/// T13: the mirror negative case. An event signed for the CANONICAL host
/// must be REJECTED at a request bound through the alias — the same
/// exact-match property as the plain cross-host test above, proving the
/// alias path does not loosen `nip98_expected_url` into accepting either
/// address for one tenant. Row 44 ("the `u` URL host must match
/// req.community") holds regardless of which resolution path bound the
/// tenant.
#[test]
fn verify_bridge_auth_rejects_nip98_event_signed_for_canonical_when_tenant_bound_via_alias() {
let keys = Keys::generate();
// Client signs for the CANONICAL host — the alias-unaware address.
let signed_url = "https://chat.example.com/events";
let event_json = build_nip98_event_json(&keys, signed_url, "POST");
let headers = nip98_auth_headers(&event_json);

let config_relay_url = "wss://chat.example.com";
let alias_bound_tenant = fresh_tenant("internal.tailnet.example");
let expected_url = nip98_expected_url(config_relay_url, &alias_bound_tenant, "/events");

let (status, body) = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true)
.expect_err(
"an event signed for the canonical host must be rejected on an \
alias-bound tenant — exact-match binding must hold regardless of \
resolution path",
);
assert_eq!(status, StatusCode::UNAUTHORIZED);
let msg = body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
msg.contains("URL mismatch"),
"rejection must carry the URL-mismatch signal; got body = {body:?}"
);
}

/// Mirror of the query-reconstruction `authorize_moderation_read` performs
/// before calling [`nip98_expected_url`], so the tests below pin the exact
/// seam without a DB harness. Kept in lockstep with the production match arm.
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-relay/src/api/git/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let tenant = crate::tenant::bind_community(&state.db, raw_host)
let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "repository not found").into_response())?;
let expected_url = git_expected_url(
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-relay/src/api/invites.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ async fn authenticate(
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let tenant = crate::tenant::bind_community(&state.db, raw_host)
let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases)
.await
.map_err(|_| {
api_error(
Expand Down
4 changes: 2 additions & 2 deletions crates/buzz-relay/src/api/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let tenant = crate::tenant::bind_community(&state.db, raw_host)
let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases)
.await
.map_err(|_| MediaError::NotFound)?;

Expand Down Expand Up @@ -481,7 +481,7 @@ async fn bind_media_read_tenant(
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
crate::tenant::bind_community(&state.db, raw_host)
crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases)
.await
.map_err(|_| MediaError::NotFound)
}
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-relay/src/api/nip05.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pub async fn nostr_nip05(
.unwrap_or("");
let json = match (
params.name,
crate::tenant::bind_community(&state.db, raw_host).await,
crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases).await,
) {
(Some(n), Ok(tenant)) => {
let name = n.to_lowercase();
Expand Down
8 changes: 7 additions & 1 deletion crates/buzz-relay/src/audio/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,13 @@ pub async fn ws_audio_handler(
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let tenant = match crate::tenant::bind_community(&state.db, raw_host).await {
let tenant = match crate::tenant::bind_community(
&state.db,
raw_host,
&state.config.host_aliases,
)
.await
{
Ok(ctx) => ctx,
Err(_) => {
return (
Expand Down
Loading