From 435dfbfb33277eb5af49cebcb3a174d3742c2563 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 5 Aug 2026 11:37:51 +0200 Subject: [PATCH] feat(nix): swarm-controller systemd unit, service user and socket services.hyperhive.swarm.controller.{enable,package,socketPath} plus the unprivileged swarm-controller user, its runtime and state directories, and the unit itself. enable is deliberately not derived from services.hyperhive.enable, unlike c0re: a swarm has one controller, so turning it on is a statement about swarm topology rather than about whether hyperhive is installed. The socket gets its own RuntimeDirectory. nginx reaches a unix upstream by having the socket's directory bind-mounted into the gateway container, and the socket is 0666 because connect needs write -- so the directory is the only access control there is. Sharing one with the host admin socket would hand that socket to the gateway too. The constraint is stated at both ends, in the option description and beside the bind, because it is invisible from either site alone; a test pins the path so a tidying edit fails rather than reviews cleanly. RuntimeDirectoryPreserve and the daemon's stale-socket unlink are a pair: preserving the directory without the unlink means bind fails with EADDRINUSE after a restart. --- flake.nix | 3 + nix/host-modules/default.nix | 1 + nix/host-modules/swarm-controller.nix | 117 ++++++++++++++++++++++++++ swarm-controller/src/main.rs | 28 ++++++ 4 files changed, 149 insertions(+) create mode 100644 nix/host-modules/swarm-controller.nix diff --git a/flake.nix b/flake.nix index 87d4f7e4..62abd8ad 100644 --- a/flake.nix +++ b/flake.nix @@ -142,6 +142,9 @@ agentBaseToplevel = lib.mkDefault self.packages.x86_64-linux.agent-base-toplevel; managerToplevel = lib.mkDefault self.packages.x86_64-linux.ruth-toplevel; }; + services.hyperhive.swarm.controller.package = + lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-controller; services.hyperhive.gateway.swaggerUiTheme = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme; diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 2b87a421..3b5e41cc 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -21,6 +21,7 @@ ./hive-priv.nix ./hive-tls.nix ./otel.nix + ./swarm-controller.nix ./swarm-snapshot-store.nix ./swarm-wireguard.nix ./swarm.nix diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix new file mode 100644 index 00000000..9449e39b --- /dev/null +++ b/nix/host-modules/swarm-controller.nix @@ -0,0 +1,117 @@ +# The swarm-level controller daemon. Per-host opt-in: a swarm has one +# controller, so most hives leave this off and point at the hive that +# runs it. Distinct from hive-c0re, which every hive runs — c0re owns +# the agents on one host, this owns what is true across hives. +# +# Serves HTTP over a unix socket rather than a TCP port: the gateway's +# nginx is the only intended client, it reaches the socket through a +# bind-mount, and a socket that is never bound to an address cannot be +# reached from off-host by mistake. +{ + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.swarm.controller; +in +{ + options.services.hyperhive.swarm.controller = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Run the swarm-controller daemon on this host. Off by default and + deliberately not derived from `services.hyperhive.enable`: a swarm + has one controller, so enabling it per hive is a decision about + swarm topology, not about whether hyperhive is installed. + ''; + }; + + package = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-controller"; + description = '' + swarm-controller package. Wired by default from this flake's own + package set (see `flake.nix`); override to run a different build. + ''; + }; + + socketPath = lib.mkOption { + type = lib.types.str; + default = "/run/swarm-controller/controller.sock"; + description = '' + Unix socket the daemon serves on, and the path the gateway's nginx + 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 + one socket and is bind-mounted into one container. Moving this path + under a directory that carries anything else — `/run/hyperhive`, + which holds the host admin socket, above all — hands whatever else + lives there to every consumer that mounts it. + + Changing this therefore means re-checking the gateway bind-mount, + not just the daemon. + ''; + }; + }; + + config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) { + users.users.swarm-controller = { + isSystemUser = true; + group = "swarm-controller"; + description = "hyperhive swarm-controller daemon"; + }; + users.groups.swarm-controller = { }; + + systemd.services.swarm-controller = { + description = "hyperhive swarm-level controller daemon"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + ExecStart = "${cfg.package}/bin/swarm-controller"; + User = "swarm-controller"; + Group = "swarm-controller"; + Restart = "on-failure"; + RestartSec = "5s"; + + # `/run/swarm-controller` — its own directory, holding only the + # 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"; + # Preserved across restarts so the bind-mount source never vanishes + # from under a running gateway container. The daemon unlinks a stale + # socket on start, which is what makes preservation safe. + RuntimeDirectoryPreserve = "yes"; + + StateDirectory = "swarm-controller"; + StateDirectoryMode = "0750"; + + # Nothing here needs a writable filesystem, real privileges, or a + # view of the rest of the machine; the daemon reads its socket path + # from config and serves. + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + NoNewPrivileges = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ + "AF_UNIX" + ]; + }; + + environment.SWARM_CONTROLLER_SOCKET = cfg.socketPath; + }; + }; +} diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 581116c9..aefb3a75 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -87,3 +87,31 @@ async fn main() -> Result<()> { .await .context("serving swarm-controller") } + +#[cfg(test)] +mod tests { + use super::DEFAULT_SOCKET; + use std::path::Path; + + /// 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. + /// + /// A test rather than a comment: the failure this guards against is a + /// one-word edit that looks tidier and reads fine in review. + #[test] + fn socket_lives_in_its_own_runtime_dir() { + let parent = Path::new(DEFAULT_SOCKET) + .parent() + .expect("socket path has a parent directory"); + assert_eq!( + parent, + 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" + ); + } +}