Watch
0
0
Fork
You've already forked hyperhive
0

scope the cache access to hive-core: client-side nix copy, daemon trusts only the key

This commit is contained in:
atlas 2026-11-07 15:25:30 +01:00 • committed by mara
commit dab7768869
6 changed files with 365 additions and 271 deletions

View file

@ -316,7 +316,7 @@ in
# (/run/hyperhive), and ReadWritePaths below, which systemd keeps
# writable. Beyond the managed directories:
# - the one nix call is the closure pull (src/cache_pull.rs):
# `nix-store --realise` with NIX_REMOTE=daemon, so the host daemon
# `nix copy --from` with NIX_REMOTE=daemon, so the host daemon
# writes the store — hive-c0re never writes to /nix itself. It
# connects to the daemon's socket, which RestrictAddressFamilies
# (deferred above) must keep allowing.

View file

@ -1,21 +1,27 @@
# The binary-cache facts the host's nix daemon and hive-priv (root) trust, kept
# where only root can change them.
# The binary-cache facts the host's nix daemon, hive-priv (root) and hive-c0re
# use, kept where only root can change them.
#
# hive-c0re downloads an agent's closure from the swarm cache through the
# host's nix daemon, and hive-priv applies it only if the swarm's key signed
# it. What decides whether a download is accepted — which cache the daemon
# lets an unprivileged client name, and which key must have signed it — must
# not come from a file the unprivileged hive-c0re writes, or a compromised
# hive-c0re could have root accept a closure from a cache of its own. So:
# hive-c0re pulls an agent's closure from the swarm cache with its own
# `nix copy --from`: the client does the HTTP fetch with the pull token, and
# hands the NAR to the host's nix daemon, which stores it only if a key in its
# `trusted-public-keys` signed it. hive-priv applies a closure only if the
# swarm's key signed it. What decides that must not come from a file the
# unprivileged hive-c0re writes. So:
#
# - the cache's address is this host's configuration
# ({option}`services.hyperhive.swarm.attic.domain` and `cache`);
# ({option}`services.hyperhive.swarm.attic.domain` and `cache`), handed to
# hive-c0re in its unit's environment;
# - the cache's public key and the pull token are read from the store by a root
# oneshot, `hive-priv-cache-facts`, under this hive's own store identity. The
# key and the token land in `/run/hyperhive-priv/attic/` (root, `0700`; files
# `0600`), and the address, the key and the netrc's path in a nix.conf
# fragment, `/run/hyperhive-nix/cache.conf`, which the host's nix.conf
# includes (`!include`, so a host whose unit has not run yet still starts).
# `0600`). The key also lands in a nix.conf fragment,
# `/run/hyperhive-nix/cache.conf`, as `extra-trusted-public-keys`, which the
# host's nix.conf includes (`!include`, so a host whose unit has not run yet
# still starts). The fragment names no substituter and no netrc, so a client
# of the daemon that is not hive-c0re (an agent container on `nixBuilds =
# host`) cannot fetch from the cache or send the token;
# - the token also lands in `/run/hyperhive-nix/netrc`, `root:hive-core 0640`:
# the one place hive-core, and nothing else unprivileged, can read it.
#
# hive-c0re keeps its own copies under `/run/hyperhive/attic/` for the probe,
# which only reports.
@ -47,7 +53,7 @@ let
dir = "/run/hyperhive-priv/attic";
confDir = "/run/hyperhive-nix";
confFile = "${confDir}/cache.conf";
url = "https://${atticCfg.domain}/${atticCfg.cache}";
clientNetrc = "${confDir}/netrc";
# Where the store runs on this host, the first login waits for its container.
# No `wants`: a pull would start the container mid-switch on the old closure.
@ -66,6 +72,10 @@ in
# `!include` tolerates a fragment that does not exist yet.
nix.extraOptions = "!include ${confFile}";
# The address hive-c0re names to `nix copy --from`; no request chooses it.
systemd.services.hive-c0re.environment.HIVE_C0RE_CACHE_URL =
"https://${atticCfg.domain}/${atticCfg.cache}";
systemd.services.hive-priv-cache-facts = {
description = "Read the swarm cache's public key and pull token for the host's nix daemon and hive-priv";
wantedBy = [ "multi-user.target" ];
@ -101,7 +111,8 @@ in
script = import ./lib/attic-root-facts.nix { inherit lib; } {
hive = hiveController.hiveName;
host = atticCfg.domain;
inherit dir url confFile;
inherit dir confFile clientNetrc;
clientGroup = config.users.users.hive-core.group;
};
};

