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

@ -33,17 +33,24 @@ pub const MANAGER_NAME: &str = "ruth";
pub const AGENT_PREFIX: &str = "h-";
/// Sibling service containers managed by hive-c0re. This doubles as the
/// authoritative allowlist for infra lifecycle ops
/// ([`PrivRequest::ControlInfraContainer`]): any of these four may be
/// started / stopped / restarted (by the hive-wide `hivectl stop`/`start`
/// flow or an `infra_admin` agent's `restart`). `hive-c0re` is deliberately
/// absent — stopping it would sever the very socket the request arrived on.
/// authoritative allowlist for the requests that name a container as a
/// string (bind-mount edits, journal reads): only these — or a valid agent
/// name — are accepted. `hive-c0re` is deliberately absent; so is the
/// gateway, whose nginx is a plain host unit rather than a container.
/// hive-priv re-validates against this list root-side, so it's authoritative
/// regardless of what the caller sends.
pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway", "hive-ci"];
///
/// ⚠️ This is the *container-name* allowlist, not the lifecycle one:
/// [`InfraContainer`] is what gates
/// [`PrivRequest::ControlInfraContainer`], and it has one variant more than
/// this list (the gateway). Keep the distinction — a name that belongs to
/// no container has no business reaching a `-M` / `nixos-container` call.
pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-ci"];
/// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly
/// to `systemctl <verb> container@<container>.service`.
/// to `systemctl <verb> <unit>`, where the unit comes from
/// [`InfraContainer::service_unit`] — usually `container@<name>.service`,
/// but not always (see [`InfraTarget`]).
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InfraAction {
@ -71,8 +78,9 @@ impl InfraAction {
/// by a runtime check. The c0re↔hive-priv wire form uses serde's default
/// variant naming (`"Ci"`, `"Forge"`, …); it's an internal protocol (both
/// ends rebuild together) so it needn't match the container name.
/// [`unit_name`](Self::unit_name) is the separate systemd / container name
/// (`hive-ci`).
/// [`name`](Self::name) is the separate stable identity string
/// (`hive-ci`), and [`service_unit`](Self::service_unit) the systemd unit
/// it actually resolves to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InfraContainer {
Ci,
@ -81,9 +89,27 @@ pub enum InfraContainer {
Matrix,
}
/// What an [`InfraContainer`] resolves to on the host — i.e. the thing a
/// lifecycle verb actually acts on.
///
/// The gateway is why this exists: its nginx + dnsmasq were lifted out of
/// an nspawn container and onto the host, so "restart the gateway" is a
/// plain host unit now. The operator verb is unchanged; only its target
/// moved. Everything else is still a container.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InfraTarget {
/// An nspawn container, controlled via `container@<name>.service` and
/// readable with `journalctl -M <name>`.
Container(&'static str),
/// A plain host unit. There is no machine to enter: no `-M` journal,
/// no `nixos-container` verb.
HostUnit(&'static str),
}
impl InfraContainer {
/// Every controllable infra container. The source of truth that
/// [`SIBLING_CONTAINERS`] is kept consistent with (see the test).
/// Every controllable infra target. A superset of
/// [`SIBLING_CONTAINERS`] — not all of these are containers (see the
/// test).
pub const ALL: [InfraContainer; 4] = [
InfraContainer::Ci,
InfraContainer::Forge,
@ -91,11 +117,14 @@ impl InfraContainer {
InfraContainer::Matrix,
];
/// The container / systemd-unit name, e.g. `hive-ci` →
/// `container@hive-ci.service`. (Distinct from the serde wire form,
/// which is the default variant name `"Ci"`.)
/// Stable identity string, e.g. `hive-ci`. This is what the operator
/// types, what the dashboard displays, and what [`FromStr`](std::str::FromStr)
/// parses — it stays `hive-gateway` even though the gateway is no
/// longer a container, because it names the *service*, not its
/// implementation. (Distinct from the serde wire form, which is the
/// default variant name `"Ci"`.)
#[must_use]
pub fn unit_name(self) -> &'static str {
pub fn name(self) -> &'static str {
match self {
InfraContainer::Ci => "hive-ci",
InfraContainer::Forge => "hive-forge",
@ -103,16 +132,48 @@ impl InfraContainer {
InfraContainer::Matrix => "hive-matrix",
}
}
/// Where this target lives on the host.
#[must_use]
pub fn target(self) -> InfraTarget {
match self {
InfraContainer::Gateway => InfraTarget::HostUnit("nginx.service"),
other => InfraTarget::Container(other.name()),
}
}
/// The systemd unit a lifecycle verb acts on.
#[must_use]
pub fn service_unit(self) -> String {
match self.target() {
InfraTarget::Container(name) => format!("container@{name}.service"),
InfraTarget::HostUnit(unit) => unit.to_owned(),
}
}
/// Whether an agent holding `infra_admin` may restart this target.
///
/// The gateway is excluded by operator ruling: nginx now fronts every
/// hive service from the host, so an agent restarting it can take the
/// forge, dashboard and matrix down with it — including the path its
/// own PR would have to travel to fix it. The operator surface
/// (`hivectl`, dashboard) is unaffected.
#[must_use]
pub fn agent_restartable(self) -> bool {
!matches!(self, InfraContainer::Gateway)
}
}
impl std::str::FromStr for InfraContainer {
type Err = ();
/// Parse a container name (`hive-ci`, …) into a variant. Used to decide
/// whether an MCP `restart(<name>)` target is a controllable infra
/// container. `Err(())` for anything that isn't one.
/// Parse an infra name (`hive-ci`, …) into a variant. Recognition
/// only — it says the name denotes a hive service, *not* that the
/// caller may act on it. The agent restart path additionally checks
/// [`agent_restartable`](InfraContainer::agent_restartable).
/// `Err(())` for anything that isn't one.
fn from_str(s: &str) -> Result<Self, ()> {
Self::ALL.into_iter().find(|c| c.unit_name() == s).ok_or(())
Self::ALL.into_iter().find(|c| c.name() == s).ok_or(())
}
}
@ -364,15 +425,25 @@ pub enum PrivRequest {
/// Run `systemctl daemon-reload`.
DaemonReload,
/// Synchronise the nginx unit inside the `hive-gateway` container.
/// Synchronise the host's nginx unit after an `agents.conf` write.
///
/// hive-priv queries `ActiveState` and dispatches:
/// - `active` → `systemctl reload nginx` (SIGHUP, zero-downtime)
/// - `failed` → `systemctl reset-failed nginx` + `systemctl start nginx`
/// - otherwise → `systemctl start nginx`
///
/// Requires root: `--machine=hive-gateway` enters the container
/// namespace via the machine bus (forbidden for unprivileged users).
/// Requires root because hive-c0re runs as the unprivileged
/// `hive-core` user and cannot act on a system unit. It used to be
/// root for a *different* reason — `--machine=hive-gateway` entering
/// the container's namespace over the machine bus — and that reason
/// died with the container: nginx is a host unit now. The
/// requirement survived the move; its justification did not.
///
/// ⚠️ The unit name is **not** a parameter and must stay that way.
/// `--machine=` was doing double duty — transport *and* scope — so
/// dropping it removed the containment along with the namespace hop.
/// Hard-coding `nginx` is what replaces it: a caller cannot name the
/// unit, so this verb cannot be steered at any other service.
ReloadGatewayNginx,
// --- Forge admin CLI ---
@ -824,13 +895,14 @@ pub enum PrivEvent {
#[cfg(test)]
mod tests {
use super::{InfraContainer, SIBLING_CONTAINERS};
use super::{InfraContainer, InfraTarget, SIBLING_CONTAINERS};
#[test]
fn infra_control_allowlist_excludes_c0re_includes_matrix() {
// SIBLING_CONTAINERS is the authoritative allowlist for infra
// lifecycle ops. hive-c0re must NEVER be in it — stopping the daemon
// would sever the socket the request arrived on.
// SIBLING_CONTAINERS is the authoritative allowlist for the
// requests that name a container as a string. hive-c0re must NEVER
// be in it — stopping the daemon would sever the socket the request
// arrived on.
assert!(!SIBLING_CONTAINERS.contains(&"hive-c0re"));
// hive-matrix IS controllable (operator can stop/start/restart it).
assert!(SIBLING_CONTAINERS.contains(&"hive-matrix"));
@ -838,26 +910,62 @@ mod tests {
#[test]
fn infra_container_enum_matches_sibling_containers() {
// The InfraContainer enum (the control-path allowlist) and the
// SIBLING_CONTAINERS slice (the general container-name validator)
// must list exactly the same four containers — they're separate
// surfaces for the same set, so keep them in lockstep.
let mut from_enum: Vec<&str> = InfraContainer::ALL.iter().map(|c| c.unit_name()).collect();
// The two lists are the same set *minus the things that aren't
// containers*: every `Container` variant must appear in
// SIBLING_CONTAINERS and vice versa, so a `-M` journal read or a
// `nixos-container` verb can never be pointed at a name that has no
// machine behind it.
let mut from_enum: Vec<&str> = InfraContainer::ALL
.iter()
.filter_map(|c| match c.target() {
InfraTarget::Container(name) => Some(name),
InfraTarget::HostUnit(_) => None,
})
.collect();
from_enum.sort_unstable();
let mut from_slice: Vec<&str> = SIBLING_CONTAINERS.to_vec();
from_slice.sort_unstable();
assert_eq!(from_enum, from_slice);
// The gateway is the one that is not: a host unit, and absent from
// the container-name allowlist.
assert_eq!(
InfraContainer::Gateway.target(),
InfraTarget::HostUnit("nginx.service")
);
assert!(!SIBLING_CONTAINERS.contains(&"hive-gateway"));
// hive-c0re has no variant — unrepresentable, can't be controlled.
assert!("hive-c0re".parse::<InfraContainer>().is_err());
}
#[test]
fn infra_container_name_round_trips() {
// `unit_name` is the single source of truth for the wire form (the
// serde impls + FromStr all key off it), so a name→variant→name
// round-trip proves the mapping is consistent in both directions.
// `name` is the single source of truth for the operator-facing form
// (FromStr keys off it), so a name→variant→name round-trip proves
// the mapping is consistent in both directions. It holds for the
// gateway too: the name is still recognised, it's the *permission*
// that differs (see below), not the parse.
for c in InfraContainer::ALL {
assert_eq!(c.unit_name().parse::<InfraContainer>(), Ok(c));
assert_eq!(c.name().parse::<InfraContainer>(), Ok(c));
}
}
#[test]
fn service_unit_wraps_containers_but_not_host_units() {
assert_eq!(
InfraContainer::Ci.service_unit(),
"container@hive-ci.service"
);
assert_eq!(InfraContainer::Gateway.service_unit(), "nginx.service");
}
#[test]
fn only_the_gateway_is_off_limits_to_agents() {
// Recognising a name and being allowed to restart it are separate
// questions — the gateway parses fine and is still refused.
assert!("hive-gateway".parse::<InfraContainer>().is_ok());
assert!(!InfraContainer::Gateway.agent_restartable());
for c in InfraContainer::ALL {
assert_eq!(c.agent_restartable(), c != InfraContainer::Gateway, "{c:?}");
}
}
}