141 lines
5.8 KiB
Rust
141 lines
5.8 KiB
Rust
//! 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://<forge.domain>`).
|
|
///
|
|
/// # 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<String> = 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/<pid>/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/<pid>/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/<pid>/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 `<login>:<token>`, 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}"
|
|
);
|
|
}
|
|
}
|