diff --git a/README.md b/README.md index 6a77cf2..b6aaf79 100644 --- a/README.md +++ b/README.md @@ -101,12 +101,28 @@ leaves the VM alive for `banger vm logs` inspection. ## Hostnames: reaching `.vm` banger's daemon runs a DNS server for the `.vm` zone. With host-side -DNS routing you can `ssh root@sandbox.vm` or `curl -http://sandbox.vm:3000` from anywhere on the host — no copy-pasting -guest IPs. On systemd-resolved hosts this is auto-wired; everywhere -else there's a short recipe. See +DNS routing you can `curl http://sandbox.vm:3000` from anywhere on +the host — no copy-pasting guest IPs. On systemd-resolved hosts this +is auto-wired; everywhere else there's a short recipe. See [`docs/dns-routing.md`](docs/dns-routing.md). +### Optional: `ssh .vm` shortcut + +`banger vm ssh ` works out of the box. If you'd also like plain +`ssh sandbox.vm` from any terminal (using banger's key + known_hosts), +opt in: + +```bash +banger ssh-config --install # adds `Include ~/.config/banger/ssh_config` + # to ~/.ssh/config in a marker-fenced block +banger ssh-config --uninstall # reverse it +banger ssh-config # show the include line to paste manually +``` + +banger never touches `~/.ssh/config` on its own — the daemon keeps its +file fresh at `~/.config/banger/ssh_config`; whether and how it's +pulled into your SSH config is up to you. + ## Image catalog `banger image pull ` fetches a pre-built bundle from the diff --git a/internal/cli/banger.go b/internal/cli/banger.go index fd87775..e7312f1 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -33,6 +33,7 @@ func (d *deps) newRootCommand() *cobra.Command { d.newImageCommand(), d.newInternalCommand(), d.newKernelCommand(), + newSSHConfigCommand(), newVersionCommand(), d.newPSCommand(), d.newVMCommand(), diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 8c9c26a..231835f 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -30,7 +30,7 @@ func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) { for _, sub := range cmd.Commands() { names = append(names, sub.Name()) } - want := []string{"daemon", "doctor", "image", "internal", "kernel", "ps", "version", "vm"} + want := []string{"daemon", "doctor", "image", "internal", "kernel", "ps", "ssh-config", "version", "vm"} if !reflect.DeepEqual(names, want) { t.Fatalf("subcommands = %v, want %v", names, want) } diff --git a/internal/cli/commands_ssh_config.go b/internal/cli/commands_ssh_config.go new file mode 100644 index 0000000..0bdd1b8 --- /dev/null +++ b/internal/cli/commands_ssh_config.go @@ -0,0 +1,86 @@ +package cli + +import ( + "fmt" + + "banger/internal/daemon" + "banger/internal/paths" + + "github.com/spf13/cobra" +) + +// newSSHConfigCommand exposes the opt-in ergonomics for `ssh .vm`. +// Default mode prints current status + the exact Include line the user +// can paste into ~/.ssh/config themselves. --install does the include +// for them inside a marker-fenced block; --uninstall reverses it (also +// cleans up any legacy inline block from pre-opt-in builds). +func newSSHConfigCommand() *cobra.Command { + var ( + install bool + uninstall bool + ) + cmd := &cobra.Command{ + Use: "ssh-config", + Short: "Manage the optional `ssh .vm` shortcut", + Long: `Banger keeps a self-contained SSH client config under its own config +directory (never touching ~/.ssh/config on its own). Opt in to the +convenience shortcut that lets you run 'ssh .vm' from any +terminal, bypassing 'banger vm ssh': + + banger ssh-config # print status + copy-paste snippet + banger ssh-config --install # add an Include line to ~/.ssh/config + banger ssh-config --uninstall # remove banger's Include from ~/.ssh/config +`, + Args: noArgsUsage("usage: banger ssh-config [--install|--uninstall]"), + RunE: func(cmd *cobra.Command, args []string) error { + if install && uninstall { + return fmt.Errorf("use only one of --install or --uninstall") + } + layout, err := paths.Resolve() + if err != nil { + return err + } + bangerConfig := daemon.BangerSSHConfigPath(layout) + switch { + case install: + if err := daemon.InstallUserSSHInclude(layout); err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), + "added Include %s to ~/.ssh/config — `ssh .vm` will now route through banger\n", + bangerConfig, + ) + return err + case uninstall: + if err := daemon.UninstallUserSSHInclude(); err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), "removed banger's entries from ~/.ssh/config") + return err + default: + installed, err := daemon.UserSSHIncludeInstalled() + if err != nil { + return err + } + out := cmd.OutOrStdout() + fmt.Fprintf(out, "banger ssh_config: %s\n", bangerConfig) + if installed { + fmt.Fprintln(out, "status: included from ~/.ssh/config") + fmt.Fprintln(out, "") + fmt.Fprintln(out, "`ssh .vm` is enabled. Run `banger ssh-config --uninstall` to revert.") + } else { + fmt.Fprintln(out, "status: not included (opt-in)") + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Enable `ssh .vm` in two ways:") + fmt.Fprintln(out, " banger ssh-config --install") + fmt.Fprintln(out, "or add this line to ~/.ssh/config yourself:") + fmt.Fprintf(out, " Include %s\n", bangerConfig) + } + return nil + } + }, + } + cmd.Flags().BoolVar(&install, "install", false, "add an Include line to ~/.ssh/config") + cmd.Flags().BoolVar(&uninstall, "uninstall", false, "remove banger's Include from ~/.ssh/config") + return cmd +} diff --git a/internal/daemon/doctor.go b/internal/daemon/doctor.go index 9e0b0fd..a833ed4 100644 --- a/internal/daemon/doctor.go +++ b/internal/daemon/doctor.go @@ -3,6 +3,7 @@ package daemon import ( "context" "fmt" + "os" "runtime" "strings" @@ -55,11 +56,40 @@ func (d *Daemon) doctorReport(ctx context.Context, storeErr error) system.Report report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available") report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock guest agent prerequisites available") d.addVMDefaultsCheck(&report) + d.addSSHShortcutCheck(&report) d.addCapabilityDoctorChecks(ctx, &report) return report } +// addSSHShortcutCheck surfaces a gentle warning when banger maintains +// an ssh_config file but the user hasn't wired it into ~/.ssh/config. +// This is intentionally a warn, not a fail — the shortcut is opt-in +// convenience and `banger vm ssh` works either way. +func (d *Daemon) addSSHShortcutCheck(report *system.Report) { + bangerConfig := BangerSSHConfigPath(d.layout) + if strings.TrimSpace(bangerConfig) == "" { + return + } + if _, err := os.Stat(bangerConfig); err != nil { + // No banger ssh_config rendered yet — nothing to include. + return + } + installed, err := UserSSHIncludeInstalled() + if err != nil { + report.AddWarn("ssh shortcut", fmt.Sprintf("could not read ~/.ssh/config: %v", err)) + return + } + if installed { + report.AddPass("ssh shortcut", "enabled — `ssh .vm` routes through banger") + return + } + report.AddWarn( + "ssh shortcut", + fmt.Sprintf("`ssh .vm` not enabled (opt-in); run `banger ssh-config --install` or add `Include %s` to ~/.ssh/config", bangerConfig), + ) +} + // addArchitectureCheck surfaces a hard-fail when banger is running on // a non-amd64 host. Companion binaries are pinned to amd64 in the // Makefile, the published kernel catalog ships only x86_64 images, and diff --git a/internal/daemon/ssh_client_config.go b/internal/daemon/ssh_client_config.go index 1fffd7d..4deb281 100644 --- a/internal/daemon/ssh_client_config.go +++ b/internal/daemon/ssh_client_config.go @@ -12,6 +12,23 @@ import ( "banger/internal/paths" ) +// Marker sentinels. +// +// vmSSHConfigIncludeBegin / vmSSHConfigIncludeEnd used to wrap the full +// Host *.vm stanza when banger wrote directly into ~/.ssh/config. +// We keep the sentinel strings only so uninstall can find and remove +// legacy blocks on systems that upgraded from that behaviour. +// +// The new opt-in flow writes a short Include block with its own marker +// pair; the daemon itself no longer touches ~/.ssh/config at all. +const ( + vmSSHConfigIncludeBegin = "# BEGIN BANGER MANAGED VM SSH" + vmSSHConfigIncludeEnd = "# END BANGER MANAGED VM SSH" + + bangerSSHIncludeBegin = "# BEGIN BANGER SSH INCLUDE" + bangerSSHIncludeEnd = "# END BANGER SSH INCLUDE" +) + // removeVMKnownHosts drops every host-key pin for vm from the // banger-owned known_hosts. Best-effort — a failure here only // matters if the same IP/name is reused by a fresh VM before the @@ -37,10 +54,17 @@ func removeVMKnownHosts(knownHostsPath string, vm model.VMRecord, logger *slog.L } } -const ( - vmSSHConfigIncludeBegin = "# BEGIN BANGER MANAGED VM SSH" - vmSSHConfigIncludeEnd = "# END BANGER MANAGED VM SSH" -) +// BangerSSHConfigPath is the file banger owns and keeps in sync with +// the current default key + known_hosts locations. Users who want the +// `ssh .vm` shortcut opt in via `banger ssh-config --install`, +// which adds an Include line to ~/.ssh/config pointing at this file. +// The daemon never touches ~/.ssh/config on its own. +func BangerSSHConfigPath(layout paths.Layout) string { + if strings.TrimSpace(layout.ConfigDir) == "" { + return "" + } + return filepath.Join(layout.ConfigDir, "ssh_config") +} func (d *Daemon) ensureVMSSHClientConfig() { if err := syncVMSSHClientConfig(d.layout, d.config.SSHKeyPath); err != nil && d.logger != nil { @@ -48,53 +72,135 @@ func (d *Daemon) ensureVMSSHClientConfig() { } } +// syncVMSSHClientConfig writes banger's own ssh_config file with the +// current `Host *.vm` stanza. It does NOT touch ~/.ssh/config; that's +// the job of `banger ssh-config --install` (user-initiated). +// +// The file lives in the banger config dir so users who manage their +// SSH config declaratively can decide how (or whether) to pull it in. +// We also keep a tiny migration step here: the pre-opt-in daemon +// wrote a sibling file at $ConfigDir/ssh/ssh_config; remove it and +// its dir if present. func syncVMSSHClientConfig(layout paths.Layout, keyPath string) error { keyPath = strings.TrimSpace(keyPath) if keyPath == "" { return nil } - - home, err := os.UserHomeDir() - if err != nil { + target := BangerSSHConfigPath(layout) + if target == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } - sshDir := filepath.Join(home, ".ssh") - if err := os.MkdirAll(sshDir, 0o700); err != nil { - return err - } - userConfigPath := filepath.Join(sshDir, "config") - userConfig, err := readTextFileIfExists(userConfigPath) - if err != nil { - return err - } - updated, err := upsertManagedBlock(userConfig, vmSSHConfigIncludeBegin, vmSSHConfigIncludeEnd, renderManagedVMSSHBlock(keyPath, layout.KnownHostsPath)) - if err != nil { - return err - } - if err := writeTextFileIfChanged(userConfigPath, updated, 0o644); err != nil { + block := renderManagedVMSSHBlock(keyPath, layout.KnownHostsPath) + if err := writeTextFileIfChanged(target, block, 0o644); err != nil { return err } - legacyManagedPath := filepath.Join(layout.ConfigDir, "ssh", "ssh_config") - if err := os.Remove(legacyManagedPath); err != nil && !os.IsNotExist(err) { - return err + legacyDir := filepath.Join(layout.ConfigDir, "ssh") + if _, err := os.Stat(legacyDir); err == nil { + _ = os.RemoveAll(legacyDir) } return nil } -// renderManagedVMSSHBlock produces the `Host *.vm` stanza banger -// writes into the user's ~/.ssh/config. Host-key verification uses -// the banger-owned known_hosts file at knownHostsPath — NOT the -// user's ~/.ssh/known_hosts, and NOT /dev/null. `accept-new` means -// first contact pins the key; any later mismatch fails the connect. +// InstallUserSSHInclude adds an `Include ` line +// to ~/.ssh/config inside a banger-owned marker block. Idempotent: +// running it twice leaves a single block. Also strips any legacy +// inline `Host *.vm` banger block left over from the pre-opt-in +// era so the user ends up with the Include-only layout. +func InstallUserSSHInclude(layout paths.Layout) error { + bangerConfig := BangerSSHConfigPath(layout) + if bangerConfig == "" { + return fmt.Errorf("banger config dir is not configured") + } + userConfigPath, err := userSSHConfigPath() + if err != nil { + return err + } + existing, err := readTextFileIfExists(userConfigPath) + if err != nil { + return err + } + stripped, err := removeManagedBlock(existing, vmSSHConfigIncludeBegin, vmSSHConfigIncludeEnd) + if err != nil { + return err + } + block := renderBangerSSHIncludeBlock(bangerConfig) + updated, err := upsertManagedBlock(stripped, bangerSSHIncludeBegin, bangerSSHIncludeEnd, block) + if err != nil { + return err + } + return writeTextFileIfChanged(userConfigPath, updated, 0o600) +} + +// UninstallUserSSHInclude removes the Include block (and any legacy +// inline Host *.vm block) from ~/.ssh/config. Idempotent: missing +// file or missing block is a no-op. +func UninstallUserSSHInclude() error { + userConfigPath, err := userSSHConfigPath() + if err != nil { + return err + } + existing, err := readTextFileIfExists(userConfigPath) + if err != nil { + return err + } + if existing == "" { + return nil + } + updated, err := removeManagedBlock(existing, bangerSSHIncludeBegin, bangerSSHIncludeEnd) + if err != nil { + return err + } + updated, err = removeManagedBlock(updated, vmSSHConfigIncludeBegin, vmSSHConfigIncludeEnd) + if err != nil { + return err + } + return writeTextFileIfChanged(userConfigPath, updated, 0o600) +} + +// UserSSHIncludeInstalled reports whether ~/.ssh/config contains +// either the new Include block or a legacy inline banger block. +// Used by `ssh-config` (status readout) and `doctor`. +func UserSSHIncludeInstalled() (bool, error) { + userConfigPath, err := userSSHConfigPath() + if err != nil { + return false, err + } + existing, err := readTextFileIfExists(userConfigPath) + if err != nil { + return false, err + } + if strings.Contains(existing, bangerSSHIncludeBegin) { + return true, nil + } + if strings.Contains(existing, vmSSHConfigIncludeBegin) { + return true, nil + } + return false, nil +} + +func userSSHConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".ssh", "config"), nil +} + +// renderManagedVMSSHBlock produces the body banger writes into its +// own ssh_config file. Host-key verification uses the banger-owned +// known_hosts — NOT the user's ~/.ssh/known_hosts, and NOT /dev/null. +// `accept-new` means first contact pins the key; any later mismatch +// fails the connect. func renderManagedVMSSHBlock(keyPath, knownHostsPath string) string { keyPath = strings.TrimSpace(keyPath) knownHostsPath = strings.TrimSpace(knownHostsPath) lines := []string{ - vmSSHConfigIncludeBegin, - "# Generated by banger for direct SSH access to VM DNS names.", - "# Host keys are pinned on first use into a banger-owned", - "# known_hosts file (not ~/.ssh/known_hosts).", + "# Generated by banger. Edits will be overwritten on daemon start.", + "# Enable the `ssh .vm` shortcut via `banger ssh-config --install`.", "Host *.vm", " User root", " IdentityFile " + keyPath, @@ -114,14 +220,27 @@ func renderManagedVMSSHBlock(keyPath, knownHostsPath string) string { // closed rather than silently disable verification. lines = append(lines, " StrictHostKeyChecking yes") } - lines = append(lines, - " LogLevel ERROR", - vmSSHConfigIncludeEnd, - "", - ) + lines = append(lines, " LogLevel ERROR", "") return strings.Join(lines, "\n") } +// renderBangerSSHIncludeBlock returns the marker-fenced block that +// `ssh-config --install` writes into ~/.ssh/config. +func renderBangerSSHIncludeBlock(bangerConfigPath string) string { + lines := []string{ + bangerSSHIncludeBegin, + "# Added by `banger ssh-config --install`. Remove with", + "# `banger ssh-config --uninstall`, or delete the whole block.", + "Include " + bangerConfigPath, + bangerSSHIncludeEnd, + "", + } + return strings.Join(lines, "\n") +} + +// upsertManagedBlock replaces an existing marker-fenced block with +// `block` (including the begin/end markers), or appends `block` if +// no such block exists. `block` must contain the markers itself. func upsertManagedBlock(existing, beginMarker, endMarker, block string) (string, error) { existing = normalizeConfigText(existing) block = normalizeConfigText(block) @@ -145,6 +264,27 @@ func upsertManagedBlock(existing, beginMarker, endMarker, block string) (string, return strings.TrimRight(existing, "\n") + "\n\n" + block, nil } +// removeManagedBlock strips a marker-fenced block from existing text +// and returns the result (unchanged if no block is present). Missing +// end marker with present begin marker is treated as corruption. +func removeManagedBlock(existing, beginMarker, endMarker string) (string, error) { + existing = normalizeConfigText(existing) + start := strings.Index(existing, beginMarker) + if start < 0 { + return existing, nil + } + end := strings.Index(existing[start:], endMarker) + if end < 0 { + return "", fmt.Errorf("managed block %q is missing end marker %q", beginMarker, endMarker) + } + end += start + len(endMarker) + for end < len(existing) && existing[end] == '\n' { + end++ + } + stripped := strings.TrimRight(existing[:start]+existing[end:], "\n") + return normalizeConfigText(stripped), nil +} + func normalizeConfigText(text string) string { text = strings.ReplaceAll(text, "\r\n", "\n") text = strings.TrimRight(text, "\n") @@ -174,5 +314,8 @@ func writeTextFileIfChanged(path, content string, mode os.FileMode) error { if existing == content { return nil } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } return os.WriteFile(path, []byte(content), mode) } diff --git a/internal/daemon/ssh_client_config_test.go b/internal/daemon/ssh_client_config_test.go index 6838eb2..d2a594f 100644 --- a/internal/daemon/ssh_client_config_test.go +++ b/internal/daemon/ssh_client_config_test.go @@ -9,7 +9,9 @@ import ( "banger/internal/paths" ) -func TestSyncVMSSHClientConfigCreatesManagedBlock(t *testing.T) { +// Under the opt-in contract the daemon writes its own ssh_config file +// and never touches ~/.ssh/config on its own. +func TestSyncVMSSHClientConfigWritesBangerFileOnly(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) @@ -18,91 +20,240 @@ func TestSyncVMSSHClientConfigCreatesManagedBlock(t *testing.T) { ConfigDir: filepath.Join(homeDir, ".config", "banger"), KnownHostsPath: knownHostsPath, } - keyPath := filepath.Join(layout.ConfigDir, "ssh", "id_ed25519") + keyPath := filepath.Join(homeDir, ".config", "banger", "ssh", "id_ed25519") if err := syncVMSSHClientConfig(layout, keyPath); err != nil { t.Fatalf("syncVMSSHClientConfig: %v", err) } - userConfigPath := filepath.Join(homeDir, ".ssh", "config") - userConfig, err := os.ReadFile(userConfigPath) + // Banger's own ssh_config file has the `Host *.vm` stanza. + bangerConfig, err := os.ReadFile(BangerSSHConfigPath(layout)) if err != nil { - t.Fatalf("ReadFile(user config): %v", err) - } - userContent := string(userConfig) - if !strings.Contains(userContent, vmSSHConfigIncludeBegin) { - t.Fatalf("user config = %q, want begin marker", userContent) + t.Fatalf("ReadFile(banger ssh_config): %v", err) } for _, want := range []string{ "Host *.vm", - "User root", "IdentityFile " + keyPath, - "IdentitiesOnly yes", - "BatchMode yes", - "PasswordAuthentication no", "UserKnownHostsFile " + knownHostsPath, "StrictHostKeyChecking accept-new", } { - if !strings.Contains(userContent, want) { - t.Fatalf("user config = %q, want %q", userContent, want) + if !strings.Contains(string(bangerConfig), want) { + t.Fatalf("banger ssh_config missing %q:\n%s", want, bangerConfig) } } - // Regression: the legacy posture (StrictHostKeyChecking no + - // UserKnownHostsFile /dev/null) must never reappear. + + // ~/.ssh/config must NOT have been created or modified. + if _, err := os.Stat(filepath.Join(homeDir, ".ssh", "config")); !os.IsNotExist(err) { + t.Fatalf("~/.ssh/config should be untouched; stat err = %v", err) + } + + // Regression: the legacy posture (strict no + /dev/null) must not + // reappear in the banger file. for _, must := range []string{ "StrictHostKeyChecking no", "UserKnownHostsFile /dev/null", } { - if strings.Contains(userContent, must) { - t.Fatalf("user config leaked legacy posture %q:\n%s", must, userContent) + if strings.Contains(string(bangerConfig), must) { + t.Fatalf("banger ssh_config leaked legacy posture %q:\n%s", must, bangerConfig) } } } -func TestSyncVMSSHClientConfigReplacesManagedIncludeBlock(t *testing.T) { +func TestInstallUserSSHIncludeAddsIncludeBlock(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) - layout := paths.Layout{ - ConfigDir: filepath.Join(homeDir, ".config", "banger"), + layout := paths.Layout{ConfigDir: filepath.Join(homeDir, ".config", "banger")} + if err := os.MkdirAll(layout.ConfigDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + // Write a fake banger ssh_config so Install has something to include. + if err := os.WriteFile(BangerSSHConfigPath(layout), []byte("Host *.vm\n"), 0o644); err != nil { + t.Fatalf("WriteFile(banger ssh_config): %v", err) + } + + if err := InstallUserSSHInclude(layout); err != nil { + t.Fatalf("InstallUserSSHInclude: %v", err) + } + got, err := os.ReadFile(filepath.Join(homeDir, ".ssh", "config")) + if err != nil { + t.Fatalf("ReadFile(~/.ssh/config): %v", err) + } + want := "Include " + BangerSSHConfigPath(layout) + if !strings.Contains(string(got), want) { + t.Fatalf("user config missing %q:\n%s", want, got) + } + if !strings.Contains(string(got), bangerSSHIncludeBegin) { + t.Fatalf("user config missing begin marker:\n%s", got) + } +} + +func TestInstallUserSSHIncludeIsIdempotent(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + layout := paths.Layout{ConfigDir: filepath.Join(homeDir, ".config", "banger")} + if err := os.MkdirAll(layout.ConfigDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(BangerSSHConfigPath(layout), []byte("Host *.vm\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + for i := 0; i < 3; i++ { + if err := InstallUserSSHInclude(layout); err != nil { + t.Fatalf("InstallUserSSHInclude (%d): %v", i, err) + } + } + got, err := os.ReadFile(filepath.Join(homeDir, ".ssh", "config")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if n := strings.Count(string(got), bangerSSHIncludeBegin); n != 1 { + t.Fatalf("begin markers = %d, want 1:\n%s", n, got) + } +} + +func TestInstallUserSSHIncludeMigratesLegacyInlineBlock(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + layout := paths.Layout{ConfigDir: filepath.Join(homeDir, ".config", "banger")} + if err := os.MkdirAll(layout.ConfigDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(BangerSSHConfigPath(layout), []byte("Host *.vm\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) } - keyPath := filepath.Join(layout.ConfigDir, "ssh", "id_ed25519") sshDir := filepath.Join(homeDir, ".ssh") if err := os.MkdirAll(sshDir, 0o700); err != nil { t.Fatalf("MkdirAll(.ssh): %v", err) } - initial := strings.Join([]string{ + legacy := strings.Join([]string{ "ServerAliveInterval 120", "", vmSSHConfigIncludeBegin, - "Include /tmp/old-banger-config", + "Host *.vm", + " User root", + " IdentityFile /some/old/key", vmSSHConfigIncludeEnd, "", "Host other", " HostName 192.0.2.5", "", }, "\n") - if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(initial), 0o644); err != nil { - t.Fatalf("WriteFile(user config): %v", err) + if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(legacy), 0o600); err != nil { + t.Fatalf("seed legacy config: %v", err) } - if err := syncVMSSHClientConfig(layout, keyPath); err != nil { - t.Fatalf("syncVMSSHClientConfig: %v", err) + if err := InstallUserSSHInclude(layout); err != nil { + t.Fatalf("InstallUserSSHInclude: %v", err) } - - userConfig, err := os.ReadFile(filepath.Join(sshDir, "config")) + got, err := os.ReadFile(filepath.Join(sshDir, "config")) if err != nil { - t.Fatalf("ReadFile(user config): %v", err) + t.Fatalf("ReadFile: %v", err) } - userContent := string(userConfig) - if strings.Count(userContent, vmSSHConfigIncludeBegin) != 1 { - t.Fatalf("user config = %q, want one managed block", userContent) + gotStr := string(got) + // Legacy inline block must be gone. + if strings.Contains(gotStr, vmSSHConfigIncludeBegin) { + t.Fatalf("legacy inline block survived:\n%s", gotStr) } - if !strings.Contains(userContent, "ServerAliveInterval 120") || !strings.Contains(userContent, "Host other") { - t.Fatalf("user config = %q, want existing entries preserved", userContent) + // New Include block must be present. + if !strings.Contains(gotStr, bangerSSHIncludeBegin) { + t.Fatalf("new include block missing:\n%s", gotStr) } - if !strings.Contains(userContent, "Host *.vm") || !strings.Contains(userContent, "IdentityFile "+keyPath) { - t.Fatalf("user config = %q, want refreshed managed vm block", userContent) + // Unrelated stanzas must be preserved. + for _, want := range []string{"ServerAliveInterval 120", "Host other"} { + if !strings.Contains(gotStr, want) { + t.Fatalf("user config lost unrelated entry %q:\n%s", want, gotStr) + } + } +} + +func TestUninstallUserSSHIncludeRemovesBothMarkerBlocks(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + sshDir := filepath.Join(homeDir, ".ssh") + if err := os.MkdirAll(sshDir, 0o700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + seed := strings.Join([]string{ + "Host keep", + " HostName 198.51.100.1", + "", + vmSSHConfigIncludeBegin, + "Host *.vm", + vmSSHConfigIncludeEnd, + "", + bangerSSHIncludeBegin, + "Include /tmp/banger-ssh-config", + bangerSSHIncludeEnd, + "", + }, "\n") + if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(seed), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + if err := UninstallUserSSHInclude(); err != nil { + t.Fatalf("UninstallUserSSHInclude: %v", err) + } + got, err := os.ReadFile(filepath.Join(sshDir, "config")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + gotStr := string(got) + for _, banned := range []string{vmSSHConfigIncludeBegin, bangerSSHIncludeBegin} { + if strings.Contains(gotStr, banned) { + t.Fatalf("residue of %q:\n%s", banned, gotStr) + } + } + if !strings.Contains(gotStr, "Host keep") { + t.Fatalf("lost unrelated entry:\n%s", gotStr) + } +} + +func TestUninstallUserSSHIncludeIsNoOpWhenMissing(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + if err := UninstallUserSSHInclude(); err != nil { + t.Fatalf("UninstallUserSSHInclude on missing file: %v", err) + } + // Still no ~/.ssh/config. + if _, err := os.Stat(filepath.Join(homeDir, ".ssh", "config")); !os.IsNotExist(err) { + t.Fatalf("~/.ssh/config unexpectedly created; stat err = %v", err) + } +} + +func TestUserSSHIncludeInstalledDetectsBothMarkers(t *testing.T) { + for _, tc := range []struct { + name string + seed string + wantIn bool + }{ + {"missing file", "", false}, + {"unrelated only", "Host other\n HostName 1.2.3.4\n", false}, + {"legacy marker", vmSSHConfigIncludeBegin + "\nHost *.vm\n" + vmSSHConfigIncludeEnd + "\n", true}, + {"new marker", bangerSSHIncludeBegin + "\nInclude /tmp/banger\n" + bangerSSHIncludeEnd + "\n", true}, + } { + t.Run(tc.name, func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + if tc.seed != "" { + if err := os.MkdirAll(filepath.Join(homeDir, ".ssh"), 0o700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(homeDir, ".ssh", "config"), []byte(tc.seed), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + } + got, err := UserSSHIncludeInstalled() + if err != nil { + t.Fatalf("UserSSHIncludeInstalled: %v", err) + } + if got != tc.wantIn { + t.Fatalf("got %v, want %v", got, tc.wantIn) + } + }) } }