Files
orca/internal/proxmox/bootstrap_test.go
T
Jon Chery 325a5662f4 feat(proxmox): populate Result.HostKeyFingerprint (T02.7, REQ-058)
---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
2026-08-04 11:48:00 +00:00

714 lines
20 KiB
Go

package proxmox
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"errors"
"log/slog"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
"git.cloudinit.dev/coreci/orca/internal/security"
)
func TestSudoersContent(t *testing.T) {
content := sudoersContent("orca")
if !strings.Contains(content, "NOPASSWD: NOEXEC: /usr/bin/pct") {
t.Error("missing NOEXEC on pct (AD-020)")
}
if !strings.Contains(content, "NOPASSWD: NOEXEC: /usr/bin/qm") {
t.Error("missing NOEXEC on qm (AD-020)")
}
if !strings.Contains(content, "NOPASSWD: /usr/bin/apt-get") {
t.Error("missing NOPASSWD on apt-get")
}
if !strings.Contains(content, "NOPASSWD: /usr/bin/dpkg") {
t.Error("missing NOPASSWD on dpkg")
}
if strings.Contains(content, "NOEXEC: /usr/bin/apt-get") {
t.Error("apt-get must NOT have NOEXEC (breaks maintainer scripts)")
}
if strings.Contains(content, "NOEXEC: /usr/bin/dpkg") {
t.Error("dpkg must NOT have NOEXEC (breaks maintainer scripts)")
}
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
}
if strings.Contains(trimmed, "pvesh") {
t.Errorf("pvesh must be EXCLUDED from sudoers command lines (AD-020): %s", trimmed)
}
}
if !strings.HasPrefix(content, "# /etc/sudoers.d/orca") {
t.Error("missing managed-by-orca header")
}
if !strings.Contains(content, "orca ALL=(root)") {
t.Error("missing orca user in sudoers")
}
}
func TestSudoersContent_CustomUser(t *testing.T) {
content := sudoersContent("custom-orca")
if !strings.Contains(content, "custom-orca ALL=(root)") {
t.Error("missing custom-orca user in sudoers")
}
}
func TestOrcaOperatorPrivileges(t *testing.T) {
privs := strings.Fields(OrcaOperatorPrivileges)
expected := map[string]bool{
"VM.Audit": true,
"Datastore.AllocateSpace": true,
"SDN.Use": true,
}
if len(privs) != 3 {
t.Errorf("expected 3 privileges, got %d: %v", len(privs), privs)
}
for _, p := range privs {
if !expected[p] {
t.Errorf("unexpected privilege %q", p)
}
}
}
func TestBootstrapProxmox_Validation(t *testing.T) {
ctx := context.Background()
_, err := BootstrapProxmox(ctx, Options{Password: "pw"})
if err == nil || !strings.Contains(err.Error(), "host is required") {
t.Errorf("expected host-required error, got %v", err)
}
_, err = BootstrapProxmox(ctx, Options{Host: "10.0.0.1"})
if err == nil || !strings.Contains(err.Error(), "password is required") {
t.Errorf("expected password-required error, got %v", err)
}
}
func TestDefaultOptions(t *testing.T) {
if DefaultProxmoxUser != "orca" {
t.Errorf("DefaultProxmoxUser = %q, want orca", DefaultProxmoxUser)
}
if DefaultProxmoxRole != "OrcaOperator" {
t.Errorf("DefaultProxmoxRole = %q, want OrcaOperator", DefaultProxmoxRole)
}
if DefaultSSHPort != 22 {
t.Errorf("DefaultSSHPort = %d, want 22", DefaultSSHPort)
}
}
type mockSSHDialer struct {
client *ssh.Client
err error
calls int
lastAddr string
lastCfg *ssh.ClientConfig
}
func (m *mockSSHDialer) DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
m.calls++
m.lastAddr = addr
m.lastCfg = config
if m.err != nil {
return nil, m.err
}
return m.client, nil
}
func setupORCAHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
knownHosts := filepath.Join(dir, "known_hosts")
if err := os.WriteFile(knownHosts, []byte{}, 0o600); err != nil {
t.Fatalf("create known_hosts: %v", err)
}
return dir
}
func TestBootstrapProxmox_SSHAuthFailure(t *testing.T) {
orig := sshDialer
defer func() { sshDialer = orig }()
sshDialer = &mockSSHDialer{err: errors.New("ssh: handshake failed: ssh: unable to authenticate")}
setupORCAHome(t)
_, err := BootstrapProxmox(context.Background(), Options{
Host: "10.0.0.1",
Password: "pw",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "ssh") {
t.Errorf("error should mention ssh, got: %v", err)
}
if !strings.Contains(err.Error(), "ssh dial") {
t.Errorf("error should mention ssh dial, got: %v", err)
}
}
func TestBootstrapProxmox_SSHDialCalledWithCorrectAddr(t *testing.T) {
orig := sshDialer
defer func() { sshDialer = orig }()
dialer := &mockSSHDialer{err: errors.New("connection refused")}
sshDialer = dialer
setupORCAHome(t)
_, _ = BootstrapProxmox(context.Background(), Options{
Host: "10.0.0.42",
Password: "pw",
SSHPort: 2222,
})
if dialer.calls != 1 {
t.Errorf("dialer calls = %d, want 1", dialer.calls)
}
if dialer.lastAddr != "10.0.0.42:2222" {
t.Errorf("dial addr = %q, want 10.0.0.42:2222", dialer.lastAddr)
}
}
func TestBootstrapProxmox_DefaultSSHPort(t *testing.T) {
orig := sshDialer
defer func() { sshDialer = orig }()
dialer := &mockSSHDialer{err: errors.New("connection refused")}
sshDialer = dialer
setupORCAHome(t)
_, _ = BootstrapProxmox(context.Background(), Options{
Host: "10.0.0.99",
Password: "pw",
})
if dialer.lastAddr != "10.0.0.99:22" {
t.Errorf("dial addr = %q, want 10.0.0.99:22 (default port)", dialer.lastAddr)
}
}
func TestBootstrapProxmox_CustomSSHUser(t *testing.T) {
orig := sshDialer
defer func() { sshDialer = orig }()
dialer := &mockSSHDialer{err: errors.New("connection refused")}
sshDialer = dialer
setupORCAHome(t)
_, _ = BootstrapProxmox(context.Background(), Options{
Host: "10.0.0.1",
Password: "pw",
SSHUser: "custom-admin",
})
if dialer.calls != 1 {
t.Errorf("dialer calls = %d, want 1", dialer.calls)
}
if dialer.lastCfg == nil || dialer.lastCfg.User != "custom-admin" {
t.Errorf("ssh user not propagated, got %+v", dialer.lastCfg)
}
}
func TestBootstrapProxmox_SSHKeyGenerated(t *testing.T) {
orig := sshDialer
defer func() { sshDialer = orig }()
sshDialer = &mockSSHDialer{err: errors.New("connection refused")}
dir := setupORCAHome(t)
_, _ = BootstrapProxmox(context.Background(), Options{
Host: "10.0.0.1",
Password: "pw",
})
keyPath := filepath.Join(dir, "orca_ssh_key")
pubPath := filepath.Join(dir, "orca_ssh_key.pub")
if _, err := os.Stat(keyPath); err != nil {
t.Errorf("SSH key not generated at %s: %v", keyPath, err)
}
if _, err := os.Stat(pubPath); err != nil {
t.Errorf("SSH pub not generated at %s: %v", pubPath, err)
}
}
func TestBootstrapProxmox_KnownHostsFileCreated(t *testing.T) {
orig := sshDialer
defer func() { sshDialer = orig }()
sshDialer = &mockSSHDialer{err: errors.New("connection refused")}
dir := setupORCAHome(t)
_, _ = BootstrapProxmox(context.Background(), Options{
Host: "10.0.0.1",
Password: "pw",
})
knownHosts := filepath.Join(dir, "known_hosts")
if _, err := os.Stat(knownHosts); err != nil {
t.Errorf("known_hosts not created at %s: %v", knownHosts, err)
}
}
func TestBootstrapProxmox_NilLogger(t *testing.T) {
orig := sshDialer
defer func() { sshDialer = orig }()
sshDialer = &mockSSHDialer{err: errors.New("connection refused")}
setupORCAHome(t)
defer func() {
if r := recover(); r != nil {
t.Fatalf("nil logger panicked: %v", r)
}
}()
_, _ = BootstrapProxmox(context.Background(), Options{
Host: "10.0.0.1",
Password: "pw",
Logger: nil,
})
}
func TestBootstrapProxmox_CustomLogger(t *testing.T) {
orig := sshDialer
defer func() { sshDialer = orig }()
sshDialer = &mockSSHDialer{err: errors.New("connection refused")}
setupORCAHome(t)
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, nil))
defer func() {
if r := recover(); r != nil {
t.Fatalf("custom logger panicked: %v", r)
}
}()
_, _ = BootstrapProxmox(context.Background(), Options{
Host: "10.0.0.1",
Password: "pw",
Logger: log,
})
_ = buf.String()
}
func TestBootstrapProxmox_ContextCancelled(t *testing.T) {
orig := sshDialer
defer func() { sshDialer = orig }()
sshDialer = &mockSSHDialer{err: errors.New("connection refused")}
setupORCAHome(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := BootstrapProxmox(ctx, Options{
Host: "10.0.0.1",
Password: "pw",
})
if err == nil {
t.Fatal("expected error with cancelled context")
}
}
func TestDeployPubKey_EmptyPubLine(t *testing.T) {
err := deployPubKey("orca", "")
if err == nil {
t.Error("expected error for empty pub line")
}
if !strings.Contains(err.Error(), "empty pub line") {
t.Errorf("error should mention empty pub line, got: %v", err)
}
}
func TestDeployPubKey_WhitespaceOnlyPubLine(t *testing.T) {
err := deployPubKey("orca", " \n \t ")
if err == nil {
t.Error("expected error for whitespace-only pub line")
}
}
func TestBootstrapProxmox_FullFlow_IdempotentReRun(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 }()
host, _, _ := net.SplitHostPort(srv.addr())
sshDialer = &funcDialer{fn: func(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
return fakeSSHClient(t, srv), nil
}}
for i := 0; i < 2; i++ {
sessionRunner = nil
if _, err := BootstrapProxmox(t.Context(), Options{
Host: host,
Password: "pw",
}); err != nil {
t.Fatalf("bootstrap run %d: %v", i+1, err)
}
}
}
func TestBootstrapProxmox_FullFlow_NoPasswordInLogs(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
sshDialer = &staticDialer{client: fakeSSHClient(t, srv)}
host, _, _ := net.SplitHostPort(srv.addr())
var logBuf bytes.Buffer
_, err := BootstrapProxmox(t.Context(), Options{
Host: host,
Password: "super-secret-pw-12345",
Logger: slog.New(slog.NewTextHandler(&logBuf, nil)),
})
if err != nil {
t.Fatalf("BootstrapProxmox: %v", err)
}
out := logBuf.String()
if strings.Contains(out, "super-secret-pw-12345") {
t.Errorf("password leaked into logs (D-031): %s", out)
}
}
func TestBootstrapProxmox_FullFlow_ValidateSudoersFails(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
srv.forceSudoersInvalid = true
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
sshDialer = &staticDialer{client: fakeSSHClient(t, srv)}
host, _, _ := net.SplitHostPort(srv.addr())
_, err := BootstrapProxmox(t.Context(), Options{
Host: host,
Password: "pw",
})
if err == nil {
t.Fatal("expected error for invalid sudoers")
}
if !strings.Contains(err.Error(), "validate sudoers") {
t.Errorf("error should mention validate sudoers, got: %v", err)
}
}
func TestDefaultSSHDialer_DialContext_ConnectionRefused(t *testing.T) {
d := defaultSSHDialer{}
cfg := &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{ssh.Password("pw")},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 200 * time.Millisecond,
}
_, err := d.DialContext(context.Background(), "tcp", "127.0.0.1:1", cfg)
if err == nil {
t.Fatal("expected error for connection refused")
}
}
func TestBootstrapProxmox_FullFlow_CreateLinuxUserFails(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
sshDialer = &staticDialer{client: fakeSSHClient(t, srv)}
host, _, _ := net.SplitHostPort(srv.addr())
// ProxmoxUser=root exercises the /root home branch in deployPubKey.
_, err := BootstrapProxmox(t.Context(), Options{
Host: host,
Password: "pw",
ProxmoxUser: "root",
})
if err != nil {
t.Fatalf("BootstrapProxmox with ProxmoxUser=root: %v", err)
}
}
func TestSSHSessionRunner_CombinedOutput_NewSessionError(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
conn := fakeSSHClient(t, srv)
conn.Close()
r := &sshSessionRunner{client: conn}
_, err := r.CombinedOutput("echo hi")
if err == nil {
t.Fatal("expected error from NewSession on closed client")
}
if !strings.Contains(err.Error(), "new session") {
t.Errorf("error should mention new session, got: %v", err)
}
}
// TestPinnedHostKeyCallback_Match verifies the pinned callback returns
// nil when the server-presented key matches the operator-supplied
// fingerprint (T02.5, REQ-058).
func TestPinnedHostKeyCallback_Match(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
host, port, _ := net.SplitHostPort(srv.addr())
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
expectedFP := security.SSHFingerprintSHA256(hostKey)
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
// closed on mismatch (T02.5, REQ-058).
func TestPinnedHostKeyCallback_Mismatch(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
host, _, _ := net.SplitHostPort(srv.addr())
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
cb, err := pinnedHostKeyCallback("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", nil)
if err != nil {
t.Fatalf("pinnedHostKeyCallback: %v", err)
}
err = cb(host+":22", &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey)
if err == nil {
t.Fatal("expected mismatch error, got nil")
}
if !strings.Contains(err.Error(), "REQ-058") {
t.Errorf("mismatch error should mention REQ-058, got: %v", err)
}
}
// TestPinnedHostKeyCallback_RejectsRawHex verifies the constructor
// rejects a non-SHA256:-prefixed fingerprint (T02.5, D-045).
func TestPinnedHostKeyCallback_RejectsRawHex(t *testing.T) {
_, err := pinnedHostKeyCallback("abcdef0123456789", nil)
if err == nil {
t.Fatal("expected error for raw hex fingerprint, got nil")
}
if !strings.Contains(err.Error(), "SHA256:") {
t.Errorf("error should mention SHA256: prefix requirement, got: %v", err)
}
}
// TestTOFUHostKeyCallback_FirstConnectCapturesKey verifies that on
// first connect (empty known_hosts) the TOFU callback captures the
// server key, writes it to known_hosts, and allows the dial (T02.6 —
// v0.6 ship-defect fix).
func TestTOFUHostKeyCallback_FirstConnectCapturesKey(t *testing.T) {
home := setupORCAHome(t) // empty known_hosts
srv := newFakeSSHServer(t)
defer srv.close()
host, port, _ := net.SplitHostPort(srv.addr())
addr := host + ":" + port
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
cb, err := tofuHostKeyCallback(addr, nil)
if err != nil {
t.Fatalf("tofuHostKeyCallback: %v", err)
}
if err := cb(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil {
t.Fatalf("first-connect callback returned error: %v", err)
}
data, err := os.ReadFile(filepath.Join(home, "known_hosts"))
if err != nil {
t.Fatalf("read known_hosts: %v", err)
}
if len(data) == 0 {
t.Fatal("known_hosts is empty — TOFU capture did not write the key (v0.6 ship-defect not fixed)")
}
if !strings.Contains(string(data), knownhosts.Normalize(addr)) {
t.Errorf("known_hosts missing the normalized addr %q: %s", knownhosts.Normalize(addr), data)
}
if !strings.Contains(string(data), hostKey.Type()) {
t.Errorf("known_hosts missing the host key type %q: %s", hostKey.Type(), data)
}
}
// TestTOFUHostKeyCallback_SecondConnectMatches verifies that on a
// second connect (known_hosts already has the key) the TOFU callback
// matches and returns nil (T02.6).
func TestTOFUHostKeyCallback_SecondConnectMatches(t *testing.T) {
setupORCAHome(t)
srv := newFakeSSHServer(t)
defer srv.close()
host, port, _ := net.SplitHostPort(srv.addr())
addr := host + ":" + port
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
// First connect: capture + write.
cb1, err := tofuHostKeyCallback(addr, nil)
if err != nil {
t.Fatalf("tofuHostKeyCallback #1: %v", err)
}
if err := cb1(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil {
t.Fatalf("first connect: %v", err)
}
// Second connect: the fresh knownhosts.New reads the written key.
cb2, err := tofuHostKeyCallback(addr, nil)
if err != nil {
t.Fatalf("tofuHostKeyCallback #2: %v", err)
}
if err := cb2(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil {
t.Fatalf("second connect should match, got: %v", err)
}
}
// TestTOFUHostKeyCallback_MismatchFails verifies that on a mismatch
// (known_hosts has a different key) the TOFU callback fails closed
// (MITM detection) (T02.6).
func TestTOFUHostKeyCallback_MismatchFails(t *testing.T) {
setupORCAHome(t)
srv := newFakeSSHServer(t)
defer srv.close()
host, port, _ := net.SplitHostPort(srv.addr())
addr := host + ":" + port
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
// Capture the real key first so known_hosts is populated.
cb1, err := tofuHostKeyCallback(addr, nil)
if err != nil {
t.Fatalf("tofuHostKeyCallback #1: %v", err)
}
if err := cb1(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil {
t.Fatalf("first connect: %v", err)
}
// Generate a different key + present it: callback must fail.
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 gen: %v", err)
}
altKey, err := ssh.NewPublicKey(pub)
if err != nil {
t.Fatalf("new pub: %v", err)
}
cb2, err := tofuHostKeyCallback(addr, nil)
if err != nil {
t.Fatalf("tofuHostKeyCallback #2: %v", err)
}
err = cb2(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, altKey)
if err == nil {
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)
}
}