Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-c0re/src/dashboard/extra_forges.rs
iris 5869f34390 dashboard: fix stale nix/modules path in extra_forges.rs doc comment
nix/modules/ was restructured into nix/host-modules/ + nix/agent-modules/
before the extra-forges branch merged; this doc-only fix from PR #2422
(e53f70ac) landed after the merge went through and got dropped. Reapplying
directly against main.
2026-07-14 19:00:03 +02:00

205 lines
7.8 KiB
Rust

//! Dashboard-driven external (non-internal) Forgejo/Gitea/Codeberg-compatible
//! forge accounts, per agent. Entirely dashboard-provisioned — there is no
//! host-side nix config for these (see `nix/host-modules/hive-forge/`'s
//! removed `extraForges` option). The operator manually creates a token on
//! the external forge themselves (however that forge lets them: PAT UI, a
//! teammate with admin, whatever) and pastes a label + base URL + token into
//! the dashboard's FORGES tab, same shape as the GitHub PAT flow
//! (`post_github_account`) plus the homeserver field from the matrix extra-
//! account flow (`post_matrix_account_login`).
//!
//! No remote account minting, no admin API, no revoke-on-the-remote-side —
//! this module only ever touches the *local* agent state dir. hive-c0re
//! persists the token to `<state>/forge-<label>-token` (0600) and the base
//! URL to a `<state>/forge-<label>.json` sidecar (not secret, but kept next
//! to the token so both survive together) via hive-priv. Listing derives the
//! configured set from those files, mirroring `matrix_accounts.rs`'s
//! filename-scan approach — there is no separate "catalog" now that there's
//! no nix config to enumerate.
use std::path::Path;
use axum::extract::{Form, Query};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use super::error_response;
use crate::coordinator::Coordinator;
/// Plain-identifier check matching hive-priv's `validate_name_chars`
/// (lowercase ascii + digits + hyphens) — same guard used by
/// `matrix_accounts::is_plain_ident`. Duplicated locally (private, not
/// worth a shared-util churn for one predicate) rather than exported from
/// that module, since both call sites are dashboard-only.
fn is_plain_ident(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
#[derive(Deserialize)]
struct ForgeSidecar {
base_url: String,
}
/// Read the base URL an agent stashed for `label` from its
/// `forge-<label>.json` sidecar. `None` if the sidecar is missing or
/// unparseable (e.g. a token file left over from a partial/older write).
fn read_base_url(dir: &Path, label: &str) -> Option<String> {
let s = std::fs::read_to_string(dir.join(format!("forge-{label}.json"))).ok()?;
serde_json::from_str::<ForgeSidecar>(&s)
.ok()
.map(|s| s.base_url)
}
#[derive(Serialize)]
struct ExtraForgeAccount {
label: String,
base_url: Option<String>,
}
#[derive(Serialize)]
struct ExtraForgesResponse {
forges: Vec<ExtraForgeAccount>,
}
#[derive(Deserialize)]
pub(super) struct ExtraForgesQuery {
agent: String,
}
/// `GET /api/extra-forges?agent=<name>` — list the external forge accounts
/// currently provisioned for `agent`, derived from every `forge-<label>-
/// token` file in its state dir (mirrors `matrix_accounts.rs`'s filename-scan
/// listing). `base_url` is backfilled from the matching `forge-<label>.json`
/// sidecar when present. Never returns a token.
pub(super) async fn get_extra_forges(Query(q): Query<ExtraForgesQuery>) -> Response {
let agent = q.agent.trim();
if !is_plain_ident(agent) {
return error_response(&format!("extra-forges: invalid agent {agent:?}"));
}
let dir = Coordinator::agent_notes_dir(agent);
let mut forges = Vec::new();
match std::fs::read_dir(&dir) {
Ok(entries) => {
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|ft| ft.is_file()) {
continue;
}
let fname = entry.file_name();
let Some(fname) = fname.to_str() else {
continue;
};
// `forge-token` (no suffix) is the mandatory internal forge —
// not one of these dashboard-provisioned extra accounts.
let Some(label) = fname
.strip_prefix("forge-")
.and_then(|s| s.strip_suffix("-token"))
else {
continue;
};
if label.is_empty() {
continue;
}
forges.push(ExtraForgeAccount {
base_url: read_base_url(&dir, label),
label: label.to_owned(),
});
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return error_response(&format!("extra-forges: read {}: {e}", dir.display()));
}
}
forges.sort_by(|a, b| a.label.cmp(&b.label));
axum::Json(ExtraForgesResponse { forges }).into_response()
}
/// Form body for `POST /api/extra-forge-account` (urlencoded, the
/// dashboard's mutation convention). `action` is `"add"` (needs `base_url` +
/// `token`) or `"remove"`.
#[derive(Deserialize)]
pub(super) struct ExtraForgeAccountForm {
agent: String,
label: String,
action: String,
base_url: Option<String>,
token: Option<String>,
}
#[derive(Serialize)]
struct ExtraForgeAccountResult {
ok: bool,
}
/// `POST /api/extra-forge-account` — add persists the operator-pasted
/// label/base-URL/token to the agent's state dir via hive-priv; remove
/// deletes both files. Purely local — no remote account creation or
/// revocation, there is no admin access assumed on the external forge.
/// Operator-authenticated (dashboard). Never echoes the token back.
pub(super) async fn post_extra_forge_account(Form(f): Form<ExtraForgeAccountForm>) -> Response {
let agent = f.agent.trim();
let label = f.label.trim();
if !is_plain_ident(agent) {
return error_response(&format!("extra-forge-account: invalid agent {agent:?}"));
}
if !is_plain_ident(label) {
return error_response(&format!("extra-forge-account: invalid label {label:?}"));
}
match f.action.as_str() {
"add" => {
let base_url = f.base_url.as_deref().unwrap_or_default().trim();
let base_url = base_url.trim_end_matches('/');
if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
return error_response(&format!(
"extra-forge-account: base_url must be an http(s) URL, got {base_url:?}"
));
}
let Some(token) = f.token.as_deref().filter(|t| !t.is_empty()) else {
return error_response("extra-forge-account: token is required");
};
if let Err(e) =
crate::priv_client::write_agent_extra_forge_account(agent, label, base_url, token)
.await
{
return error_response(&format!(
"extra-forge-account: write account failed: {e:#}"
));
}
tracing::info!(%agent, %label, "extra-forge-account: provisioned");
}
"remove" => {
if let Err(e) = crate::priv_client::delete_agent_extra_forge_account(agent, label).await
{
return error_response(&format!(
"extra-forge-account: delete account failed: {e:#}"
));
}
tracing::info!(%agent, %label, "extra-forge-account: removed");
}
other => {
return error_response(&format!(
"extra-forge-account: unknown action {other:?} (want add|remove)"
));
}
}
axum::Json(ExtraForgeAccountResult { ok: true }).into_response()
}
#[cfg(test)]
mod tests {
use super::is_plain_ident;
#[test]
fn is_plain_ident_matches_validate_name_chars() {
assert!(is_plain_ident("codeberg"));
assert!(is_plain_ident("my-forge-1"));
assert!(!is_plain_ident(""));
assert!(!is_plain_ident("MyForge"));
assert!(!is_plain_ident("my_forge"));
assert!(!is_plain_ident("../escape"));
assert!(!is_plain_ident("a/b"));
}
}