package cli import ( "context" "errors" "fmt" "io" "os/exec" "strings" "time" "banger/internal/model" "banger/internal/paths" "banger/internal/system" "banger/internal/vsockagent" ) // runSSHSession executes ssh with the given args. On exit it decides // whether to print the "vm is still running" reminder: we skip it if // the caller asked (e.g. --rm is about to delete the VM), if the // ctx is already done, or if the ssh error isn't the one that // typically means "user disconnected cleanly". func (d *deps) runSSHSession(ctx context.Context, socketPath, vmRef string, stdin io.Reader, stdout, stderr io.Writer, sshArgs []string, skipReminder bool) error { sshErr := d.sshExec(ctx, stdin, stdout, stderr, sshArgs) if skipReminder || !shouldCheckSSHReminder(sshErr) || ctx.Err() != nil { return sshErr } pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() health, err := d.vmHealth(pingCtx, socketPath, vmRef) if err != nil { _, _ = fmt.Fprintln(stderr, vsockagent.WarningMessage(vmRef, err)) return sshErr } if health.Healthy { name := health.Name if strings.TrimSpace(name) == "" { name = vmRef } _, _ = fmt.Fprintln(stderr, vsockagent.ReminderMessage(name)) } return sshErr } func shouldCheckSSHReminder(err error) bool { if err == nil { return true } var exitErr *exec.ExitError if !errors.As(err, &exitErr) { return false } return exitErr.ExitCode() != 255 } // sshCommandArgs builds the argv for `ssh` invocations against a VM. // Host-key verification uses a banger-owned known_hosts file // populated by the daemon's first successful Go-SSH dial to each VM // (trust-on-first-use). `accept-new` means: accept-and-pin on first // contact; strict-verify afterwards. The user's own // ~/.ssh/known_hosts is never touched. func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]string, error) { if guestIP == "" { return nil, errors.New("vm has no guest IP") } args := []string{} args = append(args, "-F", "/dev/null") if cfg.SSHKeyPath != "" { args = append(args, "-i", cfg.SSHKeyPath) } knownHosts, khErr := bangerKnownHostsPath() args = append( args, "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes", "-o", "PreferredAuthentications=publickey", "-o", "PasswordAuthentication=no", "-o", "KbdInteractiveAuthentication=no", ) if khErr == nil { args = append(args, "-o", "UserKnownHostsFile="+knownHosts, "-o", "StrictHostKeyChecking=accept-new", ) } else { // If we can't resolve the banger path (unusual — paths.Resolve // basically can't fail), fall through to a hard-fail posture // rather than silently disabling verification. args = append(args, "-o", "StrictHostKeyChecking=yes", ) } args = append(args, "root@"+guestIP) args = append(args, extra...) return args, nil } // bangerKnownHostsPath resolves the TOFU file the daemon writes into // and the CLI reads back. Both sides must agree on the path or the // pin doesn't round-trip. func bangerKnownHostsPath() (string, error) { layout, err := paths.Resolve() if err != nil { return "", err } return layout.KnownHostsPath, nil } func validateSSHPrereqs(cfg model.DaemonConfig) error { checks := system.NewPreflight() checks.RequireCommand("ssh", "install openssh-client") if strings.TrimSpace(cfg.SSHKeyPath) != "" { checks.RequireFile(cfg.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`) } return checks.Err("ssh preflight failed") } func validateVMRunPrereqs(cfg model.DaemonConfig) error { checks := system.NewPreflight() checks.RequireCommand("git", "install git") if strings.TrimSpace(cfg.SSHKeyPath) != "" { checks.RequireFile(cfg.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`) } return checks.Err("vm run preflight failed") }