View file

@ -1,18 +1,22 @@
# The root-side copy of the cache facts the host's nix daemon and hive-priv
# trust: the key a downloaded closure must be signed by and the netrc the pull
# token goes out in. The script runs as root on the hive host under the hive's
# own store identity and writes both into `dir`, which only root can read, and
# a nix.conf fragment at `confFile` that the host's nix.conf includes.
# The root-side copy of the cache facts the host's nix daemon, hive-priv and
# hive-c0re use: the key a downloaded closure must be signed by and the netrc
# the pull token goes out in. The script runs as root on the hive host under
# the hive's own store identity and writes:
#
# What the daemon and hive-priv decide on is here or in the host's
# configuration, and nothing is read from a file hive-c0re (unprivileged)
# writes: the key and the token come from the store, the cache's address from
# `services.hyperhive.swarm.attic.{domain,cache}`. The fragment holds no secret
# (the key is public, the netrc stays in `dir`) and hive-c0re can read it: it
# takes the cache's address from there.
# - the key and the netrc into `dir`, which only root can read (hive-priv's
# check of the key);
# - a nix.conf fragment at `confFile` that the host's nix.conf includes, with
# `extra-trusted-public-keys` and nothing else: no trusted substituter and no
# netrc, so no other client of the daemon can fetch from the cache or send
# the token;
# - a second copy of the netrc at `clientNetrc`, `root:<clientGroup> 0640`,
# that hive-c0re passes to its own `nix copy --from` as a client setting.
#
# A store that holds no facts yet, or cannot be reached, leaves what is in `dir`
# and `confFile` as they are. Nothing here removes a file.
# Nothing is read from a file hive-c0re (unprivileged) writes: the key and the
# token come from the store. The fragment holds no secret.
#
# A store that holds no facts yet, or cannot be reached, leaves what is in
# `dir`, `confFile` and `clientNetrc` as they are. Nothing here removes a file.
#
# Pure function — NOT a NixOS module. Call it from a unit's `script`, with
# `bao`, `coreutils`, `gnugrep` and `systemd` on its `path` and the `BAO_*`
@ -20,7 +24,8 @@
#
# script = import ./lib/attic-root-facts.nix { inherit lib; } {
# hive = "alpha"; host = "attic.example.org"; dir = "/run/hyperhive-priv/attic";
# url = "https://attic.example.org/swarm"; confFile = "/run/hyperhive-nix/cache.conf";
# confFile = "/run/hyperhive-nix/cache.conf"; clientNetrc = "/run/hyperhive-nix/netrc";
# clientGroup = "hive-core";
# };
{ lib }:
{
@ -31,10 +36,11 @@
host,
# Where the key and the netrc go.
dir,
# The cache's address as the daemon lists it in `trusted-substituters`.
url,
# Where the nix.conf fragment goes; its directory is root's and readable.
confFile,
# Where the netrc copy for the client goes, and the group that may read it.
clientNetrc,
clientGroup,
}:
''
set -euo pipefail
@ -44,8 +50,9 @@
dir=${lib.escapeShellArg dir}
conf=${lib.escapeShellArg confFile}
client_netrc=${lib.escapeShellArg clientNetrc}
install -d -m 0700 "$dir"
install -d -m 0755 "$(dirname -- "$conf")"
install -d -m 0755 "$(dirname -- "$conf")" "$(dirname -- "$client_netrc")"
store_init
store_login
@ -95,14 +102,17 @@
atomic_write_secret 0600 "" "$dir/netrc" "$netrc"
fi
# Last, so a daemon that reads the fragment finds the netrc in place. The
# address is the one hive-c0re may name as the substituter. The mtime moves
# only with the content. Lix re-reads nix.conf for every connection, CppNix
# only at start, so a daemon that started before this write is restarted.
printf -v fragment '%s\n%s\n%s' \
${lib.escapeShellArg "extra-trusted-substituters = ${url}"} \
"extra-trusted-public-keys = $key" \
"netrc-file = $dir/netrc"
# The client's copy: readable by the group, which is a deliberate grant of the
# pull token to hive-c0re's user. Written before the fragment, so a host with
# the key has the netrc.
if secret_differs "$client_netrc" "$netrc"; then
atomic_write_secret 0640 ":${clientGroup}" "$client_netrc" "$netrc"
fi
# Last. The mtime moves only with the content. Lix re-reads nix.conf for every
# connection, CppNix only at start, so a daemon that started before this write
# is restarted.
fragment="extra-trusted-public-keys = $key"
if secret_differs "$conf" "$fragment"; then
atomic_write_secret 0644 "" "$conf" "$fragment"
fi

