package daemon import ( "fmt" "log/slog" "os" "path/filepath" "strings" "banger/internal/guest" "banger/internal/model" "banger/internal/paths" ) // Marker sentinels that fence the `Include` block banger writes into // ~/.ssh/config when the user runs `banger ssh-config --install`. const ( 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.userLayout, 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. 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) return writeTextFileIfChanged(target, block, 0o644) } // InstallUserSSHInclude adds an `Include ` line // to ~/.ssh/config inside a banger-owned marker block. Idempotent: // running it twice leaves a single block. 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 } block := renderBangerSSHIncludeBlock(bangerConfig) updated, err := upsertManagedBlock(existing, bangerSSHIncludeBegin, bangerSSHIncludeEnd, block) if err != nil { return err } return writeTextFileIfChanged(userConfigPath, updated, 0o600) } // UninstallUserSSHInclude removes the Include 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 } return writeTextFileIfChanged(userConfigPath, updated, 0o600) } // UserSSHIncludeInstalled reports whether ~/.ssh/config contains the // banger Include 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 } return strings.Contains(existing, bangerSSHIncludeBegin), 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) }