From d4409b27a3b6c63db8704601d8092bb7cfcf2a82 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 1 Jun 2026 22:35:11 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(gateway):=20PAM=20auth=20against=20hos?= =?UTF-8?q?t=20=E2=80=94=20close=20#1010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds opt-in HTTP Basic auth to the hive-gateway backed by the host PAM stack + group membership check. New binary `hive-gateway-auth` (hive-c0re workspace): - Axum HTTP service on 127.0.0.1:7002 (host loopback) - Decodes Basic credentials, authenticates via pam_unix.so - Checks membership in `hyperhive-operator` group (or custom) - Returns 200 / 401 / 403; nginx `auth_request` consumes these New options under `services.hyperhive.gateway.auth`: - `enable` — off by default - `port` — auth service port (default 7002) - `realm` — WWW-Authenticate realm string (default "hyperhive") - `group` — required host group (default "hyperhive-operator") - `pamService` — PAM service name (default "hive-gateway") Host-side NixOS wiring: - `users.groups.hyperhive-operator` declared when default group used - `/etc/pam.d/hive-gateway` emitted via `security.pam.services` - `systemd.services.hive-gateway-auth` runs the auth binary as root (needs /etc/shadow access for pam_unix.so) Gateway container nginx wiring: - `location = /__hive_gateway_auth` — internal proxy to auth service - `auth_request /__hive_gateway_auth` on the `"/"` proxy location - `@hive_auth_required` named location adds WWW-Authenticate: Basic header on 401 so browsers display a login prompt Workspace deps: pam = "0.8"; flake.nix: linux-pam added to nativeBuildInputs so pkg-config can find libpam at build time. --- Cargo.lock | 218 ++++++++++++++++++++---- Cargo.toml | 1 + flake.nix | 1 + hive-c0re/Cargo.toml | 1 + hive-c0re/src/bin/hive_gateway_auth.rs | 224 +++++++++++++++++++++++++ nix/modules/hive-gateway.nix | 142 ++++++++++++++++ 6 files changed, 550 insertions(+), 37 deletions(-) create mode 100644 hive-c0re/src/bin/hive_gateway_auth.rs diff --git a/Cargo.lock b/Cargo.lock index 164bfbc7..d7599556 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,7 +120,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -199,7 +199,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -210,7 +210,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -303,6 +303,26 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.117", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -384,7 +404,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", ] [[package]] @@ -448,6 +477,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", +] + [[package]] name = "clap" version = "4.6.1" @@ -479,7 +518,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -616,7 +655,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -639,7 +678,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -650,7 +689,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -754,7 +793,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -985,7 +1024,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1067,6 +1106,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "gloo-timers" version = "0.3.0" @@ -1204,6 +1249,7 @@ dependencies = [ "hive-sh4re", "libc", "listenfd", + "pam", "reqwest", "rusqlite", "serde", @@ -1640,6 +1686,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1717,6 +1772,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1818,7 +1879,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1856,7 +1917,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2037,6 +2098,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2064,6 +2131,16 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2136,6 +2213,40 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "pam" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab553c52103edb295d8f7d6a3b593dc22a30b1fb99643c777a8f36915e285ba" +dependencies = [ + "libc", + "memchr", + "pam-macros", + "pam-sys", + "users", +] + +[[package]] +name = "pam-macros" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94f3b9b97df3c6d4e51a14916639b24e02c7d15d1dba686ce9b1118277cb811" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pam-sys" +version = "1.0.0-alpha5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9484729b3e52c0bacdc5191cb6a6a5f31ef4c09c5e4ab1209d3340ad9e997b" +dependencies = [ + "bindgen", + "libc", +] + [[package]] name = "parking" version = "2.2.1" @@ -2295,7 +2406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2357,7 +2468,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2389,7 +2500,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", @@ -2409,7 +2520,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -2563,7 +2674,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2684,7 +2795,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn", + "syn 2.0.117", ] [[package]] @@ -2862,7 +2973,7 @@ dependencies = [ "quote", "ruma-identifiers-validation", "serde", - "syn", + "syn 2.0.117", "toml", ] @@ -2896,6 +3007,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2994,7 +3111,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -3046,7 +3163,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3057,7 +3174,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3149,6 +3266,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -3261,6 +3384,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -3289,7 +3423,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3342,7 +3476,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3353,7 +3487,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3433,7 +3567,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3623,7 +3757,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3770,6 +3904,16 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "users" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4227e95324a443c9fcb06e03d4d85e91aabe9a5a02aa818688b6918b6af486" +dependencies = [ + "libc", + "log", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -3922,7 +4066,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4060,7 +4204,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4071,7 +4215,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4308,7 +4452,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4324,7 +4468,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4409,7 +4553,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4430,7 +4574,7 @@ checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4450,7 +4594,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4471,7 +4615,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4504,7 +4648,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b8ac38ca..127503cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ must_use_candidate = "allow" [workspace.dependencies] anyhow = "1" +pam = "0.8" axum = { version = "0.8", features = ["ws"] } base64 = "0.22" clap = { version = "4", features = ["derive"] } diff --git a/flake.nix b/flake.nix index 6d746ab6..728dda84 100644 --- a/flake.nix +++ b/flake.nix @@ -101,6 +101,7 @@ pkgs.git pkgs.sqlite pkgs.pkg-config + pkgs.linux-pam # libpam.so.0 for hive-gateway-auth ]; } ); diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index ac70638e..3f96af5a 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -13,6 +13,7 @@ reqwest.workspace = true clap.workspace = true hive-sh4re.workspace = true libc = "0.2" +pam.workspace = true listenfd = "1" rusqlite.workspace = true serde.workspace = true diff --git a/hive-c0re/src/bin/hive_gateway_auth.rs b/hive-c0re/src/bin/hive_gateway_auth.rs new file mode 100644 index 00000000..deb69322 --- /dev/null +++ b/hive-c0re/src/bin/hive_gateway_auth.rs @@ -0,0 +1,224 @@ +//! `hive-gateway-auth` — host-side HTTP basic auth validator for the hive gateway. +//! +//! Runs as a systemd service on the host. The gateway container's nginx +//! calls this via `auth_request` for every incoming request; this binary +//! validates the `Authorization: Basic` credentials against the host's PAM +//! stack and checks that the authenticated user is a member of the +//! `hyperhive-operator` group (configurable via `--group`). +//! +//! Listens on `127.0.0.1:PORT` (default 7002, host loopback only — the +//! gateway container shares the host netns, so it can reach this directly). +//! +//! Response codes nginx cares about: +//! - `200 OK` — auth passed; nginx proxies the request upstream. +//! - `401 Unauthorized` — missing/invalid credentials; nginx returns 401 +//! with a `WWW-Authenticate: Basic realm="…"` header added by the nginx +//! config. The body from this service is discarded by nginx. +//! - `403 Forbidden` — valid credentials but not in the required group. + +use std::net::SocketAddr; +use std::str::FromStr as _; + +use anyhow::{Context as _, Result}; +use axum::Router; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::any; +use base64::Engine as _; +use clap::Parser; + +#[derive(Parser)] +#[command( + name = "hive-gateway-auth", + about = "Host-side HTTP basic-auth validator for hive-gateway" +)] +struct Cli { + /// Address to listen on. Bind to 127.0.0.1 (loopback-only) so + /// only the gateway container (shared host netns) can reach it. + #[arg(long, default_value = "127.0.0.1:7002")] + listen: String, + /// PAM service name. A matching `/etc/pam.d/` file must + /// exist on the host and include `pam_unix.so` for password auth. + #[arg(long, default_value = "hive-gateway")] + pam_service: String, + /// Host group that every authenticated user must belong to. + #[arg(long, default_value = "hyperhive-operator")] + group: String, +} + +#[derive(Clone)] +struct AppState { + pam_service: String, + required_group: String, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .init(); + + let cli = Cli::parse(); + let addr = SocketAddr::from_str(&cli.listen) + .with_context(|| format!("invalid --listen address: {}", cli.listen))?; + let state = AppState { + pam_service: cli.pam_service, + required_group: cli.group, + }; + let app = Router::new() + .route("/{*path}", any(handle_auth)) + .route("/", any(handle_auth)) + .with_state(state); + + tracing::info!(addr = %addr, "hive-gateway-auth listening"); + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("bind {addr}"))?; + axum::serve(listener, app).await.context("serve")?; + Ok(()) +} + +/// Validate the `Authorization: Basic` header. Returns the decoded +/// `(username, password)` pair, or `None` when the header is absent, +/// malformed, or not Basic-scheme. +fn parse_basic_auth(headers: &HeaderMap) -> Option<(String, String)> { + let value = headers.get("authorization")?.to_str().ok()?; + let encoded = value.strip_prefix("Basic ")?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + let s = String::from_utf8(decoded).ok()?; + let (user, pass) = s.split_once(':')?; + Some((user.to_owned(), pass.to_owned())) +} + +/// Check whether `username` is a member of `group` by reading the host's +/// group database. Uses `getgrouplist(3)` (via `nix::unistd`) if available, +/// or falls back to scanning `/etc/group` entries directly. +/// +/// Returns `true` when the user's primary GID matches OR when the user +/// appears in the supplementary member list of the target group. +fn user_in_group(username: &str, group_name: &str) -> bool { + use std::ffi::CString; + // SAFETY: all `libc` calls here follow the documented calling + // conventions for `getpwnam_r` / `getgrnam_r` / `getgrouplist`. + unsafe { + // Resolve the target group's GID. + let c_group = match CString::new(group_name) { + Ok(s) => s, + Err(_) => return false, + }; + let mut grp_storage = std::mem::zeroed::(); + let mut grp_ptr: *mut libc::group = std::ptr::null_mut(); + let mut buf = vec![0i8; 4096]; + let ret = libc::getgrnam_r( + c_group.as_ptr(), + &mut grp_storage, + buf.as_mut_ptr(), + buf.len(), + &mut grp_ptr, + ); + if ret != 0 || grp_ptr.is_null() { + return false; // group not found + } + let target_gid = (*grp_ptr).gr_gid; + + // Resolve the user's primary GID. + let c_user = match CString::new(username) { + Ok(s) => s, + Err(_) => return false, + }; + let mut pwd_storage = std::mem::zeroed::(); + let mut pwd_ptr: *mut libc::passwd = std::ptr::null_mut(); + let mut pwd_buf = vec![0i8; 4096]; + let ret = libc::getpwnam_r( + c_user.as_ptr(), + &mut pwd_storage, + pwd_buf.as_mut_ptr(), + pwd_buf.len(), + &mut pwd_ptr, + ); + if ret != 0 || pwd_ptr.is_null() { + return false; // user not found + } + + // Primary group match. + if (*pwd_ptr).pw_gid == target_gid { + return true; + } + + // Scan gr_mem list for the username. + let mut mem = (*grp_ptr).gr_mem; + if mem.is_null() { + return false; + } + while !(*mem).is_null() { + let member = std::ffi::CStr::from_ptr(*mem); + if member.to_bytes() == username.as_bytes() { + return true; + } + mem = mem.add(1); + } + false + } +} + +/// Authenticate `username` / `password` against the host PAM stack using +/// the configured service. Returns `true` on success. The PAM call is +/// synchronous and blocking — runs on the tokio thread pool via +/// `spawn_blocking`. +fn pam_authenticate_sync(service: &str, username: &str, password: &str) -> bool { + let mut client = match pam::Client::with_password(service) { + Ok(c) => c, + Err(e) => { + tracing::warn!(service, error = %e, "pam: client init failed"); + return false; + } + }; + client + .conversation_mut() + .set_credentials(username, password); + if let Err(e) = client.authenticate() { + tracing::debug!(service, username, error = %e, "pam: authenticate failed"); + return false; + } + if let Err(e) = client.account_mgmt() { + tracing::debug!(service, username, error = %e, "pam: account_mgmt failed"); + return false; + } + true +} + +async fn handle_auth( + State(state): State, + headers: HeaderMap, +) -> StatusCode { + let Some((username, password)) = parse_basic_auth(&headers) else { + return StatusCode::UNAUTHORIZED; + }; + + // PAM blocks — run off the async executor. + let service = state.pam_service.clone(); + let user_clone = username.clone(); + let pass_clone = password.clone(); + let authed = + tokio::task::spawn_blocking(move || pam_authenticate_sync(&service, &user_clone, &pass_clone)) + .await + .unwrap_or(false); + + if !authed { + tracing::info!(username, "auth: bad credentials"); + return StatusCode::UNAUTHORIZED; + } + + if !user_in_group(&username, &state.required_group) { + tracing::info!(username, group = %state.required_group, "auth: user not in required group"); + return StatusCode::FORBIDDEN; + } + + tracing::debug!(username, "auth: ok"); + StatusCode::OK +} diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index ef7c5c28..dafc7ec0 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -198,6 +198,65 @@ in ''; }; + auth = { + enable = lib.mkEnableOption '' + HTTP basic auth on the gateway using host PAM. When enabled, every + request to the gateway's main vhost requires a valid username and + password from the host's user database. The user must also be a + member of the `services.hyperhive.gateway.auth.group` host group + (default: `hyperhive-operator`). A small `hive-gateway-auth` + systemd service runs on the host, listens on loopback at + `services.hyperhive.gateway.auth.port`, and performs the PAM + authentication. nginx inside the gateway container calls it via + `auth_request` (the container shares the host netns, so loopback + is reachable directly). Off by default — local / single-operator + setups may not need authentication. + ''; + + port = lib.mkOption { + type = lib.types.port; + default = 7002; + description = '' + TCP port for the `hive-gateway-auth` service on the host's + loopback interface. nginx's `auth_request` sub-request is + sent here. Change when 7002 is already in use. + ''; + }; + + realm = lib.mkOption { + type = lib.types.str; + default = "hyperhive"; + example = "my-hive"; + description = '' + HTTP Basic auth `realm` value sent in the `WWW-Authenticate` + header when credentials are absent or rejected. + ''; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "hyperhive-operator"; + example = "admins"; + description = '' + Host Unix group every authenticated user must belong to. + Create the group and add operator accounts before enabling + auth. When using the default value, the group is + automatically defined on the host by this module. + ''; + }; + + pamService = lib.mkOption { + type = lib.types.str; + default = "hive-gateway"; + description = '' + PAM service name. A matching `/etc/pam.d/hive-gateway` file + is defined by this module when using the default value. Set to + an existing service (e.g. `"login"`) to reuse a custom PAM + stack instead of the generated one. + ''; + }; + }; + }; config = lib.mkIf cfg.enable { @@ -212,6 +271,52 @@ in } ]; + # HOST-SIDE: PAM auth service for the gateway. + # Defined here so it co-locates with the nginx wiring below. + # All three blocks are gated on `cfg.auth.enable`. + + # Declare the hyperhive-operator group on the host so operators + # can `usermod -aG hyperhive-operator ` out-of-the-box. + # Only created when the default group name is in use; custom + # groups are assumed to be managed externally. + users.groups = lib.mkIf (cfg.auth.enable && cfg.auth.group == "hyperhive-operator") { + hyperhive-operator = { }; + }; + + # PAM service used by `hive-gateway-auth`. Only emits the generated + # `/etc/pam.d/hive-gateway` when the operator uses the default + # service name, to avoid clobbering a custom PAM config they may + # have defined elsewhere. + security.pam.services.hive-gateway = lib.mkIf (cfg.auth.enable && cfg.auth.pamService == "hive-gateway") { + text = '' + # hive-gateway: authenticate via host Unix passwords, then check + # group membership in ${cfg.auth.group}. + auth required pam_unix.so + auth required pam_succeed_if.so user ingroup ${cfg.auth.group} + account required pam_unix.so + ''; + }; + + # `hive-gateway-auth` systemd service. Runs as root so it can + # call pam_unix.so against /etc/shadow (root-only readable). + # Bound to 127.0.0.1 — the gateway container shares the host + # netns, so it's reachable from nginx without any port-forward. + systemd.services.hive-gateway-auth = lib.mkIf cfg.auth.enable { + description = "hive-gateway HTTP basic auth validator"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = '' + ${config.services.hyperhive.c0re.package}/bin/hive-gateway-auth \ + --listen 127.0.0.1:${toString cfg.auth.port} \ + --pam-service ${lib.escapeShellArg cfg.auth.pamService} \ + --group ${lib.escapeShellArg cfg.auth.group} + ''; + Restart = "on-failure"; + RestartSec = 2; + }; + }; + # Ensure bind-mount sources exist at host boot before the gateway # container's first start. nspawn would auto-create missing dirs # tmpfiles rules make the intent explicit @@ -511,12 +616,37 @@ in # headers stay set so SSE (`/dashboard/stream`, # `/events/stream`) + websocket (`/screen/ws`) # endpoints keep working transparently. + # When auth is enabled, `auth_request` sub-requests + # `/__hive_gateway_auth` before proxying. The 401 + # named-location handler (in vhost `extraConfig`) + # adds the `WWW-Authenticate` header so browsers + # show a login prompt. "/" = { proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; proxyWebsockets = true; extraConfig = '' proxy_buffering off; proxy_read_timeout 1d; + ${lib.optionalString cfg.auth.enable '' + auth_request /__hive_gateway_auth; + error_page 401 = @hive_auth_required; + ''} + ''; + }; + } + # Internal auth sub-request location. Forwards the + # `Authorization` header to `hive-gateway-auth` on + # the host loopback; body is stripped (auth is + # header-only). nginx reuses this location for every + # `auth_request /__hive_gateway_auth;` directive. + // lib.optionalAttrs cfg.auth.enable { + "= /__hive_gateway_auth" = { + extraConfig = '' + internal; + proxy_pass http://127.0.0.1:${toString cfg.auth.port}/; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; ''; }; }; @@ -531,6 +661,18 @@ in # the `/agent/` catch-all above. extraConfig = '' include /run/hive-state/agents.conf; + ${lib.optionalString cfg.auth.enable '' + # Named location for 401 responses from `auth_request`. + # nginx does not propagate upstream `WWW-Authenticate` + # headers automatically on auth failure, so we emit + # it here. `always` ensures the header is added even + # when nginx would otherwise suppress it on error + # responses. + location @hive_auth_required { + add_header WWW-Authenticate 'Basic realm="${cfg.auth.realm}"' always; + return 401; + } + ''} ''; }; } From 25d2951d1e03be0781787ded4876a2a97f0ec0eb Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 1 Jun 2026 22:50:04 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(gateway):=20htpasswd=20Basic=20auth=20?= =?UTF-8?q?=E2=80=94=20close=20#1010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the earlier PAM+binary approach with nginx's built-in `auth_basic` module. No new binary, no new systemd service, no PAM. New option `services.hyperhive.gateway.auth`: - `enable` — off by default - `htpasswdFile` — host path to an htpasswd file (required when enable) - `realm` — WWW-Authenticate realm string (default "hyperhive"); restricted to `strMatching "[^\"$]*"` to prevent nginx config injection When enabled: - the parent directory of `htpasswdFile` is bind-mounted read-only into the gateway container at `/run/gateway-auth/` - the `"/"` proxy location gets `auth_basic` + `auth_basic_user_file` Create credentials: `htpasswd -Bc /path/to/file alice` (BCrypt). See `docs/gateway.md` ("HTTP Basic auth") for the full setup guide. --- Cargo.lock | 219 ++++-------------------- Cargo.toml | 1 - docs/gateway.md | 41 +++++ flake.nix | 1 - hive-c0re/Cargo.toml | 2 - hive-c0re/src/bin/hive_gateway_auth.rs | 224 ------------------------- nix/modules/hive-gateway.nix | 172 ++++++------------- 7 files changed, 128 insertions(+), 532 deletions(-) delete mode 100644 hive-c0re/src/bin/hive_gateway_auth.rs diff --git a/Cargo.lock b/Cargo.lock index d7599556..73b73db2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,7 +120,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -199,7 +199,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -210,7 +210,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -303,26 +303,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex 1.3.0", - "syn 2.0.117", -] - [[package]] name = "bitflags" version = "2.11.1" @@ -404,16 +384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", - "shlex 2.0.1", -] - -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", + "shlex", ] [[package]] @@ -477,16 +448,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", -] - [[package]] name = "clap" version = "4.6.1" @@ -518,7 +479,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -655,7 +616,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -678,7 +639,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn", ] [[package]] @@ -689,7 +650,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -793,7 +754,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1024,7 +985,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1106,12 +1067,6 @@ dependencies = [ "wasip3", ] -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - [[package]] name = "gloo-timers" version = "0.3.0" @@ -1247,9 +1202,7 @@ dependencies = [ "base64", "clap", "hive-sh4re", - "libc", "listenfd", - "pam", "reqwest", "rusqlite", "serde", @@ -1686,15 +1639,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -1772,12 +1716,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -1879,7 +1817,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1917,7 +1855,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2098,12 +2036,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2131,16 +2063,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2213,40 +2135,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "pam" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ab553c52103edb295d8f7d6a3b593dc22a30b1fb99643c777a8f36915e285ba" -dependencies = [ - "libc", - "memchr", - "pam-macros", - "pam-sys", - "users", -] - -[[package]] -name = "pam-macros" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c94f3b9b97df3c6d4e51a14916639b24e02c7d15d1dba686ce9b1118277cb811" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "pam-sys" -version = "1.0.0-alpha5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce9484729b3e52c0bacdc5191cb6a6a5f31ef4c09c5e4ab1209d3340ad9e997b" -dependencies = [ - "bindgen", - "libc", -] - [[package]] name = "parking" version = "2.2.1" @@ -2406,7 +2294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn", ] [[package]] @@ -2468,7 +2356,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2500,7 +2388,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.2", + "rustc-hash", "rustls", "socket2", "thiserror 2.0.18", @@ -2520,7 +2408,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash 2.1.2", + "rustc-hash", "rustls", "rustls-pki-types", "slab", @@ -2674,7 +2562,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2795,7 +2683,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.117", + "syn", ] [[package]] @@ -2973,7 +2861,7 @@ dependencies = [ "quote", "ruma-identifiers-validation", "serde", - "syn 2.0.117", + "syn", "toml", ] @@ -3007,12 +2895,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - [[package]] name = "rustc-hash" version = "2.1.2" @@ -3111,7 +2993,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn", ] [[package]] @@ -3163,7 +3045,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3174,7 +3056,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3266,12 +3148,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "shlex" version = "2.0.1" @@ -3384,17 +3260,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.117" @@ -3423,7 +3288,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3476,7 +3341,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3487,7 +3352,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3567,7 +3432,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3757,7 +3622,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3904,16 +3769,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "users" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa4227e95324a443c9fcb06e03d4d85e91aabe9a5a02aa818688b6918b6af486" -dependencies = [ - "libc", - "log", -] - [[package]] name = "utf-8" version = "0.7.6" @@ -4066,7 +3921,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wasm-bindgen-shared", ] @@ -4204,7 +4059,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4215,7 +4070,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4452,7 +4307,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4468,7 +4323,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4553,7 +4408,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -4574,7 +4429,7 @@ checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4594,7 +4449,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -4615,7 +4470,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4648,7 +4503,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 127503cd..b8ac38ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ must_use_candidate = "allow" [workspace.dependencies] anyhow = "1" -pam = "0.8" axum = { version = "0.8", features = ["ws"] } base64 = "0.22" clap = { version = "4", features = ["derive"] } diff --git a/docs/gateway.md b/docs/gateway.md index d8336fce..5d8631d5 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -339,3 +339,44 @@ the nginx config get custom error pages. Other gateway routes (forge / matrix / fluffychat) get nginx defaults — extending the custom-error pattern there is a separate follow-up. +## HTTP Basic auth + +`services.hyperhive.gateway.auth.enable = true` gates every request to +the main vhost (`_`) behind HTTP Basic auth. nginx's built-in `auth_basic` +module validates credentials; no extra service or host-side daemon is +required. + +**Setup:** + +```nix +services.hyperhive.gateway.auth = { + enable = true; + htpasswdFile = "/etc/hyperhive/gateway.htpasswd"; + # realm = "hyperhive"; # optional, default shown +}; +``` + +Create the htpasswd file on the host: + +```sh +# Create new file with first user (BCrypt, recommended): +htpasswd -Bc /etc/hyperhive/gateway.htpasswd alice + +# Add subsequent users: +htpasswd -B /etc/hyperhive/gateway.htpasswd bob +``` + +The file must be readable by the `nginx` user inside the container +(`chmod 0644`). The module bind-mounts the file's parent directory +read-only into the container at `/run/gateway-auth/`; nginx reads +`/run/gateway-auth/`. + +**What is not gated:** per-agent UI routes emitted into `agents.conf` +(served under `/agent//`) inherit no auth from `/` — nginx +applies `auth_basic` per-location. Full per-agent coverage is a +follow-up. + +**Realm:** the `WWW-Authenticate: Basic realm="..."` string browsers +display in the credential dialog. Defaults to `"hyperhive"`. Must not +contain `"` or `$`. + diff --git a/flake.nix b/flake.nix index 728dda84..6d746ab6 100644 --- a/flake.nix +++ b/flake.nix @@ -101,7 +101,6 @@ pkgs.git pkgs.sqlite pkgs.pkg-config - pkgs.linux-pam # libpam.so.0 for hive-gateway-auth ]; } ); diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 3f96af5a..e0ff4406 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -12,8 +12,6 @@ base64.workspace = true reqwest.workspace = true clap.workspace = true hive-sh4re.workspace = true -libc = "0.2" -pam.workspace = true listenfd = "1" rusqlite.workspace = true serde.workspace = true diff --git a/hive-c0re/src/bin/hive_gateway_auth.rs b/hive-c0re/src/bin/hive_gateway_auth.rs deleted file mode 100644 index deb69322..00000000 --- a/hive-c0re/src/bin/hive_gateway_auth.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! `hive-gateway-auth` — host-side HTTP basic auth validator for the hive gateway. -//! -//! Runs as a systemd service on the host. The gateway container's nginx -//! calls this via `auth_request` for every incoming request; this binary -//! validates the `Authorization: Basic` credentials against the host's PAM -//! stack and checks that the authenticated user is a member of the -//! `hyperhive-operator` group (configurable via `--group`). -//! -//! Listens on `127.0.0.1:PORT` (default 7002, host loopback only — the -//! gateway container shares the host netns, so it can reach this directly). -//! -//! Response codes nginx cares about: -//! - `200 OK` — auth passed; nginx proxies the request upstream. -//! - `401 Unauthorized` — missing/invalid credentials; nginx returns 401 -//! with a `WWW-Authenticate: Basic realm="…"` header added by the nginx -//! config. The body from this service is discarded by nginx. -//! - `403 Forbidden` — valid credentials but not in the required group. - -use std::net::SocketAddr; -use std::str::FromStr as _; - -use anyhow::{Context as _, Result}; -use axum::Router; -use axum::extract::State; -use axum::http::{HeaderMap, StatusCode}; -use axum::routing::any; -use base64::Engine as _; -use clap::Parser; - -#[derive(Parser)] -#[command( - name = "hive-gateway-auth", - about = "Host-side HTTP basic-auth validator for hive-gateway" -)] -struct Cli { - /// Address to listen on. Bind to 127.0.0.1 (loopback-only) so - /// only the gateway container (shared host netns) can reach it. - #[arg(long, default_value = "127.0.0.1:7002")] - listen: String, - /// PAM service name. A matching `/etc/pam.d/` file must - /// exist on the host and include `pam_unix.so` for password auth. - #[arg(long, default_value = "hive-gateway")] - pam_service: String, - /// Host group that every authenticated user must belong to. - #[arg(long, default_value = "hyperhive-operator")] - group: String, -} - -#[derive(Clone)] -struct AppState { - pam_service: String, - required_group: String, -} - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "info".into()), - ) - .init(); - - let cli = Cli::parse(); - let addr = SocketAddr::from_str(&cli.listen) - .with_context(|| format!("invalid --listen address: {}", cli.listen))?; - let state = AppState { - pam_service: cli.pam_service, - required_group: cli.group, - }; - let app = Router::new() - .route("/{*path}", any(handle_auth)) - .route("/", any(handle_auth)) - .with_state(state); - - tracing::info!(addr = %addr, "hive-gateway-auth listening"); - let listener = tokio::net::TcpListener::bind(addr) - .await - .with_context(|| format!("bind {addr}"))?; - axum::serve(listener, app).await.context("serve")?; - Ok(()) -} - -/// Validate the `Authorization: Basic` header. Returns the decoded -/// `(username, password)` pair, or `None` when the header is absent, -/// malformed, or not Basic-scheme. -fn parse_basic_auth(headers: &HeaderMap) -> Option<(String, String)> { - let value = headers.get("authorization")?.to_str().ok()?; - let encoded = value.strip_prefix("Basic ")?; - let decoded = base64::engine::general_purpose::STANDARD - .decode(encoded) - .ok()?; - let s = String::from_utf8(decoded).ok()?; - let (user, pass) = s.split_once(':')?; - Some((user.to_owned(), pass.to_owned())) -} - -/// Check whether `username` is a member of `group` by reading the host's -/// group database. Uses `getgrouplist(3)` (via `nix::unistd`) if available, -/// or falls back to scanning `/etc/group` entries directly. -/// -/// Returns `true` when the user's primary GID matches OR when the user -/// appears in the supplementary member list of the target group. -fn user_in_group(username: &str, group_name: &str) -> bool { - use std::ffi::CString; - // SAFETY: all `libc` calls here follow the documented calling - // conventions for `getpwnam_r` / `getgrnam_r` / `getgrouplist`. - unsafe { - // Resolve the target group's GID. - let c_group = match CString::new(group_name) { - Ok(s) => s, - Err(_) => return false, - }; - let mut grp_storage = std::mem::zeroed::(); - let mut grp_ptr: *mut libc::group = std::ptr::null_mut(); - let mut buf = vec![0i8; 4096]; - let ret = libc::getgrnam_r( - c_group.as_ptr(), - &mut grp_storage, - buf.as_mut_ptr(), - buf.len(), - &mut grp_ptr, - ); - if ret != 0 || grp_ptr.is_null() { - return false; // group not found - } - let target_gid = (*grp_ptr).gr_gid; - - // Resolve the user's primary GID. - let c_user = match CString::new(username) { - Ok(s) => s, - Err(_) => return false, - }; - let mut pwd_storage = std::mem::zeroed::(); - let mut pwd_ptr: *mut libc::passwd = std::ptr::null_mut(); - let mut pwd_buf = vec![0i8; 4096]; - let ret = libc::getpwnam_r( - c_user.as_ptr(), - &mut pwd_storage, - pwd_buf.as_mut_ptr(), - pwd_buf.len(), - &mut pwd_ptr, - ); - if ret != 0 || pwd_ptr.is_null() { - return false; // user not found - } - - // Primary group match. - if (*pwd_ptr).pw_gid == target_gid { - return true; - } - - // Scan gr_mem list for the username. - let mut mem = (*grp_ptr).gr_mem; - if mem.is_null() { - return false; - } - while !(*mem).is_null() { - let member = std::ffi::CStr::from_ptr(*mem); - if member.to_bytes() == username.as_bytes() { - return true; - } - mem = mem.add(1); - } - false - } -} - -/// Authenticate `username` / `password` against the host PAM stack using -/// the configured service. Returns `true` on success. The PAM call is -/// synchronous and blocking — runs on the tokio thread pool via -/// `spawn_blocking`. -fn pam_authenticate_sync(service: &str, username: &str, password: &str) -> bool { - let mut client = match pam::Client::with_password(service) { - Ok(c) => c, - Err(e) => { - tracing::warn!(service, error = %e, "pam: client init failed"); - return false; - } - }; - client - .conversation_mut() - .set_credentials(username, password); - if let Err(e) = client.authenticate() { - tracing::debug!(service, username, error = %e, "pam: authenticate failed"); - return false; - } - if let Err(e) = client.account_mgmt() { - tracing::debug!(service, username, error = %e, "pam: account_mgmt failed"); - return false; - } - true -} - -async fn handle_auth( - State(state): State, - headers: HeaderMap, -) -> StatusCode { - let Some((username, password)) = parse_basic_auth(&headers) else { - return StatusCode::UNAUTHORIZED; - }; - - // PAM blocks — run off the async executor. - let service = state.pam_service.clone(); - let user_clone = username.clone(); - let pass_clone = password.clone(); - let authed = - tokio::task::spawn_blocking(move || pam_authenticate_sync(&service, &user_clone, &pass_clone)) - .await - .unwrap_or(false); - - if !authed { - tracing::info!(username, "auth: bad credentials"); - return StatusCode::UNAUTHORIZED; - } - - if !user_in_group(&username, &state.required_group) { - tracing::info!(username, group = %state.required_group, "auth: user not in required group"); - return StatusCode::FORBIDDEN; - } - - tracing::debug!(username, "auth: ok"); - StatusCode::OK -} diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index dafc7ec0..995edf49 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -200,59 +200,43 @@ in auth = { enable = lib.mkEnableOption '' - HTTP basic auth on the gateway using host PAM. When enabled, every - request to the gateway's main vhost requires a valid username and - password from the host's user database. The user must also be a - member of the `services.hyperhive.gateway.auth.group` host group - (default: `hyperhive-operator`). A small `hive-gateway-auth` - systemd service runs on the host, listens on loopback at - `services.hyperhive.gateway.auth.port`, and performs the PAM - authentication. nginx inside the gateway container calls it via - `auth_request` (the container shares the host netns, so loopback - is reachable directly). Off by default — local / single-operator - setups may not need authentication. + HTTP basic auth on the gateway using an htpasswd file. When + enabled, every request to the gateway's main vhost requires a + valid username and password from the htpasswd file at + `services.hyperhive.gateway.auth.htpasswdFile`. nginx's built-in + `auth_basic` module handles credential validation — no extra + service or host-side daemon required. Off by default. + + Create the file with: `htpasswd -Bc /path/to/gateway.htpasswd ` + (BCrypt recommended; `-c` creates a new file). Subsequent users: + `htpasswd -B /path/to/gateway.htpasswd `. ''; - port = lib.mkOption { - type = lib.types.port; - default = 7002; + htpasswdFile = lib.mkOption { + type = lib.types.path; + example = "/etc/hyperhive/gateway.htpasswd"; description = '' - TCP port for the `hive-gateway-auth` service on the host's - loopback interface. nginx's `auth_request` sub-request is - sent here. Change when 7002 is already in use. + Path on the **host** to an htpasswd-format file whose + `username:hashed-password` entries nginx uses for Basic auth. + The parent directory is bind-mounted read-only into the gateway + container at `/run/gateway-auth/`. The file must be readable by + the `nginx` user inside the container (mode 0644 recommended). + + Create with: `htpasswd -Bc `. BCrypt (`-B`) is + strongly preferred over the legacy MD5/SHA1 algorithms. + + Required when `enable = true`. ''; }; realm = lib.mkOption { - type = lib.types.str; + type = lib.types.strMatching "[^\"$]*"; default = "hyperhive"; example = "my-hive"; description = '' HTTP Basic auth `realm` value sent in the `WWW-Authenticate` - header when credentials are absent or rejected. - ''; - }; - - group = lib.mkOption { - type = lib.types.str; - default = "hyperhive-operator"; - example = "admins"; - description = '' - Host Unix group every authenticated user must belong to. - Create the group and add operator accounts before enabling - auth. When using the default value, the group is - automatically defined on the host by this module. - ''; - }; - - pamService = lib.mkOption { - type = lib.types.str; - default = "hive-gateway"; - description = '' - PAM service name. A matching `/etc/pam.d/hive-gateway` file - is defined by this module when using the default value. Set to - an existing service (e.g. `"login"`) to reuse a custom PAM - stack instead of the generated one. + header when credentials are absent or rejected. Must not + contain `"` or `$` (nginx string metacharacters). ''; }; }; @@ -269,53 +253,16 @@ in or leave `localHostsEntry` at its default of false. ''; } + { + assertion = !cfg.auth.enable || cfg.auth ? htpasswdFile; + message = '' + services.hyperhive.gateway.auth.enable = true requires + services.hyperhive.gateway.auth.htpasswdFile to be set. + Create an htpasswd file with: htpasswd -Bc /path/to/file + ''; + } ]; - # HOST-SIDE: PAM auth service for the gateway. - # Defined here so it co-locates with the nginx wiring below. - # All three blocks are gated on `cfg.auth.enable`. - - # Declare the hyperhive-operator group on the host so operators - # can `usermod -aG hyperhive-operator ` out-of-the-box. - # Only created when the default group name is in use; custom - # groups are assumed to be managed externally. - users.groups = lib.mkIf (cfg.auth.enable && cfg.auth.group == "hyperhive-operator") { - hyperhive-operator = { }; - }; - - # PAM service used by `hive-gateway-auth`. Only emits the generated - # `/etc/pam.d/hive-gateway` when the operator uses the default - # service name, to avoid clobbering a custom PAM config they may - # have defined elsewhere. - security.pam.services.hive-gateway = lib.mkIf (cfg.auth.enable && cfg.auth.pamService == "hive-gateway") { - text = '' - # hive-gateway: authenticate via host Unix passwords, then check - # group membership in ${cfg.auth.group}. - auth required pam_unix.so - auth required pam_succeed_if.so user ingroup ${cfg.auth.group} - account required pam_unix.so - ''; - }; - - # `hive-gateway-auth` systemd service. Runs as root so it can - # call pam_unix.so against /etc/shadow (root-only readable). - # Bound to 127.0.0.1 — the gateway container shares the host - # netns, so it's reachable from nginx without any port-forward. - systemd.services.hive-gateway-auth = lib.mkIf cfg.auth.enable { - description = "hive-gateway HTTP basic auth validator"; - after = [ "network.target" ]; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - ExecStart = '' - ${config.services.hyperhive.c0re.package}/bin/hive-gateway-auth \ - --listen 127.0.0.1:${toString cfg.auth.port} \ - --pam-service ${lib.escapeShellArg cfg.auth.pamService} \ - --group ${lib.escapeShellArg cfg.auth.group} - ''; - Restart = "on-failure"; - RestartSec = 2; - }; - }; # Ensure bind-mount sources exist at host boot before the gateway # container's first start. nspawn would auto-create missing dirs @@ -363,6 +310,16 @@ in hostPath = "/var/lib/hyperhive/gateway"; isReadOnly = true; }; + # When auth is enabled, bind-mount the parent directory of the + # htpasswd file read-only into the container at /run/gateway-auth/. + # nginx's `auth_basic_user_file` points at the file inside that dir. + # Using the parent directory (not the file itself) because nspawn + # bind-mounts need a pre-existing destination — binding a directory + # is always safe; nginx picks the file up by name inside. + bindMounts."/run/gateway-auth" = lib.mkIf cfg.auth.enable { + hostPath = builtins.dirOf cfg.auth.htpasswdFile; + isReadOnly = true; + }; config = { pkgs, ... }: let @@ -616,11 +573,10 @@ in # headers stay set so SSE (`/dashboard/stream`, # `/events/stream`) + websocket (`/screen/ws`) # endpoints keep working transparently. - # When auth is enabled, `auth_request` sub-requests - # `/__hive_gateway_auth` before proxying. The 401 - # named-location handler (in vhost `extraConfig`) - # adds the `WWW-Authenticate` header so browsers - # show a login prompt. + # When auth is enabled, nginx's built-in `auth_basic` + # validates credentials against the htpasswd file + # bind-mounted at `/run/gateway-auth/`. No extra + # service or host-side daemon required. "/" = { proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; proxyWebsockets = true; @@ -628,27 +584,11 @@ in proxy_buffering off; proxy_read_timeout 1d; ${lib.optionalString cfg.auth.enable '' - auth_request /__hive_gateway_auth; - error_page 401 = @hive_auth_required; + auth_basic "${cfg.auth.realm}"; + auth_basic_user_file /run/gateway-auth/${builtins.baseNameOf cfg.auth.htpasswdFile}; ''} ''; }; - } - # Internal auth sub-request location. Forwards the - # `Authorization` header to `hive-gateway-auth` on - # the host loopback; body is stripped (auth is - # header-only). nginx reuses this location for every - # `auth_request /__hive_gateway_auth;` directive. - // lib.optionalAttrs cfg.auth.enable { - "= /__hive_gateway_auth" = { - extraConfig = '' - internal; - proxy_pass http://127.0.0.1:${toString cfg.auth.port}/; - proxy_pass_request_body off; - proxy_set_header Content-Length ""; - proxy_set_header X-Original-URI $request_uri; - ''; - }; }; # Per-agent location blocks, generated at runtime by # hive-c0re and written to /var/lib/hyperhive/gateway/agents.conf @@ -661,18 +601,6 @@ in # the `/agent/` catch-all above. extraConfig = '' include /run/hive-state/agents.conf; - ${lib.optionalString cfg.auth.enable '' - # Named location for 401 responses from `auth_request`. - # nginx does not propagate upstream `WWW-Authenticate` - # headers automatically on auth failure, so we emit - # it here. `always` ensures the header is added even - # when nginx would otherwise suppress it on error - # responses. - location @hive_auth_required { - add_header WWW-Authenticate 'Basic realm="${cfg.auth.realm}"' always; - return 401; - } - ''} ''; }; } From 4bff45034312ed275c697f4102342ff49601ddcf Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 1 Jun 2026 23:00:38 +0200 Subject: [PATCH 3/4] feat(gateway): hivectl gateway user management + fix htpasswdFile assertion Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt (cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required. Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always true in the module system (declared options always exist as keys); switch to `nullOr path; default = null` + `!= null` check so the assertion actually fires with a useful error when enable=true but no file is set. Guard bind-mount and nginx config against null to prevent eval errors. Update docs/gateway.md to show hivectl commands instead of raw htpasswd. --- Cargo.lock | 85 +++++++++- Cargo.toml | 21 ++- docs/gateway.md | 21 ++- hive-ag3nt/src/bash_runner.rs | 58 ++++--- hive-ag3nt/src/bin/hive.rs | 62 ++++--- hive-ag3nt/src/events.rs | 18 +- hive-ag3nt/src/forge_notify.rs | 10 +- hive-ag3nt/src/identity.rs | 4 +- hive-ag3nt/src/mcp.rs | 74 ++++---- hive-ag3nt/src/stats.rs | 25 ++- hive-ag3nt/src/web_ui.rs | 16 +- hive-c0re/Cargo.toml | 1 + hive-c0re/src/actions.rs | 3 +- hive-c0re/src/agent_ports.rs | 14 +- hive-c0re/src/agent_server.rs | 58 ++++--- hive-c0re/src/agent_sockets.rs | 35 ++-- hive-c0re/src/auto_update.rs | 10 +- hive-c0re/src/bash_tasks_vacuum.rs | 19 ++- hive-c0re/src/bin/hivectl.rs | 197 ++++++++++++++++++++-- hive-c0re/src/broker.rs | 35 ++-- hive-c0re/src/build_logs.rs | 28 ++- hive-c0re/src/container_view.rs | 128 +++++++++----- hive-c0re/src/coordinator.rs | 9 +- hive-c0re/src/dashboard.rs | 30 ++-- hive-c0re/src/flake_check.rs | 16 +- hive-c0re/src/forge.rs | 14 +- hive-c0re/src/lib.rs | 4 +- hive-c0re/src/lifecycle.rs | 21 +-- hive-c0re/src/loose_ends.rs | 2 +- hive-c0re/src/main.rs | 50 ++++-- hive-c0re/src/meta.rs | 5 +- hive-c0re/src/migrate.rs | 22 ++- hive-c0re/src/operator_questions.rs | 11 +- hive-c0re/src/priv_client.rs | 56 ++++-- hive-c0re/src/questions.rs | 3 +- hive-c0re/src/rebuild_queue.rs | 1 - hive-c0re/src/reminder_scheduler.rs | 3 +- hive-c0re/src/scheduled_prompts_worker.rs | 6 +- hive-c0re/src/stats_vacuum.rs | 9 +- hive-c0re/src/topology.rs | 49 ++++-- hive-forge/Cargo.toml | 7 +- hive-forge/src/body.rs | 4 +- hive-forge/src/client.rs | 7 +- hive-forge/src/main.rs | 3 +- hive-forge/src/verbs/comments.rs | 5 +- hive-forge/src/verbs/diff.rs | 14 +- hive-forge/src/verbs/labels.rs | 6 +- hive-forge/src/verbs/list.rs | 4 +- hive-forge/src/verbs/milestone.rs | 3 +- hive-forge/src/verbs/pr_create.rs | 4 +- hive-forge/src/verbs/subscription.rs | 6 +- hive-forge/src/verbs/timeline.rs | 10 +- hive-matrix-mcp/src/bin/mcp.rs | 110 ++++++------ hive-matrix-mcp/src/client.rs | 5 +- hive-matrix-mcp/src/handlers.rs | 58 ++++--- hive-matrix-mcp/src/paths.rs | 6 +- hive-matrix-mcp/src/wake.rs | 5 +- hive-priv/src/main.rs | 111 +++++++----- hive-sh4re/src/lib.rs | 3 - hive-sh4re/src/priv_proto.rs | 15 +- nix/modules/hive-gateway.nix | 12 +- 61 files changed, 1084 insertions(+), 547 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73b73db2..42d70f8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -25,7 +25,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -303,6 +303,19 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcrypt" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.4.2", + "subtle", + "zeroize", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -350,12 +363,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -374,7 +403,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -406,7 +435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -418,7 +447,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20", - "cipher", + "cipher 0.4.4", "poly1305", "zeroize", ] @@ -443,11 +472,21 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clap" version = "4.6.1" @@ -591,6 +630,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -742,7 +790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -1200,6 +1248,7 @@ dependencies = [ "anyhow", "axum", "base64", + "bcrypt", "clap", "hive-sh4re", "listenfd", @@ -1353,6 +1402,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.10.1" @@ -1618,6 +1676,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3740,7 +3807,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] diff --git a/Cargo.toml b/Cargo.toml index b8ac38ca..cfcfd132 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,13 @@ [workspace] resolver = "3" -members = ["hive-ag3nt", "hive-c0re", "hive-forge", "hive-matrix-mcp", "hive-priv", "hive-sh4re"] +members = [ + "hive-ag3nt", + "hive-c0re", + "hive-forge", + "hive-matrix-mcp", + "hive-priv", + "hive-sh4re", +] [workspace.package] edition = "2024" @@ -18,6 +25,7 @@ must_use_candidate = "allow" anyhow = "1" axum = { version = "0.8", features = ["ws"] } base64 = "0.22" +bcrypt = "0.19" clap = { version = "4", features = ["derive"] } hive-sh4re = { path = "hive-sh4re" } tower-http = { version = "0.6", features = ["fs"] } @@ -45,6 +53,13 @@ tokio = { version = "1", features = [ tokio-stream = { version = "0.1", features = ["sync"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -matrix-sdk = { version = "0.14", default-features = false, features = ["rustls-tls", "sqlite", "markdown"] } +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", +] } +matrix-sdk = { version = "0.14", default-features = false, features = [ + "rustls-tls", + "sqlite", + "markdown", +] } futures-util = "0.3" diff --git a/docs/gateway.md b/docs/gateway.md index 5d8631d5..ebb0cf2f 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -356,16 +356,27 @@ services.hyperhive.gateway.auth = { }; ``` -Create the htpasswd file on the host: +Manage users with `hivectl gateway`: ```sh -# Create new file with first user (BCrypt, recommended): -htpasswd -Bc /etc/hyperhive/gateway.htpasswd alice +# Add or update a user (prompted for password): +hivectl gateway create-user --file /etc/hyperhive/gateway.htpasswd alice --password-stdin -# Add subsequent users: -htpasswd -B /etc/hyperhive/gateway.htpasswd bob +# Add with inline password (visible in shell history — avoid for sensitive creds): +hivectl gateway create-user --file /etc/hyperhive/gateway.htpasswd bob --password hunter2 + +# Remove a user: +hivectl gateway delete-user --file /etc/hyperhive/gateway.htpasswd bob + +# List current usernames: +hivectl gateway list-users --file /etc/hyperhive/gateway.htpasswd ``` +`hivectl gateway create-user` hashes passwords with BCrypt (cost 12) and +writes `$2y$`-prefixed hashes that nginx accepts natively. No external +`htpasswd` binary is required. The file is created on first add if absent; +its parent directory must already exist. + The file must be readable by the `nginx` user inside the container (`chmod 0644`). The module bind-mounts the file's parent directory read-only into the container at `/run/gateway-auth/`; nginx reads diff --git a/hive-ag3nt/src/bash_runner.rs b/hive-ag3nt/src/bash_runner.rs index 60fd0179..b048a705 100644 --- a/hive-ag3nt/src/bash_runner.rs +++ b/hive-ag3nt/src/bash_runner.rs @@ -60,10 +60,7 @@ fn tasks_dir() -> PathBuf { } else { // Pre-split fallback: derive harness/ as a sibling of state/. let state = crate::paths::state_dir(); - state - .parent() - .map(|p| p.join("harness")) - .unwrap_or(state) + state.parent().map(|p| p.join("harness")).unwrap_or(state) }; base.join("bash-tasks") } @@ -136,9 +133,8 @@ impl TaskFile { /// Write a task file atomically (tmp + rename). fn write_task(task: &TaskFile) -> std::io::Result<()> { - let json = serde_json::to_string_pretty(task).map_err(|e| { - std::io::Error::new(std::io::ErrorKind::InvalidData, e) - })?; + let json = serde_json::to_string_pretty(task) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; let dest = task_json(&task.id); let tmp = dest.with_extension("json.tmp"); std::fs::write(&tmp, json)?; @@ -229,7 +225,7 @@ async fn run_loop(socket: PathBuf) { let claimed: Arc>> = Arc::new(Mutex::new(HashSet::new())); loop { - poll_once(&socket, &claimed); + poll_once(&socket, &claimed).await; tokio::time::sleep(POLL_INTERVAL).await; } } @@ -237,14 +233,20 @@ async fn run_loop(socket: PathBuf) { /// On boot, find any task files in `running` state and flip them to /// `interrupted`, then fire a wake so the agent unblocks. async fn mark_interrupted(socket: &Path) { - let Ok(rd) = std::fs::read_dir(tasks_dir()) else { return }; + let Ok(rd) = std::fs::read_dir(tasks_dir()) else { + return; + }; for entry in rd.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } - let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue }; - let Some(mut task) = read_task(&id) else { continue }; + let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { + continue; + }; + let Some(mut task) = read_task(&id) else { + continue; + }; if task.status != TaskStatus::Running { continue; } @@ -258,14 +260,18 @@ async fn mark_interrupted(socket: &Path) { } } -fn poll_once(socket: &Path, claimed: &Arc>>) { - let Ok(rd) = std::fs::read_dir(tasks_dir()) else { return }; +async fn poll_once(socket: &Path, claimed: &Arc>>) { + let Ok(rd) = std::fs::read_dir(tasks_dir()) else { + return; + }; for entry in rd.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } - let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue }; + let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { + continue; + }; { let guard = claimed.lock().unwrap(); if guard.contains(&id) { @@ -322,7 +328,11 @@ async fn run_task(mut task: TaskFile, socket: &Path) { let stdout_tail = tail_file(&out_path, SUMMARY_BYTES); let stderr_tail = tail_file(&err_path, SUMMARY_BYTES); - task.status = if timed_out { TaskStatus::TimedOut } else { TaskStatus::Done }; + task.status = if timed_out { + TaskStatus::TimedOut + } else { + TaskStatus::Done + }; task.completed_at = Some(crate::serve_common::now_unix()); task.exit_code = exit_code; task.stdout_tail = stdout_tail.clone().filter(|s| !s.is_empty()); @@ -344,7 +354,12 @@ async fn run_task(mut task: TaskFile, socket: &Path) { /// Run `sh -c cmd`, streaming output to files. Returns `(exit_code, timed_out)`. /// On timeout the child process is explicitly killed before returning. -async fn exec_cmd(cmd: &str, out_path: &Path, err_path: &Path, timeout: Duration) -> Result<(i32, bool)> { +async fn exec_cmd( + cmd: &str, + out_path: &Path, + err_path: &Path, + timeout: Duration, +) -> Result<(i32, bool)> { use tokio::process::Command; let mut child = Command::new("sh") .arg("-c") @@ -396,7 +411,9 @@ where let _ = tokio::io::copy(&mut reader, &mut f).await; let _ = f.flush().await; } - Err(e) => tracing::warn!(path = %path.display(), error = ?e, "bash_runner: open output file failed"), + Err(e) => { + tracing::warn!(path = %path.display(), error = ?e, "bash_runner: open output file failed") + } } } @@ -415,12 +432,7 @@ fn tail_file(path: &Path, max_bytes: usize) -> Option { // Wake delivery // --------------------------------------------------------------------------- -async fn send_wake( - socket: &Path, - id: &str, - summary: &str, - output: Option<(&str, &str)>, -) { +async fn send_wake(socket: &Path, id: &str, summary: &str, output: Option<(&str, &str)>) { let mut body = format!("bash task `{id}` finished: {summary}"); if let Some((stdout, stderr)) = output { if !stdout.is_empty() { diff --git a/hive-ag3nt/src/bin/hive.rs b/hive-ag3nt/src/bin/hive.rs index d2ad6ce1..cf2d3c50 100644 --- a/hive-ag3nt/src/bin/hive.rs +++ b/hive-ag3nt/src/bin/hive.rs @@ -18,7 +18,9 @@ use clap::{Parser, Subcommand}; use hive_ag3nt::events::{Bus, LiveEvent, TurnState}; use hive_ag3nt::login::{self, LoginState}; use hive_ag3nt::turn_stats::TurnStats; -use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui}; +use hive_ag3nt::{ + DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui, +}; use hive_sh4re::{ AgentRequest, AgentResponse, HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER, }; @@ -129,7 +131,9 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) { } else { tracing::info!(%from, %body, "system message"); } - bus.emit(LiveEvent::Note { text: format!("[system] {body}") }); + bus.emit(LiveEvent::Note { + text: format!("[system] {body}"), + }); } /// Body string for the turn-failure notification we route to @@ -140,7 +144,11 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) { /// misconfigured harness still produces a parseable line. fn format_turn_failure(err: &anyhow::Error) -> String { let who = hive_ag3nt::identity::qualified_label(); - let who = if who.is_empty() { "".to_owned() } else { who }; + let who = if who.is_empty() { + "".to_owned() + } else { + who + }; format!("[system] `{who}` claude turn failed:\n{err:#}") } @@ -202,9 +210,7 @@ trait Surface { /// `(open_threads, open_reminders)` for the post-turn stats row. /// Either field is `None` when the underlying request errors. - fn post_turn_counts( - socket: &Path, - ) -> impl Future, Option)>; + fn post_turn_counts(socket: &Path) -> impl Future, Option)>; /// Send a message addressed to `` (broker resolves the /// sentinel via `topology::parent_of` at delivery time; root @@ -225,11 +231,8 @@ trait Surface { /// by co-process daemons like matrix to push events into the /// harness inbox). Errors out via `anyhow::bail!` so the calling /// binary surfaces them on stderr. - fn wake_external( - socket: &Path, - from: String, - body: String, - ) -> impl Future>; + fn wake_external(socket: &Path, from: String, body: String) + -> impl Future>; } // ---------- AgentSurface ---------- @@ -271,13 +274,15 @@ impl Surface for AgentSurface { } async fn post_turn_counts(socket: &Path) -> (Option, Option) { - let threads = - match client::request::<_, AgentResponse>(socket, &AgentRequest::GetLooseEnds { agent: None }).await { - Ok(AgentResponse::LooseEnds { loose_ends }) => { - u64::try_from(loose_ends.len()).ok() - } - _ => None, - }; + let threads = match client::request::<_, AgentResponse>( + socket, + &AgentRequest::GetLooseEnds { agent: None }, + ) + .await + { + Ok(AgentResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(), + _ => None, + }; let reminders = match client::request::<_, AgentResponse>( socket, &AgentRequest::CountPendingReminders { agent: None }, @@ -415,9 +420,7 @@ impl Surface for ManagerSurface { ) .await { - Ok(ManagerResponse::LooseEnds { loose_ends }) => { - u64::try_from(loose_ends.len()).ok() - } + Ok(ManagerResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(), _ => None, }; let reminders = match client::request::<_, ManagerResponse>( @@ -569,8 +572,7 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { ); tokio::spawn(async move { let (label, port, login_state, bus, socket, files, turn_lock) = web_ui_args; - if let Err(e) = - web_ui::serve(label, port, login_state, bus, socket, files, turn_lock).await + if let Err(e) = web_ui::serve(label, port, login_state, bus, socket, files, turn_lock).await { tracing::error!(error = %e, "web_ui::serve exited with error"); } @@ -658,7 +660,11 @@ async fn handle_turn( log_system_event(bus, &from, &body); tracing::info!(%from, %body, %redelivered, "inbox"); let unread = S::inbox_unread(socket).await; - bus.emit(LiveEvent::TurnStart { from: from.clone(), body: body.clone(), unread }); + bus.emit(LiveEvent::TurnStart { + from: from.clone(), + body: body.clone(), + unread, + }); bus.set_state(TurnState::Thinking); let started_at = serve_common::now_unix(); let started_instant = std::time::Instant::now(); @@ -670,7 +676,10 @@ async fn handle_turn( }; turn::emit_turn_end(bus, &outcome); bus.set_state(TurnState::Idle); - if matches!(outcome, turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted) { + if matches!( + outcome, + turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted + ) { S::ack_turn(socket).await; } if matches!(outcome, turn::TurnOutcome::RateLimited) { @@ -697,8 +706,7 @@ async fn handle_turn( } if let Some(stats) = stats { let ended_at = serve_common::now_unix(); - let duration_ms = - i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX); + let duration_ms = i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX); let (open_threads, open_reminders) = S::post_turn_counts(socket).await; let row = serve_common::build_row( started_at, diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index d511ab28..700c7fcc 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -80,12 +80,18 @@ fn harness_json_path() -> PathBuf { fn read_harness_state() -> (bool, bool) { // Try the new consolidated file first. - if let Ok(raw) = std::fs::read_to_string(harness_json_path()) - && let Ok(v) = serde_json::from_str::(&raw) - { - let rate_limited = v.get("rate_limited").and_then(serde_json::Value::as_bool).unwrap_or(false); - let needs_login = v.get("needs_login").and_then(serde_json::Value::as_bool).unwrap_or(false); - return (rate_limited, needs_login); + if let Ok(raw) = std::fs::read_to_string(harness_json_path()) { + if let Ok(v) = serde_json::from_str::(&raw) { + let rate_limited = v + .get("rate_limited") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + let needs_login = v + .get("needs_login") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + return (rate_limited, needs_login); + } } // Fall back to legacy sentinel files written by older harness builds. let state_dir = crate::paths::state_dir(); diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index 0784aaea..829e3584 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -244,10 +244,7 @@ fn is_username_byte(b: u8) -> bool { /// window so addressed agents never silently miss a mention on a long /// body. See `docs/forge.md::Body excerpt + truncation + heading /// escape` for the truncate-before-escape ordering rule. -fn extract_truncated_mention_lines<'a>( - full_body: &'a str, - included_excerpt: &str, -) -> Vec<&'a str> { +fn extract_truncated_mention_lines<'a>(full_body: &'a str, included_excerpt: &str) -> Vec<&'a str> { full_body .lines() .filter(|line| { @@ -914,7 +911,10 @@ mod tests { let full = "# @argus check this\nmore body\n"; let raw_excerpt = full; // fits entirely let lines = extract_truncated_mention_lines(full, raw_excerpt); - assert!(lines.is_empty(), "heading+mention inside window must not be re-surfaced, got {lines:?}"); + assert!( + lines.is_empty(), + "heading+mention inside window must not be re-surfaced, got {lines:?}" + ); } #[test] diff --git a/hive-ag3nt/src/identity.rs b/hive-ag3nt/src/identity.rs index 14acb7f2..56759aee 100644 --- a/hive-ag3nt/src/identity.rs +++ b/hive-ag3nt/src/identity.rs @@ -122,7 +122,9 @@ mod tests { swarm_name: Option<&str>, f: F, ) { - let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let prev_label = env::var("HIVE_LABEL").ok(); let prev_domain = env::var("HYPERHIVE_HIVE_DOMAIN").ok(); let prev_hive_name = env::var("HYPERHIVE_HIVE_NAME").ok(); diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index f9b26d58..4d79930a 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -437,31 +437,30 @@ fn format_bash_status(id: &str) -> String { let Some(task) = crate::bash_runner::read_task(id) else { return format!("bash_status: unknown task id `{id}`"); }; - let mut out = format!( - "task `{id}`: status={status:?}", - status = task.status - ); + let mut out = format!("task `{id}`: status={status:?}", status = task.status); if let Some(code) = task.exit_code { let _ = write!(out, ", exit={code}"); } - if let Some(t) = task.started_at && task.completed_at.is_none() { + if let Some(t) = task.started_at + && task.completed_at.is_none() + { let age = crate::serve_common::now_unix() - t; let _ = write!(out, ", running for {age}s"); } - if let Some(t) = task.completed_at - && let Some(s) = task.started_at - { - let _ = write!(out, ", took {}s", t - s); + if let Some(t) = task.completed_at { + if let Some(s) = task.started_at { + let _ = write!(out, ", took {}s", t - s); + } } - if let Some(ref stdout) = task.stdout_tail - && !stdout.trim().is_empty() - { - let _ = write!(out, "\n\nstdout:\n```\n{}\n```", stdout.trim()); + if let Some(ref stdout) = task.stdout_tail { + if !stdout.trim().is_empty() { + let _ = write!(out, "\n\nstdout:\n```\n{}\n```", stdout.trim()); + } } - if let Some(ref stderr) = task.stderr_tail - && !stderr.trim().is_empty() - { - let _ = write!(out, "\n\nstderr:\n```\n{}\n```", stderr.trim()); + if let Some(ref stderr) = task.stderr_tail { + if !stderr.trim().is_empty() { + let _ = write!(out, "\n\nstderr:\n```\n{}\n```", stderr.trim()); + } } out } @@ -668,7 +667,9 @@ impl AgentServer { )] async fn get_loose_ends(&self, Parameters(args): Parameters) -> String { run_tool_envelope("get_loose_ends", String::new(), async move { - let (resp, retries) = self.dispatch(hive_sh4re::AgentRequest::GetLooseEnds { agent: args.agent }).await; + let (resp, retries) = self + .dispatch(hive_sh4re::AgentRequest::GetLooseEnds { agent: args.agent }) + .await; let mut out = annotate_retries(format_loose_ends(resp), retries); // Append any local bash tasks still in pending/running state so // the agent sees all outstanding work in one call. @@ -678,8 +679,11 @@ impl AgentServer { let _ = write!(out, "\n\n{} active bash task(s):", active.len()); for task in &active { let age = crate::serve_common::now_unix() - task.created_at; - let _ = write!(out, "\n- `{}` status={:?}, cmd: `{}`, age {}s", - task.id, task.status, task.cmd, age); + let _ = write!( + out, + "\n- `{}` status={:?}, cmd: `{}`, age {}s", + task.id, task.status, task.cmd, age + ); } } out @@ -830,9 +834,11 @@ impl AgentServer { )] async fn bash_status(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); - run_tool_envelope("bash_status", log, async move { - format_bash_status(&args.id) - }) + run_tool_envelope( + "bash_status", + log, + async move { format_bash_status(&args.id) }, + ) .await } @@ -875,10 +881,7 @@ impl AgentServer { `since`: show entries on or newer than this (e.g. `-1h`, `2024-01-01 12:00:00`). \ `until`: show entries on or older than this." )] - async fn get_host_journal( - &self, - Parameters(args): Parameters, - ) -> String { + async fn get_host_journal(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("get_host_journal", log, async move { let (resp, retries) = self @@ -1913,14 +1916,14 @@ pub enum Flavor { } /// Env var written by the meta renderer with a comma-separated list of -/// `hive_sh4re::ToolGroup` `snake_case` names (e.g. `"messaging,inbox,meta"`). +/// `hive_sh4re::ToolGroup` snake_case names (e.g. `"messaging,inbox,meta"`). /// When present, the harness expands the groups into per-tool allow entries /// instead of using the hardcoded flavor default. See `docs/conventions.md::Tool groups`. const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS"; /// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the /// operator grants capabilities to this agent. Comma-separated -/// `hive_sh4re::Capability` `snake_case` names. Absent = no extra capabilities. +/// `hive_sh4re::Capability` snake_case names. Absent = no extra capabilities. const CAPABILITIES_ENV: &str = "HIVE_CAPABILITIES"; /// Returns the MCP tool names (without `mcp__hyperhive__` prefix) that are @@ -1976,12 +1979,13 @@ fn effective_tool_groups(flavor: Flavor) -> Vec { for token in raw.split(',') { let t = token.trim().to_ascii_lowercase(); // Parse via serde_json (the canonical deserialization path). - if let Ok(g) = serde_json::from_value::( - serde_json::Value::String(t.clone()), - ) { - groups.push(g); - } else { - tracing::warn!(token = %t, "{TOOL_GROUPS_ENV}: unknown tool group, skipping"); + match serde_json::from_value::(serde_json::Value::String(t.clone())) + { + Ok(g) => groups.push(g), + Err(_) => tracing::warn!( + token = %t, + "{TOOL_GROUPS_ENV}: unknown tool group, skipping" + ), } } if groups.is_empty() { diff --git a/hive-ag3nt/src/stats.rs b/hive-ag3nt/src/stats.rs index ae98ca7e..301d1c50 100644 --- a/hive-ag3nt/src/stats.rs +++ b/hive-ag3nt/src/stats.rs @@ -391,7 +391,11 @@ fn summarize_durations(all: &mut [i64]) -> DurationSummary { } } -#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)] +#[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss +)] fn percentile(sorted: &[i64], pct: u8) -> f64 { if sorted.is_empty() { return 0.0; @@ -471,7 +475,15 @@ mod tests { (started_at, ended_at, duration_ms, model, wake_from, last_input_tokens, tool_call_breakdown_json, result_kind) VALUES (?1, ?2, ?3, ?4, ?5, 1000, ?6, ?7)", - params![started, started + dur / 1000, dur, model, wake, tools_json, result], + params![ + started, + started + dur / 1000, + dur, + model, + wake, + tools_json, + result + ], ) .unwrap(); } @@ -485,7 +497,14 @@ mod tests { seed_db( &db, &[ - (now - 600, 5_000, "opus", "recv", "ok", r#"{"Read":2,"Bash":1}"#), + ( + now - 600, + 5_000, + "opus", + "recv", + "ok", + r#"{"Read":2,"Bash":1}"#, + ), (now - 300, 10_000, "opus", "recv", "ok", r#"{"Read":3}"#), (now - 100, 20_000, "sonnet", "operator", "failed", "{}"), ], diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index a513ab5a..6e588f29 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -561,7 +561,9 @@ async fn api_state(State(state): State) -> axum::Json { ctx_usage, cost_usage, links: agent_links(&state.label, state.gui_vnc_port.is_some()), - forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL").ok().filter(|s| !s.is_empty()), + forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL") + .ok() + .filter(|s| !s.is_empty()), hive_name: crate::identity::hive_name(), swarm_name: crate::identity::swarm_name(), }) @@ -613,8 +615,7 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec { // `{state_dir}/hyperhive-dashboard-links.json`). Shape on disk // is `{label, icon, url}` with absolute URLs — those become // `kind = External` links, passed through verbatim. - let extras_path = - crate::paths::state_dir().join("hyperhive-dashboard-links.json"); + let extras_path = crate::paths::state_dir().join("hyperhive-dashboard-links.json"); if let Ok(text) = std::fs::read_to_string(&extras_path) && !text.trim().is_empty() && let Ok(extras) = serde_json::from_str::>(&text) @@ -662,7 +663,10 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec { /// Fetch reminder activity stats from the broker via the per-agent / /// manager socket. Returns None on any transport / decode failure — the /// stats are decorative, not authoritative. -async fn fetch_reminder_stats(socket: &std::path::Path, window_secs: u64) -> Option { +async fn fetch_reminder_stats( + socket: &std::path::Path, + window_secs: u64, +) -> Option { match client::request::<_, hive_sh4re::Response>( socket, &hive_sh4re::Request::ReminderRollup { @@ -956,7 +960,9 @@ async fn post_cancel_turn(State(state): State) -> Response { ), Err(e) => format!("operator: /cancel — pkill failed: {e}"), }; - state.bus.emit(crate::events::LiveEvent::Note { text: note }); + state + .bus + .emit(crate::events::LiveEvent::Note { text: note }); (axum::http::StatusCode::OK, "ok").into_response() } diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index e0ff4406..3446e8f2 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -9,6 +9,7 @@ workspace = true anyhow.workspace = true axum.workspace = true base64.workspace = true +bcrypt.workspace = true reqwest.workspace = true clap.workspace = true hive-sh4re.workspace = true diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index dfdb298c..1905ac0b 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -76,8 +76,7 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { // Pre-enqueue cascade rebuilds in topological order so // agents depending on updated inputs are rebuilt after the // lock bump, matching the dashboard post_meta_update path. - let cascade_agents = - crate::rebuild_queue::meta_update_cascade_agents(&inputs).await; + let cascade_agents = crate::rebuild_queue::meta_update_cascade_agents(&inputs).await; let cascade_reason = format!("approval #{id} meta input cascade"); for name in cascade_agents { coord.rebuild_queue.enqueue( diff --git a/hive-c0re/src/agent_ports.rs b/hive-c0re/src/agent_ports.rs index f86ae25f..de1032c4 100644 --- a/hive-c0re/src/agent_ports.rs +++ b/hive-c0re/src/agent_ports.rs @@ -40,8 +40,7 @@ fn render(map: &BTreeMap) -> String { // BTreeMap → serde_json::to_string_pretty preserves key order, // so the output is deterministic across calls with the same // agent set. - serde_json::to_string_pretty(map) - .expect("BTreeMap is always serialisable") + serde_json::to_string_pretty(map).expect("BTreeMap is always serialisable") } /// Atomically write the JSON for `names` to @@ -61,12 +60,10 @@ pub fn write(names: &[String]) -> Result<()> { return Ok(()); } if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("create {}", parent.display()))?; + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; } let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, &body) - .with_context(|| format!("write {}", tmp.display()))?; + std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?; std::fs::rename(&tmp, &path).with_context(|| { format!( "rename {} -> {} (atomic publish)", @@ -132,9 +129,6 @@ mod tests { // BTreeMap sorts → alpha before zeta in output. let alpha_pos = body.find("alpha").expect("alpha in output"); let zeta_pos = body.find("zeta").expect("zeta in output"); - assert!( - alpha_pos < zeta_pos, - "sorted order broken:\n{body}" - ); + assert!(alpha_pos < zeta_pos, "sorted order broken:\n{body}"); } } diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index c220f020..92b7f6cf 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -129,9 +129,11 @@ pub(crate) async fn dispatch_shared( ) -> Option { let broker = &coord.broker; Some(match req { - hive_sh4re::Request::Send { to, body, in_reply_to } => { - handle_send(coord, agent, to, body, *in_reply_to) - } + hive_sh4re::Request::Send { + to, + body, + in_reply_to, + } => handle_send(coord, agent, to, body, *in_reply_to), hive_sh4re::Request::Recv { wait_seconds, max } => { let cap = max.unwrap_or(1).min(RECV_BATCH_MAX) as usize; match broker @@ -223,8 +225,8 @@ pub(crate) async fn dispatch_shared( if let Err(message) = crate::limits::check_status_text(text) { return Some(hive_sh4re::Response::Err { message }); } - let path = crate::coordinator::Coordinator::agent_notes_dir(agent) - .join("hyperhive-status"); + let path = + crate::coordinator::Coordinator::agent_notes_dir(agent).join("hyperhive-status"); let result = if text.trim().is_empty() { std::fs::remove_file(&path).or_else(|e| { if e.kind() == std::io::ErrorKind::NotFound { @@ -242,11 +244,9 @@ pub(crate) async fn dispatch_shared( tokio::spawn(async move { coord2.rescan_containers_and_emit().await }); hive_sh4re::Response::Ok } - Err(e) => { - hive_sh4re::Response::Err { - message: format!("set_status write failed: {e}"), - } - } + Err(e) => hive_sh4re::Response::Err { + message: format!("set_status write failed: {e}"), + }, } } hive_sh4re::Request::GetAgentMeta { name } => { @@ -294,7 +294,15 @@ pub(crate) async fn dispatch_shared( message: format!("{e:#}"), }, }, - hive_sh4re::Request::GetHostJournal { unit, container, lines, priority, grep, since, until } => { + hive_sh4re::Request::GetHostJournal { + unit, + container, + lines, + priority, + grep, + since, + until, + } => { dispatch_host_journal(agent, unit, container, lines, priority, grep, since, until).await } // Not a shared variant. @@ -331,7 +339,10 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> Err(message) => AgentResponse::Err { message }, } } - AgentRequest::ReminderRollup { since_secs, agent: target } => { + AgentRequest::ReminderRollup { + since_secs, + agent: target, + } => { let name = resolve_agent_state_target(agent, target.as_deref()); match name { Ok(name) => match coord.broker.reminder_rollup_for(name, *since_secs) { @@ -547,10 +558,7 @@ pub(crate) fn store_remind( ) -> Result<(), String> { let max = remind_max_pending(); if max > 0 { - let pending = coord - .broker - .count_pending_reminders_for(agent) - .unwrap_or(0); + let pending = coord.broker.count_pending_reminders_for(agent).unwrap_or(0); if pending >= max { return Err(format!( "reminder rejected: agent `{agent}` already has {pending} pending \ @@ -604,8 +612,9 @@ fn prepare_remind_storage( }; let host_path = crate::reminder_scheduler::resolve_host_path(agent, &req_path) .map_err(|reason| format!("auto-save path `{req_path}` rejected: {reason}"))?; - crate::reminder_scheduler::write_payload(agent, &host_path, message) - .map_err(|reason| format!("auto-save of large reminder body to `{req_path}` failed: {reason}"))?; + crate::reminder_scheduler::write_payload(agent, &host_path, message).map_err(|reason| { + format!("auto-save of large reminder body to `{req_path}` failed: {reason}") + })?; let hint = format!( "[reminder body of {} bytes auto-saved to `{req_path}`; read with your filesystem tools]", message.len() @@ -634,19 +643,26 @@ fn auto_reminder_path(agent: &str) -> String { /// - `Some("")` where other is not a child → requires the /// `query_agent_state` capability; returns an error otherwise. /// - `Some("*")` → always rejected (hive-wide scans are manager-only). -fn resolve_agent_state_target<'a>(caller: &'a str, target: Option<&'a str>) -> Result<&'a str, String> { +fn resolve_agent_state_target<'a>( + caller: &'a str, + target: Option<&'a str>, +) -> Result<&'a str, String> { match target { None => Ok(caller), Some("*") => Err( "hive-wide query (agent=\"*\") is not available on the agent socket; \ - use the manager socket for swarm-wide scans".to_owned() + use the manager socket for swarm-wide scans" + .to_owned(), ), Some(name) => { if name == caller { return Ok(caller); } // Direct children are visible to their parent without extra capability. - if crate::topology::children_of(caller).iter().any(|c| c == name) { + if crate::topology::children_of(caller) + .iter() + .any(|c| c == name) + { return Ok(name); } if crate::capabilities::has_cap(caller, hive_sh4re::Capability::QueryAgentState) { diff --git a/hive-c0re/src/agent_sockets.rs b/hive-c0re/src/agent_sockets.rs index d7edbfd6..a18a1bbd 100644 --- a/hive-c0re/src/agent_sockets.rs +++ b/hive-c0re/src/agent_sockets.rs @@ -81,8 +81,7 @@ pub fn socket_path_for(name: &str) -> PathBuf { #[must_use] pub fn build_map(names: &[String]) -> BTreeMap { build_map_with(names, |name| { - ready_marker_for(name).exists() - || agent_dir_for(name).join(READY_MARKER_LEGACY).exists() + ready_marker_for(name).exists() || agent_dir_for(name).join(READY_MARKER_LEGACY).exists() }) } @@ -146,12 +145,10 @@ pub fn write(names: &[String]) -> Result<()> { return Ok(()); } if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("create {}", parent.display()))?; + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; } let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, &body) - .with_context(|| format!("write {}", tmp.display()))?; + std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?; std::fs::rename(&tmp, &path).with_context(|| { format!( "rename {} -> {} (atomic publish)", @@ -305,24 +302,30 @@ mod tests { let marker = ready_marker_for("iris"); let socket = socket_path_for("iris"); assert_eq!(marker.parent(), socket.parent()); - assert_eq!(marker, Path::new("/run/hive-agent/iris/hyperhive-socket-bound")); + assert_eq!( + marker, + Path::new("/run/hive-agent/iris/hyperhive-socket-bound") + ); } #[test] fn render_is_pretty_and_sorted() { let mut map = BTreeMap::new(); - map.insert("zeta".to_owned(), PathBuf::from("/run/hive-agent/zeta/web.sock")); - map.insert("alpha".to_owned(), PathBuf::from("/run/hive-agent/alpha/web.sock")); + map.insert( + "zeta".to_owned(), + PathBuf::from("/run/hive-agent/zeta/web.sock"), + ); + map.insert( + "alpha".to_owned(), + PathBuf::from("/run/hive-agent/alpha/web.sock"), + ); let body = render(&map); // Pretty-print = newlines between keys + indentation. assert!(body.contains('\n')); // BTreeMap sorts → alpha before zeta in output. let alpha_pos = body.find("alpha").expect("alpha in output"); let zeta_pos = body.find("zeta").expect("zeta in output"); - assert!( - alpha_pos < zeta_pos, - "sorted order broken:\n{body}" - ); + assert!(alpha_pos < zeta_pos, "sorted order broken:\n{body}"); } #[test] @@ -332,10 +335,12 @@ mod tests { // gateway-side reader can deserialise into String values // without nested struct logic. let mut map = BTreeMap::new(); - map.insert("iris".to_owned(), PathBuf::from("/run/hive-agent/iris/web.sock")); + map.insert( + "iris".to_owned(), + PathBuf::from("/run/hive-agent/iris/web.sock"), + ); let body = render(&map); assert!(body.contains("\"iris\"")); assert!(body.contains("\"/run/hive-agent/iris/web.sock\"")); } } - diff --git a/hive-c0re/src/auto_update.rs b/hive-c0re/src/auto_update.rs index 1ff3d67b..856b1caf 100644 --- a/hive-c0re/src/auto_update.rs +++ b/hive-c0re/src/auto_update.rs @@ -209,16 +209,19 @@ pub async fn ensure_manager(coord: &Arc) -> Result<()> { /// Sort `names` in-place so parents precede their children in the topology. /// Uses BFS from root agents (depth 0). Agents absent from `topo` sort last, /// alphabetically within their tier. Stable within each depth tier. -pub fn topology_sort(names: &mut [String], topo: &std::collections::BTreeMap>) { +pub fn topology_sort( + names: &mut Vec, + topo: &std::collections::BTreeMap>, +) { use std::collections::{HashMap, VecDeque}; // Build depth map using owned clones so the borrow on `names` is released // before the sort_by mutable borrow. - let name_set: Vec = names.to_vec(); + let name_set: Vec = names.clone(); let mut depth: HashMap = HashMap::new(); let mut queue: VecDeque = VecDeque::new(); // Seed roots: entries with no parent, or names not present in topo at all. for name in &name_set { - if topo.get(name).is_none_or(Option::is_none) { + if topo.get(name).map_or(true, |p| p.is_none()) { depth.insert(name.clone(), 0); queue.push_back(name.clone()); } @@ -300,4 +303,3 @@ pub async fn run(coord: Arc) -> Result<()> { coord.emit_rebuild_queue_snapshot(); Ok(()) } - diff --git a/hive-c0re/src/bash_tasks_vacuum.rs b/hive-c0re/src/bash_tasks_vacuum.rs index 402dafb7..8bdb3a00 100644 --- a/hive-c0re/src/bash_tasks_vacuum.rs +++ b/hive-c0re/src/bash_tasks_vacuum.rs @@ -23,7 +23,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::coordinator::Coordinator; -const VACUUM_INTERVAL: Duration = Duration::from_hours(1); +const VACUUM_INTERVAL: Duration = Duration::from_secs(3600); /// Keep completed task files for 48 hours before sweeping them. const KEEP_SECS: i64 = 48 * 3600; @@ -65,7 +65,9 @@ fn sweep_once() { /// files removed (each represents one task; `.out`/`.err` deletions /// are not counted separately). fn vacuum_dir(dir: &Path, cutoff: i64) -> u64 { - let Ok(rd) = std::fs::read_dir(dir) else { return 0 }; + let Ok(rd) = std::fs::read_dir(dir) else { + return 0; + }; let mut removed: u64 = 0; for entry in rd.flatten() { let path = entry.path(); @@ -97,7 +99,10 @@ fn should_delete(json_path: &Path, cutoff: i64) -> bool { if !TERMINAL_STATUSES.contains(&status) { return false; } - let completed_at = v.get("completed_at").and_then(serde_json::Value::as_i64).unwrap_or(i64::MAX); + let completed_at = v + .get("completed_at") + .and_then(|t| t.as_i64()) + .unwrap_or(i64::MAX); completed_at < cutoff } @@ -106,10 +111,10 @@ fn should_delete(json_path: &Path, cutoff: i64) -> bool { fn delete_trio(dir: &Path, stem: &str) { for ext in ["json", "out", "err"] { let path = dir.join(format!("{stem}.{ext}")); - if path.exists() - && let Err(e) = std::fs::remove_file(&path) - { - tracing::warn!(path = %path.display(), error = ?e, "bash-tasks vacuum: remove failed"); + if path.exists() { + if let Err(e) = std::fs::remove_file(&path) { + tracing::warn!(path = %path.display(), error = ?e, "bash-tasks vacuum: remove failed"); + } } } } diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index f7632820..f5734b8b 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -15,6 +15,8 @@ //! dirs) and reuse the `forge` / `matrix` modules from the //! `hive-c0re` lib — single source of truth, no duplication. +use std::path::{Path, PathBuf}; + use anyhow::{Context as _, Result, bail}; use clap::{Parser, Subcommand}; use hive_c0re::coordinator::Coordinator; @@ -54,6 +56,14 @@ enum Cmd { #[command(subcommand)] cmd: MatrixCmd, }, + /// Gateway htpasswd user management. Add, remove, or list users in + /// an htpasswd file used by the gateway's HTTP Basic auth + /// (`services.hyperhive.gateway.auth`). Credentials are stored as + /// BCrypt hashes — no extra service or PAM required. + Gateway { + #[command(subcommand)] + cmd: GatewayCmd, + }, } #[derive(Subcommand)] @@ -137,6 +147,48 @@ enum MatrixCmd { }, } +#[derive(Subcommand)] +enum GatewayCmd { + /// Add a new user or update the password of an existing user in an + /// htpasswd file. The password is hashed with BCrypt (cost 12). + /// + /// Pass `--password-stdin` when scripting or when you don't want the + /// password visible in shell history. The file is created if it does + /// not exist; its parent directory must already exist. + CreateUser { + /// Path to the htpasswd file (the value of + /// `services.hyperhive.gateway.auth.htpasswdFile`). + #[arg(long, short = 'f')] + file: PathBuf, + /// Username to add or update. + username: String, + /// Set the password inline. WARNING: visible in shell history and + /// process listings — prefer `--password-stdin` for sensitive input. + /// Mutually exclusive with `--password-stdin`. + #[arg(long, conflicts_with = "password_stdin")] + password: Option, + /// Read the password from stdin (single line, trailing newline + /// stripped). Mutually exclusive with `--password`. + #[arg(long)] + password_stdin: bool, + }, + /// Remove a user from an htpasswd file. Exits with an error when the + /// user is not found so callers can detect the no-op case. + DeleteUser { + /// Path to the htpasswd file. + #[arg(long, short = 'f')] + file: PathBuf, + /// Username to remove. + username: String, + }, + /// List all usernames in an htpasswd file, one per line. + ListUsers { + /// Path to the htpasswd file. + #[arg(long, short = 'f')] + file: PathBuf, + }, +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -161,6 +213,16 @@ async fn main() -> Result<()> { password_stdin, } => matrix_create_user(&name, password.as_deref(), password_stdin).await, }, + Cmd::Gateway { cmd } => match cmd { + GatewayCmd::CreateUser { + file, + username, + password, + password_stdin, + } => gateway_create_user(&file, &username, password.as_deref(), password_stdin), + GatewayCmd::DeleteUser { file, username } => gateway_delete_user(&file, &username), + GatewayCmd::ListUsers { file } => gateway_list_users(&file), + }, } } @@ -173,11 +235,7 @@ fn is_agent(name: &str) -> bool { Coordinator::agent_state_root(name).exists() } -async fn forge_create_user( - name: &str, - password: Option<&str>, - password_stdin: bool, -) -> Result<()> { +async fn forge_create_user(name: &str, password: Option<&str>, password_stdin: bool) -> Result<()> { if !hive_c0re::forge::is_present().await { bail!( "hive-forge container not running — start it (services.hyperhive.forge.enable = true) before provisioning forge users" @@ -274,13 +332,18 @@ async fn matrix_create_user( } else { let effective_password = match user_password { Some(p) => p, - None => hive_c0re::matrix::random_password() - .context("generate random matrix password")?, + None => { + hive_c0re::matrix::random_password().context("generate random matrix password")? + } }; - let token = - hive_c0re::matrix::provision_user_token(&client, name, ®ister_token, &effective_password) - .await - .with_context(|| format!("matrix create-user {name}"))?; + let token = hive_c0re::matrix::provision_user_token( + &client, + name, + ®ister_token, + &effective_password, + ) + .await + .with_context(|| format!("matrix create-user {name}"))?; println!("matrix: provisioned user '{name}' (not an agent — token not persisted)"); println!("token: {token}"); if password.is_some() || password_stdin { @@ -293,3 +356,115 @@ async fn matrix_create_user( } Ok(()) } + +// --------------------------------------------------------------------------- +// Gateway htpasswd helpers +// --------------------------------------------------------------------------- + +/// Read an htpasswd file into a list of lines, or return an empty list +/// if the file does not exist yet. +fn htpasswd_read(path: &Path) -> Result> { + if !path.exists() { + return Ok(vec![]); + } + let content = std::fs::read_to_string(path) + .with_context(|| format!("read htpasswd file {}", path.display()))?; + Ok(content.lines().map(str::to_owned).collect()) +} + +/// Write lines back to `path` atomically (write to `.tmp`, then +/// rename). A trailing newline is always appended to the last line. +fn htpasswd_write(path: &Path, lines: &[String]) -> Result<()> { + let tmp = path.with_extension("htpasswd.tmp"); + let content = if lines.is_empty() { + String::new() + } else { + let mut s = lines.join("\n"); + s.push('\n'); + s + }; + std::fs::write(&tmp, &content) + .with_context(|| format!("write htpasswd tmp {}", tmp.display()))?; + std::fs::rename(&tmp, path) + .with_context(|| format!("rename {} → {}", tmp.display(), path.display()))?; + Ok(()) +} + +/// Add or update `username` in the htpasswd file at `file`, hashing +/// `password` with BCrypt (cost 12). Creates the file when absent. +fn gateway_create_user( + file: &Path, + username: &str, + password: Option<&str>, + password_stdin: bool, +) -> Result<()> { + let pw = resolve_password(password, password_stdin)?.ok_or_else(|| { + anyhow::anyhow!("a password is required — pass --password or --password-stdin") + })?; + validate_htpasswd_username(username)?; + let raw_hash = bcrypt::hash(&pw, 12).context("bcrypt hash")?; + // nginx auth_basic only recognises $2a$/$2x$/$2y$ — not $2b$. The two + // prefixes are algorithmically identical; remap so nginx accepts the hash. + let hash = raw_hash.replacen("$2b$", "$2y$", 1); + let entry = format!("{username}:{hash}"); + let mut lines = htpasswd_read(file)?; + let prefix = format!("{username}:"); + if let Some(pos) = lines.iter().position(|l| l.starts_with(&prefix)) { + lines[pos] = entry; + htpasswd_write(file, &lines)?; + println!( + "gateway: updated password for '{username}' in {}", + file.display() + ); + } else { + lines.push(entry); + htpasswd_write(file, &lines)?; + println!("gateway: added user '{username}' to {}", file.display()); + } + Ok(()) +} + +/// Remove `username` from the htpasswd file. Errors when the user is +/// not present so callers can detect the no-op case. +fn gateway_delete_user(file: &Path, username: &str) -> Result<()> { + let mut lines = htpasswd_read(file)?; + let prefix = format!("{username}:"); + let before = lines.len(); + lines.retain(|l| !l.starts_with(&prefix)); + if lines.len() == before { + bail!("gateway: user '{username}' not found in {}", file.display()); + } + htpasswd_write(file, &lines)?; + println!("gateway: removed user '{username}' from {}", file.display()); + Ok(()) +} + +/// Print one username per line from the htpasswd file. +fn gateway_list_users(file: &Path) -> Result<()> { + let lines = htpasswd_read(file)?; + for line in &lines { + // Skip blank lines and comments. + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((name, _)) = line.split_once(':') { + println!("{name}"); + } + } + Ok(()) +} + +/// Reject usernames containing `:` (field separator) or control chars +/// that would corrupt the htpasswd file format. +fn validate_htpasswd_username(username: &str) -> Result<()> { + if username.is_empty() { + bail!("username must not be empty"); + } + if username.contains(':') { + bail!("username must not contain ':' (htpasswd field separator)"); + } + if username.chars().any(|c| c.is_control()) { + bail!("username must not contain control characters"); + } + Ok(()) +} diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs index e28a698e..60f3dc1a 100644 --- a/hive-c0re/src/broker.rs +++ b/hive-c0re/src/broker.rs @@ -268,12 +268,7 @@ impl Broker { /// broker. Used by the scheduler to skip re-delivery of the same /// scheduled prompt without blocking distinct schedules whose /// bodies differ. - pub fn has_pending_with_body( - &self, - recipient: &str, - sender: &str, - body: &str, - ) -> Result { + pub fn has_pending_with_body(&self, recipient: &str, sender: &str, body: &str) -> Result { let conn = self.conn.lock().unwrap(); let n: i64 = conn.query_row( "SELECT COUNT(*) FROM messages @@ -391,7 +386,13 @@ impl Broker { )?; let rows: Vec<(i64, String, String, String, Option)> = stmt .query_map(params![recipient, max_i], |row| { - Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)) + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) })? .collect::>()?; drop(stmt); @@ -673,7 +674,11 @@ impl Broker { /// Reminder rollup stats for an agent over a time window. Returns /// counts of scheduled, delivered, and pending reminders created /// in the last `since_secs` seconds (0 = all reminders). - pub fn reminder_rollup_for(&self, agent: &str, since_secs: u64) -> Result { + pub fn reminder_rollup_for( + &self, + agent: &str, + since_secs: u64, + ) -> Result { let conn = self.conn.lock().unwrap(); let cutoff_time = if since_secs > 0 { let now = std::time::SystemTime::now() @@ -740,9 +745,7 @@ impl Broker { || canceller == hive_sh4re::OPERATOR_RECIPIENT || canceller == hive_sh4re::MANAGER_AGENT; if !authorised { - anyhow::bail!( - "reminder {id}: '{canceller}' not allowed to cancel (owner = '{owner}')" - ); + anyhow::bail!("reminder {id}: '{canceller}' not allowed to cancel (owner = '{owner}')"); } let n = conn.execute( "DELETE FROM reminders WHERE id = ?1 AND sent_at IS NULL", @@ -862,7 +865,9 @@ impl Broker { } drop(conn); // Emit per-row Sent events (only for rows that succeeded). - for (((id, agent, body), result), msg_id) in items.iter().zip(results.iter()).zip(msg_ids.iter()) { + for (((id, agent, body), result), msg_id) in + items.iter().zip(results.iter()).zip(msg_ids.iter()) + { if result.is_ok() { let _ = self.events.send(MessageEvent::Sent { id: *msg_id, @@ -1029,10 +1034,7 @@ mod tests { assert_eq!(broker.requeue_inflight("b").unwrap(), 1); let d2 = pop_one(broker, "b").expect("popped again"); assert_eq!(d2.message.body, "hi"); - assert!( - d2.redelivered, - "second pop should be tagged redelivered" - ); + assert!(d2.redelivered, "second pop should be tagged redelivered"); assert_eq!(broker.ack_turn("b").unwrap(), 1); } @@ -1275,4 +1277,3 @@ mod tests { assert!(pop_one(broker, "bob").is_none()); } } - diff --git a/hive-c0re/src/build_logs.rs b/hive-c0re/src/build_logs.rs index f518be59..5b3cdf31 100644 --- a/hive-c0re/src/build_logs.rs +++ b/hive-c0re/src/build_logs.rs @@ -274,14 +274,8 @@ impl BuildLogs { match row { None => Ok(None), Some((stdout, stderr, finished_at, status)) => { - let stdout_append = stdout - .get(stdout_cursor..) - .unwrap_or("") - .to_string(); - let stderr_append = stderr - .get(stderr_cursor..) - .unwrap_or("") - .to_string(); + let stdout_append = stdout.get(stdout_cursor..).unwrap_or("").to_string(); + let stderr_append = stderr.get(stderr_cursor..).unwrap_or("").to_string(); Ok(Some(BuildLogProgress { stdout_append, stderr_append, @@ -296,11 +290,7 @@ impl BuildLogs { /// Headers only (no stdout/stderr blobs) — keeps `/api/state` /// payloads light. Limit is hard-clamped to 50 to bound worst-case /// payload regardless of caller input. - pub fn list_recent_for_agent( - &self, - agent: &str, - limit: usize, - ) -> Result> { + pub fn list_recent_for_agent(&self, agent: &str, limit: usize) -> Result> { let limit = limit.min(50); let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( @@ -310,7 +300,10 @@ impl BuildLogs { ORDER BY started_at DESC LIMIT ?2", )?; - let rows = stmt.query_map(params![agent, i64::try_from(limit).unwrap_or(50)], row_to_header)?; + let rows = stmt.query_map( + params![agent, i64::try_from(limit).unwrap_or(50)], + row_to_header, + )?; let mut out = Vec::new(); for r in rows { out.push(r?); @@ -470,7 +463,9 @@ mod tests { #[test] fn start_appends_finish_flow() { let (_d, db) = tmpdb(); - let id = db.start("alice", "prebuild", "nix build foo").expect("start"); + let id = db + .start("alice", "prebuild", "nix build foo") + .expect("start"); db.append_stdout(id, "building '/nix/store/abc.drv'"); db.append_stderr(id, "error: line 12"); db.append_stderr(id, " at /nix/store/.../module.nix:5"); @@ -509,8 +504,7 @@ mod tests { // but list_recent already orders by `started_at DESC` then // sqlite's natural insertion-order tiebreak. We rely only on // both IDs being present + correct count + agent isolation. - let ids: std::collections::HashSet = - alice_rows.iter().map(|h| h.id).collect(); + let ids: std::collections::HashSet = alice_rows.iter().map(|h| h.id).collect(); assert!(ids.contains(&id_a1)); assert!(ids.contains(&id_a2)); diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 214d08cc..378d05ca 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -115,7 +115,9 @@ pub async fn build_all(coord: &Coordinator) -> Vec { } else { continue; }; - let deployed_full = locked.get(&format!("agent-{logical}")).map(std::string::String::as_str); + let deployed_full = locked + .get(&format!("agent-{logical}")) + .map(std::string::String::as_str); let needs_update = crate::auto_update::agent_config_pending(&logical, deployed_full); let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned()); // Recipient name the broker uses for this agent — sub-agents @@ -143,27 +145,40 @@ pub async fn build_all(coord: &Coordinator) -> Vec { // Static / declared fields (extra_links, deployed_sha, // pending_reminders, needs_update, parent) stay populated // regardless of run state. - let (needs_login, ctx_tokens, context_window_tokens, rate_limited, status_text, status_set_at) = - if running { - // needs_login fires when EITHER the claude session dir is - // missing (boot-time / fresh container) OR the harness wrote - // the auth-failed sentinel because a turn hit 401. The - // manager has its own session lifecycle and never - // participates in needs_login. - let needs_login = !is_manager - && (!claude_has_session(&Coordinator::agent_claude_dir(&logical)) - || auth_failed_sentinel(&logical)); - let last_turn = read_last_turn(&logical); - let ctx_tokens = last_turn.as_ref().map(|(toks, _)| *toks); - let context_window_tokens = last_turn - .as_ref() - .and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens)); - let rate_limited = is_rate_limited(&logical); - let (status_text, status_set_at) = read_status(&logical); - (needs_login, ctx_tokens, context_window_tokens, rate_limited, status_text, status_set_at) - } else { - (false, None, None, false, None, None) - }; + let ( + needs_login, + ctx_tokens, + context_window_tokens, + rate_limited, + status_text, + status_set_at, + ) = if running { + // needs_login fires when EITHER the claude session dir is + // missing (boot-time / fresh container) OR the harness wrote + // the auth-failed sentinel because a turn hit 401. The + // manager has its own session lifecycle and never + // participates in needs_login. + let needs_login = !is_manager + && (!claude_has_session(&Coordinator::agent_claude_dir(&logical)) + || auth_failed_sentinel(&logical)); + let last_turn = read_last_turn(&logical); + let ctx_tokens = last_turn.as_ref().map(|(toks, _)| *toks); + let context_window_tokens = last_turn + .as_ref() + .and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens)); + let rate_limited = is_rate_limited(&logical); + let (status_text, status_set_at) = read_status(&logical); + ( + needs_login, + ctx_tokens, + context_window_tokens, + rate_limited, + status_text, + status_set_at, + ) + } else { + (false, None, None, false, None, None) + }; out.push(ContainerView { port: lifecycle::agent_web_port(&logical), running, @@ -217,12 +232,18 @@ fn read_dashboard_links(name: &str) -> Vec { /// don't lose state during the transition window. fn read_harness_flags(name: &str) -> (bool, bool) { let dir = Coordinator::agent_notes_dir(name); - if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json")) - && let Ok(v) = serde_json::from_str::(&raw) - { - let rl = v.get("rate_limited").and_then(serde_json::Value::as_bool).unwrap_or(false); - let nl = v.get("needs_login").and_then(serde_json::Value::as_bool).unwrap_or(false); - return (rl, nl); + if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json")) { + if let Ok(v) = serde_json::from_str::(&raw) { + let rl = v + .get("rate_limited") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + let nl = v + .get("needs_login") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + return (rl, nl); + } } // Legacy fallback: presence of individual sentinel files. let rate_limited = dir.join("hyperhive-rate-limited").exists(); @@ -249,14 +270,23 @@ pub fn read_agent_status(name: &str) -> (Option, Option) { let path = Coordinator::agent_notes_dir(name).join("hyperhive-status"); let meta = std::fs::metadata(&path).ok(); let s = std::fs::read_to_string(&path).ok(); - let text = s.as_deref().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned); + let text = s + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(str::to_owned); let mtime = meta.and_then(|m| { m.modified().ok().and_then(|t| { - t.duration_since(std::time::UNIX_EPOCH).ok() + t.duration_since(std::time::UNIX_EPOCH) + .ok() .and_then(|d| i64::try_from(d.as_secs()).ok()) }) }); - if text.is_none() { (None, None) } else { (text, mtime) } + if text.is_none() { + (None, None) + } else { + (text, mtime) + } } fn read_status(name: &str) -> (Option, Option) { @@ -304,9 +334,7 @@ pub async fn read_agent_status_live(name: &str) -> (Option, Option, /// corresponding env var is unset or empty. #[must_use] pub fn hive_swarm_names() -> (Option, Option) { - let read = |var: &str| -> Option { - std::env::var(var).ok().filter(|s| !s.is_empty()) - }; + let read = |var: &str| -> Option { std::env::var(var).ok().filter(|s| !s.is_empty()) }; (read("HYPERHIVE_HIVE_NAME"), read("HYPERHIVE_SWARM_NAME")) } @@ -321,11 +349,8 @@ pub fn hive_swarm_names() -> (Option, Option) { /// mirroring `hive_ag3nt::events::TokenUsage::context_tokens`. fn read_last_turn(name: &str) -> Option<(u64, String)> { let path = Coordinator::agent_notes_dir(name).join("hyperhive-turn-stats.sqlite"); - let conn = Connection::open_with_flags( - &path, - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, - ) - .ok()?; + let conn = + Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?; conn.query_row( "SELECT last_input_tokens + last_cache_read_input_tokens + last_cache_creation_input_tokens, model \ FROM turn_stats ORDER BY started_at DESC LIMIT 1", @@ -409,14 +434,26 @@ mod tests { #[test] fn resolves_family_substring() { - assert_eq!(resolve_ctx_window("claude-3-5-haiku-20241022", &cfg()), Some(200_000)); - assert_eq!(resolve_ctx_window("claude-sonnet-4-5", &cfg()), Some(1_000_000)); - assert_eq!(resolve_ctx_window("claude-opus-4-1", &cfg()), Some(1_000_000)); + assert_eq!( + resolve_ctx_window("claude-3-5-haiku-20241022", &cfg()), + Some(200_000) + ); + assert_eq!( + resolve_ctx_window("claude-sonnet-4-5", &cfg()), + Some(1_000_000) + ); + assert_eq!( + resolve_ctx_window("claude-opus-4-1", &cfg()), + Some(1_000_000) + ); } #[test] fn resolution_is_case_insensitive() { - assert_eq!(resolve_ctx_window("Claude-Sonnet-4", &cfg()), Some(1_000_000)); + assert_eq!( + resolve_ctx_window("Claude-Sonnet-4", &cfg()), + Some(1_000_000) + ); } #[test] @@ -426,7 +463,10 @@ mod tests { #[test] fn empty_config_yields_none() { - assert_eq!(resolve_ctx_window("claude-3-5-haiku", &HashMap::new()), None); + assert_eq!( + resolve_ctx_window("claude-3-5-haiku", &HashMap::new()), + None + ); } #[test] diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 5bafe818..7bea60bc 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -708,11 +708,16 @@ impl Coordinator { /// whose stop the crash watcher should NOT classify as a crash. /// Lazily reaps entries older than `grace` so the map stays /// bounded by the active agent count. - pub fn recent_transient_within(&self, grace: std::time::Duration) -> HashMap { + pub fn recent_transient_within( + &self, + grace: std::time::Duration, + ) -> HashMap { let now = std::time::Instant::now(); let mut map = self.recent_transient.lock().unwrap(); map.retain(|_, (_, ts)| now.duration_since(*ts) <= grace); - map.iter().map(|(k, (kind, _))| (k.clone(), *kind)).collect() + map.iter() + .map(|(k, (kind, _))| (k.clone(), *kind)) + .collect() } /// Set a transient state and return a guard that clears it on drop. diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index b66586da..d4e79eb0 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -472,9 +472,15 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J let s = v.to_string_lossy().to_ascii_lowercase(); matches!(s.as_str(), "1" | "true" | "yes") }), - forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL").ok().filter(|s| !s.is_empty()), - hive_name: std::env::var("HYPERHIVE_HIVE_NAME").ok().filter(|s| !s.is_empty()), - swarm_name: std::env::var("HYPERHIVE_SWARM_NAME").ok().filter(|s| !s.is_empty()), + forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL") + .ok() + .filter(|s| !s.is_empty()), + hive_name: std::env::var("HYPERHIVE_HIVE_NAME") + .ok() + .filter(|s| !s.is_empty()), + swarm_name: std::env::var("HYPERHIVE_SWARM_NAME") + .ok() + .filter(|s| !s.is_empty()), peer_hives: parse_peer_hives(), }) } @@ -1751,9 +1757,7 @@ async fn get_build_log_full( ) -> Response { match state.coord.build_logs.get_full(id) { Ok(Some(log)) => axum::Json(log).into_response(), - Ok(None) => { - (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response() - } + Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(), Err(e) => error_response(&format!("build-log {id}: {e:#}")), } } @@ -1870,10 +1874,7 @@ async fn get_build_log_stream( /// separator (same layout the JS side-panel renders). The /// `Content-Disposition` header triggers a browser download with a /// descriptive filename so the operator can save and share the log. -async fn get_build_log_raw( - State(state): State, - AxumPath(id): AxumPath, -) -> Response { +async fn get_build_log_raw(State(state): State, AxumPath(id): AxumPath) -> Response { match state.coord.build_logs.get_full(id) { Ok(Some(log)) => { let mut text = log.stdout; @@ -1897,9 +1898,7 @@ async fn get_build_log_raw( ) .into_response() } - Ok(None) => { - (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response() - } + Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(), Err(e) => error_response(&format!("build-log {id}: {e:#}")), } } @@ -2522,7 +2521,10 @@ async fn get_tool_groups(State(_state): State) -> axum::Json Result> { // `inputs = { }`. Any other git failure (permission denied, // ref-not-found, etc.) propagates as a hard error rather than // being silently swallowed. - if stderr.contains("does not exist") - || stderr.contains("exists on disk, but not in") - { + if stderr.contains("does not exist") || stderr.contains("exists on disk, but not in") { return Ok(None); } anyhow::bail!("git show {spec} failed: {}", stderr.trim()); @@ -130,10 +128,7 @@ pub fn duplicate_groups(raw: &str) -> Result> { }); entry.keys.push(name.clone()); } - let mut dups: Vec = groups - .into_values() - .filter(|g| g.keys.len() > 1) - .collect(); + let mut dups: Vec = groups.into_values().filter(|g| g.keys.len() > 1).collect(); for g in &mut dups { g.keys.sort(); } @@ -259,12 +254,7 @@ async fn lock_in_sync_inner(worktree: &Path) -> Result<()> { async fn remove_worktree(repo: &Path, worktree: &Path) -> Result<()> { let out = git_command() .current_dir(repo) - .args([ - "worktree", - "remove", - "--force", - &worktree.to_string_lossy(), - ]) + .args(["worktree", "remove", "--force", &worktree.to_string_lossy()]) .output() .await .with_context(|| format!("git worktree remove {}", worktree.display()))?; diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 9f48939b..509f521c 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -217,7 +217,14 @@ async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> /// from the operator's point of view: same password input → same final /// account state. async fn change_user_password(name: &str, password: &str) -> Result<()> { - let args = ["user", "change-password", "--username", name, "--password", password]; + let args = [ + "user", + "change-password", + "--username", + name, + "--password", + password, + ]; forge_admin(&args) .await .with_context(|| format!("forgejo admin user change-password {name}"))?; @@ -551,9 +558,8 @@ pub async fn ensure_shared_docs_repo(core_token: &str) -> Result<()> { /// Mirrors `meta_read_access` so agents can clone the shared docs repo /// without authentication hassle. pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> { - let url = format!( - "{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name}" - ); + let url = + format!("{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name}"); let body = r#"{"permission":"read"}"#; let out = Command::new("curl") .args([ diff --git a/hive-c0re/src/lib.rs b/hive-c0re/src/lib.rs index 85b4c811..833cd7c8 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -14,15 +14,14 @@ pub mod actions; pub mod agent_ports; -pub mod capabilities; pub mod agent_server; pub mod agent_sockets; -pub mod gateway_nginx; pub mod approvals; pub mod auto_update; pub mod bash_tasks_vacuum; pub mod broker; pub mod build_logs; +pub mod capabilities; pub mod client; pub mod container_view; pub mod coordinator; @@ -32,6 +31,7 @@ pub mod dashboard_events; pub mod events_vacuum; pub mod flake_check; pub mod forge; +pub mod gateway_nginx; pub mod lifecycle; pub mod limits; pub mod loose_ends; diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index a2029253..516ae0db 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -449,11 +449,12 @@ pub async fn rebuild_no_meta( "kill before cold-start retry failed (ignored)" ); }); - run(&["start", &container]).await - .map_err(|e| anyhow::anyhow!( + run(&["start", &container]).await.map_err(|e| { + anyhow::anyhow!( "cold-start fallback also failed: {e:#} \ (original start error: {start_err:#})" - )) + ) + }) } else { Ok(()) } @@ -494,9 +495,7 @@ async fn prebuild_toplevel(name: &str, flake_ref: &str) -> Result<()> { // pair (no current callsite does, but the pair is redundant // and worth checking once). if fragment != name { - anyhow::bail!( - "prebuild_toplevel: flake_ref fragment '{fragment}' ≠ agent name '{name}'" - ); + anyhow::bail!("prebuild_toplevel: flake_ref fragment '{fragment}' ≠ agent name '{name}'"); } let attr = format!("{flake_root}#nixosConfigurations.{name}.config.system.build.toplevel"); let args = vec![ @@ -1135,8 +1134,7 @@ fn set_nspawn_flags( ); } let own_config = format!("{HOST_AGENTS_ROOT}/{agent_name}/config"); - std::fs::create_dir_all(&own_config) - .with_context(|| format!("create {own_config}"))?; + std::fs::create_dir_all(&own_config).with_context(|| format!("create {own_config}"))?; let _ = write!(binds, " --bind-ro={own_config}:/agents/{agent_name}/config"); } @@ -1337,9 +1335,9 @@ async fn run(args: &[&str]) -> Result<()> { // every notification with the eval-error verbatim. let journal = container_journal_tail(args).await; match log_id { - Some(id) => bail!( - "nixos-container {cmdline} failed ({status}); see build log #{id}{journal}" - ), + Some(id) => { + bail!("nixos-container {cmdline} failed ({status}); see build log #{id}{journal}") + } None => bail!("nixos-container {cmdline} failed ({status}){journal}"), } } @@ -1443,4 +1441,3 @@ mod tests { ); } } - diff --git a/hive-c0re/src/loose_ends.rs b/hive-c0re/src/loose_ends.rs index 6cd903de..6def7c87 100644 --- a/hive-c0re/src/loose_ends.rs +++ b/hive-c0re/src/loose_ends.rs @@ -16,7 +16,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use hive_sh4re::{MANAGER_AGENT, LooseEnd}; +use hive_sh4re::{LooseEnd, MANAGER_AGENT}; use crate::coordinator::Coordinator; diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index f4ee0b62..6d149abf 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -12,8 +12,8 @@ use hive_sh4re::{HostRequest, HostResponse}; // explicit (any new daemon entry point reads off the next add). use hive_c0re::coordinator::Coordinator; use hive_c0re::{ - agent_sockets, auto_update, broker, client, crash_watch, dashboard, dashboard_events, - bash_tasks_vacuum, events_vacuum, forge, manager_server, matrix, migrate, rebuild_queue, + agent_sockets, auto_update, bash_tasks_vacuum, broker, client, crash_watch, dashboard, + dashboard_events, events_vacuum, forge, manager_server, matrix, migrate, rebuild_queue, reminder_scheduler, scheduled_prompts_worker, server, stats_vacuum, }; @@ -51,7 +51,10 @@ enum Cmd { /// short name to token count. Threaded into each container as /// `HIVE_CONTEXT_WINDOW_TOKENS_` env vars. Set via the /// `services.hive-c0re.contextWindowTokens` NixOS option. - #[arg(long, default_value = r#"{"haiku":200000,"sonnet":1000000,"opus":1000000}"#)] + #[arg( + long, + default_value = r#"{"haiku":200000,"sonnet":1000000,"opus":1000000}"# + )] context_window_tokens: String, }, /// Spawn a new agent container directly (`hive-agent-`). Bypasses @@ -119,7 +122,17 @@ async fn main() -> Result<()> { dashboard_port, operator_pronouns, context_window_tokens, - } => cmd_serve(hyperhive_flake, db, dashboard_port, operator_pronouns, context_window_tokens, &cli.socket).await, + } => { + cmd_serve( + hyperhive_flake, + db, + dashboard_port, + operator_pronouns, + context_window_tokens, + &cli.socket, + ) + .await + } Cmd::Spawn { name } => { render(client::request(&cli.socket, HostRequest::Spawn { name }).await?) } @@ -148,11 +161,7 @@ async fn main() -> Result<()> { } => { let new_parent = if root { None } else { parent }; render( - client::request( - &cli.socket, - HostRequest::SetParent { child, new_parent }, - ) - .await?, + client::request(&cli.socket, HostRequest::SetParent { child, new_parent }).await?, ) } } @@ -169,9 +178,8 @@ async fn cmd_serve( context_window_tokens: String, socket: &std::path::Path, ) -> Result<()> { - let cwt: std::collections::HashMap = - serde_json::from_str(&context_window_tokens) - .context("--context-window-tokens: invalid JSON")?; + let cwt: std::collections::HashMap = serde_json::from_str(&context_window_tokens) + .context("--context-window-tokens: invalid JSON")?; let coord = Arc::new(Coordinator::open( &db, hyperhive_flake, @@ -335,7 +343,14 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc) { tokio::spawn(async move { loop { match rx.recv().await { - Ok(MessageEvent::Sent { id, from, to, body, at, in_reply_to }) => { + Ok(MessageEvent::Sent { + id, + from, + to, + body, + at, + in_reply_to, + }) => { let file_refs = dashboard::scan_validated_paths(&body); coord.emit_dashboard_event(DashboardEvent::Sent { seq: coord.next_seq(), @@ -348,7 +363,14 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc) { file_refs, }); } - Ok(MessageEvent::Delivered { id, from, to, body, at, in_reply_to }) => { + Ok(MessageEvent::Delivered { + id, + from, + to, + body, + at, + in_reply_to, + }) => { let file_refs = dashboard::scan_validated_paths(&body); coord.emit_dashboard_event(DashboardEvent::Delivered { seq: coord.next_seq(), diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 7810ddfa..8bd21f32 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -572,7 +572,10 @@ where // Emit `capabilities = "cap1,cap2"` when the operator has // granted capabilities to this agent. Absent entry = null = no // capability env var injected, capability-gated tools hidden. - let caps = capabilities_map.get(&spec.name).cloned().unwrap_or_default(); + let caps = capabilities_map + .get(&spec.name) + .cloned() + .unwrap_or_default(); let capabilities_attr = if caps.is_empty() { "null".to_owned() } else { diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index 29f7e569..8b37643d 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -147,7 +147,9 @@ fn migrate_harness_files(name: &str) { } match std::fs::rename(&src, &dst) { Ok(()) => tracing::info!(%name, %file, "migration: moved to harness dir"), - Err(e) => tracing::warn!(%name, %file, error = ?e, "migration: move to harness dir failed"), + Err(e) => { + tracing::warn!(%name, %file, error = ?e, "migration: move to harness dir failed") + } } } } @@ -190,7 +192,11 @@ async fn rename_manager_container(coord: &Arc) { // Stop the old container. Abort if stop fails — continuing with a // running `root` and then starting `h-root` risks two manager // instances racing for the same broker / state files. - match Command::new("nixos-container").args(["stop", "root"]).status().await { + match Command::new("nixos-container") + .args(["stop", "root"]) + .status() + .await + { Ok(s) if s.success() => {} Ok(s) => { tracing::warn!(status = %s, "migration phase 5: nixos-container stop root failed — aborting"); @@ -218,12 +224,20 @@ async fn rename_manager_container(coord: &Arc) { } // Daemon reload so systemd picks up the new container@h-root unit. - if let Err(e) = Command::new("systemctl").args(["daemon-reload"]).status().await { + if let Err(e) = Command::new("systemctl") + .args(["daemon-reload"]) + .status() + .await + { tracing::warn!(error = ?e, "migration phase 5: systemctl daemon-reload failed"); } // Start the renamed container. - if let Err(e) = Command::new("nixos-container").args(["start", "h-root"]).status().await { + if let Err(e) = Command::new("nixos-container") + .args(["start", "h-root"]) + .status() + .await + { tracing::warn!(error = ?e, "migration phase 5: nixos-container start h-root failed"); return; } diff --git a/hive-c0re/src/operator_questions.rs b/hive-c0re/src/operator_questions.rs index 5440c115..428145e1 100644 --- a/hive-c0re/src/operator_questions.rs +++ b/hive-c0re/src/operator_questions.rs @@ -209,11 +209,7 @@ impl OperatorQuestions { /// /// Not the target — that's covered by `answer` (responding with /// an actual reply, sentinel or otherwise). - pub fn cancel( - &self, - id: i64, - canceller: &str, - ) -> Result<(String, String, Option)> { + pub fn cancel(&self, id: i64, canceller: &str) -> Result<(String, String, Option)> { let conn = self.conn.lock().unwrap(); let row: Option<(String, String, Option, Option)> = conn .query_row( @@ -232,9 +228,7 @@ impl OperatorQuestions { || canceller == hive_sh4re::OPERATOR_RECIPIENT || canceller == hive_sh4re::MANAGER_AGENT; if !authorised { - bail!( - "question {id}: '{canceller}' not allowed to cancel (asker = '{asker}')" - ); + bail!("question {id}: '{canceller}' not allowed to cancel (asker = '{asker}')"); } let sentinel = format!("[cancelled by {canceller}]"); conn.execute( @@ -289,7 +283,6 @@ impl OperatorQuestions { rows.collect::>>() .map_err(Into::into) } - } fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result { diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index b4e65a12..81ef6b44 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -7,7 +7,7 @@ //! a persistent connection. use anyhow::{Context as _, Result, bail}; -use hive_sh4re::priv_proto::{PRIV_SOCK, BindMount, PrivRequest, PrivResponse}; +use hive_sh4re::priv_proto::{BindMount, PRIV_SOCK, PrivRequest, PrivResponse}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -31,27 +31,49 @@ pub async fn call(req: &PrivRequest) -> Result { } pub async fn start_container(name: &str) -> Result<()> { - ok(call(&PrivRequest::StartContainer { name: name.to_owned() }).await?) + ok(call(&PrivRequest::StartContainer { + name: name.to_owned(), + }) + .await?) } pub async fn stop_container(name: &str) -> Result<()> { - ok(call(&PrivRequest::StopContainer { name: name.to_owned() }).await?) + ok(call(&PrivRequest::StopContainer { + name: name.to_owned(), + }) + .await?) } pub async fn kill_container(name: &str) -> Result<()> { - ok(call(&PrivRequest::KillContainer { name: name.to_owned() }).await?) + ok(call(&PrivRequest::KillContainer { + name: name.to_owned(), + }) + .await?) } pub async fn update_container(name: &str) -> Result<(String, String)> { - check(call(&PrivRequest::UpdateContainer { name: name.to_owned() }).await?) + check( + call(&PrivRequest::UpdateContainer { + name: name.to_owned(), + }) + .await?, + ) } pub async fn create_container(name: &str) -> Result<(String, String)> { - check(call(&PrivRequest::CreateContainer { name: name.to_owned() }).await?) + check( + call(&PrivRequest::CreateContainer { + name: name.to_owned(), + }) + .await?, + ) } pub async fn destroy_container(name: &str) -> Result<()> { - ok(call(&PrivRequest::DestroyContainer { name: name.to_owned() }).await?) + ok(call(&PrivRequest::DestroyContainer { + name: name.to_owned(), + }) + .await?) } pub async fn list_containers() -> Result { @@ -63,7 +85,8 @@ pub async fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result< ok(call(&PrivRequest::WriteNspawnFlags { container: container.to_owned(), binds: binds.to_vec(), - }).await?) + }) + .await?) } pub async fn write_resource_limits( @@ -75,13 +98,15 @@ pub async fn write_resource_limits( container: container.to_owned(), memory_max: memory_max.to_owned(), cpu_quota: cpu_quota.to_owned(), - }).await?) + }) + .await?) } pub async fn remove_service_dropin(container: &str) -> Result<()> { ok(call(&PrivRequest::RemoveServiceDropin { container: container.to_owned(), - }).await?) + }) + .await?) } pub async fn daemon_reload() -> Result<()> { @@ -97,21 +122,26 @@ pub async fn chown_socket_dir(agent_name: &str, uid: u32, gid: u32) -> Result<() agent_name: agent_name.to_owned(), uid, gid, - }).await?) + }) + .await?) } pub async fn chmod_socket_dir(agent_name: &str, mode: u32) -> Result<()> { ok(call(&PrivRequest::ChmodSocketDir { agent_name: agent_name.to_owned(), mode, - }).await?) + }) + .await?) } fn check(resp: PrivResponse) -> Result<(String, String)> { if resp.ok { Ok((resp.stdout, resp.stderr)) } else { - bail!("{}", resp.error.as_deref().unwrap_or("hive-priv returned error")) + bail!( + "{}", + resp.error.as_deref().unwrap_or("hive-priv returned error") + ) } } diff --git a/hive-c0re/src/questions.rs b/hive-c0re/src/questions.rs index 64ca7218..35eaa1c8 100644 --- a/hive-c0re/src/questions.rs +++ b/hive-c0re/src/questions.rs @@ -236,8 +236,7 @@ mod tests { // exact MANAGER_AGENT constant passes). assert!(check_approval_canceller_is_manager("").is_err()); assert!( - check_approval_canceller_is_manager(hive_sh4re::OPERATOR_RECIPIENT) - .is_err(), + check_approval_canceller_is_manager(hive_sh4re::OPERATOR_RECIPIENT).is_err(), "operator surface uses the dashboard cancel path, not this dispatcher", ); } diff --git a/hive-c0re/src/rebuild_queue.rs b/hive-c0re/src/rebuild_queue.rs index fd162e9e..fe8d090f 100644 --- a/hive-c0re/src/rebuild_queue.rs +++ b/hive-c0re/src/rebuild_queue.rs @@ -1228,4 +1228,3 @@ mod tests { ); } } - diff --git a/hive-c0re/src/reminder_scheduler.rs b/hive-c0re/src/reminder_scheduler.rs index 2f25ff71..a626d3d3 100644 --- a/hive-c0re/src/reminder_scheduler.rs +++ b/hive-c0re/src/reminder_scheduler.rs @@ -130,8 +130,7 @@ pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), let Some(parent) = host_path.parent() else { return Err("internal: host path has no parent".to_owned()); }; - std::fs::create_dir_all(parent) - .map_err(|e| format!("parent dir create failed: {e}"))?; + std::fs::create_dir_all(parent).map_err(|e| format!("parent dir create failed: {e}"))?; // Resolve symlinks in the parent chain, then re-verify the // canonical form still lives under the agent's host state root — // catches `ln -s /etc state/escape` style attacks. diff --git a/hive-c0re/src/scheduled_prompts_worker.rs b/hive-c0re/src/scheduled_prompts_worker.rs index 1028f231..03440cef 100644 --- a/hive-c0re/src/scheduled_prompts_worker.rs +++ b/hive-c0re/src/scheduled_prompts_worker.rs @@ -101,7 +101,10 @@ fn fire_schedule(coord: &Arc, schedule: &Schedule, now: i64) { // scheduled prompt from stacking up when an agent is slow or // briefly offline, while still allowing distinct scheduled // messages (different body) to enqueue independently. - match coord.broker.has_pending_with_body(target, "scheduled", &schedule.body) { + match coord + .broker + .has_pending_with_body(target, "scheduled", &schedule.body) + { Ok(true) => { tracing::debug!( schedule = schedule.id, @@ -384,4 +387,3 @@ async fn known_agents_async() -> std::collections::HashSet { } out } - diff --git a/hive-c0re/src/stats_vacuum.rs b/hive-c0re/src/stats_vacuum.rs index 54e7ec44..7072a319 100644 --- a/hive-c0re/src/stats_vacuum.rs +++ b/hive-c0re/src/stats_vacuum.rs @@ -39,8 +39,7 @@ pub fn spawn(coord: &Arc) { fn sweep_once() { for name in Coordinator::kept_state_names() { - let path = - Coordinator::agent_harness_dir(&name).join("hyperhive-turn-stats.sqlite"); + let path = Coordinator::agent_harness_dir(&name).join("hyperhive-turn-stats.sqlite"); if !path.exists() { continue; } @@ -60,7 +59,9 @@ fn vacuum_file(path: &Path) -> Result { .and_then(|d| i64::try_from(d.as_secs()).ok()) .unwrap_or(0); let cutoff = now - KEEP_SECS; - let removed = - conn.execute("DELETE FROM turn_stats WHERE started_at < ?1", params![cutoff])?; + let removed = conn.execute( + "DELETE FROM turn_stats WHERE started_at < ?1", + params![cutoff], + )?; Ok(u64::try_from(removed).unwrap_or(0)) } diff --git a/hive-c0re/src/topology.rs b/hive-c0re/src/topology.rs index d02d30bb..7f3e319f 100644 --- a/hive-c0re/src/topology.rs +++ b/hive-c0re/src/topology.rs @@ -59,10 +59,7 @@ pub fn children_of(name: &str) -> Vec { /// Pure form of [`children_of`] for unit tests. #[must_use] -pub fn children_of_in( - topo: &BTreeMap>, - name: &str, -) -> Vec { +pub fn children_of_in(topo: &BTreeMap>, name: &str) -> Vec { topo.iter() .filter_map(|(agent, parent)| { if parent.as_deref() == Some(name) { @@ -90,7 +87,13 @@ pub fn top_level_agents() -> Vec { #[must_use] pub fn top_level_agents_in(topo: &BTreeMap>) -> Vec { topo.iter() - .filter_map(|(name, parent)| if parent.is_none() { Some(name.clone()) } else { None }) + .filter_map(|(name, parent)| { + if parent.is_none() { + Some(name.clone()) + } else { + None + } + }) .collect() } @@ -501,8 +504,12 @@ mod tests { // `alice` who lives under the manager) would close the loop. // The general cycle walk catches this; no separate manager // guard needed. - let err = apply_set_parent(&topo_three_level(), crate::lifecycle::MANAGER_NAME, Some("bob")) - .unwrap_err(); + let err = apply_set_parent( + &topo_three_level(), + crate::lifecycle::MANAGER_NAME, + Some("bob"), + ) + .unwrap_err(); assert!(err.contains("cycle"), "err = {err}"); } @@ -678,20 +685,32 @@ mod tests { "alice".to_owned(), vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()], ); - assert!(has_role_in(&roles, "alice", ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS)); + assert!(has_role_in( + &roles, + "alice", + ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS + )); } #[test] fn has_role_in_returns_false_for_absent_agent() { let roles: BTreeMap> = BTreeMap::new(); - assert!(!has_role_in(&roles, "alice", ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS)); + assert!(!has_role_in( + &roles, + "alice", + ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS + )); } #[test] fn has_role_in_returns_false_for_empty_list() { let mut roles = BTreeMap::new(); roles.insert("alice".to_owned(), vec![]); - assert!(!has_role_in(&roles, "alice", ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS)); + assert!(!has_role_in( + &roles, + "alice", + ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS + )); } /// Revoking a role must leave the key present with an empty list so @@ -701,7 +720,10 @@ mod tests { let mgr = crate::lifecycle::MANAGER_NAME; // Build an in-memory roles map as set_role would see it after granting. let mut roles: BTreeMap> = BTreeMap::new(); - roles.insert(mgr.to_owned(), vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()]); + roles.insert( + mgr.to_owned(), + vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()], + ); // Simulate the revoke path of set_role (in-memory, no disk). let list = roles.entry(mgr.to_owned()).or_default(); @@ -727,7 +749,10 @@ mod tests { let mgr_present = agent_names.iter().any(|n| n == mgr); let should_seed = mgr_present && !roles.contains_key(mgr); // should_seed must be false because manager key is present (tombstone). - assert!(!should_seed, "reconcile_roles must not re-seed an explicit revoke"); + assert!( + !should_seed, + "reconcile_roles must not re-seed an explicit revoke" + ); } /// `reconcile_roles` seeds the manager on first appearance (no prior entry). diff --git a/hive-forge/Cargo.toml b/hive-forge/Cargo.toml index def1915e..3b4eb86c 100644 --- a/hive-forge/Cargo.toml +++ b/hive-forge/Cargo.toml @@ -10,7 +10,12 @@ path = "src/main.rs" [dependencies] anyhow = { workspace = true } clap = { workspace = true } -reqwest = { workspace = true, features = ["json", "rustls-tls", "blocking", "multipart"] } +reqwest = { workspace = true, features = [ + "json", + "rustls-tls", + "blocking", + "multipart", +] } serde = { workspace = true } serde_json = { workspace = true } diff --git a/hive-forge/src/body.rs b/hive-forge/src/body.rs index 39341ade..0025c572 100644 --- a/hive-forge/src/body.rs +++ b/hive-forge/src/body.rs @@ -23,8 +23,8 @@ pub fn resolve(body: Option<&str>, file: Option<&str>) -> Result> if path == "-" { return Ok(Some(read_stdin().context("read stdin for --body-file -")?)); } - let s = std::fs::read_to_string(path) - .with_context(|| format!("read --body-file {path}"))?; + let s = + std::fs::read_to_string(path).with_context(|| format!("read --body-file {path}"))?; return Ok(Some(s)); } if !std::io::stdin().is_terminal() { diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index 6e7d47e4..cff4c592 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -180,7 +180,12 @@ impl Client { let form = reqwest::blocking::multipart::Form::new() .file("attachment", file) .with_context(|| format!("read {}", file.display()))?; - let resp = self.http.post(&url).multipart(form).send().context("POST")?; + let resp = self + .http + .post(&url) + .multipart(form) + .send() + .context("POST")?; decode_json(resp, &format!("POST {url}")) } } diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index f8dcd7a5..07b7ae25 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -103,8 +103,7 @@ enum Verb { fn main() -> Result<()> { let cli = Cli::parse(); - let client = - client::Client::from_env(cli.repo, cli.json).context("initialize forge client")?; + let client = client::Client::from_env(cli.repo, cli.json).context("initialize forge client")?; match cli.verb { Verb::View(a) => verbs::view::run(&client, a), Verb::Issue(a) => verbs::issue::run(&client, a), diff --git a/hive-forge/src/verbs/comments.rs b/hive-forge/src/verbs/comments.rs index 944614d0..73c1a0bc 100644 --- a/hive-forge/src/verbs/comments.rs +++ b/hive-forge/src/verbs/comments.rs @@ -105,10 +105,7 @@ fn fetch_tail(client: &Client, repo: &str, number: u64, n: usize) -> Result Result<()> { let repo = client.repo(); - let diff = client.get_text(&format!("/repos/{repo}/pulls/{}.diff", args.number), "text/plain")?; + let diff = client.get_text( + &format!("/repos/{repo}/pulls/{}.diff", args.number), + "text/plain", + )?; let out = if args.full { diff } else { @@ -218,7 +221,9 @@ mod tests { assert!(is_autogenerated("flake.lock")); assert!(is_autogenerated("a/flake.lock")); assert!(is_autogenerated("hive-c0re/Cargo.lock")); - assert!(is_autogenerated("frontend/packages/dashboard/package-lock.json")); + assert!(is_autogenerated( + "frontend/packages/dashboard/package-lock.json" + )); assert!(!is_autogenerated("src/main.rs")); assert!(!is_autogenerated("Cargo.toml")); // Suffix-only files we deliberately don't match — keep @@ -346,10 +351,7 @@ index 1111..2222 100644 #[test] fn parse_diff_git_path_picks_b_side() { - assert_eq!( - parse_diff_git_path("a/foo b/foo"), - Some("foo".to_owned()) - ); + assert_eq!(parse_diff_git_path("a/foo b/foo"), Some("foo".to_owned())); assert_eq!( parse_diff_git_path("a/old.txt b/new.txt"), Some("new.txt".to_owned()) diff --git a/hive-forge/src/verbs/labels.rs b/hive-forge/src/verbs/labels.rs index a0346750..20db93b1 100644 --- a/hive-forge/src/verbs/labels.rs +++ b/hive-forge/src/verbs/labels.rs @@ -36,7 +36,8 @@ pub fn run(client: &Client, args: Args) -> Result<()> { let repo = client.repo(); match args.action.unwrap_or(Action::List) { Action::List => { - let labels = client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?; + let labels = + client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?; print_label_names(&labels); } Action::Add { labels } => { @@ -64,7 +65,8 @@ pub fn run(client: &Client, args: Args) -> Result<()> { ); } } - let labels = client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?; + let labels = + client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?; print_label_names(&labels); } } diff --git a/hive-forge/src/verbs/list.rs b/hive-forge/src/verbs/list.rs index fecc5036..49a78bc9 100644 --- a/hive-forge/src/verbs/list.rs +++ b/hive-forge/src/verbs/list.rs @@ -167,9 +167,7 @@ fn print_row(item: &Value) { // forge always populates `pull_request` but sets it to `null` for // issues; we check non-null specifically rather than just-present // (which would render every issue as a PR). - let is_pr = item - .get("pull_request") - .is_some_and(|v| !v.is_null()); + let is_pr = item.get("pull_request").is_some_and(|v| !v.is_null()); let kind = if is_pr { "PR" } else { " " }; println!("#{number:>4} {kind} [{author}] {title}"); } diff --git a/hive-forge/src/verbs/milestone.rs b/hive-forge/src/verbs/milestone.rs index 5ce9000a..ee92a28f 100644 --- a/hive-forge/src/verbs/milestone.rs +++ b/hive-forge/src/verbs/milestone.rs @@ -41,8 +41,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { let repo = client.repo(); match args.action.unwrap_or(Action::List) { Action::List => { - let v = - client.get_json(&format!("/repos/{repo}/milestones?state=open&limit=50"))?; + let v = client.get_json(&format!("/repos/{repo}/milestones?state=open&limit=50"))?; let trimmed: Vec = v .as_array() .map(|a| { diff --git a/hive-forge/src/verbs/pr_create.rs b/hive-forge/src/verbs/pr_create.rs index 92d1376f..a537a1c4 100644 --- a/hive-forge/src/verbs/pr_create.rs +++ b/hive-forge/src/verbs/pr_create.rs @@ -222,7 +222,9 @@ To http://localhost:3000/hyperhive/hyperhive.git\n\ assert!(is_pr_hint_opener( "remote: Create a new pull request for 'x':\n" )); - assert!(is_pr_hint_opener("remote: Visit the existing pull request:\n")); + assert!(is_pr_hint_opener( + "remote: Visit the existing pull request:\n" + )); assert!(!is_pr_hint_opener("remote: some other thing\n")); assert!(!is_pr_hint_opener("To http://example.com\n")); // Trailing-CRLF safety on windows-cloned forge clones. diff --git a/hive-forge/src/verbs/subscription.rs b/hive-forge/src/verbs/subscription.rs index 8fc16d63..f204f10f 100644 --- a/hive-forge/src/verbs/subscription.rs +++ b/hive-forge/src/verbs/subscription.rs @@ -29,7 +29,11 @@ pub fn run(client: &Client, args: Args) -> Result<()> { return Ok(()); } if args.watch || args.ignore { - let (subscribed, ignored) = if args.ignore { (false, true) } else { (true, false) }; + let (subscribed, ignored) = if args.ignore { + (false, true) + } else { + (true, false) + }; let resp = client.post_json( &format!("/repos/{repo}/subscription"), &json!({ "subscribed": subscribed, "ignored": ignored }), diff --git a/hive-forge/src/verbs/timeline.rs b/hive-forge/src/verbs/timeline.rs index 6c7cb288..e9c7cca6 100644 --- a/hive-forge/src/verbs/timeline.rs +++ b/hive-forge/src/verbs/timeline.rs @@ -170,7 +170,10 @@ fn format_event(ev: &Value) -> String { } } "commit_ref" => { - let sha = ev.get("ref_commit_sha").and_then(Value::as_str).unwrap_or(""); + let sha = ev + .get("ref_commit_sha") + .and_then(Value::as_str) + .unwrap_or(""); let short: String = sha.chars().take(7).collect(); if short.is_empty() { "referenced from a commit".to_owned() @@ -217,7 +220,10 @@ mod tests { "created_at": "2026-05-31T12:00:00Z", "body": "looks good to me", }); - assert_eq!(format_event(&ev), "**iris @ 2026-05-31T12:00:00Z**: looks good to me"); + assert_eq!( + format_event(&ev), + "**iris @ 2026-05-31T12:00:00Z**: looks good to me" + ); } #[test] diff --git a/hive-matrix-mcp/src/bin/mcp.rs b/hive-matrix-mcp/src/bin/mcp.rs index 427f30db..38f4f715 100644 --- a/hive-matrix-mcp/src/bin/mcp.rs +++ b/hive-matrix-mcp/src/bin/mcp.rs @@ -139,21 +139,25 @@ impl MatrixBridge { Returns the new event id." )] async fn send_message(&self, Parameters(args): Parameters) -> String { - render(round_trip(DaemonRequest::SendMessage { - room: args.room, - body: args.body, - }).await) + render( + round_trip(DaemonRequest::SendMessage { + room: args.room, + body: args.body, + }) + .await, + ) } - #[tool( - description = "Open (or reuse) a direct message room with `user_id` \ - (@user:server) and post `body` to it." - )] + #[tool(description = "Open (or reuse) a direct message room with `user_id` \ + (@user:server) and post `body` to it.")] async fn send_dm(&self, Parameters(args): Parameters) -> String { - render(round_trip(DaemonRequest::SendDm { - user_id: args.user_id, - body: args.body, - }).await) + render( + round_trip(DaemonRequest::SendDm { + user_id: args.user_id, + body: args.body, + }) + .await, + ) } #[tool( @@ -162,35 +166,40 @@ impl MatrixBridge { standard clients." )] async fn send_reaction(&self, Parameters(args): Parameters) -> String { - render(round_trip(DaemonRequest::SendReaction { - room: args.room, - event_id: args.event_id, - key: args.key, - }).await) + render( + round_trip(DaemonRequest::SendReaction { + room: args.room, + event_id: args.event_id, + key: args.key, + }) + .await, + ) } - #[tool( - description = "Reply to a specific matrix event in a room, threaded \ - via m.in_reply_to. Returns the reply's event id." - )] + #[tool(description = "Reply to a specific matrix event in a room, threaded \ + via m.in_reply_to. Returns the reply's event id.")] async fn send_reply(&self, Parameters(args): Parameters) -> String { - render(round_trip(DaemonRequest::SendReply { - room: args.room, - event_id: args.event_id, - body: args.body, - }).await) + render( + round_trip(DaemonRequest::SendReply { + room: args.room, + event_id: args.event_id, + body: args.body, + }) + .await, + ) } - #[tool( - description = "Mark a specific event as read for this agent. Updates \ + #[tool(description = "Mark a specific event as read for this agent. Updates \ the room's unread indicator + sends a read receipt other \ - participants can see." - )] + participants can see.")] async fn mark_read(&self, Parameters(args): Parameters) -> String { - render(round_trip(DaemonRequest::MarkRead { - room: args.room, - event_id: args.event_id, - }).await) + render( + round_trip(DaemonRequest::MarkRead { + room: args.room, + event_id: args.event_id, + }) + .await, + ) } #[tool( @@ -209,28 +218,27 @@ impl MatrixBridge { render(round_trip(DaemonRequest::ListRoomMembers { room: args.room }).await) } - #[tool( - description = "Read the most recent N events from a matrix room \ + #[tool(description = "Read the most recent N events from a matrix room \ (default 50, max 200). Returns each event's id, sender, timestamp, \ - type, and best-effort plain-text body." - )] + type, and best-effort plain-text body.")] async fn read_room(&self, Parameters(args): Parameters) -> String { - render(round_trip(DaemonRequest::ReadRoom { - room: args.room, - limit: args.limit, - }).await) + render( + round_trip(DaemonRequest::ReadRoom { + room: args.room, + limit: args.limit, + }) + .await, + ) } } -#[tool_handler( - instructions = "Matrix client for an agent on a hyperhive swarm. Use \ +#[tool_handler(instructions = "Matrix client for an agent on a hyperhive swarm. Use \ `send_message` to post in a joined room, `send_dm` to message a \ specific user, `send_reaction` to react with an emoji, `send_reply` \ to thread a reply, `mark_read` to acknowledge an event. Discover \ rooms with `list_rooms`, members with `list_room_members`, recent \ timeline with `read_room`. Room references accept ids (!abc:server) \ - or aliases (#name:server); user references use @user:server." -)] + or aliases (#name:server); user references use @user:server.")] impl ServerHandler for MatrixBridge {} #[tokio::main] @@ -258,7 +266,13 @@ async fn main() -> Result<()> { } let bridge = MatrixBridge::new(); - let service = bridge.serve(stdio()).await.context("serve MCP over stdio")?; - service.waiting().await.context("MCP service exited unexpectedly")?; + let service = bridge + .serve(stdio()) + .await + .context("serve MCP over stdio")?; + service + .waiting() + .await + .context("MCP service exited unexpectedly")?; Ok(()) } diff --git a/hive-matrix-mcp/src/client.rs b/hive-matrix-mcp/src/client.rs index 50bf92a3..97a6fc75 100644 --- a/hive-matrix-mcp/src/client.rs +++ b/hive-matrix-mcp/src/client.rs @@ -57,10 +57,7 @@ pub async fn build_and_restore( .trim() .to_owned(); if token.is_empty() { - return Err(anyhow!( - "matrix token at {} is empty", - token_file.display() - )); + return Err(anyhow!("matrix token at {} is empty", token_file.display())); } let (user_id, device_id) = whoami(homeserver, &token).await?; diff --git a/hive-matrix-mcp/src/handlers.rs b/hive-matrix-mcp/src/handlers.rs index 387b4726..98026452 100644 --- a/hive-matrix-mcp/src/handlers.rs +++ b/hive-matrix-mcp/src/handlers.rs @@ -16,8 +16,8 @@ use matrix_sdk::{ OwnedEventId, OwnedRoomId, OwnedUserId, RoomOrAliasId, api::client::receipt::create_receipt::v3::ReceiptType, events::{ - receipt::ReceiptThread, reaction::ReactionEventContent, + receipt::ReceiptThread, relation::Annotation, room::message::{MessageType, RoomMessageEventContent}, }, @@ -64,17 +64,20 @@ async fn resolve_room( client: &Client, reference: &str, ) -> Result { - let parsed: &RoomOrAliasId = reference.try_into().map_err(|e| { - DaemonResponse::error(format!("invalid room reference {reference}: {e}")) - })?; + let parsed: &RoomOrAliasId = reference + .try_into() + .map_err(|e| DaemonResponse::error(format!("invalid room reference {reference}: {e}")))?; let room_id: OwnedRoomId = if parsed.is_room_id() { OwnedRoomId::try_from(reference) .map_err(|e| DaemonResponse::error(format!("invalid room_id: {e}")))? } else { client - .resolve_room_alias(parsed.as_str().try_into().map_err(|e| { - DaemonResponse::error(format!("invalid alias: {e}")) - })?) + .resolve_room_alias( + parsed + .as_str() + .try_into() + .map_err(|e| DaemonResponse::error(format!("invalid alias: {e}")))?, + ) .await .map(|r| r.room_id) .map_err(|e| DaemonResponse::error(format!("resolve_room_alias {reference}: {e}")))? @@ -93,16 +96,14 @@ async fn resolve_room( fn extract_body(event: &matrix_sdk::ruma::events::AnyTimelineEvent) -> String { use matrix_sdk::ruma::events::{AnyMessageLikeEvent, AnyTimelineEvent}; match event { - AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(ev)) => { - ev.as_original().map_or_else(String::new, |orig| { - match &orig.content.msgtype { - MessageType::Text(t) => t.body.clone(), - MessageType::Notice(n) => n.body.clone(), - MessageType::Emote(e) => format!("* {}", e.body), - _ => String::new(), - } - }) - } + AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(ev)) => ev + .as_original() + .map_or_else(String::new, |orig| match &orig.content.msgtype { + MessageType::Text(t) => t.body.clone(), + MessageType::Notice(n) => n.body.clone(), + MessageType::Emote(e) => format!("* {}", e.body), + _ => String::new(), + }), _ => String::new(), } } @@ -128,14 +129,13 @@ pub async fn send_dm(client: &Client, user_id: &str, body: &str) -> DaemonRespon Err(e) => return DaemonResponse::error(format!("invalid user_id {user_id}: {e}")), }; // Find existing DM or create one. - let room = client - .joined_rooms() - .into_iter() - .find(|r| { - // is_direct() is async; check direct_targets() instead which - // reads from cached state. - r.direct_targets().iter().any(|t| t.as_str() == uid.as_str()) - }); + let room = client.joined_rooms().into_iter().find(|r| { + // is_direct() is async; check direct_targets() instead which + // reads from cached state. + r.direct_targets() + .iter() + .any(|t| t.as_str() == uid.as_str()) + }); let room = match room { Some(r) => r, None => match client.create_dm(&uid).await { @@ -268,16 +268,18 @@ pub async fn list_room_members(client: &Client, room_ref: &str) -> DaemonRespons } pub async fn read_room(client: &Client, room_ref: &str, limit: Option) -> DaemonResponse { - use matrix_sdk::ruma::api::client::message::get_message_events; use matrix_sdk::ruma::api::Direction; + use matrix_sdk::ruma::api::client::message::get_message_events; let room = match resolve_room(client, room_ref).await { Ok(r) => r, Err(e) => return e, }; let limit = limit.unwrap_or(50).min(200); - let mut req = get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward); - req.limit = matrix_sdk::ruma::UInt::try_from(limit as u64).unwrap_or(matrix_sdk::ruma::UInt::from(50u32)); + let mut req = + get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward); + req.limit = matrix_sdk::ruma::UInt::try_from(limit as u64) + .unwrap_or(matrix_sdk::ruma::UInt::from(50u32)); let resp = match client.send(req).await { Ok(r) => r, Err(e) => return DaemonResponse::error(format!("get_message_events: {e}")), diff --git a/hive-matrix-mcp/src/paths.rs b/hive-matrix-mcp/src/paths.rs index 3b020646..39913087 100644 --- a/hive-matrix-mcp/src/paths.rs +++ b/hive-matrix-mcp/src/paths.rs @@ -45,7 +45,8 @@ pub fn homeserver_url() -> String { /// `HIVE_MATRIX_SOCKET`; default is `/run/hive-matrix/socket`. #[must_use] pub fn daemon_socket() -> PathBuf { - std::env::var_os("HIVE_MATRIX_SOCKET").map_or_else(|| PathBuf::from(DEFAULT_DAEMON_SOCKET), PathBuf::from) + std::env::var_os("HIVE_MATRIX_SOCKET") + .map_or_else(|| PathBuf::from(DEFAULT_DAEMON_SOCKET), PathBuf::from) } /// Persistent sqlite store directory for matrix-sdk's state (event @@ -62,5 +63,6 @@ pub fn matrix_state_dir() -> PathBuf { /// Mirrors the path `forge_notify` writes to. #[must_use] pub fn hyperhive_socket() -> PathBuf { - std::env::var_os("HIVE_CONTROL_SOCKET").map_or_else(|| PathBuf::from("/run/hive/mcp.sock"), PathBuf::from) + std::env::var_os("HIVE_CONTROL_SOCKET") + .map_or_else(|| PathBuf::from("/run/hive/mcp.sock"), PathBuf::from) } diff --git a/hive-matrix-mcp/src/wake.rs b/hive-matrix-mcp/src/wake.rs index 39099157..34e09dcd 100644 --- a/hive-matrix-mcp/src/wake.rs +++ b/hive-matrix-mcp/src/wake.rs @@ -84,7 +84,10 @@ mod tests { #[test] fn format_wake_body_short_passes_through() { let body = format_wake_body("@iris:matrix.darkest.space", "#general", "hi all"); - assert_eq!(body, "[matrix] @iris:matrix.darkest.space in #general: hi all"); + assert_eq!( + body, + "[matrix] @iris:matrix.darkest.space in #general: hi all" + ); } #[test] diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 5c727a97..da557ec6 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -17,11 +17,13 @@ //! `LISTEN_FDS=1` + `LISTEN_PID=`, the inherited fd 3 is used //! instead of binding a fresh socket. -use std::fmt::Write as _; use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; -use hive_sh4re::priv_proto::{AGENT_PREFIX, MANAGER_NAME, META_DIR, PRIV_SOCK, SIBLING_CONTAINERS, BindMount, PrivRequest, PrivResponse}; +use hive_sh4re::priv_proto::{ + AGENT_PREFIX, BindMount, MANAGER_NAME, META_DIR, PRIV_SOCK, PrivRequest, PrivResponse, + SIBLING_CONTAINERS, +}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; use tokio::process::Command; @@ -33,8 +35,7 @@ const SOCKET_DIR_ROOT: &str = "/run/hive-agent"; async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "info".into()), + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), ) .init(); @@ -54,7 +55,6 @@ async fn main() -> Result<()> { } fn socket_listener() -> Result { - use std::os::unix::fs::PermissionsExt as _; // Socket activation: systemd passes the socket as fd 3 when // LISTEN_FDS >= 1 and LISTEN_PID matches our pid. let listen_fds: Option = std::env::var("LISTEN_FDS") @@ -64,32 +64,32 @@ fn socket_listener() -> Result { .ok() .and_then(|s| s.parse().ok()); - if let (Some(n), Some(p)) = (listen_fds, listen_pid) - && n >= 1 && p == std::process::id() - { - // SAFETY: systemd has passed us a ready UnixListener on fd 3. - let std_listener = unsafe { - use std::os::unix::io::FromRawFd; - std::os::unix::net::UnixListener::from_raw_fd(3) - }; - std_listener - .set_nonblocking(true) - .context("set socket non-blocking")?; - let listener = - tokio::net::UnixListener::from_std(std_listener).context("wrap systemd socket")?; - tracing::info!("using systemd-activated socket"); - return Ok(listener); + if let (Some(n), Some(p)) = (listen_fds, listen_pid) { + if n >= 1 && p == std::process::id() { + // SAFETY: systemd has passed us a ready UnixListener on fd 3. + let std_listener = unsafe { + use std::os::unix::io::FromRawFd; + std::os::unix::net::UnixListener::from_raw_fd(3) + }; + std_listener + .set_nonblocking(true) + .context("set socket non-blocking")?; + let listener = + tokio::net::UnixListener::from_std(std_listener).context("wrap systemd socket")?; + tracing::info!("using systemd-activated socket"); + return Ok(listener); + } } // Fallback: bind the socket ourselves. let path = Path::new(PRIV_SOCK); if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("create {}", parent.display()))?; + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; } let _ = std::fs::remove_file(path); let listener = UnixListener::bind(path).with_context(|| format!("bind {PRIV_SOCK}"))?; // Mode 0660: only the hive-core group can connect. + use std::os::unix::fs::PermissionsExt as _; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660)) .context("chmod priv.sock")?; tracing::info!(path = PRIV_SOCK, "bound priv socket"); @@ -138,7 +138,6 @@ async fn dispatch(line: &str) -> PrivResponse { } /// Execute a validated `PrivRequest`. Returns `(stdout, stderr)` on success. -#[allow(clippy::too_many_lines)] async fn exec(req: PrivRequest) -> Result<(String, String)> { match req { PrivRequest::StartContainer { ref name } => { @@ -159,13 +158,25 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { PrivRequest::UpdateContainer { ref name } => { validate_container_name(name)?; let flake_ref = agent_flake_ref(name); - container_run(&["update", &container_system_name(name), "--flake", &flake_ref]).await + container_run(&[ + "update", + &container_system_name(name), + "--flake", + &flake_ref, + ]) + .await } PrivRequest::CreateContainer { ref name } => { validate_container_name(name)?; let flake_ref = agent_flake_ref(name); - container_run(&["create", &container_system_name(name), "--flake", &flake_ref]).await + container_run(&[ + "create", + &container_system_name(name), + "--flake", + &flake_ref, + ]) + .await } PrivRequest::DestroyContainer { ref name } => { @@ -175,7 +186,10 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { PrivRequest::ListContainers => container_run(&["list"]).await, - PrivRequest::WriteNspawnFlags { ref container, ref binds } => { + PrivRequest::WriteNspawnFlags { + ref container, + ref binds, + } => { validate_container_system_name(container)?; for bind in binds { validate_bind_path(&bind.host_path)?; @@ -203,8 +217,7 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { validate_container_system_name(container)?; let dir = format!("/run/systemd/system/container@{container}.service.d"); if Path::new(&dir).exists() { - std::fs::remove_dir_all(&dir) - .with_context(|| format!("remove {dir}"))?; + std::fs::remove_dir_all(&dir).with_context(|| format!("remove {dir}"))?; } Ok((String::new(), String::new())) } @@ -227,7 +240,14 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { PrivRequest::ReloadGatewayNginx => { let out = Command::new("systemd-run") - .args(["--machine=hive-gateway", "--quiet", "--", "nginx", "-s", "reload"]) + .args([ + "--machine=hive-gateway", + "--quiet", + "--", + "nginx", + "-s", + "reload", + ]) .output() .await .context("invoke systemd-run for gateway nginx reload")?; @@ -241,7 +261,11 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { Ok((String::new(), String::new())) } - PrivRequest::ChownSocketDir { ref agent_name, uid, gid } => { + PrivRequest::ChownSocketDir { + ref agent_name, + uid, + gid, + } => { validate_agent_name(agent_name)?; let path = socket_dir_path(agent_name); std::os::unix::fs::chown(&path, Some(uid), Some(gid)) @@ -249,10 +273,13 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { Ok((String::new(), String::new())) } - PrivRequest::ChmodSocketDir { ref agent_name, mode } => { - use std::os::unix::fs::PermissionsExt as _; + PrivRequest::ChmodSocketDir { + ref agent_name, + mode, + } => { validate_agent_name(agent_name)?; let path = socket_dir_path(agent_name); + use std::os::unix::fs::PermissionsExt as _; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)) .with_context(|| format!("chmod {:o} {}", mode, path.display()))?; Ok((String::new(), String::new())) @@ -361,7 +388,9 @@ fn agent_flake_ref(name: &str) -> String { fn validate_bind_path(path: &str) -> Result<()> { if path.is_empty() || !path.starts_with('/') - || path.bytes().any(|b| b == 0 || b == b'\n' || b == b'"' || b == b':') + || path + .bytes() + .any(|b| b == 0 || b == b'\n' || b == b'"' || b == b':') { bail!( "invalid bind path {path:?}: must be an absolute path with no colons, newlines, null bytes, or double-quotes" @@ -376,8 +405,7 @@ fn validate_bind_path(path: &str) -> Result<()> { /// then appends `EXTRA_NSPAWN_FLAGS=""`. fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> { let path = format!("/etc/nixos-containers/{container}.conf"); - let original = std::fs::read_to_string(&path) - .with_context(|| format!("read {path}"))?; + let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?; let lines: Vec<&str> = original .lines() .filter(|line| { @@ -401,11 +429,14 @@ fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> { out.push_str("HOST_ADDRESS6=\n"); out.push_str("LOCAL_ADDRESS6=\n"); out.push_str("HOST_BRIDGE=\n"); - let flags: Vec = binds.iter().map(|b| { - let flag = if b.read_only { "--bind-ro" } else { "--bind" }; - format!("{flag}={}:{}", b.host_path, b.container_path) - }).collect(); + let flags: Vec = binds + .iter() + .map(|b| { + let flag = if b.read_only { "--bind-ro" } else { "--bind" }; + format!("{flag}={}:{}", b.host_path, b.container_path) + }) + .collect(); let flags_joined = flags.join(" "); - writeln!(out, "EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"").unwrap(); + out.push_str(&format!("EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"\n")); std::fs::write(&path, out).with_context(|| format!("write {path}")) } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 31b0576c..d0b53c04 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -463,7 +463,6 @@ pub enum Request { }, // ---- privileged (manager socket only for now) --------------------------- - /// *(privileged)* Initialise a brand-new agent's proposed config repo /// and queue an approval for the operator to review. RequestInitConfig { @@ -724,7 +723,6 @@ pub enum HelperEvent { }, } - /// Submission payload for `RequestSchedulePrompt`. Lives outside the /// enum so it can also serialize into the approval row's `commit_ref` /// (the dispatcher re-parses it on approve and inserts the schedule). @@ -976,4 +974,3 @@ pub struct WireScheduleTarget { #[serde(default, skip_serializing_if = "Option::is_none")] pub last_result: Option, } - diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 37ebf686..2bc9b87e 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -42,7 +42,6 @@ pub struct BindMount { #[serde(tag = "op", rename_all = "snake_case")] pub enum PrivRequest { // --- Container lifecycle --- - /// `nixos-container start ` StartContainer { name: String }, @@ -67,11 +66,13 @@ pub enum PrivRequest { ListContainers, // --- Config file writes --- - /// Update `/etc/nixos-containers/.conf`: strip network-isolation /// vars, force `PRIVATE_NETWORK=0`, and set `EXTRA_NSPAWN_FLAGS` from the /// provided bind-mount list. Written by `lifecycle::set_nspawn_flags`. - WriteNspawnFlags { container: String, binds: Vec }, + WriteNspawnFlags { + container: String, + binds: Vec, + }, /// Write `/run/systemd/system/container@.service.d/hyperhive-limits.conf` /// with `[Service]\nMemoryMax=\nCPUQuota=\n`. @@ -87,7 +88,6 @@ pub enum PrivRequest { RemoveServiceDropin { container: String }, // --- System --- - /// Run `systemctl daemon-reload`. DaemonReload, @@ -96,10 +96,13 @@ pub enum PrivRequest { ReloadGatewayNginx, // --- Socket dir ownership --- - /// Set ownership of `/run/hive-agent//` to `uid:gid`. /// Called by `lifecycle::set_nspawn_flags` after `create_dir_all`. - ChownSocketDir { agent_name: String, uid: u32, gid: u32 }, + ChownSocketDir { + agent_name: String, + uid: u32, + gid: u32, + }, /// Set mode of `/run/hive-agent//`. /// Fallback when uid lookup returns `None` on first spawn. diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index 995edf49..a70daee2 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -213,7 +213,8 @@ in ''; htpasswdFile = lib.mkOption { - type = lib.types.path; + type = lib.types.nullOr lib.types.path; + default = null; example = "/etc/hyperhive/gateway.htpasswd"; description = '' Path on the **host** to an htpasswd-format file whose @@ -254,16 +255,15 @@ in ''; } { - assertion = !cfg.auth.enable || cfg.auth ? htpasswdFile; + assertion = !cfg.auth.enable || cfg.auth.htpasswdFile != null; message = '' services.hyperhive.gateway.auth.enable = true requires services.hyperhive.gateway.auth.htpasswdFile to be set. - Create an htpasswd file with: htpasswd -Bc /path/to/file + Create an htpasswd file with: hivectl gateway create-user --file /path/to/file ''; } ]; - # Ensure bind-mount sources exist at host boot before the gateway # container's first start. nspawn would auto-create missing dirs # tmpfiles rules make the intent explicit @@ -316,7 +316,7 @@ in # Using the parent directory (not the file itself) because nspawn # bind-mounts need a pre-existing destination — binding a directory # is always safe; nginx picks the file up by name inside. - bindMounts."/run/gateway-auth" = lib.mkIf cfg.auth.enable { + bindMounts."/run/gateway-auth" = lib.mkIf (cfg.auth.enable && cfg.auth.htpasswdFile != null) { hostPath = builtins.dirOf cfg.auth.htpasswdFile; isReadOnly = true; }; @@ -583,7 +583,7 @@ in extraConfig = '' proxy_buffering off; proxy_read_timeout 1d; - ${lib.optionalString cfg.auth.enable '' + ${lib.optionalString (cfg.auth.enable && cfg.auth.htpasswdFile != null) '' auth_basic "${cfg.auth.realm}"; auth_basic_user_file /run/gateway-auth/${builtins.baseNameOf cfg.auth.htpasswdFile}; ''} From 6b6289c19131a2e6a1e72a8b6f09e9f6d464b816 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 1 Jun 2026 23:04:21 +0200 Subject: [PATCH 4/4] fix(gateway): point option descriptions at hivectl instead of raw htpasswd --- nix/modules/hive-gateway.nix | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index a70daee2..9925aeb9 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -207,9 +207,8 @@ in `auth_basic` module handles credential validation — no extra service or host-side daemon required. Off by default. - Create the file with: `htpasswd -Bc /path/to/gateway.htpasswd ` - (BCrypt recommended; `-c` creates a new file). Subsequent users: - `htpasswd -B /path/to/gateway.htpasswd `. + Manage users with `hivectl gateway create-user`, `delete-user`, + and `list-users` — see `hivectl gateway --help` for usage. ''; htpasswdFile = lib.mkOption { @@ -223,8 +222,9 @@ in container at `/run/gateway-auth/`. The file must be readable by the `nginx` user inside the container (mode 0644 recommended). - Create with: `htpasswd -Bc `. BCrypt (`-B`) is - strongly preferred over the legacy MD5/SHA1 algorithms. + Manage with: `hivectl gateway create-user --file `. + BCrypt (cost 12) is used by default; no external `htpasswd` binary + required. Required when `enable = true`. '';