From c5d466c5c54b6c8c3719b8c7f8d28d5ec9a35ef4 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 30 May 2026 19:29:27 +0200 Subject: [PATCH] c0re: bind dashboard to 127.0.0.1 only (#652) --- hive-c0re/src/dashboard.rs | 11 +++++++++-- nix/modules/hive-c0re.nix | 27 +++++++++++++++++---------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 6fdf6380..79339c00 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -92,9 +92,16 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { // /static/dashboard.css → dist/static/dashboard.css, etc.). .fallback_service(ServeDir::new(&static_dir)) .with_state(AppState { coord }); - let addr = SocketAddr::from(([0, 0, 0, 0], port)); + // Bind loopback-only (#652). External access funnels through + // hive-gateway (in-host-netns nginx container), which proxies + // `/` → `127.0.0.1:` upstream. Operators who opt + // out of the gateway lose remote dashboard access — that's by + // design; the c0re HTTP surface is privileged (approve / deny / + // destroy, etc.) and any external exposure needs to pass through + // a real reverse proxy with auth. + let addr = SocketAddr::from(([127, 0, 0, 1], port)); let listener = bind_with_retry(addr).await?; - tracing::info!(%port, "dashboard listening"); + tracing::info!(%addr, "dashboard listening"); axum::serve(listener, app).await?; Ok(()) } diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 9492f015..b67a729f 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -194,21 +194,28 @@ in managerToplevel ]; - # Dashboard + per-container web UIs share the host's network namespace and - # need their ports reachable when there's no gateway in front. Dashboard: - # `cfg.dashboardPort` (default 7000). Manager: 8000. Sub-agents: 8100..8999 - # (deterministic hash; see `lifecycle::agent_web_port`). + # Per-container web UIs share the host's network namespace and need + # their ports reachable when there's no gateway in front. Manager: + # 8000. Sub-agents: 8100..8999 (deterministic hash; see + # `lifecycle::agent_web_port`). + # + # The dashboard port (`cfg.dashboardPort`, default 7000) is *not* + # listed here — since #652 the dashboard binds `127.0.0.1` only, + # so opening the firewall hole would be a no-op. Remote dashboard + # access flows through hive-gateway (default-on); operators who + # opt out of the gateway lose external dashboard reach by design — + # the surface is privileged (approve / deny / destroy) and must + # not be exposed without a real reverse proxy in front. # # When `services.hyperhive.gateway.enable = true` (the default), the # gateway nginx is the sole external entry point and proxies to - # `127.0.0.1:7000` etc. internally — leaving the direct ports open - # in the host firewall would defeat the gateway's "single front - # door" story (closes #621). Operators who opt out of the gateway - # still get the direct ports opened so the legacy - # `http://:7000/` flow works. + # `127.0.0.1:7000` etc. internally — leaving the per-agent ports + # open in the host firewall would defeat the gateway's "single + # front door" story (closes #621). Operators who opt out of the + # gateway still get those direct ports opened so the legacy + # `http://:8100/` flow works. networking.firewall = lib.mkIf (!config.services.hyperhive.gateway.enable) { allowedTCPPorts = [ - cfg.dashboardPort 8000 ]; allowedTCPPortRanges = [