banger/internal/daemon/ssh_client_config_test.go
Thales Maciel ae14b9499d
ssh: trust-on-first-use host key pinning everywhere
Guest host-key verification was off in all three SSH paths:

  * Go SSH (internal/guest/ssh.go) used ssh.InsecureIgnoreHostKey
  * `banger vm ssh` passed StrictHostKeyChecking=no
    + UserKnownHostsFile=/dev/null
  * `~/.ssh/config` Host *.vm shipped the same posture into the
    user's global config

Now each path verifies against a banger-owned known_hosts file at
`~/.local/state/banger/ssh/known_hosts` with TOFU semantics:

  * First dial to a VM pins the key.
  * Subsequent dials require an exact match. A mismatch fails with
    an explicit "possible MITM" error.
  * `vm delete` removes the entries so a future VM reusing the IP
    or name re-pins cleanly.
  * The user's `~/.ssh/known_hosts` is untouched.

Changes:

  internal/guest/known_hosts.go (new) — OpenSSH-compatible parser,
    TOFUHostKeyCallback, RemoveKnownHosts. Process-wide mutex
    around the file.
  internal/guest/ssh.go — Dial and WaitForSSH grew a knownHostsPath
    parameter threaded through the callback. Empty path keeps the
    insecure callback (tests + throwaway tools only; documented).
  internal/daemon/{guest_sessions,session_attach,session_lifecycle,
    session_stream}.go — call sites pass d.layout.KnownHostsPath.
  internal/daemon/ssh_client_config.go — the ~/.ssh/config Host *.vm
    block now points at banger's known_hosts and uses
    StrictHostKeyChecking=accept-new. Missing path → fail closed.
  internal/daemon/vm_lifecycle.go — deleteVMLocked drops known_hosts
    entries for the VM's IP and DNS name via removeVMKnownHosts.
  internal/cli/banger.go — sshCommandArgs swaps StrictHostKeyChecking
    no + /dev/null for banger's file + accept-new. Path resolution
    failure falls through to StrictHostKeyChecking=yes.
  internal/paths/paths.go — Layout gains SSHDir + KnownHostsPath;
    Ensure creates SSHDir at 0700.

Tests (internal/guest/known_hosts_test.go): pin on first use, accept
matching key on second dial, reject mismatch, empty path skips
checking, RemoveKnownHosts drops the entry, re-pin works after
remove. Existing daemon + cli tests updated to assert the new
posture and regression-guard against the old flags.

Live verified: vm run writes the pin to banger's known_hosts at 0600
inside a 0700 dir; banger vm ssh + ssh root@<vm>.vm both succeed
using the pin; vm delete clears it.
2026-04-19 16:46:03 -03:00

108 lines
3.2 KiB
Go

package daemon
import (
"os"
"path/filepath"
"strings"
"testing"
"banger/internal/paths"
)
func TestSyncVMSSHClientConfigCreatesManagedBlock(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
knownHostsPath := filepath.Join(homeDir, ".local", "state", "banger", "ssh", "known_hosts")
layout := paths.Layout{
ConfigDir: filepath.Join(homeDir, ".config", "banger"),
KnownHostsPath: knownHostsPath,
}
keyPath := filepath.Join(layout.ConfigDir, "ssh", "id_ed25519")
if err := syncVMSSHClientConfig(layout, keyPath); err != nil {
t.Fatalf("syncVMSSHClientConfig: %v", err)
}
userConfigPath := filepath.Join(homeDir, ".ssh", "config")
userConfig, err := os.ReadFile(userConfigPath)
if err != nil {
t.Fatalf("ReadFile(user config): %v", err)
}
userContent := string(userConfig)
if !strings.Contains(userContent, vmSSHConfigIncludeBegin) {
t.Fatalf("user config = %q, want begin marker", userContent)
}
for _, want := range []string{
"Host *.vm",
"User root",
"IdentityFile " + keyPath,
"IdentitiesOnly yes",
"BatchMode yes",
"PasswordAuthentication no",
"UserKnownHostsFile " + knownHostsPath,
"StrictHostKeyChecking accept-new",
} {
if !strings.Contains(userContent, want) {
t.Fatalf("user config = %q, want %q", userContent, want)
}
}
// Regression: the legacy posture (StrictHostKeyChecking no +
// UserKnownHostsFile /dev/null) must never reappear.
for _, must := range []string{
"StrictHostKeyChecking no",
"UserKnownHostsFile /dev/null",
} {
if strings.Contains(userContent, must) {
t.Fatalf("user config leaked legacy posture %q:\n%s", must, userContent)
}
}
}
func TestSyncVMSSHClientConfigReplacesManagedIncludeBlock(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
layout := paths.Layout{
ConfigDir: filepath.Join(homeDir, ".config", "banger"),
}
keyPath := filepath.Join(layout.ConfigDir, "ssh", "id_ed25519")
sshDir := filepath.Join(homeDir, ".ssh")
if err := os.MkdirAll(sshDir, 0o700); err != nil {
t.Fatalf("MkdirAll(.ssh): %v", err)
}
initial := strings.Join([]string{
"ServerAliveInterval 120",
"",
vmSSHConfigIncludeBegin,
"Include /tmp/old-banger-config",
vmSSHConfigIncludeEnd,
"",
"Host other",
" HostName 192.0.2.5",
"",
}, "\n")
if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(initial), 0o644); err != nil {
t.Fatalf("WriteFile(user config): %v", err)
}
if err := syncVMSSHClientConfig(layout, keyPath); err != nil {
t.Fatalf("syncVMSSHClientConfig: %v", err)
}
userConfig, err := os.ReadFile(filepath.Join(sshDir, "config"))
if err != nil {
t.Fatalf("ReadFile(user config): %v", err)
}
userContent := string(userConfig)
if strings.Count(userContent, vmSSHConfigIncludeBegin) != 1 {
t.Fatalf("user config = %q, want one managed block", userContent)
}
if !strings.Contains(userContent, "ServerAliveInterval 120") || !strings.Contains(userContent, "Host other") {
t.Fatalf("user config = %q, want existing entries preserved", userContent)
}
if !strings.Contains(userContent, "Host *.vm") || !strings.Contains(userContent, "IdentityFile "+keyPath) {
t.Fatalf("user config = %q, want refreshed managed vm block", userContent)
}
}