The symlink test in this commit catches a real bug: sameDirOrParent used filepath.Abs for both sides of the "is the key inside the legacy dir?" check, but filepath.Abs doesn't resolve symlinks. A user whose ssh_key_path pointed into ConfigDir/ssh via a symlinked spelling (e.g. ConfigDir itself is a symlink, or the user maintains an alias tree) would have their key silently deleted by the legacy-dir scrub — the gate thought the key lived elsewhere because the two spellings didn't match lexically. Fix: resolvePathForComparison tries filepath.EvalSymlinks first, falls back to filepath.Abs when the path doesn't exist yet (new install, pre-first-Open). Both sides of the sameDirOrParent comparison now use this helper, so a symlinked key + canonical dir (or the reverse) lands in the same physical path before the Rel check. Tests added in this commit: internal/daemon/ssh_client_config_test.go TestSameDirOrParentHandlesSymlinks — symlinked-key + canonical-dir and the reverse are both reported "inside"; unrelated paths stay out. Skips if the filesystem doesn't support symlinks. internal/config/config_test.go TestLoadNormalizesAbsoluteSSHKeyPath — trailing slash, duplicate slashes, dot segments all collapse via filepath.Clean, so two spellings of the same path compare equal downstream. TestEnsureDefaultSSHKeyRejectsCorruptExistingFile — regression guard against a future "regenerate if invalid" patch that would silently nuke a real user key. TestResolveSSHKeyPathRejectsEmptySSHDirAndStateDir — pins the absolute-path guard that stops a bad layout from scribbling into cwd (this was the test that caught the stray internal/config/ssh/ a few commits back). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
380 lines
12 KiB
Go
380 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 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 <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)
|
|
}
|