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.
108 lines
3.2 KiB
Go
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)
|
|
}
|
|
}
|