//! 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 problem_details::ProblemDetails; use super::{error_problem, 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, ) -> Result { // 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 Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) .with_detail(format!("bad agent name: {reason}"))); } // 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 Err(ProblemDetails::from_status_code(StatusCode::NOT_FOUND) .with_detail(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 Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) .with_detail(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); } Ok(([("content-type", "text/plain; charset=utf-8")], body).into_response()) } Err(e) => Err(error_problem(&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, ) -> Result { 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 Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) .with_detail(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)); } Ok(([("content-type", "text/plain; charset=utf-8")], body).into_response()) } Err(e) => Err(error_problem(&format!("journalctl spawn: {e}"))), } }