//! Page: CI runners (`/admin/runners`). Instance admins only; anyone else gets 404. //! //! Lists the registered runners with their labels, last contact and state, and offers disable/enable //! and delete for each. "Create registration token" is a POST with a CSRF token: it answers with a page //! that shows the one-time token and the exact `forge runner register` command, and that page is the //! only place the token ever appears. The list never shows a token. Creating a token and deleting a //! runner are audited; the token itself is never written to the audit log. use forge_core::AuditAction; use forge_core::ci::CiRunner; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, see_other}; use topcoat::router::request::uri; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::check_csrf; use crate::i18n::admin::{self as g, K}; use crate::i18n::{self, Key, Lang}; use crate::pages::admin_users::admin_only; use crate::pages::repo_mirrors::stamp_in_tz; use crate::state::AppState; use crate::views::{enc, shell}; path_param!(runner_id); /// Notice words (the only text a `?notice=` query can show), their text key, and whether they are errors. const NOTICES: [(&str, K, bool); 5] = [ ("disabled", K::RunnerDisabledNotice, false), ("enabled", K::RunnerEnabledNotice, false), ("deleted", K::RunnerDeletedNotice, false), ("missing", K::NoRunnerWithId, true), ("token-failed", K::TokenCreateFailed, true), ]; #[derive(Deserialize)] struct CsrfForm { #[serde(default)] csrf: String, } #[derive(Deserialize)] struct DisableForm { #[serde(default)] csrf: String, #[serde(default)] disabled: String, } /// One runner row, already formatted. struct RunnerRow { name: String, labels: String, last_seen: String, disabled: bool, toggle_action: String, delete_action: String, } fn runner_row(runner: &CiRunner, tz: &str, lang: Lang) -> RunnerRow { RunnerRow { name: runner.name.clone(), labels: runner.labels.join(", "), last_seen: runner .last_seen_at .as_deref() .map(|stamp| stamp_in_tz(stamp, tz)) .unwrap_or_else(|| i18n::t(lang, Key::Never).to_owned()), disabled: runner.disabled, toggle_action: format!("/admin/runners/{}/disabled", enc(&runner.id)), delete_action: format!("/admin/runners/{}/delete", enc(&runner.id)), } } /// The notice from `?notice=`. Anything else is ignored. fn notice_from_query(cx: &Cx, lang: Lang) -> Option<(&'static str, bool)> { let query = uri(cx).query()?; let word = form_urlencoded::parse(query.as_bytes()) .find(|(key, _)| key == "notice") .map(|(_, value)| value.into_owned())?; NOTICES .iter() .find(|(name, _, _)| *name == word) .map(|(_, key, is_error)| (g::t(lang, *key), *is_error)) } fn back_to_list(notice: &str) -> SeeOther { see_other(format!("/admin/runners?notice={}", enc(notice))) } /// The command a new runner runs. The base URL comes from the server config. fn register_command(base_url: &str, token: &str) -> String { format!( "forge runner register --url {} --token {token} --name --labels linux", base_url.trim_end_matches('/') ) } #[component] async fn runners_nav(lang: Lang) -> Result { Ok(view! {

(g::t(lang, K::Users)) " · " (g::t(lang, K::AuditLog)) " · " (g::t(lang, K::Backups)) " · " (g::t(lang, K::Replication)) " · " (g::t(lang, K::Mail)) " · " (g::t(lang, K::Runners))

}) } #[component] async fn runners_view( lang: Lang, csrf: String, rows: Vec, notice: Option<(&'static str, bool)>, note: String, ) -> Result { Ok(view! {

(g::t(lang, K::CiRunners))

runners_nav(lang: lang)
if let Some((text, is_error)) = notice { if is_error { } else {
(text)
} }

(g::t(lang, K::Runners))

if rows.is_empty() {

(g::t(lang, K::NoRunnersYet))

} else {
for row in rows.iter() { }
(g::t(lang, K::TableName)) (g::t(lang, K::TableLabels)) (g::t(lang, K::LastSeen)) (g::t(lang, K::TableState))
(row.name.as_str()) (row.labels.as_str()) (row.last_seen.as_str()) (if row.disabled { g::t(lang, K::Disabled) } else { g::t(lang, K::Enabled) })
}

(g::t(lang, K::AddARunner))

(note.as_str())

}) } #[component] async fn token_view( lang: Lang, token: String, command: String, expiry: String, ) -> Result { Ok(view! {

(g::t(lang, K::RegistrationToken))

runners_nav(lang: lang)

(g::t(lang, K::CopyTokenNow))

(expiry.as_str())

(g::t(lang, K::Token))

(token.as_str())

(g::t(lang, K::RegisterARunner))

(g::t(lang, K::ReplacePrefix))""(g::t(lang, K::ReplaceSuffix))

(command.as_str())

