banger/internal/daemon/ssh_client_config.go
Thales Maciel 700a1e6e60
cleanup: drop pre-v0.1 migration scaffolding + legacy-behavior refs
banger hasn't shipped a public release — every "legacy", "pre-opt-in",
"previously", "migration note", "no longer" reference in the tree is
pinning against a state no real user's install has ever been in.
That scaffolding has weight: it's a coordinate system future readers
have to decode, and it keeps dead code alive.

Removed (code):
- internal/daemon/ssh_client_config.go
    - vmSSHConfigIncludeBegin / vmSSHConfigIncludeEnd constants and
      every `removeManagedBlock(existing, vm...)` call they enabled
      (legacy inline `Host *.vm` block scrub)
    - cleanupLegacySSHConfigDir (+ its caller in syncVMSSHClientConfig)
      — wiped a pre-opt-in sibling file under $ConfigDir/ssh
    - sameDirOrParent + resolvePathForComparison — only ever used
      by cleanupLegacySSHConfigDir
    - the "also check legacy marker" fallback in
      UserSSHIncludeInstalled / UninstallUserSSHInclude
- internal/store/migrations.go
    - migrateDropDeadImageColumns (migration 2) + its slice entry
    - dropColumnIfExists (orphaned after the above)
    - addColumnIfMissing + the whole "columns added across the pre-
      versioning lifetime" block at the end of migrateBaseline —
      subsumed into the baseline CREATE TABLE
    - `packages_path TEXT` column on the images table (the
      throwaway migration 2 dropped it, but there was never any
      reader)
- internal/daemon/vm.go
    - vmDNSRecordName local wrapper — was justified as "avoid
      pulling vmdns into every file"; three of four callers already
      imported vmdns directly, so inline the one stray call
- internal/cli/cli_test.go
    - TestLegacyRemovedCommandIsRejected (`tui` subcommand never
      shipped)

Removed / simplified (tests):
- ssh_client_config_test.go: dropped TestSameDirOrParentHandlesSymlinks,
  TestSyncVMSSHClientConfigPreservesUserKeyInLegacyDir,
  TestSyncVMSSHClientConfigNarrowsCleanupToLegacyFile,
  TestSyncVMSSHClientConfigLeavesUnexpectedLegacyContents,
  TestInstallUserSSHIncludeMigratesLegacyInlineBlock, plus the
  "legacy posture" regression strings in the remaining happy-path
  test; TestUninstallUserSSHIncludeRemovesBothMarkerBlocks collapsed
  to a single-block test
- migrations_test.go: dropped TestMigrateDropDeadImageColumns_AcrossInstallPaths,
  TestDropColumnIfExistsIsIdempotent; TestOpenReadOnlyDoesNotRunMigrations
  simplified to test against the baseline marker

Removed (docs):
- README.md "**Migration note.**" blockquote about the SSH-key path move
- docs/advanced.md parenthetical "(the old behaviour)"

Reworded (comments):
- Dropped "Previously this file also contained LogLevel DEBUG3..."
  history from vm_disk.go's sshdGuestConfig doc
- Dropped "Call sites that previously read vm.Runtime.{PID,...}"
  from vm_handles.go; now documents the current contract
- Dropped "Pre-v0.1 the defaults are" scaffolding in doctor_test.go
- Dropped "no longer does its own git inspection" phrasing in vm_run.go
- Dropped the "(also cleans up legacy inline block from pre-opt-in
  builds)" aside on the `ssh-config` CLI docstring
- Renamed test var `legacyKey` → `existingKey` in vm_test.go; its
  purpose was "pre-existing authorized_keys line," not banger-legacy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:56:32 -03:00

284 lines
8.8 KiB
Go

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 <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.
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 <bangerSSHConfigPath>` 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 <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)
}