package guest import ( "crypto/ed25519" "crypto/rand" "net" "os" "path/filepath" "strings" "testing" "golang.org/x/crypto/ssh" ) // makeTestHostKey generates a fresh ed25519 key and returns the // ssh.PublicKey the server would present during a handshake. func makeTestHostKey(t *testing.T) ssh.PublicKey { t.Helper() pub, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("GenerateKey: %v", err) } sshPub, err := ssh.NewPublicKey(pub) if err != nil { t.Fatalf("NewPublicKey: %v", err) } return sshPub } func TestTOFUHostKeyCallbackPinsOnFirstUse(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "known_hosts") cb := TOFUHostKeyCallback(path) key := makeTestHostKey(t) addr := &net.TCPAddr{IP: net.ParseIP("172.16.0.5"), Port: 22} if err := cb("172.16.0.5:22", addr, key); err != nil { t.Fatalf("first-use callback: %v", err) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile: %v", err) } content := string(data) if !strings.Contains(content, "172.16.0.5") { t.Errorf("known_hosts missing host:\n%s", content) } if !strings.Contains(content, key.Type()) { t.Errorf("known_hosts missing key type:\n%s", content) } } func TestTOFUHostKeyCallbackAcceptsMatch(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "known_hosts") cb := TOFUHostKeyCallback(path) key := makeTestHostKey(t) addr := &net.TCPAddr{IP: net.ParseIP("172.16.0.6"), Port: 22} if err := cb("172.16.0.6:22", addr, key); err != nil { t.Fatalf("first-use: %v", err) } // Same key, second dial: must succeed. if err := cb("172.16.0.6:22", addr, key); err != nil { t.Fatalf("second dial with matching key: %v", err) } } func TestTOFUHostKeyCallbackRejectsMismatch(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "known_hosts") cb := TOFUHostKeyCallback(path) addr := &net.TCPAddr{IP: net.ParseIP("172.16.0.7"), Port: 22} original := makeTestHostKey(t) if err := cb("172.16.0.7:22", addr, original); err != nil { t.Fatalf("pin original: %v", err) } impostor := makeTestHostKey(t) err := cb("172.16.0.7:22", addr, impostor) if err == nil { t.Fatal("expected mismatch error, got nil") } if !strings.Contains(err.Error(), "does not match") { t.Errorf("error = %v, want message about mismatch", err) } } func TestTOFUEmptyPathDisablesVerification(t *testing.T) { t.Parallel() // Empty path returns an Insecure callback — useful for tests / // throwaway tools. Document behaviour so the fallback doesn't // silently regress to "always verify but without a file". cb := TOFUHostKeyCallback("") addr := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 22} if err := cb("127.0.0.1:22", addr, makeTestHostKey(t)); err != nil { t.Fatalf("empty-path callback should accept: %v", err) } } func TestRemoveKnownHostsDropsEntry(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "known_hosts") cb := TOFUHostKeyCallback(path) keep := makeTestHostKey(t) drop := makeTestHostKey(t) if err := cb("172.16.0.10:22", &net.TCPAddr{IP: net.ParseIP("172.16.0.10"), Port: 22}, keep); err != nil { t.Fatalf("pin keep: %v", err) } if err := cb("172.16.0.11:22", &net.TCPAddr{IP: net.ParseIP("172.16.0.11"), Port: 22}, drop); err != nil { t.Fatalf("pin drop: %v", err) } if err := RemoveKnownHosts(path, "172.16.0.11"); err != nil { t.Fatalf("RemoveKnownHosts: %v", err) } data, _ := os.ReadFile(path) content := string(data) if !strings.Contains(content, "172.16.0.10") { t.Errorf("kept entry missing:\n%s", content) } if strings.Contains(content, "172.16.0.11") { t.Errorf("dropped entry still present:\n%s", content) } } func TestRemoveKnownHostsMissingFileIsNoOp(t *testing.T) { t.Parallel() missing := filepath.Join(t.TempDir(), "absent") if err := RemoveKnownHosts(missing, "any"); err != nil { t.Fatalf("RemoveKnownHosts on missing: %v", err) } } func TestRemoveKnownHostsEmptyPathIsNoOp(t *testing.T) { t.Parallel() if err := RemoveKnownHosts("", "any"); err != nil { t.Fatalf("RemoveKnownHosts(empty): %v", err) } } // TestTOFURewritesAllowsReuseAfterRemove: after a VM is deleted and // its pin is cleared, a future VM reusing the same IP (with a fresh // host key) should re-pin cleanly, not fail the mismatch branch. func TestTOFURewritesAllowsReuseAfterRemove(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "known_hosts") cb := TOFUHostKeyCallback(path) addr := &net.TCPAddr{IP: net.ParseIP("172.16.0.15"), Port: 22} original := makeTestHostKey(t) if err := cb("172.16.0.15:22", addr, original); err != nil { t.Fatalf("pin original: %v", err) } // VM deleted → pin removed. if err := RemoveKnownHosts(path, "172.16.0.15"); err != nil { t.Fatalf("RemoveKnownHosts: %v", err) } // New VM, same IP, new host key. Must re-pin without error. replacement := makeTestHostKey(t) if err := cb("172.16.0.15:22", addr, replacement); err != nil { t.Fatalf("re-pin after remove: %v", err) } } func TestHostLookupKeyStripsPort(t *testing.T) { t.Parallel() if got := hostLookupKey("10.0.0.1:22", nil); got != "10.0.0.1" { t.Errorf("got %q, want 10.0.0.1", got) } if got := hostLookupKey("host.vm", nil); got != "host.vm" { t.Errorf("got %q, want host.vm", got) } addr := &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 22} if got := hostLookupKey("", addr); got != "1.2.3.4" { t.Errorf("fallback: got %q, want 1.2.3.4", got) } }