Land the nginx stream context; run Idunn as root, and say why in the unit

Shared prerequisite for every routed target, done on yggdrasil. Debian
ships ngx_stream_module as a separate dynamic package and it was absent:
nginx -V showed --with-stream=dynamic with no .so. Installed
libnginx-mod-stream at 1.26.3-3+deb13u7, matching the running nginx
exactly so nothing else moved, added a top-level stream block including
/etc/nginx/idunn-stream-routes/*.conf, and created that directory
root:idunn 0775. nginx -t passed before the reload; gamecult.org and
heimdall.gamecult.org/healthz both returned 200 after it. Backup at
/root/nginx.conf.bak-20260906-stream.

Worth recording how the module check nearly went wrong: the first
`nginx -V` ran without sudo, hit "command not found" because the binary is
not on the unprivileged PATH, and reported NO STREAM MODULE. That is the
estate's own trap -- a failed read presented as a negative result -- caught
only because the answer looked too convenient.

The larger correction: the unit I wrote yesterday ran User=idunn, and this
generation cannot work that way. There is no sudo anywhere in Idunn's
source; it calls systemctl, systemd-run, nginx and docker directly, so a
confined user actuates nothing. deployment-authority.md states the intended
model plainly -- freeze source as idunn, copy into a root-owned actuation
stage, the privileged driver never opens Git -- and --source-uid/--source-gid
are validated as non-zero, which only makes sense for a privileged daemon
dropping down for source work.

So the unit now runs as root with --source-uid 986 --source-gid 978, keeps
ProtectSystem=full and re-opens each actuated path individually, and adds
RuntimeDirectory=idunn for the route driver's preflight staging. The
comment block explains the tradeoff rather than leaving the next reader to
infer it: privilege separation is internal, and what makes it tolerable is
that Idunn accepts no imperative input.

That is a genuine increase in blast radius over a confined process, and
migration.md says so instead of selling it. The counter-argument is that
the old shape was confined in name only -- a sandboxed process whose sudo
grant pointed at an unaudited root shell with eleven hardcoded targets.

systemd-analyze verify passes on the rewritten unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-06 15:00:04 +02:00
commit 7eba051c5a
2 changed files with 101 additions and 18 deletions

View file

@ -12,11 +12,29 @@ Wants=docker.service
[Service]
Type=simple
User=idunn
Group=idunn
# Idunn discovers no authority implicitly. Every store is named here, and
# every one of them is Idunn's own -- no path below belongs to a managed
# target. That is the invariant, expressed as a command line.
# Idunn runs privileged, and this is deliberate rather than laziness. It is the
# actuation authority: it starts transient units with systemd-run, writes unit
# files and route configuration, reloads nginx, and drives Docker. It invokes
# those programs directly -- there is no sudo anywhere in the source -- so a
# confined User= cannot actuate anything.
#
# The privilege separation is *internal*, not at the process boundary. Source
# freezing runs as the unprivileged idunn user via --source-uid/--source-gid
# (both validated as non-zero); the privileged driver never opens Git. What
# stops a compromised target repository from using that privilege is that Idunn
# accepts no imperative input: a recipe declares capability, the operator
# binding grants affordances, and cli_exposes_only_declarative_commands asserts
# there is no way to hand the daemon a command string.
#
# The previous generation ran as User=idunn and reached root through a narrow
# sudoers grant onto a shell actuator. That inverted the safety story: the
# process was confined but the thing it invoked was an unaudited root shell.
User=root
Group=root
# Idunn discovers no authority implicitly. Every store is named here, and every
# one of them is Idunn's own -- no path below belongs to a managed target.
ExecStart=/usr/local/bin/idunn serve \
--state-store /var/lib/gamecult/idunn/control.cc \
--bindings-dir /etc/gamecult/idunn/bindings \
@ -26,27 +44,40 @@ ExecStart=/usr/local/bin/idunn serve \
--idunn-identity-store /var/lib/gamecult/idunn/idunn-service-identity.cc \
--odin-correlation-store /var/lib/gamecult/odin/idunn-runtime-topology.cc \
--odin-trust-anchor /etc/gamecult/idunn/odin-topology-anchor.cc \
--deployment-brake-operator-anchor /etc/gamecult/idunn/deployment-brake-operator-anchor.cc
--deployment-brake-operator-anchor /etc/gamecult/idunn/deployment-brake-operator-anchor.cc \
--source-uid 986 \
--source-gid 978
Restart=always
RestartSec=5
TimeoutStopSec=20
KillSignal=SIGINT
UMask=027
# /run/idunn, for the route driver's preflight staging. systemd creates and
# removes it with the unit, so it never survives as stale state across a
# restart.
RuntimeDirectory=idunn
RuntimeDirectoryMode=0750
PrivateTmp=yes
ProtectHome=yes
# ProtectSystem=full keeps /usr and /etc read-only, then the paths Idunn
# actually actuates are re-opened one at a time. This list is the blast radius,
# and it is the reason running as root is tolerable: root plus an enumerated
# write set is narrower than root everywhere.
ProtectSystem=full
# Idunn's own state, and the build root it materializes releases in.
# Enumerated rather than broadened: this list is the blast radius.
ReadWritePaths=/var/lib/gamecult/idunn /var/lib/gamecult/idunn-authority /srv/build
# Everything Idunn only reads. The operator anchor and target bindings are
# operator-authored: Idunn admits against them, it does not write them.
# /var/lib/gamecult/odin is a managed target's directory and is read-only on
# purpose -- Idunn consumes Odin's published topology correlation to gate
# *deployment* promotion, and nothing else. Continuity never consults it
# (see stale_odin_provider_receipt_cannot_gate_continuity_restart), and an
# absent store returns None rather than failing, so Odin being down blocks
# promotions and leaves daemon survival untouched.
ReadOnlyPaths=/etc/gamecult/idunn /var/lib/gamecult/odin
ReadWritePaths=/etc/gamecult/idunn
ReadWritePaths=/etc/nginx/idunn-stream-routes
ReadWritePaths=/etc/systemd/system
ReadWritePaths=/var/lib/gamecult/idunn /var/lib/gamecult/idunn-authority
ReadWritePaths=/srv/build
# Odin's directory is a managed target's, and is read-only on purpose: Idunn
# consumes Odin's published topology correlation to gate *deployment* promotion
# and nothing else. Continuity never consults it -- see
# stale_odin_provider_receipt_cannot_gate_continuity_restart -- and an absent
# store yields None rather than an error, so Odin being down blocks promotions
# and leaves daemon survival untouched.
ReadOnlyPaths=/var/lib/gamecult/odin
[Install]
WantedBy=multi-user.target