1
0
Fork 0
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%
Find a file
Ananth a3440e07d1
feat(ssh): generate a root password when none is given (#19)
* 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.
2026-08-19 13:47:51 +05:30
.github/workflows test(pivot): assert the machine survives its own pivot (#18) 2026-08-18 18:45:02 +05:30
cmd/xmorph M3/M4/M5: --contain, post-pivot init runtime, real pivot pipeline 2026-06-21 13:28:31 +05:30
docs feat(ssh): generate a root password when none is given (#19) 2026-08-19 13:47:51 +05:30
init/systemd M7: cut Zig out, rename xenomorph → xmorph, switch flake to Go 2026-06-21 17:14:20 +05:30
internal feat(ssh): generate a root password when none is given (#19) 2026-08-19 13:47:51 +05:30
nix feat(ssh): generate a root password when none is given (#19) 2026-08-19 13:47:51 +05:30
.envrc xenomorph breaks you out of your running Linux 2026-02-08 12:31:02 +05:30
.gitignore ci: switch release to goreleaser; pin Go 1.26.4; fix nix build 2026-06-21 18:15:32 +05:30
.goreleaser.yaml ci: switch release to goreleaser; pin Go 1.26.4; fix nix build 2026-06-21 18:15:32 +05:30
_config.yml docs: enable jekyll-readme-index plugin so README.md maps to / 2026-07-13 02:56:00 +05:30
flake.lock ci: switch release to goreleaser; pin Go 1.26.4; fix nix build 2026-06-21 18:15:32 +05:30
flake.nix feat(ssh): generate a root password when none is given (#19) 2026-08-19 13:47:51 +05:30
go.mod refactor: native replacements for shell-outs (finish ec7d853) 2026-07-16 02:34:05 +05:30
go.sum feat(sshd): implement --ssh.enable with pubkey + password auth 2026-07-13 02:53:01 +05:30
LICENSE Re-license under AGPL-3.0 (oci-zig dependency is AGPL) 2026-03-28 16:32:41 +05:30
README.md test: add real-kernel pivot integration test in a NixOS VM + wire into CI 2026-07-17 03:07:01 +05:30

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.

Licensed under the AGPL-3.0.