diff --git a/deploy/idunn-yggdrasil.service b/deploy/idunn-yggdrasil.service index 51317fe..cca19be 100644 --- a/deploy/idunn-yggdrasil.service +++ b/deploy/idunn-yggdrasil.service @@ -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 diff --git a/docs/migration.md b/docs/migration.md index d28e885..7ea8602 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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.