(g::t(lang, K::BackToRunners))

}) } #[page("/admin/runners")] async fn runners_page(cx: &Cx) -> Result { let viewer = admin_only(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let state = app_context::(cx); let tz = viewer.0.timezone.clone(); let rows: Vec = state .store .list_runners() .await? .iter() .map(|runner| runner_row(runner, &tz, lang)) .collect(); let csrf = viewer.1.csrf_token.clone(); let notice = notice_from_query(cx, lang); let note = g::token_ttl_note( lang, &g::hours_text(lang, state.config.ci.registration_token_hours), ); let title = g::t(lang, K::CiRunners); Ok(view! { shell(title: title, viewer: Some(&viewer), runners_view(lang: lang, csrf: csrf, rows: rows, notice: notice, note: note)) }) } /// Creates a registration token and shows it once. The token is not stored in plain text: only its /// hash is kept, so this response is the only copy. #[page(POST "/admin/runners/tokens")] async fn runner_token_create(cx: &Cx, Form(form): Form) -> Result { let viewer = admin_only(cx).await?; if !check_csrf(&viewer.1, &form.csrf) { return Err(forbidden().into()); } let state = app_context::(cx); let lang = i18n::lang(cx, Some(&viewer.0)); let ttl_hours = state.config.ci.registration_token_hours; let token = match state .store .create_registration_token(&viewer.0.id, ttl_hours) .await { Ok(token) => token, Err(e) => { tracing::warn!(error = %e, "registration token not created"); return Err(back_to_list("token-failed").into()); } }; audit::record( &state.store, Some(cx), Actor::User(&viewer.0), AuditAction::CiRegistrationTokenCreated, Target { kind: "ci_runner_token", id: "", name: "", }, serde_json::json!({ "ttl_hours": ttl_hours }), ) .await; let command = register_command(&state.config.base_url, &token); let expiry = g::token_expiry_line(lang, &g::hours_text(lang, ttl_hours)); let title = g::t(lang, K::RegistrationToken); Ok(view! { shell(title: title, viewer: Some(&viewer), token_view(lang: lang, token: token, command: command, expiry: expiry)) }) } /// Looks the runner up, so a stale id gets a notice instead of a silent no-op. async fn runner_named(state: &AppState, id: &str) -> forge_core::Result> { Ok(state .store .list_runners() .await? .into_iter() .find(|runner| runner.id == id)) } /// Disables or enables a runner. A disabled runner keeps its registration but gets no jobs. #[route(POST "/admin/runners/{runner_id}/disabled")] async fn runner_set_disabled(cx: &Cx, Form(form): Form) -> Result { let viewer = admin_only(cx).await?; if !check_csrf(&viewer.1, &form.csrf) { return Err(forbidden().into()); } let id = path_param::(cx); let state = app_context::(cx); if runner_named(state, id).await?.is_none() { return Ok(back_to_list("missing")); } let disabled = form.disabled == "1"; state.store.set_runner_disabled(id, disabled).await?; tracing::info!(actor_id = %viewer.0.id, runner_id = %id, disabled, "ci runner state changed"); Ok(back_to_list(if disabled { "disabled" } else { "enabled" })) } /// Deletes a runner. Its registration is removed; jobs it held are reaped by the lost-job check. #[route(POST "/admin/runners/{runner_id}/delete")] async fn runner_delete(cx: &Cx, Form(form): Form) -> Result { let viewer = admin_only(cx).await?; if !check_csrf(&viewer.1, &form.csrf) { return Err(forbidden().into()); } let id = path_param::(cx); let state = app_context::(cx); let Some(runner) = runner_named(state, id).await? else { return Ok(back_to_list("missing")); }; state.store.delete_runner(id).await?; audit::record( &state.store, Some(cx), Actor::User(&viewer.0), AuditAction::CiRunnerDeleted, Target { kind: "ci_runner", id: &runner.id, name: &runner.name, }, serde_json::json!({ "runner_id": runner.id }), ) .await; Ok(back_to_list("deleted")) } #[cfg(test)] mod tests { use super::*; use forge_core::Store; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; use crate::test_support::{app_router, create_session, test_state}; const ADMIN_TOKEN: &str = "runners-admin-session"; const USER_TOKEN: &str = "runners-user-session"; const CSRF: &str = "runners-csrf"; async fn fixture(dir: &std::path::Path) -> (Store, Router) { let state = test_state(dir).await; let store = state.store.clone(); let admin = store .create_user("root", None, "h", true) .await .expect("admin"); let user = store .create_user("plain", None, "h", false) .await .expect("user"); create_session(&store, &admin.id, ADMIN_TOKEN, CSRF).await; create_session(&store, &user.id, USER_TOKEN, CSRF).await; (store, app_router(state)) } async fn send( router: &Router, method: &str, uri: &str, token: Option<&str>, form: Option, ) -> (u16, String) { let mut builder = Request::builder().method(method).uri(uri); if let Some(token) = token { builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={token}")); } let body = match form { Some(form) => { builder = builder.header("content-type", "application/x-www-form-urlencoded"); Body::from(form) } None => Body::empty(), }; let res = router.handle(builder.body(body).expect("request")).await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); (status, String::from_utf8_lossy(&bytes).into_owned()) } fn csrf_form(extra: &str) -> Option { Some(format!("csrf={CSRF}{extra}")) } #[test] fn register_command_uses_the_base_url() { let cmd = register_command("https://forge.example/", "tok123"); assert_eq!( cmd, "forge runner register --url https://forge.example --token tok123 --name --labels linux" ); } #[tokio::test] async fn page_is_404_for_anonymous_and_non_admins() { let dir = tempfile::tempdir().expect("tempdir"); let (_, router) = fixture(dir.path()).await; let (anon, _) = send(&router, "GET", "/admin/runners", None, None).await; assert_eq!(anon, 404, "anonymous gets 404 here, not a login redirect"); let (user, _) = send(&router, "GET", "/admin/runners", Some(USER_TOKEN), None).await; assert_eq!(user, 404); let (tok, _) = send( &router, "POST", "/admin/runners/tokens", Some(USER_TOKEN), csrf_form(""), ) .await; assert_eq!(tok, 404); let (del, _) = send( &router, "POST", "/admin/runners/x/delete", Some(USER_TOKEN), csrf_form(""), ) .await; assert_eq!(del, 404); } #[tokio::test] async fn posts_without_a_valid_csrf_token_are_forbidden() { let dir = tempfile::tempdir().expect("tempdir"); let (store, router) = fixture(dir.path()).await; let bad = Some("csrf=wrong".to_owned()); let (tok, _) = send( &router, "POST", "/admin/runners/tokens", Some(ADMIN_TOKEN), bad.clone(), ) .await; assert_eq!(tok, 403); let (del, _) = send( &router, "POST", "/admin/runners/x/delete", Some(ADMIN_TOKEN), bad.clone(), ) .await; assert_eq!(del, 403); let (dis, _) = send( &router, "POST", "/admin/runners/x/disabled", Some(ADMIN_TOKEN), Some("csrf=wrong&disabled=1".to_owned()), ) .await; assert_eq!(dis, 403); assert!(store.list_runners().await.expect("list").is_empty()); } #[tokio::test] async fn token_page_shows_the_token_once_and_the_list_never_does() { let dir = tempfile::tempdir().expect("tempdir"); let (store, router) = fixture(dir.path()).await; let (status, created) = send( &router, "POST", "/admin/runners/tokens", Some(ADMIN_TOKEN), csrf_form(""), ) .await; assert_eq!(status, 200); let token = created .split("--token ") .nth(1) .and_then(|rest| rest.split(' ').next()) .expect("command with token") .to_owned(); assert!(created.contains("forge runner register --url")); assert!( created.contains("--name <name> --labels linux"), "placeholder is escaped" ); assert_eq!( created.matches(&token).count(), 2, "token in the box and the command" ); let (_, list) = send(&router, "GET", "/admin/runners", Some(ADMIN_TOKEN), None).await; assert!(!list.contains(&token), "the list never shows the token"); assert!(!list.contains("forge runner register")); // Only the hash is stored, so the list and the database hold no plain token. assert!(store.list_runners().await.expect("list").is_empty()); } #[tokio::test] async fn disable_enable_and_delete_a_runner() { let dir = tempfile::tempdir().expect("tempdir"); let (store, router) = fixture(dir.path()).await; let (_, created) = send( &router, "POST", "/admin/runners/tokens", Some(ADMIN_TOKEN), csrf_form(""), ) .await; let token = created .split("--token ") .nth(1) .and_then(|rest| rest.split(' ').next()) .expect("token") .to_owned(); store .register_runner(&token, "box-1", &["linux".to_owned()]) .await .expect("register"); let runner = store.list_runners().await.expect("list").remove(0); let disable = format!("/admin/runners/{}/disabled", runner.id); let (status, _) = send( &router, "POST", &disable, Some(ADMIN_TOKEN), csrf_form("&disabled=1"), ) .await; assert_eq!(status, 303); assert!(store.list_runners().await.expect("list")[0].disabled); let (_, _) = send( &router, "POST", &disable, Some(ADMIN_TOKEN), csrf_form("&disabled=0"), ) .await; assert!(!store.list_runners().await.expect("list")[0].disabled); let delete = format!("/admin/runners/{}/delete", runner.id); let (status, _) = send(&router, "POST", &delete, Some(ADMIN_TOKEN), csrf_form("")).await; assert_eq!(status, 303); assert!(store.list_runners().await.expect("list").is_empty()); let (_, _) = send(&router, "POST", &delete, Some(ADMIN_TOKEN), csrf_form("")).await; let (_, page) = send( &router, "GET", "/admin/runners?notice=missing", Some(ADMIN_TOKEN), None, ) .await; assert!(page.contains("No runner has that id")); } }