diff --git a/internal/proxmox/bootstrap_test.go b/internal/proxmox/bootstrap_test.go index c5c4451..f44017a 100644 --- a/internal/proxmox/bootstrap_test.go +++ b/internal/proxmox/bootstrap_test.go @@ -784,3 +784,265 @@ func TestResetHostKey_EmptyHostErrors(t *testing.T) { t.Error("expected error for empty host, got nil") } } + +// bootstrapE2ESetup wires the real dialer against a fake SSH server so +// the full HostKeyCallback path (pinned or TOFU) runs end-to-end through +// BootstrapProxmox. Returns the host, port, and server (for fingerprint +// computation). The known_hosts file is created empty in the temp +// ORCA_HOME. +func bootstrapE2ESetup(t *testing.T) (srv *fakeSSHServer, host, port string) { + t.Helper() + srv = newFakeSSHServer(t) + t.Cleanup(srv.close) + home := t.TempDir() + t.Setenv("ORCA_HOME", home) + if err := os.WriteFile(filepath.Join(home, "known_hosts"), []byte{}, 0o600); err != nil { + t.Fatalf("create known_hosts: %v", err) + } + orig := sshDialer + t.Cleanup(func() { sshDialer = orig }) + origRunner := sessionRunner + t.Cleanup(func() { sessionRunner = origRunner }) + sessionRunner = nil + sshDialer = defaultSSHDialer{} + host, port, _ = net.SplitHostPort(srv.addr()) + return srv, host, port +} + +// TestBootstrapE2E_PinnedFingerprintCorrect verifies that +// --host-key-fingerprint with the correct pin (T02.10 case 1) succeeds +// end-to-end and Result.HostKeyFingerprint equals the pinned value. +func TestBootstrapE2E_PinnedFingerprintCorrect(t *testing.T) { + srv, host, port := bootstrapE2ESetup(t) + hostKey := srv.hostPublicKey() + if hostKey == nil { + t.Fatal("server host key is nil") + } + pin := security.SSHFingerprintSHA256(hostKey) + portNum, _ := strconv.Atoi(port) + + result, err := BootstrapProxmox(t.Context(), Options{ + Host: host, + Password: "pw", + SSHPort: portNum, + HostKeyFingerprint: pin, + }) + if err != nil { + t.Fatalf("BootstrapProxmox with correct pin: %v", err) + } + if result.HostKeyFingerprint != pin { + t.Errorf("Result.HostKeyFingerprint = %q, want %q (pinned value)", + result.HostKeyFingerprint, pin) + } +} + +// TestBootstrapE2E_PinnedFingerprintWrong verifies that +// --host-key-fingerprint with a wrong pin (T02.10 case 2) fails fast +// with the REQ-058 mismatch error, before any SSH session commands run. +func TestBootstrapE2E_PinnedFingerprintWrong(t *testing.T) { + _, host, port := bootstrapE2ESetup(t) + portNum, _ := strconv.Atoi(port) + wrong := "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + _, err := BootstrapProxmox(t.Context(), Options{ + Host: host, + Password: "pw", + SSHPort: portNum, + HostKeyFingerprint: wrong, + }) + if err == nil { + t.Fatal("expected error for wrong pin, got nil") + } + if !strings.Contains(err.Error(), "REQ-058") { + t.Errorf("error should mention REQ-058, got: %v", err) + } +} + +// TestBootstrapE2E_TOFUFirstConnectCapturesKey verifies that with no +// --host-key-fingerprint on a first connect (empty known_hosts) (T02.10 +// case 3) the TOFU callback captures the key, writes known_hosts, and +// bootstrap succeeds — exercised end-to-end through BootstrapProxmox. +func TestBootstrapE2E_TOFUFirstConnectCapturesKey(t *testing.T) { + srv, host, port := bootstrapE2ESetup(t) + hostKey := srv.hostPublicKey() + if hostKey == nil { + t.Fatal("server host key is nil") + } + portNum, _ := strconv.Atoi(port) + home := os.Getenv("ORCA_HOME") + knownHostsPath := filepath.Join(home, "known_hosts") + + before, _ := os.ReadFile(knownHostsPath) + if len(before) != 0 { + t.Fatalf("precondition: known_hosts not empty: %q", before) + } + + result, err := BootstrapProxmox(t.Context(), Options{ + Host: host, + Password: "pw", + SSHPort: portNum, + }) + if err != nil { + t.Fatalf("BootstrapProxmox first connect: %v", err) + } + + data, err := os.ReadFile(knownHostsPath) + if err != nil { + t.Fatalf("read known_hosts: %v", err) + } + if len(data) == 0 { + t.Fatal("known_hosts empty — TOFU did not capture the key end-to-end") + } + expectedFP := security.SSHFingerprintSHA256(hostKey) + if result.HostKeyFingerprint != expectedFP { + t.Errorf("Result.HostKeyFingerprint = %q, want %q", result.HostKeyFingerprint, expectedFP) + } +} + +// TestBootstrapE2E_TOFUSecondConnectMatches verifies that a second +// connect (known_hosts already has the key from the first connect) +// (T02.10 case 4) matches and succeeds end-to-end. +func TestBootstrapE2E_TOFUSecondConnectMatches(t *testing.T) { + srv, host, port := bootstrapE2ESetup(t) + portNum, _ := strconv.Atoi(port) + + for i := 0; i < 2; i++ { + sessionRunner = nil + if _, err := BootstrapProxmox(t.Context(), Options{ + Host: host, + Password: "pw", + SSHPort: portNum, + }); err != nil { + t.Fatalf("bootstrap run %d: %v", i+1, err) + } + } + _ = srv +} + +// TestBootstrapE2E_TOFUMismatchFails verifies that when known_hosts has +// a different key (T02.10 case 5) the second connect fails with a +// mismatch (MITM detection) — end-to-end through BootstrapProxmox. +func TestBootstrapE2E_TOFUMismatchFails(t *testing.T) { + srv, host, port := bootstrapE2ESetup(t) + hostKey := srv.hostPublicKey() + if hostKey == nil { + t.Fatal("server host key is nil") + } + portNum, _ := strconv.Atoi(port) + home := os.Getenv("ORCA_HOME") + knownHostsPath := filepath.Join(home, "known_hosts") + + altPub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("ed25519 gen: %v", err) + } + altKey, err := ssh.NewPublicKey(altPub) + if err != nil { + t.Fatalf("new pub: %v", err) + } + addr := host + ":" + port + altLine := knownhosts.Line([]string{knownhosts.Normalize(addr)}, altKey) + if err := os.WriteFile(knownHostsPath, []byte(altLine+"\n"), 0o600); err != nil { + t.Fatalf("write known_hosts: %v", err) + } + + _, err = BootstrapProxmox(t.Context(), Options{ + Host: host, + Password: "pw", + SSHPort: portNum, + }) + if err == nil { + t.Fatal("expected MITM/mismatch error, got nil") + } + if !strings.Contains(err.Error(), "ssh dial") { + t.Errorf("error should mention ssh dial, got: %v", err) + } +} + +// TestBootstrapE2E_PrePopulatedKnownHostsMatches verifies the v0.6→v0.8 +// migration path (T02.10 case 7): a known_hosts entry written by a prior +// join (simulating a v0.6 install) is matched on second-connect without +// re-capture, end-to-end through BootstrapProxmox. +func TestBootstrapE2E_PrePopulatedKnownHostsMatches(t *testing.T) { + srv, host, port := bootstrapE2ESetup(t) + hostKey := srv.hostPublicKey() + if hostKey == nil { + t.Fatal("server host key is nil") + } + portNum, _ := strconv.Atoi(port) + home := os.Getenv("ORCA_HOME") + knownHostsPath := filepath.Join(home, "known_hosts") + + addr := host + ":" + port + preLine := knownhosts.Line([]string{knownhosts.Normalize(addr)}, hostKey) + if err := os.WriteFile(knownHostsPath, []byte(preLine+"\n"), 0o600); err != nil { + t.Fatalf("write known_hosts: %v", err) + } + + result, err := BootstrapProxmox(t.Context(), Options{ + Host: host, + Password: "pw", + SSHPort: portNum, + }) + if err != nil { + t.Fatalf("BootstrapProxmox on pre-populated known_hosts: %v", err) + } + expectedFP := security.SSHFingerprintSHA256(hostKey) + if result.HostKeyFingerprint != expectedFP { + t.Errorf("Result.HostKeyFingerprint = %q, want %q", result.HostKeyFingerprint, expectedFP) + } +} + +// TestBootstrapE2E_KeyResetThenRePin verifies T02.10 case 6: after +// ResetHostKey removes the known_hosts entry, the next BootstrapProxmox +// connect re-pins the key via TOFU and succeeds end-to-end. The reset +// target is the known_hosts entry key (host:port, normalized), which +// matches how the cli resolves the host from a proxmox node's address +// for non-default ports. +func TestBootstrapE2E_KeyResetThenRePin(t *testing.T) { + srv, host, port := bootstrapE2ESetup(t) + portNum, _ := strconv.Atoi(port) + home := os.Getenv("ORCA_HOME") + knownHostsPath := filepath.Join(home, "known_hosts") + addr := host + ":" + port + + // First connect: TOFU captures + writes known_hosts. + sessionRunner = nil + if _, err := BootstrapProxmox(t.Context(), Options{ + Host: host, + Password: "pw", + SSHPort: portNum, + }); err != nil { + t.Fatalf("first bootstrap: %v", err) + } + before, _ := os.ReadFile(knownHostsPath) + if len(before) == 0 { + t.Fatal("precondition: known_hosts empty after first connect") + } + + // Reset: known_hosts entry removed. Pass the full addr (host:port) + // so Normalize produces the same bracketed form the TOFU callback + // wrote for a non-default port. + if err := ResetHostKey(addr); err != nil { + t.Fatalf("ResetHostKey: %v", err) + } + after, _ := os.ReadFile(knownHostsPath) + if strings.Contains(string(after), knownhosts.Normalize(addr)) { + t.Fatalf("known_hosts still contains host after reset: %q", after) + } + + // Next connect re-pins via TOFU + succeeds. + sessionRunner = nil + if _, err := BootstrapProxmox(t.Context(), Options{ + Host: host, + Password: "pw", + SSHPort: portNum, + }); err != nil { + t.Fatalf("re-pin bootstrap after reset: %v", err) + } + rePinned, _ := os.ReadFile(knownHostsPath) + if !strings.Contains(string(rePinned), knownhosts.Normalize(addr)) { + t.Fatalf("known_hosts not re-populated on next connect: %q", rePinned) + } + _ = srv +}