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
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue