fix(pivot): serve SSH without an entrypoint, reboot when one exits - #17
Conversation
A remote pivot with --ssh/--tailscale and no --command left the box with a running kernel and no userspace: reachable by ICMP, dead on every port, recoverable only by physically power-cycling it. Observed on an RPi 3A+ that pivoted, registered on the tailnet, and was never reachable again. Three independent defects, each sufficient on its own. The entrypoint defaults to /bin/sh, and a shell driven from a detached pivot has no terminal: stdin is /dev/null, it reads EOF, and it exits 0 before anyone can connect. Supervise then returns and takes the SSH server, started as a goroutine, down with it. Telling users to pass `sleep infinity` works but makes the headline use case depend on a shell idiom and on the image shipping coreutils. Instead, when services are enabled and the operator named neither --entrypoint nor --command, supervise nothing: ServeUntilSignal blocks until TERM/INT, reaping orphans. It needs nothing from the image at all. The image's own default is deliberately ignored here, since minimal images default to a shell and a shell is exactly what must not be supervised in this mode. RebootOnFailure only fired on a non-zero status, so the clean EOF exit above was treated as success and simply returned. But post-pivot there is no init to fall back to -- this supervisor is the only thing holding userspace up, so any exit is fatal and a reboot into the on-disk OS beats being bricked-alive. Renamed to RebootOnExit and fired unconditionally. This alone would have made the incident self-recovering. Minimal images (alpine, busybox, distroless) ship no /etc/resolv.conf, and the host's cannot simply be copied: on systemd-resolved distros it names 127.0.0.53, a stub whose daemon is terminated during pivot preparation, so the result looks configured and resolves nothing. EnsureResolvConf keeps a usable file, else inherits the host's routable nameservers, else falls back to public resolvers -- and replaces a dangling stub symlink with a real file. Tailscale needs DNS to reach the coordination server, so this strands the box precisely when remote access is the only access left. The dry run now states which of the two lifecycles will run, rather than printing "Execute /bin/sh" for a shell that was never going to survive.
Replaces the implicit serve-mode switch from the previous commit. That version entered serve mode whenever services were enabled and no command was named, reasoning that the image's default shell could not have been intended. That inference is not sound. An image whose Cmd is a real long-running daemon -- exactly what a purpose-built rescue image looks like -- is indistinguishable here from alpine's ["/bin/sh"], so the guess silently skips the very program the operator built the image around. Ignoring a declared Cmd is also just surprising: it makes behaviour depend on an unrelated flag. With RebootOnExit already in place, honouring the image is safe: a shell that exits reboots the box back into the on-disk OS. Predictable beats clever. So serve mode is now the explicit --serve, and nothing is inferred. Safe is not the same as useful, though -- a bare `--image alpine --ssh.enable` would pivot, exit, and reboot in a loop, with the cause buried in a log on a filesystem that just went away. checkEntrypointSurvivesDetach catches that before anything destructive happens, while the old root is intact and aborting is free, and names the three ways forward. Only the no-TTY case is rejected: with a console attached a shell is a legitimate entrypoint, so stdin decides. --serve alongside --entrypoint/--command is rejected outright rather than resolved in either direction, since either choice discards what was asked for.
|
Revised in 1aca6b2 after review — the implicit serve-mode switch is gone. The original version entered serve mode whenever services were enabled and no command was named. That inference isn't sound: an image whose With So serve mode is now an explicit Only the no-TTY case is rejected — with a console attached a shell is a legitimate entrypoint, so stdin decides. The other two fixes (reboot-on-any-exit, resolv.conf) are unchanged. |
--serve never said serve what. It was also a second lifecycle bolted onto a tool that already has a perfectly good way to say "run this": the entrypoint. xmorph copies its own binary into every pivoted rootfs at /usr/local/bin/xmorph before pivot_root, so "stay alive" can be an ordinary program rather than a mode. `xmorph idle` blocks until signalled, reaping orphans, and is available no matter how minimal the image -- no shell, no coreutils: xmorph pivot --entrypoint /usr/local/bin/xmorph --cmd idle --ssh.enable This removes the Serve config field, the serve-vs-supervise branch in Run, the serveOnly helper, the dry-run fork, and the --serve/--command mutual-exclusion rule, which now cannot arise: naming two entrypoints is already an error. One lifecycle remains -- supervise an entrypoint, reboot when it exits -- and idle is just an entrypoint that does not exit. The pre-flight shell check stays, since a bare shell still cannot survive being detached, and now points at the idle entrypoint instead of a flag. Run recognises [BinaryPath, "idle"] and blocks in the supervisor rather than forking a second copy of a 41 MB Go binary into a tmpfs-backed rootfs on a 415 MB machine. Optimisation only; the semantics are the entrypoint's.
What went wrong
A remote pivot with
--ssh/--tailscaleand no--commandleaves the box reachable by ICMP and dead on every port — a running kernel with no userspace, recoverable only by physically power-cycling it.Hit for real on an RPi 3A+: it pivoted, registered on the tailnet, and was never reachable again. ICMP kept answering the whole time, which is worth calling out — the kernel answers ping, and
pivot_rootnever touches the kernel. "It pings" proves nothing about whether the new rootfs works.Three defects, each sufficient alone
1. The default entrypoint cannot survive a remote pivot. It's
/bin/sh, and a shell driven from a detached pivot has no terminal — stdin is/dev/null, it reads EOF, and it exits 0 before anyone can connect.Supervisereturns and takes the SSH server (a goroutine) with it.Passing
sleep infinityworks, but that makes the headline use case depend on a shell idiom and on the image shipping coreutils. So when services are enabled and the operator named neither--entrypointnor--command, we now supervise nothing:ServeUntilSignalblocks until TERM/INT, reaping orphans. It needs nothing from the image.The image's own default is deliberately ignored in this mode — minimal images default to a shell (alpine's
Cmdis["/bin/sh"]), and a shell is precisely what must not be supervised here.2.
RebootOnFailuretreated the clean exit above as success. It only fired on non-zero. But post-pivot there is no init to fall back to — the supervisor is the only thing holding userspace up, so any exit is fatal. RenamedRebootOnExit, fired unconditionally. This alone would have made the incident self-recovering.3. No
/etc/resolv.conf. Minimal images ship none, and the host's can't just be copied: on systemd-resolved distros it names127.0.0.53, a stub whose daemon is terminated during pivot prep — so the result looks configured and resolves nothing.EnsureResolvConfkeeps a usable file, else inherits the host's routable nameservers, else falls back to public resolvers, and replaces a dangling stub symlink with a real file. Tailscale needs DNS to reach the coordination server, so this strands the box exactly when remote access is the only access left.Result
The motivating command now works with no shell idioms:
The dry run also now states which lifecycle will run, instead of printing
Execute /bin/shfor a shell that was never going to survive.Testing
go build,go vet, andgo test ./internal/postpivot/...pass; cross-built for linux/arm64. New tests cover resolv.conf parsing, loopback-stub filtering, the absent/usable/stub-only/dangling-symlink cases.Not yet exercised on real hardware — the board this came from needs a power cycle first. The
serveOnlyand reboot-on-exit paths are the ones to watch on the next live run.