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:
atlas 2026-08-11 18:00:27 +02:00
commit 07852cabc1
34 changed files with 704 additions and 618 deletions

View file

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

View file

@ -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) => {

View file

@ -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,
});
}

View file

@ -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.

View file

@ -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()

View file

@ -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)");
}

View file

@ -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")

View file

@ -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?)
}

View file

@ -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

View file

@ -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(

View file

@ -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`,