diff --git a/docs/rescue.md b/docs/rescue.md index add1413..fb148db 100644 --- a/docs/rescue.md +++ b/docs/rescue.md @@ -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 `-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 @@ -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 @@ -142,7 +147,7 @@ Tailscale's hosted control plane, point `--tailscale.server` at it: sudo xmorph pivot --image alpine \ --tailscale.authkey \ --tailscale.server https://headscale.example.com \ - --headless + --force ``` This appends `--login-server=https://headscale.example.com` to the @@ -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 @@ -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-.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 @@ -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 ``` diff --git a/go.mod b/go.mod index b321ad7..9e96abf 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/internal/cli/dryrun.go b/internal/cli/dryrun.go index abffb1f..1a71544 100644 --- a/internal/cli/dryrun.go +++ b/internal/cli/dryrun.go @@ -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 diff --git a/internal/cli/dryrun_test.go b/internal/cli/dryrun_test.go index 534f43a..4dec2c7 100644 --- a/internal/cli/dryrun_test.go +++ b/internal/cli/dryrun_test.go @@ -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 @@ -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", diff --git a/internal/cli/pivot.go b/internal/cli/pivot.go index e445416..ae5bb0b 100644 --- a/internal/cli/pivot.go +++ b/internal/cli/pivot.go @@ -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" @@ -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. @@ -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 { @@ -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. @@ -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 @@ -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") @@ -239,6 +280,7 @@ func runPivot(ctx context.Context, cfg *config.Config, stdout interface { } // Exec the post-pivot supervisor: /usr/local/bin/xmorph --init . + 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) diff --git a/internal/cli/root.go b/internal/cli/root.go index 043791a..0b769df 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "log/slog" + "log/syslog" "os" xlog "github.com/ananthb/xmorph/internal/log" @@ -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`, @@ -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, diff --git a/internal/config/config.go b/internal/config/config.go index d02d70c..e169837 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/config/flags.go b/internal/config/flags.go index e7da390..8ff11f8 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -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)") @@ -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. diff --git a/internal/config/flags_test.go b/internal/config/flags_test.go index cf731b5..f401830 100644 --- a/internal/config/flags_test.go +++ b/internal/config/flags_test.go @@ -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) { diff --git a/internal/config/validate.go b/internal/config/validate.go index 211a306..b918524 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -40,9 +40,5 @@ func (c *Config) Validate(warnW io.Writer) error { fmt.Fprintln(warnW, "Warning: --tailscale.image is deprecated and ignored — xmorph runs Tailscale via tsnet in-process") } - if c.Headless && !c.TailscaleEnabled() && c.SSHPort == nil { - fmt.Fprintln(warnW, "Warning: --headless without remote access — ensure your entrypoint provides access") - } - return nil } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go deleted file mode 100644 index 771cef6..0000000 --- a/internal/daemon/daemon.go +++ /dev/null @@ -1,74 +0,0 @@ -// Package daemon implements headless detach for `xmorph pivot --headless`. -// The parent process forks (via raw clone with SIGCHLD because Go's -// syscall.ForkExec always execs and we want to keep running the Go -// program in the child), prints the child PID + log path, and exits. -// The child becomes session leader and redirects stdio. -package daemon - -import ( - "fmt" - "os" - "path/filepath" - "syscall" - - "golang.org/x/sys/unix" -) - -// LogFileName is the basename written under cfg.LogDir for headless mode. -// Path becomes filepath.Join(logDir, LogFileName). -const LogFileName = "xmorph.log" - -// Daemonize forks once via raw clone(SIGCHLD,…). The parent prints the -// child PID + log path and exits. The child: -// -// 1. setsid() so SIGHUP from the dying sshd session doesn't kill it. -// 2. opens the log file and redirects stderr → it. -// 3. opens /dev/null and redirects stdin + stdout → it. -// -// Risk #2 in the port plan: Go's syscall.ForkExec always execs, so we -// use unix.Syscall(SYS_CLONE, SIGCHLD, 0, 0) directly — mirrors -// src/daemon.zig:28-31. This runs BEFORE any goroutine fan-out so the -// Go runtime's locks are not in flight. -func Daemonize(logDir string) error { - if err := os.MkdirAll(logDir, 0o755); err != nil { - return fmt.Errorf("create log dir: %w", err) - } - logPath := filepath.Join(logDir, LogFileName) - logFD, err := unix.Open(logPath, unix.O_WRONLY|unix.O_CREAT|unix.O_TRUNC, 0o644) - if err != nil { - return fmt.Errorf("open log: %w", err) - } - - pid, _, errno := unix.Syscall(unix.SYS_CLONE, uintptr(syscall.SIGCHLD), 0, 0) - if errno != 0 { - _ = unix.Close(logFD) - return fmt.Errorf("clone: %v", errno) - } - - if pid > 0 { - // Parent: print status and exit. - fmt.Printf("xmorph: daemonized (pid=%d, log=%s)\n", pid, logPath) - _ = unix.Close(logFD) - os.Exit(0) - } - - // Child path. - if _, err := unix.Setsid(); err != nil { - // Continue even if Setsid fails — best-effort detach. - _ = err - } - - nullFD, err := unix.Open("/dev/null", unix.O_RDWR, 0) - if err == nil { - _ = unix.Dup2(nullFD, 0) // stdin -> /dev/null - _ = unix.Dup2(nullFD, 1) // stdout -> /dev/null - if nullFD > 2 { - _ = unix.Close(nullFD) - } - } - _ = unix.Dup2(logFD, 2) // stderr -> logfile - if logFD > 2 { - _ = unix.Close(logFD) - } - return nil -} diff --git a/internal/initsys/coordinator.go b/internal/initsys/coordinator.go index 3596da8..c038993 100644 --- a/internal/initsys/coordinator.go +++ b/internal/initsys/coordinator.go @@ -5,9 +5,14 @@ import ( "errors" "fmt" "os" - "os/exec" - "strings" "time" + + "github.com/godbus/dbus/v5" +) + +const ( + systemdDest = "org.freedesktop.systemd1" + systemdPath = dbus.ObjectPath("/org/freedesktop/systemd1") ) // Coordinator gracefully transitions the running init system to a @@ -29,16 +34,30 @@ func NewCoordinator(timeout time.Duration) Coordinator { // shouldn't abort the pivot (the process terminator catches what // the init system missed). func (c Coordinator) TransitionToRescue() error { - switch c.Kind { - case Systemd: - // basic.target keeps journal + dbus up but stops all user - // services. rescue.target would additionally start sulogin - // on the console. - return c.run("systemctl", "isolate", "basic.target") - case OpenRC: - return c.run("openrc", "single") - case SysVinit: - return c.run("telinit", "1") + if c.Kind == Systemd { + // basic.target keeps journald + dbus up but stops user services; + // "isolate" stops everything the target doesn't require. Done over + // D-Bus rather than shelling out to systemctl. + return systemdStartUnit("basic.target", "isolate") + } + // OpenRC/SysVinit/runit/… expose no native control channel we can talk + // to without shelling out, and the transition is advisory anyway: the + // native process terminator (process.Terminate) stops what's running. + return nil +} + +// systemdStartUnit invokes Manager.StartUnit(name, mode) on the system bus. +func systemdStartUnit(name, mode string) error { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return fmt.Errorf("connect system bus: %w", err) + } + defer conn.Close() + var job dbus.ObjectPath + if err := conn.Object(systemdDest, systemdPath).Call( + systemdDest+".Manager.StartUnit", 0, name, mode, + ).Store(&job); err != nil { + return fmt.Errorf("StartUnit %s (%s): %w", name, mode, err) } return nil } @@ -52,9 +71,16 @@ func (c Coordinator) WaitForServicesToStop() error { time.Sleep(min(c.Timeout, 2*time.Second)) return nil } + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil // can't measure; trust the timeout + } + defer conn.Close() + mgr := conn.Object(systemdDest, systemdPath) + deadline := time.Now().Add(c.Timeout) for time.Now().Before(deadline) { - n, err := c.systemdPendingJobs() + n, err := systemdJobCount(mgr) if err != nil { return nil // can't measure; trust the timeout } @@ -66,28 +92,23 @@ func (c Coordinator) WaitForServicesToStop() error { return errors.New("timeout waiting for services to stop") } -func (c Coordinator) systemdPendingJobs() (int, error) { - out, err := exec.Command("systemctl", "list-jobs", "--no-legend").Output() - if err != nil { - return 0, err - } - n := 0 - for _, line := range strings.Split(string(out), "\n") { - if strings.TrimSpace(line) != "" { - n++ - } - } - return n, nil +// systemdJob mirrors one entry of Manager.ListJobs (D-Bus a(usssoo)). +type systemdJob struct { + ID uint32 + Unit string + Type string + State string + JobPath dbus.ObjectPath + UnitPath dbus.ObjectPath } -func (c Coordinator) run(argv ...string) error { - cmd := exec.Command(argv[0], argv[1:]...) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("%s: %w (%s)", strings.Join(argv, " "), err, strings.TrimSpace(stderr.String())) +// systemdJobCount returns the number of jobs systemd currently has queued. +func systemdJobCount(mgr dbus.BusObject) (int, error) { + var jobs []systemdJob + if err := mgr.Call(systemdDest+".Manager.ListJobs", 0).Store(&jobs); err != nil { + return 0, err } - return nil + return len(jobs), nil } // ShouldSkipCoordination is true when we're inside a container — the diff --git a/internal/initsys/scope.go b/internal/initsys/scope.go new file mode 100644 index 0000000..cf42859 --- /dev/null +++ b/internal/initsys/scope.go @@ -0,0 +1,76 @@ +package initsys + +import ( + "fmt" + "os" + + "github.com/godbus/dbus/v5" +) + +// systemdProperty is a (string, variant) pair, the element type of +// StartTransientUnit's properties argument (D-Bus signature a(sv)). +type systemdProperty struct { + Name string + Value dbus.Variant +} + +// systemdAux mirrors StartTransientUnit's aux argument (a(sa(sv))). We pass +// an empty slice — no auxiliary units. +type systemdAux struct { + Name string + Properties []systemdProperty +} + +// RelocateToTransientScope asks systemd, over the system bus, to adopt the +// calling process into a fresh transient .scope unit. This is exactly what +// `systemd-run --scope` does, applied to the already-running process: it +// moves us out of whatever cgroup launched us — a run0/systemd-run +// transient .service, or an interactive SSH session scope — into a +// systemd-owned scope that is NOT reaped when the launcher exits (a +// .service stops with KillMode=control-group and SIGKILLs its leftover +// cgroup; a session scope's teardown does the same on logout). +// +// No fork and no exec: systemd simply reassigns our cgroup. Returns the +// created scope's unit name. Only meaningful on systemd hosts reachable +// over D-Bus; callers gate on Detect() == Systemd. +func RelocateToTransientScope(description string) (string, error) { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return "", fmt.Errorf("connect system bus: %w", err) + } + defer conn.Close() + + // Scope names must be unique and end in ".scope"; tie it to our PID. + name := fmt.Sprintf("xmorph-%d.scope", os.Getpid()) + props := []systemdProperty{ + {Name: "Description", Value: dbus.MakeVariant(description)}, + {Name: "PIDs", Value: dbus.MakeVariant([]uint32{uint32(os.Getpid())})}, + // Survive `systemctl isolate`: the init coordinator isolates + // basic.target to stop services, which would otherwise stop our own + // scope and SIGKILL us mid-pivot. + {Name: "IgnoreOnIsolate", Value: dbus.MakeVariant(true)}, + // Garbage-collect the scope once it goes inactive/failed rather + // than leaving a dead unit behind (we never stop it cleanly — the + // process pivots away). + {Name: "CollectMode", Value: dbus.MakeVariant("inactive-or-failed")}, + } + + mgr := conn.Object("org.freedesktop.systemd1", dbus.ObjectPath("/org/freedesktop/systemd1")) + // mode "fail": don't clobber an existing unit of the same name. + var job dbus.ObjectPath + if err := mgr.Call( + "org.freedesktop.systemd1.Manager.StartTransientUnit", 0, + name, "fail", props, []systemdAux{}, + ).Store(&job); err != nil { + return "", fmt.Errorf("StartTransientUnit %s: %w", name, err) + } + return name, nil +} + +// RunningOverSSH reports whether the process was launched over an SSH +// session, per the variables sshd/tailscale-ssh export into the session. +func RunningOverSSH() bool { + return os.Getenv("SSH_CONNECTION") != "" || + os.Getenv("SSH_CLIENT") != "" || + os.Getenv("SSH_TTY") != "" +} diff --git a/internal/log/log.go b/internal/log/log.go index 92b013d..297709d 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -15,11 +15,13 @@ import ( "log/slog" "os" "path/filepath" + "strings" "sync" ) -// Handler is an slog.Handler that writes formatted lines to both stderr -// and an in-memory buffer. Safe for concurrent use. +// Handler is an slog.Handler that writes each record to stderr (colored), +// an in-memory buffer (for the pivot flush), and any additional sinks +// registered via AddSink (uncolored). Safe for concurrent use. type Handler struct { mu sync.Mutex stderr io.Writer @@ -27,6 +29,15 @@ type Handler struct { level slog.Level colors bool scope string + sinks *sinkList +} + +// sinkList holds the additional log sinks. It is shared by pointer across +// handler clones (WithAttrs/WithGroup) so a sink registered on the root +// handler is seen by every clone. +type sinkList struct { + mu sync.Mutex + w []io.Writer } // NewHandler returns a Handler. If stderr is nil, os.Stderr is used. @@ -40,7 +51,20 @@ func NewHandler(stderr io.Writer, level slog.Level, colors bool) *Handler { buf: &bytes.Buffer{}, level: level, colors: colors, + sinks: &sinkList{}, + } +} + +// AddSink registers an additional writer that receives every uncolored log +// line, alongside stderr and the in-memory buffer. Shared across clones. +// Safe for concurrent use; a nil writer is ignored. +func (h *Handler) AddSink(w io.Writer) { + if w == nil { + return } + h.sinks.mu.Lock() + h.sinks.w = append(h.sinks.w, w) + h.sinks.mu.Unlock() } func (h *Handler) Enabled(_ context.Context, lvl slog.Level) bool { @@ -112,18 +136,32 @@ func (h *Handler) Handle(_ context.Context, r slog.Record) error { }) fmt.Fprintln(h.stderr) - // Buffer line — never colored, same content otherwise. - fmt.Fprintf(h.buf, "[%s] ", tag) + // Uncolored line — identical content — to the in-memory buffer and + // every additional sink (file, syslog, …). + line := h.plainLine(tag, r) + h.buf.WriteString(line) + h.sinks.mu.Lock() + for _, w := range h.sinks.w { + _, _ = io.WriteString(w, line) + } + h.sinks.mu.Unlock() + return nil +} + +// plainLine renders the uncolored "[TAG] [scope] message k=v …\n" form. +func (h *Handler) plainLine(tag string, r slog.Record) string { + var b strings.Builder + fmt.Fprintf(&b, "[%s] ", tag) if h.scope != "" { - fmt.Fprintf(h.buf, "[%s] ", h.scope) + fmt.Fprintf(&b, "[%s] ", h.scope) } - fmt.Fprint(h.buf, r.Message) + b.WriteString(r.Message) r.Attrs(func(a slog.Attr) bool { - fmt.Fprintf(h.buf, " %s=%v", a.Key, a.Value.Any()) + fmt.Fprintf(&b, " %s=%v", a.Key, a.Value.Any()) return true }) - fmt.Fprintln(h.buf) - return nil + b.WriteByte('\n') + return b.String() } func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { @@ -153,6 +191,7 @@ func (h *Handler) clone() *Handler { level: h.level, colors: h.colors, scope: h.scope, + sinks: h.sinks, } } diff --git a/internal/pivot/contain.go b/internal/pivot/contain.go index 20337d1..3ef190a 100644 --- a/internal/pivot/contain.go +++ b/internal/pivot/contain.go @@ -21,10 +21,10 @@ type ContainOptions struct { Env []string } -// Contain re-execs Argv[0] inside an unshared mount+PID namespace, -// using exec.Command with SysProcAttr.Cloneflags. Inside the child, -// /dev /run /proc /sys get fresh mounts via SetupEssentials and the -// shell is chrooted into NewRoot. No pivot_root. +// Contain runs Entrypoint inside an unshared mount+PID namespace, using +// exec.Command with SysProcAttr.Cloneflags. Inside the child, /dev /run +// /proc /sys get fresh mounts via SetupEssentials and the entrypoint is +// chrooted into NewRoot. No pivot_root. // // This is the M3 implementation; src/cmd/pivot.zig:338-373 calls // runz.run.runContainer which does the equivalent. Here we use the @@ -39,9 +39,9 @@ func Contain(opts ContainOptions) error { argv := append([]string{opts.Entrypoint}, opts.Args...) - // We run the entrypoint INSIDE the new namespace via a shell-style - // exec: spawn a /bin/sh -c that chroots and execs. Simpler than - // re-exec'ing xmorph itself with another sentinel. + // Run the entrypoint directly inside the new namespace — no /bin/sh + // wrapper and no xmorph re-exec. SysProcAttr below sets the clone + // flags (new mount+PID namespace) and chroots the child into NewRoot. cmd := exec.Command(argv[0], argv[1:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout diff --git a/internal/postpivot/firewall.go b/internal/postpivot/firewall.go deleted file mode 100644 index 81a968b..0000000 --- a/internal/postpivot/firewall.go +++ /dev/null @@ -1,24 +0,0 @@ -package postpivot - -import ( - "os/exec" -) - -// FlushFirewall clears all packet-filter rules across the standard -// commands (iptables, ip6tables, nft). Missing binaries are tolerated -// — the goal is "the rootfs's view is empty," not "every backend was -// scrubbed." Mirrors src/xenomorph-init.zig:104-121. -func FlushFirewall() { - cmds := [][]string{ - {"iptables", "-F"}, - {"iptables", "-X"}, - {"iptables", "-t", "nat", "-F"}, - {"iptables", "-t", "mangle", "-F"}, - {"ip6tables", "-F"}, - {"ip6tables", "-X"}, - {"nft", "flush", "ruleset"}, - } - for _, c := range cmds { - _ = exec.Command(c[0], c[1:]...).Run() - } -} diff --git a/internal/postpivot/firewall_linux.go b/internal/postpivot/firewall_linux.go new file mode 100644 index 0000000..bc16446 --- /dev/null +++ b/internal/postpivot/firewall_linux.go @@ -0,0 +1,21 @@ +//go:build linux + +package postpivot + +import "github.com/google/nftables" + +// FlushFirewall clears the host's nftables ruleset so the pivoted rootfs +// starts from an empty packet filter. Done natively over netlink — no +// shelling out to iptables/nft. On modern systems iptables is an nftables +// front-end (iptables-nft), so flushing the nft ruleset also clears those +// rules; a legacy xt_tables setup (rare now) is not touched. Best-effort: +// any error is ignored — the goal is "the rootfs's view is empty," and +// there may simply be no ruleset or no netfilter support. +func FlushFirewall() { + c, err := nftables.New() + if err != nil { + return + } + c.FlushRuleset() + _ = c.Flush() +} diff --git a/internal/postpivot/firewall_other.go b/internal/postpivot/firewall_other.go new file mode 100644 index 0000000..cfc004b --- /dev/null +++ b/internal/postpivot/firewall_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +package postpivot + +// FlushFirewall is a no-op off Linux. xmorph only pivots on Linux; this +// stub keeps the package building for host-side tests on other platforms. +func FlushFirewall() {} diff --git a/internal/postpivot/supervise.go b/internal/postpivot/supervise.go index f29d890..cae012d 100644 --- a/internal/postpivot/supervise.go +++ b/internal/postpivot/supervise.go @@ -7,10 +7,25 @@ import ( "os" "os/exec" "os/signal" + "sync" "syscall" "time" ) +// lockedWriter serializes concurrent writes to w. os/exec copies the +// child's stdout and stderr on separate goroutines; when both tee into the +// same LogWriter their writes must not race or interleave. +type lockedWriter struct { + mu sync.Mutex + w io.Writer +} + +func (l *lockedWriter) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.w.Write(p) +} + // SuperviseOptions configures Supervise. type SuperviseOptions struct { // Argv is the entrypoint with its arguments. argv[0] is exec'd. @@ -44,8 +59,11 @@ func Supervise(opts SuperviseOptions) (exitCode int, err error) { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if opts.LogWriter != nil { - cmd.Stdout = io.MultiWriter(os.Stdout, opts.LogWriter) - cmd.Stderr = io.MultiWriter(os.Stderr, opts.LogWriter) + // Share one locked writer between the two tees so the stdout- and + // stderr-copy goroutines don't race on LogWriter. + lw := &lockedWriter{w: opts.LogWriter} + cmd.Stdout = io.MultiWriter(os.Stdout, lw) + cmd.Stderr = io.MultiWriter(os.Stderr, lw) } if opts.Env != nil { cmd.Env = opts.Env diff --git a/internal/postpivot/watchdog_linux.go b/internal/postpivot/watchdog_linux.go index e418d46..276e968 100644 --- a/internal/postpivot/watchdog_linux.go +++ b/internal/postpivot/watchdog_linux.go @@ -5,9 +5,11 @@ package postpivot import ( "errors" "fmt" + "io/fs" "log/slog" "os" - "os/exec" + "path/filepath" + "strings" "sync" "time" @@ -38,7 +40,56 @@ var watchdogDev = "/dev/watchdog" // modprobeCmd auto-loads softdog when /dev/watchdog is absent. var // so tests can swap it for a fake. var modprobeCmd = func() error { - return exec.Command("modprobe", "softdog").Run() + return loadKernelModule("softdog") +} + +// moduleInitCompressedFile is finit_module(2)'s MODULE_INIT_COMPRESSED_FILE +// flag (kernel >= 5.17): let the kernel decompress a .ko.{zst,xz,gz}. +const moduleInitCompressedFile = 0x4 + +// loadKernelModule loads a kernel module by name via finit_module(2) — +// natively, no shelling out to modprobe. It finds name.ko(.zst|.xz|.gz) +// under /lib/modules// and hands the open file to the kernel. +// softdog has no dependencies, so a single finit_module suffices. +func loadKernelModule(name string) error { + var u unix.Utsname + if err := unix.Uname(&u); err != nil { + return fmt.Errorf("uname: %w", err) + } + base := filepath.Join("/lib/modules", unix.ByteSliceToString(u.Release[:])) + + var koPath string + _ = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if n := d.Name(); n == name+".ko" || strings.HasPrefix(n, name+".ko.") { + koPath = p + return fs.SkipAll + } + return nil + }) + if koPath == "" { + return fmt.Errorf("kernel module %q not found under %s", name, base) + } + + f, err := os.Open(koPath) + if err != nil { + return fmt.Errorf("open %s: %w", koPath, err) + } + defer f.Close() + + var flags int + if filepath.Ext(koPath) != ".ko" { // compressed .ko.zst/.xz/.gz + flags = moduleInitCompressedFile + } + if err := unix.FinitModule(int(f.Fd()), "", flags); err != nil { + if errors.Is(err, unix.EEXIST) { + return nil // already loaded + } + return fmt.Errorf("finit_module %s: %w", koPath, err) + } + return nil } // EnsureWatchdogAvailable opens /dev/watchdog (loading softdog if