refactor(#1456): extract dashboard journal-read endpoints into dashboard/journal.rs

This commit is contained in:
damocles 2026-06-08 22:46:01 +02:00 committed by mara
commit e2fdaae841
2 changed files with 142 additions and 124 deletions

View file

@ -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<Coordinator>) -> 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<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.
async fn get_journal(
AxumPath(name): AxumPath<String>,
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
) -> 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<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.
async fn get_journal_host(
axum::extract::Query(q): axum::extract::Query<JournalHostQuery>,
) -> 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.

View file

@ -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<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.
pub(super) async fn get_journal(
AxumPath(name): AxumPath<String>,
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
) -> 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<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>,
) -> 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}")),
}
}