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

@ -888,18 +888,23 @@ async fn register_ci_runner(token: &str) -> Result<(String, String)> {
}
/// `ControlInfraContainer` — start/stop/restart a hive infrastructure
/// container via `systemctl <verb> container@<container>.service`. The
/// [`InfraContainer`] enum is the allowlist: serde already rejected any
/// unknown / unsafe name (hive-c0re has no variant, so a stop can't sever
/// the daemon socket) at deserialisation, so no root-side `.contains()`
/// check is needed here. Serves both the hive-wide `hivectl stop`/`start`
/// flow and an `infra_admin` agent's `restart` (action = Restart).
/// service via `systemctl <verb> <unit>`. The [`InfraContainer`] enum is
/// the allowlist: serde already rejected any unknown / unsafe name
/// (hive-c0re has no variant, so a stop can't sever the daemon socket) at
/// deserialisation, so no root-side `.contains()` check is needed here.
/// Serves both the hive-wide `hivectl stop`/`start` flow and an
/// `infra_admin` agent's `restart` (action = Restart).
///
/// ⚠️ The unit is derived from the variant, never sent by the caller —
/// which is what keeps this from being a general `systemctl` pass-through.
/// It is not always `container@<name>.service`: the gateway resolves to the
/// host's `nginx.service`.
async fn control_infra_container(
container: InfraContainer,
action: InfraAction,
) -> Result<(String, String)> {
let verb = action.systemctl_verb();
let unit = format!("container@{}.service", container.unit_name());
let unit = container.service_unit();
let out = Command::new("systemctl")
.args([verb, &unit])
.output()
@ -2173,34 +2178,33 @@ async fn read_container_journal(container: &str, query: &JournalQuery) -> Result
Ok((stdout, stderr))
}
/// Synchronise the nginx unit inside the `hive-gateway` container.
/// Synchronise the host's nginx unit after an `agents.conf` write.
///
/// Queries `ActiveState` via `systemctl --machine=hive-gateway` (requires
/// root — machine-bus transport enters the container namespace), then
/// dispatches:
/// Queries `ActiveState` and dispatches:
/// - `active` → `systemctl reload nginx` (SIGHUP, zero-downtime)
/// - `failed` → `systemctl reset-failed nginx` + `systemctl start nginx`
/// - otherwise → `systemctl start nginx`
///
/// ⚠️ `nginx` is hard-coded on purpose — see `PrivRequest::ReloadGatewayNginx`.
/// The unit name is the scope of this verb, and it used to be enforced by
/// `--machine=hive-gateway` (which could only reach into that container).
/// With nginx on the host there is no namespace to bound it, so the
/// literal is the only thing standing between "reload the gateway" and
/// "reload anything".
///
/// Returns `(String::new(), String::new())` on success so it fits the
/// `exec` return type directly.
async fn sync_gateway_nginx() -> Result<(String, String)> {
let state_out = Command::new("systemctl")
.args([
"--machine=hive-gateway",
"show",
"--property=ActiveState",
"--value",
"nginx",
])
.args(["show", "--property=ActiveState", "--value", "nginx"])
.output()
.await
.context("query nginx ActiveState in hive-gateway")?;
.context("query gateway nginx ActiveState")?;
if !state_out.status.success() {
tracing::warn!(
exit_code = ?state_out.status.code(),
stderr = %String::from_utf8_lossy(&state_out.stderr).trim(),
"systemctl show ActiveState exited non-zero — gateway container may be down"
"systemctl show ActiveState exited non-zero — gateway nginx may be down"
);
}
let state = String::from_utf8_lossy(&state_out.stdout).trim().to_owned();
@ -2209,10 +2213,10 @@ async fn sync_gateway_nginx() -> Result<(String, String)> {
match state.as_str() {
"active" => {
let out = Command::new("systemctl")
.args(["--machine=hive-gateway", "reload", "nginx"])
.args(["reload", "nginx"])
.output()
.await
.context("reload nginx in hive-gateway")?;
.context("reload gateway nginx")?;
if !out.status.success() {
bail!(
"gateway nginx reload failed ({}): {}",
@ -2224,14 +2228,14 @@ async fn sync_gateway_nginx() -> Result<(String, String)> {
"failed" => {
// Clear start-limit so the next start can proceed.
let _ = Command::new("systemctl")
.args(["--machine=hive-gateway", "reset-failed", "nginx"])
.args(["reset-failed", "nginx"])
.status()
.await;
let out = Command::new("systemctl")
.args(["--machine=hive-gateway", "start", "nginx"])
.args(["start", "nginx"])
.output()
.await
.context("start nginx after reset-failed in hive-gateway")?;
.context("start gateway nginx after reset-failed")?;
if !out.status.success() {
bail!(
"gateway nginx start (after reset-failed) failed ({}): {}",
@ -2243,10 +2247,10 @@ async fn sync_gateway_nginx() -> Result<(String, String)> {
_ => {
// inactive, activating, deactivating, unknown — just start.
let out = Command::new("systemctl")
.args(["--machine=hive-gateway", "start", "nginx"])
.args(["start", "nginx"])
.output()
.await
.context("start nginx in hive-gateway")?;
.context("start gateway nginx")?;
if !out.status.success() {
bail!(
"gateway nginx start failed (state={state:?}) ({}): {}",