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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user