182 lines
7.1 KiB
Rust
182 lines
7.1 KiB
Rust
//! 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<String>,
|
|
/// Number of trailing lines to return. Capped at 5000.
|
|
#[serde(default)]
|
|
lines: Option<u32>,
|
|
}
|
|
|
|
/// Read `journalctl -M <container> -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<String>,
|
|
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
|
|
) -> Result<Response, ProblemDetails> {
|
|
let lines = q.lines.unwrap_or(500).min(5000);
|
|
|
|
if let Ok(infra) = name.parse::<hive_priv_sock::InfraContainer>() {
|
|
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 <machine> [-u <unit>]` 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<String>,
|
|
lines: u32,
|
|
) -> Result<Response, ProblemDetails> {
|
|
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<String>,
|
|
/// Number of trailing lines. Capped at 5000. Default 500.
|
|
#[serde(default)]
|
|
lines: Option<u32>,
|
|
}
|
|
|
|
/// `GET /api/journal-host?unit=<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<JournalHostQuery>,
|
|
) -> Result<Response, ProblemDetails> {
|
|
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}"))),
|
|
}
|
|
}
|