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

@ -297,6 +297,58 @@ Alarm, not resurrection. The distinction the old generation *can* draw is
between failures it owns and failures it does not — not between a target that
is broken and a target that is finished.
## Host prerequisites for any routed target
Done on yggdrasil 2026-09-06. These are shared, not per-target: the first routed
migration needs them, every later one reuses them.
**nginx stream context.** Debian ships `ngx_stream_module` as a dynamic module
in a separate package, and it was not installed — `--with-stream=dynamic` in
`nginx -V` with no `.so` present. Installed `libnginx-mod-stream` at
`1.26.3-3+deb13u7`, exactly matching the running nginx, so nothing else moved.
Added a top-level `stream` block to `/etc/nginx/nginx.conf` including
`/etc/nginx/idunn-stream-routes/*.conf`, and created that directory `root:idunn
0775`. `nginx -t` passed before the reload; after it, `gamecult.org` and
`heimdall.gamecult.org/healthz` both still answered 200. Backup at
`/root/nginx.conf.bak-20260906-stream`.
Note the shape this locks in: **the vhost stays operator-owned and static.**
nginx keeps TLS, `server_name` and path routing and proxies to a stable local
endpoint; Idunn owns only the hop from that stable endpoint to whichever
candidate is admitted. Writing into `/etc/nginx/idunn-stream-routes` is the
whole of Idunn's nginx authority, and stream configuration cannot do the things
an http-context injection could.
**Idunn runs as root, and the unit says why.** This is the largest difference
between the generations and it should not be discovered at install time.
The previous generation ran `User=idunn` and reached root through a narrow
sudoers grant onto `/usr/local/libexec/idunn-yggdrasil`, a shell actuator. The
current generation has **no `sudo` anywhere in its source**: it calls
`systemctl`, `systemd-run`, `nginx` and `docker` directly, and a confined
`User=` cannot actuate anything at all. `docs/deployment-authority.md` states
the intended model — *"Freeze the exact source and recipe as `idunn`, then copy
and verify it 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 separation is internal rather than at the process boundary, and the
protection is that Idunn accepts no imperative input: capability lives in the
recipe, affordances in the binding, and `cli_exposes_only_declarative_commands`
asserts there is no way to hand it a command string. The unit still runs under
`ProtectSystem=full` with each actuated path re-opened individually, so the
write set is enumerated even though the uid is root.
That is a real increase in blast radius over a confined process, and worth a
deliberate look before the cutover rather than a shrug. The counter-argument is
that the old shape was confined in name only: the process was sandboxed and the
thing it invoked was an unaudited root shell script with eleven hardcoded
targets.
**Still outstanding here:** the route driver stages preflight in
`/run/idunn/route-preflight`; the unit now declares `RuntimeDirectory=idunn`,
which creates and removes `/run/idunn` with the service.
## Cutting over the authority
Idunn last, and not by deploying Idunn with Idunn.