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" + ); + } +}