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::coordinator::Coordinator;
|
||||||
use crate::lifecycle::{self, MANAGER_NAME};
|
use crate::lifecycle::{self, MANAGER_NAME};
|
||||||
|
|
||||||
|
mod journal;
|
||||||
mod permissions;
|
mod permissions;
|
||||||
mod schedules;
|
mod schedules;
|
||||||
mod webhook;
|
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("/answer-question/{id}", post(post_answer_question))
|
||||||
.route("/cancel-question/{id}", post(post_cancel_question))
|
.route("/cancel-question/{id}", post(post_cancel_question))
|
||||||
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
|
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
|
||||||
.route("/api/journal/{name}", get(get_journal))
|
.route("/api/journal/{name}", get(journal::get_journal))
|
||||||
.route("/api/journal-host", get(get_journal_host))
|
.route("/api/journal-host", get(journal::get_journal_host))
|
||||||
.route("/api/approval-diff/{id}", get(get_approval_diff))
|
.route("/api/approval-diff/{id}", get(get_approval_diff))
|
||||||
.route("/api/state-file", get(get_state_file))
|
.route("/api/state-file", get(get_state_file))
|
||||||
.route("/api/reminders", get(api_reminders))
|
.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)]
|
#[derive(Deserialize)]
|
||||||
struct BuildLogsAllQuery {
|
struct BuildLogsAllQuery {
|
||||||
/// Max rows to return. Capped at 100. Default 30.
|
/// 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