Skip to content

Remove --headless (systemd transient-scope detach); logging, tee fix, and native shell-out removal#12

Merged
ananthb merged 5 commits into
mainfrom
fix/headless-cgroup-escape
Jul 15, 2026
Merged

Remove --headless (systemd transient-scope detach); logging, tee fix, and native shell-out removal#12
ananthb merged 5 commits into
mainfrom
fix/headless-cgroup-escape

Conversation

@ananthb

@ananthb ananthb commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Started as the --headless fix; grew to remove --headless entirely and, per review, eliminate every system-utility shell-out.

--headless → systemd transient-scope detach

--headless daemonized via a raw clone()+setsid() fork, silently reaped when launched inside a systemd .service (run0, systemd-run): the service deactivates when the forked parent exits and KillMode=control-group SIGKILLs the child. Found dogfooding a remote reprovision.

  • Remove --headless and the internal/daemon package (no fork, no re-exec).
  • On systemd, relocate the running process into a transient scope via StartTransientUnit over D-Bus (systemd-run --scope semantics), marked IgnoreOnIsolate so the init coordinator's isolate basic.target doesn't stop it. Skipped under --systemd-mode.
  • Detect SSH: warn the session drops during the pivot; when we couldn't detach (non-systemd/failure), pause a grace window so a disconnect can't silently kill the pivot.

Logging

  • --log-dir kept as a general additional file sink (not headless-only), flushed into the new rootfs before pivot.
  • journald via stdout when present; syslog only when journald is absent — not both.
  • More breadcrumbs; log.Handler gains AddSink, shared across clones.

No more shell-outs

Replaced every system-utility exec with native calls:

  • coordinator: systemctl isolate / list-jobs → D-Bus Manager.StartUnit/ListJobs; dropped openrc/telinit (non-systemd relies on the native process.Terminate).
  • watchdog: modprobe softdogfinit_module(2) (locates the .ko, decompresses .ko.zst/.xz/.gz via the kernel).
  • firewall: iptables/nft → native google/nftables ruleset flush (already in the module graph; firewall_linux.go + non-linux stub).

Remaining exec.Command sites run the user's own program — the entrypoint (supervise, contain) and the SSH session shell (sshd) — which is xmorph's purpose, not a utility shell-out.

CI fix

fix(postpivot): serialize supervisor log tee — the flaky TestSuperviseTeesToLogWriter was a real data race (two exec copy goroutines writing one bytes.Buffer); serialized through a locked writer, 30× -race clean.

Test

CGO_ENABLED=0 GOOS=linux go build ./..., go vet, gofmt, and native -race tests all clean. go.sum unchanged, so the nix vendorHash is unaffected. The D-Bus/finit_module/nftables paths are compile-verified here; runtime validation is on a real systemd host (the reprovision this was found on).

Companion: #11 (flatcar remote doc, updated to --systemd-mode).

@ananthb
ananthb force-pushed the fix/headless-cgroup-escape branch from 94f8647 to f771539 Compare July 15, 2026 20:16
@ananthb ananthb changed the title fix(daemon): escape launcher cgroup so --headless survives run0 fix(pivot): refuse --headless inside a systemd .service; use --systemd-mode Jul 15, 2026
ananthb added 3 commits July 16, 2026 02:12
Supervise gave cmd.Stdout and cmd.Stderr two separate MultiWriters both
teeing into the same LogWriter. os/exec copies the child's stdout and
stderr on distinct goroutines, so the shared bytes.Buffer was written
concurrently without synchronization — a data race that dropped lines
(TestSuperviseTeesToLogWriter flaked in CI). Share one mutex-guarded
writer between the two tees.
--headless daemonized via a raw clone()+setsid() fork, which is silently
reaped when launched inside a systemd .service (run0, systemd-run): the
service deactivates the moment the forked parent exits and
KillMode=control-group SIGKILLs the leftover child. Found dogfooding a
remote reprovision over 'ssh … run0 xmorph pivot --headless'.