View file

@ -881,18 +881,32 @@ let
# The daemon only learns the cache from the fragment the root unit
# writes, and lists as trusted the one address from the host's
# configuration. A host with no unit has no include.
name = "the host's nix.conf includes the cache fragment the root unit writes, with the cache's address";
name = "the host's nix.conf includes a fragment that trusts only the swarm key, and only hive-core gets the token";
ok =
let
u = units.hive-priv-cache-facts;
c0re = allLocal.systemd.services.hive-c0re.environment;
in
lib.hasInfix "!include /run/hyperhive-nix/cache.conf" allLocal.nix.extraOptions
&& !(lib.hasInfix "hyperhive-nix" noIdentity.nix.extraOptions)
&& lib.hasInfix "conf=/run/hyperhive-nix/cache.conf" u.script
&& lib.hasInfix "extra-trusted-substituters = https://attic.t.local/swarm" u.script
&& lib.hasInfix ''fragment="extra-trusted-public-keys = $key"'' u.script
&& lib.hasInfix ''atomic_write_secret 0644 "" "$conf"'' u.script
# The daemon is told no substituter and no netrc: no other client of it
# can fetch from the cache or send the token.
&& !(lib.hasInfix "extra-trusted-substituters" u.script)
&& !(lib.hasInfix "trusted-substituters" allLocal.nix.extraOptions)
&& !(lib.hasInfix "netrc-file" u.script)
# The token's one unprivileged reader is the hive-core group.
&& lib.hasInfix "client_netrc=/run/hyperhive-nix/netrc" u.script
&& lib.hasInfix ''atomic_write_secret 0640 ":hive-core" "$client_netrc"'' u.script
&& lib.elem "d /run/hyperhive-nix 0755 root root - -" allLocal.systemd.tmpfiles.rules
# Control for the absence: the lookup finds the unit's other variable.
# hive-c0re is told the address; hive-priv no longer is.
&& c0re.HIVE_C0RE_CACHE_URL == "https://attic.t.local/swarm"
&& !(noIdentity.systemd.services.hive-c0re.environment ? HIVE_C0RE_CACHE_URL)
# Control for the absences: the lookups find the units' other variables.
&& c0re ? HOME
&& noIdentity.systemd.services.hive-c0re.environment ? HOME
&& allLocal.systemd.services.hive-priv.environment ? NIX_REMOTE
&& !(allLocal.systemd.services.hive-priv.environment ? HIVE_PRIV_CACHE_URL);
}

View file

@ -15,8 +15,9 @@ let
hive = "alpha";
host = "cache.example";
dir = "@DIR@/attic";
url = "https://cache.example/swarm";
confFile = "@DIR@/nix/cache.conf";
clientNetrc = "@DIR@/nix/netrc";
clientGroup = "@GROUP@";
}
);
in

View file

