//! The hive's forge access: reads of the forge go through [`reader_token`] on //! every hive. The //! swarm-wide orgs, teams and repos, and each agent's read grant on //! `internal/docs`, are swarm-controller's. Full design: //! `docs/integrations/forge.md`. mod reader; pub use reader::reader_token; use std::sync::OnceLock; use anyhow::{Context, Result}; use url::Url; const FORGE_CONTAINER: &str = "hive-forge"; /// Base HTTP URL for the local Forgejo instance, from `HIVE_FORGE_URL` /// (set unconditionally by the hyperhive NixOS module on the `hive-c0re` /// unit, to `http://`). /// /// # Panics /// /// When `HIVE_FORGE_URL` is unset (`validate_env` rejects that at startup, so /// the daemon never reaches this). That is deliberate: this daemon only /// runs under the NixOS module, which always sets it, so an unset var /// means the deployment is broken. There is no loopback fallback, /// because a guess is wrong in exactly the cases that matter — the /// forge may live on a different host from the daemon, and a fallback /// turns "misconfigured" into "silently talking to the wrong machine" /// or, worse, "connection refused" surfacing far from its cause. pub(crate) fn forge_http_base() -> &'static str { static BASE: OnceLock = OnceLock::new(); BASE.get_or_init(|| { std::env::var("HIVE_FORGE_URL").expect( "HIVE_FORGE_URL is unset — the hyperhive NixOS module sets it \ unconditionally on the hive-c0re unit, so this process was \ started outside that unit", ) }) } /// Check the env the forge integration cannot run without: `HIVE_FORGE_URL` /// set and a valid URL. Called once from `main` before any worker is /// spawned, so a missing or malformed value fails the unit start naming the /// variable; the lazy accessors then never hit their `expect`s. pub(crate) fn validate_env() -> Result<()> { let url = std::env::var("HIVE_FORGE_URL").context( "HIVE_FORGE_URL is unset (the hyperhive NixOS module sets it on the hive-c0re unit)", )?; Url::parse(&url).with_context(|| format!("HIVE_FORGE_URL is not a valid URL: {url:?}"))?; Ok(()) } /// Git remote for `repo` (e.g. `"hyperhive/hyperhive"`) — **credential-free**. /// /// The token does not go here. A URL is a process argument, and `argv` is /// world-readable through `/proc//cmdline` for as long as the git child /// lives, so a credentialed remote publishes the forge token to every /// local user on the host. Credentials travel in the environment instead, via /// [`basic_auth_header`] and [`crate::lifecycle::git_command_authed`] — /// `/proc//environ` is owner-only. pub(crate) fn forge_git_url(repo: &str) -> String { git_url_with_base(forge_http_base(), repo) } /// The pure half of [`forge_git_url`], split out so it can be tested without a /// process-wide env var (which would race every other test in this binary). fn git_url_with_base(base: &str, repo: &str) -> String { format!("{base}/{repo}.git") } /// The `http.extraHeader` value carrying `token`. Forgejo takes the account /// from the token, so `login` only labels the request. /// /// Basic auth over a header rather than userinfo in the URL, so the secret /// reaches git through the environment (see [`forge_git_url`]). Pair with /// [`crate::lifecycle::git_command_authed`], which is the only thing that /// should ever hold the result. fn basic_auth_header(login: &str, token: &str) -> String { use base64::Engine as _; let basic = base64::engine::general_purpose::STANDARD.encode(format!("{login}:{token}")); format!("Authorization: Basic {basic}") } /// Probe whether `hive-forge` exists as a nixos-container. Cheap — /// `nixos-container list` is just a directory scan in /etc. Routed /// through hive-priv: `nixos-container` needs root, and hive-c0re runs /// unprivileged (privsep). pub async fn is_present() -> bool { let Ok(stdout) = crate::priv_client::list_containers().await else { return false; }; stdout.lines().any(|l| l.trim() == FORGE_CONTAINER) } #[cfg(test)] mod tests { use super::git_url_with_base; /// The remote carries **no credential**. `argv` is world-readable through /// `/proc//cmdline`, so a token spliced in here would be published to /// every local user for the life of the git child. /// /// Deliberately not via `forge_git_url`, which reads `HIVE_FORGE_URL` — /// setting that here would race every other test in this binary. #[test] fn forge_git_url_carries_no_credential() { let url = git_url_with_base("http://forge.example.test", "a/iris"); assert_eq!(url, "http://forge.example.test/a/iris.git"); assert!(!url.contains('@'), "no userinfo: {url}"); } #[test] fn forge_git_url_preserves_https() { // The scheme is carried through rather than assumed: a swarm // whose forge is behind TLS must not be downgraded to http. let url = git_url_with_base("https://forge.example.test", "a/iris"); assert!(url.starts_with("https://"), "https must survive: {url}"); } /// The credential goes in an `Authorization` header instead — decodable /// back to `:`, so the swap is genuinely equivalent auth and /// not a silent downgrade to anonymous. #[test] fn basic_auth_header_is_basic_login_token() { use base64::Engine as _; let header = super::basic_auth_header("hive-alpha", "s3cret"); let b64 = header .strip_prefix("Authorization: Basic ") .expect("basic auth header"); let decoded = base64::engine::general_purpose::STANDARD .decode(b64) .expect("valid base64"); assert_eq!(String::from_utf8_lossy(&decoded), "hive-alpha:s3cret"); assert!( !header.contains("s3cret"), "token not in the clear: {header}" ); } }