Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 52 additions & 22 deletions docs/rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,24 @@ broken on disk without rebooting from external media.
sudo xmorph pivot \
--image docker.io/library/alpine:latest \
--tailscale.authkey tskey-auth-xxxxx \
--headless
--force
```

What this does:

1. Pulls Alpine + the Tailscale image into a tmpfs in RAM
2. Coordinates with systemd / OpenRC / SysVinit to stop services
3. `pivot_root`s into the new rootfs
4. Forks and detaches so your current SSH session can close cleanly
2. On a systemd host, relocates itself into a transient scope so your SSH
session (or a `run0`/`systemd-run` wrapper) can't take it down when it
ends — see [Surviving your SSH session](#surviving-your-ssh-session)
3. Coordinates with systemd / OpenRC / SysVinit to stop services
4. `pivot_root`s into the new rootfs
5. Brings up Tailscale in the new rootfs; the node appears as
`<hostname>-xmorph` on your tailnet
6. Mounts the old root at `/mnt/oldroot`

Your SSH session drops when the pivot tears down the old OS's networking —
that's expected. You reconnect to the rescue node over Tailscale.

From your laptop:

```sh
Expand Down Expand Up @@ -123,7 +128,7 @@ sudo xmorph pivot \
--rootfs ./overlay/ \
--entrypoint /entrypoint.sh \
--tailscale.authkey tskey-auth-xxxxx \
--headless
--force
```

The image is cached at `/var/cache/xmorph` (override with
Expand All @@ -142,7 +147,7 @@ Tailscale's hosted control plane, point `--tailscale.server` at it:
sudo xmorph pivot --image alpine \
--tailscale.authkey <key-from-headscale> \
--tailscale.server https://headscale.example.com \
--headless
--force
```

This appends `--login-server=https://headscale.example.com` to the
Expand All @@ -154,14 +159,14 @@ If you have a routable IP (static, or DHCP with known address) or you're
on a serial console:

```sh
# Plain dropbear SSH on port 22 with your key
# Plain SSH on port 22 with your key
sudo xmorph pivot --image alpine \
--ssh.port 22 \
--ssh.keyfile ~/.ssh/id_ed25519.pub \
--headless
--ssh.authorized-keys "$(cat ~/.ssh/id_ed25519.pub)" \
--force

# Already on a serial console — just stay attached, no --headless
sudo xmorph pivot --image alpine
# Already on a serial console — just stay attached and watch it run
sudo xmorph pivot --image alpine --force
```

The `--ssh.enable` path stands up a small pure-Go OpenSSH server inside
Expand All @@ -176,18 +181,43 @@ up across the pivot, but if the broken OS had odd routing or firewall
rules, those die with it. The firewall is flushed by default during
pivot; pass `--keep-firewall` to keep it.

## Headless flag details
## Surviving your SSH session

Rescuing from an existing SSH session works without any special flag. The
risk it guards against is your login session — or a `run0`/`systemd-run`
wrapper — being a systemd unit that gets torn down (with
`KillMode=control-group`) the moment it ends, which would SIGKILL xmorph
mid-pivot.

On a **systemd** host, xmorph handles this automatically: before it starts
building the rootfs it asks systemd to adopt the running process into a
transient scope (`xmorph-<pid>.scope`) — the same move `systemd-run
--scope` makes, but for the already-running process. That scope is
systemd-owned and independent of your session, so the pivot survives the
session ending. No fork, no re-exec; systemd just reassigns the cgroup.

xmorph also notices when it's running over SSH:

- If it detached into a scope, it logs a reminder that the session will
drop during the pivot and you should reconnect via Tailscale/SSH in the
new rootfs.
- If it could **not** detach (a non-systemd host, or the relocation
failed), it warns that a disconnect may kill the pivot and pauses for a
short grace window — `Ctrl-C` to abort. On those hosts, run it under a
detachment mechanism of your own (`systemd-run`, `setsid`, `nohup`, `tmux`).

Make sure you have a way back in before you pivot: set `--tailscale.authkey`
or `--ssh.port`, or you'll lock yourself out. `--log-dir` (default
`/var/log`) writes `xmorph.log` on the old root and flushes it into the new
rootfs, so there's a breadcrumb trail either way.

`--headless` is what makes rescue-from-an-existing-SSH-session work. It:
### Running as a managed unit

- Forks and `setsid()`s so the parent (your SSH session) can return
- Closes stdin/stdout/stderr — your shell prompt comes back immediately
- Logs to `/var/log/xmorph.log` in the new rootfs
- Prints the new Tailscale hostname (and PID) before forking so you know
where to reconnect
- Implies `--force` (no interactive confirmation prompt)
- Requires at least one of `--tailscale.authkey` or `--ssh.port` — without
a way to reach the rescued box, you'd lock yourself out
If you drive the pivot from a systemd unit yourself — a `rescue.target`
unit, or `systemd-run --unit xmorph-pivot -- xmorph pivot …` — pass
`--systemd-mode` instead. It skips the self-relocation and the init
coordination (systemd already stopped services on the way into the target)
and implies `--force`. See [systemd integration](systemd.md).

