forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
20.1 KiB
1//! Page: CI runners (`/admin/runners`). Instance admins only; anyone else gets 404.
2//!
3//! Lists the registered runners with their labels, last contact and state, and offers disable/enable
4//! and delete for each. "Create registration token" is a POST with a CSRF token: it answers with a page
5//! that shows the one-time token and the exact `forge runner register` command, and that page is the
6//! only place the token ever appears. The list never shows a token. Creating a token and deleting a
7//! runner are audited; the token itself is never written to the audit log.
8
9use forge_core::AuditAction;
10use forge_core::ci::CiRunner;
11use serde::Deserialize;
12use topcoat::Result;
13use topcoat::context::{Cx, app_context};
14use topcoat::router::content::Form;
15use topcoat::router::error::{SeeOther, forbidden, see_other};
16use topcoat::router::request::uri;
17use topcoat::router::{page, path_param, route};
18use topcoat::view::{View, component, view};
19
20use crate::audit::{self, Actor, Target};
21use crate::auth::check_csrf;
22use crate::i18n::admin::{self as g, K};
23use crate::i18n::{self, Key, Lang};
24use crate::pages::admin_users::admin_only;
25use crate::pages::repo_mirrors::stamp_in_tz;
26use crate::state::AppState;
27use crate::views::{enc, shell};
28
29path_param!(runner_id);
30
31/// Notice words (the only text a `?notice=` query can show), their text key, and whether they are errors.
32const NOTICES: [(&str, K, bool); 5] = [
33 ("disabled", K::RunnerDisabledNotice, false),
34 ("enabled", K::RunnerEnabledNotice, false),
35 ("deleted", K::RunnerDeletedNotice, false),
36 ("missing", K::NoRunnerWithId, true),
37 ("token-failed", K::TokenCreateFailed, true),
38];
39
40#[derive(Deserialize)]
41struct CsrfForm {
42 #[serde(default)]
43 csrf: String,
44}
45
46#[derive(Deserialize)]
47struct DisableForm {
48 #[serde(default)]
49 csrf: String,
50 #[serde(default)]
51 disabled: String,
52}
53
54/// One runner row, already formatted.
55struct RunnerRow {
56 name: String,
57 labels: String,
58 last_seen: String,
59 disabled: bool,
60 toggle_action: String,
61 delete_action: String,
62}
63
64fn runner_row(runner: &CiRunner, tz: &str, lang: Lang) -> RunnerRow {
65 RunnerRow {
66 name: runner.name.clone(),
67 labels: runner.labels.join(", "),
68 last_seen: runner
69 .last_seen_at
70 .as_deref()
71 .map(|stamp| stamp_in_tz(stamp, tz))
72 .unwrap_or_else(|| i18n::t(lang, Key::Never).to_owned()),
73 disabled: runner.disabled,
74 toggle_action: format!("/admin/runners/{}/disabled", enc(&runner.id)),
75 delete_action: format!("/admin/runners/{}/delete", enc(&runner.id)),
76 }
77}
78
79/// The notice from `?notice=<word>`. Anything else is ignored.
80fn notice_from_query(cx: &Cx, lang: Lang) -> Option<(&'static str, bool)> {
81 let query = uri(cx).query()?;
82 let word = form_urlencoded::parse(query.as_bytes())
83 .find(|(key, _)| key == "notice")
84 .map(|(_, value)| value.into_owned())?;
85 NOTICES
86 .iter()
87 .find(|(name, _, _)| *name == word)
88 .map(|(_, key, is_error)| (g::t(lang, *key), *is_error))
89}
90
91fn back_to_list(notice: &str) -> SeeOther {
92 see_other(format!("/admin/runners?notice={}", enc(notice)))
93}
94
95/// The command a new runner runs. The base URL comes from the server config.
96fn register_command(base_url: &str, token: &str) -> String {
97 format!(
98 "forge runner register --url {} --token {token} --name <name> --labels linux",
99 base_url.trim_end_matches('/')
100 )
101}
102
103#[component]
104async fn runners_nav(lang: Lang) -> Result<impl View> {
105 Ok(view! {
106 <p><a href="/admin/users">(g::t(lang, K::Users))</a> " · " <a href="/admin/audit">(g::t(lang, K::AuditLog))</a> " · " <a href="/admin/backups">(g::t(lang, K::Backups))</a> " · " <a href="/admin/replication">(g::t(lang, K::Replication))</a> " · " <a href="/admin/mail">(g::t(lang, K::Mail))</a> " · " <a href="/admin/runners" aria-current="page">(g::t(lang, K::Runners))</a></p>
107 })
108}
109
110#[component]
111async fn runners_view(
112 lang: Lang,
113 csrf: String,
114 rows: Vec<RunnerRow>,
115 notice: Option<(&'static str, bool)>,
116 note: String,
117) -> Result<impl View> {
118 Ok(view! {
119 <section class="page-head">
120 <h1>(g::t(lang, K::CiRunners))</h1>
121 runners_nav(lang: lang)
122 </section>
123 if let Some((text, is_error)) = notice {
124 if is_error {
125 <section class="card notice alert" role="alert">(text)</section>
126 } else {
127 <section class="card notice" role="status">(text)</section>
128 }
129 }
130 <section class="card">
131 <h2>(g::t(lang, K::Runners))</h2>
132 if rows.is_empty() {
133 <p class="muted">(g::t(lang, K::NoRunnersYet))</p>
134 } else {
135 <div class="table-wrap">
136 <table class="table runners">
137 <thead>
138 <tr>
139 <th>(g::t(lang, K::TableName))</th>
140 <th>(g::t(lang, K::TableLabels))</th>
141 <th>(g::t(lang, K::LastSeen))</th>
142 <th>(g::t(lang, K::TableState))</th>
143 <th></th>
144 </tr>
145 </thead>
146 <tbody>
147 for row in rows.iter() {
148 <tr>
149 <td>(row.name.as_str())</td>
150 <td><code>(row.labels.as_str())</code></td>
151 <td class="small">(row.last_seen.as_str())</td>
152 <td>(if row.disabled { g::t(lang, K::Disabled) } else { g::t(lang, K::Enabled) })</td>
153 <td>
154 <div class="actions">
155 <form method="post" action=(row.toggle_action.as_str()) class="inline-form">
156 <input type="hidden" name="csrf" value=(csrf.as_str())>
157 <input type="hidden" name="disabled" value=(if row.disabled { "0" } else { "1" })>
158 <button type="submit">(if row.disabled { g::t(lang, K::Enable) } else { g::t(lang, K::Disable) })</button>
159 </form>
160 <form method="post" action=(row.delete_action.as_str()) class="inline-form">
161 <input type="hidden" name="csrf" value=(csrf.as_str())>
162 <button type="submit" class="danger">(i18n::t(lang, Key::Delete))</button>
163 </form>
164 </div>
165 </td>
166 </tr>
167 }
168 </tbody>
169 </table>
170 </div>
171 }
172 </section>
173 <section class="card">
174 <h2>(g::t(lang, K::AddARunner))</h2>
175 <p class="muted small">(note.as_str())</p>
176 <form method="post" action="/admin/runners/tokens" class="inline-form">
177 <input type="hidden" name="csrf" value=(csrf.as_str())>
178 <button type="submit" class="primary">(g::t(lang, K::CreateRegistrationToken))</button>
179 </form>
180 </section>
181 })
182}
183
184#[component]
185async fn token_view(
186 lang: Lang,
187 token: String,
188 command: String,
189 expiry: String,
190) -> Result<impl View> {
191 Ok(view! {
192 <section class="page-head">
193 <h1>(g::t(lang, K::RegistrationToken))</h1>
194 runners_nav(lang: lang)
195 </section>
196 <section class="card notice" role="status">
197 <p><strong>(g::t(lang, K::CopyTokenNow))</strong></p>
198 <p>(expiry.as_str())</p>
199 </section>
200 <section class="card">
201 <h2>(g::t(lang, K::Token))</h2>
202 <pre><code>(token.as_str())</code></pre>
203 <h2>(g::t(lang, K::RegisterARunner))</h2>
204 <p class="muted small">(g::t(lang, K::ReplacePrefix))<code>"<name>"</code>(g::t(lang, K::ReplaceSuffix))</p>
205 <pre><code>(command.as_str())</code></pre>
206 <p><a href="/admin/runners">(g::t(lang, K::BackToRunners))</a></p>
207 </section>
208 })
209}
210
211#[page("/admin/runners")]
212async fn runners_page(cx: &Cx) -> Result<impl View> {
213 let viewer = admin_only(cx).await?;
214 let lang = i18n::lang(cx, Some(&viewer.0));
215 let state = app_context::<AppState>(cx);
216 let tz = viewer.0.timezone.clone();
217 let rows: Vec<RunnerRow> = state
218 .store
219 .list_runners()
220 .await?
221 .iter()
222 .map(|runner| runner_row(runner, &tz, lang))
223 .collect();
224 let csrf = viewer.1.csrf_token.clone();
225 let notice = notice_from_query(cx, lang);
226 let note = g::token_ttl_note(
227 lang,
228 &g::hours_text(lang, state.config.ci.registration_token_hours),
229 );
230 let title = g::t(lang, K::CiRunners);
231 Ok(view! {
232 shell(title: title, viewer: Some(&viewer), runners_view(lang: lang, csrf: csrf, rows: rows, notice: notice, note: note))
233 })
234}
235
236/// Creates a registration token and shows it once. The token is not stored in plain text: only its
237/// hash is kept, so this response is the only copy.
238#[page(POST "/admin/runners/tokens")]
239async fn runner_token_create(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<impl View> {
240 let viewer = admin_only(cx).await?;
241 if !check_csrf(&viewer.1, &form.csrf) {
242 return Err(forbidden().into());
243 }
244 let state = app_context::<AppState>(cx);
245 let lang = i18n::lang(cx, Some(&viewer.0));
246 let ttl_hours = state.config.ci.registration_token_hours;
247 let token = match state
248 .store
249 .create_registration_token(&viewer.0.id, ttl_hours)
250 .await
251 {
252 Ok(token) => token,
253 Err(e) => {
254 tracing::warn!(error = %e, "registration token not created");
255 return Err(back_to_list("token-failed").into());
256 }
257 };
258 audit::record(
259 &state.store,
260 Some(cx),
261 Actor::User(&viewer.0),
262 AuditAction::CiRegistrationTokenCreated,
263 Target {
264 kind: "ci_runner_token",
265 id: "",
266 name: "",
267 },
268 serde_json::json!({ "ttl_hours": ttl_hours }),
269 )
270 .await;
271 let command = register_command(&state.config.base_url, &token);
272 let expiry = g::token_expiry_line(lang, &g::hours_text(lang, ttl_hours));
273 let title = g::t(lang, K::RegistrationToken);
274 Ok(view! {
275 shell(title: title, viewer: Some(&viewer), token_view(lang: lang, token: token, command: command, expiry: expiry))
276 })
277}
278
279/// Looks the runner up, so a stale id gets a notice instead of a silent no-op.
280async fn runner_named(state: &AppState, id: &str) -> forge_core::Result<Option<CiRunner>> {
281 Ok(state
282 .store
283 .list_runners()
284 .await?
285 .into_iter()
286 .find(|runner| runner.id == id))
287}
288
289/// Disables or enables a runner. A disabled runner keeps its registration but gets no jobs.
290#[route(POST "/admin/runners/{runner_id}/disabled")]
291async fn runner_set_disabled(cx: &Cx, Form(form): Form<DisableForm>) -> Result<SeeOther> {
292 let viewer = admin_only(cx).await?;
293 if !check_csrf(&viewer.1, &form.csrf) {
294 return Err(forbidden().into());
295 }
296 let id = path_param::<RunnerId>(cx);
297 let state = app_context::<AppState>(cx);
298 if runner_named(state, id).await?.is_none() {
299 return Ok(back_to_list("missing"));
300 }
301 let disabled = form.disabled == "1";
302 state.store.set_runner_disabled(id, disabled).await?;
303 tracing::info!(actor_id = %viewer.0.id, runner_id = %id, disabled, "ci runner state changed");
304 Ok(back_to_list(if disabled { "disabled" } else { "enabled" }))
305}
306
307/// Deletes a runner. Its registration is removed; jobs it held are reaped by the lost-job check.
308#[route(POST "/admin/runners/{runner_id}/delete")]
309async fn runner_delete(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
310 let viewer = admin_only(cx).await?;
311 if !check_csrf(&viewer.1, &form.csrf) {
312 return Err(forbidden().into());
313 }
314 let id = path_param::<RunnerId>(cx);
315 let state = app_context::<AppState>(cx);
316 let Some(runner) = runner_named(state, id).await? else {
317 return Ok(back_to_list("missing"));
318 };
319 state.store.delete_runner(id).await?;
320 audit::record(
321 &state.store,
322 Some(cx),
323 Actor::User(&viewer.0),
324 AuditAction::CiRunnerDeleted,
325 Target {
326 kind: "ci_runner",
327 id: &runner.id,
328 name: &runner.name,
329 },
330 serde_json::json!({ "runner_id": runner.id }),
331 )
332 .await;
333 Ok(back_to_list("deleted"))
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use forge_core::Store;
340 use topcoat::router::request::Request;
341 use topcoat::router::{Body, Router, to_bytes};
342
343 use crate::auth::SESSION_COOKIE;
344 use crate::test_support::{app_router, create_session, test_state};
345
346 const ADMIN_TOKEN: &str = "runners-admin-session";
347 const USER_TOKEN: &str = "runners-user-session";
348 const CSRF: &str = "runners-csrf";
349
350 async fn fixture(dir: &std::path::Path) -> (Store, Router) {
351 let state = test_state(dir).await;
352 let store = state.store.clone();
353 let admin = store
354 .create_user("root", None, "h", true)
355 .await
356 .expect("admin");
357 let user = store
358 .create_user("plain", None, "h", false)
359 .await
360 .expect("user");
361 create_session(&store, &admin.id, ADMIN_TOKEN, CSRF).await;
362 create_session(&store, &user.id, USER_TOKEN, CSRF).await;
363 (store, app_router(state))
364 }
365
366 async fn send(
367 router: &Router,
368 method: &str,
369 uri: &str,
370 token: Option<&str>,
371 form: Option<String>,
372 ) -> (u16, String) {
373 let mut builder = Request::builder().method(method).uri(uri);
374 if let Some(token) = token {
375 builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={token}"));
376 }
377 let body = match form {
378 Some(form) => {
379 builder = builder.header("content-type", "application/x-www-form-urlencoded");
380 Body::from(form)
381 }
382 None => Body::empty(),
383 };
384 let res = router.handle(builder.body(body).expect("request")).await;
385 let status = res.status().as_u16();
386 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
387 (status, String::from_utf8_lossy(&bytes).into_owned())
388 }
389
390 fn csrf_form(extra: &str) -> Option<String> {
391 Some(format!("csrf={CSRF}{extra}"))
392 }
393
394 #[test]
395 fn register_command_uses_the_base_url() {
396 let cmd = register_command("https://forge.example/", "tok123");
397 assert_eq!(
398 cmd,
399 "forge runner register --url https://forge.example --token tok123 --name <name> --labels linux"
400 );
401 }
402
403 #[tokio::test]
404 async fn page_is_404_for_anonymous_and_non_admins() {
405 let dir = tempfile::tempdir().expect("tempdir");
406 let (_, router) = fixture(dir.path()).await;
407 let (anon, _) = send(&router, "GET", "/admin/runners", None, None).await;
408 assert_eq!(anon, 404, "anonymous gets 404 here, not a login redirect");
409 let (user, _) = send(&router, "GET", "/admin/runners", Some(USER_TOKEN), None).await;
410 assert_eq!(user, 404);
411 let (tok, _) = send(
412 &router,
413 "POST",
414 "/admin/runners/tokens",
415 Some(USER_TOKEN),
416 csrf_form(""),
417 )
418 .await;
419 assert_eq!(tok, 404);
420 let (del, _) = send(
421 &router,
422 "POST",
423 "/admin/runners/x/delete",
424 Some(USER_TOKEN),
425 csrf_form(""),
426 )
427 .await;
428 assert_eq!(del, 404);
429 }
430
431 #[tokio::test]
432 async fn posts_without_a_valid_csrf_token_are_forbidden() {
433 let dir = tempfile::tempdir().expect("tempdir");
434 let (store, router) = fixture(dir.path()).await;
435 let bad = Some("csrf=wrong".to_owned());
436 let (tok, _) = send(
437 &router,
438 "POST",
439 "/admin/runners/tokens",
440 Some(ADMIN_TOKEN),
441 bad.clone(),
442 )
443 .await;
444 assert_eq!(tok, 403);
445 let (del, _) = send(
446 &router,
447 "POST",
448 "/admin/runners/x/delete",
449 Some(ADMIN_TOKEN),
450 bad.clone(),
451 )
452 .await;
453 assert_eq!(del, 403);
454 let (dis, _) = send(
455 &router,
456 "POST",
457 "/admin/runners/x/disabled",
458 Some(ADMIN_TOKEN),
459 Some("csrf=wrong&disabled=1".to_owned()),
460 )
461 .await;
462 assert_eq!(dis, 403);
463 assert!(store.list_runners().await.expect("list").is_empty());
464 }
465
466 #[tokio::test]
467 async fn token_page_shows_the_token_once_and_the_list_never_does() {
468 let dir = tempfile::tempdir().expect("tempdir");
469 let (store, router) = fixture(dir.path()).await;
470 let (status, created) = send(
471 &router,
472 "POST",
473 "/admin/runners/tokens",
474 Some(ADMIN_TOKEN),
475 csrf_form(""),
476 )
477 .await;
478 assert_eq!(status, 200);
479 let token = created
480 .split("--token ")
481 .nth(1)
482 .and_then(|rest| rest.split(' ').next())
483 .expect("command with token")
484 .to_owned();
485 assert!(created.contains("forge runner register --url"));
486 assert!(
487 created.contains("--name &lt;name&gt; --labels linux"),
488 "placeholder is escaped"
489 );
490 assert_eq!(
491 created.matches(&token).count(),
492 2,
493 "token in the box and the command"
494 );
495
496 let (_, list) = send(&router, "GET", "/admin/runners", Some(ADMIN_TOKEN), None).await;
497 assert!(!list.contains(&token), "the list never shows the token");
498 assert!(!list.contains("forge runner register"));
499 // Only the hash is stored, so the list and the database hold no plain token.
500 assert!(store.list_runners().await.expect("list").is_empty());
501 }
502
503 #[tokio::test]
504 async fn disable_enable_and_delete_a_runner() {
505 let dir = tempfile::tempdir().expect("tempdir");
506 let (store, router) = fixture(dir.path()).await;
507 let (_, created) = send(
508 &router,
509 "POST",
510 "/admin/runners/tokens",
511 Some(ADMIN_TOKEN),
512 csrf_form(""),
513 )
514 .await;
515 let token = created
516 .split("--token ")
517 .nth(1)
518 .and_then(|rest| rest.split(' ').next())
519 .expect("token")
520 .to_owned();
521 store
522 .register_runner(&token, "box-1", &["linux".to_owned()])
523 .await
524 .expect("register");
525 let runner = store.list_runners().await.expect("list").remove(0);
526 let disable = format!("/admin/runners/{}/disabled", runner.id);
527 let (status, _) = send(
528 &router,
529 "POST",
530 &disable,
531 Some(ADMIN_TOKEN),
532 csrf_form("&disabled=1"),
533 )
534 .await;
535 assert_eq!(status, 303);
536 assert!(store.list_runners().await.expect("list")[0].disabled);
537
538 let (_, _) = send(
539 &router,
540 "POST",
541 &disable,
542 Some(ADMIN_TOKEN),
543 csrf_form("&disabled=0"),
544 )
545 .await;
546 assert!(!store.list_runners().await.expect("list")[0].disabled);
547
548 let delete = format!("/admin/runners/{}/delete", runner.id);
549 let (status, _) = send(&router, "POST", &delete, Some(ADMIN_TOKEN), csrf_form("")).await;
550 assert_eq!(status, 303);
551 assert!(store.list_runners().await.expect("list").is_empty());
552
553 let (_, _) = send(&router, "POST", &delete, Some(ADMIN_TOKEN), csrf_form("")).await;
554 let (_, page) = send(
555 &router,
556 "GET",
557 "/admin/runners?notice=missing",
558 Some(ADMIN_TOKEN),
559 None,
560 )
561 .await;
562 assert!(page.contains("No runner has that id"));
563 }
564}