refactor(#1456): extract dashboard journal-read endpoints into dashboard/journal.rs
This commit is contained in:
parent
1255268f4f
commit
e2fdaae841
2 changed files with 142 additions and 124 deletions
|
|
@ -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.
|
||||
|
|
|
|||
139
hive-c0re/src/dashboard/journal.rs
Normal file
139
hive-c0re/src/dashboard/journal.rs
Normal 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}")),
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue