Watch
0
0
Fork
You've already forked hyperhive
0

run opted-in agents in a user namespace with a dedicated data owner

This commit is contained in:
atlas 2026-11-01 13:50:53 +01:00 • committed by mara
commit bfdf603d12
2 changed files with 828 additions and 21 deletions

View file

@ -10,6 +10,8 @@
let
controllerCfg = config.services.hyperhive.deploy.hive-controller;
rootfsCheck = import ./lib/container-rootfs-check.nix { inherit config; };
userns = config.services.hyperhive.userns;
dataOwnerProblems = import ./lib/userns-data-owner.nix { inherit lib; };
in
{
# The platform containers run their warn-only root check by this path
@ -24,12 +26,60 @@ in
}
];
options.services.hyperhive.userns = {
agents = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "alice" ];
description = ''
Agents that run in their own user namespace (`--private-users=pick`,
root idmapped). Every hive bind into such a container is mounted
`owneridmap`, so its data on disk belongs to `dataOwner`, which the
agent user is inside. Listing an agent moves its `state`, `harness`,
`work` and `claude` dirs to `dataOwner` once, at its next start with
the container stopped. Take the agent off the list only after chowning
those dirs back and removing `/var/lib/hive-priv/userns/<agent>`;
until then it will not start.
'';
};
dataOwner = lib.mkOption {
type = lib.types.ints.u32;
default = 60000;
description = ''
The host uid and gid that own a namespaced agent's data on disk. 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. 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 = userns.agents == [ ] || problems == [ ];
message = "services.hyperhive.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.
@ -93,6 +143,10 @@ in
# routing so the closure `RealiseClosure` downloads is in the same
# store the container is then pointed at.
NIX_REMOTE = "daemon";
# Who is namespaced, and the owner their data moves to: set by the
# operator in the unit, so only root decides.
HIVE_PRIV_USERNS_AGENTS = lib.concatStringsSep " " userns.agents;
HIVE_PRIV_USERNS_DATA_OWNER = toString userns.dataOwner;
};
serviceConfig = {
ExecStart = "${controllerCfg.package}/bin/hive-priv";

View file

@ -0,0 +1,17 @@
# What is wrong with a uid:gid chosen as the on-disk owner of a user-namespaced
# agent's data. Empty when it is fine.
{ lib }:
{
owner,
# Every uid and gid the host defines, from `users.users` and `users.groups`.
uids,
gids,
}:
lib.optional (
owner < 30000
) "${toString owner} is in the range NixOS gives normal users and system users (below 30000)"
++
lib.optional (owner >= 61184)
"${toString owner} is in the range systemd allocates dynamic users from (61184 and above), or is `nobody`"
++ lib.optional (builtins.elem owner uids) "${toString owner} is the uid of a user the host defines"
++ lib.optional (builtins.elem owner gids) "${toString owner} is the gid of a group the host defines"