Files
buzz/crates/buzz-relay/src/config.rs

1151 lines
47 KiB
Rust

//! Relay configuration from environment variables.
use std::net::SocketAddr;
use std::time::Duration;
use sha2::{Digest, Sha256};
use thiserror::Error;
use tracing::warn;
/// Default maximum inbound WebSocket frame size in bytes.
///
/// Must comfortably exceed accepted event content sizes after Nostr JSON and
/// NIP-44 encryption overhead.
pub const DEFAULT_MAX_FRAME_BYTES: usize = 512 * 1024;
/// Errors that can occur while loading relay configuration.
#[derive(Debug, Error)]
pub enum ConfigError {
/// The `BUZZ_BIND_ADDR` environment variable could not be parsed as a socket address.
#[error("invalid BUZZ_BIND_ADDR: {0}")]
InvalidBindAddr(String),
/// A configuration value failed validation.
#[error("invalid config: {0}")]
InvalidValue(String),
}
/// Relay-hosted policy content presented on join surfaces.
#[derive(Debug, Clone)]
pub struct JoinPolicyConfig {
/// Operator-provided Terms of Service document in Markdown.
pub terms_markdown: Option<String>,
/// Operator-provided Privacy Policy document in Markdown.
pub privacy_markdown: Option<String>,
/// Whether join surfaces must collect an 18+ attestation.
pub age_attestation_required: bool,
/// Content-derived identifier binding receipts to the exact policy revision.
pub version: String,
}
/// Relay runtime configuration, loaded from environment variables.
#[derive(Debug, Clone)]
pub struct Config {
/// Address the relay HTTP/WebSocket server binds to.
pub bind_addr: SocketAddr,
/// Postgres database connection URL.
pub database_url: String,
/// Redis connection URL used by the pub/sub manager.
pub redis_url: String,
/// Public WebSocket URL of this relay, advertised in NIP-11.
pub relay_url: String,
/// Public WebSocket URL of the dedicated device-pairing relay, when configured.
pub pairing_relay_url: Option<String>,
/// Maximum number of concurrent WebSocket connections.
pub max_connections: usize,
/// Maximum number of concurrently executing message handlers.
pub max_concurrent_handlers: usize,
/// Per-connection outbound message buffer size (number of messages).
pub send_buffer_size: usize,
/// Maximum inbound WebSocket frame size in bytes.
pub max_frame_bytes: usize,
/// Number of consecutive buffer-full events tolerated before cancelling a slow client.
pub slow_client_grace_limit: u8,
/// Authentication provider configuration.
pub auth: buzz_auth::AuthConfig,
/// Whether REST API requests must present a valid token. Independent of
/// WebSocket protocol auth, which is *always* required by REQ/EVENT/COUNT.
pub require_auth_token: bool,
/// Comma-separated list of allowed CORS origins.
/// If empty, permissive CORS is used (dev mode).
/// Example: "tauri://localhost,http://localhost:3000"
pub cors_origins: Vec<String>,
/// Optional hex-encoded private key for the relay's signing keypair.
/// If absent, a fresh keypair is generated at startup.
pub relay_private_key: Option<String>,
/// Optional Unix Domain Socket path. When set, the relay also listens on this
/// UDS for traffic (e.g. service mesh sidecar). Health probes still use TCP.
pub uds_path: Option<String>,
/// TCP port for the health-only router (`/_liveness`, `/_readiness`, `/_status`).
/// Separate from the app router so K8s probes bypass Istio and auth middleware.
pub health_port: u16,
/// TCP port for the Prometheus metrics exporter (`GET /metrics`).
pub metrics_port: u16,
/// When true, NIP-42 pubkey-only authentication (no API token) is
/// restricted to pubkeys in the `pubkey_allowlist` table. Users with valid
/// API tokens bypass the allowlist entirely.
/// Applies to all NIP-42 pubkey-only connections, regardless of `require_auth_token`.
pub pubkey_allowlist_enabled: bool,
/// When true, every authenticated request must also pass a relay-level
/// membership check against the `relay_members` table.
/// When false (default), the check is a no-op and all authenticated callers
/// are permitted regardless of auth method (API token, NIP-42).
pub require_relay_membership: bool,
/// Whether this deployment can serve huddle (voice) audio.
///
/// Huddle audio frames are relayed peer-to-peer *within a single pod*
/// (`AudioRoomManager` is an in-process map; only huddle lifecycle events
/// cross pods via Redis). Under horizontal scaling (any-pod-any-connection,
/// plan §4 fork B) two peers in the same huddle can land on different pods
/// and never hear each other. Rather than sticky-route huddles or ship a
/// silent split-room (plan §5b, decided by Tyler), a horizontally-scaled
/// deployment sets this `false` and the relay surfaces a clear, client-
/// handleable "huddle audio unavailable" signal on join.
///
/// Defaults to `true` so single-pod deployments (the N=1 case) keep today's
/// behavior unchanged. Operators running multiple relay pods MUST set
/// `BUZZ_HUDDLE_AUDIO_AVAILABLE=false` until the out-of-relay media/SFU
/// service lands.
pub huddle_audio_available: bool,
/// Inter-relay mesh configuration (`BUZZ_MESH`, `BUZZ_MESH_BIND_ADDR`).
/// Opt-in: mesh forms only when `BUZZ_MESH=on` is explicit. The default
/// (absent/off) is exact single-instance behavior — no bind, no Redis
/// registry write — so an image upgrade with untouched env is a strict
/// no-regression rollout.
pub mesh: buzz_relay_mesh::MeshConfig,
/// Testbed-only reliable-stream echo consumer (`BUZZ_MESH_DEMO_ECHO`).
/// When `on`, the owner side of an inbound reliable mesh stream echoes
/// every validated `Data` frame back to the sender — a transport/
/// session-routing smoke for cross-pod evidence runs, NOT a product flow.
/// Same strict opt-in as `BUZZ_MESH`; default off means inbound reliable
/// streams are accepted, logged, and closed (no session consumer yet).
pub mesh_demo_echo: bool,
/// Optional hex-encoded pubkey of the relay owner.
/// When set, this pubkey is automatically bootstrapped into `relay_members`
/// with the `owner` role on first startup.
pub relay_owner_pubkey: Option<String>,
/// Canonical HTTP origin of the deployment-global operator API.
///
/// Every operator NIP-98 `u` tag is verified against this origin, independent
/// of the inbound HTTP `Host` header and tenant registry. Required when
/// `RELAY_OPERATOR_PUBKEYS` is non-empty. Set via `RELAY_OPERATOR_API_ORIGIN`
/// as an `http://` or `https://` origin with no path, query, or fragment.
pub relay_operator_api_origin: Option<String>,
/// Deployment-level relay operator pubkeys allowed to use the
/// `/operator/communities` management endpoints.
///
/// Unlike `relay_owner_pubkey` (a role *within* the deployment community),
/// operators span tenants: they may create new communities and bootstrap
/// initial owners, but hold no implicit tenant membership row.
/// Empty (the default) disables community provisioning entirely — fail closed.
///
/// Set via `RELAY_OPERATOR_PUBKEYS` as a comma-separated list of 64-char
/// hex pubkeys. Invalid entries are rejected at startup (config error), not
/// skipped — a typo must not silently disable an operator.
pub relay_operator_pubkeys: Vec<String>,
/// Allow NIP-OA owner attestation for relay membership.
///
/// When `true` and `require_relay_membership` is also `true`, agents
/// bearing a valid NIP-OA `auth` tag can authenticate by proving their
/// owner is a relay member. The agent gets session-scoped access.
///
/// On open relays (`require_relay_membership = false`), NIP-OA owner
/// extraction for agent→owner backfill happens unconditionally (the
/// signature is cryptographically self-proving). This flag only controls
/// whether NIP-OA can grant membership access on closed relays.
///
/// Default: `false`. Set via `BUZZ_ALLOW_NIP_OA_AUTH=true`.
pub allow_nip_oa_auth: bool,
/// Media storage configuration (S3/MinIO).
pub media: buzz_media::MediaConfig,
/// Maximum concurrent media uploads handled by one relay process.
pub media_max_concurrent_uploads: usize,
/// Maximum concurrent media uploads accepted from one pubkey.
pub media_max_concurrent_uploads_per_pubkey: u32,
/// Maximum media upload starts accepted from one pubkey per minute.
pub media_uploads_per_minute: u32,
/// Require Blossom kind:24242 `t=get` auth plus relay membership before
/// serving media GET/HEAD. Default off for staged client rollout.
pub require_media_get_auth: bool,
/// Optional override for ephemeral channel TTL (in seconds).
/// When set, any channel created with a TTL tag will use this value instead
/// of the client-provided one. Useful for testing ephemeral expiry quickly.
/// Example: `BUZZ_EPHEMERAL_TTL_OVERRIDE=60` → all ephemeral channels expire
/// 60 seconds after the last message.
pub ephemeral_ttl_override: Option<i32>,
/// Root directory for the relay's local git scratch. No per-repo bare repos
/// or persistent git state live here — runtime reads/writes hydrate
/// ephemeral repos from object storage per request, and all temporary Git
/// workspaces and buffered subprocess output are created beneath this path.
/// Repo-name uniqueness lives in Postgres (`git_repo_names`), not on disk,
/// so this directory need not be persistent or shared across replicas.
pub git_repo_path: std::path::PathBuf,
/// Maximum pack file size for git push (bytes). Default: 500 MB.
pub git_max_pack_bytes: u64,
/// Maximum total bytes materialized for one git repo request. Default: 1 GB.
///
/// This bounds clone/fetch hydration work across a repo's historical pack
/// set rather than only bounding one incoming push body.
pub git_max_repo_bytes: u64,
/// Maximum number of repos per pubkey. Default: 100.
pub git_max_repos_per_pubkey: u32,
/// Maximum concurrent git subprocess operations. Default: 20.
pub git_max_concurrent_ops: usize,
/// HMAC secret for git pre-receive hook callbacks.
/// Used to authenticate internal policy endpoint requests.
pub git_hook_hmac_secret: String,
/// Descriptor key identifier accepted in kind:30350 `exec` tags.
pub push_executor_key_id: String,
/// Exact HTTPS gateway endpoint used to submit client-authorized APNs delivery capabilities.
/// Push lease support is disabled when unset.
pub push_gateway_delivery_url: Option<url::Url>,
/// Hard timeout for one gateway delivery request.
pub push_gateway_timeout: Duration,
/// Optional relay-hosted policy shown on join surfaces. Disabled when no
/// documents or age attestation are configured.
pub join_policy: Option<JoinPolicyConfig>,
/// Optional path to the web UI `dist/` directory.
/// When set, the relay serves the invite landing page and its static assets.
/// When unset, no static file serving happens (relay behaves as before).
pub web_dir: Option<std::path::PathBuf>,
/// Whether the configured web bundle serves Git browser routes in addition
/// to the public invite landing page. Defaults to false.
pub serve_git_web_gui: bool,
}
fn parse_bind_addr(raw: &str) -> Result<SocketAddr, ConfigError> {
raw.parse::<SocketAddr>()
.map_err(|e| ConfigError::InvalidBindAddr(e.to_string()))
}
fn positive_u64_from_env(name: &str, default: u64) -> Result<u64, ConfigError> {
match std::env::var(name) {
Ok(raw) => raw
.parse::<u64>()
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| ConfigError::InvalidValue(format!("{name} must be a positive integer"))),
Err(std::env::VarError::NotPresent) => Ok(default),
Err(std::env::VarError::NotUnicode(_)) => Err(ConfigError::InvalidValue(format!(
"{name} must be valid Unicode"
))),
}
}
fn rate_limit_config_from_env() -> Result<buzz_auth::RateLimitConfig, ConfigError> {
let defaults = buzz_auth::RateLimitConfig::default();
Ok(buzz_auth::RateLimitConfig {
human_messages_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN",
defaults.human_messages_per_min,
)?,
human_api_calls_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN",
defaults.human_api_calls_per_min,
)?,
human_ws_events_per_sec: positive_u64_from_env(
"BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC",
defaults.human_ws_events_per_sec,
)?,
agent_standard_messages_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN",
defaults.agent_standard_messages_per_min,
)?,
agent_standard_api_calls_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_AGENT_STANDARD_API_CALLS_PER_MIN",
defaults.agent_standard_api_calls_per_min,
)?,
agent_elevated_messages_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN",
defaults.agent_elevated_messages_per_min,
)?,
agent_platform_messages_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN",
defaults.agent_platform_messages_per_min,
)?,
})
}
fn parse_operator_api_origin(raw: &str) -> Result<String, ConfigError> {
let raw = raw.trim();
let url = url::Url::parse(raw).map_err(|e| {
ConfigError::InvalidValue(format!("RELAY_OPERATOR_API_ORIGIN is not a valid URL: {e}"))
})?;
if !matches!(url.scheme(), "http" | "https")
|| url.host().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.path() != "/"
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(ConfigError::InvalidValue(
"RELAY_OPERATOR_API_ORIGIN must be an http(s) origin with no credentials, path, query, or fragment"
.to_string(),
));
}
Ok(raw.trim_end_matches('/').to_string())
}
const DEFAULT_PUSH_GATEWAY_DELIVERY_URL: &str = "https://push.buzz.xyz/v1/deliveries/apns";
fn parse_push_gateway_delivery_url(raw: &str) -> Result<url::Url, ConfigError> {
let url = url::Url::parse(raw.trim()).map_err(|e| {
ConfigError::InvalidValue(format!(
"BUZZ_PUSH_GATEWAY_DELIVERY_URL is not a valid URL: {e}"
))
})?;
if url.scheme() != "https"
|| url.host().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.path() != "/v1/deliveries/apns"
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(ConfigError::InvalidValue(
"BUZZ_PUSH_GATEWAY_DELIVERY_URL must be an exact HTTPS /v1/deliveries/apns URL without credentials, query, or fragment"
.to_string(),
));
}
Ok(url)
}
fn parse_optional_bool(name: &str) -> Result<bool, ConfigError> {
match std::env::var(name) {
Err(std::env::VarError::NotPresent) => Ok(false),
Err(error) => Err(ConfigError::InvalidValue(format!(
"{name} must be valid UTF-8: {error}"
))),
Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "on" => Ok(true),
"false" | "0" | "off" | "" => Ok(false),
_ => Err(ConfigError::InvalidValue(format!(
"{name} must be true or false"
))),
},
}
}
fn ensure_git_repo_path(
raw: impl Into<std::path::PathBuf>,
) -> Result<std::path::PathBuf, ConfigError> {
let git_repo_path = raw.into();
if let Err(e) = std::fs::create_dir_all(&git_repo_path) {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_GIT_REPO_PATH={} could not be created: {e}",
git_repo_path.display()
)));
}
Ok(git_repo_path)
}
impl Config {
/// Loads configuration from environment variables, falling back to development defaults.
pub fn from_env() -> Result<Self, ConfigError> {
let bind_addr_raw =
std::env::var("BUZZ_BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".to_string());
let bind_addr = parse_bind_addr(&bind_addr_raw)?;
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1
let redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());
let relay_url =
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
let pairing_relay_url = std::env::var("BUZZ_PAIRING_RELAY_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(|value| {
let parsed = url::Url::parse(&value).map_err(|e| {
ConfigError::InvalidValue(format!(
"BUZZ_PAIRING_RELAY_URL must be a valid ws:// or wss:// URL: {e}"
))
})?;
if !matches!(parsed.scheme(), "ws" | "wss") || parsed.host_str().is_none() {
return Err(ConfigError::InvalidValue(
"BUZZ_PAIRING_RELAY_URL must be a valid ws:// or wss:// URL".to_string(),
));
}
Ok(value)
})
.transpose()?;
let max_connections = std::env::var("BUZZ_MAX_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10_000);
let max_concurrent_handlers = std::env::var("BUZZ_MAX_CONCURRENT_HANDLERS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1024);
let send_buffer_size = std::env::var("BUZZ_SEND_BUFFER")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1_000);
let max_frame_bytes = std::env::var("BUZZ_MAX_FRAME_BYTES")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&v| v > 0)
.unwrap_or(DEFAULT_MAX_FRAME_BYTES);
let slow_client_grace_limit = std::env::var("BUZZ_SLOW_CLIENT_GRACE_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(15);
let require_auth_token = std::env::var("BUZZ_REQUIRE_AUTH_TOKEN")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let pubkey_allowlist_enabled = std::env::var("BUZZ_PUBKEY_ALLOWLIST")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let require_relay_membership = std::env::var("BUZZ_REQUIRE_RELAY_MEMBERSHIP")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
// Defaults true → single-pod (N=1) keeps today's huddle behavior. A
// horizontally-scaled deployment sets this false; see the field doc.
let huddle_audio_available = std::env::var("BUZZ_HUDDLE_AUDIO_AVAILABLE")
.map(|v| !(v == "false" || v == "0"))
.unwrap_or(true);
// Mesh opt-in: default OFF. Strict rollout no-regression — an image
// upgrade with untouched env must not bind a new UDP port or write a
// new Redis key. Horizontally-scaled deployments explicitly set
// `BUZZ_MESH=on`; anything else (absent, `off`, other values) keeps
// exact single-instance behavior.
let mesh_enabled = std::env::var("BUZZ_MESH")
.map(|v| v.eq_ignore_ascii_case("on") || v == "true" || v == "1")
.unwrap_or(false);
let mesh_bind_addr = std::env::var("BUZZ_MESH_BIND_ADDR")
.map(|raw| {
raw.parse::<SocketAddr>().map_err(|e| {
ConfigError::InvalidValue(format!("invalid BUZZ_MESH_BIND_ADDR: {e}"))
})
})
.unwrap_or_else(|_| Ok("0.0.0.0:3478".parse().expect("static default parses")))?;
let mesh = buzz_relay_mesh::MeshConfig {
enabled: mesh_enabled,
bind_addr: mesh_bind_addr,
registry_refresh: std::time::Duration::from_secs(15),
};
// Demo echo opt-in: same strict pattern as BUZZ_MESH — explicit
// `on`/`true`/`1` only, anything else (absent, `off`, typos) is off.
let mesh_demo_echo = std::env::var("BUZZ_MESH_DEMO_ECHO")
.map(|v| v.eq_ignore_ascii_case("on") || v == "true" || v == "1")
.unwrap_or(false);
let allow_nip_oa_auth = std::env::var("BUZZ_ALLOW_NIP_OA_AUTH")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
// Note: intentionally not prefixed with BUZZ_ — this is a relay-identity
// config that may be shared across multiple services (e.g., ACP agent).
let relay_owner_pubkey = std::env::var("RELAY_OWNER_PUBKEY")
.ok()
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.and_then(|s| {
// Must be exactly 64 lowercase hex characters (32-byte pubkey).
let valid = s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit());
if valid {
Some(s)
} else {
warn!(
"RELAY_OWNER_PUBKEY is not a valid 64-char hex pubkey — ignoring. \
Got: {s:?}"
);
None
}
});
// Note: intentionally not prefixed with BUZZ_ — same relay-identity
// config family as RELAY_OWNER_PUBKEY. Comma-separated 64-char hex
// pubkeys. Unlike RELAY_OWNER_PUBKEY (warn-and-ignore), an invalid
// entry here is a hard config error: silently dropping an operator
// pubkey would silently disable provisioning for that operator.
let relay_operator_api_origin = std::env::var("RELAY_OPERATOR_API_ORIGIN")
.ok()
.filter(|raw| !raw.trim().is_empty())
.map(|raw| parse_operator_api_origin(&raw))
.transpose()?;
let relay_operator_pubkeys = match std::env::var("RELAY_OPERATOR_PUBKEYS") {
Ok(raw) => {
let mut pubkeys = Vec::new();
for entry in raw.split(',') {
let entry = entry.trim().to_lowercase();
if entry.is_empty() {
continue;
}
let valid = entry.len() == 64 && entry.chars().all(|c| c.is_ascii_hexdigit());
if !valid {
return Err(ConfigError::InvalidValue(format!(
"RELAY_OPERATOR_PUBKEYS entry is not a valid 64-char hex pubkey: {entry:?}"
)));
}
if !pubkeys.contains(&entry) {
pubkeys.push(entry);
}
}
pubkeys
}
Err(_) => Vec::new(),
};
if !relay_operator_pubkeys.is_empty() && relay_operator_api_origin.is_none() {
return Err(ConfigError::InvalidValue(
"RELAY_OPERATOR_API_ORIGIN is required when RELAY_OPERATOR_PUBKEYS is configured"
.to_string(),
));
}
let auth = buzz_auth::AuthConfig {
rate_limits: rate_limit_config_from_env()?,
};
if !require_auth_token {
warn!(
"BUZZ_REQUIRE_AUTH_TOKEN is false — REST API requests bypass token auth. \
WebSocket protocol auth is unaffected. Set to true for production."
);
}
let cors_origins = std::env::var("BUZZ_CORS_ORIGINS")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let relay_private_key = std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok();
let uds_path = std::env::var("BUZZ_UDS_PATH")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let health_port = std::env::var("BUZZ_HEALTH_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8080);
let metrics_port = std::env::var("BUZZ_METRICS_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(9102);
let media = buzz_media::MediaConfig {
s3_endpoint: std::env::var("BUZZ_S3_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:9000".to_string()),
s3_access_key: std::env::var("BUZZ_S3_ACCESS_KEY")
.unwrap_or_else(|_| "buzz_dev".to_string()),
s3_secret_key: std::env::var("BUZZ_S3_SECRET_KEY")
.unwrap_or_else(|_| "buzz_dev_secret".to_string()),
s3_bucket: std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-media".to_string()),
s3_region: std::env::var("BUZZ_S3_REGION")
.or_else(|_| std::env::var("AWS_REGION"))
.unwrap_or_else(|_| "us-east-1".to_string()),
max_image_bytes: std::env::var("BUZZ_MAX_IMAGE_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(50 * 1024 * 1024),
max_gif_bytes: std::env::var("BUZZ_MAX_GIF_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10 * 1024 * 1024),
max_video_bytes: std::env::var("BUZZ_MAX_VIDEO_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500 * 1024 * 1024),
max_file_bytes: std::env::var("BUZZ_MAX_FILE_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100 * 1024 * 1024),
public_base_url: std::env::var("BUZZ_MEDIA_BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000/media".to_string()),
// Per-upload-event records (`_uploads/` moderation side channel).
// Off by default; coherence between the three knobs is enforced in
// MediaConfig::validate at startup.
upload_records_enabled: std::env::var("BUZZ_MEDIA_UPLOAD_RECORDS")
.map(|v| v == "true" || v == "1")
.unwrap_or(false),
upload_ip_header: std::env::var("BUZZ_MEDIA_UPLOAD_IP_HEADER")
.ok()
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty()),
upload_port_header: std::env::var("BUZZ_MEDIA_UPLOAD_PORT_HEADER")
.ok()
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty()),
};
let media_max_concurrent_uploads: usize =
std::env::var("BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&v| v > 0)
.unwrap_or(8);
let media_max_concurrent_uploads_per_pubkey: u32 =
std::env::var("BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&v| v > 0)
.unwrap_or(2)
.min(u32::try_from(media_max_concurrent_uploads).unwrap_or(u32::MAX));
let media_uploads_per_minute: u32 = std::env::var("BUZZ_MEDIA_UPLOADS_PER_MINUTE")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&v| v > 0)
.unwrap_or(30);
let require_media_get_auth = std::env::var("BUZZ_REQUIRE_MEDIA_GET_AUTH")
.map(|v| {
v == "true"
|| v == "1"
|| v.eq_ignore_ascii_case("yes")
|| v.eq_ignore_ascii_case("on")
})
.unwrap_or(false);
let ephemeral_ttl_override = std::env::var("BUZZ_EPHEMERAL_TTL_OVERRIDE")
.ok()
.and_then(|v| v.parse::<i32>().ok())
.filter(|&v| v > 0);
if let Some(ttl) = ephemeral_ttl_override {
warn!(
"BUZZ_EPHEMERAL_TTL_OVERRIDE={ttl}s — all ephemeral channels will use \
this TTL instead of the client-provided value."
);
}
// Git server config
let git_repo_path = ensure_git_repo_path(
std::env::var("BUZZ_GIT_REPO_PATH").unwrap_or_else(|_| "./repos".to_string()),
)?;
let git_max_pack_bytes: u64 = std::env::var("BUZZ_GIT_MAX_PACK_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500 * 1024 * 1024); // 500 MB
let git_max_repo_bytes: u64 = std::env::var("BUZZ_GIT_MAX_REPO_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| git_max_pack_bytes.saturating_mul(2)); // 1 GB at defaults
let git_max_repos_per_pubkey: u32 = std::env::var("BUZZ_GIT_MAX_REPOS_PER_PUBKEY")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
let git_max_concurrent_ops: usize = std::env::var("BUZZ_GIT_MAX_CONCURRENT_OPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let git_hook_hmac_secret: String = std::env::var("BUZZ_GIT_HOOK_HMAC_SECRET")
.unwrap_or_else(|_| {
// Generate a random secret if not configured (dev mode).
let secret: [u8; 32] = rand::random();
hex::encode(secret)
});
let push_executor_key_id =
std::env::var("BUZZ_PUSH_EXECUTOR_KEY_ID").unwrap_or_else(|_| "relay-v1".to_string());
if push_executor_key_id.is_empty() || push_executor_key_id.len() > 64 {
return Err(ConfigError::InvalidValue(
"BUZZ_PUSH_EXECUTOR_KEY_ID must contain 1..=64 bytes".to_string(),
));
}
let push_gateway_delivery_url = match std::env::var("BUZZ_PUSH_GATEWAY_DELIVERY_URL") {
Ok(raw) if raw.trim().is_empty() => None,
Ok(raw) => Some(parse_push_gateway_delivery_url(&raw)?),
Err(_) => Some(parse_push_gateway_delivery_url(
DEFAULT_PUSH_GATEWAY_DELIVERY_URL,
)?),
};
let push_gateway_timeout_millis = match std::env::var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS") {
Ok(raw) => raw
.parse::<u64>()
.ok()
.filter(|millis| (100..=10_000).contains(millis))
.ok_or_else(|| {
ConfigError::InvalidValue(
"BUZZ_PUSH_GATEWAY_TIMEOUT_MS must be an integer in 100..=10000"
.to_string(),
)
})?,
Err(_) => 2_000,
};
let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis);
const MAX_POLICY_MARKDOWN_BYTES: usize = 256 * 1024;
let read_policy_markdown = |name: &str| -> Result<Option<String>, ConfigError> {
let value = std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if value
.as_ref()
.is_some_and(|value| value.len() > MAX_POLICY_MARKDOWN_BYTES)
{
return Err(ConfigError::InvalidValue(format!(
"{name} must contain at most {MAX_POLICY_MARKDOWN_BYTES} bytes"
)));
}
Ok(value)
};
let terms_markdown = read_policy_markdown("BUZZ_TERMS_OF_SERVICE_MARKDOWN")?;
let privacy_markdown = read_policy_markdown("BUZZ_PRIVACY_POLICY_MARKDOWN")?;
let age_attestation_required = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED")?;
let join_policy = if terms_markdown.is_none()
&& privacy_markdown.is_none()
&& !age_attestation_required
{
None
} else {
let mut hasher = Sha256::new();
hasher.update(terms_markdown.as_deref().unwrap_or_default().as_bytes());
hasher.update([0]);
hasher.update(privacy_markdown.as_deref().unwrap_or_default().as_bytes());
hasher.update([0, u8::from(age_attestation_required)]);
Some(JoinPolicyConfig {
terms_markdown,
privacy_markdown,
age_attestation_required,
version: hex::encode(hasher.finalize()),
})
};
// Web UI static file serving
let web_dir = std::env::var("BUZZ_WEB_DIR")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from);
let serve_git_web_gui = std::env::var("BUZZ_SERVE_GIT_WEB_GUI")
.map(|value| value == "true" || value == "1")
.unwrap_or(false);
if let Some(ref dir) = web_dir {
if !dir.join("index.html").is_file() {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_WEB_DIR={} does not contain index.html",
dir.display()
)));
}
tracing::info!("BUZZ_WEB_DIR={} — serving web UI from relay", dir.display());
}
// Reject explicitly-configured secrets that are too short.
// The auto-generated fallback is always 64 hex chars (32 bytes), so this
// only fires when someone sets BUZZ_GIT_HOOK_HMAC_SECRET to a weak value.
if std::env::var("BUZZ_GIT_HOOK_HMAC_SECRET").is_ok() && git_hook_hmac_secret.len() < 32 {
return Err(ConfigError::InvalidValue(
"BUZZ_GIT_HOOK_HMAC_SECRET must be at least 32 characters (16 bytes hex)"
.to_string(),
));
}
Ok(Self {
bind_addr,
database_url,
redis_url,
relay_url,
pairing_relay_url,
max_connections,
max_concurrent_handlers,
send_buffer_size,
max_frame_bytes,
slow_client_grace_limit,
auth,
require_auth_token,
cors_origins,
relay_private_key,
uds_path,
health_port,
metrics_port,
pubkey_allowlist_enabled,
require_relay_membership,
huddle_audio_available,
mesh,
mesh_demo_echo,
relay_owner_pubkey,
relay_operator_api_origin,
relay_operator_pubkeys,
allow_nip_oa_auth,
media,
media_max_concurrent_uploads,
media_max_concurrent_uploads_per_pubkey,
media_uploads_per_minute,
require_media_get_auth,
ephemeral_ttl_override,
git_repo_path,
git_max_pack_bytes,
git_max_repo_bytes,
git_max_repos_per_pubkey,
git_max_concurrent_ops,
git_hook_hmac_secret,
push_executor_key_id,
push_gateway_delivery_url,
push_gateway_timeout,
join_policy,
web_dir,
serve_git_web_gui,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
// Mutex to serialize tests that mutate environment variables.
// Parallel env-var mutation causes `defaults_are_valid` to see the invalid
// value set by `invalid_bind_addr_returns_error`, causing a flaky failure.
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn defaults_are_valid() {
let _guard = ENV_MUTEX.lock().unwrap();
let config = Config::from_env().expect("default config");
assert!(config.bind_addr.port() > 0);
assert!(!config.database_url.is_empty());
assert!(!config.redis_url.is_empty());
assert!(config.max_connections > 0);
assert!(config.send_buffer_size > 0);
assert_eq!(config.max_frame_bytes, DEFAULT_MAX_FRAME_BYTES);
assert!(config.slow_client_grace_limit > 0);
assert!(
!config.pubkey_allowlist_enabled,
"pubkey_allowlist_enabled should default to false"
);
assert!(
!config.require_relay_membership,
"require_relay_membership should default to false"
);
assert!(
config.relay_owner_pubkey.is_none(),
"relay_owner_pubkey should default to None"
);
assert!(
config.relay_operator_pubkeys.is_empty(),
"relay_operator_pubkeys should default empty (provisioning disabled)"
);
assert!(
!config.allow_nip_oa_auth,
"allow_nip_oa_auth should default to false"
);
assert!(
!config.serve_git_web_gui,
"serve_git_web_gui should default to false"
);
assert!(
!config.require_media_get_auth,
"require_media_get_auth should default to false for staged client rollout"
);
assert!(
config.join_policy.is_none(),
"join_policy should default to None so policy prompts and acceptance receipts are opt-in"
);
assert!(
config.huddle_audio_available,
"huddle_audio_available should default to true so single-pod (N=1) keeps today's huddle behavior"
);
}
#[test]
fn join_policy_age_attestation_rejects_invalid_boolean() {
let _guard = ENV_MUTEX.lock().unwrap();
let previous = std::env::var_os("BUZZ_AGE_ATTESTATION_REQUIRED");
std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", "sometimes");
let result = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED");
if let Some(value) = previous {
std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", value);
} else {
std::env::remove_var("BUZZ_AGE_ATTESTATION_REQUIRED");
}
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_AGE_ATTESTATION_REQUIRED")
));
}
#[test]
fn rate_limits_can_be_overridden() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN", "1001");
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN", "1002");
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC", "1003");
let config = Config::from_env().expect("config");
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN");
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN");
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC");
assert_eq!(config.auth.rate_limits.human_messages_per_min, 1001);
assert_eq!(config.auth.rate_limits.human_api_calls_per_min, 1002);
assert_eq!(config.auth.rate_limits.human_ws_events_per_sec, 1003);
}
#[test]
fn rate_limit_overrides_reject_zero() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC", "0");
let result = Config::from_env();
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC")
));
}
#[test]
fn relay_operator_pubkeys_parse_dedupe_and_normalize() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var(
"RELAY_OPERATOR_PUBKEYS",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
std::env::set_var(
"RELAY_OPERATOR_API_ORIGIN",
"http://buzz.mesh.bb-production.com",
);
let config = Config::from_env().expect("config");
std::env::remove_var("RELAY_OPERATOR_PUBKEYS");
std::env::remove_var("RELAY_OPERATOR_API_ORIGIN");
assert_eq!(
config.relay_operator_pubkeys,
vec![
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
]
);
}
#[test]
fn relay_operator_pubkeys_invalid_entry_is_error() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("RELAY_OPERATOR_PUBKEYS", "not-a-pubkey");
let result = Config::from_env();
std::env::remove_var("RELAY_OPERATOR_PUBKEYS");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref msg)) if msg.contains("RELAY_OPERATOR_PUBKEYS")
));
}
#[test]
fn relay_operator_pubkeys_require_api_origin() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var(
"RELAY_OPERATOR_PUBKEYS",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
std::env::remove_var("RELAY_OPERATOR_API_ORIGIN");
let result = Config::from_env();
std::env::remove_var("RELAY_OPERATOR_PUBKEYS");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref msg)) if msg.contains("RELAY_OPERATOR_API_ORIGIN is required")
));
}
#[test]
fn relay_operator_api_origin_rejects_paths() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("RELAY_OPERATOR_API_ORIGIN", "https://buzz.example/operator");
let result = Config::from_env();
std::env::remove_var("RELAY_OPERATOR_API_ORIGIN");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref msg)) if msg.contains("must be an http(s) origin")
));
}
#[test]
fn push_gateway_defaults_to_buzz_and_can_be_disabled() {
let _guard = ENV_MUTEX.lock().unwrap();
let previous = std::env::var_os("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
let config = Config::from_env().expect("default config");
assert_eq!(
config
.push_gateway_delivery_url
.as_ref()
.map(url::Url::as_str),
Some(DEFAULT_PUSH_GATEWAY_DELIVERY_URL)
);
std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", "");
let config = Config::from_env().expect("disabled push config");
assert!(config.push_gateway_delivery_url.is_none());
if let Some(value) = previous {
std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", value);
} else {
std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
}
}
#[test]
fn push_gateway_url_is_exact_and_fail_closed() {
assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok());
for invalid in [
"http://push.example/v1/deliveries/apns",
"https://push.example/v1/deliveries/apns/",
"https://push.example/v1/deliveries/apns?token=x",
"https://user@push.example/v1/deliveries/apns",
] {
assert!(
parse_push_gateway_delivery_url(invalid).is_err(),
"{invalid}"
);
}
}
#[test]
fn invalid_push_gateway_timeout_is_not_silently_defaulted() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS", "99");
let result = Config::from_env();
std::env::remove_var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_PUSH_GATEWAY_TIMEOUT_MS")
));
}
#[test]
fn invalid_push_executor_key_id_is_rejected() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("BUZZ_PUSH_EXECUTOR_KEY_ID", "");
let result = Config::from_env();
std::env::remove_var("BUZZ_PUSH_EXECUTOR_KEY_ID");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_PUSH_EXECUTOR_KEY_ID")
));
}
#[test]
fn huddle_audio_available_can_be_disabled_for_horizontal_scaling() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("BUZZ_HUDDLE_AUDIO_AVAILABLE", "false");
let config = Config::from_env().expect("config");
std::env::remove_var("BUZZ_HUDDLE_AUDIO_AVAILABLE");
assert!(
!config.huddle_audio_available,
"BUZZ_HUDDLE_AUDIO_AVAILABLE=false must disable huddle audio (multi-pod deployments)"
);
}
#[test]
fn invalid_bind_addr_returns_error() {
assert!(matches!(
parse_bind_addr("not-an-addr"),
Err(ConfigError::InvalidBindAddr(_))
));
}
#[test]
fn pairing_relay_url_accepts_websocket_urls_and_rejects_http() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("BUZZ_PAIRING_RELAY_URL", "wss://pairing.buzz.xyz");
let config = Config::from_env().expect("config");
assert_eq!(
config.pairing_relay_url.as_deref(),
Some("wss://pairing.buzz.xyz")
);
std::env::set_var("BUZZ_PAIRING_RELAY_URL", "https://pairing.buzz.xyz");
let result = Config::from_env();
std::env::remove_var("BUZZ_PAIRING_RELAY_URL");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref msg)) if msg.contains("BUZZ_PAIRING_RELAY_URL")
));
}
#[test]
fn max_frame_bytes_can_be_configured() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("BUZZ_MAX_FRAME_BYTES", "262144");
let config = Config::from_env().expect("config");
std::env::remove_var("BUZZ_MAX_FRAME_BYTES");
assert_eq!(config.max_frame_bytes, 262_144);
}
#[test]
fn git_repo_path_is_created_if_missing() {
let _guard = ENV_MUTEX.lock().unwrap();
// Pick a path under temp_dir that definitely doesn't exist yet.
let base = std::env::temp_dir().join(format!(
"buzz-test-git-repo-path-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let nested = base.join("nested").join("repos");
assert!(!nested.exists(), "test precondition: path must not exist");
std::env::set_var("BUZZ_GIT_REPO_PATH", &nested);
let result = Config::from_env();
std::env::remove_var("BUZZ_GIT_REPO_PATH");
let config = result.expect("config should self-bootstrap missing git_repo_path");
assert_eq!(config.git_repo_path, nested);
assert!(
nested.is_dir(),
"git_repo_path should exist after config load"
);
// Cleanup.
let _ = std::fs::remove_dir_all(&base);
}
#[test]
#[cfg(unix)]
fn git_repo_path_unwritable_returns_error() {
// Try to create a path under a regular file — must fail.
// Using /dev/null as the parent guarantees create_dir_all fails on unix.
let bogus = std::path::PathBuf::from("/dev/null/cannot-create-here");
let result = ensure_git_repo_path(&bogus);
assert!(
matches!(result, Err(ConfigError::InvalidValue(ref msg)) if msg.contains("BUZZ_GIT_REPO_PATH")),
"expected InvalidValue mentioning BUZZ_GIT_REPO_PATH, got {result:?}"
);
}
}