//! Journal-read endpoints for the dashboard. //! //! `GET /api/journal/{name}` reads a managed agent container's journal, OR //! one of the four hive infra containers (`hive-ci`, `hive-forge`, //! `hive-gateway`, `hive-matrix` — [`hive_priv_sock::InfraContainer`] is the //! allowlist), 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::{Ident, error_problem, strip_container_prefix}; use crate::lifecycle; #[derive(Deserialize)] pub(super) struct JournalQuery { /// Optional systemd unit filter — e.g. `hive-agent.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. /// /// `name` is either a managed agent name (`iris`, optionally already /// carrying the `h-` prefix) or one of the four infra container names /// (`hive-ci` / `hive-forge` / `hive-gateway` / `hive-matrix` — see /// [`hive_priv_sock::InfraContainer`]). Infra containers don't run the /// per-agent hive daemons, so `unit` is ignored for them — always the /// full machine journal. pub(super) async fn get_journal( AxumPath(name): AxumPath, axum::extract::Query(q): axum::extract::Query, ) -> Result { let lines = q.lines.unwrap_or(500).min(5000); if let Ok(infra) = name.parse::() { return read_journal_response(infra.unit_name(), None, lines).await; } // 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. let name = match Ident::parse(&name) { Ok(n) => n, Err(reason) => { 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.as_str()); 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 unit = match q.unit.as_deref().filter(|s| !s.is_empty()) { Some(u) => { // accept any of the per-container hive daemons [.service] — // anything else refused. let allowed = [ "hive-agent.service", "hive-mcp-http.service", "hive-bash-daemon.service", "hive-matrix-daemon.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, }; read_journal_response(&prefixed, unit, lines).await } /// Shared `journalctl -M [-u ]` shellout + response /// formatting for [`get_journal`], factored out so the infra-container /// branch (no `unit` filtering) and the agent-container branch (allow-listed /// `unit` filtering) don't duplicate the priv-client call + stdout/stderr /// combining. async fn read_journal_response( machine: &str, unit: Option, lines: u32, ) -> Result { match crate::priv_client::read_container_journal( machine, hive_priv_sock::JournalQuery { lines, boot: true, output: hive_priv_sock::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", "hive-priv.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}"))), } }