# hive-priv — the narrow root privileged helper hive-c0re delegates # to, socket-activated at /run/hive/priv.sock. See docs/trust-boundary/boundary.md # for the operator/agent trust-boundary design. { pkgs, lib, config, ... }: let controllerCfg = config.services.hyperhive.deploy.hive-controller; rootfsCheck = import ./lib/container-rootfs-check.nix { inherit config; }; userns = controllerCfg.userns; dataOwnerProblems = import ./lib/userns-data-owner.nix { inherit lib; }; in { # The platform containers run their warn-only root check by this path # (`lib/container-rootfs-check.nix`), so their units carry no store path. # Declared outside the controller toggle below: a host that runs platform # containers but no hive carries the check too. imports = [ { systemd.tmpfiles.rules = [ "L+ ${rootfsCheck.hivePriv} - - - - ${controllerCfg.package}/bin/hive-priv" ]; } ]; options.services.hyperhive.deploy.hive-controller.userns.dataOwner = lib.mkOption { type = lib.types.ints.u32; default = 60000; description = '' The host uid and gid that own the data of an agent running in its own user namespace (the agent sets `services.hyperhive.agent.user.privateUsers`). One number for both: nspawn copies the uid map for gids, so the gid cannot differ. It must be no host user or group, outside the normal and system users (below 30000) and the dynamic users (61184 and above), and below the range `pick` allocates from (524288 and above), so no container's range covers it. `hive-priv` chowns such an agent's data to it once. Changing it after an agent has moved is refused at that agent's next start. ''; }; config = lib.mkIf config.services.hyperhive.deploy.hive-controller.enable { # The parent of every agent socket dir. hive-priv is its only writer # (`EnsureAgentSocketDir`), and its unit lists it in `ReadWritePaths`, # which fails the unit when the path is missing. systemd.tmpfiles.rules = [ "d /run/hive-agent 0755 root root - -" ]; assertions = [ ( let problems = dataOwnerProblems { owner = userns.dataOwner; uids = lib.filter (uid: uid != null) (lib.mapAttrsToList (_: u: u.uid) config.users.users); gids = lib.filter (gid: gid != null) (lib.mapAttrsToList (_: g: g.gid) config.users.groups); }; in { assertion = problems == [ ]; message = "services.hyperhive.deploy.hive-controller.userns.dataOwner: ${lib.concatStringsSep "; " problems}"; } ) ]; # Socket unit for hive-priv — the narrow root helper that executes # privileged operations on behalf of hive-c0re. Systemd creates and # holds `/run/hive/priv.sock` before the first connection arrives. # # Mode 0660 hive-core:hive-core: only the hive-c0re service user can # connect. hive-priv (server) runs as root and validates every request # against a strict allowlist before executing any privileged op. systemd.sockets.hive-priv = { description = "hive-priv privileged helper socket"; wantedBy = [ "sockets.target" ]; socketConfig = { ListenStream = "/run/hive/priv.sock"; SocketMode = "0660"; SocketGroup = "hive-core"; # Create /run/hive/ if absent; 0755 so the hive-core user can # traverse into it to reach the socket. DirectoryMode = "0755"; }; }; # Service unit for hive-priv. Runs as root — it genuinely needs root to # invoke `nixos-container`, write `/etc/nixos-containers/`, write # systemd drop-ins in `/run/systemd/system/`, and call `chown(2)`. # Every request is validated against a strict container-name allowlist # inside the binary; the attack surface is narrow by design. # # Socket-activated: systemd starts hive-priv on the first connection # (no earlier). LISTEN_FDS + LISTEN_PID are set by systemd; hive-priv # reads them to accept the pre-bound socket fd instead of binding its # own. systemd.services.hive-priv = { description = "hive-priv privileged helper"; # No wantedBy — socket-activated exclusively. The socket unit is the # entry point; systemd starts this service on first connect. after = [ "hive-priv.socket" ]; requires = [ "hive-priv.socket" ]; # `nixos-container` is a perl script that shells out by bare name to # nix / nix-env / nix-instantiate (create + update), machinectl + # systemctl (start/stop), and find / rm / umount / chattr (destroy); # only nsenter + su are hardcoded. Give the helper exactly those — # not the whole system profile — on top of the systemd/coreutils/ # findutils already in the default unit PATH. Without `nixos-container` # on PATH every container op fails ENOENT, which `build_all` silently # swallows into an empty list ("no managed containers"). path = [ pkgs.nixos-container pkgs.nix # nix, nix-env, nix-instantiate — create + update pkgs.util-linux # umount (nsenter is hardcoded in the script) pkgs.e2fsprogs # chattr pkgs.btrfs-progs # btrfs subvolume create/delete — Ensure/DeleteAgentSubvolume ]; environment = { # nix (`nix-env`, `nix-store`) writes its cache under $HOME/.cache. # With ProtectHome and no explicit HOME this lands on the unwritable # /var/empty and Lix errors out. Point HOME at the StateDirectory # below (persistent, so the cache survives across restarts). HOME = "/var/lib/hive-priv"; # hive-priv runs as root. Root nix defaults to store=auto which # resolves to the LOCAL store — bypassing the host daemon, its # remote builders, and prebuilt derivation outputs. Force daemon # routing so the closure `RealiseClosure` downloads is in the same # store the container is then pointed at. NIX_REMOTE = "daemon"; # The owner a namespaced agent's data moves to. Which agents are # namespaced is not set here: hive-c0re sends it with each start. HIVE_PRIV_USERNS_DATA_OWNER = toString userns.dataOwner; }; serviceConfig = { ExecStart = "${controllerCfg.package}/bin/hive-priv"; SyslogIdentifier = "hive-priv"; Type = "simple"; User = "root"; PrivateTmp = true; ProtectHome = true; # Harden the file system view: strict makes the entire hierarchy # read-only by default; ReadWritePaths carves out exactly the paths # hive-priv must write to at runtime. Each is a confirmed hive-priv # write that EROFSes (os error 30) without its carve-out: # /etc/nixos-containers — .conf (bind mounts, nspawn flags) # /etc/tmpfiles.d — unlinks the legacy hyperhive-agents.conf; # drop once every host has run it # /run/hive-agent — creates per-agent socket dirs # /run/systemd — container@ drop-ins + machined state # /run/lock — nixos-container's create/destroy lock file # /var/lib/nixos-containers — container rootfs # /var/lib/hyperhive — agent state (forge/matrix token files) # /nix — nix store + profile updates on create/update ProtectSystem = "strict"; ReadWritePaths = [ "/etc/nixos-containers" "/etc/tmpfiles.d" "/run/hive-agent" "/run/systemd" "/run/lock" "/var/lib/nixos-containers" "/var/lib/hyperhive" "/nix" ]; # Writable HOME for nix's caches (see environment.HOME above). StateDirectory = "hive-priv"; # /run/hive-priv: the per-container salvage lock files. Root-owned and # outside every path a container can write. RuntimeDirectory = "hive-priv"; RuntimeDirectoryMode = "0700"; # With ProtectSystem=strict the root filesystem is read-only inside # hive-priv. When `nixos-container create/update` invokes nix, nix # creates a temporary result symlink in its working directory. Without # an explicit WorkingDirectory the cwd is / (inherited from systemd), # which is read-only under strict, causing: # error: creating symlink "/.tmp.tmp-..." -> ...: Read-only file system # Point the working directory at the writable StateDirectory so nix # drops its temp symlink there instead. WorkingDirectory = "/var/lib/hive-priv"; # Kernel surfaces neither hive-priv nor its children use: no # personality(2), no real-time scheduling, no /dev/kmsg or # /proc/kmsg, no explicit module loading (kernel-initiated autoload # still works). Container start/stop run in `container@.service`, # not under this unit. LockPersonality = true; RestrictRealtime = true; ProtectKernelLogs = true; ProtectKernelModules = true; }; }; }; }