mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(relay): bind TenantContext from connection host at WS upgrade
Resolve the community from the connection host BEFORE the WebSocket upgrade (conformance row-zero) and carry it as ConnectionState.tenant for the whole connection lifetime. Handlers read &conn.tenant and pass it into scoped DB/pub-sub calls; nothing downstream can mint or change it. - RelayError::HostNotMapped: fail-closed, generic message (no host oracle). - normalize_host(&HeaderMap, fallback): request Host header, lowercased, port-stripped; falls back to the configured relay_url host (N=1 parity). - AppState::resolve_tenant: the ONLY mint site, via the buzz-db lookup seam. Integration seam: depends on buzz-db Db::lookup_community_by_host (Mari's lane). Until that lands on rebase, the crate has exactly that one unresolved symbol; everything else (normalize_host + tests, the field plumbing) compiles clean. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
Tyler Longwell
parent
ab4b518d5d
commit
b6f162bb31
@@ -14,6 +14,7 @@ use tracing::{debug, info, trace, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use buzz_auth::{generate_challenge, AuthContext};
|
||||
use buzz_core::TenantContext;
|
||||
use nostr::Filter;
|
||||
|
||||
use crate::handlers;
|
||||
@@ -44,6 +45,11 @@ pub enum AuthState {
|
||||
pub struct ConnectionState {
|
||||
/// Unique identifier for this connection.
|
||||
pub conn_id: Uuid,
|
||||
/// The community this connection is bound to, resolved from the connection
|
||||
/// host at WebSocket upgrade *before* the receive loop starts (conformance
|
||||
/// row-zero). Handlers read `&conn.tenant` and pass it into every scoped
|
||||
/// DB / pub-sub call; nothing downstream can mint or change it.
|
||||
pub tenant: TenantContext,
|
||||
/// Remote socket address of the client.
|
||||
pub remote_addr: SocketAddr,
|
||||
/// Current NIP-42 authentication state.
|
||||
@@ -102,7 +108,15 @@ impl ConnectionState {
|
||||
///
|
||||
/// Acquires a connection semaphore permit, sends the NIP-42 AUTH challenge,
|
||||
/// then drives the send, heartbeat, and receive loops until the connection closes.
|
||||
pub async fn handle_connection(socket: WebSocket, state: Arc<AppState>, addr: SocketAddr) {
|
||||
///
|
||||
/// `tenant` is resolved from the connection host at upgrade time (see
|
||||
/// `router::nip11_or_ws_handler`) and carried for the connection's whole lifetime.
|
||||
pub async fn handle_connection(
|
||||
socket: WebSocket,
|
||||
state: Arc<AppState>,
|
||||
addr: SocketAddr,
|
||||
tenant: TenantContext,
|
||||
) {
|
||||
let permit = match state.conn_semaphore.clone().try_acquire_owned() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
@@ -125,6 +139,7 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<AppState>, addr: So
|
||||
|
||||
let conn = Arc::new(ConnectionState {
|
||||
conn_id,
|
||||
tenant,
|
||||
remote_addr: addr,
|
||||
auth_state: RwLock::new(AuthState::Pending {
|
||||
challenge: challenge.clone(),
|
||||
|
||||
@@ -37,6 +37,15 @@ pub enum RelayError {
|
||||
#[error("Not authenticated")]
|
||||
NotAuthenticated,
|
||||
|
||||
/// The connection host does not map to any community.
|
||||
///
|
||||
/// Fail-closed tenant resolution (conformance row-zero): an unmapped host is
|
||||
/// rejected outright — there is no default-community fallthrough. The message
|
||||
/// is deliberately generic so it cannot be used as a cross-tenant existence
|
||||
/// oracle (which hosts are configured).
|
||||
#[error("Host not mapped to a community")]
|
||||
HostNotMapped,
|
||||
|
||||
/// The client sent a message that could not be parsed.
|
||||
#[error("Invalid message format: {0}")]
|
||||
InvalidMessage(String),
|
||||
|
||||
@@ -161,9 +161,25 @@ async fn nip11_or_ws_handler(
|
||||
}
|
||||
|
||||
match WebSocketUpgrade::from_request(req, &state).await {
|
||||
Ok(ws) => ws
|
||||
.on_upgrade(move |socket| handle_connection(socket, state, addr))
|
||||
.into_response(),
|
||||
Ok(ws) => {
|
||||
// Conformance row-zero: resolve the tenant from the connection host
|
||||
// BEFORE upgrading, so a connection that upgrades already carries a
|
||||
// resolved `TenantContext`. An unmapped host is rejected fail-closed —
|
||||
// there is no default-community fallthrough.
|
||||
let host = normalize_host(
|
||||
&headers,
|
||||
&crate::api::nip05::extract_domain(&state.config.relay_url),
|
||||
);
|
||||
let tenant = match state.resolve_tenant(&host).await {
|
||||
Ok(tenant) => tenant,
|
||||
Err(_) => {
|
||||
// Generic rejection: do not leak which hosts are configured.
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
};
|
||||
ws.on_upgrade(move |socket| handle_connection(socket, state, addr, tenant))
|
||||
.into_response()
|
||||
}
|
||||
Err(_) => {
|
||||
// Browser requesting HTML and web UI is configured → serve SPA.
|
||||
if let Some(ref dir) = state.config.web_dir {
|
||||
@@ -185,6 +201,27 @@ async fn nip11_or_ws_handler(
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize the connection host into the canonical form used to look up a
|
||||
/// community (lowercase, no port). This is the *connection* host — the request's
|
||||
/// `Host` header — because the tenant must be resolved from where the client
|
||||
/// actually connected, not from static relay config (conformance row-zero).
|
||||
///
|
||||
/// `fallback_host` is used only when the header is absent or empty (e.g. a direct
|
||||
/// internal hit); the caller passes the configured `relay_url` host. In a
|
||||
/// single-community (N=1) deployment the configured host and the request host
|
||||
/// coincide, so behaviour matches today's relay; the DB lookup still has the
|
||||
/// final say and an unmapped host is rejected.
|
||||
fn normalize_host(headers: &HeaderMap, fallback_host: &str) -> String {
|
||||
headers
|
||||
.get(axum::http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
// Strip an optional port and lowercase. Mirrors `nip05::extract_domain`
|
||||
// but operates on the live request authority rather than a URL.
|
||||
.map(|h| h.split(':').next().unwrap_or(h).to_lowercase())
|
||||
.filter(|h| !h.is_empty())
|
||||
.unwrap_or_else(|| fallback_host.to_lowercase())
|
||||
}
|
||||
|
||||
async fn health_handler() -> impl IntoResponse {
|
||||
(StatusCode::OK, "ok")
|
||||
}
|
||||
@@ -262,3 +299,52 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer {
|
||||
.allow_methods(tower_http::cors::Any)
|
||||
.allow_headers(tower_http::cors::Any)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::header::HOST;
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
fn headers_with_host(host: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(HOST, HeaderValue::from_str(host).unwrap());
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_header_wins_over_fallback() {
|
||||
let headers = headers_with_host("tenant.example");
|
||||
assert_eq!(
|
||||
normalize_host(&headers, "configured.example"),
|
||||
"tenant.example"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_header_is_lowercased_and_port_stripped() {
|
||||
let headers = headers_with_host("Tenant.Example:8080");
|
||||
assert_eq!(
|
||||
normalize_host(&headers, "configured.example"),
|
||||
"tenant.example"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_when_header_absent() {
|
||||
let headers = HeaderMap::new();
|
||||
assert_eq!(
|
||||
normalize_host(&headers, "Configured.Example"),
|
||||
"configured.example"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_host_header_falls_back() {
|
||||
let headers = headers_with_host("");
|
||||
assert_eq!(
|
||||
normalize_host(&headers, "configured.example"),
|
||||
"configured.example"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,6 +580,28 @@ impl AppState {
|
||||
}
|
||||
Ok(visibility)
|
||||
}
|
||||
|
||||
/// Resolve a normalized connection host to its [`TenantContext`].
|
||||
///
|
||||
/// This is the ONLY place a `TenantContext` is minted from a live request
|
||||
/// (conformance row-zero): the host comes from the connection, the community
|
||||
/// from the durable `communities` map, and the client never influences it.
|
||||
/// An unmapped host fails closed via [`RelayError::HostNotMapped`] — there is
|
||||
/// no default-community fallthrough.
|
||||
pub async fn resolve_tenant(
|
||||
&self,
|
||||
normalized_host: &str,
|
||||
) -> Result<buzz_core::TenantContext, crate::error::RelayError> {
|
||||
let record = self
|
||||
.db
|
||||
.lookup_community_by_host(normalized_host)
|
||||
.await?
|
||||
.ok_or(crate::error::RelayError::HostNotMapped)?;
|
||||
Ok(buzz_core::TenantContext::resolved(
|
||||
record.id,
|
||||
normalized_host,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle for graceful audit worker shutdown.
|
||||
@@ -722,6 +744,10 @@ mod tests {
|
||||
|
||||
let conn = ConnectionState {
|
||||
conn_id,
|
||||
tenant: buzz_core::TenantContext::resolved(
|
||||
buzz_core::CommunityId::from_uuid(Uuid::nil()),
|
||||
"test.localhost",
|
||||
),
|
||||
remote_addr: "127.0.0.1:1234".parse().unwrap(),
|
||||
auth_state: RwLock::new(AuthState::Failed),
|
||||
subscriptions: Arc::new(Mutex::new(HashMap::new())),
|
||||
|
||||
Reference in New Issue
Block a user