Skip to content

fix(pivot): serve SSH without an entrypoint, reboot when one exits - #17

Merged
ananthb merged 3 commits into
mainfrom
fix/serve-mode-no-shell
Aug 17, 2026
Merged

fix(pivot): serve SSH without an entrypoint, reboot when one exits#17
ananthb merged 3 commits into
mainfrom
fix/serve-mode-no-shell

Conversation

@ananthb

@ananthb ananthb commented Aug 17, 2026

Copy link
Copy Markdown
Owner

What went wrong

A remote pivot with --ssh/--tailscale and no --command leaves 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_root never 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. Supervise returns and takes the SSH server (a goroutine) with it.

Passing sleep infinity works, 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 --entrypoint nor --command, we now supervise nothing: ServeUntilSignal blocks 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 Cmd is ["/bin/sh"]), and a shell is precisely what must not be supervised here.

2. RebootOnFailure treated 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. Renamed RebootOnExit, 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 names 127.0.0.53, a stub whose daemon is terminated during pivot prep — 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 exactly when remote access is the only access left.

Result

The motivating command now works with no shell idioms:

xmorph pivot --force --image alpine:latest --tailscale.authkey ... --ssh.enable

The dry run also now states which lifecycle will run, instead of printing Execute /bin/sh for a shell that was never going to survive.

Testing

go build, go vet, and go 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 serveOnly and reboot-on-exit paths are the ones to watch on the next live run.

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.
@ananthb

ananthb commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

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 Cmd is a real long-running daemon — exactly what a purpose-built rescue image looks like — is indistinguishable at that point from alpine's ["/bin/sh"], so the guess would silently skip the very program the operator built the image around. It also makes behaviour depend on an unrelated flag.

With RebootOnExit in place, honouring the image is already safe: a shell that exits reboots the box back into the on-disk OS.

So serve mode is now an explicit --serve, and nothing is inferred. Safe isn't 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:

entrypoint "/bin/sh" is a shell with no terminal attached: it will read EOF on stdin
and exit immediately after the pivot, rebooting the box back into the on-disk OS.
  --serve            stay up serving SSH (what a remote rescue pivot wants)
  --command ...      run a specific program instead
  --entrypoint ...   override the image's default explicitly

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.

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.
@ananthb
ananthb merged commit fd44e77 into main Aug 17, 2026
3 checks passed
@ananthb
ananthb deleted the fix/serve-mode-no-shell branch August 17, 2026 19:52
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