Remove --headless and the internal/daemon package entirely. On systemd
hosts xmorph now relocates the *running* process into a transient scope via
Manager.StartTransientUnit over D-Bus — the move 'systemd-run --scope'
makes, but with no fork and no re-exec — so the pivot outlives the
launching session/service. Skipped under --systemd-mode (already a unit).
Detect SSH and, when we could not detach, warn + pause a grace window so a
disconnect doesn't silently kill the pivot.

Logging: keep --log-dir but as a general additional file sink (not
headless-only); add a syslog sink when journald is absent (journald already
captures stderr); more breadcrumbs across the pivot pipeline. log.Handler
grows AddSink, shared across clones.

- add initsys.RelocateToTransientScope + initsys.RunningOverSSH
- godbus/dbus/v5 promoted to a direct dependency
…less

Detaching from an SSH session is now automatic on systemd (transient-scope
relocation); non-systemd hosts get a warning + grace window. Drop --headless
from every example, add a 'Surviving your SSH session' section, and point
unit-driven runs at --systemd-mode.
@ananthb
ananthb force-pushed the fix/headless-cgroup-escape branch from f771539 to 40685a3 Compare July 15, 2026 20:43
@ananthb ananthb changed the title fix(pivot): refuse --headless inside a systemd .service; use --systemd-mode Replace --headless with systemd transient-scope relocation; logging + tee fixes Jul 15, 2026
ananthb added 2 commits July 16, 2026 02:31
…ftables)

xmorph shelled out to systemctl/openrc/telinit, modprobe, and iptables/nft.
Replace all of it with native calls:

- initsys/coordinator: systemd transition + job wait go over D-Bus
  (Manager.StartUnit isolate / ListJobs) instead of exec'ing systemctl.
  Non-systemd inits (OpenRC/SysVinit/…) have no native control channel, so
  drop the advisory openrc/telinit shell-out — process.Terminate already
  stops what's running natively.
- initsys/scope: mark the transient scope IgnoreOnIsolate so isolating
  basic.target (above) doesn't stop our own scope and kill the pivot.
- postpivot/watchdog: load softdog via finit_module(2) (locating the .ko
  under /lib/modules, decompressing .ko.zst/.xz/.gz via the kernel) instead
  of exec'ing modprobe.
- postpivot/firewall: flush the ruleset natively via google/nftables
  (already in the module graph) rather than iptables/ip6tables/nft. Split
  into firewall_linux.go + a no-op firewall_other.go stub.

The only remaining exec.Command sites run the user's own program — the
entrypoint (supervise, contain) and the SSH session shell (sshd) — which is
xmorph's purpose, not a utility shell-out. go.sum unchanged (nftables was
already an indirect dep), so the nix vendorHash is unaffected.
ec7d853 captured only the firewall.go deletion (git add aborted on a stale
pathspec, leaving the real changes uncommitted). Land them:

- initsys/coordinator: systemd transition + job wait over D-Bus
  (StartUnit isolate / ListJobs); drop openrc/telinit exec (process.Terminate
  covers non-systemd inits natively).
- initsys/scope: transient scope marked IgnoreOnIsolate so isolating
  basic.target doesn't stop our own scope mid-pivot.
- postpivot/watchdog: load softdog via finit_module(2), not modprobe.
- postpivot/firewall_{linux,other}: native google/nftables flush + a
  non-linux no-op stub, replacing iptables/ip6tables/nft.
- pivot/contain: correct a stale comment about a /bin/sh -c wrapper.

go.sum unchanged; nix vendorHash unaffected.
@ananthb ananthb changed the title Replace --headless with systemd transient-scope relocation; logging + tee fixes Remove --headless (systemd transient-scope detach); logging, tee fix, and native shell-out removal Jul 15, 2026
@ananthb
ananthb merged commit e018a70 into main Jul 15, 2026
1 of 2 checks passed
@ananthb
ananthb deleted the fix/headless-cgroup-escape branch July 15, 2026 21:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant