diff --git a/docs/rescue.md b/docs/rescue.md index fb148db..115d6c8 100644 --- a/docs/rescue.md +++ b/docs/rescue.md @@ -173,8 +173,41 @@ The `--ssh.enable` path stands up a small pure-Go OpenSSH server inside the pivoted rootfs on the given port (default 22) with an ephemeral ed25519 host key. Auth is public-key (from `--ssh.authorized-keys`, standard `authorized_keys` format, one per line) and/or password -(`--ssh.password`); at least one must be configured. Sessions run -`/bin/sh` as root — the rescue rootfs has no user database. +(`--ssh.password`). Sessions run `/bin/sh` as root — the rescue rootfs +has no user database. + +Give it neither and xmorph generates a root password: three random words +from the EFF short wordlist, joined by hyphens, printed as a banner both +before the pivot and again after it. + +``` + ============================================================ + SSH is enabled and no credentials were given, so xmorph + generated a root password for it: + + swan-pesky-tofu + + Log in with: ssh -p 22 root@ + Write it down. Nothing keeps a copy you can read later. + ============================================================ +``` + +It is printed twice on purpose. The first copy goes to the terminal you +ran `xmorph pivot` from, which is usually the SSH session the pivot is +about to kill; the second goes to the console after the pivot, where a +serial line will still have it. + +| | | +|---|---| +| Strength | ~31 bits — three words from a 1295-word list | +| Good for | the minutes-to-hours a rescue lasts, against online guessing | +| Not good for | a box left facing the open internet; use `--ssh.authorized-keys` | +| Where it ends up | the console, and `xmorph.log` under `--log-persist-path` | + +A password you pass yourself is never printed and never logged — it may +be one you use elsewhere, so xmorph treats it as yours. Only the +generated one, which is worthless anywhere else and useless if you +cannot read it, goes to the console. It assumes the network already works: your machine's interface stays up across the pivot, but if the broken OS had odd routing or firewall diff --git a/flake.nix b/flake.nix index 4f4187e..93504be 100644 --- a/flake.nix +++ b/flake.nix @@ -116,11 +116,18 @@ '' else '' # Native macOS test for pure-Go packages, plus compile check # of every package targeted at Linux. - go test ./internal/config/... ./internal/log/... ./internal/helpers/... + go test ./internal/config/... ./internal/log/... ./internal/helpers/... \ + ./internal/passphrase/... tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT GOOS=linux CGO_ENABLED=0 go build -o "$tmp/xmorph" ./cmd/xmorph echo "linux cross-compile OK ($tmp/xmorph)" + # Compile the Linux-only test binaries too. Without this a broken + # _test.go in, say, internal/cli builds clean on a Mac and only + # fails in CI, which is a slow way to find a typo. + GOOS=linux CGO_ENABLED=0 go test -run=NONE -count=1 -exec=true ./... \ + >/dev/null + echo "linux test compile OK" ''; }; diff --git a/internal/cli/dryrun.go b/internal/cli/dryrun.go index 214634b..7b8a107 100644 --- a/internal/cli/dryrun.go +++ b/internal/cli/dryrun.go @@ -89,8 +89,14 @@ func printDryRun(w io.Writer, cfg *config.Config) { if cfg.SSHAuthorizedKeys != "" { fmt.Fprintf(w, " - Auth: public key (%d configured)\n", strings.Count(strings.TrimSpace(cfg.SSHAuthorizedKeys), "\n")+1) } - if cfg.SSHPassword != "" { + switch { + case cfg.SSHPassword != "": fmt.Fprintf(w, " - Auth: password\n") + case cfg.SSHAuthorizedKeys == "": + // Nothing was supplied, so the real run would generate one. + // Don't generate it here: a dry run must not print a credential + // that will never be the credential. + fmt.Fprintf(w, " - Auth: password (generated at pivot time, printed to the console)\n") } } diff --git a/internal/cli/entrypoint_test.go b/internal/cli/entrypoint_test.go index 5765961..eed4e08 100644 --- a/internal/cli/entrypoint_test.go +++ b/internal/cli/entrypoint_test.go @@ -1,10 +1,13 @@ package cli import ( + "bytes" "strings" "testing" "github.com/ananthb/xmorph/internal/config" + "github.com/ananthb/xmorph/internal/passphrase" + "github.com/ananthb/xmorph/internal/postpivot" ) // The last line of defence before anything destructive happens. A bare shell @@ -63,43 +66,126 @@ func TestEntrypointRefusalNamesTheFix(t *testing.T) { // --ssh.enable with no credentials produced a machine that pivoted, stayed up, // and could not be logged into: sshd logged "no auth method configured" to a // console nobody was reading and never bound the port. The VM test found it. -func TestCheckSSHUsable(t *testing.T) { +// Now the missing credential is invented rather than demanded. +func TestEnsureSSHUsable(t *testing.T) { enabled := true + disabled := false for _, tc := range []struct { - name string - cfg config.Config - wantErr bool + name string + cfg config.Config + wantGenerated bool + wantPassword string // "" means "whatever was generated" }{ {name: "ssh off", cfg: config.Config{}}, + {name: "ssh explicitly off", cfg: config.Config{SSHEnable: &disabled}}, { - name: "enabled with nothing to authenticate with", - cfg: config.Config{SSHEnable: &enabled}, - wantErr: true, + name: "enabled with nothing to authenticate with", + cfg: config.Config{SSHEnable: &enabled}, + wantGenerated: true, }, { - name: "password", - cfg: config.Config{SSHEnable: &enabled, SSHPassword: "hunter2"}, + name: "operator password is left alone", + cfg: config.Config{SSHEnable: &enabled, SSHPassword: "hunter2"}, + wantPassword: "hunter2", }, { - name: "authorized keys", + // Keys are enough on their own; generating a password here would + // weaken a setup that deliberately has no password to guess. + name: "authorized keys, no password invented", cfg: config.Config{SSHEnable: &enabled, SSHAuthorizedKeys: "ssh-ed25519 AAAA"}, }, // SSHEnabled() is implied by any other ssh.* flag, so this is on too. - {name: "implied by password alone", cfg: config.Config{SSHPassword: "hunter2"}}, + { + name: "implied by password alone", + cfg: config.Config{SSHPassword: "hunter2"}, + wantPassword: "hunter2", + }, } { t.Run(tc.name, func(t *testing.T) { - err := checkSSHUsable(&tc.cfg) - if tc.wantErr != (err != nil) { - t.Fatalf("checkSSHUsable() = %v, wantErr %v", err, tc.wantErr) + got, err := ensureSSHUsable(&tc.cfg) + if err != nil { + t.Fatalf("ensureSSHUsable() error: %v", err) } - if err == nil { - return + if tc.wantGenerated != (got != "") { + t.Fatalf("ensureSSHUsable() = %q, wantGenerated %v", got, tc.wantGenerated) } - for _, want := range []string{"--ssh.authorized-keys", "--ssh.password"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("refusal does not mention %q: %v", want, err) + if tc.cfg.SSHPasswordGenerated != tc.wantGenerated { + t.Errorf("SSHPasswordGenerated = %v, want %v", + tc.cfg.SSHPasswordGenerated, tc.wantGenerated) + } + if !tc.wantGenerated { + if tc.cfg.SSHPassword != tc.wantPassword { + t.Errorf("SSHPassword = %q, want %q", tc.cfg.SSHPassword, tc.wantPassword) } + return + } + // The generated password has to reach the post-pivot config, + // which reads it from cfg and not from the return value. + if tc.cfg.SSHPassword != got { + t.Errorf("cfg.SSHPassword = %q, generated %q", tc.cfg.SSHPassword, got) + } + if n := strings.Count(got, "-") + 1; n != passphrase.DefaultWords { + t.Errorf("generated %q, want %d hyphenated words", got, passphrase.DefaultWords) } }) } } + +// The whole point of generating a password is that someone can read it. A +// banner that prints everything except the password would pass every test +// above. +func TestAnnounceSSHPasswordShowsThePassword(t *testing.T) { + var buf bytes.Buffer + postpivot.AnnounceSSHPassword(&buf, "swan-pesky-tofu", 2222) + out := buf.String() + if !strings.Contains(out, "swan-pesky-tofu") { + t.Errorf("banner omits the password: %s", out) + } + if !strings.Contains(out, "2222") { + t.Errorf("banner omits the port an operator has to connect to: %s", out) + } +} + +// An operator-supplied password must never be echoed to a console or into the +// persistent log; only one we generated is ours to print. +func TestOperatorPasswordIsNotMarkedGenerated(t *testing.T) { + enabled := true + cfg := config.Config{SSHEnable: &enabled, SSHPassword: "reused-elsewhere"} + if _, err := ensureSSHUsable(&cfg); err != nil { + t.Fatalf("ensureSSHUsable() error: %v", err) + } + pc := buildPostpivotConfig(&cfg, "/bin/true", nil) + if pc.SSH == nil { + t.Fatal("post-pivot config has no SSH section") + } + if pc.SSH.PasswordGenerated { + t.Error("an operator-supplied password is flagged as generated; it would be printed") + } + if pc.SSH.Password != "reused-elsewhere" { + t.Errorf("SSH password = %q, want the one the operator gave", pc.SSH.Password) + } +} + +// ...and the generated one must be flagged, or the post-pivot console never +// shows it and the machine is unreachable again for a different reason. +func TestGeneratedPasswordReachesPostPivotConfig(t *testing.T) { + enabled := true + cfg := config.Config{SSHEnable: &enabled} + got, err := ensureSSHUsable(&cfg) + if err != nil { + t.Fatalf("ensureSSHUsable() error: %v", err) + } + pc := buildPostpivotConfig(&cfg, "/bin/true", nil) + if pc.SSH == nil { + t.Fatal("post-pivot config has no SSH section") + } + if !pc.SSH.PasswordGenerated { + t.Error("generated password is not flagged; it would never be printed post-pivot") + } + if pc.SSH.Password != got { + t.Errorf("SSH password = %q, generated %q", pc.SSH.Password, got) + } + if pc.SSH.Port != 22 { + t.Errorf("SSH port = %d, want the default 22", pc.SSH.Port) + } +} diff --git a/internal/cli/pivot.go b/internal/cli/pivot.go index aa7fbc3..01af0b6 100644 --- a/internal/cli/pivot.go +++ b/internal/cli/pivot.go @@ -16,6 +16,7 @@ import ( "github.com/ananthb/xmorph/internal/helpers" "github.com/ananthb/xmorph/internal/initsys" "github.com/ananthb/xmorph/internal/oci" + "github.com/ananthb/xmorph/internal/passphrase" "github.com/ananthb/xmorph/internal/pivot" "github.com/ananthb/xmorph/internal/postpivot" "github.com/ananthb/xmorph/internal/process" @@ -252,9 +253,13 @@ func runPivot(ctx context.Context, cfg *config.Config, stdout interface { if err := checkEntrypointSurvivesDetach(cfg, entrypoint); err != nil { return err } - if err := checkSSHUsable(cfg); err != nil { + generatedPassword, err := ensureSSHUsable(cfg) + if err != nil { return err } + if generatedPassword != "" { + postpivot.AnnounceSSHPassword(os.Stderr, generatedPassword, int(sshPort(cfg))) + } // Write the postpivot config (read back by `xmorph --init`) and copy // the running binary into the new rootfs. @@ -447,14 +452,11 @@ func buildPostpivotConfig(cfg *config.Config, entrypoint string, entryArgs []str } if cfg.SSHEnabled() { ssh := &postpivot.SSHConfig{ - Password: cfg.SSHPassword, - AuthorizedKeys: cfg.SSHAuthorizedKeys, - } - if cfg.SSHPort != nil { - ssh.Port = int(*cfg.SSHPort) - } else { - ssh.Port = 22 + Password: cfg.SSHPassword, + PasswordGenerated: cfg.SSHPasswordGenerated, + AuthorizedKeys: cfg.SSHAuthorizedKeys, } + ssh.Port = int(sshPort(cfg)) pc.SSH = ssh } if cfg.TailscaleEnabled() { @@ -549,28 +551,50 @@ func checkEntrypointSurvivesDetach(cfg *config.Config, entrypoint string) error entrypoint, postpivot.BinaryPath) } -// checkSSHUsable refuses a pivot that asks for SSH without any way to -// authenticate to it. +// ensureSSHUsable makes sure a pivot that asks for SSH can actually be +// logged into, generating a root password when the operator supplied no +// credentials at all. // // The post-pivot sshd needs a password or authorized keys; given neither it // logs an error and never listens. That log goes to a console nobody is // reading, on a machine whose whole reason for pivoting was to be reachable — -// so `--ssh.enable` on its own hands back a box that is up, healthy, holding -// itself open, and impossible to get into. Cheaper to say so here. +// so `--ssh.enable` on its own used to hand back a box that was up, healthy, +// holding itself open, and impossible to get into. +// +// Generating one is better than refusing, because refusing pushes the +// operator into inventing a password on a command line, under time pressure, +// on the box they are about to take apart. Three random words beat that every +// time. Returns the generated password so the caller can decide where to +// announce it; empty means the operator brought their own credentials. // // Found by nix/tests/lifecycle.nix, which is what those tests are for. -func checkSSHUsable(cfg *config.Config) error { +func ensureSSHUsable(cfg *config.Config) (generated string, err error) { if !cfg.SSHEnabled() { - return nil + return "", nil } if cfg.SSHPassword != "" || cfg.SSHAuthorizedKeys != "" { - return nil + return "", nil + } + pw, err := passphrase.New() + if err != nil { + // No randomness means no credential means an unreachable machine. + // Refuse here, where refusing is still free. + return "", fmt.Errorf( + "SSH is enabled with no credentials and no password could be generated: %w\n"+ + " --ssh.authorized-keys '' let a key in\n"+ + " --ssh.password '' let a password in", err) + } + cfg.SSHPassword = pw + cfg.SSHPasswordGenerated = true + return pw, nil +} + +// sshPort is the port the post-pivot sshd will bind, defaulting to 22. +func sshPort(cfg *config.Config) uint16 { + if cfg.SSHPort != nil { + return *cfg.SSHPort } - return errors.New( - "SSH is enabled but has no way to authenticate anyone: sshd will refuse " + - "to start and the pivoted machine will be unreachable.\n" + - " --ssh.authorized-keys '' let a key in\n" + - " --ssh.password '' let a password in") + return 22 } // resolveEntrypoint picks the effective entrypoint + args + env from the diff --git a/internal/config/config.go b/internal/config/config.go index 69009bf..3238013 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -129,6 +129,13 @@ type Config struct { SSHPort *uint16 SSHPassword string SSHAuthorizedKeys string + // SSHPasswordGenerated records that xmorph invented SSHPassword rather + // than the operator supplying it. It decides one thing: whether the + // password is safe to print on the post-pivot console. A password the + // operator chose is theirs and may be reused elsewhere, so it stays off + // the console; one we generated is worthless anywhere else and useless + // unless they can read it. + SSHPasswordGenerated bool // Tailscale: tri-state Enable (nil = auto from authkey set). TailscaleEnable *bool diff --git a/internal/config/flags.go b/internal/config/flags.go index 3efd5d6..8f51d77 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -87,10 +87,11 @@ func bindSSH(fs *pflag.FlagSet, cfg *Config) { fs.Var(&tristateBool{dst: &cfg.SSHEnable}, "ssh.enable", "enable SSH in the new rootfs (auto when other ssh.* set)") fs.Lookup("ssh.enable").NoOptDefVal = "true" fs.Var(&tristateUint16{dst: &cfg.SSHPort}, "ssh.port", "SSH listen port (default 22)") - // No default is generated: sshd needs one of these two or it will not - // start, and the pivot is refused up front rather than leaving an - // unreachable machine behind (see checkSSHUsable). - fs.StringVar(&cfg.SSHPassword, "ssh.password", "", "root password (required unless ssh.authorized-keys is set)") + // Empty means "generate one", not "no password" — sshd needs a password + // or a key or it will not start, and a pivot that silently leaves an + // unreachable machine behind is the failure this whole path exists to + // avoid. See ensureSSHUsable. + fs.StringVar(&cfg.SSHPassword, "ssh.password", "", "root password (default: three random words, printed before and after the pivot)") fs.StringVar(&cfg.SSHAuthorizedKeys, "ssh.authorized-keys", "", "authorized public keys (inline)") } diff --git a/internal/passphrase/passphrase.go b/internal/passphrase/passphrase.go new file mode 100644 index 0000000..7c2faf6 --- /dev/null +++ b/internal/passphrase/passphrase.go @@ -0,0 +1,100 @@ +// Package passphrase generates short, readable, hyphenated passphrases for +// credentials a human has to carry from one screen to another. +// +// The one caller today is `xmorph pivot --ssh.enable` with no credentials +// supplied. That password appears on a console and gets typed, by hand, +// against a machine that has just thrown away its userspace — so the words +// have to survive a phone camera, a serial terminal in an unfamiliar font, +// and someone reading them aloud. That rules out random characters, which is +// why this exists rather than a base64 of 16 random bytes. +// +// The wordlist is the EFF short wordlist #1 (CC BY 3.0 US, Electronic +// Frontier Foundation, 2016), chosen for exactly this property: every word is +// 3-5 letters, common, and distinguishable from the others by its first three +// characters. One entry, "yo-yo", is dropped here because a hyphen inside a +// word makes a hyphen-separated phrase ambiguous to read back. +package passphrase + +import ( + "crypto/rand" + _ "embed" + "errors" + "fmt" + "math" + "math/big" + "strings" +) + +//go:embed wordlist.txt +var wordlistRaw string + +// words is the wordlist, split once at init. Lines starting with # are the +// attribution header, not words. +var words = parseWordlist(wordlistRaw) + +func parseWordlist(raw string) []string { + var out []string + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out = append(out, line) + } + return out +} + +// DefaultWords is how many words New uses. +// +// Three words out of 1295 is a shade under 31 bits. That is not a key, and it +// is not meant to be one: it protects a root shell for the minutes-to-hours a +// rescue pivot lasts, against an attacker who has to guess online. It is also +// strictly better than the alternative it replaces, which was a human picking +// a password at the command line — and far better than the alternative before +// that, which was no SSH at all because sshd silently refused to start. +// +// Raise this if the pivoted machine will face the open internet for long. It +// is a one-line change and each extra word adds ~10.3 bits. +const DefaultWords = 3 + +// New returns a DefaultWords-long hyphenated passphrase. +func New() (string, error) { return Generate(DefaultWords) } + +// Generate returns an n-word hyphenated passphrase drawn uniformly from the +// wordlist with crypto/rand. Words may repeat: rejecting duplicates would +// remove entropy rather than add it, and at n=3 a repeat is a 1-in-432 curio, +// not a weakness. +func Generate(n int) (string, error) { + if n <= 0 { + return "", errors.New("passphrase: word count must be positive") + } + if len(words) == 0 { + return "", errors.New("passphrase: wordlist is empty") + } + picked := make([]string, n) + for i := range picked { + // crypto/rand.Int is the uniform-without-modulo-bias primitive. + // Reaching for math/big to pick one of 1295 things is overkill by + // weight but exactly right by correctness, and this runs three times + // in the life of a process. + idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(words)))) + if err != nil { + return "", fmt.Errorf("passphrase: read randomness: %w", err) + } + picked[i] = words[idx.Int64()] + } + return strings.Join(picked, "-"), nil +} + +// BitsOfEntropy reports the entropy of an n-word phrase from this wordlist, +// so callers can state the number rather than assert a vibe. +func BitsOfEntropy(n int) float64 { + if n <= 0 || len(words) == 0 { + return 0 + } + return float64(n) * math.Log2(float64(len(words))) +} + +// WordCount is the size of the wordlist. Exported for tests and for anyone +// checking the entropy claim above. +func WordCount() int { return len(words) } diff --git a/internal/passphrase/passphrase_test.go b/internal/passphrase/passphrase_test.go new file mode 100644 index 0000000..311a7f1 --- /dev/null +++ b/internal/passphrase/passphrase_test.go @@ -0,0 +1,116 @@ +package passphrase + +import ( + "math" + "regexp" + "strings" + "testing" +) + +// The wordlist is a data file, and data files rot silently. These invariants +// are the ones the format actually depends on: a hyphen or an uppercase +// letter inside a word makes a hyphenated phrase ambiguous to read back, and +// a duplicate quietly costs entropy the docs claim we have. +func TestWordlistInvariants(t *testing.T) { + if len(words) < 1000 { + t.Fatalf("wordlist has %d words; too small to be the EFF list", len(words)) + } + valid := regexp.MustCompile(`^[a-z]{3,6}$`) + seen := make(map[string]bool, len(words)) + for _, w := range words { + if !valid.MatchString(w) { + t.Errorf("word %q is not 3-6 lowercase letters", w) + } + if seen[w] { + t.Errorf("word %q appears twice", w) + } + seen[w] = true + } +} + +func TestNewShape(t *testing.T) { + got, err := New() + if err != nil { + t.Fatalf("New() error: %v", err) + } + parts := strings.Split(got, "-") + if len(parts) != DefaultWords { + t.Fatalf("New() = %q, want %d hyphen-separated words", got, DefaultWords) + } + for _, p := range parts { + if !inWordlist(p) { + t.Errorf("New() = %q contains %q, which is not in the wordlist", got, p) + } + } +} + +// A generator that returns the same thing every time still passes a shape +// test. This is the one that would catch it. +func TestGenerateVaries(t *testing.T) { + const draws = 200 + seen := make(map[string]bool, draws) + for range draws { + got, err := New() + if err != nil { + t.Fatalf("New() error: %v", err) + } + seen[got] = true + } + // With ~31 bits, 200 draws colliding even once is a 1-in-10-million + // event. Anything less than all-distinct means the source is broken. + if len(seen) != draws { + t.Errorf("%d draws produced only %d distinct passphrases", draws, len(seen)) + } +} + +func TestGenerateWordCount(t *testing.T) { + for _, n := range []int{1, 2, 3, 6} { + got, err := Generate(n) + if err != nil { + t.Fatalf("Generate(%d) error: %v", n, err) + } + if c := strings.Count(got, "-") + 1; c != n { + t.Errorf("Generate(%d) = %q, has %d words", n, got, c) + } + } +} + +func TestGenerateRejectsNonPositive(t *testing.T) { + for _, n := range []int{0, -1} { + if _, err := Generate(n); err == nil { + t.Errorf("Generate(%d) = nil error, want one", n) + } + } +} + +// The package doc quotes a number at people deciding whether this is strong +// enough for their situation. If the wordlist shrinks, the number has to move +// with it. +func TestBitsOfEntropy(t *testing.T) { + if got := BitsOfEntropy(0); got != 0 { + t.Errorf("BitsOfEntropy(0) = %v, want 0", got) + } + got := BitsOfEntropy(DefaultWords) + if got < 30 || got > 32 { + t.Errorf("BitsOfEntropy(%d) = %v; the ~31-bit claim in the docs no longer holds", + DefaultWords, got) + } + if want := 2 * BitsOfEntropy(1); math.Abs(BitsOfEntropy(2)-want) > 1e-9 { + t.Errorf("BitsOfEntropy(2) = %v, want %v", BitsOfEntropy(2), want) + } +} + +func TestWordCount(t *testing.T) { + if WordCount() != len(words) { + t.Errorf("WordCount() = %d, want %d", WordCount(), len(words)) + } +} + +func inWordlist(s string) bool { + for _, w := range words { + if w == s { + return true + } + } + return false +} diff --git a/internal/passphrase/wordlist.txt b/internal/passphrase/wordlist.txt new file mode 100644 index 0000000..a27a5d4 --- /dev/null +++ b/internal/passphrase/wordlist.txt @@ -0,0 +1,1299 @@ +# EFF short wordlist #1, Electronic Frontier Foundation, 2016. +# https://www.eff.org/dice — licensed CC BY 3.0 US. +# Modified: the entry "yo-yo" is removed, because a hyphen inside a word +# makes a hyphen-separated passphrase ambiguous to read back. +acid +acorn +acre +acts +afar +affix +aged +agent +agile +aging +agony +ahead +aide +aids +aim +ajar +alarm +alias +alibi +alien +alike +alive +aloe +aloft +aloha +alone +amend +amino +ample +amuse +angel +anger +angle +ankle +apple +april +apron +aqua +area +arena +argue +arise +armed +armor +army +aroma +array +arson +art +ashen +ashes +atlas +atom +attic +audio +avert +avoid +awake +award +awoke +axis +bacon +badge +bagel +baggy +baked +baker +balmy +banjo +barge +barn +bash +basil +bask +batch +bath +baton +bats +blade +blank +blast +blaze +bleak +blend +bless +blimp +blink +bloat +blob +blog +blot +blunt +blurt +blush +boast +boat +body +boil +bok +bolt +boned +boney +bonus +bony +book +booth +boots +boss +botch +both +boxer +breed +bribe +brick +bride +brim +bring +brink +brisk +broad +broil +broke +brook +broom +brush +buck +bud +buggy +bulge +bulk +bully +bunch +bunny +bunt +bush +bust +busy +buzz +cable +cache +cadet +cage +cake +calm +cameo +canal +candy +cane +canon +cape +card +cargo +carol +carry +carve +case +cash +cause +cedar +chain +chair +chant +chaos +charm +chase +cheek +cheer +chef +chess +chest +chew +chief +chili +chill +chip +chomp +chop +chow +chuck +chump +chunk +churn +chute +cider +cinch +city +civic +civil +clad +claim +clamp +clap +clash +clasp +class +claw +clay +clean +clear +cleat +cleft +clerk +click +cling +clink +clip +cloak +clock +clone +cloth +cloud +clump +coach +coast +coat +cod +coil +coke +cola +cold +colt +coma +come +comic +comma +cone +cope +copy +coral +cork +cost +cot +couch +cough +cover +cozy +craft +cramp +crane +crank +crate +crave +crawl +crazy +creme +crepe +crept +crib +cried +crisp +crook +crop +cross +crowd +crown +crumb +crush +crust +cub +cult +cupid +cure +curl +curry +curse +curve +curvy +cushy +cut +cycle +dab +dad +daily +dairy +daisy +dance +dandy +darn +dart +dash +data +date +dawn +deaf +deal +dean +debit +debt +debug +decaf +decal +decay +deck +decor +decoy +deed +delay +denim +dense +dent +depth +derby +desk +dial +diary +dice +dig +dill +dime +dimly +diner +dingy +disco +dish +disk +ditch +ditzy +dizzy +dock +dodge +doing +doll +dome +donor +donut +dose +dot +dove +down +dowry +doze +drab +drama +drank +draw +dress +dried +drift +drill +drive +drone +droop +drove +drown +drum +dry +duck +duct +dude +dug +duke +duo +dusk +dust +duty +dwarf +dwell +eagle +early +earth +easel +east +eaten +eats +ebay +ebony +ebook +echo +edge +eel +eject +elbow +elder +elf +elk +elm +elope +elude +elves +email +emit +empty +emu +enter +entry +envoy +equal +erase +error +erupt +essay +etch +evade +even +evict +evil +evoke +exact +exit +fable +faced +fact +fade +fall +false +fancy +fang +fax +feast +feed +femur +fence +fend +ferry +fetal +fetch +fever +fiber +fifth +fifty +film +filth +final +finch +fit +five +flag +flaky +flame +flap +flask +fled +flick +fling +flint +flip +flirt +float +flock +flop +floss +flyer +foam +foe +fog +foil +folic +folk +food +fool +found +fox +foyer +frail +frame +fray +fresh +fried +frill +frisk +from +front +frost +froth +frown +froze +fruit +gag +gains +gala +game +gap +gas +gave +gear +gecko +geek +gem +genre +gift +gig +gills +given +giver +glad +glass +glide +gloss +glove +glow +glue +goal +going +golf +gong +good +gooey +goofy +gore +gown +grab +grain +grant +grape +graph +grasp +grass +grave +gravy +gray +green +greet +grew +grid +grief +grill +grip +grit +groom +grope +growl +grub +grunt +guide +gulf +gulp +gummy +guru +gush +gut +guy +habit +half +halo +halt +happy +harm +hash +hasty +hatch +hate +haven +hazel +hazy +heap +heat +heave +hedge +hefty +help +herbs +hers +hub +hug +hula +hull +human +humid +hump +hung +hunk +hunt +hurry +hurt +hush +hut +ice +icing +icon +icy +igloo +image +ion +iron +islam +issue +item +ivory +ivy +jab +jam +jaws +jazz +jeep +jelly +jet +jiffy +job +jog +jolly +jolt +jot +joy +judge +juice +juicy +july +jumbo +jump +junky +juror +jury +keep +keg +kept +kick +kilt +king +kite +kitty +kiwi +knee +knelt +koala +kung +ladle +lady +lair +lake +lance +land +lapel +large +lash +lasso +last +latch +late +lazy +left +legal +lemon +lend +lens +lent +level +lever +lid +life +lift +lilac +lily +limb +limes +line +lint +lion +lip +list +lived +liver +lunar +lunch +lung +lurch +lure +lurk +lying +lyric +mace +maker +malt +mama +mango +manor +many +map +march +mardi +marry +mash +match +mate +math +moan +mocha +moist +mold +mom +moody +mop +morse +most +motor +motto +mount +mouse +mousy +mouth +move +movie +mower +mud +mug +mulch +mule +mull +mumbo +mummy +mural +muse +music +musky +mute +nacho +nag +nail +name +nanny +nap +navy +near +neat +neon +nerd +nest +net +next +niece +ninth +nutty +oak +oasis +oat +ocean +oil +old +olive +omen +onion +only +ooze +opal +open +opera +opt +otter +ouch +ounce +outer +oval +oven +owl +ozone +pace +pagan +pager +palm +panda +panic +pants +panty +paper +park +party +pasta +patch +path +patio +payer +pecan +penny +pep +perch +perky +perm +pest +petal +petri +petty +photo +plank +plant +plaza +plead +plot +plow +pluck +plug +plus +poach +pod +poem +poet +pogo +point +poise +poker +polar +polio +polka +polo +pond +pony +poppy +pork +poser +pouch +pound +pout +power +prank +press +print +prior +prism +prize +probe +prong +proof +props +prude +prune +pry +pug +pull +pulp +pulse +puma +punch +punk +pupil +puppy +purr +purse +push +putt +quack +quake +query +quiet +quill +quilt +quit +quota +quote +rabid +race +rack +radar +radio +raft +rage +raid +rail +rake +rally +ramp +ranch +range +rank +rant +rash +raven +reach +react +ream +rebel +recap +relax +relay +relic +remix +repay +repel +reply +rerun +reset +rhyme +rice +rich +ride +rigid +rigor +rinse +riot +ripen +rise +risk +ritzy +rival +river +roast +robe +robin +rock +rogue +roman +romp +rope +rover +royal +ruby +rug +ruin +rule +runny +rush +rust +rut +sadly +sage +said +saint +salad +salon +salsa +salt +same +sandy +santa +satin +sauna +saved +savor +sax +say +scale +scam +scan +scare +scarf +scary +scoff +scold +scoop +scoot +scope +score +scorn +scout +scowl +scrap +scrub +scuba +scuff +sect +sedan +self +send +sepia +serve +set +seven +shack +shade +shady +shaft +shaky +sham +shape +share +sharp +shed +sheep +sheet +shelf +shell +shine +shiny +ship +shirt +shock +shop +shore +shout +shove +shown +showy +shred +shrug +shun +shush +shut +shy +sift +silk +silly +silo +sip +siren +sixth +size +skate +skew +skid +skier +skies +skip +skirt +skit +sky +slab +slack +slain +slam +slang +slash +slate +slaw +sled +sleek +sleep +sleet +slept +slice +slick +slimy +sling +slip +slit +slob +slot +slug +slum +slurp +slush +small +smash +smell +smile +smirk +smog +snack +snap +snare +snarl +sneak +sneer +sniff +snore +snort +snout +snowy +snub +snuff +speak +speed +spend +spent +spew +spied +spill +spiny +spoil +spoke +spoof +spool +spoon +sport +spot +spout +spray +spree +spur +squad +squat +squid +stack +staff +stage +stain +stall +stamp +stand +stank +stark +start +stash +state +stays +steam +steep +stem +step +stew +stick +sting +stir +stock +stole +stomp +stony +stood +stool +stoop +stop +storm +stout +stove +straw +stray +strut +stuck +stud +stuff +stump +stung +stunt +suds +sugar +sulk +surf +sushi +swab +swan +swarm +sway +swear +sweat +sweep +swell +swept +swim +swing +swipe +swirl +swoop +swore +syrup +tacky +taco +tag +take +tall +talon +tamer +tank +taper +taps +tarot +tart +task +taste +tasty +taunt +thank +thaw +theft +theme +thigh +thing +think +thong +thorn +those +throb +thud +thumb +thump +thus +tiara +tidal +tidy +tiger +tile +tilt +tint +tiny +trace +track +trade +train +trait +trap +trash +tray +treat +tree +trek +trend +trial +tribe +trick +trio +trout +truce +truck +trump +trunk +try +tug +tulip +tummy +turf +tusk +tutor +tutu +tux +tweak +tweet +twice +twine +twins +twirl +twist +uncle +uncut +undo +unify +union +unit +untie +upon +upper +urban +used +user +usher +utter +value +vapor +vegan +venue +verse +vest +veto +vice +video +view +viral +virus +visa +visor +vixen +vocal +voice +void +volt +voter +vowel +wad +wafer +wager +wages +wagon +wake +walk +wand +wasp +watch +water +wavy +wheat +whiff +whole +whoop +wick +widen +widow +width +wife +wifi +wilt +wimp +wind +wing +wink +wipe +wired +wiry +wise +wish +wispy +wok +wolf +womb +wool +woozy +word +work +worry +wound +woven +wrath +wreck +wrist +xerox +yahoo +yam +yard +year +yeast +yelp +yield +yodel +yoga +yoyo +yummy +zebra +zero +zesty +zippy +zone +zoom diff --git a/internal/postpivot/announce.go b/internal/postpivot/announce.go new file mode 100644 index 0000000..a6e83be --- /dev/null +++ b/internal/postpivot/announce.go @@ -0,0 +1,38 @@ +package postpivot + +import ( + "fmt" + "io" +) + +// AnnounceSSHPassword prints a generated root password where a human will +// see it. `xmorph pivot` calls it before the pivot and Run calls it again +// after, deliberately: the terminal reading the first copy is usually the SSH +// session the pivot is about to tear down, and the console reading the second +// may be a serial line nobody was watching until things went wrong. +// +// Deliberately a banner rather than a slog line. This is the one piece of +// output in the whole run that somebody has to read off a screen and retype — +// quite possibly from a phone photo of a serial console — so it gets blank +// lines around it and none of the key=value noise every other line carries. +// +// Only ever called for a password xmorph generated. One the operator chose is +// theirs, may well be reused somewhere that matters, and must not be echoed +// onto a console and into the persistent log. +func AnnounceSSHPassword(w io.Writer, password string, port int) { + if port == 0 { + port = 22 + } + fmt.Fprintf(w, ` + ============================================================ + SSH is enabled and no credentials were given, so xmorph + generated a root password for it: + + %s + + Log in with: ssh -p %d root@ + Write it down. Nothing keeps a copy you can read later. + ============================================================ + +`, password, port) +} diff --git a/internal/postpivot/configwrite.go b/internal/postpivot/configwrite.go index 5d6d75d..28c4be0 100644 --- a/internal/postpivot/configwrite.go +++ b/internal/postpivot/configwrite.go @@ -45,9 +45,15 @@ type Config struct { // SSHConfig describes the in-rootfs SSH setup (dropbear for now). type SSHConfig struct { - Port int `json:"port"` - Password string `json:"password,omitempty"` - AuthorizedKeys string `json:"authorized_keys,omitempty"` + Port int `json:"port"` + Password string `json:"password,omitempty"` + // PasswordGenerated means xmorph invented Password because the operator + // enabled SSH without supplying any credentials. Run reprints it on the + // console after the pivot; see AnnounceSSHPassword for why that is worth + // doing and why an operator-supplied password never gets the same + // treatment. + PasswordGenerated bool `json:"password_generated,omitempty"` + AuthorizedKeys string `json:"authorized_keys,omitempty"` } // TSConfig is the legacy tailscale-via-image schema. Under tsnet we diff --git a/internal/postpivot/run.go b/internal/postpivot/run.go index 4d40d13..c366305 100644 --- a/internal/postpivot/run.go +++ b/internal/postpivot/run.go @@ -96,6 +96,13 @@ func Run(argv []string) int { } if cfg != nil && cfg.SSH != nil { + // Reprint before starting sshd, not after: the pre-pivot copy of this + // banner went to a terminal the pivot has since killed, and if sshd + // fails to bind its error lands directly underneath, which is exactly + // where an operator hunting for the password will be looking. + if cfg.SSH.PasswordGenerated && cfg.SSH.Password != "" { + AnnounceSSHPassword(os.Stderr, cfg.SSH.Password, cfg.SSH.Port) + } go func() { if err := StartSSHServer(context.Background(), cfg.SSH, tailnet); err != nil { slog.Error("sshd", "err", err) diff --git a/internal/postpivot/sshd_linux.go b/internal/postpivot/sshd_linux.go index 7c81992..8462e3b 100644 --- a/internal/postpivot/sshd_linux.go +++ b/internal/postpivot/sshd_linux.go @@ -293,14 +293,35 @@ func runSession(ch ssh.Channel, reqs <-chan *ssh.Request, cmd *exec.Cmd, term st go handleWinCh(reqs, f) _, _ = io.Copy(ch, f) } else { - cmd.Stdin = ch + // Deliberately not 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. + // The client closes it when its own stdin hits EOF, which for + // `ssh host cmd` from a terminal or a live pipe is never. So the + // command would exit, produce its output, and the session would hang + // anyway, until someone thought to add `< /dev/null`. On a rescue box + // that is a very bad time to be debugging a hung SSH session. + // + // StdinPipe has the behaviour we want: Wait closes the pipe once the + // process exits, which unblocks the copy below. + stdin, err := cmd.StdinPipe() + if err != nil { + fmt.Fprintf(ch.Stderr(), "exec: %v\n", err) + sendExitStatus(ch, 127) + return + } cmd.Stdout = ch cmd.Stderr = ch.Stderr() if err := cmd.Start(); err != nil { + stdin.Close() fmt.Fprintf(ch.Stderr(), "exec: %v\n", err) sendExitStatus(ch, 127) return } + go func() { + _, _ = io.Copy(stdin, ch) + _ = stdin.Close() + }() go handleWinCh(reqs, nil) } diff --git a/internal/postpivot/sshd_linux_test.go b/internal/postpivot/sshd_linux_test.go index f310a37..aa91ae5 100644 --- a/internal/postpivot/sshd_linux_test.go +++ b/internal/postpivot/sshd_linux_test.go @@ -8,6 +8,7 @@ import ( "crypto/ed25519" "crypto/rand" "errors" + "io" "net" "strings" "testing" @@ -201,3 +202,48 @@ func TestParseAuthorizedKeysBadLineReturnsError(t *testing.T) { t.Errorf("err = %v, want to mention line number", err) } } + +// `ssh host cmd` must return when the command returns, even though the +// client's stdin is still open. +// +// This is the shape every other test in this file misses. x/crypto/ssh's +// Session sends channel EOF immediately when Stdin is nil, so sess.Run("true") +// passes even against a server that deadlocks — and a real client only sends +// EOF when its own stdin does, which for a command run from a terminal or a +// live pipe is never. The server used to set cmd.Stdin = ch, which makes +// os/exec's Wait() block until that copy finishes, i.e. until the client +// closes the channel, i.e. until after the thing it is blocking. Found by the +// VM test in nix/tests/lifecycle.nix, which logs in over a real ssh(1). +func TestSSHExecReturnsWithStdinStillOpen(t *testing.T) { + addr := serveSSHTest(t, &SSHConfig{Password: "s3cret"}) + c := dialClient(t, addr, []ssh.AuthMethod{ssh.Password("s3cret")}) + sess, err := c.NewSession() + if err != nil { + t.Fatalf("session: %v", err) + } + defer sess.Close() + + // A stdin that never reaches EOF, the way a terminal behaves. Nothing is + // ever written to it; the point is only that it stays open. + pr, pw := io.Pipe() + defer pw.Close() + sess.Stdin = pr + + var out bytes.Buffer + sess.Stdout = &out + + done := make(chan error, 1) + go func() { done <- sess.Run("echo logged-in") }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("run: %v", err) + } + if got := strings.TrimSpace(out.String()); got != "logged-in" { + t.Errorf("stdout = %q, want %q", got, "logged-in") + } + case <-time.After(10 * time.Second): + t.Fatal("session never returned; the server is waiting on a stdin the client will not close") + } +} diff --git a/nix/tests/lifecycle.nix b/nix/tests/lifecycle.nix index ca999a0..8c33c00 100644 --- a/nix/tests/lifecycle.nix +++ b/nix/tests/lifecycle.nix @@ -18,8 +18,8 @@ # # * The guest is booted with `console=ttyS0 console=tty0`, and the last one # wins: /dev/console is the graphics console, which the driver never reads. -# wait_for_console_text watches the serial line. Output has to go to -# /dev/ttyS0 by name — writing to /dev/console is silence. +# The driver watches the serial line, so output has to go to /dev/ttyS0 by +# name — writing to /dev/console is silence. # # The same properties are asserted far more cheaply by the Go tests in # internal/postpivot/lifecycle_test.go, which is where a regression will @@ -54,6 +54,33 @@ let networking.firewall.enable = false; }; + # Console assertions do NOT go through wait_for_console_text. That method + # drains its queue with a single non-blocking get() per retry iteration, and + # retry sleeps a second between iterations — one line per second, against a + # NixOS boot that emits hundreds. Whether it matches in time is a race with + # the backlog: the same call took 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 has everything since boot and is not consumed by reading + # it, so polling it is both faster and deterministic. + consoleHelper = '' + import re + import time + + + def wait_console(machine, pattern, timeout=180): + """Wait for pattern to appear anywhere in the console log; return the match.""" + for _ in range(timeout): + match = re.search(pattern, machine.get_console_log()) + if match: + return match + time.sleep(1) + raise Exception( + f"{pattern!r} never appeared on the console:\\n" + + machine.get_console_log()[-4000:] + ) + ''; + # Launch a pivot the way an operator does: detached, no terminal, output on # the serial console because every other channel goes away with the old root. # @@ -79,15 +106,22 @@ in # after the machine that would normally answer questions has stopped # existing. Nothing in NixOS listens on 22 here, so the port is xmorph's # alone: open means userspace lived through the pivot. + # + # It also covers the credential half of "reachable". SSH is enabled with no + # password and no keys, which is the shape that silently produced a dead + # port, so the test reads the generated password off the console exactly as + # an operator would and logs in with it. idle-stays-up = pkgs.testers.nixosTest { name = "xmorph-lifecycle-idle-stays-up"; nodes = { inherit target; prober = { ... }: { - environment.systemPackages = [ pkgs.netcat-openbsd ]; + environment.systemPackages = [ pkgs.netcat-openbsd pkgs.openssh pkgs.sshpass ]; }; }; testScript = '' + ${consoleHelper} + start_all() target.wait_for_unit("multi-user.target") prober.wait_for_unit("multi-user.target") @@ -95,33 +129,71 @@ in # Nothing is listening yet — otherwise the assertion below proves nothing. prober.fail("nc -z -w 2 target 22") - # A password, because sshd will not start without one — nothing here - # authenticates, the assertion is only that the port answers. - target.execute( - "${pivotCmd "--entrypoint /usr/local/bin/xmorph --cmd idle --ssh.password=lifecycle-test"}" - ) + # Everything past this point runs against a machine whose backdoor is + # about to die, so it all lives in a try/finally. When the script ends + # the driver runs execute("sync") on every machine that is_up(), and + # execute() calls connect(), which waits on the backdoor shell in a loop + # with no way out. On the happy path that is merely wrong; on a failed + # assertion it swallows the failure, because the run sits there until + # the job timeout and gets scored as a hang instead of the one-line + # error that actually explains it. crash() goes through QMP and needs + # nothing from the guest, so it works in both cases — but only if it + # runs in both cases. + try: + # No credentials at all. --ssh.enable used to be the trap: sshd + # needs a password or a key, got neither, logged it to a console + # nobody was reading, and never bound the port. xmorph now + # generates one. + target.execute( + "${pivotCmd "--entrypoint /usr/local/bin/xmorph --cmd idle --ssh.enable"}" + ) + + # xmorph idle logs this once it is holding the box up. + wait_console(target, "serving; no entrypoint to supervise") - # xmorph idle logs this once it is holding the box up. - target.wait_for_console_text("serving; no entrypoint to supervise") - - # The real assertion, and the only one that distinguishes this from the - # incident: someone else can still open a connection to it. - prober.wait_until_succeeds("nc -z -w 2 target 22", timeout=120) - - # And it has to KEEP holding. A machine that pivots and then reboots - # moments later is the loop this design exists to avoid. - prober.succeed("sleep 20") - prober.succeed("nc -z -w 2 target 22") - - # Pull the plug, and do it here rather than leaving it to the driver. - # When the script ends the driver runs execute("sync") on every machine - # that is_up(), and execute() calls connect(), which waits on the - # backdoor shell in a loop with no way out. The backdoor died with the - # old root — that is the premise of this whole test — so the run would - # sit there until the global timeout and be scored as a failure with - # every assertion already passed. crash() goes through QMP and needs - # nothing from the guest. - target.crash() + # The real assertion, and the only one that distinguishes this from + # the incident: someone else can still open a connection to it. + prober.wait_until_succeeds("nc -z -w 2 target 22", timeout=120) + + # A generated password nobody can read is the same failure wearing a + # different hat, so take it the way an operator does — off the + # console. The banner is printed *before* the "serving" line above, + # so this has to search the whole log rather than wait forward. + password = wait_console( + target, + r"generated a root password for it:\s+([a-z]{3,6}-[a-z]{3,6}-[a-z]{3,6})", + timeout=60, + ).group(1) + + # And it has to actually let someone in. Force password auth so a + # misconfigured sshd cannot pass this by accepting a key, or by + # accepting nothing at all. + ssh = ( + "sshpass -p '{}' ssh -o StrictHostKeyChecking=no " + "-o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password " + "-o PubkeyAuthentication=no -o ConnectTimeout=10 -o NumberOfPasswordPrompts=1 " + "root@target {}" + ) + out = prober.wait_until_succeeds( + ssh.format(password, "'echo logged-in'"), timeout=90 + ) + assert "logged-in" in out, out + + # An sshd that accepts every password would have passed the line + # above. + prober.fail(ssh.format("not-the-password", "true"), timeout=60) + + # And it has to KEEP holding. A machine that pivots and then reboots + # moments later is the loop this design exists to avoid. + prober.succeed("sleep 20") + prober.succeed("nc -z -w 2 target 22") + finally: + # Best-effort: if the guest already died the point is moot, and an + # exception here would mask the real one. + try: + target.crash() + except Exception as e: # noqa: BLE001 + print(f"target.crash() failed, continuing: {e}") ''; }; @@ -136,6 +208,8 @@ in name = "xmorph-lifecycle-exit-reboots"; nodes.target = target; testScript = '' + ${consoleHelper} + target.start(allow_reboot=True) target.wait_for_unit("multi-user.target") first_boot = target.succeed("cat /proc/sys/kernel/random/boot_id").strip() @@ -145,19 +219,32 @@ in # which is what turned a stumble into a box needing physical access. target.execute("${pivotCmd "--entrypoint /bin/true"}") - target.wait_for_console_text("no userspace left, rebooting") + # If the reboot never happens, the machine sits pivoted with a dead + # backdoor, and the driver's end-of-script execute("sync") waits on it + # forever — turning a legible assertion failure into a job timeout. + # crash() goes through QMP and works without the guest. Only on the + # failure path: when the reboot does happen the backdoor comes back and + # the driver can shut down normally. + try: + wait_console(target, "no userspace left, rebooting") - # The backdoor went down with the old root; the reboot brings a new one. - target.connected = False - target.wait_for_unit("multi-user.target") + # The backdoor went down with the old root; the reboot brings a new one. + target.connected = False + target.wait_for_unit("multi-user.target") - second_boot = target.succeed("cat /proc/sys/kernel/random/boot_id").strip() - assert first_boot != second_boot, ( - f"boot_id unchanged ({first_boot}); the machine never actually rebooted" - ) + second_boot = target.succeed("cat /proc/sys/kernel/random/boot_id").strip() + assert first_boot != second_boot, ( + f"boot_id unchanged ({first_boot}); the machine never actually rebooted" + ) - # Back on the real OS, not still in the pivoted rootfs. - target.succeed("test -d /nix/store") + # Back on the real OS, not still in the pivoted rootfs. + target.succeed("test -d /nix/store") + except Exception: + try: + target.crash() + except Exception as e: # noqa: BLE001 + print(f"target.crash() failed, continuing: {e}") + raise ''; };