diff --git a/internal/proxmox/bootstrap.go b/internal/proxmox/bootstrap.go index e797503..16b0ebd 100644 --- a/internal/proxmox/bootstrap.go +++ b/internal/proxmox/bootstrap.go @@ -136,15 +136,16 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) { // on first connect WITHOUT writing the captured key, so the first // `orca node join --type proxmox` always failed. sshAddr := fmt.Sprintf("%s:%d", opts.Host, opts.SSHPort) + var capturedHostKey ssh.PublicKey var hostKeyCallback ssh.HostKeyCallback if opts.HostKeyFingerprint != "" { - cb, err := pinnedHostKeyCallback(opts.HostKeyFingerprint) + cb, err := pinnedHostKeyCallback(opts.HostKeyFingerprint, &capturedHostKey) if err != nil { return nil, fmt.Errorf("host-key fingerprint: %w", err) } hostKeyCallback = cb } else { - cb, err := tofuHostKeyCallback(sshAddr) + cb, err := tofuHostKeyCallback(sshAddr, &capturedHostKey) if err != nil { return nil, fmt.Errorf("tofu host-key callback: %w", err) } @@ -170,10 +171,16 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) { sessionRunner = &sshSessionRunner{client: conn} } + hostKeyFP := "" + if capturedHostKey != nil { + hostKeyFP = security.SSHFingerprintSHA256(capturedHostKey) + } + log.Info("proxmox.ssh_connected", slog.String("event", "proxmox.ssh_connected"), slog.String("host", opts.Host), slog.String("ssh_user", opts.SSHUser), + slog.String("host_key_fingerprint", hostKeyFP), ) // Step 3: Deploy orca pubkey to ~orca/.ssh/authorized_keys (idempotent). @@ -220,8 +227,9 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) { ) return &Result{ - NodeName: opts.Host, - NodeAddress: opts.Host + ":8443", + NodeName: opts.Host, + NodeAddress: opts.Host + ":8443", + HostKeyFingerprint: hostKeyFP, }, nil } @@ -232,8 +240,9 @@ var sshDialer sshDialerType = defaultSSHDialer{} // pinnedHostKeyCallback returns an ssh.HostKeyCallback that pins the // server's host key to the operator-supplied SHA256:base64 fingerprint // (REQ-058, AD-028). It validates the `SHA256:` prefix up front (D-045) -// and fails closed on any mismatch. -func pinnedHostKeyCallback(expectedSHA256Base64 string) (ssh.HostKeyCallback, error) { +// and fails closed on any mismatch. The capturedKey out-param records +// the verified server key so the caller can populate Result. +func pinnedHostKeyCallback(expectedSHA256Base64 string, capturedKey *ssh.PublicKey) (ssh.HostKeyCallback, error) { if !strings.HasPrefix(expectedSHA256Base64, "SHA256:") { return nil, fmt.Errorf("pinnedHostKeyCallback: fingerprint must be SHA256:-prefixed (D-045), got %q", expectedSHA256Base64) } @@ -242,6 +251,9 @@ func pinnedHostKeyCallback(expectedSHA256Base64 string) (ssh.HostKeyCallback, er if got != expectedSHA256Base64 { return fmt.Errorf("REQ-058 host-key fingerprint mismatch: pinned=%s server=%s", expectedSHA256Base64, got) } + if capturedKey != nil { + *capturedKey = key + } return nil }, nil } @@ -251,11 +263,13 @@ func pinnedHostKeyCallback(expectedSHA256Base64 string) (ssh.HostKeyCallback, er // (D-035). On a host-unknown KeyError{Want:[]} it writes the // server-presented key to certpaths.KnownHostsPath() atomically // (security.WriteAtomic, AD-029) and allows the dial to proceed; on a -// mismatch (Want non-empty) it fails closed (MITM detection). This -// fixes the v0.6 ship-defect where knownhosts.New returned -// KeyError{Want:[]} on first connect WITHOUT writing the captured key, -// so the first `orca node join --type proxmox` always failed. -func tofuHostKeyCallback(addr string) (ssh.HostKeyCallback, error) { +// mismatch (Want non-empty) it fails closed (MITM detection). The +// capturedKey out-param records the verified/captured server key so +// the caller can populate Result. This fixes the v0.6 ship-defect +// where knownhosts.New returned KeyError{Want:[]} on first connect +// WITHOUT writing the captured key, so the first +// `orca node join --type proxmox` always failed. +func tofuHostKeyCallback(addr string, capturedKey *ssh.PublicKey) (ssh.HostKeyCallback, error) { cb, err := knownhosts.New(certpaths.KnownHostsPath()) if err != nil { return nil, err @@ -263,6 +277,9 @@ func tofuHostKeyCallback(addr string) (ssh.HostKeyCallback, error) { return func(hostname string, remote net.Addr, key ssh.PublicKey) error { err := cb(hostname, remote, key) if err == nil { + if capturedKey != nil { + *capturedKey = key + } return nil } var keyErr *knownhosts.KeyError @@ -280,6 +297,9 @@ func tofuHostKeyCallback(addr string) (ssh.HostKeyCallback, error) { if writeErr := security.WriteAtomic(path, 0o600, updated); writeErr != nil { return fmt.Errorf("tofu write known_hosts: %w", writeErr) } + if capturedKey != nil { + *capturedKey = key + } return nil } return err diff --git a/internal/proxmox/bootstrap_test.go b/internal/proxmox/bootstrap_test.go index 30cfc3e..0ccba34 100644 --- a/internal/proxmox/bootstrap_test.go +++ b/internal/proxmox/bootstrap_test.go @@ -10,6 +10,7 @@ import ( "net" "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -507,13 +508,17 @@ func TestPinnedHostKeyCallback_Match(t *testing.T) { } expectedFP := security.SSHFingerprintSHA256(hostKey) - cb, err := pinnedHostKeyCallback(expectedFP) + var captured ssh.PublicKey + cb, err := pinnedHostKeyCallback(expectedFP, &captured) if err != nil { t.Fatalf("pinnedHostKeyCallback: %v", err) } if err := cb(host+":"+port, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil { t.Errorf("match callback returned error: %v", err) } + if !bytes.Equal(captured.Marshal(), hostKey.Marshal()) { + t.Error("captured key does not match server host key") + } } // TestPinnedHostKeyCallback_Mismatch verifies the pinned callback fails @@ -527,7 +532,7 @@ func TestPinnedHostKeyCallback_Mismatch(t *testing.T) { t.Fatal("server host key is nil") } - cb, err := pinnedHostKeyCallback("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + cb, err := pinnedHostKeyCallback("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", nil) if err != nil { t.Fatalf("pinnedHostKeyCallback: %v", err) } @@ -543,7 +548,7 @@ func TestPinnedHostKeyCallback_Mismatch(t *testing.T) { // TestPinnedHostKeyCallback_RejectsRawHex verifies the constructor // rejects a non-SHA256:-prefixed fingerprint (T02.5, D-045). func TestPinnedHostKeyCallback_RejectsRawHex(t *testing.T) { - _, err := pinnedHostKeyCallback("abcdef0123456789") + _, err := pinnedHostKeyCallback("abcdef0123456789", nil) if err == nil { t.Fatal("expected error for raw hex fingerprint, got nil") } @@ -567,7 +572,7 @@ func TestTOFUHostKeyCallback_FirstConnectCapturesKey(t *testing.T) { t.Fatal("server host key is nil") } - cb, err := tofuHostKeyCallback(addr) + cb, err := tofuHostKeyCallback(addr, nil) if err != nil { t.Fatalf("tofuHostKeyCallback: %v", err) } @@ -604,7 +609,7 @@ func TestTOFUHostKeyCallback_SecondConnectMatches(t *testing.T) { } // First connect: capture + write. - cb1, err := tofuHostKeyCallback(addr) + cb1, err := tofuHostKeyCallback(addr, nil) if err != nil { t.Fatalf("tofuHostKeyCallback #1: %v", err) } @@ -613,7 +618,7 @@ func TestTOFUHostKeyCallback_SecondConnectMatches(t *testing.T) { } // Second connect: the fresh knownhosts.New reads the written key. - cb2, err := tofuHostKeyCallback(addr) + cb2, err := tofuHostKeyCallback(addr, nil) if err != nil { t.Fatalf("tofuHostKeyCallback #2: %v", err) } @@ -637,7 +642,7 @@ func TestTOFUHostKeyCallback_MismatchFails(t *testing.T) { } // Capture the real key first so known_hosts is populated. - cb1, err := tofuHostKeyCallback(addr) + cb1, err := tofuHostKeyCallback(addr, nil) if err != nil { t.Fatalf("tofuHostKeyCallback #1: %v", err) } @@ -655,7 +660,7 @@ func TestTOFUHostKeyCallback_MismatchFails(t *testing.T) { t.Fatalf("new pub: %v", err) } - cb2, err := tofuHostKeyCallback(addr) + cb2, err := tofuHostKeyCallback(addr, nil) if err != nil { t.Fatalf("tofuHostKeyCallback #2: %v", err) } @@ -664,3 +669,45 @@ func TestTOFUHostKeyCallback_MismatchFails(t *testing.T) { t.Fatal("expected mismatch error, got nil") } } + +// TestBootstrapProxmox_PopulatesHostKeyFingerprint verifies that after +// a successful bootstrap via TOFU, Result.HostKeyFingerprint is +// non-empty and SHA256:-prefixed (T02.7). +func TestBootstrapProxmox_PopulatesHostKeyFingerprint(t *testing.T) { + srv := newFakeSSHServer(t) + defer 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 + defer func() { sshDialer = orig }() + origRunner := sessionRunner + defer func() { sessionRunner = origRunner }() + sessionRunner = nil + // Use the real dialer so the TOFU HostKeyCallback actually runs + // against the fake server (a static dialer with an insecure client + // would bypass the callback and leave HostKeyFingerprint empty). + sshDialer = defaultSSHDialer{} + + host, port, _ := net.SplitHostPort(srv.addr()) + portNum, _ := strconv.Atoi(port) + + result, err := BootstrapProxmox(t.Context(), Options{ + Host: host, + Password: "pw", + SSHPort: portNum, + }) + if err != nil { + t.Fatalf("BootstrapProxmox: %v", err) + } + if result.HostKeyFingerprint == "" { + t.Fatal("Result.HostKeyFingerprint is empty") + } + if !strings.HasPrefix(result.HostKeyFingerprint, "SHA256:") { + t.Errorf("Result.HostKeyFingerprint = %q, want SHA256: prefix", result.HostKeyFingerprint) + } +}