mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Why
The usage poller pays memory and query costs proportional to community
count even when per-community emission is disabled. Every pod loads the
full host map, and the leader materializes all per-community aggregates
before suppressing their labels. That makes telemetry collection itself
a scaling constraint.
## What changed
- Fleet-only usage metrics are now the default. `all` remains available
as an explicit rollback mode.
- In fleet-only mode, the poller skips the community host map and all
community-sized query results.
- Exact community counting is replaced with the PostgreSQL planner's
table-size estimate.
- Stock totals are collected as one fixed row, scanning each stock table
once per hour.
- 1d/7d/30d active-user totals come from one bounded 30-day scan per
day.
- Fleet database snapshots run only on a proved read replica, with a
telemetry-specific freshness budget and server-side statement timeouts.
If no such replica is available, the collection is skipped; it never
falls back to the writer.
- Successful fixed-row snapshots are cached and re-emitted on each poll
tick, so gauges stay scrape-visible without raising query frequency.
- `buzz_usage_snapshot_available{family}` reports whether the latest due
refresh of that family succeeded. After a skipped or failed refresh, the
last values stay visible (with `buzz_usage_snapshot_age_seconds`) but
the family reads unavailable until a 60-second retry succeeds.
- The `all` rollback collector publishes the same availability gauges,
so dashboards gated on availability keep working during a rollback.
- Each due refresh outcome is applied by `apply_stock_refresh` /
`apply_activity_refresh`, the single production mapping from `Ok(Some)`
/ `Ok(None)` / `Err` to cache, availability, skip counter, and retry;
the regression test drives that mapping directly.
- `buzz_usage_snapshot_age_seconds` is emitted in the `all` rollback
too, so age-based alerts keep firing through a rollback.
- Every fleet telemetry attempt that completes lands in
`buzz_db_route_decision`; a query error on a proved reader records
`skipped/replica_error`. An attempt abandoned at the relay deadline
records no route decision and appears only as
`buzz_usage_query_skipped_total{reason="timeout"}`. The shared
`proved_reader` warnings no longer claim "routing to writer", because
the telemetry caller never does (serving callers still record their
writer decision in the route counter).
- The PostgreSQL production-method matrix now exercises
`usage_fleet_stock_snapshot`, so the new `reader/maintenance` pool pair
is proven to be emitted by a real Db method.
- Each fleet collection, reader proof included, runs under a relay-side
`tokio::time::timeout` (10s stock, 20s activity, both above their
server-side `statement_timeout`). A reader that stops answering expires
into the same failure mapping with
`buzz_usage_query_skipped_total{reason="timeout"}`, so it cannot stall
the leader's poller, hold the advisory lock past a liveness check, or
leave a frozen snapshot reporting available.
- Fleet telemetry reader checkouts (`ReaderOperation::Maintenance`) opt
into SQLx `close_on_drop` before their first post-acquire await, so an
abandoned collection's connection is closed within SQLx's 5s
`CLOSE_ON_DROP_TIMEOUT` instead of going through the unbounded
return-to-pool ping while it holds a slot in the reader pool that
serving reads share. Serving reads keep pooled reuse; the cost is one
reader reconnect per fleet refresh.
- On leader demotion, cached snapshots and availability are cleared, and
a fresh collection is forced after the lock is reacquired.
- Fleet-only mode passes no host map to the worker-snapshot storage
emitter. It therefore omits community attribution instead of reporting
every byte as `buzz_storage_unmapped_community_bytes`.
- Lifetime stored-message and active-channel totals are no longer
queried in fleet-only mode. Event throughput remains available through
the existing stored-event counter.
- No test-selector changes: main's `config::tests::` selector in `just
test-unit` and the `run-tests.sh unit` fallback already runs the new
replica-budget config test, and the new fleet schedule, availability,
and storage tests run through the existing bin `tests::` and
`storage_sweep::tests::` selectors.
- Added seeded PostgreSQL contract tests and operator docs covering
rollout, rollback, availability, the no-replica behavior, and leader
election.
No migrations, tables, indexes, or new persistent storage are added.
### What got simpler
The relay-side storage half of the original branch (totals-only S3
listing and `buzz_storage_community_breakdown_available`) was dropped.
Main moved S3 listing out of the relay in #7543 and #7845, so that code
was dead on arrival. The rework adds no storage code path; it only gates
attribution on the presence of a host map.
## Risk
Medium. The default changes which telemetry is emitted:
- In fleet-only mode, `buzz_communities_estimated` replaces the exact
`buzz_communities_total`.
- `buzz_total_messages` and `buzz_total_active_channels` are not
refreshed in fleet-only mode (also called out in the rollout steps of
`docs/usage-metrics.md`).
- Deployments without a verified reader (including local compose and
single-node self-hosts) skip database-derived fleet snapshots instead of
querying the writer, and report both families unavailable.
- Multi-pod dashboards must filter database and storage series to
`buzz_usage_poller_is_leader == 1`.
Setting `BUZZ_USAGE_METRICS_PER_COMMUNITY=all` restores the legacy
collector for temporary rollback.
## Verification
At head `1879ee3aedf4227942120a3b521c8d35fc7839fe` (tree `78477e74`;
merges main `da651ad6`; the PR's diff against main is byte-identical to
`1613823f`'s, minus the Justfile and `run-tests.sh` selector lines that
main's `config::tests::` selector now subsumes):
- On Blox: `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p
buzz-relay --all-targets -- -D warnings` pass. Relay bin `tests::`
34/34, `config::tests::` 55/55, `storage_sweep::tests::` 10/10 and
`store::usage::tests` pass, as do the four `fleet_` and lock PostgreSQL
tests that use scratch databases, including
`abandoned_fleet_collection_releases_its_reader_slot`.
- Against a freshly migrated PostgreSQL database:
`store::usage::postgres_tests::` 14/14,
`storage_sweep::postgres_tests::` and `buzz-db --test
observability_source` 14/14 pass. The new reader code passes main's
crate-wide typed-acquisition and serving-table policy checks.
- E2E at `1613823f` is reused for this head. It ran in an isolated relay
against a real PostgreSQL primary and streaming standby. In 2/2 runs a
stock read went dark mid-result; the deadline abandoned it at about 10s,
the reader pool's active and size counts dropped to zero about 1s later,
and a fresh activity read and the scheduled stock retry succeeded on new
backends while the dark session stayed open. The `all`-mode and
healthy-replica controls also passed. Main's merged changes are on the
writer path and in startup step timing, and touch none of the reader
checkout, the `close_on_drop` seam, the fleet poller or the deadlines.
At head `1613823fa8ef1a423bc1a499c57bc40d692fcd4f` (tree `12bc6d1b`;
test/docs-only on top of `57749418`), on a Blox workstation:
- `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p
buzz-relay --all-targets -- -D warnings` pass. Relay bin `tests::` 34/34
pass.
- New PostgreSQL test
`abandoned_fleet_collection_releases_its_reader_slot`: a one-slot reader
pool behind a loopback proxy that goes dark mid-session; the collection
is cut at a deadline, then a later acquire must succeed in under 8s on a
fresh connection. The dark call runs on a fresh `Db` with a cold Aurora
capability cache, so the first post-acquire await is the capability
probe. It passes in 3.4s. With `close_on_drop` removed, or moved below
the probe await, it fails with `PoolTimedOut` after the 15s acquire
timeout. The other three `fleet_` PostgreSQL tests that use scratch
databases pass.
- `each_fleet_family_is_abandoned_at_its_own_deadline` now has an outer
timeout: replacing the stock deadline with a bare `.await` fails it
(`Elapsed`) instead of hanging.
At head `fd194ec79d6c6796d6764564de225ad56485cfc7` (tree `79e94727`,
main merged at `932228936`), on a Blox workstation:
- `cargo fmt --all -- --check` and `cargo clippy -p buzz-relay -p
buzz-db --all-targets -- -D warnings` pass.
- Relay bin `tests::` nextest selection: 27/27 pass (plus
`store::usage::tests`), including
`fleet_refresh_outcomes_drive_availability_through_production_mapping`
and the new `stalled_fleet_collection_times_out_into_failed_refresh`
(paused time: two never-completing collections are abandoned at exactly
10s + 20s; both families read unavailable with cached values kept,
`reason="timeout"` skips, retry scheduled; the next tick runs and
recovers; no route decision is recorded for an abandoned attempt) and
`each_fleet_family_is_abandoned_at_its_own_deadline`. Compile-time
asserts keep each deadline above its `statement_timeout`.
- Falsifiability: replacing the stock deadline with a bare `.await`
makes the new test fail (the tick stalls); swapping the two deadlines
fails the per-family test; deleting `schedule.mark_stock_failure(now)`
from the `Ok(None)` arm fails the mapping test. Trees restored and
re-verified.
- At `970414db`: CI PostgreSQL Tests 809/809 including the
production-method matrix with `reader/maintenance`; Unit Tests ran
`finish_usage_read_records_every_attempt_in_route_decisions`.
- `just ci` / `just test` at `5b18914a` on Blox: not green for
environmental reasons (`mobile-check` could not reach pub.dev;
`_ensure-services` failed so service-backed suites did not run;
`buzz-acp git_bootstrap` fails identically on `origin/main`). PostgreSQL
and integration coverage comes from GitHub CI at this head.
E2E at the previous head `0a01e5f22` (isolated relay, real Postgres
primary plus streaming replica, worker storage snapshot, desktop
frontend in relay mode): with no reader, both families report
skipped/unavailable and route `skipped/disabled` (no writer queries);
fleet-only storage emits totals without `buzz_community_storage_*` or
unmapped bytes; `all` mode reports both availability gauges `1` with
per-community storage series; with a fresh replica, both fleet paths
route `replica/fresh` on `reader/maintenance` acquisitions; desktop
send/receive works. The cached success → due failure → recovery
transition was not reached live (hourly/daily cadence); it is covered by
the seam test above.
The original branch also recorded live verification at `aa3e7023`,
before this rework: reduced-idle cache re-emission, two-relay leader
takeover with demoted availability clearing, the no-reader skip with
60-second retry, `all` rollback restoring per-community series, and a
desktop smoke test. Those runs predate the availability and storage
changes and are not claimed for this head.
## Linear tracking
Related work: BUZZ-167.
---------
Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2833 lines
117 KiB
Rust
2833 lines
117 KiB
Rust
//! Relay configuration from environment variables.
|
|
|
|
use std::time::Duration;
|
|
use std::{collections::HashMap, net::SocketAddr};
|
|
|
|
use sha2::{Digest, Sha256};
|
|
use thiserror::Error;
|
|
use tracing::{error, 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),
|
|
}
|
|
|
|
/// Authentication mode for the deployment-admin API.
|
|
///
|
|
/// Configured by `BUZZ_ADMIN_AUTH`: unset/empty/`nip98` → `Nip98` (fail-secure
|
|
/// default), `disabled` → `Disabled`, anything else is a startup error.
|
|
///
|
|
/// # Role resolution (nip98 mode only)
|
|
///
|
|
/// In `nip98` mode the authenticated pubkey is resolved to an
|
|
/// `AdminPrincipal` at request time via [`crate::api::admin::auth::resolve_admin_principal`]:
|
|
/// - `Operator/Config` if pubkey ∈ `RELAY_OPERATOR_PUBKEYS`
|
|
/// - `Operator/OwnerFallback` if pubkey == `RELAY_OWNER_PUBKEY` **and**
|
|
/// `RELAY_OPERATOR_PUBKEYS` is empty (evaluated from config, never runtime rows)
|
|
/// - `Moderator/Db` from the `relay_operators` table otherwise
|
|
/// - `None` → 403 (no fall-through role, ever)
|
|
///
|
|
/// Disabled mode is always read-only. NIP-98 mode is read-write per resolved
|
|
/// principal.
|
|
#[derive(Debug, Clone)]
|
|
pub enum AdminAuth {
|
|
/// Authentication disabled. The operator has explicitly asserted
|
|
/// that the admin API is protected at the network layer (reverse proxy,
|
|
/// VPN, firewall). `Host`/`Origin` checks remain active as defense-in-depth.
|
|
/// Selected by `BUZZ_ADMIN_AUTH=disabled`.
|
|
/// Always read-only: `authorize()` resolves no principal for this mode, so
|
|
/// mutation and staffing routes always 403.
|
|
Disabled,
|
|
/// NIP-98 HTTP Auth. Every request must carry an `Authorization: Nostr`
|
|
/// header containing a signed kind-27235 event. The authenticated pubkey
|
|
/// is resolved to an [`crate::api::admin::auth::AdminPrincipal`] at request
|
|
/// time from config + DB. Selected by `BUZZ_ADMIN_AUTH=nip98` or by leaving
|
|
/// `BUZZ_ADMIN_AUTH` unset (fail-secure default). Read-write per resolved
|
|
/// principal; attributes mutations to a distinct human operator.
|
|
Nip98,
|
|
}
|
|
|
|
/// Deny-by-default deployment-admin configuration. Mutation and staffing routes
|
|
/// require a resolved principal (NIP-98 only); disabled mode is always
|
|
/// read-only.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AdminConfig {
|
|
/// Exact admin HTTP authority.
|
|
pub host: String,
|
|
/// Authentication mode selected at startup.
|
|
pub auth: AdminAuth,
|
|
/// Optional admin SPA bundle directory.
|
|
pub web_dir: Option<std::path::PathBuf>,
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// Optional KLIPY GIF-search integration owned by the relay operator.
|
|
///
|
|
/// The API key deliberately stays private and its [`Debug`] implementation is
|
|
/// redacted so dumping [`Config`] cannot disclose it.
|
|
#[derive(Clone)]
|
|
pub struct KlipyConfig {
|
|
api_key: String,
|
|
}
|
|
|
|
impl KlipyConfig {
|
|
/// Return the key only to the outbound KLIPY client.
|
|
pub(crate) fn api_key(&self) -> &str {
|
|
&self.api_key
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for KlipyConfig {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("KlipyConfig")
|
|
.field("api_key", &"[REDACTED]")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// Maximum configured jitter, leaving ten seconds of the hard-drain budget for
|
|
/// WebSocket close-frame delivery after the final delayed cancellation.
|
|
pub const MAX_DRAIN_JITTER_MS: u64 = 20_000;
|
|
|
|
/// 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,
|
|
/// Optional read-replica connection URL (e.g. an Aurora `cluster-ro-`
|
|
/// endpoint). Unset means all reads stay on the writer.
|
|
pub read_database_url: Option<String>,
|
|
/// Replica read budget `B` in milliseconds (`BUZZ_REPLICA_READ_MAX_AGE_MS`).
|
|
/// `0` (the default) disables bounded-staleness replica routing; see
|
|
/// [`buzz_db::DbConfig::replica_read_max_age_ms`].
|
|
pub replica_read_max_age_ms: u64,
|
|
/// Replica freshness budget for fleet usage telemetry
|
|
/// (`BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS`). Independent of serving-read
|
|
/// routing; defaults to 30 seconds and `0` disables telemetry DB queries.
|
|
pub usage_metrics_replica_max_age_ms: u64,
|
|
|
|
/// Upper bound, in milliseconds, of the per-connection random delay applied
|
|
/// when sending the `1012 Service Restart` close frame during graceful
|
|
/// shutdown (`BUZZ_DRAIN_JITTER_MS`). Each live connection is closed after
|
|
/// an independent delay drawn uniformly from `[1, drain_jitter_ms]` when
|
|
/// jitter is enabled, which
|
|
/// spreads client reconnects across the window instead of releasing the
|
|
/// whole pod's sockets in one instant (the reconnect thundering herd that
|
|
/// drives DB pool-timeout bursts on rolling deploys).
|
|
///
|
|
/// Default `0` reproduces the previous all-at-once close. Values above
|
|
/// [`MAX_DRAIN_JITTER_MS`] are capped, leaving headroom under the relay's
|
|
/// 30-second hard-drain timeout for close-frame delivery.
|
|
pub drain_jitter_ms: u64,
|
|
/// Redis connection URL used by the pub/sub manager.
|
|
pub redis_url: String,
|
|
/// Maximum connections in the shared Redis pool. Defaults to 16.
|
|
///
|
|
/// deadpool's own default is `CPU_COUNT * 2`, which on a 2-vCPU relay
|
|
/// pod is only 4 — small enough that rate-limit checks, presence, and
|
|
/// pub/sub publishes queue behind each other under load.
|
|
pub redis_pool_size: usize,
|
|
/// Maximum connections in the Postgres writer/reader pools. Defaults to 50.
|
|
///
|
|
/// The `buzz-db` default of 20 was sized for a handful of pods against
|
|
/// `max_connections=100`. Against Aurora (~5,000 connections) that cap
|
|
/// is the binding constraint: a burst of concurrent handlers exhausts
|
|
/// the per-pod pool and requests fail on acquire timeout while the
|
|
/// database sits idle.
|
|
pub db_pool_size: u32,
|
|
/// Maximum connections in the Postgres read-replica pool
|
|
/// (`BUZZ_DB_READ_POOL_SIZE`). Defaults to `db_pool_size`. Sized
|
|
/// independently so reader capacity can be tuned against the replica's
|
|
/// headroom without touching the writer pool.
|
|
pub db_read_pool_size: Option<u32>,
|
|
/// 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,
|
|
/// Opt-in private accessory API; disabled until explicitly deployed.
|
|
pub buzz_v1_enabled: bool,
|
|
/// Author-time unread tracking duration, independent of NIP-RS retention.
|
|
pub buzz_v1_retention_seconds: u32,
|
|
/// 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,
|
|
/// Interval between read-only partition catalog audits.
|
|
pub partition_audit_interval: Duration,
|
|
/// Whether the partition manager may create uncovered monthly partitions.
|
|
pub partition_manager_create_enabled: bool,
|
|
|
|
/// 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 only to
|
|
/// *use* the community-provisioning endpoints: when it is unset, those
|
|
/// endpoints fail closed at request time (see
|
|
/// `api::operator::authorize_operator_request`). It is NOT required at boot
|
|
/// even when `RELAY_OPERATOR_PUBKEYS` is set, because that allowlist is
|
|
/// shared with the NIP-98 admin console, which needs no origin. 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>,
|
|
|
|
/// Configured operator-listener identities and their HTTPS delivery URLs.
|
|
pub operator_listener_delivery_urls: HashMap<String, url::Url>,
|
|
|
|
/// 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,
|
|
|
|
/// Relay-owned KLIPY integration. Unset means GIF search is not advertised
|
|
/// and its proxy routes return 404.
|
|
pub klipy: Option<KlipyConfig>,
|
|
|
|
/// 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,
|
|
|
|
/// Whether tamper-evident event/media audit logging is enabled. Defaults to true.
|
|
/// This does not control the separate `moderation_actions` audit trail.
|
|
/// Set `BUZZ_AUDIT_ENABLED=false` for deployments that do not require it.
|
|
pub audit_enabled: 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 authoritative
|
|
/// repository state lives here — runtime reads/writes hydrate ephemeral
|
|
/// repos from object storage per request. Temporary workspaces, buffered
|
|
/// subprocess output, and the disposable immutable pack cache live below
|
|
/// 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,
|
|
/// Parent directory for process-isolated immutable pack cache sessions.
|
|
pub git_pack_cache_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 bytes retained in the process-local immutable pack/index cache.
|
|
/// Zero disables retention while preserving request-local hydration.
|
|
pub git_pack_cache_max_bytes: u64,
|
|
/// Maximum pack digests populated concurrently in one relay process.
|
|
pub git_pack_cache_max_concurrent_populations: usize,
|
|
/// 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,
|
|
|
|
/// Whether NIP-PL push discovery, lease acceptance, matching, and delivery
|
|
/// are enabled for this deployment. Defaults to false.
|
|
pub push_enabled: bool,
|
|
/// Descriptor key identifier accepted in kind:30350 `exec` tags.
|
|
pub push_executor_key_id: String,
|
|
/// Exact HTTP(S) gateway endpoint used to sign and submit APNs delivery capabilities.
|
|
/// Required while push is enabled. An explicitly empty setting is allowed
|
|
/// only while push is disabled.
|
|
pub push_gateway_delivery_url: Option<url::Url>,
|
|
/// Hard timeout for one push gateway delivery request.
|
|
pub push_gateway_timeout: Duration,
|
|
/// Hard timeout for one operator-listener delivery request.
|
|
pub operator_listener_timeout: Duration,
|
|
|
|
/// Optional relay-hosted policy shown on join surfaces. Disabled when no
|
|
/// documents or age attestation are configured.
|
|
pub join_policy: Option<JoinPolicyConfig>,
|
|
|
|
/// Deployment-admin API and SPA configuration. Absent means the surface is disabled.
|
|
pub admin: Option<AdminConfig>,
|
|
|
|
/// 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,
|
|
|
|
/// NIP-FI federated-identity enforcement configuration.
|
|
///
|
|
/// Selected by `BUZZ_NIP_FI_MODE`: `enforce` validates assertions at HTTP
|
|
/// ingress and (via S3) at WebSocket upgrade; `deny_protected` denies
|
|
/// protected requests without evaluating; `shadow` evaluates and records
|
|
/// the enforce verdict but admits exactly as `off`. `Off` (the default)
|
|
/// leaves all identity enforcement to NIP-42 alone.
|
|
pub nip_fi: crate::nip_fi_config::NipFiRelayConfig,
|
|
}
|
|
|
|
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,
|
|
)?,
|
|
gif_searches_per_min: positive_u64_from_env(
|
|
"BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN",
|
|
defaults.gif_searches_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())
|
|
}
|
|
|
|
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 !matches!(url.scheme(), "http" | "https")
|
|
|| url.host().is_none()
|
|
|| (url.scheme() == "https" && url.port().is_some())
|
|
|| !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 HTTP(S) /v1/deliveries/apns URL without credentials, query, or fragment; explicit ports are supported only for HTTP"
|
|
.to_string(),
|
|
));
|
|
}
|
|
Ok(url)
|
|
}
|
|
|
|
fn parse_operator_listener_delivery_urls(
|
|
raw: &str,
|
|
) -> Result<HashMap<String, url::Url>, ConfigError> {
|
|
let mut endpoints = HashMap::new();
|
|
let entries: Vec<&str> = raw
|
|
.split(';')
|
|
.map(str::trim)
|
|
.filter(|entry| !entry.is_empty())
|
|
.collect();
|
|
if !raw.trim().is_empty() && entries.is_empty() {
|
|
return Err(ConfigError::InvalidValue(
|
|
"BUZZ_OPERATOR_LISTENERS must contain pubkey:delivery_url pairs".to_string(),
|
|
));
|
|
}
|
|
for entry in entries {
|
|
let (pubkey, delivery_url) = entry.split_once(':').ok_or_else(|| {
|
|
ConfigError::InvalidValue(
|
|
"BUZZ_OPERATOR_LISTENERS entries must be pubkey:delivery_url pairs".to_string(),
|
|
)
|
|
})?;
|
|
let pubkey = pubkey.trim().to_ascii_lowercase();
|
|
if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
return Err(ConfigError::InvalidValue(format!(
|
|
"BUZZ_OPERATOR_LISTENERS entry has an invalid pubkey: {pubkey:?}"
|
|
)));
|
|
}
|
|
let url = url::Url::parse(delivery_url.trim()).map_err(|e| {
|
|
ConfigError::InvalidValue(format!(
|
|
"BUZZ_OPERATOR_LISTENERS endpoint is not a valid URL: {e}"
|
|
))
|
|
})?;
|
|
if url.scheme() != "https"
|
|
|| url.host().is_none()
|
|
|| !url.username().is_empty()
|
|
|| url.password().is_some()
|
|
|| url.query().is_some()
|
|
|| url.fragment().is_some()
|
|
{
|
|
return Err(ConfigError::InvalidValue(
|
|
"BUZZ_OPERATOR_LISTENERS endpoints must be HTTPS URLs without credentials, query, or fragment"
|
|
.to_string(),
|
|
));
|
|
}
|
|
if endpoints.insert(pubkey.clone(), url).is_some() {
|
|
return Err(ConfigError::InvalidValue(format!(
|
|
"BUZZ_OPERATOR_LISTENERS contains duplicate pubkey: {pubkey}"
|
|
)));
|
|
}
|
|
}
|
|
Ok(endpoints)
|
|
}
|
|
|
|
fn parse_bool(name: &str, default: bool) -> Result<bool, ConfigError> {
|
|
match std::env::var(name) {
|
|
Err(std::env::VarError::NotPresent) => Ok(default),
|
|
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 parse_optional_bool(name: &str) -> Result<bool, ConfigError> {
|
|
parse_bool(name, false)
|
|
}
|
|
|
|
fn ensure_git_repo_path(
|
|
raw: impl Into<std::path::PathBuf>,
|
|
) -> Result<std::path::PathBuf, ConfigError> {
|
|
ensure_git_path("BUZZ_GIT_REPO_PATH", raw)
|
|
}
|
|
|
|
fn ensure_git_path(
|
|
setting: &str,
|
|
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!(
|
|
"{setting}={} could not be created: {e}",
|
|
git_repo_path.display()
|
|
)));
|
|
}
|
|
Ok(git_repo_path)
|
|
}
|
|
|
|
/// Env vars that once gated authenticated media reads.
|
|
///
|
|
/// `BUZZ_REQUIRE_MEDIA_GET_AUTH` was the real flag; `BUZZ_REQUIRE_MEDIA_READ_AUTH`
|
|
/// was documented in `.env.example` as an accepted alias but was never read by
|
|
/// the relay. Media reads are now unconditionally authenticated, so both are
|
|
/// inert and an operator still setting either — especially to `false` — holds a
|
|
/// belief about their deployment that is no longer true.
|
|
const INERT_MEDIA_READ_AUTH_VARS: [&str; 2] = [
|
|
"BUZZ_REQUIRE_MEDIA_GET_AUTH",
|
|
"BUZZ_REQUIRE_MEDIA_READ_AUTH",
|
|
];
|
|
|
|
/// Which of `names` are present, so startup can warn that they do nothing.
|
|
///
|
|
/// `lookup` is injected rather than calling `std::env::var` directly: process
|
|
/// env is global mutable state, so a test that set real vars would race every
|
|
/// other test in the binary.
|
|
fn inert_env_vars<'a>(names: &[&'a str], lookup: impl Fn(&str) -> Option<String>) -> Vec<&'a str> {
|
|
names
|
|
.iter()
|
|
.copied()
|
|
.filter(|name| lookup(name).is_some())
|
|
.collect()
|
|
}
|
|
|
|
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 read_database_url = std::env::var("READ_DATABASE_URL")
|
|
.ok()
|
|
.map(|v| v.trim().to_string())
|
|
.filter(|v| !v.is_empty());
|
|
|
|
// The old seconds-denominated name is a hard startup error, not an
|
|
// alias: silently honouring it would mean 1000x the intended budget.
|
|
if std::env::var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS").is_ok() {
|
|
return Err(ConfigError::InvalidValue(
|
|
"BUZZ_REPLICA_HEAD_MAX_AGE_SECS was renamed to BUZZ_REPLICA_READ_MAX_AGE_MS \
|
|
(note: milliseconds, not seconds); refusing to start"
|
|
.to_string(),
|
|
));
|
|
}
|
|
|
|
// Replica read budget: 0 = off (the rollout default), so this is a
|
|
// non-negative parse, unlike `positive_u64_from_env`.
|
|
let replica_read_max_age_ms = match std::env::var("BUZZ_REPLICA_READ_MAX_AGE_MS") {
|
|
Ok(raw) => raw.trim().parse::<u64>().map_err(|_| {
|
|
ConfigError::InvalidValue(
|
|
"BUZZ_REPLICA_READ_MAX_AGE_MS must be a non-negative integer".to_string(),
|
|
)
|
|
})?,
|
|
Err(_) => 0,
|
|
};
|
|
let usage_metrics_replica_max_age_ms =
|
|
match std::env::var("BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS") {
|
|
Ok(raw) => raw.trim().parse::<u64>().map_err(|_| {
|
|
ConfigError::InvalidValue(
|
|
"BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS must be a non-negative integer"
|
|
.to_string(),
|
|
)
|
|
})?,
|
|
Err(_) => 30_000,
|
|
};
|
|
|
|
// Drain jitter: 0 = off (default). Clamp oversized values so every
|
|
// delayed close is initiated with ten seconds left in the relay's
|
|
// hard-drain budget. An empty/whitespace-only value is treated as unset
|
|
// (jitter off), matching the sibling vars in this file — so setting the
|
|
// var to "" is a valid kill switch, not a crashloop.
|
|
let drain_jitter_ms = match std::env::var("BUZZ_DRAIN_JITTER_MS") {
|
|
Ok(raw) if raw.trim().is_empty() => 0,
|
|
Ok(raw) => raw
|
|
.trim()
|
|
.parse::<u64>()
|
|
.map_err(|_| {
|
|
ConfigError::InvalidValue(
|
|
"BUZZ_DRAIN_JITTER_MS must be a non-negative integer".to_string(),
|
|
)
|
|
})?
|
|
.min(MAX_DRAIN_JITTER_MS),
|
|
Err(_) => 0,
|
|
};
|
|
|
|
let redis_url =
|
|
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());
|
|
|
|
let redis_pool_size = std::env::var("BUZZ_REDIS_POOL_SIZE")
|
|
.ok()
|
|
.and_then(|v| v.parse::<usize>().ok())
|
|
.filter(|&v| v > 0)
|
|
.unwrap_or(16);
|
|
|
|
let db_pool_size = std::env::var("BUZZ_DB_POOL_SIZE")
|
|
.ok()
|
|
.and_then(|v| v.parse::<u32>().ok())
|
|
.filter(|&v| v > 0)
|
|
.unwrap_or(50);
|
|
|
|
let db_read_pool_size = std::env::var("BUZZ_DB_READ_POOL_SIZE")
|
|
.ok()
|
|
.and_then(|v| v.parse::<u32>().ok())
|
|
.filter(|&v| v > 0);
|
|
|
|
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);
|
|
|
|
let klipy = std::env::var("BUZZ_KLIPY_API_KEY")
|
|
.ok()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
.map(|api_key| KlipyConfig { api_key });
|
|
|
|
// 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())
|
|
.map(|s| {
|
|
// Must be exactly 64 lowercase hex characters (32-byte pubkey).
|
|
// Fail closed — once RELAY_OWNER_PUBKEY can be the break-glass
|
|
// operator root (owner-fallback B), silently discarding a malformed
|
|
// value would be a lockout, not a graceful degradation.
|
|
let valid = s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit());
|
|
if valid {
|
|
Ok(s)
|
|
} else {
|
|
Err(ConfigError::InvalidValue(format!(
|
|
"RELAY_OWNER_PUBKEY is not a valid 64-char hex pubkey — \
|
|
got: {s:?}. Fix or unset it; a malformed value is a startup error \
|
|
because this key can serve as the break-glass operator root when \
|
|
RELAY_OPERATOR_PUBKEYS is empty."
|
|
)))
|
|
}
|
|
})
|
|
.transpose()?;
|
|
|
|
// 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() {
|
|
// Do NOT fail closed at boot: RELAY_OPERATOR_PUBKEYS is the shared
|
|
// allowlist for BOTH the community-provisioning endpoints and the
|
|
// NIP-98 admin console. Only provisioning needs the canonical
|
|
// origin, so requiring it at boot would force admin-console
|
|
// operators to configure a provisioning surface they never use.
|
|
// The provisioning endpoints stay fail-closed at request time
|
|
// (see `api::operator::authorize_operator_request`, which rejects
|
|
// when the origin is unconfigured); this warning names that so an
|
|
// operator who *did* want provisioning knows why it 500s.
|
|
warn!(
|
|
"RELAY_OPERATOR_PUBKEYS is set but RELAY_OPERATOR_API_ORIGIN is not — \
|
|
the community-provisioning endpoints (POST /operator/communities) will \
|
|
reject every request until RELAY_OPERATOR_API_ORIGIN is set. The NIP-98 \
|
|
admin console does not require it and is unaffected."
|
|
);
|
|
}
|
|
|
|
let operator_listener_delivery_urls = match std::env::var("BUZZ_OPERATOR_LISTENERS") {
|
|
Ok(raw) => parse_operator_listener_delivery_urls(&raw)?,
|
|
Err(std::env::VarError::NotPresent) => HashMap::new(),
|
|
Err(error) => {
|
|
return Err(ConfigError::InvalidValue(format!(
|
|
"BUZZ_OPERATOR_LISTENERS must be valid UTF-8: {error}"
|
|
)));
|
|
}
|
|
};
|
|
if !operator_listener_delivery_urls.is_empty() && relay_operator_api_origin.is_none() {
|
|
error!(
|
|
"BUZZ_OPERATOR_LISTENERS is set but RELAY_OPERATOR_API_ORIGIN is not — operator-listener registration requests will reject every request until RELAY_OPERATOR_API_ORIGIN is set"
|
|
);
|
|
}
|
|
|
|
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);
|
|
|
|
// Catalog-only and cheap, but clamp operator mistakes away from a hot
|
|
// loop or a multi-day observability gap.
|
|
let partition_audit_interval = Duration::from_secs(
|
|
std::env::var("BUZZ_PARTITION_AUDIT_INTERVAL_SECS")
|
|
.ok()
|
|
.and_then(|value| value.parse::<u64>().ok())
|
|
.unwrap_or(15 * 60)
|
|
.clamp(60, 24 * 60 * 60),
|
|
);
|
|
let partition_manager_create_enabled =
|
|
parse_bool("BUZZ_PARTITION_MANAGER_CREATE_ENABLED", true)?;
|
|
|
|
let s3_addressing_style = match std::env::var("BUZZ_S3_ADDRESSING_STYLE") {
|
|
Ok(value) => value.parse().map_err(ConfigError::InvalidValue)?,
|
|
Err(std::env::VarError::NotPresent) => buzz_media::config::S3AddressingStyle::default(),
|
|
Err(std::env::VarError::NotUnicode(_)) => {
|
|
return Err(ConfigError::InvalidValue(
|
|
"BUZZ_S3_ADDRESSING_STYLE must be valid Unicode and one of 'path' or 'virtual'"
|
|
.to_string(),
|
|
));
|
|
}
|
|
};
|
|
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()),
|
|
s3_addressing_style,
|
|
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);
|
|
|
|
for name in inert_env_vars(&INERT_MEDIA_READ_AUTH_VARS, |n| std::env::var(n).ok()) {
|
|
warn!(
|
|
"{name} is set but is no longer read — GET/HEAD /media/* always require \
|
|
Blossom t=get auth plus relay membership. Remove it; a value of `false` \
|
|
does not re-open unauthenticated media reads."
|
|
);
|
|
}
|
|
|
|
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_pack_cache_path = ensure_git_path(
|
|
"BUZZ_GIT_PACK_CACHE_PATH",
|
|
std::env::var("BUZZ_GIT_PACK_CACHE_PATH")
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or_else(|_| git_repo_path.join(".pack-cache")),
|
|
)?;
|
|
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_pack_cache_max_bytes: u64 = std::env::var("BUZZ_GIT_PACK_CACHE_MAX_BYTES")
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or_else(|| git_max_repo_bytes.saturating_mul(5)); // 5 GB at defaults
|
|
let git_pack_cache_max_concurrent_populations: usize =
|
|
std::env::var("BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS")
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.filter(|value| *value > 0)
|
|
.unwrap_or(2);
|
|
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_enabled = parse_bool("BUZZ_PUSH_ENABLED", false)?;
|
|
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() && push_enabled => {
|
|
return Err(ConfigError::InvalidValue(
|
|
"BUZZ_PUSH_GATEWAY_DELIVERY_URL must not be empty when BUZZ_PUSH_ENABLED=true"
|
|
.to_string(),
|
|
));
|
|
}
|
|
Ok(raw) if raw.trim().is_empty() => None,
|
|
Ok(raw) => Some(parse_push_gateway_delivery_url(&raw)?),
|
|
Err(std::env::VarError::NotPresent) if push_enabled => {
|
|
return Err(ConfigError::InvalidValue(
|
|
"BUZZ_PUSH_GATEWAY_DELIVERY_URL must be configured when BUZZ_PUSH_ENABLED=true"
|
|
.to_string(),
|
|
));
|
|
}
|
|
Err(std::env::VarError::NotPresent) => None,
|
|
Err(error) => {
|
|
return Err(ConfigError::InvalidValue(format!(
|
|
"BUZZ_PUSH_GATEWAY_DELIVERY_URL must be valid UTF-8: {error}"
|
|
)));
|
|
}
|
|
};
|
|
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);
|
|
let operator_listener_timeout_millis = match std::env::var(
|
|
"BUZZ_OPERATOR_LISTENER_TIMEOUT_MS",
|
|
) {
|
|
Ok(raw) => match raw
|
|
.parse::<u64>()
|
|
.ok()
|
|
.filter(|millis| (100..=10_000).contains(millis))
|
|
{
|
|
Some(millis) => millis,
|
|
None => {
|
|
error!(
|
|
value = %raw,
|
|
"invalid BUZZ_OPERATOR_LISTENER_TIMEOUT_MS; using default 5000ms (expected integer in 100..=10000)"
|
|
);
|
|
5_000
|
|
}
|
|
},
|
|
Err(std::env::VarError::NotPresent) => 5_000,
|
|
Err(err) => {
|
|
error!(
|
|
error = %err,
|
|
"invalid BUZZ_OPERATOR_LISTENER_TIMEOUT_MS; using default 5000ms"
|
|
);
|
|
5_000
|
|
}
|
|
};
|
|
let operator_listener_timeout = Duration::from_millis(operator_listener_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 audit_enabled = parse_bool("BUZZ_AUDIT_ENABLED", true)?;
|
|
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()),
|
|
})
|
|
};
|
|
|
|
// Deployment-admin surface. The route is absent when the host is unset.
|
|
let admin = match std::env::var("BUZZ_ADMIN_HOST")
|
|
.ok()
|
|
.map(|value| value.trim().to_owned())
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
None => {
|
|
if std::env::var_os("BUZZ_ADMIN_TOKEN").is_some() {
|
|
tracing::warn!(
|
|
"BUZZ_ADMIN_TOKEN is set but token authentication was removed — \
|
|
the value is ignored; the admin API now supports only \
|
|
BUZZ_ADMIN_AUTH=nip98 (default) or disabled; remove \
|
|
BUZZ_ADMIN_TOKEN from the environment"
|
|
);
|
|
}
|
|
if std::env::var_os("BUZZ_ADMIN_AUTH").is_some() {
|
|
tracing::warn!(
|
|
"BUZZ_ADMIN_AUTH is set without BUZZ_ADMIN_HOST — \
|
|
the admin dashboard and API stay disabled and the value is ignored"
|
|
);
|
|
}
|
|
None
|
|
}
|
|
Some(host) => {
|
|
if host.contains(['/', '\\', '@']) {
|
|
return Err(ConfigError::InvalidValue(
|
|
"BUZZ_ADMIN_HOST must be an exact authority".to_string(),
|
|
));
|
|
}
|
|
|
|
// IPv6 authorities must be bracketed (RFC 3986). An unbracketed
|
|
// literal such as `::1` cannot form a valid URI authority — the
|
|
// advertised NIP-11 origin and the NIP-98 `u`-tag verifier would
|
|
// emit `http://::1`, which no URL parser accepts, and no real
|
|
// client sends an unbracketed IPv6 `Host` header. Reject it here
|
|
// so every accepted host yields usable discovery and signing URLs.
|
|
if !host.starts_with('[') && host.matches(':').count() > 1 {
|
|
return Err(ConfigError::InvalidValue(format!(
|
|
"BUZZ_ADMIN_HOST={host} looks like a bare IPv6 literal; \
|
|
wrap IPv6 addresses in brackets, e.g. [::1] or [::1]:3000"
|
|
)));
|
|
}
|
|
|
|
// Catch-all authority gate: every accepted host is interpolated
|
|
// into the NIP-11 advertisement and NIP-98 `u`-tag URLs, so it
|
|
// must be exactly an authority — a host with an optional port and
|
|
// nothing else. Parsing `http://{host}` and requiring the sentinel
|
|
// to carry only a host rejects any shape that smuggles a path,
|
|
// query, fragment, or credentials into the value (the bracket guard
|
|
// above already names the honest bare-IPv6 shape).
|
|
// Structural check, not parse-only: `admin.example.com?x=1` parses
|
|
// as a valid URL but lands `?x=1` in the query, which would corrupt
|
|
// both the advertised origin and the canonical `u`-tag URL. Mirrors
|
|
// `parse_operator_api_origin`. After passing the gate the host is
|
|
// lowercased (hostnames are case-insensitive per RFC 4343) so a
|
|
// mixed-case BUZZ_ADMIN_HOST round-trips correctly through desktop
|
|
// URL parsing, which always lowercases hostnames (the `url` crate
|
|
// normalizes an empty path to `/`, so a bare authority satisfies
|
|
// `path == "/"`).
|
|
let is_bare_authority =
|
|
url::Url::parse(&format!("http://{host}")).is_ok_and(|url| {
|
|
url.host().is_some()
|
|
&& url.username().is_empty()
|
|
&& url.password().is_none()
|
|
&& url.path() == "/"
|
|
&& url.query().is_none()
|
|
&& url.fragment().is_none()
|
|
});
|
|
if !is_bare_authority {
|
|
return Err(ConfigError::InvalidValue(format!(
|
|
"BUZZ_ADMIN_HOST={host} is not a valid URL authority; \
|
|
it must be a host with an optional port and nothing else \
|
|
(no path, query, fragment, or credentials), e.g. \
|
|
relay.example.com:8443 or [::1]:3000"
|
|
)));
|
|
}
|
|
let host = host.to_lowercase();
|
|
|
|
// Parse BUZZ_ADMIN_AUTH. Accepted values: "nip98" (default when
|
|
// unset or empty) and "disabled". Any other value is a startup
|
|
// error (typo-proofing). Token authentication was removed —
|
|
// BUZZ_ADMIN_TOKEN in the environment is ignored with a startup
|
|
// warning so a deploy that used to honor a credential learns the
|
|
// value is now inert without bricking the boot.
|
|
if std::env::var_os("BUZZ_ADMIN_TOKEN").is_some() {
|
|
tracing::warn!(
|
|
"BUZZ_ADMIN_TOKEN is set but token authentication was removed — \
|
|
the value is ignored; the admin API now supports only \
|
|
BUZZ_ADMIN_AUTH=nip98 (default) or disabled; remove \
|
|
BUZZ_ADMIN_TOKEN from the environment"
|
|
);
|
|
}
|
|
|
|
let auth = match std::env::var("BUZZ_ADMIN_AUTH")
|
|
.ok()
|
|
.as_deref()
|
|
.map(str::trim)
|
|
{
|
|
None | Some("") | Some("nip98") => AdminAuth::Nip98,
|
|
Some("disabled") => {
|
|
tracing::warn!(
|
|
"BUZZ_ADMIN_AUTH=disabled — the admin API is \
|
|
unauthenticated; the operator has asserted that access is \
|
|
controlled at the network layer (reverse proxy, VPN, firewall)"
|
|
);
|
|
AdminAuth::Disabled
|
|
}
|
|
Some(other) => {
|
|
return Err(ConfigError::InvalidValue(format!(
|
|
"BUZZ_ADMIN_AUTH must be \"nip98\" or \"disabled\"; got \"{other}\""
|
|
)))
|
|
}
|
|
};
|
|
|
|
let web_dir = std::env::var("BUZZ_ADMIN_WEB_DIR")
|
|
.ok()
|
|
.map(|value| std::path::PathBuf::from(value.trim()))
|
|
.filter(|value| !value.as_os_str().is_empty());
|
|
if let Some(ref dir) = web_dir {
|
|
if !dir.join("index.html").is_file() {
|
|
return Err(ConfigError::InvalidValue(format!(
|
|
"BUZZ_ADMIN_WEB_DIR={} does not contain index.html",
|
|
dir.display()
|
|
)));
|
|
}
|
|
}
|
|
Some(AdminConfig {
|
|
host,
|
|
auth,
|
|
web_dir,
|
|
})
|
|
}
|
|
};
|
|
|
|
// 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(),
|
|
));
|
|
}
|
|
|
|
let buzz_v1_enabled = std::env::var("BUZZ_V1_ENABLED").is_ok_and(|v| v == "true");
|
|
// Read only when enabled: a disabled relay ignores every v1 setting.
|
|
let buzz_v1_retention_seconds = match std::env::var("BUZZ_V1_RETENTION_SECONDS") {
|
|
Ok(v) if buzz_v1_enabled => v.parse().ok().filter(|s| *s > 0).ok_or_else(|| {
|
|
ConfigError::InvalidValue("BUZZ_V1_RETENTION_SECONDS must be positive".into())
|
|
})?,
|
|
_ => buzz_db::personal_read::DEFAULT_RETENTION_SECONDS,
|
|
};
|
|
|
|
Ok(Self {
|
|
bind_addr,
|
|
database_url,
|
|
read_database_url,
|
|
replica_read_max_age_ms,
|
|
usage_metrics_replica_max_age_ms,
|
|
drain_jitter_ms,
|
|
redis_url,
|
|
redis_pool_size,
|
|
db_pool_size,
|
|
db_read_pool_size,
|
|
relay_url,
|
|
pairing_relay_url,
|
|
max_connections,
|
|
max_concurrent_handlers,
|
|
send_buffer_size,
|
|
max_frame_bytes,
|
|
slow_client_grace_limit,
|
|
auth,
|
|
require_auth_token,
|
|
buzz_v1_enabled,
|
|
buzz_v1_retention_seconds,
|
|
cors_origins,
|
|
relay_private_key,
|
|
uds_path,
|
|
health_port,
|
|
metrics_port,
|
|
partition_audit_interval,
|
|
partition_manager_create_enabled,
|
|
pubkey_allowlist_enabled,
|
|
require_relay_membership,
|
|
huddle_audio_available,
|
|
mesh,
|
|
mesh_demo_echo,
|
|
relay_owner_pubkey,
|
|
relay_operator_api_origin,
|
|
relay_operator_pubkeys,
|
|
operator_listener_delivery_urls,
|
|
allow_nip_oa_auth,
|
|
klipy,
|
|
media,
|
|
media_max_concurrent_uploads,
|
|
media_max_concurrent_uploads_per_pubkey,
|
|
media_uploads_per_minute,
|
|
audit_enabled,
|
|
ephemeral_ttl_override,
|
|
git_repo_path,
|
|
git_pack_cache_path,
|
|
git_max_pack_bytes,
|
|
git_max_repo_bytes,
|
|
git_pack_cache_max_bytes,
|
|
git_pack_cache_max_concurrent_populations,
|
|
git_max_repos_per_pubkey,
|
|
git_max_concurrent_ops,
|
|
git_hook_hmac_secret,
|
|
push_enabled,
|
|
push_executor_key_id,
|
|
push_gateway_delivery_url,
|
|
push_gateway_timeout,
|
|
operator_listener_timeout,
|
|
join_policy,
|
|
admin,
|
|
web_dir,
|
|
serve_git_web_gui,
|
|
nip_fi: crate::nip_fi_config::NipFiRelayConfig::from_env()?,
|
|
})
|
|
}
|
|
|
|
/// Build a baseline `Config` suitable for test fixtures that need a
|
|
/// structurally valid config without caring about specific field values.
|
|
///
|
|
/// Equivalent to `from_env()` with all env variables absent, but calls
|
|
/// that function while holding `NIP_FI_ENV_LOCK` so that concurrent NIP-FI
|
|
/// env-writer tests cannot produce a partially-written env state that this
|
|
/// call observes. Every test fixture that previously called
|
|
/// `Config::from_env().expect("…")` should use this instead — it is the
|
|
/// only env-isolation-safe way to obtain a default config in test code.
|
|
///
|
|
/// Fields that differ from production defaults (`database_url`,
|
|
/// `redis_url`, etc.) should be overridden on the returned struct after
|
|
/// calling this function, exactly as was done before.
|
|
///
|
|
/// [FI-TRACE-ENV-RACE]
|
|
#[cfg(test)]
|
|
pub(crate) fn for_test() -> Self {
|
|
crate::nip_fi_config::FOR_TEST_LOCK_WAITERS
|
|
.lock()
|
|
.unwrap()
|
|
.push(std::thread::current().id());
|
|
let _fi_guard = crate::nip_fi_config::NIP_FI_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
Self::from_env().expect("default config must load for test fixture")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn klipy_config_debug_redacts_the_api_key() {
|
|
let config = KlipyConfig {
|
|
api_key: "private-klipy-key".to_string(),
|
|
};
|
|
|
|
let debug = format!("{config:?}");
|
|
assert!(debug.contains("[REDACTED]"));
|
|
assert!(!debug.contains("private-klipy-key"));
|
|
}
|
|
|
|
// 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(());
|
|
|
|
/// Acquire both the FI env lock and the config-test env lock in a consistent
|
|
/// order so concurrent FI writers never observe a partially-written env state.
|
|
///
|
|
/// Lock ordering: NIP_FI_ENV_LOCK → ENV_MUTEX. Every direct `Config::from_env()`
|
|
/// caller in this test module must hold both locks. Use this helper instead of
|
|
/// acquiring them separately to guarantee the order is never inverted.
|
|
///
|
|
/// [FI-TRACE-ENV-RACE]
|
|
fn env_guards() -> (
|
|
std::sync::MutexGuard<'static, ()>,
|
|
std::sync::MutexGuard<'static, ()>,
|
|
) {
|
|
let fi = crate::nip_fi_config::NIP_FI_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let cfg = ENV_MUTEX.lock().unwrap();
|
|
(fi, cfg)
|
|
}
|
|
|
|
/// Look up against a fixed set, standing in for process env.
|
|
fn env_of<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + use<'a> {
|
|
move |name| {
|
|
set.iter()
|
|
.find(|(key, _)| *key == name)
|
|
.map(|(_, value)| (*value).to_string())
|
|
}
|
|
}
|
|
|
|
/// The case that matters: an operator who pinned the old flag to `false`
|
|
/// must be told it is inert, not left believing media reads are still open.
|
|
#[test]
|
|
fn inert_media_read_auth_vars_are_reported_even_when_false() {
|
|
let found = inert_env_vars(
|
|
&INERT_MEDIA_READ_AUTH_VARS,
|
|
env_of(&[("BUZZ_REQUIRE_MEDIA_GET_AUTH", "false")]),
|
|
);
|
|
|
|
assert_eq!(found, vec!["BUZZ_REQUIRE_MEDIA_GET_AUTH"]);
|
|
}
|
|
|
|
/// `BUZZ_REQUIRE_MEDIA_READ_AUTH` was advertised in `.env.example` as an
|
|
/// accepted alias but the relay never read it, so operators may hold it
|
|
/// today. It warns too.
|
|
#[test]
|
|
fn inert_media_read_auth_vars_include_the_documented_alias() {
|
|
let found = inert_env_vars(
|
|
&INERT_MEDIA_READ_AUTH_VARS,
|
|
env_of(&[
|
|
("BUZZ_REQUIRE_MEDIA_GET_AUTH", "true"),
|
|
("BUZZ_REQUIRE_MEDIA_READ_AUTH", "false"),
|
|
]),
|
|
);
|
|
|
|
assert_eq!(
|
|
found,
|
|
vec![
|
|
"BUZZ_REQUIRE_MEDIA_GET_AUTH",
|
|
"BUZZ_REQUIRE_MEDIA_READ_AUTH"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn inert_media_read_auth_vars_stay_quiet_when_unset() {
|
|
let found = inert_env_vars(
|
|
&INERT_MEDIA_READ_AUTH_VARS,
|
|
env_of(&[("BUZZ_REQUIRE_RELAY_MEMBERSHIP", "true")]),
|
|
);
|
|
|
|
assert!(found.is_empty(), "unrelated vars must not warn: {found:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn defaults_are_valid() {
|
|
let _guards = env_guards();
|
|
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_eq!(config.redis_pool_size, 16);
|
|
assert_eq!(config.db_pool_size, 50);
|
|
assert!(config.max_connections > 0);
|
|
assert!(config.send_buffer_size > 0);
|
|
assert_eq!(config.max_frame_bytes, DEFAULT_MAX_FRAME_BYTES);
|
|
assert_eq!(config.partition_audit_interval, Duration::from_secs(900));
|
|
assert!(config.partition_manager_create_enabled);
|
|
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_eq!(
|
|
config.media.s3_addressing_style,
|
|
buzz_media::config::S3AddressingStyle::Path,
|
|
"S3 addressing must default to path style for bundled MinIO compatibility"
|
|
);
|
|
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"
|
|
);
|
|
}
|
|
|
|
/// Run `Config::from_env()` with the admin variables forced to `values`,
|
|
/// restoring the ambient environment afterwards.
|
|
///
|
|
/// Callers must already hold [`env_guards()`] before calling this function,
|
|
/// so that FI writers cannot produce a partially-written env state while
|
|
/// `Config::from_env()` reads it. [FI-TRACE-ENV-RACE]
|
|
fn config_with_admin_env(values: &[(&str, Option<&str>)]) -> Result<Config, ConfigError> {
|
|
const KEYS: [&str; 3] = ["BUZZ_ADMIN_HOST", "BUZZ_ADMIN_TOKEN", "BUZZ_ADMIN_AUTH"];
|
|
let previous: Vec<_> = KEYS
|
|
.iter()
|
|
.map(|key| (*key, std::env::var_os(key)))
|
|
.collect();
|
|
for key in KEYS {
|
|
std::env::remove_var(key);
|
|
}
|
|
for (key, value) in values {
|
|
match value {
|
|
Some(value) => std::env::set_var(key, value),
|
|
None => std::env::remove_var(key),
|
|
}
|
|
}
|
|
let config = Config::from_env();
|
|
for (key, value) in previous {
|
|
match value {
|
|
Some(value) => std::env::set_var(key, value),
|
|
None => std::env::remove_var(key),
|
|
}
|
|
}
|
|
config
|
|
}
|
|
|
|
/// Like `config_with_admin_env`, but also captures the tracing output
|
|
/// emitted during `Config::from_env()` so a test can assert the startup
|
|
/// warning fired. The `BUZZ_ADMIN_TOKEN` warning is the sole behavioral
|
|
/// value of retaining the guards (the variable is otherwise inert), so it
|
|
/// must be regression-protected: deleting a warn block has to fail a test.
|
|
fn config_with_admin_env_capturing_logs(
|
|
values: &[(&str, Option<&str>)],
|
|
) -> (Result<Config, ConfigError>, String) {
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
let _tracing = crate::test_support::tracing_dispatch_lock();
|
|
|
|
#[derive(Clone)]
|
|
struct CapturingMakeWriter {
|
|
buf: Arc<Mutex<Vec<u8>>>,
|
|
}
|
|
struct CapturingWriter {
|
|
buf: Arc<Mutex<Vec<u8>>>,
|
|
}
|
|
impl std::io::Write for CapturingWriter {
|
|
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
|
self.buf.lock().unwrap().extend_from_slice(data);
|
|
Ok(data.len())
|
|
}
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturingMakeWriter {
|
|
type Writer = CapturingWriter;
|
|
fn make_writer(&'a self) -> Self::Writer {
|
|
CapturingWriter {
|
|
buf: Arc::clone(&self.buf),
|
|
}
|
|
}
|
|
}
|
|
|
|
let buf = Arc::new(Mutex::new(Vec::<u8>::new()));
|
|
let subscriber = tracing_subscriber::fmt()
|
|
.with_writer(CapturingMakeWriter {
|
|
buf: Arc::clone(&buf),
|
|
})
|
|
.with_ansi(false)
|
|
.finish();
|
|
let config =
|
|
tracing::subscriber::with_default(subscriber, || config_with_admin_env(values));
|
|
let captured = String::from_utf8(buf.lock().unwrap().clone()).unwrap_or_default();
|
|
(config, captured)
|
|
}
|
|
|
|
fn config_with_operator_listeners(raw: Option<&str>) -> Result<Config, ConfigError> {
|
|
let previous = std::env::var_os("BUZZ_OPERATOR_LISTENERS");
|
|
match raw {
|
|
Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value),
|
|
None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"),
|
|
}
|
|
let result = Config::from_env();
|
|
match previous {
|
|
Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value),
|
|
None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"),
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Assert `captured` contains a WARN naming the removal of `BUZZ_ADMIN_TOKEN`
|
|
/// so the migration breadcrumb Will's ruling preserved cannot silently regress.
|
|
fn assert_admin_token_removal_warning(captured: &str) {
|
|
assert!(
|
|
captured.contains("WARN"),
|
|
"expected a WARN line: {captured:?}"
|
|
);
|
|
for needle in ["BUZZ_ADMIN_TOKEN", "removed", "ignored"] {
|
|
assert!(
|
|
captured.contains(needle),
|
|
"WARN must mention {needle:?}: {captured:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A valid-looking token value, used only to prove that setting
|
|
/// `BUZZ_ADMIN_TOKEN` is now ignored with a startup warning and never
|
|
/// changes the resolved auth mode (token auth was removed).
|
|
const SOME_ADMIN_TOKEN: &str =
|
|
"5f0e1d2c3b4a59687786958493a2b1c0decadebeefcafe0123456789abcdef01";
|
|
|
|
#[test]
|
|
fn admin_token_set_is_ignored_and_warns_at_startup() {
|
|
let _guards = env_guards();
|
|
// Token authentication was removed. A lingering BUZZ_ADMIN_TOKEN with a
|
|
// host is ignored (logged as a warning) and never changes the resolved
|
|
// auth mode: unset/nip98 stay nip98, disabled stays disabled.
|
|
for (auth, expected) in [
|
|
(None, AdminAuth::Nip98),
|
|
(Some("nip98"), AdminAuth::Nip98),
|
|
(Some("disabled"), AdminAuth::Disabled),
|
|
] {
|
|
let (config, logs) = config_with_admin_env_capturing_logs(&[
|
|
("BUZZ_ADMIN_HOST", Some("admin.example")),
|
|
("BUZZ_ADMIN_TOKEN", Some(SOME_ADMIN_TOKEN)),
|
|
("BUZZ_ADMIN_AUTH", auth),
|
|
]);
|
|
let admin = config
|
|
.unwrap_or_else(|e| {
|
|
panic!("BUZZ_ADMIN_TOKEN with auth={auth:?} must be ignored: {e:?}")
|
|
})
|
|
.admin
|
|
.expect("admin surface is configured");
|
|
assert_eq!(admin.host, "admin.example");
|
|
assert_eq!(
|
|
std::mem::discriminant(&admin.auth),
|
|
std::mem::discriminant(&expected),
|
|
"BUZZ_ADMIN_TOKEN must not change auth mode for auth={auth:?}"
|
|
);
|
|
assert_admin_token_removal_warning(&logs);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn admin_surface_defaults_to_nip98_when_auth_unset() {
|
|
let _guards = env_guards();
|
|
let admin = config_with_admin_env(&[("BUZZ_ADMIN_HOST", Some("admin.example"))])
|
|
.expect("config with an admin host and no BUZZ_ADMIN_AUTH")
|
|
.admin
|
|
.expect("admin surface is configured");
|
|
assert_eq!(admin.host, "admin.example");
|
|
assert!(
|
|
matches!(admin.auth, crate::config::AdminAuth::Nip98),
|
|
"unset BUZZ_ADMIN_AUTH must default to nip98 (fail-secure)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn admin_host_bare_ipv6_literal_fails_closed() {
|
|
let _guards = env_guards();
|
|
for host in ["::1", "::1:3000", "fe80::1", "2001:db8::1"] {
|
|
let result = config_with_admin_env(&[("BUZZ_ADMIN_HOST", Some(host))]);
|
|
assert!(
|
|
matches!(
|
|
result,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("BUZZ_ADMIN_HOST") && message.contains("bracket")
|
|
),
|
|
"bare IPv6 host {host:?} must be rejected: {result:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn admin_host_malformed_authority_fails_closed() {
|
|
// Shapes that slip the earlier guards but are not a bare authority, so
|
|
// they would corrupt the NIP-11 advertisement and NIP-98 `u`-tag URL:
|
|
// - unclosed-bracket typos start with `[` (pass the bracket guard)
|
|
// but are not parseable authorities;
|
|
// - query/fragment suffixes parse as a valid URL, but the `?x=1` /
|
|
// `#frag` lands in the query/fragment rather than the host, so a
|
|
// parse-only gate would miss them — the structural check catches them.
|
|
let _guards = env_guards();
|
|
for host in [
|
|
"[::1",
|
|
"[::1:3000",
|
|
"[not-closed",
|
|
"admin.example.com?x=1",
|
|
"admin.example.com#frag",
|
|
"[::1]?x=1",
|
|
"[::1]#frag",
|
|
] {
|
|
let result = config_with_admin_env(&[("BUZZ_ADMIN_HOST", Some(host))]);
|
|
assert!(
|
|
matches!(
|
|
result,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("BUZZ_ADMIN_HOST") && message.contains("valid URL authority")
|
|
),
|
|
"malformed authority {host:?} must be rejected: {result:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn admin_host_bracketed_ipv6_literal_is_accepted() {
|
|
let _guards = env_guards();
|
|
for host in ["[::1]", "[::1]:3000", "[2001:db8::1]:8443"] {
|
|
let admin = config_with_admin_env(&[("BUZZ_ADMIN_HOST", Some(host))])
|
|
.unwrap_or_else(|e| panic!("bracketed IPv6 host {host:?} must be accepted: {e:?}"))
|
|
.admin
|
|
.expect("admin surface is configured");
|
|
assert_eq!(admin.host, host);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn admin_host_mixed_case_is_normalized_to_lowercase() {
|
|
let _guards = env_guards();
|
|
// Hostnames are case-insensitive (RFC 4343). A mixed-case BUZZ_ADMIN_HOST
|
|
// must be stored lowercase so it round-trips through desktop URL parsing
|
|
// (url::Url always lowercases hostnames) without a mismatch.
|
|
for (input, expected) in [
|
|
("Admin.Example.com", "admin.example.com"),
|
|
("Admin.Example.com:8443", "admin.example.com:8443"),
|
|
("LOCALHOST:3000", "localhost:3000"),
|
|
] {
|
|
let admin = config_with_admin_env(&[("BUZZ_ADMIN_HOST", Some(input))])
|
|
.unwrap_or_else(|e| panic!("mixed-case host {input:?} must be accepted: {e:?}"))
|
|
.admin
|
|
.expect("admin surface is configured");
|
|
assert_eq!(
|
|
admin.host, expected,
|
|
"host {input:?} must be stored as lowercase {expected:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn admin_token_without_a_host_is_ignored_and_warns() {
|
|
let _guards = env_guards();
|
|
// Even without BUZZ_ADMIN_HOST, a lingering BUZZ_ADMIN_TOKEN is ignored
|
|
// (logged as a warning) — token auth was removed and the admin surface
|
|
// stays absent because the host is unset, not because of the token.
|
|
let (config, logs) = config_with_admin_env_capturing_logs(&[
|
|
("BUZZ_ADMIN_HOST", None),
|
|
("BUZZ_ADMIN_TOKEN", Some(SOME_ADMIN_TOKEN)),
|
|
]);
|
|
let admin = config
|
|
.expect("BUZZ_ADMIN_TOKEN without a host is ignored, not a startup error")
|
|
.admin;
|
|
assert!(
|
|
admin.is_none(),
|
|
"admin surface stays absent when the host is unset: {admin:?}"
|
|
);
|
|
assert_admin_token_removal_warning(&logs);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_mode_activates_without_a_token() {
|
|
let _guards = env_guards();
|
|
let admin = config_with_admin_env(&[
|
|
("BUZZ_ADMIN_HOST", Some("admin.example")),
|
|
("BUZZ_ADMIN_TOKEN", None),
|
|
("BUZZ_ADMIN_AUTH", Some("disabled")),
|
|
])
|
|
.expect("disabled mode without a token is valid")
|
|
.admin
|
|
.expect("admin surface is configured");
|
|
assert_eq!(admin.host, "admin.example");
|
|
assert!(matches!(admin.auth, crate::config::AdminAuth::Disabled));
|
|
}
|
|
|
|
#[test]
|
|
fn admin_auth_junk_values_all_fail_closed() {
|
|
let _guards = env_guards();
|
|
for junk in [
|
|
"1",
|
|
"yes",
|
|
"TRUE",
|
|
"True",
|
|
"false",
|
|
"0",
|
|
"on",
|
|
"insecure_no_auth",
|
|
// "token" is now a junk value — token authentication was removed.
|
|
"token",
|
|
] {
|
|
let result = config_with_admin_env(&[
|
|
("BUZZ_ADMIN_HOST", Some("admin.example")),
|
|
("BUZZ_ADMIN_TOKEN", None),
|
|
("BUZZ_ADMIN_AUTH", Some(junk)),
|
|
]);
|
|
assert!(
|
|
matches!(
|
|
result,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("BUZZ_ADMIN_AUTH")
|
|
),
|
|
"{junk:?} must be rejected: {result:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn admin_auth_empty_string_defaults_to_nip98() {
|
|
// An empty value (e.g. `BUZZ_ADMIN_AUTH=`) is treated as unset → nip98,
|
|
// the fail-secure default.
|
|
let _guards = env_guards();
|
|
let admin = config_with_admin_env(&[
|
|
("BUZZ_ADMIN_HOST", Some("admin.example")),
|
|
("BUZZ_ADMIN_TOKEN", None),
|
|
("BUZZ_ADMIN_AUTH", Some("")),
|
|
])
|
|
.expect("empty BUZZ_ADMIN_AUTH defaults to nip98")
|
|
.admin
|
|
.expect("admin surface is configured");
|
|
assert!(matches!(admin.auth, crate::config::AdminAuth::Nip98));
|
|
}
|
|
|
|
#[test]
|
|
fn nip98_mode_parses_and_succeeds_without_pubkeys_env() {
|
|
let _guards = env_guards();
|
|
let admin = config_with_admin_env(&[
|
|
("BUZZ_ADMIN_HOST", Some("admin.example")),
|
|
("BUZZ_ADMIN_AUTH", Some("nip98")),
|
|
])
|
|
.expect(
|
|
"nip98 mode succeeds without BUZZ_ADMIN_PUBKEYS (role resolution is at request time)",
|
|
)
|
|
.admin
|
|
.expect("admin surface is configured");
|
|
assert!(matches!(admin.auth, crate::config::AdminAuth::Nip98));
|
|
}
|
|
|
|
#[test]
|
|
fn buzz_v1_retention_is_validated_only_when_enabled() {
|
|
let _guards = env_guards();
|
|
const KEYS: [&str; 2] = ["BUZZ_V1_ENABLED", "BUZZ_V1_RETENTION_SECONDS"];
|
|
let previous = KEYS.map(std::env::var_os);
|
|
std::env::set_var("BUZZ_V1_RETENTION_SECONDS", "0");
|
|
std::env::remove_var("BUZZ_V1_ENABLED");
|
|
let disabled = Config::from_env();
|
|
std::env::set_var("BUZZ_V1_ENABLED", "true");
|
|
let enabled = Config::from_env();
|
|
for (key, value) in KEYS.into_iter().zip(previous) {
|
|
match value {
|
|
Some(value) => std::env::set_var(key, value),
|
|
None => std::env::remove_var(key),
|
|
}
|
|
}
|
|
assert!(
|
|
disabled.is_ok(),
|
|
"a disabled relay ignores it: {disabled:?}"
|
|
);
|
|
assert!(matches!(
|
|
enabled,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("BUZZ_V1_RETENTION_SECONDS")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_relay_owner_pubkey_is_a_startup_error_not_warn_and_ignore() {
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("RELAY_OWNER_PUBKEY");
|
|
for bad in ["not-a-pubkey", &"a".repeat(63), &"z".repeat(64), "abcd"] {
|
|
std::env::set_var("RELAY_OWNER_PUBKEY", bad);
|
|
let result = Config::from_env();
|
|
std::env::remove_var("RELAY_OWNER_PUBKEY");
|
|
assert!(
|
|
matches!(
|
|
result,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("RELAY_OWNER_PUBKEY")
|
|
),
|
|
"malformed RELAY_OWNER_PUBKEY {bad:?} must be a startup error, got: {result:?}"
|
|
);
|
|
}
|
|
// Restore.
|
|
match previous {
|
|
Some(v) => std::env::set_var("RELAY_OWNER_PUBKEY", v),
|
|
None => std::env::remove_var("RELAY_OWNER_PUBKEY"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn valid_relay_owner_pubkey_parses_correctly() {
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("RELAY_OWNER_PUBKEY");
|
|
let valid = "a".repeat(64);
|
|
std::env::set_var("RELAY_OWNER_PUBKEY", &valid);
|
|
let config = Config::from_env().expect("valid RELAY_OWNER_PUBKEY parses");
|
|
std::env::remove_var("RELAY_OWNER_PUBKEY");
|
|
if let Some(v) = previous {
|
|
std::env::set_var("RELAY_OWNER_PUBKEY", v);
|
|
}
|
|
assert_eq!(config.relay_owner_pubkey, Some(valid));
|
|
}
|
|
|
|
#[test]
|
|
fn partition_manager_config_has_bounded_interval_and_create_kill_switch() {
|
|
let _guard = ENV_MUTEX.lock().unwrap();
|
|
let previous_interval = std::env::var_os("BUZZ_PARTITION_AUDIT_INTERVAL_SECS");
|
|
let previous_create = std::env::var_os("BUZZ_PARTITION_MANAGER_CREATE_ENABLED");
|
|
|
|
std::env::set_var("BUZZ_PARTITION_AUDIT_INTERVAL_SECS", "1");
|
|
std::env::set_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED", "false");
|
|
let minimum = Config::from_env().expect("minimum config");
|
|
std::env::set_var("BUZZ_PARTITION_AUDIT_INTERVAL_SECS", "999999");
|
|
let maximum = Config::from_env().expect("maximum config");
|
|
|
|
if let Some(value) = previous_interval {
|
|
std::env::set_var("BUZZ_PARTITION_AUDIT_INTERVAL_SECS", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_PARTITION_AUDIT_INTERVAL_SECS");
|
|
}
|
|
if let Some(value) = previous_create {
|
|
std::env::set_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED");
|
|
}
|
|
|
|
assert_eq!(minimum.partition_audit_interval, Duration::from_secs(60));
|
|
assert!(!minimum.partition_manager_create_enabled);
|
|
assert_eq!(
|
|
maximum.partition_audit_interval,
|
|
Duration::from_secs(24 * 60 * 60)
|
|
);
|
|
assert!(!maximum.partition_manager_create_enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn partition_manager_create_kill_switch_parses_false_values_strictly() {
|
|
let _guard = ENV_MUTEX.lock().unwrap();
|
|
let previous = std::env::var_os("BUZZ_PARTITION_MANAGER_CREATE_ENABLED");
|
|
|
|
for value in ["FALSE", "off", " false "] {
|
|
std::env::set_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED", value);
|
|
let config = Config::from_env().expect("recognized false value");
|
|
assert!(!config.partition_manager_create_enabled, "value: {value:?}");
|
|
}
|
|
|
|
std::env::set_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED", "disable-maybe");
|
|
let invalid = Config::from_env();
|
|
|
|
if let Some(value) = previous {
|
|
std::env::set_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED");
|
|
}
|
|
|
|
assert!(matches!(
|
|
invalid,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("BUZZ_PARTITION_MANAGER_CREATE_ENABLED")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn s3_addressing_style_env_accepts_virtual_and_rejects_invalid_values() {
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("BUZZ_S3_ADDRESSING_STYLE");
|
|
|
|
std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", "virtual");
|
|
let configured = Config::from_env()
|
|
.expect("virtual style config")
|
|
.media
|
|
.s3_addressing_style;
|
|
|
|
std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", "auto");
|
|
let invalid = Config::from_env();
|
|
|
|
if let Some(value) = previous {
|
|
std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_S3_ADDRESSING_STYLE");
|
|
}
|
|
|
|
assert_eq!(configured, buzz_media::config::S3AddressingStyle::Virtual);
|
|
assert!(matches!(
|
|
invalid,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual'")
|
|
));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn s3_addressing_style_env_rejects_non_unicode_values() {
|
|
use std::os::unix::ffi::OsStringExt;
|
|
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("BUZZ_S3_ADDRESSING_STYLE");
|
|
std::env::set_var(
|
|
"BUZZ_S3_ADDRESSING_STYLE",
|
|
std::ffi::OsString::from_vec(vec![0xff]),
|
|
);
|
|
|
|
let invalid = Config::from_env();
|
|
|
|
if let Some(value) = previous {
|
|
std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_S3_ADDRESSING_STYLE");
|
|
}
|
|
|
|
assert!(matches!(
|
|
invalid,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("must be valid Unicode")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn redis_pool_size_env_override_and_invalid_fallback() {
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("BUZZ_REDIS_POOL_SIZE");
|
|
|
|
std::env::set_var("BUZZ_REDIS_POOL_SIZE", "32");
|
|
let overridden = Config::from_env().expect("config").redis_pool_size;
|
|
|
|
std::env::set_var("BUZZ_REDIS_POOL_SIZE", "0");
|
|
let zero = Config::from_env().expect("config").redis_pool_size;
|
|
|
|
std::env::set_var("BUZZ_REDIS_POOL_SIZE", "not-a-number");
|
|
let junk = Config::from_env().expect("config").redis_pool_size;
|
|
|
|
if let Some(value) = previous {
|
|
std::env::set_var("BUZZ_REDIS_POOL_SIZE", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_REDIS_POOL_SIZE");
|
|
}
|
|
|
|
assert_eq!(overridden, 32);
|
|
assert_eq!(zero, 16, "zero must fall back to the default");
|
|
assert_eq!(junk, 16, "unparsable value must fall back to the default");
|
|
}
|
|
|
|
#[test]
|
|
fn db_pool_size_env_override_and_invalid_fallback() {
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("BUZZ_DB_POOL_SIZE");
|
|
|
|
std::env::set_var("BUZZ_DB_POOL_SIZE", "80");
|
|
let overridden = Config::from_env().expect("config").db_pool_size;
|
|
|
|
std::env::set_var("BUZZ_DB_POOL_SIZE", "0");
|
|
let zero = Config::from_env().expect("config").db_pool_size;
|
|
|
|
std::env::set_var("BUZZ_DB_POOL_SIZE", "not-a-number");
|
|
let junk = Config::from_env().expect("config").db_pool_size;
|
|
|
|
if let Some(value) = previous {
|
|
std::env::set_var("BUZZ_DB_POOL_SIZE", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_DB_POOL_SIZE");
|
|
}
|
|
|
|
assert_eq!(overridden, 80);
|
|
assert_eq!(zero, 50, "zero must fall back to the default");
|
|
assert_eq!(junk, 50, "unparsable value must fall back to the default");
|
|
}
|
|
|
|
#[test]
|
|
fn db_read_pool_size_env_override_and_invalid_fallback() {
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("BUZZ_DB_READ_POOL_SIZE");
|
|
|
|
std::env::remove_var("BUZZ_DB_READ_POOL_SIZE");
|
|
let unset = Config::from_env().expect("config").db_read_pool_size;
|
|
|
|
std::env::set_var("BUZZ_DB_READ_POOL_SIZE", "40");
|
|
let overridden = Config::from_env().expect("config").db_read_pool_size;
|
|
|
|
std::env::set_var("BUZZ_DB_READ_POOL_SIZE", "0");
|
|
let zero = Config::from_env().expect("config").db_read_pool_size;
|
|
|
|
std::env::set_var("BUZZ_DB_READ_POOL_SIZE", "not-a-number");
|
|
let junk = Config::from_env().expect("config").db_read_pool_size;
|
|
|
|
if let Some(value) = previous {
|
|
std::env::set_var("BUZZ_DB_READ_POOL_SIZE", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_DB_READ_POOL_SIZE");
|
|
}
|
|
|
|
assert_eq!(unset, None, "unset must inherit the writer pool sizing");
|
|
assert_eq!(overridden, Some(40));
|
|
assert_eq!(zero, None, "zero must fall back to inheriting");
|
|
assert_eq!(junk, None, "unparsable value must fall back to inheriting");
|
|
}
|
|
|
|
#[test]
|
|
fn read_database_url_unset_or_blank_is_none() {
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("READ_DATABASE_URL");
|
|
|
|
std::env::remove_var("READ_DATABASE_URL");
|
|
let unset = Config::from_env().expect("config").read_database_url;
|
|
|
|
std::env::set_var("READ_DATABASE_URL", " ");
|
|
let blank = Config::from_env().expect("config").read_database_url;
|
|
|
|
std::env::set_var("READ_DATABASE_URL", "postgres://buzz:pw@replica:5432/buzz"); // sadscan:disable np.postgres.1
|
|
let set = Config::from_env().expect("config").read_database_url;
|
|
|
|
if let Some(value) = previous {
|
|
std::env::set_var("READ_DATABASE_URL", value);
|
|
} else {
|
|
std::env::remove_var("READ_DATABASE_URL");
|
|
}
|
|
|
|
assert_eq!(unset, None, "unset READ_DATABASE_URL must disable routing");
|
|
assert_eq!(blank, None, "blank READ_DATABASE_URL must disable routing");
|
|
assert_eq!(
|
|
set.as_deref(),
|
|
Some("postgres://buzz:pw@replica:5432/buzz") // sadscan:disable np.postgres.1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn replica_read_max_age_defaults_off_and_rejects_junk() {
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("BUZZ_REPLICA_READ_MAX_AGE_MS");
|
|
let previous_old = std::env::var_os("BUZZ_REPLICA_HEAD_MAX_AGE_SECS");
|
|
std::env::remove_var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS");
|
|
|
|
std::env::remove_var("BUZZ_REPLICA_READ_MAX_AGE_MS");
|
|
let unset = Config::from_env().expect("config").replica_read_max_age_ms;
|
|
|
|
std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", "1000");
|
|
let set = Config::from_env().expect("config").replica_read_max_age_ms;
|
|
|
|
std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", "0");
|
|
let zero = Config::from_env().expect("config").replica_read_max_age_ms;
|
|
|
|
std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", "soon");
|
|
let junk = Config::from_env();
|
|
|
|
// The retired seconds-denominated name must be a hard startup
|
|
// error even alongside a valid new-name value: silently ignoring
|
|
// it (or honouring it) would mean 1000x the intended budget.
|
|
std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", "1000");
|
|
std::env::set_var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS", "5");
|
|
let old_name = Config::from_env();
|
|
|
|
std::env::remove_var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS");
|
|
if let Some(value) = previous {
|
|
std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_REPLICA_READ_MAX_AGE_MS");
|
|
}
|
|
if let Some(value) = previous_old {
|
|
std::env::set_var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS", value);
|
|
}
|
|
|
|
assert_eq!(unset, 0, "replica read routing must default off");
|
|
assert_eq!(set, 1000);
|
|
assert_eq!(zero, 0, "explicit 0 is off");
|
|
assert!(
|
|
junk.is_err(),
|
|
"an unparsable budget must fail loudly, not silently disable"
|
|
);
|
|
match old_name {
|
|
Err(ConfigError::InvalidValue(message)) => assert!(
|
|
message.contains("BUZZ_REPLICA_READ_MAX_AGE_MS"),
|
|
"the error must name the replacement env var, got: {message}"
|
|
),
|
|
other => panic!("old env name must hard-fail startup, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn usage_metrics_replica_budget_defaults_on_independently_of_serving_reads() {
|
|
let _guard = ENV_MUTEX.lock().unwrap();
|
|
let previous_usage = std::env::var_os("BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS");
|
|
let previous_serving = std::env::var_os("BUZZ_REPLICA_READ_MAX_AGE_MS");
|
|
|
|
std::env::remove_var("BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS");
|
|
std::env::remove_var("BUZZ_REPLICA_READ_MAX_AGE_MS");
|
|
let defaults = Config::from_env().expect("config");
|
|
|
|
std::env::set_var("BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS", "0");
|
|
let disabled = Config::from_env().expect("config");
|
|
|
|
if let Some(value) = previous_usage {
|
|
std::env::set_var("BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS");
|
|
}
|
|
if let Some(value) = previous_serving {
|
|
std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_REPLICA_READ_MAX_AGE_MS");
|
|
}
|
|
|
|
assert_eq!(defaults.replica_read_max_age_ms, 0);
|
|
assert_eq!(defaults.usage_metrics_replica_max_age_ms, 30_000);
|
|
assert_eq!(disabled.usage_metrics_replica_max_age_ms, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn drain_jitter_defaults_off_and_rejects_junk() {
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("BUZZ_DRAIN_JITTER_MS");
|
|
|
|
std::env::remove_var("BUZZ_DRAIN_JITTER_MS");
|
|
let unset = Config::from_env().expect("config").drain_jitter_ms;
|
|
|
|
std::env::set_var("BUZZ_DRAIN_JITTER_MS", "20000");
|
|
let set = Config::from_env().expect("config").drain_jitter_ms;
|
|
|
|
std::env::set_var("BUZZ_DRAIN_JITTER_MS", "60000");
|
|
let capped = Config::from_env().expect("config").drain_jitter_ms;
|
|
|
|
std::env::set_var("BUZZ_DRAIN_JITTER_MS", "0");
|
|
let zero = Config::from_env().expect("config").drain_jitter_ms;
|
|
|
|
std::env::set_var("BUZZ_DRAIN_JITTER_MS", "soon");
|
|
let junk = Config::from_env();
|
|
|
|
std::env::set_var("BUZZ_DRAIN_JITTER_MS", "");
|
|
let empty = Config::from_env()
|
|
.expect("empty is a valid kill switch")
|
|
.drain_jitter_ms;
|
|
|
|
std::env::set_var("BUZZ_DRAIN_JITTER_MS", " ");
|
|
let blank = Config::from_env()
|
|
.expect("whitespace-only is a valid kill switch")
|
|
.drain_jitter_ms;
|
|
|
|
if let Some(value) = previous {
|
|
std::env::set_var("BUZZ_DRAIN_JITTER_MS", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_DRAIN_JITTER_MS");
|
|
}
|
|
|
|
assert_eq!(unset, 0, "drain jitter must default off");
|
|
assert_eq!(set, MAX_DRAIN_JITTER_MS);
|
|
assert_eq!(
|
|
capped, MAX_DRAIN_JITTER_MS,
|
|
"oversized jitter leaves close-frame flush headroom"
|
|
);
|
|
assert_eq!(zero, 0, "explicit 0 is off");
|
|
assert!(
|
|
junk.is_err(),
|
|
"an unparsable jitter must fail loudly, not silently disable"
|
|
);
|
|
assert_eq!(
|
|
empty, 0,
|
|
"an empty value is treated as unset — a kill switch, not a crashloop"
|
|
);
|
|
assert_eq!(blank, 0, "a whitespace-only value is treated as unset");
|
|
}
|
|
|
|
#[test]
|
|
fn audit_logging_defaults_on_and_accepts_explicit_off() {
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("BUZZ_AUDIT_ENABLED");
|
|
std::env::remove_var("BUZZ_AUDIT_ENABLED");
|
|
assert!(parse_bool("BUZZ_AUDIT_ENABLED", true).unwrap());
|
|
std::env::set_var("BUZZ_AUDIT_ENABLED", "false");
|
|
assert!(!parse_bool("BUZZ_AUDIT_ENABLED", true).unwrap());
|
|
if let Some(value) = previous {
|
|
std::env::set_var("BUZZ_AUDIT_ENABLED", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_AUDIT_ENABLED");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn audit_logging_rejects_invalid_boolean() {
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("BUZZ_AUDIT_ENABLED");
|
|
std::env::set_var("BUZZ_AUDIT_ENABLED", "sometimes");
|
|
let result = parse_bool("BUZZ_AUDIT_ENABLED", true);
|
|
if let Some(value) = previous {
|
|
std::env::set_var("BUZZ_AUDIT_ENABLED", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_AUDIT_ENABLED");
|
|
}
|
|
assert!(matches!(
|
|
result,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("BUZZ_AUDIT_ENABLED")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn join_policy_age_attestation_rejects_invalid_boolean() {
|
|
let _guards = env_guards();
|
|
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 _guards = env_guards();
|
|
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN", "1001");
|
|
std::env::set_var("BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN", "1004");
|
|
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_GIF_SEARCHES_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.gif_searches_per_min, 1004);
|
|
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 _guards = env_guards();
|
|
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 _guards = env_guards();
|
|
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 _guards = env_guards();
|
|
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_without_api_origin_boots_and_warns() {
|
|
// Regression: RELAY_OPERATOR_PUBKEYS is the shared allowlist for both
|
|
// community provisioning and the NIP-98 admin console. Configuring the
|
|
// admin console (pubkeys) must NOT force the provisioning origin — boot
|
|
// succeeds; provisioning stays fail-closed at request time.
|
|
let _guards = env_guards();
|
|
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");
|
|
|
|
let config = result.expect("pubkeys-set/origin-unset must boot, not fail closed");
|
|
assert_eq!(
|
|
config.relay_operator_pubkeys,
|
|
vec!["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()]
|
|
);
|
|
assert!(
|
|
config.relay_operator_api_origin.is_none(),
|
|
"origin stays unset — only the provisioning path requires it, at request time"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn relay_operator_api_origin_rejects_paths() {
|
|
let _guards = env_guards();
|
|
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_is_opt_in_and_gateway_is_required_when_enabled() {
|
|
let _guards = env_guards();
|
|
let previous_enabled = std::env::var_os("BUZZ_PUSH_ENABLED");
|
|
let previous = std::env::var_os("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
|
|
std::env::remove_var("BUZZ_PUSH_ENABLED");
|
|
std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
|
|
let config = Config::from_env().expect("default config");
|
|
assert!(!config.push_enabled);
|
|
assert!(config.push_gateway_delivery_url.is_none());
|
|
|
|
std::env::set_var("BUZZ_PUSH_ENABLED", "true");
|
|
let result = Config::from_env();
|
|
assert!(matches!(
|
|
result,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("must be configured")
|
|
));
|
|
|
|
std::env::set_var(
|
|
"BUZZ_PUSH_GATEWAY_DELIVERY_URL",
|
|
"https://push.example/v1/deliveries/apns",
|
|
);
|
|
let config = Config::from_env().expect("enabled push config");
|
|
assert!(config.push_enabled);
|
|
assert_eq!(
|
|
config
|
|
.push_gateway_delivery_url
|
|
.as_ref()
|
|
.map(url::Url::as_str),
|
|
Some("https://push.example/v1/deliveries/apns")
|
|
);
|
|
|
|
std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", "");
|
|
let result = Config::from_env();
|
|
assert!(matches!(
|
|
result,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("must not be empty")
|
|
));
|
|
|
|
std::env::set_var("BUZZ_PUSH_ENABLED", "false");
|
|
let config = Config::from_env().expect("disabled push config");
|
|
assert!(config.push_gateway_delivery_url.is_none());
|
|
|
|
if let Some(value) = previous_enabled {
|
|
std::env::set_var("BUZZ_PUSH_ENABLED", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_PUSH_ENABLED");
|
|
}
|
|
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 invalid_push_enabled_value_is_rejected() {
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("BUZZ_PUSH_ENABLED");
|
|
std::env::set_var("BUZZ_PUSH_ENABLED", "sometimes");
|
|
let result = Config::from_env();
|
|
if let Some(value) = previous {
|
|
std::env::set_var("BUZZ_PUSH_ENABLED", value);
|
|
} else {
|
|
std::env::remove_var("BUZZ_PUSH_ENABLED");
|
|
}
|
|
assert!(matches!(
|
|
result,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("BUZZ_PUSH_ENABLED")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn push_gateway_url_is_exact_and_fail_closed() {
|
|
assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok());
|
|
let delivery_url = "http://push.example:8080/v1/deliveries/apns";
|
|
assert_eq!(
|
|
parse_push_gateway_delivery_url(delivery_url)
|
|
.unwrap()
|
|
.as_str(),
|
|
delivery_url
|
|
);
|
|
assert!(parse_push_gateway_delivery_url("http://push-gateway/v1/deliveries/apns").is_ok());
|
|
for invalid in [
|
|
"ftp://push.example/v1/deliveries/apns",
|
|
"http://push-gateway:8080/v1/deliveries/apns?token=x",
|
|
"http://user@push-gateway:8080/v1/deliveries/apns",
|
|
"http://push-gateway:8080/v1/deliveries/apns#fragment",
|
|
"https://push.example:8443/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 operator_listener_routes_parse_and_normalize() {
|
|
let routes = parse_operator_listener_delivery_urls(
|
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:https://one.example/mentions;bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:https://two.example/mentions",
|
|
)
|
|
.expect("valid operator-listener routes");
|
|
assert_eq!(routes.len(), 2);
|
|
assert!(
|
|
routes.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
|
);
|
|
assert!(
|
|
routes.contains_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn operator_listener_routes_reject_unsafe_or_duplicate_entries() {
|
|
let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
for raw in [
|
|
format!("{key}:http://listener.example/mentions"),
|
|
format!("{key}:https://user:pass@listener.example/mentions"),
|
|
format!("{key}:https://listener.example/mentions?token=secret"),
|
|
format!("{key}:https://listener.example/mentions;{key}:https://other.example"),
|
|
] {
|
|
assert!(
|
|
parse_operator_listener_delivery_urls(&raw).is_err(),
|
|
"unsafe or duplicate route accepted: {raw}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_operator_listener_config_prevents_startup() {
|
|
let _guards = env_guards();
|
|
let result = config_with_operator_listeners(Some(";"));
|
|
|
|
assert!(matches!(
|
|
result,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("BUZZ_OPERATOR_LISTENERS")
|
|
));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn non_utf8_operator_listener_config_prevents_startup() {
|
|
use std::os::unix::ffi::OsStringExt;
|
|
|
|
let _guards = env_guards();
|
|
let previous = std::env::var_os("BUZZ_OPERATOR_LISTENERS");
|
|
std::env::set_var(
|
|
"BUZZ_OPERATOR_LISTENERS",
|
|
std::ffi::OsString::from_vec(vec![0xff]),
|
|
);
|
|
|
|
let result = Config::from_env();
|
|
|
|
match previous {
|
|
Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value),
|
|
None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"),
|
|
}
|
|
|
|
assert!(matches!(
|
|
result,
|
|
Err(ConfigError::InvalidValue(ref message))
|
|
if message.contains("BUZZ_OPERATOR_LISTENERS must be valid UTF-8")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn valid_and_absent_operator_listener_config_preserve_routes() {
|
|
let _guards = env_guards();
|
|
let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
let raw = format!("{key}:https://listener.example/mentions");
|
|
let config = config_with_operator_listeners(Some(&raw)).expect("valid listener config");
|
|
assert_eq!(
|
|
config
|
|
.operator_listener_delivery_urls
|
|
.get(key)
|
|
.map(url::Url::as_str),
|
|
Some("https://listener.example/mentions")
|
|
);
|
|
|
|
let config = config_with_operator_listeners(None).expect("absent listener config");
|
|
assert!(config.operator_listener_delivery_urls.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_push_gateway_timeout_is_not_silently_defaulted() {
|
|
let _guards = env_guards();
|
|
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 _guards = env_guards();
|
|
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 _guards = env_guards();
|
|
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 _guards = env_guards();
|
|
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 _guards = env_guards();
|
|
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 _guards = env_guards();
|
|
// 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:?}"
|
|
);
|
|
}
|
|
}
|