## Auto-reset on hang

Expand All @@ -199,7 +229,7 @@ back on the original OS.
```sh
sudo xmorph pivot --image alpine --rootfs ./overlay/ \
--entrypoint /install.sh \
--watchdog-timeout 5m --headless \
--watchdog-timeout 5m --force \
--tailscale.authkey tskey-auth-xxxxx
```

Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ go 1.26.4

require (
github.com/creack/pty v1.1.24
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466
github.com/google/go-containerregistry v0.21.7
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
golang.org/x/crypto v0.52.0
Expand All @@ -25,7 +27,6 @@ require (
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gaissmai/bart v0.26.1 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
Expand Down
4 changes: 0 additions & 4 deletions internal/cli/dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,6 @@ func printDryRun(w io.Writer, cfg *config.Config) {
if cfg.LogPersistPath != "" {
fmt.Fprintf(w, "Persistent logs: %s\n", filepath.Join("/", cfg.LogPersistDevice, cfg.LogPersistPath))
}
if cfg.Headless {
fmt.Fprintf(w, "Mode: headless (will fork and detach, log to /var/log/xmorph.log)\n")
}

fmt.Fprintf(w, "\nSteps that would be performed:\n")
step := 1

Expand Down
2 changes: 0 additions & 2 deletions internal/cli/dryrun_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ func TestDryRunOutputShape(t *testing.T) {
{Kind: config.LayerImage, Ref: "alpine"},
{Kind: config.LayerRootfs, Path: "/tmp/extra"},
}
cfg.Headless = true
cfg.TailscaleAuthkey = "tskey-auth-abcdefghijklmnopqrstuvwxyz"
cfg.TailscaleArgs = "--ssh --hostname=test-xmorph"
cfg.NoInitCoord = true // skip detector path so the test is hermetic
Expand All @@ -37,7 +36,6 @@ func TestDryRunOutputShape(t *testing.T) {
"Keep old root: /mnt/oldroot",
"Contain: false",
"Timeout: 30s",
"Mode: headless (will fork and detach, log to /var/log/xmorph.log)",
"Steps that would be performed:",
" 1. Build rootfs from image alpine",
" 2. Merge rootfs /tmp/extra",
Expand Down
64 changes: 53 additions & 11 deletions internal/cli/pivot.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"time"

"github.com/ananthb/xmorph/internal/config"
"github.com/ananthb/xmorph/internal/daemon"
"github.com/ananthb/xmorph/internal/helpers"
"github.com/ananthb/xmorph/internal/initsys"
"github.com/ananthb/xmorph/internal/oci"
Expand Down Expand Up @@ -55,7 +54,8 @@ arguments that would otherwise be interpreted as flags.`,
//
// 1. --dry-run → print plan, return.
// 2. --contain → build rootfs, run inside mount+PID namespace.
// 3. --headless → fork via daemon.Daemonize (parent prints PID, exits).
// 3. Detach: on systemd, relocate into a transient scope so the launching
// session/service can't reap us; warn if over SSH and undetached.
// 4. Build rootfs.
// 5. Resolve entrypoint from cfg + merged ImageConfig.
// 6. Write /etc/xmorph-init.json + copy binary to new rootfs.
Expand Down Expand Up @@ -83,19 +83,56 @@ func runPivot(ctx context.Context, cfg *config.Config, stdout interface {
return errors.New("pivot requires root (CAP_SYS_ADMIN for namespace + pivot_root)")
}

// --force or --headless skips confirmation. Plan plus the existing
// implied-flag rule (`--headless` ⇒ `--force`) means we only need
// to ask if neither is set.
// --force skips the interactive confirmation.
if !cfg.Force {
fmt.Fprintln(os.Stderr, "Refusing to pivot without --force or --headless")
return errors.New("confirmation required (--force or --headless)")
fmt.Fprintln(os.Stderr, "Refusing to pivot without --force")
return errors.New("confirmation required (--force)")
}

if cfg.Headless {
if err := daemon.Daemonize(cfg.LogDir); err != nil {
return fmt.Errorf("daemonize: %w", err)
slog.Info("pivot starting", "layers", len(cfg.Layers), "work_dir", cfg.WorkDir,
"systemd_mode", cfg.SystemdMode, "watchdog", cfg.WatchdogTimeout)

// Attach the on-disk log sink now so everything below is captured there
// too; the same buffer is flushed into the new rootfs before pivot_root.
if cfg.LogDir != "" && LogHandler != nil {
logFile := filepath.Join(cfg.LogDir, "xmorph.log")
if err := os.MkdirAll(cfg.LogDir, 0o755); err != nil {
slog.Warn("log-dir mkdir failed; file logging disabled", "dir", cfg.LogDir, "err", err)
} else if f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); err != nil {
slog.Warn("log-dir open failed; file logging disabled", "path", logFile, "err", err)
} else {
LogHandler.AddSink(f)
slog.Info("file logging enabled", "path", logFile)
}
}

// Detach from the launching session so the pivot outlives it. On
// systemd we ask systemd to adopt us into a transient scope — like
// `systemd-run --scope`, but for the running process — which moves us
// out of a run0/systemd-run .service or SSH session scope that would
// otherwise SIGKILL us on teardown. No fork, no exec. Skipped under
// --systemd-mode: there we're already a managed unit.
detached := false
if !cfg.SystemdMode && initsys.Detect() == initsys.Systemd {
if scope, err := initsys.RelocateToTransientScope("xmorph pivot into a new in-memory rootfs"); err != nil {
slog.Warn("could not relocate into a transient systemd scope; a mid-pivot disconnect may kill xmorph", "err", err)
} else {
detached = true
slog.Info("relocated into transient systemd scope", "unit", scope)
}
}

// Over SSH the pivot severs this connection. If we couldn't detach
// (non-systemd host, or the relocation failed), a disconnect during the
// pivot can take xmorph with it — warn and give a grace window to abort.
if initsys.RunningOverSSH() {
if detached {
slog.Warn("over SSH: this session will drop during the pivot, but xmorph is detached and will continue — reconnect via your entrypoint's access (e.g. Tailscale/SSH in the new rootfs)")
} else {
const grace = 10 * time.Second
slog.Warn("over SSH and NOT detached from this session — if you disconnect during the pivot xmorph may be killed; prefer --systemd-mode under `systemd-run`. Continuing shortly; press Ctrl-C to abort", "grace", grace)
time.Sleep(grace)
}
// Past here we're the daemonized child.
}

if err := os.MkdirAll(cfg.WorkDir, 0o755); err != nil {
Expand All @@ -110,6 +147,7 @@ func runPivot(ctx context.Context, cfg *config.Config, stdout interface {
slog.Info("rootfs built", "layers", result.LayerCount)

entrypoint, entryArgs, _ := resolveEntrypoint(cfg, result.Config)
slog.Info("entrypoint resolved", "entrypoint", entrypoint, "args", len(entryArgs))

// Write the postpivot config (read back by `xmorph --init`) and copy
// the running binary into the new rootfs.
Expand All @@ -120,6 +158,7 @@ func runPivot(ctx context.Context, cfg *config.Config, stdout interface {
if err := postpivot.CopyBinary(cfg.WorkDir); err != nil {
return fmt.Errorf("copy binary: %w", err)
}
slog.Info("staged post-pivot config and binary", "work_dir", cfg.WorkDir)

// Pre-pivot tailscale auth: validate the authkey against the live
// network NOW, while the old OS is still working. On success the
Expand Down Expand Up @@ -225,9 +264,11 @@ func runPivot(ctx context.Context, cfg *config.Config, stdout interface {
}
}

slog.Info("pivoting root", "new_root", cfg.WorkDir, "old_root", "/"+oldRootRel)
if err := pivot.PivotRoot(cfg.WorkDir, oldRootRel); err != nil {
return fmt.Errorf("pivot_root: %w", err)
}
slog.Info("pivot_root complete; old root at /" + oldRootRel)

if cfg.KeepOldRoot == "" {
slog.Info("unmounting old root")
Expand All @@ -239,6 +280,7 @@ func runPivot(ctx context.Context, cfg *config.Config, stdout interface {
}

// Exec the post-pivot supervisor: /usr/local/bin/xmorph --init <argv>.
slog.Info("exec post-pivot supervisor", "binary", postpivot.BinaryPath, "entrypoint", entrypoint)
supervisorArgv := append([]string{postpivot.BinaryPath, "--init", entrypoint}, entryArgs...)
if err := unix.Exec(postpivot.BinaryPath, supervisorArgv, os.Environ()); err != nil {
return fmt.Errorf("exec post-pivot supervisor: %w", err)
Expand Down
29 changes: 28 additions & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"io"
"log/slog"
"log/syslog"
"os"

xlog "github.com/ananthb/xmorph/internal/log"
Expand All @@ -28,6 +29,32 @@ var LogHandler *xlog.Handler
func InstallLogger(level slog.Level, colors bool) {
LogHandler = xlog.NewHandler(os.Stderr, level, colors)
slog.SetDefault(slog.New(LogHandler))
attachSystemLogSink(LogHandler)
}

// attachSystemLogSink wires xmorph's log into the host's system log. When
// journald is running it already captures our stderr (systemd's default
// StandardError=journal), so we rely on that and add nothing. Otherwise we
// connect to syslog (/dev/log) as an additional sink. Best-effort: a failure
// to reach syslog just means no system-log copy.
func attachSystemLogSink(h *xlog.Handler) {
if journaldPresent() {
return
}
if w, err := syslog.New(syslog.LOG_INFO|syslog.LOG_DAEMON, "xmorph"); err == nil {
h.AddSink(w)
}
}

// journaldPresent reports whether systemd-journald is running, in which case
// our stderr is captured by the journal and no explicit sink is needed.
func journaldPresent() bool {
for _, p := range []string{"/run/systemd/journal/socket", "/run/systemd/journal/stdout"} {
if _, err := os.Stat(p); err == nil {
return true
}
}
return false
}

// NewRootCmd builds the top-level `xmorph` command with `pivot`, `build`,
Expand All @@ -40,7 +67,7 @@ func NewRootCmd(stdout, stderr io.Writer) *cobra.Command {
Long: `xmorph replaces a running Linux rootfs with a new in-memory rootfs
built from OCI images and/or rootfs tarballs, by way of pivot_root(2).
It coordinates with systemd/openrc/sysvinit to stop services first and
supports headless operation over Tailscale (in-process via tsnet).`,
supports unattended operation over Tailscale (in-process via tsnet).`,
Version: helpers.Version,
SilenceUsage: true,
SilenceErrors: false,
Expand Down
8 changes: 6 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,15 @@ type Config struct {
DryRun bool
SkipVerify bool
NoCache bool
Headless bool

CacheDir string
WorkDir string
LogDir string

// LogDir is an additional on-disk log sink: xmorph mirrors its slog
// output to {LogDir}/xmorph.log (alongside stderr/journald and syslog)
// and flushes the in-memory buffer there just before pivot_root, so the
// pre-pivot log survives into the new rootfs. Empty disables the file sink.
LogDir string

Output string // build subcommand: OCI layout output dir; empty = cache only
RootfsOutput string // build subcommand: optional rootfs tarball output
Expand Down
6 changes: 1 addition & 5 deletions internal/config/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,8 @@ func bindPivotOnly(fs *pflag.FlagSet, cfg *Config) {
fs.BoolVarP(&cfg.DryRun, "dry-run", "n", false, "show what would be done without executing")
fs.BoolVar(&cfg.SkipVerify, "skip-verify", false, "skip rootfs verification")
fs.StringVar(&cfg.CacheDir, "cache-dir", DefaultCacheDir, "cache directory for OCI layers")
fs.StringVar(&cfg.LogDir, "log-dir", DefaultLogDir, "log directory (headless mode)")
fs.StringVar(&cfg.LogDir, "log-dir", DefaultLogDir, "additional log-file directory; xmorph.log is written here and flushed into the new rootfs before pivot (empty: disabled)")
fs.BoolVar(&cfg.SystemdMode, "systemd-mode", false, "running as systemd unit (implies --no-init-coord and --force)")
fs.BoolVar(&cfg.Headless, "headless", false, "detach from terminal (implies --force)")
fs.BoolVar(&cfg.KeepFirewall, "keep-firewall", false, "keep existing firewall rules (default: flush before starting services)")
fs.DurationVar(&cfg.WatchdogTimeout, "watchdog-timeout", 0, "reset the box if the pivoted entrypoint hangs longer than this (0: disabled; uses /dev/watchdog when available, otherwise a userspace timer)")
fs.StringVar(&cfg.LogPersistDevice, "log-persist-device", "", "pre-pivot mount path holding the log directory (empty: old root)")
Expand Down Expand Up @@ -137,9 +136,6 @@ func (c *Config) Normalize(fs *pflag.FlagSet, positional []string, envLookup fun
c.NoInitCoord = true
c.Force = true
}
if c.Headless {
c.Force = true
}

// --ssh.{password,authorized-keys,enable} imply port 22 unless port
// was set explicitly. Mirrors src/config.zig:273-283.
Expand Down
6 changes: 0 additions & 6 deletions internal/config/flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,6 @@ func TestParsePivotImpliedFlags(t *testing.T) {
t.Errorf("--systemd-mode: SystemdMode=%v NoInitCoord=%v Force=%v",
cfg.SystemdMode, cfg.NoInitCoord, cfg.Force)
}

// --headless implies --force (src/config.zig:264-266).
cfg = parsePivot(t, []string{"--headless"}, nil)
if !cfg.Headless || !cfg.Force {
t.Errorf("--headless: Headless=%v Force=%v", cfg.Headless, cfg.Force)
}
}

func TestParsePivotSSHEnable(t *testing.T) {
Expand Down
Loading
Loading