mirror of
https://github.com/ananthb/xmorph.git
synced 2026-09-20 16:27:03 +00:00
Replace your running rootfs with a new one without rebooting.
- Go 87.8%
- Nix 12.2%
* feat(ssh): generate a root password when none is given
`--ssh.enable` on its own used to refuse the pivot: sshd needs a password
or a key, and given neither it logged "no auth method configured" and
never bound the port. Refusing was better than the silent dead port it
replaced, but it still pushed the operator into inventing a password on a
command line, under time pressure, on the box they were about to take
apart. Three random words beat that every time.
xmorph now generates one — three words from the EFF short wordlist,
hyphenated, ~31 bits. Short enough to read off a serial console and
retype, which is the only channel that exists once the pivot is done.
It is printed twice on purpose. The pre-pivot banner goes to the terminal
that ran the command, which is usually the SSH session the pivot is about
to kill; the post-pivot one goes to the console, where a serial line will
still have it. A password the operator supplied is never printed and
never logged — it may be reused somewhere that matters, so it stays
theirs; SSHPasswordGenerated is what draws that line.
The VM test now takes the password the way an operator does: it reads the
banner off the console and logs in with it, then checks a wrong password
is refused, so an sshd that accepted anything could not pass.
Also close the gap that let a broken _test.go build clean on macOS and
fail only in CI: `nix run .#build` now compiles the Linux test binaries.
* test(lifecycle): read the password from the log, not the queue
The VM test hung for 900s waiting for a banner that had already been
printed. wait_for_console_text reads *forward* from a queue, and the
password banner comes out before "serving; no entrypoint to supervise" —
so the earlier wait had already drained past it and the second one could
never match. Poll get_console_log() instead, which is the whole log since
boot; the test then does not care what order xmorph logs things in.
Then it hung a second time, and that is the more expensive bug: the
assertions ran inside a script whose only target.crash() was on the happy
path. When the script raises, the driver still runs execute("sync") on
every machine that is_up(), which waits on a backdoor that died with the
old root — so a legible one-line failure became a 29-minute job that got
scored as a hang. Both pivot tests now crash the guest on the failure
path too.
The feature itself was fine throughout: both banners printed
throb-thorn-stash and sshd bound [::]:22.
* test(lifecycle): stop racing wait_for_console_text
wait_for_console_text drains its queue with one non-blocking get() per
retry iteration, and retry sleeps a second between iterations. That is one
console line per second, against a NixOS boot that emits hundreds — so
whether it matches inside the timeout is a race with the backlog, not a
question of whether the text was printed. The same call matched in 33s in
one CI run and blew a 180s timeout in the next, with the text present and
correct on the console both times.
full_console_log holds everything since boot and reading it consumes
nothing, so poll that instead. wait_console() does it in one place and
every console assertion in both pivot tests goes through it.
* fix(sshd): `ssh host cmd` no longer hangs with the client's stdin open
The VM test logged in with the generated password, ran `echo logged-in`,
and never got a prompt back. Auth was fine — the server logged
`sshd: accepted user=root`. The session was the problem.
runSession set cmd.Stdin = ch. os/exec copies a non-*os.File stdin on its
own goroutine and makes Wait() block until that copy returns, and it
returns only when the client closes the channel. A client closes it when
its own stdin hits EOF, which for `ssh host cmd` run from a terminal or a
live pipe is never. So the command exited, the output arrived, and the
session hung — on a rescue box, at the worst possible moment.
StdinPipe instead: Wait closes it on exit, which unblocks the copy.
Every existing test in sshd_linux_test.go missed this, and would have kept
missing it. x/crypto/ssh's Session sends channel EOF immediately when
Stdin is nil, so sess.Run("true") passes against a server that deadlocks.
The new test gives it an io.Pipe that never closes, which is what a real
terminal looks like. Reverting the fix hangs it for the full 10s budget.
|
||
|---|---|---|
| .github/workflows | ||
| cmd/xmorph | ||
| docs | ||
| init/systemd | ||
| internal | ||
| nix | ||
| .envrc | ||
| .gitignore | ||
| .goreleaser.yaml | ||
| _config.yml | ||
| flake.lock | ||
| flake.nix | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| README.md | ||
xmorph
Replaces a running Linux root filesystem with a new in-memory rootfs
built from OCI (Docker) images and rootfs tarballs — without rebooting.
The old root is kept for inspection and modification at /mnt/oldroot.
Useful as a rescue environment for hosts you can't reboot on demand, a
reprovisioning tool for pivoting into a fresh OS, or a rescue.target
replacement that stays reachable over Tailscale.
Install
# Nix
nix run github:ananthb/xmorph -- --help
# Static binary (each release ships a SHA256SUMS file alongside the archives)
curl -LO https://github.com/ananthb/xmorph/releases/latest/download/xmorph-x86_64-linux.tar.gz
tar xzf xmorph-x86_64-linux.tar.gz
sudo mv xmorph/xmorph /usr/local/bin/xmorph
# From source (Go 1.26+)
CGO_ENABLED=0 go build -o xmorph ./cmd/xmorph
Docs
Full guide, flag reference, systemd + NixOS integration, and reprovisioning recipes at ananthb.github.io/xmorph.
- Rescue an unreachable host
- Reprovision to Flatcar / FCOS / Alpine / NixOS / Ubuntu
- Testing (unit, NixOS VM, real-kernel dev loop)
Licensed under the AGPL-3.0.