package daemon import ( "fmt" "log/slog" "os" "path/filepath" "strings" "banger/internal/guest" "banger/internal/model" "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 // next daemon restart, and even then it just causes a // TOFU-mismatch error that the user can clear manually. Logged at // warn so it shows up if it ever actually breaks things. func removeVMKnownHosts(knownHostsPath string, vm model.VMRecord, logger *slog.Logger) { if strings.TrimSpace(knownHostsPath) == "" { return } var hosts []string if ip := strings.TrimSpace(vm.Runtime.GuestIP); ip != "" { hosts = append(hosts, ip) } if dns := strings.TrimSpace(vm.Runtime.DNSName); dns != "" { hosts = append(hosts, dns) } if len(hosts) == 0 { return } if err := guest.RemoveKnownHosts(knownHostsPath, hosts...); err != nil && logger != nil { logger.Warn("remove known_hosts entries", "vm_id", vm.ID, "error", err.Error()) } } // 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 { d.logger.Warn("vm ssh client config sync failed", "error", err.Error()) } } // 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. // A narrow migration step also runs here: the pre-opt-in daemon // wrote a sibling file at $ConfigDir/ssh/ssh_config. Remove only // that specific legacy file, then remove the enclosing directory // only if it's empty — never os.RemoveAll, because the user may // have pointed ssh_key_path at a key under that directory. func syncVMSSHClientConfig(layout paths.Layout, keyPath string) error { keyPath = strings.TrimSpace(keyPath) if keyPath == "" { return nil } target := BangerSSHConfigPath(layout) if target == "" { return nil } if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } block := renderManagedVMSSHBlock(keyPath, layout.KnownHostsPath) if err := writeTextFileIfChanged(target, block, 0o644); err != nil { return err } cleanupLegacySSHConfigDir(layout, keyPath) return nil } // cleanupLegacySSHConfigDir removes the pre-opt-in sibling file at // $ConfigDir/ssh/ssh_config and, if the directory is then empty, the // directory itself. Skips the whole operation when ssh_key_path // resolves under that directory — users who explicitly configured a // key there must not have the enclosing dir yanked out from under // them. All errors are swallowed: this is best-effort migration, not // a hard failure mode. func cleanupLegacySSHConfigDir(layout paths.Layout, keyPath string) { legacyDir := filepath.Join(layout.ConfigDir, "ssh") if sameDirOrParent(legacyDir, keyPath) { return } _ = os.Remove(filepath.Join(legacyDir, "ssh_config")) // Remove the dir only if it's now empty. os.Remove returns // ENOTEMPTY when it isn't, which is the signal we want. _ = os.Remove(legacyDir) } // sameDirOrParent reports whether dir contains path (or equals it) // after resolving symlinks. Used to gate destructive cleanup against // a configured key that lives inside the cleanup target — either // directly or via a symlinked spelling of the same physical // location. Lexical comparison alone would miss the symlink case // and let the scrub delete a user key aliased through an symlinked // directory. func sameDirOrParent(dir, path string) bool { if strings.TrimSpace(dir) == "" || strings.TrimSpace(path) == "" { return false } absDir, err := resolvePathForComparison(dir) if err != nil { return false } absPath, err := resolvePathForComparison(path) if err != nil { return false } rel, err := filepath.Rel(absDir, absPath) if err != nil { return false } // filepath.Rel returns "../..." when absPath is outside absDir. // A path inside (or equal to) the dir starts with "." or a // non-".." prefix. return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } // resolvePathForComparison returns an absolute, symlink-resolved // version of p. Falls back to filepath.Abs when EvalSymlinks errors // — typically because p refers to a file or directory that doesn't // exist yet, which is fine for comparison purposes: two non-existent // paths compared lexically is the best we can do and matches the // pre-symlink-aware behaviour. func resolvePathForComparison(p string) (string, error) { if resolved, err := filepath.EvalSymlinks(p); err == nil { return resolved, nil } return filepath.Abs(p) } // 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{ "# 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, " IdentitiesOnly yes", " BatchMode yes", " PreferredAuthentications publickey", " PasswordAuthentication no", " KbdInteractiveAuthentication no", } if knownHostsPath != "" { lines = append(lines, " UserKnownHostsFile "+knownHostsPath, " StrictHostKeyChecking accept-new", ) } else { // Missing known_hosts path is a configuration anomaly — fail // closed rather than silently disable verification. lines = append(lines, " StrictHostKeyChecking yes") } 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) start := strings.Index(existing, beginMarker) if start >= 0 { 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++ } existing = strings.TrimRight(existing[:start]+existing[end:], "\n") } if strings.TrimSpace(existing) == "" { return block, nil } 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") if text == "" { return "" } return text + "\n" } func readTextFileIfExists(path string) (string, error) { data, err := os.ReadFile(path) if err == nil { return string(data), nil } if os.IsNotExist(err) { return "", nil } return "", err } func writeTextFileIfChanged(path, content string, mode os.FileMode) error { content = normalizeConfigText(content) existing, err := readTextFileIfExists(path) if err != nil { return err } if existing == content { return nil } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } return os.WriteFile(path, []byte(content), mode) }