refactor(hive-c0re): split forge into submodules

mod.rs keeps the shared admin/http helpers + ensure_all/sync_agent;
user/token provisioning, repo/org/mirror ops, and the trust-boundary
PR-merge primitives move to users.rs / repos.rs / pr_merge.rs
This commit is contained in:
müde 2026-07-06 22:08:22 +02:00
commit a17015f01e
4 changed files with 1468 additions and 1409 deletions

View file

@ -0,0 +1,534 @@
//! Per-agent Forgejo user + access-token provisioning, account
//! policy (email alignment, repo-creation lockdown), avatar uploads,
//! and the bootstrap `core` admin user + token lifecycle. Shared
//! HTTP / `forgejo admin` helpers live in the module root (`super`).
use std::path::Path;
use anyhow::{Context, Result};
use base64::Engine;
use reqwest::StatusCode;
use super::{CONFIG_ORG, FORGE_HTTP, forge_admin, forge_http, is_present};
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 (org
/// creation, future webhook setup, etc.). Root-only.
const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token";
// Forge provisioning markers (`forge/core-avatar-set`,
// `forge/agent-configs-avatar-set`, `forge/email-aligned-<name>`) live
// in `crate::paths` — one-shot guards: the upload/align runs once, the
// marker is written, subsequent startups skip. Delete one to force its
// step to re-run.
// Avatar PNGs are loaded at runtime from
// `$HIVE_ASSETS_DIR/branding/{hyperhive,agent-configs}.png` via the
// helpers in `hive_sh4re::assets`. The `agent-configs.png` is
// rendered from its SVG during the `hyperhive-assets` derivation's
// build.
/// Per-agent token scopes (broad-but-not-admin). See
/// `docs/forge.md::Token scopes` for the per-scope rationale.
const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
/// Bootstrap `core` token scopes — adds `read:admin,write:admin` on
/// top of `TOKEN_SCOPES` so the host daemon can drive
/// `/api/v1/admin/*`. Site-admin membership alone isn't enough: the
/// token's own scope gate runs before the user-permission check.
/// See `docs/forge.md::Token scopes`.
const CORE_TOKEN_SCOPES: &str = "read:admin,write:admin,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: <hex>"), so just hunt the output for the
/// first long hex-looking word.
fn extract_token(output: &str) -> Option<String> {
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. `password` picks the initial
/// account password: `None` uses `--random-password` (the existing
/// agent provisioning shape — the password is never read, agents auth
/// by token); `Some(pw)` uses `--password <pw>` so the operator path
/// in `hivectl` can set a real password for matrix-style web-UI login.
async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> Result<()> {
let email = agent_email(name);
let mut args = vec!["user", "create", "--username", name, "--email", &email];
match password {
Some(pw) => args.extend(["--password", pw, "--must-change-password=false"]),
None => args.extend(["--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(())
}
}
}
}
/// Set the forgejo password for an existing user. Used by the operator
/// path in `hivectl forge create-user --password` so re-running on an
/// already-created account still updates the password (covers the
/// "I forgot the password I set last week" case + the "argus retried
/// the verb to verify the fix" case — `forgejo admin user create`
/// silently skips a password change once the account exists). Idempotent
/// 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,
];
forge_admin(&args)
.await
.with_context(|| format!("forgejo admin user change-password {name}"))?;
tracing::info!(%name, "forge: changed user password");
Ok(())
}
/// Idempotently align the Forgejo account email to `agent_email(name)`.
/// Existing agents were created with `{name}@hive.local`; this corrects
/// that so git commits (which use `{name}@hyperhive`) link to profiles.
/// Best-effort: failures are warned, not propagated.
///
/// Marker-guarded: writes `EMAIL_ALIGNED_MARKER_PREFIX{name}` on first
/// success and skips the PATCH on all subsequent calls. This prevents
/// Forgejo's admin-user-edit endpoint from resetting `use_custom_avatar`
/// on every `sync_agent` tick. Delete the marker to force re-alignment.
///
/// Uses the admin REST API (`PATCH /api/v1/admin/users/{name}`) rather
/// than `forgejo admin user edit` because the CLI dropped the `edit`
/// subcommand somewhere between forgejo 8 and current. Body includes
/// `login_name` (required by Forgejo's `EditUserOption` validator) and
/// `source_id = 0` (local auth, the default for users hive-c0re creates).
pub(super) async fn ensure_user_email(name: &str) {
let marker = crate::paths::forge_email_aligned_marker(name);
if marker.exists() {
return;
}
let Some(token) = core_token() else {
tracing::debug!(%name, "forge: skipping ensure_user_email — no core token yet");
return;
};
let email = agent_email(name);
// `login_name` is required by Forgejo's EditUserOption validator.
// Omitting it caused Forgejo to reset use_custom_avatar on each call.
let body = format!(r#"{{"email":"{email}","login_name":"{name}","source_id":0}}"#);
let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}");
match forge_http(reqwest::Method::PATCH, &url, &token, &body).await {
Ok((status, _)) if status.is_success() => {
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&marker, "").ok();
tracing::info!(%name, %email, "forge: user email aligned");
}
Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => {
// Core token missing admin scope — see
// `docs/forge.md::Token scopes` migration note.
tracing::warn!(
%name, %email, %status,
"forge: PATCH user email forbidden — core token likely missing admin scope. \
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
);
}
Ok((status, _)) => {
tracing::warn!(%name, %email, %status, "forge: PATCH user email returned non-success");
}
Err(e) => tracing::warn!(%name, error = %e, "forge: PATCH user email transport error"),
}
}
/// Disable direct repo creation for agent `name` by setting
/// `max_repo_creation = 0` on its Forgejo account. Agents must
/// create repos *through hive-c0re* (which owns the perms), never with
/// their own token — a write-scoped token can otherwise create + own
/// repos and self-merge, bypassing the operator-only-merge policy.
///
/// `max_repo_creation = 0` means `CanCreateRepo()` is false for any
/// count (Forgejo: `MaxRepoCreation >= 0 && NumRepos >= MaxRepoCreation`),
/// so creation is refused while push / PR / clone stay intact. **Existing
/// repos are untouched** — this only blocks *new* direct creation.
///
/// Marker-guarded like [`ensure_user_email`]: the PATCH runs once per
/// agent (delete the marker to re-apply). Body carries `login_name` +
/// `source_id` for the same reason `ensure_user_email` does — omitting
/// `login_name` makes Forgejo's `EditUserOption` validator reset
/// `use_custom_avatar`. Best-effort: failures warn, don't propagate.
pub(super) async fn ensure_repo_creation_disabled(name: &str) {
let marker = crate::paths::forge_repo_creation_disabled_marker(name);
if marker.exists() {
return;
}
let Some(token) = core_token() else {
tracing::debug!(%name, "forge: skipping ensure_repo_creation_disabled — no core token yet");
return;
};
let body = format!(r#"{{"login_name":"{name}","source_id":0,"max_repo_creation":0}}"#);
let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}");
match forge_http(reqwest::Method::PATCH, &url, &token, &body).await {
Ok((status, _)) if status.is_success() => {
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&marker, "").ok();
tracing::info!(%name, "forge: disabled direct repo creation (max_repo_creation=0)");
}
Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => {
tracing::warn!(
%name, %status,
"forge: PATCH max_repo_creation forbidden — core token likely missing admin scope. \
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
);
}
Ok((status, _)) => {
tracing::warn!(%name, %status, "forge: PATCH max_repo_creation returned non-success");
}
Err(e) => {
tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation transport error");
}
}
}
/// 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`;
/// use `TOKEN_SCOPES` for agents, `CORE_TOKEN_SCOPES` for the
/// bootstrap `core` user.
async fn mint_token(name: &str, scopes: &str) -> Result<String> {
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?;
let token = extract_token(&stdout)
.with_context(|| format!("parse token from forgejo output: {stdout:?}"))?;
tracing::debug!(%name, %token_name, "forge: minted access token");
Ok(token)
}
/// Mint a fresh Forgejo access token for an agent and write it to the
/// agent's state dir via hive-priv. hive-c0re runs unprivileged and
/// cannot write to agent-owned (0755) state directories directly.
async fn mint_and_persist_agent_token(name: &str) -> Result<()> {
let token = mint_token(name, TOKEN_SCOPES).await?;
crate::priv_client::write_agent_forge_token(name, &token)
.await
.with_context(|| format!("write forge-token for {name} via hive-priv"))
}
/// 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<()> {
use std::os::unix::fs::PermissionsExt;
let token = mint_token("core", CORE_TOKEN_SCOPES).await?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(path, format!("{token}\n"))
.with_context(|| format!("write core token to {}", path.display()))?;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
tracing::info!(path = %path.display(), "forge: persisted core access token");
Ok(())
}
/// Ensure `name` has a forgejo user + token file. Always re-mints the
/// token so the on-disk file always reflects the current `TOKEN_SCOPES`.
/// Safe to call on every spawn and on every hive-c0re startup.
pub async fn ensure_user_for(name: &str) -> Result<()> {
if !is_present().await {
return Ok(());
}
ensure_user_exists(name, false, None).await?;
ensure_user_email(name).await;
mint_and_persist_agent_token(name).await
}
/// Provision a forgejo user for `name` and return the freshly-minted
/// token. Unlike [`ensure_user_for`], the token is **not** persisted to
/// disk — the caller is responsible for storing it. Used by `hivectl
/// forge create-user` for human (non-agent) accounts so we don't create
/// stray `/var/lib/hyperhive/agents/<name>/` directories for users that
/// aren't agents.
///
/// `password` picks the account password. `None` keeps the existing
/// random-throwaway shape (caller doesn't need web UI access — token
/// alone is enough). `Some(pw)` sets `pw` as the password, including
/// running `forgejo admin user change-password` if the account already
/// exists, so the operator can log into the forge web UI afterwards.
/// Idempotent: re-running with the same `Some(pw)` lands on the same
/// final state.
pub async fn provision_user_token(name: &str, password: Option<&str>) -> Result<String> {
if !is_present().await {
anyhow::bail!(
"hive-forge container not running — wait for hive-c0re to start it before provisioning forge users"
);
}
ensure_user_exists(name, false, password).await?;
if let Some(pw) = password {
// `user create` silently no-ops on an existing account, so
// we run change-password unconditionally when the caller
// asked for a specific password — keeps the verb idempotent
// for "set or reset" use.
change_user_password(name, pw).await?;
}
ensure_user_email(name).await;
mint_token(name, TOKEN_SCOPES).await
}
/// 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 = hive_sh4re::assets::core_avatar_png();
let png_bytes = tokio::fs::read(&png_path)
.await
.with_context(|| format!("read core avatar PNG from {}", png_path.display()))?;
let body = format!(
r#"{{"image":"{}"}}"#,
base64::engine::general_purpose::STANDARD.encode(&png_bytes),
);
let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar");
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
if !status.is_success() {
anyhow::bail!("set core avatar: HTTP {status}");
}
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(marker, "").ok();
tracing::info!("forge: set core user avatar to hyperhive logo");
Ok(())
}
/// Set the `agent-configs` org's Forgejo avatar to the
/// configs-stack glyph once. Sibling to `ensure_core_avatar`:
/// one-shot, marker-guarded, best-effort. Forgejo's per-org avatar
/// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG
/// JSON body — same shape as the admin user endpoint above.
pub(super) async fn ensure_config_org_avatar(token: &str) -> Result<()> {
let marker = crate::paths::forge_config_org_avatar_marker();
if marker.exists() {
return Ok(());
}
let png_path = hive_sh4re::assets::config_org_avatar_png();
let png_bytes = tokio::fs::read(&png_path)
.await
.with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?;
let body = format!(
r#"{{"image":"{}"}}"#,
base64::engine::general_purpose::STANDARD.encode(&png_bytes),
);
let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar");
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
if !status.is_success() {
anyhow::bail!("set {CONFIG_ORG} avatar: HTTP {status}");
}
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(marker, "").ok();
tracing::info!(
org = CONFIG_ORG,
"forge: set org avatar to configs-stack logo"
);
Ok(())
}
/// 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 — which silently breaks the
/// hive-ci runner-registration prefetch (it reads this same token to
/// fetch a runner registration token).
#[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 the HTTP status of the token-probe call to a [`CoreTokenCheck`].
/// Pure so the decision logic is unit-testable without a live forge.
fn classify_core_token_status(status: StatusCode) -> CoreTokenCheck {
if status.is_success() {
CoreTokenCheck::Valid
} else if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
CoreTokenCheck::Invalid
} else {
CoreTokenCheck::Indeterminate
}
}
/// Probe whether `token` is still accepted by the current forge with a
/// cheap authenticated `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 url = format!("{FORGE_HTTP}/api/v1/user");
match forge_http(reqwest::Method::GET, &url, token, "").await {
Ok((status, _)) => classify_core_token_status(status),
Err(e) => {
tracing::debug!(
error = %e,
"forge: core-token probe could not reach forge; treating as indeterminate"
);
CoreTokenCheck::Indeterminate
}
}
}
/// Ensure the bootstrap `core` admin user + a token at
/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo
/// API calls (org creation, meta-repo push, and the hive-ci
/// runner-registration prefetch). 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<String> {
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, None).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<String> {
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_status};
use reqwest::StatusCode;
#[test]
fn success_statuses_are_valid() {
assert_eq!(
classify_core_token_status(StatusCode::OK),
CoreTokenCheck::Valid
);
assert_eq!(
classify_core_token_status(StatusCode::NO_CONTENT),
CoreTokenCheck::Valid
);
}
#[test]
fn auth_rejection_statuses_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_status(StatusCode::UNAUTHORIZED),
CoreTokenCheck::Invalid
);
assert_eq!(
classify_core_token_status(StatusCode::FORBIDDEN),
CoreTokenCheck::Invalid
);
}
#[test]
fn transient_and_unexpected_statuses_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_status(s),
CoreTokenCheck::Indeterminate,
"status {s} should be indeterminate"
);
}
}
}