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

@ -35,17 +35,18 @@ The socket is `0666`. It has to be: nginx runs as a different user and
sockets, and rests on the same argument — *"the bind source dir is per-agent on
host so blast radius is unchanged."*
What keeps that safe is that the directory holds **one** socket and is
bind-mounted into **one** container. So:
What keeps that safe is that the directory holds **one** socket. So:
> **Never point `socketPath` at a directory that carries anything else.**
> `/run/hyperhive` above all — it holds `host.sock`, the host **admin** socket.
> Mounting that directory to reach this socket would hand the gateway container
> the admin socket along with it.
> Pointing nginx at that directory to reach this socket would put the admin
> socket within its reach too.
Changing `socketPath` therefore means re-checking the gateway bind-mount, not
just the daemon. A unit test pins the default path so a tidying edit fails
instead of reviewing cleanly.
This got *less* forgiving when nginx moved onto the host: the gateway used to
reach a unix upstream through a bind-mount, so the mount list was a second
bound on what it could touch. There is no mount now — the directory is the
whole of the access control. A unit test pins the default path so a tidying
edit fails instead of reviewing cleanly.
`RuntimeDirectoryPreserve=yes` and the daemon's stale-socket unlink on start are
a **pair**: preserving the directory without the unlink means `bind` fails with

View file

@ -27,10 +27,12 @@ use axum::{Router, routing::get};
/// `ExecStart`, so the default names a directory the unit just produced.
///
/// The directory is its own — deliberately not shared with hive-c0re's
/// `/run/hyperhive`. nginx reaches a unix upstream by having the socket's
/// *directory* bind-mounted into the gateway container, so co-locating
/// this socket with c0re's admin socket would hand the gateway the admin
/// socket along with it.
/// `/run/hyperhive`. The socket is `0666`, so its directory is the only
/// access control it has; co-locating it with c0re's admin socket would
/// put both within reach of whatever can reach either. That used to be
/// enforced by which *directory* was bind-mounted into the gateway
/// container; with nginx on the host the mount is gone and the directory
/// is all that is left, so the rule matters more, not less.
const DEFAULT_SOCKET: &str = "/run/swarm-controller/controller.sock";
fn socket_path() -> PathBuf {
@ -96,8 +98,9 @@ mod tests {
/// The socket must not share a directory with anything else, because
/// the socket is `0666` and the directory is therefore the only access
/// control it has. `/run/hyperhive` in particular holds hive-c0re's
/// **admin** socket, and nginx reaches a unix upstream by mounting the
/// socket's whole directory into the gateway container.
/// **admin** socket. nginx used to reach a unix upstream by mounting
/// the socket's whole directory into the gateway container; it runs on
/// the host now, so nothing narrows its reach but the directory itself.
///
/// A test rather than a comment: the failure this guards against is a
/// one-word edit that looks tidier and reads fine in review.
@ -111,7 +114,7 @@ mod tests {
Path::new("/run/swarm-controller"),
"the socket's directory is its access control — moving it under a shared \
directory (notably /run/hyperhive, which holds the host admin socket) \
exposes everything else in that directory to the gateway container"
exposes everything else in that directory to the gateway's nginx"
);
}
}