//! Swarm-level controller daemon. Runs as the unprivileged //! `swarm-controller` user on whichever host the operator flips //! `services.hyperhive.swarm.controller.enable` on, and serves HTTP over a //! unix socket that the hive-gateway's nginx proxies to. //! //! Holds one piece of read-only state: the swarm's hive directory, loaded //! once at startup from an env var the NixOS module sets //! (`services.hyperhive.swarm.controller`) — see `load_hives`. Still no //! persistence and no writes; a config change means a redeploy, same as //! every other option this process reads. //! //! Distinct from `hive-c0re`, which is per-hive: c0re owns the agents on //! one host, this owns what is true across hives. //! //! `OpenAPI` spec generation mirrors `hive-c0re/src/dashboard/mod.rs` //! exactly: `#[utoipa::path(...)]` per handler, an `ApiDoc` root, and a //! raw JSON route at `/api/openapi.json`. Swagger UI itself is //! nginx-hosted from the nix store, same shape as the per-hive //! dashboard's (`nix/host-modules/hive-gateway/vhosts.nix`'s //! `swarmUiVhost` — a swagger-ui-theme dist under `/api/docs/`, no //! fallback to this daemon). Only annotated routes appear in the spec; //! an unannotated one just doesn't show up, nothing breaks. use std::os::unix::fs::PermissionsExt as _; use std::path::PathBuf; use std::sync::Arc; use anyhow::{Context, Result}; use axum::{Json, extract::State, routing::get}; use serde::{Deserialize, Serialize}; use utoipa::{OpenApi, ToSchema}; use utoipa_axum::{router::OpenApiRouter, routes}; /// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`. /// /// A compiled-in default is legitimate here and is *not* the mistake that /// a hardcoded remote address would be: this is a path this process /// **creates**, not an address it hopes to find something at. systemd's /// `RuntimeDirectory=swarm-controller` makes the parent exist before /// `ExecStart`, so the default names a directory the unit just produced. /// /// The directory is its own — deliberately not shared with hive-c0re's /// `/run/hyperhive`. The socket is `0666`, so its directory is the only /// access control it has; co-locating it with c0re's admin socket would /// put both within reach of whatever can reach either. nginx runs on the /// host, so nothing narrows its reach for you. const DEFAULT_SOCKET: &str = "/run/swarm-controller/controller.sock"; fn socket_path() -> PathBuf { std::env::var_os("SWARM_CONTROLLER_SOCKET") .map_or_else(|| PathBuf::from(DEFAULT_SOCKET), PathBuf::from) } /// Root of the auto-generated `OpenAPI` spec, served raw at /// `/api/openapi.json` — see the module doc comment above. Tag list /// grows alongside the swarm-level surfaces this daemon picks up, same /// as `hive-c0re::dashboard::ApiDoc`'s tag list did. #[derive(OpenApi)] #[openapi( info( title = "hyperhive swarm-controller API", description = "swarm-controller's HTTP surface, served over its unix \ socket behind the gateway's swarm-UI vhost." ), tags( (name = "health", description = "liveness probe"), (name = "hives", description = "the swarm's hive directory"), ) )] struct ApiDoc; /// Liveness probe. Returns the build's version so an operator can tell /// *which* controller answered without shelling onto the host. #[utoipa::path( get, path = "/health", responses((status = 200, description = "process is up, body is \"swarm-controller \"", body = String)), tag = "health" )] async fn health() -> &'static str { concat!("swarm-controller ", env!("CARGO_PKG_VERSION"), "\n") } /// One hive in the swarm's directory — the same `name`/`domain` pair /// `services.hyperhive.swarm.hives` (nix/host-modules/swarm.nix) declares, /// carried across unchanged rather than reshaped, so this stays a direct /// mirror of the nix source of truth instead of a second vocabulary for /// the same two fields. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] struct HiveEntry { name: String, domain: String, } #[derive(Clone)] struct AppState { /// Loaded once at startup (`load_hives`); never mutated, so an /// `Arc` clone per request is the whole synchronization story. hives: Arc>, } /// Env var the controller's NixOS module sets from /// `services.hyperhive.swarm.hives`, JSON-encoded — same shape hive-c0re /// already builds for `HYPERHIVE_PEERS` /// (nix/host-modules/hive-c0re/environment.nix), just the full directory /// (this daemon has no "self" to exclude) rather than peers-minus-self. const HIVES_ENV: &str = "SWARM_CONTROLLER_HIVES"; /// Parses [`HIVES_ENV`] into the swarm's hive directory. Unset or /// unparseable both fall back to an empty list with a warning rather than /// failing startup — a swarm-controller that can't yet see its own config /// (dev run, a module not wired up yet) should still serve `/health`. fn load_hives() -> Vec { let Some(raw) = std::env::var_os(HIVES_ENV) else { return Vec::new(); }; match serde_json::from_str(&raw.to_string_lossy()) { Ok(hives) => hives, Err(e) => { tracing::warn!(error = %e, env = HIVES_ENV, "failed to parse hive directory, serving an empty list"); Vec::new() } } } /// The swarm's hive directory — every hive, including whichever one this /// controller instance happens to run on (there is no "self" to exclude /// at the swarm level, unlike `hive-c0re`'s peer list). #[utoipa::path( get, path = "/api/hives", responses((status = 200, description = "every hive in the swarm", body = Vec)), tag = "hives" )] async fn get_hives(State(state): State) -> Json> { Json((*state.hives).clone()) } #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .init(); let path = socket_path(); // `RuntimeDirectoryPreserve=yes` keeps the directory across a restart, // so a socket file from the previous run can outlive the process that // owned it and `bind` would fail with EADDRINUSE. Unlinking a stale // socket is safe precisely because the directory is ours alone: nothing // else can have put a file at this path. if let Err(e) = std::fs::remove_file(&path) && e.kind() != std::io::ErrorKind::NotFound { return Err(e).with_context(|| format!("clearing stale socket at {}", path.display())); } let listener = tokio::net::UnixListener::bind(&path) .with_context(|| format!("binding {}", path.display()))?; // `bind` leaves the socket 0755, and connecting needs write — the // gateway's nginx is a different user, so it would be locked out. // 0666 matches how hive-c0re publishes the per-agent sockets // (`socket_server::start`), and rests on the same argument: **the // containing directory is the access control, not the socket mode.** // This directory holds one socket and is bind-mounted into exactly // one container. That is also why it must not be shared with // hive-c0re's `/run/hyperhive` — with a 0666 socket, a directory // that carries more than it should is the whole vulnerability. std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)) .with_context(|| format!("chmod {}", path.display()))?; tracing::info!(socket = %path.display(), "swarm-controller listening"); let state = AppState { hives: Arc::new(load_hives()), }; let (router, api) = OpenApiRouter::::with_openapi(ApiDoc::openapi()) .routes(routes!(health)) .routes(routes!(get_hives)) .split_for_parts(); // Just the JSON, not the UI — Swagger UI itself is nginx-hosted from // the nix store (see the module doc comment above). `api` is // `Clone`; each request gets its own owned copy for `Json` to // serialize, same as `hive-c0re::dashboard::serve`. let app = router .route( "/api/openapi.json", get(move || async move { Json(api.clone()) }), ) .with_state(state); axum::serve(listener, app) .await .context("serving swarm-controller") } #[cfg(test)] mod tests { use super::{DEFAULT_SOCKET, HIVES_ENV, HiveEntry, load_hives}; use std::path::Path; /// The socket must not share a directory with anything else, because /// the socket is `0666` and the directory is therefore the only access /// control it has. `/run/hyperhive` in particular holds hive-c0re's /// **admin** socket, and nginx — a host service — is bounded by /// nothing but the directory itself. /// /// A test rather than a comment: the failure this guards against is a /// one-word edit that looks tidier and reads fine in review. #[test] fn socket_lives_in_its_own_runtime_dir() { let parent = Path::new(DEFAULT_SOCKET) .parent() .expect("socket path has a parent directory"); assert_eq!( parent, Path::new("/run/swarm-controller"), "the socket's directory is its access control — moving it under a shared \ directory (notably /run/hyperhive, which holds the host admin socket) \ exposes everything else in that directory to the gateway's nginx" ); } /// One test, not three, deliberately: `HIVES_ENV` is a real process /// env var, and `cargo test`'s default parallel runner would race /// separate missing/malformed/valid tests against each other. Driving /// all three states sequentially inside one test needs no mutex and /// still proves each branch of `load_hives`. /// /// SAFETY: single-threaded mutation of a process env var no other /// test in this crate reads; restored (removed) before returning. #[test] fn load_hives_covers_missing_malformed_and_valid() { unsafe { std::env::remove_var(HIVES_ENV); } assert_eq!( load_hives(), Vec::::new(), "unset env var is an empty directory, not a startup failure" ); unsafe { std::env::set_var(HIVES_ENV, "not json"); } assert_eq!( load_hives(), Vec::::new(), "unparseable env var falls back to empty rather than panicking — \ /health must still answer even if this daemon's own config is wrong" ); unsafe { std::env::set_var( HIVES_ENV, r#"[{"name":"pr1ma","domain":"pr1ma.example.com"},{"name":"umbra","domain":"umbra.example.com"}]"#, ); } assert_eq!( load_hives(), vec![ HiveEntry { name: "pr1ma".to_string(), domain: "pr1ma.example.com".to_string(), }, HiveEntry { name: "umbra".to_string(), domain: "umbra.example.com".to_string(), }, ] ); unsafe { std::env::remove_var(HIVES_ENV); } } }