Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/forge_account.rs
atlas 92559af8e7 gateway: serve the hive dashboard on <hive>.hives.<swarm> behind authelia, drop htpasswd
The dashboard, its API, the swagger UI and the agent routes move to their
own vhost, `swarm.dashboardDomain` = `<hive>.hives.<swarm.domain>`, under
the swarm domain so authelia's session cookie reaches it. `/`, `/api/` and
`/api/docs/` ask authelia through `auth_request`, and authelia's
`operatorsOnly` rule for each hive's dashboard name lets only the admins
group in. The hive domain keeps matrix discovery and redirects every other
path to the new name.

The name rides as a SAN on the hive's own gateway leaf: the per-hive bao
role allows it, and hive-tls asks for it and checks coverage.

HTTP Basic auth is gone: the htpasswd writer and its test, the
`hivectl gateway` verbs and host-socket requests, the `gateway.auth`
options (now a removed-option error), the unauthorized page and the bcrypt
dependency. tmpfiles deletes a leftover gateway.htpasswd, which held
bcrypt hashes at 0644.

c0re hands out dashboard and agent URLs on the new name
(HYPERHIVE_DASHBOARD_DOMAIN), and the swarm controller's hive roster
carries each hive's dashboard name for the swarm UI's hive list.

Closes #3469
2026-10-15 21:44:27 +02:00

245 lines
9.6 KiB
Rust

