Every go-libvirt call runs under its own budget and a cancelled or overlong call kills the connection; credentials are read on every dial; one span and one log line per RPC come from a decorator applied where connections are made. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
344 lines
10 KiB
Go
344 lines
10 KiB
Go
package kvm2
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io/fs"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh/knownhosts"
|
|
|
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
|
)
|
|
|
|
// sshFixture is one in-process server with a client key it accepts and a
|
|
// known_hosts file naming its host key.
|
|
type sshFixture struct {
|
|
server *testSSHServer
|
|
socket string
|
|
cfg provider.KVMConfig
|
|
host provider.KVMHost
|
|
}
|
|
|
|
func newSSHFixture(t *testing.T) *sshFixture {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
socket := startEchoSocket(t)
|
|
keyPath, pub := writeKeyFile(t, dir, "id_ed25519")
|
|
server := startSSHServer(t, pub, socket)
|
|
return &sshFixture{
|
|
server: server,
|
|
socket: socket,
|
|
cfg: provider.KVMConfig{
|
|
SSHKeyFile: keyPath,
|
|
KnownHostsFile: writeKnownHosts(t, dir, "known_hosts", server.addr, server.hostKey),
|
|
},
|
|
host: provider.KVMHost{Name: "beelink", Address: server.addr, User: "virt", Socket: socket},
|
|
}
|
|
}
|
|
|
|
const dialTestTimeout = 5 * time.Second
|
|
|
|
func TestDial_forwardsUnixSocket(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newSSHFixture(t)
|
|
for round := range 2 {
|
|
sock, err := dialSSHSocket(context.Background(), fx.host, fx.cfg, dialTestTimeout)
|
|
if err != nil {
|
|
t.Fatalf("round %d: dial error = %v", round, err)
|
|
}
|
|
msg := []byte("ping over libvirt-sock")
|
|
if _, err := sock.Write(msg); err != nil {
|
|
t.Fatalf("round %d: write: %v", round, err)
|
|
}
|
|
buf := make([]byte, len(msg))
|
|
_ = sock.SetReadDeadline(time.Now().Add(dialTestTimeout))
|
|
if _, err := readFull(sock, buf); err != nil {
|
|
t.Fatalf("round %d: read: %v", round, err)
|
|
}
|
|
if string(buf) != string(msg) {
|
|
t.Errorf("round %d: echoed %q, want %q", round, buf, msg)
|
|
}
|
|
if err := sock.Close(); err != nil {
|
|
t.Errorf("round %d: close error = %v", round, err)
|
|
}
|
|
if err := sock.Close(); err != nil {
|
|
t.Errorf("round %d: second close error = %v, want nil", round, err)
|
|
}
|
|
}
|
|
for _, p := range fx.server.requestedPaths() {
|
|
if p != fx.socket {
|
|
t.Errorf("server saw channel for %q, want %q", p, fx.socket)
|
|
}
|
|
}
|
|
}
|
|
|
|
func readFull(sock *sshSocket, buf []byte) (int, error) {
|
|
n := 0
|
|
for n < len(buf) {
|
|
m, err := sock.Read(buf[n:])
|
|
n += m
|
|
if err != nil {
|
|
return n, err
|
|
}
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func TestDial_unknownHostKeyRejected(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newSSHFixture(t)
|
|
fx.cfg.KnownHostsFile = writeKnownHosts(t, t.TempDir(), "known_hosts", "other.example:22", fx.server.hostKey)
|
|
_, err := dialSSHSocket(context.Background(), fx.host, fx.cfg, dialTestTimeout)
|
|
assertDialFailure(t, err, stageSSH, provider.ErrPermanent)
|
|
var keyErr *knownhosts.KeyError
|
|
if !errors.As(err, &keyErr) {
|
|
t.Fatalf("error = %v, want *knownhosts.KeyError", err)
|
|
}
|
|
if len(keyErr.Want) != 0 {
|
|
t.Errorf("KeyError.Want = %v, want empty (host unknown)", keyErr.Want)
|
|
}
|
|
}
|
|
|
|
func TestDial_mismatchedHostKeyRejected(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newSSHFixture(t)
|
|
fx.cfg.KnownHostsFile = writeKnownHosts(t, t.TempDir(), "known_hosts", fx.server.addr, otherPublicKey(t))
|
|
_, err := dialSSHSocket(context.Background(), fx.host, fx.cfg, dialTestTimeout)
|
|
assertDialFailure(t, err, stageSSH, provider.ErrPermanent)
|
|
var keyErr *knownhosts.KeyError
|
|
if !errors.As(err, &keyErr) {
|
|
t.Fatalf("error = %v, want *knownhosts.KeyError", err)
|
|
}
|
|
if len(keyErr.Want) == 0 {
|
|
t.Error("KeyError.Want empty, want the mismatching known key")
|
|
}
|
|
}
|
|
|
|
func TestDial_insecureIgnoreHostKeyAccepts(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newSSHFixture(t)
|
|
fx.cfg.KnownHostsFile = ""
|
|
fx.cfg.InsecureIgnoreHostKey = true
|
|
sock, err := dialSSHSocket(context.Background(), fx.host, fx.cfg, dialTestTimeout)
|
|
if err != nil {
|
|
t.Fatalf("dial error = %v, want nil with host keys ignored", err)
|
|
}
|
|
_ = sock.Close()
|
|
}
|
|
|
|
func TestDial_missingKeyFileIsTransient(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newSSHFixture(t)
|
|
fx.cfg.SSHKeyFile = fx.cfg.SSHKeyFile + ".missing"
|
|
_, err := dialSSHSocket(context.Background(), fx.host, fx.cfg, dialTestTimeout)
|
|
assertDialFailure(t, err, stageCredentials, provider.ErrTransient)
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
t.Errorf("error = %v, want fs.ErrNotExist", err)
|
|
}
|
|
if !strings.Contains(err.Error(), fx.cfg.SSHKeyFile) {
|
|
t.Errorf("error = %q, want the path in it", err)
|
|
}
|
|
}
|
|
|
|
func TestDial_missingKnownHostsIsTransient(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newSSHFixture(t)
|
|
fx.cfg.KnownHostsFile = fx.cfg.KnownHostsFile + ".missing"
|
|
_, err := dialSSHSocket(context.Background(), fx.host, fx.cfg, dialTestTimeout)
|
|
assertDialFailure(t, err, stageCredentials, provider.ErrTransient)
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
t.Errorf("error = %v, want fs.ErrNotExist", err)
|
|
}
|
|
}
|
|
|
|
func TestDial_unparsableKeyIsPermanent(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newSSHFixture(t)
|
|
fx.cfg.SSHKeyFile = writeFile(t, t.TempDir(), "id_bad", "this is not a key\n")
|
|
_, err := dialSSHSocket(context.Background(), fx.host, fx.cfg, dialTestTimeout)
|
|
assertDialFailure(t, err, stageCredentials, provider.ErrPermanent)
|
|
if !strings.Contains(err.Error(), "parsing ssh key file "+fx.cfg.SSHKeyFile) {
|
|
t.Errorf("error = %q, want 'parsing ssh key file <path>'", err)
|
|
}
|
|
}
|
|
|
|
func TestDial_unparsableKnownHostsIsPermanent(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newSSHFixture(t)
|
|
fx.cfg.KnownHostsFile = writeFile(t, t.TempDir(), "known_hosts", "host.example ssh-ed25519 not-base64!\n")
|
|
_, err := dialSSHSocket(context.Background(), fx.host, fx.cfg, dialTestTimeout)
|
|
assertDialFailure(t, err, stageCredentials, provider.ErrPermanent)
|
|
if !strings.Contains(err.Error(), "parsing known_hosts file "+fx.cfg.KnownHostsFile) {
|
|
t.Errorf("error = %q, want 'parsing known_hosts file <path>'", err)
|
|
}
|
|
}
|
|
|
|
func TestDial_wrongKeyIsUnauthorized(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newSSHFixture(t)
|
|
fx.cfg.SSHKeyFile, _ = writeKeyFile(t, t.TempDir(), "id_other")
|
|
_, err := dialSSHSocket(context.Background(), fx.host, fx.cfg, dialTestTimeout)
|
|
assertDialFailure(t, err, stageSSH, provider.ErrPermanent)
|
|
if !strings.Contains(err.Error(), sshAuthFailed) {
|
|
t.Errorf("error = %q, want %q in it", err, sshAuthFailed)
|
|
}
|
|
}
|
|
|
|
func TestDial_handshakeTimeout(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newSSHFixture(t)
|
|
fx.host.Address = silentListener(t)
|
|
const budget = 200 * time.Millisecond
|
|
start := time.Now()
|
|
_, err := dialSSHSocket(context.Background(), fx.host, fx.cfg, budget)
|
|
elapsed := time.Since(start)
|
|
assertDialFailure(t, err, stageSSH, provider.ErrTransient)
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Errorf("error = %v, want context.DeadlineExceeded", err)
|
|
}
|
|
if elapsed > budget+time.Second {
|
|
t.Errorf("dial took %v, want about the %v budget", elapsed, budget)
|
|
}
|
|
}
|
|
|
|
func TestDial_cancelledContext(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newSSHFixture(t)
|
|
fx.host.Address = silentListener(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
time.Sleep(20 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
start := time.Now()
|
|
_, err := dialSSHSocket(ctx, fx.host, fx.cfg, dialTestTimeout)
|
|
if time.Since(start) > time.Second {
|
|
t.Errorf("dial took %v after cancel", time.Since(start))
|
|
}
|
|
assertDialFailure(t, err, stageSSH, provider.ErrTransient)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("error = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestDial_refusedIsTransient(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newSSHFixture(t)
|
|
fx.host.Address = closedPort(t)
|
|
_, err := dialSSHSocket(context.Background(), fx.host, fx.cfg, dialTestTimeout)
|
|
assertDialFailure(t, err, stageTCP, provider.ErrTransient)
|
|
var opErr *net.OpError
|
|
if !errors.As(err, &opErr) {
|
|
t.Errorf("error = %v, want *net.OpError", err)
|
|
}
|
|
}
|
|
|
|
func TestDial_socketRefusedIsSocketStage(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newSSHFixture(t)
|
|
fx.host.Socket = "/run/nonexistent/libvirt-sock"
|
|
_, err := dialSSHSocket(context.Background(), fx.host, fx.cfg, dialTestTimeout)
|
|
assertDialFailure(t, err, stageSocket, provider.ErrTransient)
|
|
if !strings.Contains(err.Error(), fx.host.Socket) {
|
|
t.Errorf("error = %q, want the socket path in it", err)
|
|
}
|
|
}
|
|
|
|
func TestSSH_keyMaterialNeverInErrorsOrLogs(t *testing.T) {
|
|
t.Parallel()
|
|
const keySentinel = "SENTINEL-KEY-4f9c2b"
|
|
const hostsSentinel = "SENTINEL-HOSTS-8a1d7e"
|
|
fx := newSSHFixture(t)
|
|
dir := t.TempDir()
|
|
tests := []struct {
|
|
name string
|
|
cfg func(c *provider.KVMConfig)
|
|
}{
|
|
{
|
|
name: "bad key file",
|
|
cfg: func(c *provider.KVMConfig) {
|
|
c.SSHKeyFile = writeFile(t, dir, "id_bad", "-----BEGIN NOT A KEY-----\n"+keySentinel+"\n-----END NOT A KEY-----\n")
|
|
},
|
|
},
|
|
{
|
|
name: "bad known_hosts file",
|
|
cfg: func(c *provider.KVMConfig) {
|
|
c.KnownHostsFile = writeFile(t, dir, "known_hosts_bad", "beelink ssh-ed25519 "+hostsSentinel+"\n")
|
|
},
|
|
},
|
|
{
|
|
name: "good key, host unknown",
|
|
cfg: func(c *provider.KVMConfig) {
|
|
c.KnownHostsFile = writeFile(t, dir, "known_hosts_other", knownhosts.Line([]string{"other.example:22"}, fx.server.hostKey)+"\n")
|
|
},
|
|
},
|
|
}
|
|
keyBytes := readFileT(t, fx.cfg.SSHKeyFile)
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := fx.cfg
|
|
tc.cfg(&cfg)
|
|
_, err := dialSSHSocket(context.Background(), fx.host, cfg, dialTestTimeout)
|
|
if err == nil {
|
|
t.Fatal("dial succeeded, want an error")
|
|
}
|
|
msg := err.Error()
|
|
for _, s := range []string{keySentinel, hostsSentinel} {
|
|
if strings.Contains(msg, s) {
|
|
t.Errorf("error %q contains sentinel %q", msg, s)
|
|
}
|
|
}
|
|
if strings.Contains(msg, keyBytes) || strings.Contains(msg, "PRIVATE KEY") {
|
|
t.Errorf("error %q contains key material", msg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func readFileT(t *testing.T, path string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("reading %s: %v", path, err)
|
|
}
|
|
return strings.TrimSpace(string(b))
|
|
}
|
|
|
|
func assertDialFailure(t *testing.T, err error, wantStage dialStage, wantClass error) {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatal("dial succeeded, want an error")
|
|
}
|
|
stage, ok := stageOf(err)
|
|
if !ok {
|
|
t.Fatalf("error %v is not a *dialError", err)
|
|
}
|
|
if stage != wantStage {
|
|
t.Errorf("stage = %q, want %q (error: %v)", stage, wantStage, err)
|
|
}
|
|
if got := classify("get", err); !errors.Is(got, wantClass) {
|
|
t.Errorf("classify() = %v, want %v (error: %v)", got, wantClass, err)
|
|
}
|
|
}
|
|
|
|
func TestDialError_unwrapsToCause(t *testing.T) {
|
|
t.Parallel()
|
|
cause := &knownhosts.KeyError{}
|
|
err := &dialError{stage: stageSSH, host: "beelink", err: cause}
|
|
if !errors.Is(err, cause) {
|
|
t.Error("errors.Is(dialError, cause) = false")
|
|
}
|
|
if !strings.Contains(err.Error(), "beelink") || !strings.Contains(err.Error(), "(ssh)") {
|
|
t.Errorf("Error() = %q, want the host and the stage", err)
|
|
}
|
|
if _, ok := stageOf(errors.New("plain")); ok {
|
|
t.Error("stageOf(plain error) reported a stage")
|
|
}
|
|
}
|