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
2 changes: 1 addition & 1 deletion internal/cli/dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func printDryRun(w io.Writer, cfg *config.Config) {
step++
fmt.Fprintf(w, " %d. Execute pivot_root\n", step)
step++
fmt.Fprintf(w, " %d. Execute %s\n", step, cfg.Entrypoint)
fmt.Fprintf(w, " %d. Execute %s (reboots into the on-disk OS when it exits)\n", step, cfg.Entrypoint)

fmt.Fprintf(w, "\n=== END DRY RUN ===\n")
}
Expand Down
38 changes: 38 additions & 0 deletions internal/cli/idle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package cli

import (
"github.com/ananthb/xmorph/internal/postpivot"
"github.com/spf13/cobra"
)

// newIdleCmd exposes "do nothing, stay alive" as a real program rather than a
// pivot mode.
//
// A remote rescue pivot has no program to run: the point is to reach the box
// over SSH while its disk is free. Something still has to occupy the
// entrypoint, because when the entrypoint exits there is no init left and
// xmorph reboots into the on-disk OS.
//
// `sleep infinity` fills that role on a normal system but needs coreutils, and
// a bare shell exits immediately once detached. xmorph copies its own binary
// into every pivoted rootfs, so this subcommand is always available no matter
// how minimal the image — alpine, busybox, distroless alike:
//
// xmorph pivot --entrypoint /usr/local/bin/xmorph --cmd idle --ssh.enable
func newIdleCmd() *cobra.Command {
return &cobra.Command{
Use: "idle",
Short: "Block until signalled, keeping a pivoted system up and reachable",
Long: `idle runs nothing and waits for SIGTERM or SIGINT, reaping orphaned
children while it waits.

It exists to be a pivot's entrypoint when the pivot's purpose is access rather
than execution. xmorph places its own binary at ` + postpivot.BinaryPath + ` in
the new rootfs, so this works in images that ship no shell and no coreutils.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
postpivot.ServeUntilSignal()
return nil
},
}
}
49 changes: 48 additions & 1 deletion internal/cli/pivot.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/ananthb/xmorph/internal/tsnetauth"
"github.com/spf13/cobra"
"golang.org/x/sys/unix"
"golang.org/x/term"
)

// errNotImplemented is retained for the not-yet-wired tsnet path (M6).
Expand Down Expand Up @@ -163,6 +164,10 @@ func runPivot(ctx context.Context, cfg *config.Config, stdout interface {
entrypoint, entryArgs, _ := resolveEntrypoint(cfg, result.Config)
slog.Info("entrypoint resolved", "entrypoint", entrypoint, "args", len(entryArgs))

if err := checkEntrypointSurvivesDetach(cfg, entrypoint); err != nil {
return err
}

// Write the postpivot config (read back by `xmorph --init`) and copy
// the running binary into the new rootfs.
pivotConfig := buildPostpivotConfig(cfg, entrypoint, entryArgs)
Expand All @@ -172,6 +177,11 @@ 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)
}
// Do this while the old root is still mounted — /etc/resolv.conf has to be
// read from it, and after the pivot the resolver it names is gone anyway.
if err := postpivot.EnsureResolvConf(cfg.WorkDir); err != nil {
return fmt.Errorf("prepare resolv.conf: %w", err)
}
slog.Info("staged post-pivot config and binary", "work_dir", cfg.WorkDir)

// Pre-pivot tailscale auth: validate the authkey against the live
Expand Down Expand Up @@ -339,7 +349,7 @@ func runPivot(ctx context.Context, cfg *config.Config, stdout interface {
func buildPostpivotConfig(cfg *config.Config, entrypoint string, entryArgs []string) *postpivot.Config {
pc := &postpivot.Config{
FlushFirewall: !cfg.KeepFirewall,
RebootOnFailure: true,
RebootOnExit: true,
WatchdogTimeoutSeconds: int(cfg.WatchdogTimeout / time.Second),
KeepOldRoot: cfg.KeepOldRoot,
Entrypoint: append([]string{entrypoint}, entryArgs...),
Expand Down Expand Up @@ -415,6 +425,43 @@ func ensureLogDirWritable(dir string) error {
return os.Remove(name)
}

// shellEntrypoints are the interactive shells an image is likely to name as
// its default. Run detached they exit immediately; run from a console they
// are perfectly reasonable.
var shellEntrypoints = map[string]bool{
"sh": true, "bash": true, "ash": true, "dash": true, "zsh": true, "busybox": true,
}

// checkEntrypointSurvivesDetach refuses, before anything destructive happens,
// to pivot into a bare shell that cannot survive being detached.
//
// A shell with no controlling terminal reads EOF on stdin and exits at once.
// RebootOnExit makes that safe — the box returns to the OS on disk — but safe
// is not useful: the operator wanted a machine they could reach, and instead
// gets a pivot-reboot loop with the cause buried in a log on a filesystem
// that just went away. Both the diagnosis and the fix are known here, while
// the old root is still intact and aborting still costs nothing.
//
// Only the no-TTY case is rejected. With a console attached (--contain, or a
// serial line) a shell is exactly what someone may want, so stdin decides.
func checkEntrypointSurvivesDetach(cfg *config.Config, entrypoint string) error {
if cfg.Contain {
return nil
}
if !shellEntrypoints[filepath.Base(entrypoint)] {
return nil
}
if term.IsTerminal(int(os.Stdin.Fd())) {
return nil
}
return fmt.Errorf(
"entrypoint %q 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.\n"+
" --entrypoint %s --cmd idle stay up and reachable, running nothing\n"+
" --command ... run a specific program instead",
entrypoint, postpivot.BinaryPath)
}

// resolveEntrypoint picks the effective entrypoint + args + env from the
// CLI config and the merged ImageConfig. Mirrors src/cmd/pivot.zig:194-236.
func resolveEntrypoint(cfg *config.Config, ic *oci.ImageConfig) (entrypoint string, args, env []string) {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ supports unattended operation over Tailscale (in-process via tsnet).`,
// InitDefaultVersionFlag sees an existing flag and leaves it alone.
root.Flags().BoolP("version", "V", false, "print version and exit")

root.AddCommand(newPivotCmd(), newBuildCmd(), newVersionCmd())
root.AddCommand(newPivotCmd(), newBuildCmd(), newIdleCmd(), newVersionCmd())
return root
}

Expand Down
7 changes: 5 additions & 2 deletions internal/postpivot/configwrite.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ const BinaryPath = "/usr/local/bin/xmorph"
// Config is the JSON schema written to ConfigPath. Mirrors the schema
// at the top of src/xenomorph-init.zig.
type Config struct {
FlushFirewall bool `json:"flush_firewall"`
RebootOnFailure bool `json:"reboot_on_failure"`
FlushFirewall bool `json:"flush_firewall"`
// RebootOnExit reboots into the on-disk OS when the entrypoint exits,
// whatever its status. See SuperviseOptions.RebootOnExit for why a
// clean exit is treated as fatal too.
RebootOnExit bool `json:"reboot_on_exit"`
// WatchdogTimeoutSeconds; 0 disables.
WatchdogTimeoutSeconds int `json:"watchdog_timeout_seconds,omitempty"`
// KeepOldRoot is the pre-pivot root's mount point (default
Expand Down
4 changes: 2 additions & 2 deletions internal/postpivot/configwrite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ func TestWriteConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
cfg := &Config{
FlushFirewall: true,
RebootOnFailure: true,
RebootOnExit: true,
WatchdogTimeoutSeconds: 300,
KeepOldRoot: "/mnt/oldroot",
LogPersistDir: "/mnt/oldroot/var/log/xmorph",
Expand Down Expand Up @@ -39,7 +39,7 @@ func TestWriteConfigRoundTrip(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !got.FlushFirewall || !got.RebootOnFailure {
if !got.FlushFirewall || !got.RebootOnExit {
t.Error("boolean fields lost")
}
if got.WatchdogTimeoutSeconds != 300 {
Expand Down
115 changes: 115 additions & 0 deletions internal/postpivot/resolvconf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package postpivot

import (
"bufio"
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
"strings"
)

// FallbackNameservers are used when the host has no usable resolv.conf.
// Two providers rather than two addresses from one, so a single provider
// outage doesn't leave the pivoted system unable to resolve anything.
var FallbackNameservers = []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"}

// EnsureResolvConf gives the new rootfs a working /etc/resolv.conf before
// the pivot.
//
// Two things make this necessary. Minimal OCI images (alpine, busybox,
// distroless) ship no /etc/resolv.conf at all — DNS in a container comes
// from the runtime, and there is no runtime here. And the host's own file
// usually cannot be copied verbatim: on systemd-resolved distros it reads
// "nameserver 127.0.0.53", a stub served by a daemon that gets terminated
// during pivot preparation. Copying it produces a rootfs that looks
// configured and resolves nothing.
//
// So: keep a usable file if the image has one, otherwise inherit the host's
// real nameservers, otherwise fall back to public resolvers. Tailscale needs
// working DNS to reach the coordination server, so getting this wrong
// strands the box exactly when remote access is the only access left.
func EnsureResolvConf(rootfsRoot string) error {
dst := filepath.Join(rootfsRoot, "etc", "resolv.conf")

if servers := readNameservers(dst); len(servers) > 0 && !allLoopback(servers) {
slog.Debug("rootfs already has a usable resolv.conf", "servers", servers)
return nil
}

source := "host"
servers := readNameservers("/etc/resolv.conf")
if usable := filterRoutable(servers); len(usable) > 0 {
servers = usable
} else {
// Either the host had nothing, or everything it listed was a local
// stub that dies with the old root.
slog.Warn("host resolv.conf unusable post-pivot; using fallback resolvers",
"host_servers", servers, "fallback", FallbackNameservers)
servers = FallbackNameservers
source = "fallback"
}

if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("mkdir for resolv.conf: %w", err)
}
var b strings.Builder
b.WriteString("# written by xmorph: the pre-pivot resolver does not survive pivot_root\n")
for _, s := range servers {
fmt.Fprintf(&b, "nameserver %s\n", s)
}
// A pre-existing symlink (e.g. ../run/systemd/resolve/stub-resolv.conf)
// would otherwise be followed and write into a directory that will not
// exist after the pivot.
_ = os.Remove(dst)
if err := os.WriteFile(dst, []byte(b.String()), 0o644); err != nil {
return fmt.Errorf("write resolv.conf: %w", err)
}
slog.Info("wrote resolv.conf into new rootfs", "source", source, "servers", servers)
return nil
}

// readNameservers parses the nameserver lines out of a resolv.conf.
// A missing or unreadable file yields nil rather than an error: every
// caller treats "no servers" and "could not read" the same way.
func readNameservers(path string) []string {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()

var out []string
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
fields := strings.Fields(line)
if len(fields) >= 2 && fields[0] == "nameserver" {
if ip := net.ParseIP(fields[1]); ip != nil {
out = append(out, ip.String())
}
}
}
return out
}

// filterRoutable drops loopback addresses. They are the signature of a local
// caching resolver — systemd-resolved on 127.0.0.53, dnsmasq on 127.0.0.1 —
// which is torn down with the old root and cannot answer afterwards.
func filterRoutable(servers []string) []string {
var out []string
for _, s := range servers {
if ip := net.ParseIP(s); ip != nil && !ip.IsLoopback() {
out = append(out, s)
}
}
return out
}

func allLoopback(servers []string) bool {
return len(filterRoutable(servers)) == 0
}
Loading
Loading