fix(#702): route container journal reads through hive-priv

The privsep drop to the hive-core user left four journalctl -M <container>
call sites shelling out directly. -M enters the container namespace via the
machine bus, which needs root, so all container-journal reads failed with
Permission denied. Add a ReadContainerJournal verb to hive-priv and route
dashboard get_journal, manager get_logs, the rebuild-failure journal tail,
and the agent host-journal -M path through it. Host-journal reads (no -M)
stay direct via systemd-journal group membership.
This commit is contained in:
müde 2026-06-02 23:43:02 +02:00
commit 9e12012a95
7 changed files with 279 additions and 65 deletions

View file

@ -1160,10 +1160,10 @@ struct JournalQuery {
lines: Option<u32>,
}
/// Shell out to `journalctl -M <container> -b` and return its text
/// output. Operator-only by virtue of the dashboard being host-bound;
/// hive-c0re already runs as root in its systemd unit so journalctl
/// has the access it needs.
/// 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>,
@ -1184,40 +1184,45 @@ async fn get_journal(
return error_response(&format!("journal: no managed container {prefixed:?}"));
}
let lines = q.lines.unwrap_or(500).min(5000);
let mut cmd = tokio::process::Command::new("journalctl");
cmd.args([
"-M",
&prefixed,
"-b",
"--no-pager",
"--output=short-iso",
"--lines",
])
.arg(lines.to_string());
if let Some(u) = q.unit.as_deref().filter(|s| !s.is_empty()) {
// 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:?}"));
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)
}
cmd.args(["-u", &unit]);
}
match cmd.output().await {
Ok(out) => {
None => None,
};
match crate::priv_client::read_container_journal(
&prefixed,
lines,
true,
hive_sh4re::priv_proto::JournalOutput::ShortIso,
unit,
None,
None,
None,
None,
)
.await
{
Ok((stdout, stderr)) => {
// Combine stdout + stderr — journalctl emits to both on errors.
let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
if !out.status.success() {
let mut body = stdout;
if !stderr.is_empty() {
body.push_str("\n--- stderr ---\n");
body.push_str(&String::from_utf8_lossy(&out.stderr));
body.push_str(&stderr);
}
([("content-type", "text/plain; charset=utf-8")], body).into_response()
}
Err(e) => error_response(&format!("journalctl spawn: {e}")),
Err(e) => error_response(&format!("journal read: {e:#}")),
}
}