feat(3088): move the gateway's nginx + dnsmasq onto the host
The gateway's nginx + dnsmasq no longer run in their own nspawn container. `nix/host-modules/hive-gateway/default.nix` loses the `containers.hive-gateway` wrapper and everything that existed only to punch holes in it: `privateNetwork = false`, `CAP_NET_ADMIN`, five bind mounts, its own `stateVersion`, `networking.firewall.enable = false`, `networking.resolvconf.enable = false`, and the `hive-gateway-resolv` path+service pair. 465 -> 303 lines. The container never bought isolation here. It shared the host netns by necessity — nginx binds the host's :80/:443, dnsmasq answers on the bridge — so each of those settings was undoing a boundary the gateway could not afford in the first place. Four things made it more than a deletion, none of them visible in the nix diff: - The self-signed cert service also imports the hive CA leaf, so removing it with the container would have left nginx naming a missing cert file, which it refuses to load at all. - The nginx reload is a hive-priv verb. It still needs root, but no longer for the reason its doc gave, and `--machine=` was both transport and scope — so the unit name is now hard-coded in the helper as the containment. - The lifecycle verb named a container that stops existing. - `journalctl -M hive-gateway` had no machine to enter. Per the operator's ruling, the operator verb keeps working and agents lose it. `InfraContainer` answered three questions that used to share an answer; it now splits into `name()` (identity), `target()` (Container vs HostUnit), `service_unit()` (the systemd unit), and `agent_restartable()`, which the MCP restart path checks before the capability so the refusal cannot read as "ask for infra_admin". `SIBLING_CONTAINERS` drops the gateway — it gates the requests that name a container as a string — while `FromStr` still accepts it, because that answers what a name is, not who may act on it. The dashboard's gateway journal reads host journald filtered to `nginx.service`. Prose was corrected where it only named a location, and re-argued where the container was doing security work: a `0666` per-agent socket was safe because only the gateway container had the directory bind-mounted. There is no mount now, so the directory permissions are the whole of the access control — the constraint holds, its mechanism doesn't. Gate: nix fmt / clippy --all-targets -D warnings / cargo test all clean (710 tests); hivectl-cli.md regenerated from the clap tree. The nix eval was run in both TLS shapes at this commit: every delta in the rendered virtualHosts is one of the three intended path moves, dnsmasq settings are byte-identical, and the absence probe flips true -> false with bindMounts emptied.
This commit is contained in:
parent
cae2cf8df6
commit
07852cabc1
34 changed files with 704 additions and 618 deletions
|
|
@ -1,9 +1,13 @@
|
|||
//! Dashboard endpoint for operator-driven infra-container lifecycle
|
||||
//! (start / stop / restart on `hive-ci`, `hive-forge`, `hive-gateway`,
|
||||
//! `hive-matrix`). Parallels the `infra_admin`-gated agent path in
|
||||
//! Dashboard endpoint for operator-driven infra lifecycle (start / stop /
|
||||
//! restart on `hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`).
|
||||
//! Parallels the `infra_admin`-gated agent path in
|
||||
//! `socket_server/lifecycle_handlers.rs::handle_restart_infra`, but this one
|
||||
//! is reached from the dashboard — already fully operator-authenticated —
|
||||
//! so no capability check is needed here, just the same audit trail.
|
||||
//!
|
||||
//! The two surfaces no longer cover the same set: the gateway is the
|
||||
//! operator's to restart and not an agent's, since nginx on the host fronts
|
||||
//! every hive service. This endpoint keeps all four.
|
||||
|
||||
use axum::{
|
||||
extract::{Path as AxumPath, State},
|
||||
|
|
@ -27,7 +31,7 @@ use super::{AppState, error_response};
|
|||
post,
|
||||
path = "/api/infra-container/{name}/{action}",
|
||||
params(
|
||||
("name" = String, Path, description = "infra container name (hive-ci/hive-forge/hive-gateway/hive-matrix)"),
|
||||
("name" = String, Path, description = "infra service name (hive-ci/hive-forge/hive-gateway/hive-matrix)"),
|
||||
("action" = String, Path, description = "start | stop | restart"),
|
||||
),
|
||||
responses(
|
||||
|
|
@ -53,8 +57,8 @@ pub(super) async fn post_infra_container(
|
|||
));
|
||||
}
|
||||
};
|
||||
let unit = container.unit_name();
|
||||
tracing::info!(%unit, %action, "dashboard: infra container action");
|
||||
let target = container.name();
|
||||
tracing::info!(%target, %action, "dashboard: infra container action");
|
||||
let result = crate::priv_client::control_infra_container(container, infra_action).await;
|
||||
let outcome = if result.is_ok() {
|
||||
crate::audit_log::AuditOutcome::Ok
|
||||
|
|
@ -66,12 +70,12 @@ pub(super) async fn post_infra_container(
|
|||
state
|
||||
.coord
|
||||
.audit_log
|
||||
.record("operator", action_label, unit, outcome, detail.as_deref())
|
||||
.record("operator", action_label, target, outcome, detail.as_deref())
|
||||
{
|
||||
state.coord.emit_audit_entry(entry);
|
||||
}
|
||||
match result {
|
||||
Ok(()) => (StatusCode::OK, "ok").into_response(),
|
||||
Err(e) => error_response(&format!("{unit}: {e:#}")),
|
||||
Err(e) => error_response(&format!("{target}: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
//! Journal-read endpoints for the dashboard.
|
||||
//!
|
||||
//! `GET /api/journal/{name}` reads a managed agent container's journal, OR
|
||||
//! one of the four hive infra containers (`hive-ci`, `hive-forge`,
|
||||
//! one of the four hive infra services (`hive-ci`, `hive-forge`,
|
||||
//! `hive-gateway`, `hive-matrix` — [`hive_priv_sock::InfraContainer`] is the
|
||||
//! allowlist), 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.
|
||||
//! 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,
|
||||
|
|
@ -40,11 +42,12 @@ pub(super) struct JournalQuery {
|
|||
/// 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 four infra container names
|
||||
/// (`hive-ci` / `hive-forge` / `hive-gateway` / `hive-matrix` — see
|
||||
/// [`hive_priv_sock::InfraContainer`]). Infra containers don't run the
|
||||
/// per-agent hive daemons, so `unit` is ignored for them — always the
|
||||
/// full machine journal.
|
||||
/// carrying the `h-` prefix) or one of the four infra names (`hive-ci` /
|
||||
/// `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}",
|
||||
|
|
@ -66,7 +69,17 @@ pub(super) async fn get_journal(
|
|||
let lines = q.lines.unwrap_or(500).min(5000);
|
||||
|
||||
if let Ok(infra) = name.parse::<hive_priv_sock::InfraContainer>() {
|
||||
return read_journal_response(infra.unit_name(), None, lines).await;
|
||||
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
|
||||
|
|
@ -180,21 +193,43 @@ 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);
|
||||
let allowed = ["hive-c0re.service", "hive-priv.service"];
|
||||
// `nginx.service` is the gateway: its logs used to live in the
|
||||
// hive-gateway container's journal and are host-side now.
|
||||
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) = 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 Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("journal-host: unknown unit {unit:?}")));
|
||||
}
|
||||
cmd.args(["-u", &unit]);
|
||||
if let Some(u) = unit {
|
||||
cmd.args(["-u", u]);
|
||||
}
|
||||
match cmd.output().await {
|
||||
Ok(out) => {
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ async fn infra_container_views() -> Vec<InfraContainerView> {
|
|||
let mut infra_containers = Vec::with_capacity(hive_priv_sock::InfraContainer::ALL.len());
|
||||
for container in hive_priv_sock::InfraContainer::ALL {
|
||||
infra_containers.push(InfraContainerView {
|
||||
name: container.unit_name(),
|
||||
name: container.name(),
|
||||
running: crate::lifecycle::infra_is_running(container).await,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
//! Runtime nginx include-file generator for the gateway's per-agent
|
||||
//! `/agent/<name>/` location blocks. Writes
|
||||
//! `/var/lib/hyperhive/gateway/agents.conf` on every topology change.
|
||||
//! UDS upstream selection, reload trigger (`systemd-run
|
||||
//! --machine=hive-gateway`), and idempotency:
|
||||
//! UDS upstream selection, the reload trigger, and idempotency:
|
||||
//! `docs/gateway.md::Per-agent unix-socket upstream`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
|
@ -19,13 +18,13 @@ use crate::agent_sockets;
|
|||
/// Set when `write` publishes a new agents.conf; cleared when
|
||||
/// `reload_gateway_nginx` submits the reload command successfully.
|
||||
/// Lets `spawn_poll` retry the reload on subsequent ticks when the
|
||||
/// previous attempt failed (e.g. gateway container temporarily down,
|
||||
/// systemd-run not found) without re-writing the already-correct file.
|
||||
/// previous attempt failed (e.g. gateway nginx temporarily down, priv
|
||||
/// helper unreachable) without re-writing the already-correct file.
|
||||
static RELOAD_PENDING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Unix timestamp (seconds) of the last failed reload attempt.
|
||||
/// `reload_if_pending` backs off to once per `RELOAD_RETRY_SECS` after a
|
||||
/// failure so a permanently broken gateway (bad config, container down)
|
||||
/// failure so a permanently broken gateway (bad config, nginx down)
|
||||
/// doesn't hammer `systemctl` on every 10-second `spawn_poll` tick.
|
||||
static LAST_FAILED_RELOAD: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
|
|
@ -79,7 +78,7 @@ fn render(names: &[String], frontend_dir: Option<&str>) -> String {
|
|||
let mut out = String::from(
|
||||
"# Generated by hive-c0re \u{2014} do not edit.\
|
||||
\n# Refreshed on every topology change + when agents bind/drop their unix sockets.\
|
||||
\n# Reload triggered by hive-c0re via systemd-run --machine=hive-gateway.\n",
|
||||
\n# Reload triggered by hive-c0re via hive-priv (systemctl reload nginx).\n",
|
||||
);
|
||||
for name in names {
|
||||
// Two upstream forms because named locations (split mode's
|
||||
|
|
@ -165,15 +164,19 @@ fn render(names: &[String], frontend_dir: Option<&str>) -> String {
|
|||
/// body matches what's already on disk (idempotent; avoids spurious
|
||||
/// gateway reloads on a quiet tick).
|
||||
///
|
||||
/// After a successful write, triggers the appropriate nginx action inside
|
||||
/// the gateway container via `hive-priv` (which has the
|
||||
/// `--machine=hive-gateway` transport rights hive-c0re lacks):
|
||||
/// reload when nginx is active, reset-failed+start when in a failed
|
||||
/// state, plain start otherwise. This is intentionally host-side rather
|
||||
/// than relying on a systemd path unit inside the container watching the
|
||||
/// bind-mounted file: `IN_MOVED_TO` (fired by the atomic rename) does
|
||||
/// not reliably propagate across the nspawn mount-namespace boundary, so
|
||||
/// the path-unit approach was silently broken (see `docs/gateway.md`).
|
||||
/// After a successful write, triggers the appropriate nginx action via
|
||||
/// `hive-priv` (hive-c0re runs unprivileged and cannot act on a system
|
||||
/// unit): reload when nginx is active, reset-failed+start when in a
|
||||
/// failed state, plain start otherwise. Writer and nginx are now on the
|
||||
/// same machine, so this is a plain unit action rather than the old
|
||||
/// `systemd-run --machine=hive-gateway` hop across the container
|
||||
/// boundary. It stays an explicit trigger rather than a systemd path
|
||||
/// unit watching the file. A path unit would now *work* — `IN_MOVED_TO`
|
||||
/// (fired by the atomic rename) failed to propagate across the nspawn
|
||||
/// mount-namespace boundary, and that boundary is gone — but it is still
|
||||
/// not wanted: the write already knows it changed something, and a
|
||||
/// watcher turns one causal edge into a race with the writer's own
|
||||
/// rename (see `docs/gateway.md`).
|
||||
///
|
||||
/// The priv call is best-effort — a failed sync is logged but not fatal.
|
||||
/// `reload_if_pending` retries on the next `spawn_poll` tick so a
|
||||
|
|
@ -212,7 +215,7 @@ pub async fn write(names: &[String]) -> Result<()> {
|
|||
|
||||
/// Retry a pending nginx reload if a previous attempt failed.
|
||||
/// Called by `spawn_poll` on each tick so a transient failure
|
||||
/// (gateway container temporarily down, systemd-run error) is
|
||||
/// (gateway nginx temporarily down, priv-helper error) is
|
||||
/// recovered automatically without requiring a new file write.
|
||||
///
|
||||
/// Backs off to one retry per `RELOAD_RETRY_SECS` after a failure so a
|
||||
|
|
@ -240,8 +243,8 @@ pub async fn reload_if_pending() {
|
|||
/// Synchronise the gateway nginx unit with the current agents.conf via
|
||||
/// `hive-priv` (privileged helper). The state-aware logic (active →
|
||||
/// reload; failed → reset-failed + start; inactive/unknown → start)
|
||||
/// runs inside hive-priv where it has the `--machine=hive-gateway`
|
||||
/// transport rights that hive-c0re (unprivileged) lacks.
|
||||
/// runs inside hive-priv, which is root; hive-c0re runs as the
|
||||
/// unprivileged `hive-core` user and cannot act on a system unit.
|
||||
///
|
||||
/// `RELOAD_PENDING` is cleared only after a successful operation so
|
||||
/// `reload_if_pending` keeps retrying on failure.
|
||||
|
|
|
|||
|
|
@ -604,14 +604,14 @@ pub async fn is_running(name: &str) -> bool {
|
|||
.is_ok_and(|s| s.success())
|
||||
}
|
||||
|
||||
/// True when a hive infrastructure container's systemd unit is active.
|
||||
/// Sibling of [`is_running`] for sub-agents, but infra container/unit names
|
||||
/// (`hive-ci`, …) already have no `h-` prefix to strip, so this queries
|
||||
/// `container@<unit_name>.service` directly rather than going through
|
||||
/// [`container_name`]. Used by the dashboard C0R3 page's 1NFR4 sub-tab to
|
||||
/// show each infra container's live status dot.
|
||||
/// True when a hive infrastructure service's systemd unit is active.
|
||||
/// Sibling of [`is_running`] for sub-agents, but infra names (`hive-ci`, …)
|
||||
/// have no `h-` prefix to strip and are not all containers, so the unit
|
||||
/// comes from the variant itself rather than from [`container_name`]. Used
|
||||
/// by the dashboard C0R3 page's 1NFR4 sub-tab to show each one's live
|
||||
/// status dot.
|
||||
pub async fn infra_is_running(container: hive_priv_sock::InfraContainer) -> bool {
|
||||
let unit = format!("container@{}.service", container.unit_name());
|
||||
let unit = container.service_unit();
|
||||
Command::new("systemctl")
|
||||
.args(["is-active", "--quiet", &unit])
|
||||
.status()
|
||||
|
|
|
|||
|
|
@ -168,9 +168,9 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
|||
}
|
||||
|
||||
// Refresh /var/lib/hyperhive/gateway/agents.conf — the nginx include
|
||||
// file the gateway container bind-mounts and nginx reads at runtime.
|
||||
// c0re triggers a reload (or start) inside hive-gateway via hive-priv
|
||||
// after writing the file. Same best-effort + non-fatal shape.
|
||||
// file the gateway reads at runtime. c0re then triggers a reload (or
|
||||
// start) of the host's nginx via hive-priv, since c0re is unprivileged.
|
||||
// Same best-effort + non-fatal shape.
|
||||
if let Err(e) = crate::gateway_nginx::write(&agent_names).await {
|
||||
tracing::warn!(error = ?e, "gateway_nginx::write failed (non-fatal)");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,11 +219,12 @@ pub fn shared_root() -> PathBuf {
|
|||
pub const KNOWLEDGE_DIR: &str = "/var/lib/hyperhive/knowledge";
|
||||
|
||||
/// `gateway/` — generated nginx include fragments for the gateway vhost.
|
||||
/// The gateway container bind-mounts *this subdir only* (not the whole
|
||||
/// state root) at `/run/hive-state/`, so nginx can read `agents.conf`
|
||||
/// without the rest of `/var/lib/hyperhive/` (forge/matrix tokens, etc.)
|
||||
/// being exposed to the gateway container.
|
||||
// nix: bind-mounted into the gateway container (hive-gateway.nix) — must match.
|
||||
/// nginx runs on the host and reads this path directly; it used to be
|
||||
/// bind-mounted into a gateway container at `/run/hive-state/`, exposing
|
||||
/// this subdir *only* so the rest of `/var/lib/hyperhive/` (forge/matrix
|
||||
/// tokens, etc.) stayed out of reach. On the host that narrowing is the
|
||||
/// unit's sandbox, not a mount — nginx is not confined by this path.
|
||||
// nix: named by the gateway's nginx config (hive-gateway/vhosts.nix) — must match.
|
||||
#[must_use]
|
||||
pub fn gateway_dir() -> PathBuf {
|
||||
state_root().join("gateway")
|
||||
|
|
|
|||
|
|
@ -477,20 +477,21 @@ pub async fn register_ci_runner(token: &str) -> Result<()> {
|
|||
.await?)
|
||||
}
|
||||
|
||||
/// Restart a hive infrastructure container on the host (thin wrapper over
|
||||
/// [`control_infra_container`] with `action = Restart`). hive-priv
|
||||
/// re-validates `container` against its root-side allowlist; callers must
|
||||
/// already have checked the requesting agent holds the `infra_admin`
|
||||
/// capability.
|
||||
/// Restart a hive infrastructure service on the host (thin wrapper over
|
||||
/// [`control_infra_container`] with `action = Restart`). Callers must
|
||||
/// already have checked that the requesting agent holds the `infra_admin`
|
||||
/// capability *and* that the target is
|
||||
/// [`agent_restartable`](InfraContainer::agent_restartable).
|
||||
pub async fn restart_infra_container(container: InfraContainer) -> Result<()> {
|
||||
control_infra_container(container, InfraAction::Restart).await
|
||||
}
|
||||
|
||||
/// Start / stop / restart a hive infrastructure container (`hive-ci`,
|
||||
/// Start / stop / restart a hive infrastructure service (`hive-ci`,
|
||||
/// `hive-gateway`, `hive-forge`, `hive-matrix`) on the host via `systemctl
|
||||
/// <action> container@<container>.service`. The [`InfraContainer`] enum is
|
||||
/// the allowlist — hive-priv needs no name re-validation. Used by the
|
||||
/// hive-wide `hivectl stop` / `hivectl start` flow.
|
||||
/// <action> <unit>`, where the unit is derived root-side from the variant
|
||||
/// (`container@<name>.service`, or `nginx.service` for the gateway). The
|
||||
/// [`InfraContainer`] enum is the allowlist — hive-priv needs no name
|
||||
/// re-validation. Used by the hive-wide `hivectl stop` / `start` flow.
|
||||
pub async fn control_infra_container(container: InfraContainer, action: InfraAction) -> Result<()> {
|
||||
ok(call(&PrivRequest::ControlInfraContainer { container, action }).await?)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -863,7 +863,7 @@ async fn handle_stop(
|
|||
await_dags(coord, &queued, std::time::Duration::from_mins(2)).await;
|
||||
}
|
||||
for &container in infra {
|
||||
let name = container.unit_name();
|
||||
let name = container.name();
|
||||
match crate::priv_client::control_infra_container(container, InfraAction::Stop).await {
|
||||
Ok(()) => ok_items.push(name.to_owned()),
|
||||
Err(e) => {
|
||||
|
|
@ -919,7 +919,7 @@ async fn handle_start(
|
|||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
for &container in infra {
|
||||
let name = container.unit_name();
|
||||
let name = container.name();
|
||||
match crate::priv_client::control_infra_container(container, InfraAction::Start).await {
|
||||
Ok(()) => ok_items.push(name.to_owned()),
|
||||
Err(e) => {
|
||||
|
|
@ -990,7 +990,7 @@ async fn handle_restart_scoped(
|
|||
}
|
||||
|
||||
for &container in &infra {
|
||||
let name = container.unit_name();
|
||||
let name = container.name();
|
||||
let res = async {
|
||||
crate::priv_client::control_infra_container(container, InfraAction::Stop).await?;
|
||||
crate::priv_client::control_infra_container(container, InfraAction::Start).await
|
||||
|
|
|
|||
|
|
@ -31,12 +31,13 @@ pub(super) async fn handle_start(coord: &Arc<Coordinator>, agent: &str, name: &s
|
|||
/// orthogonal: it is gated on the `infra_admin` capability and audited, so it
|
||||
/// stays ahead of the topology guard.
|
||||
pub(super) async fn handle_restart(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Response {
|
||||
// Infra-container restart: an agent holding the `infra_admin`
|
||||
// capability can restart a hive infrastructure container (hive-ci /
|
||||
// hive-gateway / hive-forge / hive-matrix) by passing its name to the
|
||||
// same restart tool. The `InfraContainer` enum parse both recognises
|
||||
// these (never agent children, so disjoint from the child path below)
|
||||
// and yields the typed value the restart path needs.
|
||||
// Infra restart: an agent holding the `infra_admin` capability can
|
||||
// restart a hive infrastructure service (hive-ci / hive-forge /
|
||||
// hive-matrix) by passing its name to the same restart tool. The
|
||||
// `InfraContainer` enum parse both recognises these (never agent
|
||||
// children, so disjoint from the child path below) and yields the typed
|
||||
// value the restart path needs. It recognises `hive-gateway` too, which
|
||||
// is then refused — a name the agent surface knows but may not act on.
|
||||
if let Ok(container) = name.parse::<hive_priv_sock::InfraContainer>() {
|
||||
return handle_restart_infra(coord, agent, container).await;
|
||||
}
|
||||
|
|
@ -60,7 +61,7 @@ async fn handle_restart_infra(
|
|||
agent: &str,
|
||||
container: hive_priv_sock::InfraContainer,
|
||||
) -> Response {
|
||||
let name = container.unit_name();
|
||||
let name = container.name();
|
||||
// Record the attempt in the operator-visible privileged-action audit
|
||||
// trail, then emit a live `AuditEntryAdded` so the dashboard audit view
|
||||
// appends it off `/dashboard/stream`. Best-effort: `record` returns the
|
||||
|
|
@ -75,6 +76,22 @@ async fn handle_restart_infra(
|
|||
coord.emit_audit_entry(entry);
|
||||
}
|
||||
};
|
||||
// Some targets are off-limits to agents regardless of capability — the
|
||||
// gateway, because nginx fronts every hive service from the host and an
|
||||
// agent bouncing it takes out the forge, the dashboard and matrix at
|
||||
// once, including the route its own fix would have to travel. Checked
|
||||
// before the capability so the refusal doesn't read as "ask for
|
||||
// infra_admin"; no capability grants this.
|
||||
if !container.agent_restartable() {
|
||||
tracing::warn!(%agent, %name, "agent: infra restart denied (not agent-restartable)");
|
||||
audit(
|
||||
crate::audit_log::AuditOutcome::Err,
|
||||
Some("denied: target is not agent-restartable"),
|
||||
);
|
||||
return Response::Err {
|
||||
message: format!("`{name}` cannot be restarted by an agent; ask the operator"),
|
||||
};
|
||||
}
|
||||
if !crate::capabilities::has_cap(agent, hive_sh4re::permissions::Capability::InfraAdmin) {
|
||||
tracing::warn!(%agent, %name, "agent: infra restart denied (no infra_admin capability)");
|
||||
audit(
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ use std::path::{Path, PathBuf};
|
|||
use anyhow::{Context, Result};
|
||||
|
||||
/// Host-side parent directory holding per-agent socket subdirs. The
|
||||
/// gateway container bind-mounts this whole tree (read-only) so it
|
||||
/// can `proxy_pass` to any agent. Each agent's container bind-mounts
|
||||
/// only its own `<name>/` subdir — agents can only access their own
|
||||
/// sockets. The literal lives in `hive-host-sock` (shared with
|
||||
/// gateway's nginx runs on the host and reads this whole tree, so it
|
||||
/// can `proxy_pass` to any agent — it used to get there through a
|
||||
/// read-only bind-mount of the same tree. Each agent's container
|
||||
/// bind-mounts only its own `<name>/` subdir, which is still what stops
|
||||
/// one agent reaching another's socket. The literal lives in `hive-host-sock` (shared with
|
||||
/// `hivectl`); re-exported here under the name this module's consumers
|
||||
/// have always used.
|
||||
pub use hive_host_sock::AGENT_SOCKET_DIR;
|
||||
|
|
@ -166,7 +167,7 @@ pub fn write(names: &[String]) -> Result<()> {
|
|||
///
|
||||
/// Also calls `gateway_nginx::reload_if_pending` on every tick to
|
||||
/// retry a gateway nginx reload that may have failed on the previous
|
||||
/// tick (e.g. gateway container temporarily down). This recovers
|
||||
/// tick (e.g. gateway nginx temporarily down). This recovers
|
||||
/// gateway routing without needing a manual gateway restart.
|
||||
///
|
||||
/// Mirrors the spawn-loop shape used by `crash_watch`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue