dashboard: remove the L0GS page and its journal backend
Logs live at swarm level now (VictoriaLogs, read through Grafana
Explore). The hive dashboard's own journal viewer goes, with
everything that existed only to serve it:
- frontend: /logs.html, logs.js, logs.css, the H0M3 "Logs" tile and
the per-agent menu's "journal logs ->" link. The build-log list
rules BU1LDS used from logs.css move into builds.css.
- hive-c0re: GET /api/journal/{name} and GET /api/journal-host, and
priv_client::read_container_journal.
- hive-priv / hive-priv-sock: the ReadContainerJournal request, its
JournalQuery/JournalOutput wire types and the journalctl -M executor.
- nix: hive-c0re's systemd-journal supplementary group, whose only
reader was the journal-host handler.
- docs: the L0GS section, the API entry and the security table row.
Closes #5113
Closes #3819
This commit is contained in:
parent
71e7fd8b20
commit
884fb3e08b
31 changed files with 192 additions and 1231 deletions
|
|
@ -1,253 +0,0 @@
|
|||
//! Journal-read endpoints for the dashboard.
|
||||
//!
|
||||
//! `GET /api/journal/{name}` reads a managed agent container's journal, OR
|
||||
//! one of the three hive infra services (`hive-forge`, `hive-gateway`,
|
||||
//! `hive-matrix` — [`hive_priv_sock::InfraContainer`] is the
|
||||
//! allowlist). A container's journal is a `journalctl -M` read, delegated
|
||||
//! to the root helper since entering a machine needs privileges hive-c0re
|
||||
//! doesn't have; `hive-gateway` is nginx on the host, so it reads host
|
||||
//! journald filtered to that unit and needs no helper at all.
|
||||
//! `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 utoipa::IntoParams;
|
||||
|
||||
use problem_details::ProblemDetails;
|
||||
|
||||
use super::{Ident, error_problem, strip_container_prefix};
|
||||
use crate::lifecycle;
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub(super) struct JournalQuery {
|
||||
/// Optional systemd unit filter — e.g. `hive-agent.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.
|
||||
///
|
||||
/// `name` is either a managed agent name (`iris`, optionally already
|
||||
/// carrying the `h-` prefix) or one of the three infra names (`hive-forge` /
|
||||
/// `hive-gateway` / `hive-matrix` — see
|
||||
/// [`hive_priv_sock::InfraContainer`]). Infra targets don't run the
|
||||
/// per-agent hive daemons, so `unit` is ignored for them — the whole
|
||||
/// machine journal, or for the gateway the host journal filtered to its
|
||||
/// own unit.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/journal/{name}",
|
||||
params(
|
||||
("name" = String, Path, description = "agent name, or one of the three infra container names"),
|
||||
JournalQuery,
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "journal text", body = String, content_type = "text/plain"),
|
||||
(status = 400, description = "bad agent name or unknown unit"),
|
||||
(status = 404, description = "no such managed container"),
|
||||
(status = 500, description = "container list unreadable"),
|
||||
),
|
||||
tag = "journal"
|
||||
)]
|
||||
pub(super) async fn get_journal(
|
||||
AxumPath(name): AxumPath<String>,
|
||||
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
let lines = q.lines.unwrap_or(500).min(5000);
|
||||
|
||||
if let Ok(infra) = name.parse::<hive_priv_sock::InfraContainer>() {
|
||||
return match infra.target() {
|
||||
hive_priv_sock::InfraTarget::Container(machine) => {
|
||||
read_journal_response(machine, None, lines).await
|
||||
}
|
||||
// No machine to enter — the gateway's nginx is a host unit, so
|
||||
// this is a plain host-journal read filtered to it. `-M` is
|
||||
// what needed root here, not journalctl itself.
|
||||
hive_priv_sock::InfraTarget::HostUnit(unit) => {
|
||||
read_host_journal_response(Some(unit), lines).await
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
let name = match Ident::parse(&name) {
|
||||
Ok(n) => n,
|
||||
Err(reason) => {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("bad agent name: {reason}")));
|
||||
}
|
||||
};
|
||||
// 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.as_str());
|
||||
let prefixed = format!("{}{container}", lifecycle::AGENT_PREFIX);
|
||||
let live = match lifecycle::list().await {
|
||||
Ok(live) => live,
|
||||
Err(e) => {
|
||||
return Err(error_problem(&format!(
|
||||
"journal: listing containers: {e:#}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if !live.iter().any(|c| c == &prefixed) {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::NOT_FOUND)
|
||||
.with_detail(format!("journal: no managed container {prefixed:?}")));
|
||||
}
|
||||
let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(u) => {
|
||||
// accept any of the per-container hive daemons [.service] —
|
||||
// anything else refused.
|
||||
let allowed = [
|
||||
"hive-agent.service",
|
||||
"hive-mcp-http.service",
|
||||
"hive-bash-daemon.service",
|
||||
"hive-subagent-daemon.service",
|
||||
"hive-matrix-daemon.service",
|
||||
];
|
||||
let unit = if u.ends_with(".service") {
|
||||
u.to_owned()
|
||||
} else {
|
||||
format!("{u}.service")
|
||||
};
|
||||
if !allowed.contains(&unit.as_str()) {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("journal: unknown unit {unit:?}")));
|
||||
}
|
||||
Some(unit)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
read_journal_response(&prefixed, unit, lines).await
|
||||
}
|
||||
|
||||
/// Shared `journalctl -M <machine> [-u <unit>]` shellout + response
|
||||
/// formatting for [`get_journal`], factored out so the infra-container
|
||||
/// branch (no `unit` filtering) and the agent-container branch (allow-listed
|
||||
/// `unit` filtering) don't duplicate the priv-client call + stdout/stderr
|
||||
/// combining.
|
||||
async fn read_journal_response(
|
||||
machine: &str,
|
||||
unit: Option<String>,
|
||||
lines: u32,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
match crate::priv_client::read_container_journal(
|
||||
machine,
|
||||
hive_priv_sock::JournalQuery {
|
||||
lines,
|
||||
boot: true,
|
||||
output: hive_priv_sock::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);
|
||||
}
|
||||
Ok(([("content-type", "text/plain; charset=utf-8")], body).into_response())
|
||||
}
|
||||
Err(e) => Err(error_problem(&format!("journal read: {e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
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>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/journal-host",
|
||||
params(JournalHostQuery),
|
||||
responses(
|
||||
(status = 200, description = "journal text", body = String, content_type = "text/plain"),
|
||||
(status = 400, description = "unknown unit"),
|
||||
),
|
||||
tag = "journal"
|
||||
)]
|
||||
pub(super) async fn get_journal_host(
|
||||
axum::extract::Query(q): axum::extract::Query<JournalHostQuery>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
let lines = q.lines.unwrap_or(500).min(5000);
|
||||
// `nginx.service` is the gateway — its logs are host-side.
|
||||
let allowed = ["hive-c0re.service", "hive-priv.service", "nginx.service"];
|
||||
let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(u) => {
|
||||
let unit = if u.ends_with(".service") {
|
||||
u.to_owned()
|
||||
} else {
|
||||
format!("{u}.service")
|
||||
};
|
||||
if !allowed.contains(&unit.as_str()) {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("journal-host: unknown unit {unit:?}")));
|
||||
}
|
||||
Some(unit)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
read_host_journal_response(unit.as_deref(), lines).await
|
||||
}
|
||||
|
||||
/// `journalctl [-u <unit>]` on the host + response formatting. No `-M`, so
|
||||
/// no root and no priv-client hop — hive-c0re reads host journald directly.
|
||||
///
|
||||
/// ⚠️ `unit` is trusted by the time it gets here: [`get_journal_host`]
|
||||
/// allow-lists an operator-supplied one, and [`get_journal`] passes a unit
|
||||
/// that came from the [`hive_priv_sock::InfraContainer`] enum. Don't hand
|
||||
/// this a raw query parameter.
|
||||
async fn read_host_journal_response(
|
||||
unit: Option<&str>,
|
||||
lines: u32,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
let mut cmd = tokio::process::Command::new("journalctl");
|
||||
cmd.args(["--no-pager", "--output=short-iso", "--lines"])
|
||||
.arg(lines.to_string());
|
||||
if let Some(u) = unit {
|
||||
cmd.args(["-u", u]);
|
||||
}
|
||||
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));
|
||||
}
|
||||
Ok(([("content-type", "text/plain; charset=utf-8")], body).into_response())
|
||||
}
|
||||
Err(e) => Err(error_problem(&format!("journalctl spawn: {e}"))),
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,6 @@ use crate::lifecycle;
|
|||
),
|
||||
tags(
|
||||
(name = "health", description = "hive-wide liveness/readiness probes"),
|
||||
(name = "journal", description = "container + host journal reads"),
|
||||
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
|
||||
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
|
||||
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||
|
|
@ -54,7 +53,6 @@ mod build_logs;
|
|||
// server reach it as `crate::dashboard::Ident`.
|
||||
pub(crate) use hive_types::Ident;
|
||||
mod health;
|
||||
mod journal;
|
||||
mod lifecycle_ops;
|
||||
mod meta_inputs;
|
||||
mod misc_api;
|
||||
|
|
@ -115,8 +113,6 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
|||
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
||||
.routes(routes!(health::get_health_live))
|
||||
.routes(routes!(health::get_health_ready))
|
||||
.routes(routes!(journal::get_journal))
|
||||
.routes(routes!(journal::get_journal_host))
|
||||
.routes(routes!(state_snapshot::api_state))
|
||||
.routes(routes!(state_files::get_state_file))
|
||||
.routes(routes!(misc_api::api_operator_inbox))
|
||||
|
|
@ -331,8 +327,6 @@ mod router_build_probe {
|
|||
let _ = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
||||
.routes(routes!(health::get_health_live))
|
||||
.routes(routes!(health::get_health_ready))
|
||||
.routes(routes!(journal::get_journal))
|
||||
.routes(routes!(journal::get_journal_host))
|
||||
.routes(routes!(state_snapshot::api_state))
|
||||
.routes(routes!(state_files::get_state_file))
|
||||
.routes(routes!(misc_api::api_operator_inbox))
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@
|
|||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use hive_priv_sock::{
|
||||
BindMount, CredentialMount, InfraAction, InfraContainer, JournalQuery,
|
||||
MEASURE_AGENT_DISK_TIMEOUT, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse,
|
||||
PrivStream,
|
||||
BindMount, CredentialMount, InfraAction, InfraContainer, MEASURE_AGENT_DISK_TIMEOUT,
|
||||
NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream,
|
||||
};
|
||||
use std::os::fd::{AsRawFd as _, OwnedFd, RawFd};
|
||||
|
||||
|
|
@ -284,22 +283,6 @@ 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.
|
||||
pub async fn read_container_journal(
|
||||
container: &str,
|
||||
query: JournalQuery,
|
||||
) -> Result<(String, String)> {
|
||||
check(
|
||||
call(&PrivRequest::ReadContainerJournal {
|
||||
container: container.to_owned(),
|
||||
query,
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn write_nspawn_flags(
|
||||
container: &str,
|
||||
binds: &[BindMount],
|
||||
|
|
|
|||
Loading…
Reference in a new issue