From e2fdaae841014b3346840ac6267796831810971e Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 22:46:01 +0200 Subject: [PATCH] refactor(#1456): extract dashboard journal-read endpoints into dashboard/journal.rs --- hive-c0re/src/dashboard.rs | 127 +------------------------- hive-c0re/src/dashboard/journal.rs | 139 +++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 124 deletions(-) create mode 100644 hive-c0re/src/dashboard/journal.rs diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 6f0e050d..3d2ddd75 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -30,6 +30,7 @@ use crate::container_view::{ContainerView, claude_has_session}; use crate::coordinator::Coordinator; use crate::lifecycle::{self, MANAGER_NAME}; +mod journal; mod permissions; mod schedules; mod webhook; @@ -66,8 +67,8 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/answer-question/{id}", post(post_answer_question)) .route("/cancel-question/{id}", post(post_cancel_question)) .route("/purge-tombstone/{name}", post(post_purge_tombstone)) - .route("/api/journal/{name}", get(get_journal)) - .route("/api/journal-host", get(get_journal_host)) + .route("/api/journal/{name}", get(journal::get_journal)) + .route("/api/journal-host", get(journal::get_journal_host)) .route("/api/approval-diff/{id}", get(get_approval_diff)) .route("/api/state-file", get(get_state_file)) .route("/api/reminders", get(api_reminders)) @@ -1194,128 +1195,6 @@ async fn post_cancel_question( } } -#[derive(Deserialize)] -struct JournalQuery { - /// Optional systemd unit filter — e.g. `hive-ag3nt.service`. When - /// omitted, returns the full machine journal. - #[serde(default)] - unit: Option, - /// Number of trailing lines to return. Capped at 5000. - #[serde(default)] - lines: Option, -} - -/// Read `journalctl -M -b` and return its text output. -/// Operator-only by virtue of the dashboard being host-bound. hive-c0re -/// runs unprivileged (privsep), so the `-M` read — which enters the -/// container namespace and needs root — is delegated to hive-priv. -async fn get_journal( - AxumPath(name): AxumPath, - axum::extract::Query(q): axum::extract::Query, -) -> Response { - // Defense-in-depth format check so weird chars never reach the - // shellout below — the `lifecycle::list()` existence check would - // catch them anyway, but rejecting at the boundary keeps the - // failure mode crisp. - if let Some(reason) = validate_agent_name(&name) { - return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); - } - // Validate the container name against the list of managed - // containers so we don't shell out with arbitrary input. - let container = strip_container_prefix(&name); - let prefixed = format!("{}{container}", lifecycle::AGENT_PREFIX); - let live = lifecycle::list().await.unwrap_or_default(); - if !live.iter().any(|c| c == &prefixed) { - return error_response(&format!("journal: no managed container {prefixed:?}")); - } - let lines = q.lines.unwrap_or(500).min(5000); - let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) { - Some(u) => { - // accept hive-ag3nt[.service] — anything else refused. - let allowed = ["hive-ag3nt.service"]; - let unit = if u.ends_with(".service") { - u.to_owned() - } else { - format!("{u}.service") - }; - if !allowed.contains(&unit.as_str()) { - return error_response(&format!("journal: unknown unit {unit:?}")); - } - Some(unit) - } - None => None, - }; - match crate::priv_client::read_container_journal( - &prefixed, - hive_sh4re::priv_proto::JournalQuery { - lines, - boot: true, - output: hive_sh4re::priv_proto::JournalOutput::ShortIso, - unit, - ..Default::default() - }, - ) - .await - { - Ok((stdout, stderr)) => { - // Combine stdout + stderr — journalctl emits to both on errors. - let mut body = stdout; - if !stderr.is_empty() { - body.push_str("\n--- stderr ---\n"); - body.push_str(&stderr); - } - ([("content-type", "text/plain; charset=utf-8")], body).into_response() - } - Err(e) => error_response(&format!("journal read: {e:#}")), - } -} - -#[derive(Deserialize)] -struct JournalHostQuery { - /// Service unit name to filter to. If omitted, returns all logs. - #[serde(default)] - unit: Option, - /// Number of trailing lines. Capped at 5000. Default 500. - #[serde(default)] - lines: Option, -} - -/// `GET /api/journal-host?unit=&lines=N` — host-side journald (no -/// `-M` container flag). Restricted to an allow-list of known host services -/// so arbitrary unit names can't be probed. Operator-only by virtue of the -/// dashboard binding to a host-only port. -async fn get_journal_host( - axum::extract::Query(q): axum::extract::Query, -) -> Response { - let lines = q.lines.unwrap_or(500).min(5000); - let allowed = ["hive-c0re.service"]; - let mut cmd = tokio::process::Command::new("journalctl"); - cmd.args(["--no-pager", "--output=short-iso", "--lines"]) - .arg(lines.to_string()); - if let Some(u) = q.unit.as_deref().filter(|s| !s.is_empty()) { - let unit = if u.ends_with(".service") { - u.to_owned() - } else { - format!("{u}.service") - }; - if !allowed.contains(&unit.as_str()) { - return error_response(&format!("journal-host: unknown unit {unit:?}")); - } - cmd.args(["-u", &unit]); - } - match cmd.output().await { - Ok(out) => { - let mut body = String::from_utf8_lossy(&out.stdout).into_owned(); - if !out.status.success() { - body.push_str("\n--- stderr ---\n"); - body.push_str(&String::from_utf8_lossy(&out.stderr)); - } - ([("content-type", "text/plain; charset=utf-8")], body).into_response() - } - Err(e) => error_response(&format!("journalctl spawn: {e}")), - } -} - #[derive(Deserialize)] struct BuildLogsAllQuery { /// Max rows to return. Capped at 100. Default 30. diff --git a/hive-c0re/src/dashboard/journal.rs b/hive-c0re/src/dashboard/journal.rs new file mode 100644 index 00000000..cdcba9fb --- /dev/null +++ b/hive-c0re/src/dashboard/journal.rs @@ -0,0 +1,139 @@ +//! Journal-read endpoints for the dashboard. +//! +//! `GET /api/journal/{name}` reads a managed container's journal via the +//! root helper (`journalctl -M`, delegated to hive-priv since hive-c0re is +//! unprivileged). `GET /api/journal-host` reads host-side journald, both +//! gated by an allow-list of known units so arbitrary unit names can't be +//! probed. Operator-only by virtue of the dashboard binding host-only. + +use axum::{ + extract::Path as AxumPath, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +use super::{error_response, strip_container_prefix, validate_agent_name}; +use crate::lifecycle; + +#[derive(Deserialize)] +pub(super) struct JournalQuery { + /// Optional systemd unit filter — e.g. `hive-ag3nt.service`. When + /// omitted, returns the full machine journal. + #[serde(default)] + unit: Option, + /// Number of trailing lines to return. Capped at 5000. + #[serde(default)] + lines: Option, +} + +/// Read `journalctl -M -b` and return its text output. +/// Operator-only by virtue of the dashboard being host-bound. hive-c0re +/// runs unprivileged (privsep), so the `-M` read — which enters the +/// container namespace and needs root — is delegated to hive-priv. +pub(super) async fn get_journal( + AxumPath(name): AxumPath, + axum::extract::Query(q): axum::extract::Query, +) -> Response { + // Defense-in-depth format check so weird chars never reach the + // shellout below — the `lifecycle::list()` existence check would + // catch them anyway, but rejecting at the boundary keeps the + // failure mode crisp. + if let Some(reason) = validate_agent_name(&name) { + return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); + } + // Validate the container name against the list of managed + // containers so we don't shell out with arbitrary input. + let container = strip_container_prefix(&name); + let prefixed = format!("{}{container}", lifecycle::AGENT_PREFIX); + let live = lifecycle::list().await.unwrap_or_default(); + if !live.iter().any(|c| c == &prefixed) { + return error_response(&format!("journal: no managed container {prefixed:?}")); + } + let lines = q.lines.unwrap_or(500).min(5000); + let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) { + Some(u) => { + // accept hive-ag3nt[.service] — anything else refused. + let allowed = ["hive-ag3nt.service"]; + let unit = if u.ends_with(".service") { + u.to_owned() + } else { + format!("{u}.service") + }; + if !allowed.contains(&unit.as_str()) { + return error_response(&format!("journal: unknown unit {unit:?}")); + } + Some(unit) + } + None => None, + }; + match crate::priv_client::read_container_journal( + &prefixed, + hive_sh4re::priv_proto::JournalQuery { + lines, + boot: true, + output: hive_sh4re::priv_proto::JournalOutput::ShortIso, + unit, + ..Default::default() + }, + ) + .await + { + Ok((stdout, stderr)) => { + // Combine stdout + stderr — journalctl emits to both on errors. + let mut body = stdout; + if !stderr.is_empty() { + body.push_str("\n--- stderr ---\n"); + body.push_str(&stderr); + } + ([("content-type", "text/plain; charset=utf-8")], body).into_response() + } + Err(e) => error_response(&format!("journal read: {e:#}")), + } +} + +#[derive(Deserialize)] +pub(super) struct JournalHostQuery { + /// Service unit name to filter to. If omitted, returns all logs. + #[serde(default)] + unit: Option, + /// Number of trailing lines. Capped at 5000. Default 500. + #[serde(default)] + lines: Option, +} + +/// `GET /api/journal-host?unit=&lines=N` — host-side journald (no +/// `-M` container flag). Restricted to an allow-list of known host services +/// so arbitrary unit names can't be probed. Operator-only by virtue of the +/// dashboard binding to a host-only port. +pub(super) async fn get_journal_host( + axum::extract::Query(q): axum::extract::Query, +) -> Response { + let lines = q.lines.unwrap_or(500).min(5000); + let allowed = ["hive-c0re.service"]; + let mut cmd = tokio::process::Command::new("journalctl"); + cmd.args(["--no-pager", "--output=short-iso", "--lines"]) + .arg(lines.to_string()); + if let Some(u) = q.unit.as_deref().filter(|s| !s.is_empty()) { + let unit = if u.ends_with(".service") { + u.to_owned() + } else { + format!("{u}.service") + }; + if !allowed.contains(&unit.as_str()) { + return error_response(&format!("journal-host: unknown unit {unit:?}")); + } + cmd.args(["-u", &unit]); + } + match cmd.output().await { + Ok(out) => { + let mut body = String::from_utf8_lossy(&out.stdout).into_owned(); + if !out.status.success() { + body.push_str("\n--- stderr ---\n"); + body.push_str(&String::from_utf8_lossy(&out.stderr)); + } + ([("content-type", "text/plain; charset=utf-8")], body).into_response() + } + Err(e) => error_response(&format!("journalctl spawn: {e}")), + } +}