From 51cc093465333604bc795790a1783e597202de39 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 17 Oct 2026 13:54:14 +0200 Subject: [PATCH] swarm-controller: restrict its socket directory to nginx's access group --- nix/host-modules/swarm-controller.nix | 37 +++++++++++++++++++++------ nix/module-eval/core-toggle.nix | 18 +++++++++++++ swarm-controller/README.md | 15 ++++++++--- swarm-controller/src/main.rs | 9 ++++--- 4 files changed, 63 insertions(+), 16 deletions(-) diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index cc06c6b9..bcf2af07 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -20,6 +20,12 @@ let natsCfg = config.services.hyperhive.swarm.nats; swarmCfg = config.services.hyperhive.swarm; + # Who may connect to the controller's socket: nginx (the gateway's `/api/` + # and `/webhook/forge/` locations) and root (`swarmctl`). A group of its own + # rather than `swarm-controller`: that group also owns the state directory, + # and nginx has no business reading it. + clientsGroup = "swarm-controller-clients"; + # Quick links to swarm services, built from `swarm.*` options alone: every # host evaluates the same entries, whichever host runs each service. One # entry per service; reading a `deploy.*` option here would tie a link to @@ -469,10 +475,13 @@ in proxies to. The **directory** is the access control here, not the socket mode: - the socket itself is `0666` (nginx runs as another user, and - `connect(2)` needs write), exactly as hive-c0re publishes the - per-agent sockets. What keeps that safe is that the directory holds - exactly one socket and is traverse-only (`0751`) for everyone else. + the socket itself is `0666`, exactly as hive-c0re publishes the + per-agent sockets, and the directory is `0750` owned by the + `${clientsGroup}` group. Members of that group (the gateway's + nginx, for `/api/` and `/webhook/forge/`) and root (`swarmctl`, + which bypasses mode bits) can reach the socket; every other local + user gets `EACCES` at the directory. What keeps `0666` safe is that + the directory holds exactly one socket. Moving this path under a directory that carries anything else — `/run/hyperhive`, which holds the host admin socket, above all — exposes whatever else lives there to the same reachability. @@ -621,6 +630,14 @@ in description = "hyperhive swarm-controller daemon"; }; users.groups.swarm-controller = { }; + users.groups.${clientsGroup} = { }; + + # Through the unit, not `users.users.nginx.extraGroups`: systemd resolves + # a unit's groups at start, and only a changed unit file makes the switch + # restart nginx (it does not reload) onto the new group. + systemd.services.nginx = lib.mkIf config.services.nginx.enable { + serviceConfig.SupplementaryGroups = [ clientsGroup ]; + }; # Installed host-wide, not into the daemon's unit: `swarmctl` is run # by a human on this box and acts as root, so the daemon's sandbox is @@ -707,10 +724,14 @@ in # socket. See `socketPath`'s description for why that is a security # property and not tidiness. RuntimeDirectory = "swarm-controller"; - # 0751: traverse-only for others, so the gateway's nginx can reach - # the socket path without being able to list the directory. Same - # shape (and same reason) as hive-c0re's runtime dir. - RuntimeDirectoryMode = "0751"; + # 0750 with the group set to `clientsGroup` below: only nginx and + # root can reach the socket inside. + RuntimeDirectoryMode = "0750"; + # systemd chowns the runtime directory to `User:Group` on every + # start, and the daemon is not a member of `clientsGroup`, so it + # cannot chgrp the directory itself. `+` runs this as root, after + # that chown. + ExecStartPre = "+${pkgs.coreutils}/bin/chgrp ${clientsGroup} ${builtins.dirOf deployCfg.swarm-controller.socketPath}"; # Preserved across restarts so the path never vanishes from under a # running nginx. The daemon unlinks a stale socket on start, which # is what makes preservation safe. diff --git a/nix/module-eval/core-toggle.nix b/nix/module-eval/core-toggle.nix index cec98e12..5e9b8ffc 100644 --- a/nix/module-eval/core-toggle.nix +++ b/nix/module-eval/core-toggle.nix @@ -414,6 +414,24 @@ let && lib.hasInfix socket l."/webhook/forge/".proxyPass && !(lib.hasInfix "auth_request" l."/webhook/forge/".extraConfig); } + { + # Only nginx (through the clients group) and root reach the controller + # socket: the runtime dir is 0750 with that group set. The last two + # clauses are the presence controls — the group exists and is not the + # daemon's own, so a misspelled name cannot satisfy the rest. + name = "the controller socket's directory is 0750 and group-owned by the group nginx joins"; + ok = + let + sc = allLocal.systemd.services.swarm-controller.serviceConfig; + group = "swarm-controller-clients"; + in + sc.RuntimeDirectoryMode == "0750" + && lib.hasPrefix "+" sc.ExecStartPre + && lib.hasInfix "chgrp ${group} /run/swarm-controller" sc.ExecStartPre + && lib.elem group allLocal.systemd.services.nginx.serviceConfig.SupplementaryGroups + && allLocal.users.groups ? ${group} + && sc.Group != group; + } { # nginx serves a path from its longest matching prefix location, so that # one must be the gated `/api/`, and no exact location may take a route diff --git a/swarm-controller/README.md b/swarm-controller/README.md index d5b88243..db38a8a1 100644 --- a/swarm-controller/README.md +++ b/swarm-controller/README.md @@ -93,8 +93,8 @@ queue, so both survive a restart. ## Why a unix socket, not a port -The hive-gateway's nginx is the only intended client and reaches the socket -through a bind-mount. A listener that is never bound to an address cannot be +The hive-gateway's nginx is the only intended client (`swarmctl` runs as root). +A listener that is never bound to an address cannot be reached from off-host by mistake. The socket path is `services.hyperhive.deploy.swarm-controller.socketPath`, default @@ -108,7 +108,12 @@ 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. So: +What keeps that safe is that the directory is `0750`, owned by the +`swarm-controller-clients` group, and holds **one** socket. nginx is in that +group through its unit's `SupplementaryGroups`; root bypasses mode bits, so +`swarmctl` needs no membership. Every other local user gets `EACCES` at the +directory. The group is not `swarm-controller`'s own, which also owns the +state directory. So: > **Never point `socketPath` at a directory that carries anything else.** > `/run/hyperhive` above all — it holds `host.sock`, the host **admin** socket. @@ -116,7 +121,9 @@ What keeps that safe is that the directory holds **one** socket. So: > socket within its reach too. nginx is a host service, so nothing narrows what it can reach except the -directory itself — that is the whole of the access control. A unit test pins +directory itself — that is the whole of the access control. The unit sets the +group with a root `ExecStartPre` (`chgrp`), because systemd chowns the runtime +directory to the unit's own group on every start. 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 diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 71aa3a7c..cdc6c1da 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -3468,10 +3468,11 @@ async fn main() -> Result<()> { // 0666 matches how hive-c0re publishes the per-agent sockets // (`socket_server::start`), and rests on the same argument: **the // containing directory is the access control, not the socket mode.** - // This directory holds one socket and is bind-mounted into exactly - // one container. That is also why it must not be shared with - // hive-c0re's `/run/hyperhive` — with a 0666 socket, a directory - // that carries more than it should is the whole vulnerability. + // The unit makes the directory `0750`, group `swarm-controller-clients`, + // so only nginx and root reach the socket. The directory holds one + // socket, which is also why it must not be shared with hive-c0re's + // `/run/hyperhive` — with a 0666 socket, a directory that carries + // more than it should is the whole vulnerability. std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)) .with_context(|| format!("chmod {}", path.display()))?; tracing::info!(socket = %path.display(), "swarm-controller listening");