@ -19,7 +19,11 @@ expect() { # <description> <got> <want>
dir=$(mktemp -d)
mkdir -p "$dir/bin"
sed "s|@DIR@|$dir|g" "$SCRIPT" >"$dir/script.sh"
# A group other than the primary one when the runner has one, so the group the script sets
# differs from the one a new file gets.
group=$(id -Gn | tr " " "\n" | grep -vx "$(id -gn)" | head -1 || true)
group=${group:-$(id -gn)}
sed -e "s|@DIR@|$dir|g" -e "s|@GROUP@|$group|g" "$SCRIPT" >"$dir/script.sh"
calls=$dir/calls
export DIR=$dir
@ -96,22 +100,26 @@ expect "netrc" "$(body "$dir/attic/netrc")" "machine cache.example password tok-
expect "key mode" "$(mode "$dir/attic/public-key")" 600
expect "netrc mode" "$(mode "$dir/attic/netrc")" 600
expect "dir mode" "$(mode "$dir/attic")" 700
expect "fragment" "$(body "$dir/nix/cache.conf")" "extra-trusted-substituters = https://cache.example/swarm
extra-trusted-public-keys = swarm:S0VZ
netrc-file = $dir/attic/netrc"
expect "fragment is the key and nothing else" "$(body "$dir/nix/cache.conf")" "extra-trusted-public-keys = swarm:S0VZ"
expect "fragment mode" "$(mode "$dir/nix/cache.conf")" 644
expect "fragment dir mode" "$(mode "$dir/nix")" 755
expect "no secret in the fragment" "$(grep -c tok-1 "$dir/nix/cache.conf")" 0
expect "no substituter or netrc in the fragment" "$(grep -c -E "substituter|netrc" "$dir/nix/cache.conf")" 0
expect "client netrc" "$(body "$dir/nix/netrc")" "machine cache.example password tok-1"
expect "client netrc mode" "$(mode "$dir/nix/netrc")" 640
expect "client netrc group" "$(stat -c %G "$dir/nix/netrc")" "$group"
case="a second run with the same facts rewrites nothing"
k=$(ino "$dir/attic/public-key")
n=$(ino "$dir/attic/netrc")
cn=$(ino "$dir/nix/netrc")
c=$(ino "$dir/nix/cache.conf")
run
expect "exit" $? 0
expect "key untouched" "$(ino "$dir/attic/public-key")" "$k"
expect "netrc untouched" "$(ino "$dir/attic/netrc")" "$n"
expect "fragment untouched" "$(ino "$dir/nix/cache.conf")" "$c"
expect "client netrc untouched" "$(ino "$dir/nix/netrc")" "$cn"
case="a rotated token replaces the netrc and leaves the key"
seed "swarm:S0VZ" "tok-2"
@ -218,6 +226,8 @@ seed "swarm:S0VZ" "tok-2"
run DAEMON_STARTED="$started"
expect "exit" $? 0
expect "netrc" "$(body "$dir/attic/netrc")" "machine cache.example password tok-2"
expect "client netrc follows" "$(body "$dir/nix/netrc")" "machine cache.example password tok-2"
expect "client netrc mode" "$(mode "$dir/nix/netrc")" 640
expect "no restart" "$(restarts)" 0
case="a rotated key rewrites the fragment and restarts the daemon"
@ -233,6 +243,7 @@ reset
run DAEMON_STARTED="Sat 2000-01-01 00:00:00.000000 UTC"
expect "exit" $? 0
expect "no fragment" "$(body "$dir/nix/cache.conf")" missing
expect "no client netrc" "$(body "$dir/nix/netrc")" missing
expect "no restart" "$(restarts)" 0
case="a key that is refused writes no fragment"
@ -241,6 +252,7 @@ seed "swarm:S0 VZ" "tok-1"
run
expect "exit" $? 1
expect "no fragment" "$(body "$dir/nix/cache.conf")" missing
expect "no client netrc" "$(body "$dir/nix/netrc")" missing
if [ "$failures" -ne 0 ]; then
echo "$failures of $count checks failed" >&2