From de8f7a75992cb1b643062f657ec86a67979856de Mon Sep 17 00:00:00 2001 From: "studio256.net" Date: Thu, 23 Jul 2026 12:45:50 -0400 Subject: [PATCH] fix(relay): warn at startup when the IPv6 loopback port is squatted The default bind is IPv4-only 0.0.0.0:3000, and localhost resolves IPv6-first on macOS - so an unrelated process on [::1]:3000 (commonly a Vite dev server) silently intercepts local clients. Dev servers answer GETs with 200 and writes with 404, which presents as a relay bug during first-launch onboarding. Adds a warn-only probe after binding: when bound IPv4-only and something is listening on the IPv6 loopback at the same port, log a warning with the BUZZ_BIND_ADDR=[::]: remediation. No binding behavior changes. Co-Authored-By: Claude Fable 5 --- crates/buzz-relay/src/main.rs | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index be9794922b..9c9ed95eba 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -31,6 +31,16 @@ fn buzz_auto_migrate_enabled(value: Option<&str>) -> bool { }) } +/// Returns true when some other process is listening on the IPv6 loopback at +/// `port`. Used to warn when the relay binds IPv4-only (the `0.0.0.0:3000` +/// default): on macOS `localhost` resolves to `::1` first, so local clients +/// silently reach the squatter instead of the relay — dev servers often answer +/// GETs with 200 and writes with 404, which looks like a relay bug. +fn ipv6_loopback_squatter(port: u16) -> bool { + let probe = std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, port)); + std::net::TcpStream::connect_timeout(&probe, std::time::Duration::from_millis(250)).is_ok() +} + /// Controls how many per-community gauge series the usage poller emits. /// /// Datadog cost is proportional to the number of unique time-series. With ~25 @@ -1147,6 +1157,18 @@ async fn serve( .map_err(|e| anyhow::anyhow!("Failed to bind {}: {e}", config.bind_addr))?; info!(addr = %config.bind_addr, "buzz-relay TCP listening"); + // Warn-only probe: an IPv4-only bind leaves [::1]: free for another + // process, and `localhost` resolves IPv6-first on macOS, silently routing + // local clients away from the relay. + if config.bind_addr.is_ipv4() && ipv6_loopback_squatter(config.bind_addr.port()) { + warn!( + port = config.bind_addr.port(), + "another process is listening on the IPv6 loopback for this port; \ + clients resolving `localhost` to ::1 will reach it instead of \ + buzz-relay — set BUZZ_BIND_ADDR=[::]: or free the port" + ); + } + #[cfg(unix)] if let Some(ref uds_path) = config.uds_path { use std::os::unix::fs::FileTypeExt as _; @@ -1814,6 +1836,20 @@ mod tests { registry::{GenerationalAtomicStorage, Registry}, }; + #[test] + fn ipv6_loopback_squatter_detects_listener() { + // Skip silently when the environment has no IPv6 loopback. + let Ok(listener) = std::net::TcpListener::bind("[::1]:0") else { + return; + }; + let Ok(addr) = listener.local_addr() else { + return; + }; + assert!(super::ipv6_loopback_squatter(addr.port())); + drop(listener); + assert!(!super::ipv6_loopback_squatter(addr.port())); + } + #[tokio::test(start_paused = true)] async fn periodic_loop_exits_immediately_on_cancellation() { let cancel = CancellationToken::new();