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
37 changes: 35 additions & 2 deletions docs/rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<this machine>
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
Expand Down
9 changes: 8 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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"
'';
};

Expand Down
8 changes: 7 additions & 1 deletion internal/cli/dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
124 changes: 105 additions & 19 deletions internal/cli/entrypoint_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
}
64 changes: 44 additions & 20 deletions internal/cli/pivot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 '<pubkey>' let a key in\n"+
" --ssh.password '<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 '<pubkey>' let a key in\n" +
" --ssh.password '<password>' let a password in")
return 22
}

// resolveEntrypoint picks the effective entrypoint + args + env from the
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions internal/config/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}

Expand Down
Loading
Loading