banger/internal/daemon/ssh_client_config.go
Thales Maciel b1fbf695ca
ssh-config: narrow the legacy-dir cleanup so it can't delete a user key
Bug: syncVMSSHClientConfig did os.RemoveAll on $ConfigDir/ssh every
daemon Open. The intent was to migrate off the pre-opt-in layout,
where banger used to write $ConfigDir/ssh/ssh_config. But a user who
sets ssh_key_path = "~/.config/banger/ssh/id_ed25519" in config.toml
has their key live exactly in that dir — and the scrub deletes it
along with every other file in the tree.

This is the same class of bug that cost the default key until
ebe6517 moved it to StateDir, but that fix was scoped to the default
path. A configured ssh_key_path pointed under the legacy dir still
dies.

Fix: replace os.RemoveAll with a narrow two-step cleanup:

 1. Skip the cleanup entirely when the configured ssh_key_path
    resolves under the legacy dir. A user who pointed banger at a
    key there must keep the enclosing directory.
 2. Otherwise, os.Remove the specific legacy file ($ConfigDir/ssh/
    ssh_config) and then os.Remove the directory. The second
    os.Remove fails with ENOTEMPTY if the dir still holds anything
    (e.g. a user-managed sibling file we don't own). Both errors
    are swallowed — this is best-effort migration, not a hard
    failure.

Tests pin all three paths: user key under legacy dir survives,
legacy dir empties and is removed when the user moved on, and a
user-managed sibling file in the legacy dir is preserved.

Also fix stale doc claims in README.md and AGENTS.md — both still
pointed at the old ~/.config/banger/ssh/id_ed25519 default, which
moved to ~/.local/state/banger/ssh/id_ed25519 in ebe6517.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:31:07 -03:00

363 lines
12 KiB
Go

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 <name>.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 relatives). Used to gate destructive cleanup
// against a configured key that lives inside the cleanup target.
func sameDirOrParent(dir, path string) bool {
if strings.TrimSpace(dir) == "" || strings.TrimSpace(path) == "" {
return false
}
absDir, err := filepath.Abs(dir)
if err != nil {
return false
}
absPath, err := filepath.Abs(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))
}
// InstallUserSSHInclude adds an `Include <bangerSSHConfigPath>` 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 <name>.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)
}