//! The bootstrap `core` admin user: its account, access-token lifecycle //! and avatar upload. The typed API-client constructor + `forgejo admin` //! helpers live in the module root (`super`). use std::path::Path; use anyhow::{Context, Result}; use base64::Engine; use forgejo_api::structs::UpdateUserAvatarOption; use forgejo_api::{ApiErrorKind, ForgejoError}; use reqwest::StatusCode; use super::{api, bounded, forge_admin}; const TOKEN_NAME_PREFIX: &str = "hyperhive"; // Where the host-side `core` admin token lives. Used by hive-c0re itself // to push the meta repo + drive admin API calls. Root-only. Literal lives // in `crate::paths`; aliased here under the long-standing name. use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH; // The `forge/core-avatar-set` marker lives in `crate::paths` — a one-shot // guard: the upload runs once, the marker is written, subsequent startups // skip. Delete it to force a re-upload. // The avatar PNG path is resolved by `core_avatar_png_path` below — // only-used-here, so it lives in this module rather than the shared // `hive_sh4re::assets` helpers (which now hold only `prompt_template`, the // one asset path every crate needs — the agent icon, the last other one, moved off the // shared-asset model entirely; see `hive-agent::web_ui::screen`). /// `$HIVE_ASSETS_DIR/branding/hyperhive.png` — the core mark, /// rasterised. Not independently configurable — override the whole /// `services.hyperhive.c0re.assets` package to change it. fn core_avatar_png_path() -> std::path::PathBuf { let dir = std::env::var("HIVE_ASSETS_DIR").expect( "HIVE_ASSETS_DIR is unset — the hyperhive NixOS module sets it from \ services.hyperhive.c0re.assets on the hive-c0re unit, so this \ process was started outside that unit", ); std::path::PathBuf::from(dir).join("branding/hyperhive.png") } /// Bootstrap `core` token scopes — the same set as swarm-controller's /// `AGENT_TOKEN_SCOPES`, with no admin scope: nothing in hive-c0re calls /// `/api/v1/admin/*`. See `docs/integrations/forge.md::Token scopes`. const CORE_TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"; /// Pull the access token out of forgejo's success message. Format /// has shifted across versions (table form vs. "Access token was /// successfully created: ``"), so just hunt the output for the /// first long hex-looking word. fn extract_token(output: &str) -> Option { output .split(|c: char| c.is_whitespace() || c == ',' || c == ':') .find(|w| w.len() >= 32 && w.chars().all(|c| c.is_ascii_hexdigit())) .map(str::to_owned) } /// Canonical email address for a hive agent's Forgejo account. /// Must match the `user.email` set by `meta::render_flake` so commits /// by the agent link back to their Forgejo profile page. fn agent_email(name: &str) -> String { format!("{name}@hyperhive.local") } /// Ensure a forgejo user named `name` exists. Idempotent: forgejo /// returns a "user already exists" error which we treat as success. /// `admin` adds `--admin` (site admin) — used for the bootstrap /// `core` user that drives the API. The password is random and never /// read: the account authenticates by token. async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> { let email = agent_email(name); let mut args = vec![ "user", "create", "--username", name, "--email", &email, "--random-password", "--must-change-password=false", ]; if admin { args.push("--admin"); } let result = forge_admin(&args).await; match result { Ok(_) => { tracing::info!(%name, "forge: created user"); Ok(()) } Err(e) => { // Forgejo's "already exists" error wording varies; just // try the next step and let token issuance surface a // real failure if the user truly isn't there. let msg = format!("{e:#}"); if msg.contains("already exists") || msg.contains("user already") { tracing::debug!(%name, "forge: user already exists"); Ok(()) } else { tracing::warn!(%name, error = %msg, "forge: user create unclear; trying token anyway"); Ok(()) } } } } /// Mint a fresh access token for `name`. Token name is suffixed with /// a monotonic clock so re-issuing doesn't collide with an existing /// token of the same name in the DB. `scopes` is the scope string /// passed to `forgejo admin user generate-access-token --scopes`; /// the bootstrap `core` user, its one caller, passes `CORE_TOKEN_SCOPES`. async fn mint_token(name: &str, scopes: &str) -> Result { let token_name = format!( "{TOKEN_NAME_PREFIX}-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_secs()) ); let stdout = forge_admin(&[ "user", "generate-access-token", "--username", name, "--token-name", &token_name, "--scopes", scopes, ]) .await?; // Deliberately does NOT include `stdout`: on this path forgejo has // already minted a live token and printed it, and an error string // propagates into logs the same way any other message does. The // shape of the output is enough to diagnose a version drift. let token = extract_token(&stdout).with_context(|| { format!( "no token-shaped word (>= 32 hex chars) in forgejo output \ ({} bytes, {} lines); output withheld, it carries the token", stdout.len(), stdout.lines().count() ) })?; tracing::debug!(%name, %token_name, "forge: minted access token"); Ok(token) } /// Mint a fresh Forgejo access token for the `core` admin user and /// write it directly to `path`. Unlike agent tokens this path is owned /// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct /// write is both correct and necessary (no priv round-trip). async fn mint_and_persist_core_token(path: &Path) -> Result<()> { let token = mint_token("core", CORE_TOKEN_SCOPES).await?; crate::secret_file::write_secret_0600(path, format!("{token}\n").as_bytes()) .with_context(|| format!("write core token to {}", path.display()))?; tracing::info!(path = %path.display(), "forge: persisted core access token"); Ok(()) } /// Set `core`'s Forgejo avatar to the hyperhive logo once, then /// remember it so subsequent startups don't re-upload. Best-effort /// — any non-2xx is logged at the caller; the project runs fine /// with the default hash identicon. pub(super) async fn ensure_core_avatar(token: &str) -> Result<()> { let marker = crate::paths::forge_core_avatar_marker(); if marker.exists() { return Ok(()); } let png_path = core_avatar_png_path(); let png_bytes = tokio::fs::read(&png_path) .await .with_context(|| format!("read core avatar PNG from {}", png_path.display()))?; // The raw-HTTP predecessor POSTed the admin endpoint // `/admin/users/core/avatar`, which forgejo-api has no method for. // `token` IS the core user's own token though, so updating "the // current user's avatar" (`POST /user/avatar`) is behaviorally // identical. bounded( "set core avatar", api(token)?.user_update_avatar(UpdateUserAvatarOption { image: Some(base64::engine::general_purpose::STANDARD.encode(&png_bytes)), }), ) .await? .context("set core avatar")?; if let Err(e) = write_marker(&marker) { tracing::warn!(marker = %marker.display(), error = ?e, "forge: could not write the avatar marker; the upload repeats on the next start"); } tracing::info!("forge: set core user avatar to hyperhive logo"); Ok(()) } /// Create the one-shot marker file, and its directory if needed. fn write_marker(marker: &Path) -> std::io::Result<()> { if let Some(parent) = marker.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(marker, "") } /// Outcome of probing whether the persisted core token still works /// against the *current* forge. Existence on disk is not validity: a /// token minted before a forge rebuild / re-provision is unknown to the /// new forge's DB and 401s on every call. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CoreTokenCheck { /// Token authenticated successfully — keep using it. Valid, /// Forge explicitly rejected the token (401/403) — re-mint. Invalid, /// Couldn't determine (forge unreachable / 5xx). Don't re-mint on a /// transient: keep the existing token and let a later ensure pass /// re-check once the forge is responsive. Re-minting here would both /// fail (mint needs the forge too) and churn tokens needlessly. Indeterminate, } /// Map a failed token-probe call to a [`CoreTokenCheck`]. Only a /// definitive auth rejection (401/403 — surfaced by the typed client /// as `Unauthorized`/`Forbidden` API errors, or defensively as bare /// `UnexpectedStatusCode`s) is `Invalid`; anything else (transport, /// 5xx, unexpected shapes) is `Indeterminate`. Pure so the decision /// logic is unit-testable without a live forge. fn classify_core_token_error(e: &ForgejoError) -> CoreTokenCheck { match e { ForgejoError::ApiError(api) => match api.error_kind() { ApiErrorKind::Unauthorized | ApiErrorKind::Forbidden => CoreTokenCheck::Invalid, _ => CoreTokenCheck::Indeterminate, }, ForgejoError::UnexpectedStatusCode(s) if *s == StatusCode::UNAUTHORIZED || *s == StatusCode::FORBIDDEN => { CoreTokenCheck::Invalid } _ => CoreTokenCheck::Indeterminate, } } /// Probe whether `token` is still accepted by the current forge with a /// cheap authenticated `user_get_current` (`GET /api/v1/user`, covered /// by the core token's `read:user` scope). See [`CoreTokenCheck`] for /// how the outcome is interpreted. async fn check_core_token(token: &str) -> CoreTokenCheck { let Ok(client) = api(token) else { return CoreTokenCheck::Indeterminate; }; let Ok(probe) = bounded("probe core token", client.user_get_current()).await else { tracing::debug!("forge: core-token probe timed out; treating as indeterminate"); return CoreTokenCheck::Indeterminate; }; match probe { Ok(_) => CoreTokenCheck::Valid, Err(e) => { let outcome = classify_core_token_error(&e); if outcome == CoreTokenCheck::Indeterminate { tracing::debug!( error = %e, "forge: core-token probe inconclusive (unreachable / unexpected response); \ treating as indeterminate" ); } outcome } } } /// Ensure the bootstrap `core` admin user + a token at /// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo /// API calls (meta-repo push, collaborator grants). /// Returns the token. /// /// Idempotent, but validity-aware: when a token file is already present /// it is **probed against the current forge** before being trusted. A /// token persisted before a forge rebuild / re-provision is stale (the /// new forge DB doesn't know it) and would 401 every caller — so on a /// definitive rejection the token is re-minted. A merely-unreachable /// forge leaves the existing token in place (a later ensure pass /// re-checks) rather than churning tokens on a transient. pub(super) async fn ensure_core_user_and_token() -> Result { let path = std::path::Path::new(CORE_TOKEN_PATH); if let Ok(existing) = std::fs::read_to_string(path) { let trimmed = existing.trim().to_owned(); if !trimmed.is_empty() { match check_core_token(&trimmed).await { CoreTokenCheck::Valid | CoreTokenCheck::Indeterminate => return Ok(trimmed), CoreTokenCheck::Invalid => { tracing::warn!( path = %path.display(), "forge: persisted core token rejected by forge (stale after rebuild?); \ re-minting" ); } } } } ensure_user_exists("core", true).await?; mint_and_persist_core_token(path).await?; let raw = std::fs::read_to_string(path) .with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?; Ok(raw.trim().to_owned()) } /// Read the persisted core token, or None when the forge isn't /// seeded yet. Cheap — just a file read. pub fn core_token() -> Option { std::fs::read_to_string(CORE_TOKEN_PATH) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) } #[cfg(test)] mod tests { use super::{CoreTokenCheck, classify_core_token_error, write_marker}; use forgejo_api::{ApiError, ApiErrorKind, ForgejoError}; use reqwest::StatusCode; #[test] fn write_marker_creates_the_missing_parent_dir() { let dir = tempfile::tempdir().expect("tempdir"); let marker = dir.path().join("not-yet").join("nested").join("avatar-set"); write_marker(&marker).expect("write"); assert!(marker.is_file()); } /// A parent that is a regular file makes `create_dir_all` fail for any /// uid, root included. #[test] fn write_marker_reports_a_path_it_cannot_create() { let dir = tempfile::tempdir().expect("tempdir"); let file = dir.path().join("a-file"); std::fs::write(&file, "").expect("file"); assert!(write_marker(&file.join("sub").join("avatar-set")).is_err()); } fn api_err(kind: ApiErrorKind) -> ForgejoError { ForgejoError::ApiError(ApiError { message: None, kind, }) } #[test] fn auth_rejection_errors_are_invalid() { // The whole point: a stale token (forge rebuilt out from under it) // 401s, and 401/403 are the only outcomes that trigger a re-mint. assert_eq!( classify_core_token_error(&api_err(ApiErrorKind::Unauthorized)), CoreTokenCheck::Invalid ); assert_eq!( classify_core_token_error(&api_err(ApiErrorKind::Forbidden)), CoreTokenCheck::Invalid ); // Defensive: the same statuses arriving as bare status codes // (endpoint spec didn't list them) must classify identically. for s in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] { assert_eq!( classify_core_token_error(&ForgejoError::UnexpectedStatusCode(s)), CoreTokenCheck::Invalid, "status {s} should be invalid" ); } } #[test] fn transient_and_unexpected_errors_are_indeterminate() { // Never re-mint on a transient — minting needs the forge too, and // churning tokens on a blip is worse than keeping the existing one. for s in [ StatusCode::INTERNAL_SERVER_ERROR, StatusCode::BAD_GATEWAY, StatusCode::SERVICE_UNAVAILABLE, StatusCode::GATEWAY_TIMEOUT, StatusCode::NOT_FOUND, ] { assert_eq!( classify_core_token_error(&ForgejoError::UnexpectedStatusCode(s)), CoreTokenCheck::Indeterminate, "status {s} should be indeterminate" ); } assert_eq!( classify_core_token_error(&api_err(ApiErrorKind::NotFound { errors: None })), CoreTokenCheck::Indeterminate ); assert_eq!( classify_core_token_error(&api_err(ApiErrorKind::Generic)), CoreTokenCheck::Indeterminate ); } }