//! Journal-read endpoints for the dashboard. //! //! `GET /api/journal/{name}` reads a managed agent container's journal, OR //! one of the four hive infra services (`hive-ci`, `hive-forge`, //! `hive-gateway`, `hive-matrix` — [`hive_priv_sock::InfraContainer`] is the //! allowlist). A container's journal is a `journalctl -M` read, delegated //! to the root helper since entering a machine needs privileges hive-c0re //! doesn't have; `hive-gateway` is nginx on the host, so it reads host //! journald filtered to that unit and needs no helper at all. //! `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 utoipa::IntoParams; use problem_details::ProblemDetails; use super::{Ident, error_problem, strip_container_prefix}; use crate::lifecycle; #[derive(Deserialize, IntoParams)] 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 names (`hive-ci` / /// `hive-forge` / `hive-gateway` / `hive-matrix` — see /// [`hive_priv_sock::InfraContainer`]). Infra targets don't run the /// per-agent hive daemons, so `unit` is ignored for them — the whole /// machine journal, or for the gateway the host journal filtered to its /// own unit. #[utoipa::path( get, path = "/api/journal/{name}", params( ("name" = String, Path, description = "agent name, or one of the four infra container names"), JournalQuery, ), responses( (status = 200, description = "journal text", body = String, content_type = "text/plain"), (status = 400, description = "bad agent name or unknown unit"), (status = 404, description = "no such managed container"), ), tag = "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 match infra.target() { hive_priv_sock::InfraTarget::Container(machine) => { read_journal_response(machine, None, lines).await } // No machine to enter — the gateway's nginx is a host unit, so // this is a plain host-journal read filtered to it. `-M` is // what needed root here, not journalctl itself. hive_priv_sock::InfraTarget::HostUnit(unit) => { read_host_journal_response(Some(unit), 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, IntoParams)] 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, } /// 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. #[utoipa::path( get, path = "/api/journal-host", params(JournalHostQuery), responses( (status = 200, description = "journal text", body = String, content_type = "text/plain"), (status = 400, description = "unknown unit"), ), tag = "journal" )] pub(super) async fn get_journal_host( axum::extract::Query(q): axum::extract::Query, ) -> Result { let lines = q.lines.unwrap_or(500).min(5000); // `nginx.service` is the gateway — its logs are host-side. let allowed = ["hive-c0re.service", "hive-priv.service", "nginx.service"]; let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) { Some(u) => { 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:?}"))); } Some(unit) } None => None, }; read_host_journal_response(unit.as_deref(), lines).await } /// `journalctl [-u ]` on the host + response formatting. No `-M`, so /// no root and no priv-client hop — hive-c0re reads host journald directly. /// /// ⚠️ `unit` is trusted by the time it gets here: [`get_journal_host`] /// allow-lists an operator-supplied one, and [`get_journal`] passes a unit /// that came from the [`hive_priv_sock::InfraContainer`] enum. Don't hand /// this a raw query parameter. async fn read_host_journal_response( unit: Option<&str>, lines: u32, ) -> Result { let mut cmd = tokio::process::Command::new("journalctl"); cmd.args(["--no-pager", "--output=short-iso", "--lines"]) .arg(lines.to_string()); if let Some(u) = unit { cmd.args(["-u", u]); } 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}"))), } }