Before this change, every daemon.Open() wrote a Host *.vm stanza into
~/.ssh/config in a marker-fenced block. That's a real footgun for users
who manage their SSH config declaratively (chezmoi, dotfiles, NixOS):
banger was mutating host state outside its own directory on every
daemon start, easy to miss and hard to audit.
New contract: the daemon only ever writes its own ssh_config file at
~/.config/banger/ssh_config. ~/.ssh/config is untouched unless the user
opts in. `banger vm ssh <name>` still works out of the box — the
shortcut only matters for plain `ssh sandbox.vm` from any terminal.
The opt-in surface is `banger ssh-config`:
banger ssh-config # prints path + include-line +
# install/uninstall hints
banger ssh-config --install # adds `Include <bangerConfig>` to
# ~/.ssh/config inside a marker-fenced
# block; idempotent; migrates any
# legacy inline Host *.vm block from
# pre-opt-in builds
banger ssh-config --uninstall # removes the new Include block AND
# any legacy inline block
Doctor gains a gentle warn-level note when banger's ssh_config exists
but the user hasn't wired it in — not a fail, since the shortcut is
convenience and `banger vm ssh` covers the essential case.
Tests cover: daemon writes banger file and does NOT touch ~/.ssh/config,
Install adds the block, Install is idempotent, Install migrates the
legacy inline block cleanly (removing it, preserving unrelated
entries, adding the new Include block), Uninstall removes both marker
variants, Uninstall is a no-op when ~/.ssh/config is absent, and
UserSSHIncludeInstalled detects both marker shapes.
README reframes the feature as optional convenience.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
321 lines
10 KiB
Go
321 lines
10 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.
|
|
// 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
|
|
}
|
|
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
|
|
}
|
|
|
|
legacyDir := filepath.Join(layout.ConfigDir, "ssh")
|
|
if _, err := os.Stat(legacyDir); err == nil {
|
|
_ = os.RemoveAll(legacyDir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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)
|
|
}
|