feat(proxmox): populate Result.HostKeyFingerprint (T02.7, REQ-058)

---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
This commit is contained in:
Jon Chery
2026-08-04 11:48:00 +00:00
parent 8b0cbe10ae
commit 325a5662f4
2 changed files with 86 additions and 19 deletions
+31 -11
View File
@@ -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
+55 -8
View File
@@ -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)
}
}