diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 7e1f064e2..b6245b250 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::net::{IpAddr, Ipv4Addr}; use std::sync::atomic::Ordering; use std::sync::Arc; @@ -18,7 +19,7 @@ use buzz_db::{Db, DbConfig}; use buzz_pubsub::{rate_limiter::AdmissionRateLimiter, InProcessNip98ReplayGuard, PubSubManager}; use buzz_search::SearchService; -use buzz_relay::config::{Config, MAX_DRAIN_JITTER_MS}; +use buzz_relay::config::{Config, RelayProfile, MAX_DRAIN_JITTER_MS}; use buzz_relay::metrics as relay_metrics; use buzz_relay::router::{build_health_router, build_router}; use buzz_relay::state::{AppBackends, AppState}; @@ -1217,7 +1218,7 @@ async fn run_single_node(config: Config, tracer_init: telemetry::TracerInit) -> )); } - relay_metrics::install(config.metrics_port, usage_metrics_idle_timeout_secs(60)); + relay_metrics::install_loopback(config.metrics_port, usage_metrics_idle_timeout_secs(60)); let db_path = std::env::var("BUZZ_LOCAL_DB").unwrap_or_else(|_| "buzz-local.sqlite".to_string()); let media_root = @@ -1334,6 +1335,13 @@ async fn run_single_node(config: Config, tracer_init: telemetry::TracerInit) -> /// roughly the 5s grace plus the ack wait. const GRACEFUL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +fn health_listener_ip(profile: RelayProfile) -> IpAddr { + match profile { + RelayProfile::SingleNode => IpAddr::V4(Ipv4Addr::LOCALHOST), + RelayProfile::Production => IpAddr::V4(Ipv4Addr::UNSPECIFIED), + } +} + async fn serve( router: axum::Router, health_router: axum::Router, @@ -1341,10 +1349,11 @@ async fn serve( ) -> anyhow::Result<()> { let config = &state.config; - let health_listener = tokio::net::TcpListener::bind(("0.0.0.0", config.health_port)) + let health_host = health_listener_ip(config.profile); + let health_listener = tokio::net::TcpListener::bind((health_host, config.health_port)) .await .map_err(|e| anyhow::anyhow!("Failed to bind health port {}: {e}", config.health_port))?; - info!(port = config.health_port, "Health probe listener started"); + info!(host = %health_host, port = config.health_port, "Health probe listener started"); tokio::spawn(async move { axum::serve(health_listener, health_router).await.ok(); }); diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index 16e521a44..21d1ff43c 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -14,6 +14,7 @@ //! recorded by [`track_metrics`] middleware on the app router. Buzz-specific //! metrics are recorded inline at their call sites. +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::time::{Duration, Instant}; use axum::{ @@ -56,6 +57,17 @@ const GIT_PACK_BUCKETS: [f64; 9] = [0.0, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 1 /// Integer-count buckets for fan-out recipient histograms. const FANOUT_BUCKETS: [f64; 9] = [0.0, 1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 500.0, 1000.0]; +fn prometheus_listener(port: u16, loopback_only: bool) -> SocketAddr { + SocketAddr::new( + IpAddr::V4(if loopback_only { + Ipv4Addr::LOCALHOST + } else { + Ipv4Addr::UNSPECIFIED + }), + port, + ) +} + /// Install the global metrics recorder and spawn the Prometheus HTTP exporter. /// /// `build()` returns the recorder + exporter future and internally spawns @@ -64,8 +76,17 @@ const FANOUT_BUCKETS: [f64; 9] = [0.0, 1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 500.0, /// Must be called from within a Tokio runtime. /// Panics if a recorder is already installed or the port is in use. pub fn install(port: u16, gauge_idle_timeout_secs: u64) { + install_on(prometheus_listener(port, false), gauge_idle_timeout_secs); +} + +/// Install the metrics exporter on loopback for the single-node profile. +pub fn install_loopback(port: u16, gauge_idle_timeout_secs: u64) { + install_on(prometheus_listener(port, true), gauge_idle_timeout_secs); +} + +fn install_on(listener: SocketAddr, gauge_idle_timeout_secs: u64) { let (recorder, exporter) = PrometheusBuilder::new() - .with_http_listener(([0, 0, 0, 0], port)) + .with_http_listener(listener) // Remove gauge series that the relay intentionally stops emitting. .idle_timeout( MetricKindMask::GAUGE, @@ -205,3 +226,14 @@ pub async fn track_metrics(req: Request, next: Next) -> Response { response } + +#[cfg(test)] +mod tests { + use super::prometheus_listener; + + #[test] + fn single_node_metrics_listener_is_loopback_only() { + assert!(prometheus_listener(9102, true).ip().is_loopback()); + assert!(prometheus_listener(9102, false).ip().is_unspecified()); + } +}