//! An agent's accounts on external forges: an operator hands us a base URL and
//! a token, we put them in the swarm's secret store.
//!
//! The agent end is `nix/agent-modules/forge-accounts.nix`, which lists the
//! agent's accounts and reads each under the agent's own certificate, and writes
//! the files `hive-forge -f <label>` reads. No hive is in the path.
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use swarm_secret_client::forge;
use utoipa::ToSchema;
use super::linked_accounts::link;
use super::{AppState, error_problem, swarm_hive};
/// The account to store for one agent's external forge.
///
/// No `Debug` derive: this carries a token.
#[derive(Deserialize, ToSchema)]
pub struct PutForgeAccountRequest {
/// The forge's base URL, `http://` or `https://`. A trailing slash is
/// dropped.
#[schema(example = "https://forge.example.org")]
url: String,
/// The access token. Never logged, and never returned by this route.
token: String,
}
/// `put_forge_account`'s success body.
#[derive(Debug, Serialize, ToSchema)]
pub struct PutForgeAccountResponse {
/// The base URL as stored.
url: String,
}
/// Store an agent's external forge account, unless one is stored under the
/// label already.
#[utoipa::path(
put,
path = "/api/hives/{hive}/agents/{agent}/forge-accounts/{label}",
params(
("hive" = String, Path, description = "hive the agent runs on"),
("agent" = String, Path, description = "agent the account belongs to"),
("label" = String, Path, description = "the name the agent passes to `hive-forge -f`"),
),
request_body = PutForgeAccountRequest,
responses(
(status = 200, description = "stored", body = PutForgeAccountResponse),
(status = 400, description = "the agent or label is not an identifier, the URL is not http(s), the token is empty, or the hive is not in this swarm (problem+json)", body = String),
(status = 409, description = "an account is stored under the label already; nothing was written (problem+json)", body = String),
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
),
tag = "agents"
)]
pub async fn put_forge_account(
State(state): State<AppState>,
axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>,
Json(req): Json<PutForgeAccountRequest>,
) -> Result<Json<PutForgeAccountResponse>, problem_details::ProblemDetails> {
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
// `hive-forge` accepts only `[a-z0-9-]` after `-f`, narrower than the
// store's charset, so a label it would refuse is refused here.
let label = hive_types::Ident::parse(&label)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let secret_path = forge::account_path(&agent, &label)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
let account = account(req).map_err(|e| error_problem(StatusCode::BAD_REQUEST, e))?;
let store = state.accounts.open().await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
link(&store, &secret_path, &account).await.map_err(|e| {
// The path names the agent and the label; the value is not in it.
tracing::warn!(path = %secret_path, error = ?e, "linking the forge account failed");
e.problem(&existing(&agent, &label))
})?;
tracing::info!(%hive, %agent, %label, url = %account.url, "forge account stored");
Ok(Json(PutForgeAccountResponse { url: account.url }))
}
/// The account a refused link names.
fn existing(agent: &str, label: &str) -> String {
format!("agent {agent} already has forge account {label:?}")
}
/// The request as it is stored, or why it cannot be.
fn account(req: PutForgeAccountRequest) -> Result<forge::Account, &'static str> {
let url = req.url.trim().trim_end_matches('/');
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err("url must start with http:// or https://");
}
if req.token.trim().is_empty() {
return Err("token is required");
}
Ok(forge::Account {
value: req.token,
url: url.to_owned(),
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::super::linked_accounts::{Accounts, tests::FakeStore};
use super::{PutForgeAccountRequest, account};
fn request(url: &str, token: &str) -> PutForgeAccountRequest {
PutForgeAccountRequest {
url: url.to_owned(),
token: token.to_owned(),
}
}
#[test]
fn the_url_loses_its_trailing_slash_and_the_token_is_kept() {
let a = account(request("https://forge.example.org/ ", "t0k3n")).expect("valid");
assert_eq!(a.url, "https://forge.example.org");
assert_eq!(a.value, "t0k3n");
}
#[test]
fn a_url_that_is_not_http_is_refused() {
assert!(account(request("forge.example.org", "t")).is_err());
assert!(account(request("file:///etc/passwd", "t")).is_err());
// The control: plain http is accepted.
assert!(account(request("http://forge.lan", "t")).is_ok());
}
#[test]
fn an_empty_token_is_refused() {
assert!(account(request("https://forge.example.org", " ")).is_err());
}
/// Bare-minimum `AppState`, as `matrix_account`'s tests build it.
fn state(store: &Arc<FakeStore>) -> super::super::AppState {
super::super::AppState {
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),
}]),
links: std::sync::Arc::new(Vec::new()),
status: None,
wanted: None,
agent_status: None,
agent_icons: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
))),
webhook_secret: None,
config_prs: std::sync::Arc::default(),
swarm_name: None,
auth: None,
forge: std::sync::Arc::default(),
create_gate: std::sync::Arc::default(),
accounts: Accounts::Fake(Arc::clone(store)),
try_outs: super::super::review_classifier::TryOuts::default(),
reviews: std::sync::Arc::default(),
runs: crate::review_classifier::runs::Runs::Live(None),
alerts: None,
}
}
async fn put(
store: &Arc<FakeStore>,
label: &str,
) -> Result<axum::Json<super::PutForgeAccountResponse>, problem_details::ProblemDetails> {
super::put_forge_account(
axum::extract::State(state(store)),
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), label.to_owned())),
axum::Json(request("https://forge.example.org", "t0k3n")),
)
.await
}
/// A label `hive-forge -f` could not name is refused before the store.
#[tokio::test]
async fn a_label_hive_forge_cannot_name_is_refused_before_the_store() {
let store = Arc::default();
for bad in ["MyForge", "my_forge", "../x"] {
let problem = put(&store, bad).await.expect_err("refused");
assert_eq!(
problem.status,
Some(axum::http::StatusCode::BAD_REQUEST),
"{bad}: {problem:?}"
);
}
assert!(store.written().is_empty());
}
/// The control: a plain label is written.
#[tokio::test]
async fn a_plain_label_reaches_the_store() {
let store = Arc::default();
let stored = put(&store, "myforge").await.expect("nothing is stored");
assert_eq!(stored.url, "https://forge.example.org");
assert_eq!(store.written(), ["swarm/agents/atlas/forge/myforge"]);
}
#[tokio::test]
async fn linking_a_label_twice_is_a_409_and_the_first_account_stays() {
use super::super::linked_accounts::{AccountStore, link};
use swarm_secret_client::forge;
let store = FakeStore::default();
let path = forge::account_path("atlas", "myforge").expect("a valid path");
let first = account(request("https://forge.example.org", "t0k3n-first")).expect("valid");
let second = account(request("https://forge.lan", "t0k3n-second")).expect("valid");
link(&store, &path, &first)
.await
.expect("nothing is stored");
let problem = link(&store, &path, &second)
.await
.expect_err("an account is stored")
.problem(&super::existing("atlas", "myforge"));
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
let detail = problem.detail.expect("a detail");
assert!(
detail.contains(r#"agent atlas already has forge account "myforge""#),
"{detail}"
);
assert_eq!(store.written(), std::slice::from_ref(&path));
let kept: forge::Account = store
.read_optional(&path)
.await
.expect("store answers")
.expect("still stored");
assert_eq!(kept.url, "https://forge.example.org");
assert_eq!(kept.value, "t0k3n-first");
}
}