Watch
0
0
Fork
You've already forked hyperhive
0

read privateUsers from the closure record at start, and move the data owner option under hive-controller

This commit is contained in:
atlas 2026-11-01 16:18:34 +01:00 • committed by mara
commit f5a609911c
3 changed files with 208 additions and 97 deletions

View file

@ -10,7 +10,7 @@
let
controllerCfg = config.services.hyperhive.deploy.hive-controller;
rootfsCheck = import ./lib/container-rootfs-check.nix { inherit config; };
userns = config.services.hyperhive.userns;
userns = controllerCfg.userns;
dataOwnerProblems = import ./lib/userns-data-owner.nix { inherit lib; };
in
{
@ -26,41 +26,20 @@ 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.
The agent's own config must pin `services.hyperhive.agent.user.uid`
and `.gid` to one number: nspawn maps a bind's gids with its uid map.
Keep an agent that holds `ManageRootAgent` off the list, since the
other agents' dirs it mounts are not owned by its data owner.
'';
};
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.
'';
};
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 {
@ -79,8 +58,8 @@ in
};
in
{
assertion = userns.agents == [ ] || problems == [ ];
message = "services.hyperhive.userns.dataOwner: ${lib.concatStringsSep "; " problems}";
assertion = problems == [ ];
message = "services.hyperhive.deploy.hive-controller.userns.dataOwner: ${lib.concatStringsSep "; " problems}";
}
)
];
@ -148,9 +127,8 @@ 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;
# 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 = {

View file

@ -178,15 +178,15 @@ let
{
# Read back from the rendered unit and assertion list: a misspelt
# variable or a dropped assertion fails here.
name = "hive-priv carries the userns agents and data owner, and the owner assertion passes";
name = "hive-priv carries the userns data owner, no agent list, and the owner assertion passes";
ok =
let
e = allLocal.systemd.services.hive-priv.environment;
in
e.HIVE_PRIV_USERNS_AGENTS == ""
!(e ? HIVE_PRIV_USERNS_AGENTS)
&& e.HIVE_PRIV_USERNS_DATA_OWNER == "60000"
&& lib.any (
a: lib.hasPrefix "services.hyperhive.userns.dataOwner" a.message && a.assertion
a: lib.hasInfix "hive-controller.userns.dataOwner" a.message && a.assertion
) allLocal.assertions;
}
{

View file

@ -18,10 +18,12 @@
# leaves the link's target untouched, and the start goes through once the
# link is gone.
#
# - an agent listed in `services.hyperhive.userns.agents` starts in its own
# - an agent whose config sets `services.hyperhive.agent.user.privateUsers`
# (hive-c0re sends the flag from the closure record; the test sends it)
# starts in its own
# user namespace with state it already had, owned by uid 1000 on disk: the
# data moves once to `dataOwner`, the agent user reads and writes it as
# itself, the unlisted agent's conf is untouched, and a second
# itself, the other agent's conf (no flag sent) is untouched, and a second
# `WriteNspawnFlags` moves nothing.
#
# The agent's journal forwarder itself (an OTLP push to the hive's collector) is
@ -118,7 +120,6 @@ pkgs.testers.runNixOSTest {
prefixLength = 24;
}
];
services.hyperhive.userns.agents = [ nsAgent ];
systemd.tmpfiles.rules =
map (dir: "d ${agentDir}/${dir} 0755 root root - -") hostDirs
# What the namespaced agent has from before: dirs its user owned.
@ -154,6 +155,7 @@ pkgs.testers.runNixOSTest {
name = nsAgent;
uid = 1000;
gid = 1000;
privateUsers = true;
};
};
};
@ -312,19 +314,20 @@ pkgs.testers.runNixOSTest {
host.succeed(f"rm {ROOT}/root && mkdir -m 0700 {ROOT}/root")
boot()
with subtest("a listed agent starts in its own user namespace with the state it had"):
with subtest("an agent whose config sets privateUsers starts in its own user namespace with the state it had"):
NS = "${nsMachine}"
NS_AGENT = "${nsAgent}"
NS_DIR = "${nsDir}"
NS_BINDS = json.loads(${builtins.toJSON (builtins.toJSON nsBinds)})
host.succeed(f"echo before > {NS_DIR}/state/pre.txt && chown 1000:1000 {NS_DIR}/state/pre.txt")
host.succeed(f"stat -c %u:%g {NS_DIR}/state/pre.txt | grep -x 1000:1000")
priv_request(host, {"op": "ensure_agent_socket_dir", "name": NS_AGENT})
priv_request(host, {"op": "ensure_agent_socket_dir", "name": NS_AGENT, "private_users": True})
write = {
"op": "write_nspawn_flags",
"container": NS,
"binds": NS_BINDS,
"isolation": {"bridge": "${bridge}", "gateway_ip": "${gateway}"},
"private_users": True,
}
priv_request(host, write)
ns_conf = host.succeed(f"cat /etc/nixos-containers/{NS}.conf")
@ -332,7 +335,7 @@ pkgs.testers.runNixOSTest {
assert "--private-users-ownership=map" in ns_conf, ns_conf
# Every bind, the read-only config included, is mounted owneridmap.
assert ns_conf.count(":owneridmap") == len(NS_BINDS), ns_conf
# The unlisted agent's conf has none of it: the control that the lookups
# The other agent's conf has none of it: the control that the lookups
# above find these strings where they are.
assert "owneridmap" not in conf() and "PRIVATE_USERS" not in conf(), conf()