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

@ -378,6 +378,36 @@ pub async fn dispatch_host_journal(
};
}
let n = lines.unwrap_or(30).min(100);
// A container (`-M`) read enters the container namespace and needs
// root, so it's delegated to hive-priv. A host read (no container)
// the unprivileged hive-core user can do directly via its
// systemd-journal group membership.
if let Some(c) = container {
tracing::info!(%agent, machine = %c, %n, "get_host_journal (container)");
return match crate::priv_client::read_container_journal(
c,
n,
false,
hive_sh4re::priv_proto::JournalOutput::Short,
unit.clone(),
priority.as_ref().map(|p| p.as_str().to_owned()),
grep.clone(),
since.clone(),
until.clone(),
)
.await
{
Ok((stdout, stderr)) => {
let content = if !stdout.is_empty() { stdout } else { stderr };
AgentResponse::HostJournal { content }
}
Err(e) => AgentResponse::Err {
message: format!("journal read: {e:#}"),
},
};
}
let mut args: Vec<String> = vec![
"--no-pager".to_owned(),
"--output=short".to_owned(),
@ -388,10 +418,6 @@ pub async fn dispatch_host_journal(
args.push("-u".to_owned());
args.push(u.clone());
}
if let Some(c) = container {
args.push("-M".to_owned());
args.push(c.clone());
}
if let Some(p) = priority {
args.push("-p".to_owned());
args.push(p.as_str().to_owned());

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:#}")),
}
}

View file

@ -1195,14 +1195,24 @@ async fn priv_run(kind: &str, name: &str) -> Result<()> {
/// or when the journal can't be read (machine gone, journalctl
/// missing); it never produces an error of its own.
async fn container_journal_tail(container: &str) -> String {
let out = Command::new("journalctl")
.args(["-M", container, "-n", "40", "--no-pager", "--output=short"])
.output()
.await;
match out {
Ok(o) if !o.stdout.is_empty() => format!(
// `-M` enters the container namespace and needs root, so the read
// is delegated to hive-priv (hive-c0re itself runs unprivileged).
let res = crate::priv_client::read_container_journal(
container,
40,
false,
hive_sh4re::priv_proto::JournalOutput::Short,
None,
None,
None,
None,
None,
)
.await;
match res {
Ok((stdout, _)) if !stdout.is_empty() => format!(
"\n--- last 40 journal lines from container '{container}' ---\n{}",
String::from_utf8_lossy(&o.stdout).trim_end()
stdout.trim_end()
),
_ => String::new(),
}

View file

@ -254,31 +254,28 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
let n = lines.unwrap_or(50);
// `journalctl -M` wants the container name (`h-<name>`),
// not the logical agent name. `container_name` adds the prefix.
// The `-M` read needs root, so it goes through hive-priv.
let machine = crate::lifecycle::container_name(agent);
tracing::info!(%agent, %machine, %n, "manager: get_logs");
match tokio::process::Command::new("journalctl")
.args([
"-M",
&machine,
"-n",
&n.to_string(),
"--no-pager",
"--output=short",
])
.output()
.await
match crate::priv_client::read_container_journal(
&machine,
n,
false,
hive_sh4re::priv_proto::JournalOutput::Short,
None,
None,
None,
None,
None,
)
.await
{
Ok(out) => {
let content = if out.status.success() || !out.stdout.is_empty() {
String::from_utf8_lossy(&out.stdout).into_owned()
} else {
let stderr = String::from_utf8_lossy(&out.stderr);
format!("journalctl exited {}: {stderr}", out.status)
};
Ok((stdout, stderr)) => {
let content = if !stdout.is_empty() { stdout } else { stderr };
ManagerResponse::Logs { content }
}
Err(e) => ManagerResponse::Err {
message: format!("journalctl spawn failed: {e:#}"),
message: format!("get_logs: {e:#}"),
},
}
}

View file

@ -7,7 +7,7 @@
//! a persistent connection.
use anyhow::{Context as _, Result, bail};
use hive_sh4re::priv_proto::{BindMount, PRIV_SOCK, PrivRequest, PrivResponse};
use hive_sh4re::priv_proto::{BindMount, JournalOutput, PRIV_SOCK, PrivRequest, PrivResponse};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
@ -81,6 +81,37 @@ pub async fn list_containers() -> Result<String> {
Ok(stdout)
}
/// Read a container's journal via the root helper (`journalctl -M`).
/// Returns `(stdout, stderr)`; a non-zero journalctl exit is reported in
/// `stderr` rather than as an `Err`, so callers can surface either.
#[allow(clippy::too_many_arguments)]
pub async fn read_container_journal(
container: &str,
lines: u32,
boot: bool,
output: JournalOutput,
unit: Option<String>,
priority: Option<String>,
grep: Option<String>,
since: Option<String>,
until: Option<String>,
) -> Result<(String, String)> {
check(
call(&PrivRequest::ReadContainerJournal {
container: container.to_owned(),
lines,
boot,
output,
unit,
priority,
grep,
since,
until,
})
.await?,
)
}
pub async fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> {
ok(call(&PrivRequest::WriteNspawnFlags {
container: container.to_owned(),