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:
parent
d32d46e7be
commit
7eba051c5a
2 changed files with 101 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue