/etc/tmpfiles.d/hyperhive-agents.conf was a boot-time backstop (#2290) that pre-created every agent's bind sources. The start preamble already creates them for every c0re-driven start, and on this host only hive-c0re starts agent containers. The file was also the reason the socket dir's owner had to be declared there, which is how it spent its life at `0777 root root` whenever the uid could not be resolved (#4742). - hive-priv gains `EnsureAgentSocketDir { name }`, called from `set_nspawn_flags` in every start path. It creates `/run/hive-agent/<name>` `0751 root:root` with mkdirat relative to an O_DIRECTORY|O_NOFOLLOW fd for the parent. An existing entry has to be a directory (fstatat AT_SYMLINK_NOFOLLOW); anything else is refused, and a directory is left alone. hive-c0re's own create_dir_all went: its /run is read-only under ProtectSystem=strict. - The container's `hive-agent-user-migrate` activation chowns that dir to the agent user and sets 0751, the same way it already handles state/ and harness/. It refuses a symlink or non-directory there, since `test -d` and chmod follow links. No host-side passwd parse, and no window where the dir is world-writable. - `/run/hyperhive/agents/<name>` stays created by hive-c0re itself (`ensure_agent_runtime_dir`). It holds the `mcp.sock` that hive-c0re binds as hive-core, so it must not become root- or agent-owned. - The `/run/hive-agent` parent is declared in hive-priv.nix, `0755 root:root`, instead of hive-gateway's hive-core rule. hive-priv is its only writer now, and hive-priv's ReadWritePaths needs it to exist. - The manager start in `ensure_root_agent` now goes through `converge_start_preamble` + `start_with_fallback`. It was a bare start, so after a reboot the manager's bind sources existed only because of the tmpfiles file, and its limits drop-in did not exist at all. - Removed: `sync_tmpfiles`, `agent_uid_gid` / `parse_passwd_uid_gid`, `priv_client::sync_agent_tmpfiles`, `AgentTmpfilesEntry`, the tmpfiles body builder and their tests, plus the three call sites. - Legacy: hive-priv unlinks the file at every start, ignoring ENOENT. `SyncAgentTmpfiles` stays one release as a payload-ignoring variant that does the same unlink and returns Ok, for an older hive-c0re. Salvaged from #4752: the boundary.md correction that nginx only dials, because ProtectSystem=strict makes its /run read-only. Behaviour change: a manual `nixos-container start h-<name>` right after a reboot, before hive-c0re has started that agent, now fails on a missing bind source instead of starting. Closes #4742
158 lines
7.9 KiB
Nix
158 lines
7.9 KiB
Nix
# 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
|
|
cfg = config.services.hyperhive.c0re;
|
|
|
|
# Same safe.directory gitconfig as the c0re unit (see ./hive-c0re)
|
|
# — hive-priv (root) runs nix, which fetches the hive-core-owned
|
|
# meta/applied repos; libgit2 refuses cross-user reads without it.
|
|
safeDirGitconfig = pkgs.writeText "hyperhive-safe-gitconfig" ''
|
|
[safe]
|
|
directory = *
|
|
'';
|
|
in
|
|
{
|
|
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 - -" ];
|
|
|
|
# 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").
|
|
#
|
|
# `nix` itself shells out by bare name too: `git` whenever it has to
|
|
# fetch/re-resolve a git-source flake input (an agent.nix with a
|
|
# `git+https://…` input, or a stale flake.lock whose node URL no longer
|
|
# matches the flake's declared input → nix re-resolves at eval), and
|
|
# `ssh` to dispatch to remote builders (`nix.buildMachines` /
|
|
# `ssh-ng://`). Without these on PATH `nixos-container update` dies with
|
|
# `executing "git": No such file or directory` / `Could not find
|
|
# executable 'ssh'` — the agent build fails before it starts.
|
|
path = [
|
|
pkgs.nixos-container
|
|
pkgs.nix # nix, nix-env, nix-instantiate — create + update
|
|
pkgs.gitMinimal # git — nix fetches/re-resolves git-source flake inputs
|
|
pkgs.openssh # ssh — nix dispatches builds to remote builders
|
|
pkgs.util-linux # umount (nsenter is hardcoded in the script)
|
|
pkgs.e2fsprogs # chattr
|
|
pkgs.btrfs-progs # btrfs subvolume create/delete — Ensure/DeleteAgentSubvolume
|
|
];
|
|
environment = {
|
|
# `nixos-container update/create` runs `nix`, which writes its
|
|
# fetcher/eval 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 rebuilds).
|
|
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 nixos-container update and the nix prebuild see the
|
|
# same store and substituters as every other build context.
|
|
NIX_REMOTE = "daemon";
|
|
};
|
|
serviceConfig = {
|
|
ExecStart = "${cfg.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 — <container>.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
|
|
# /run/hive-ci — register_ci_runner's runner-token write
|
|
# (else token stays placeholder → runner
|
|
# crash-loops → no CI hive-wide)
|
|
# /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"
|
|
"/run/hive-ci"
|
|
"/var/lib/nixos-containers"
|
|
"/var/lib/hyperhive"
|
|
"/nix"
|
|
];
|
|
# Writable HOME for nix's caches (see environment.HOME above).
|
|
StateDirectory = "hive-priv";
|
|
# 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";
|
|
# nix (run here as root for `nixos-container update --flake
|
|
# /var/lib/hyperhive/meta#<agent>`) fetches the hive-core-owned
|
|
# meta/applied repos; libgit2 refuses them without safe.directory.
|
|
# See safeDirGitconfig above.
|
|
ExecStartPre = "+-${pkgs.coreutils}/bin/cp ${safeDirGitconfig} /var/lib/hive-priv/.gitconfig";
|
|
};
|
|
};
|
|
};
|
|
}
|