a73x

internal/server/syncsvc/hostcert_test.go

Ref:   Size: 6.0 KiB   History

package syncsvc

import (
	"bytes"
	"crypto/ed25519"
	"crypto/rand"
	"errors"
	"testing"

	"github.com/a73x/eitri/internal/pb"
	"github.com/a73x/eitri/internal/server/store"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/crypto/ssh"
	"google.golang.org/protobuf/proto"
)

// countingSigner is a hostCertSigner that records what it was asked to sign, so
// a test can assert BOTH the principal the control plane chose and how often it
// spent a signature.
type countingSigner struct {
	calls      int
	principals []string
	err        error
}

func (c *countingSigner) SignHostCert(pub ssh.PublicKey, principal string) (string, error) {
	c.calls++
	c.principals = append(c.principals, principal)
	if c.err != nil {
		return "", c.err
	}
	return "cert-for:" + principal + ":" + string(ssh.MarshalAuthorizedKey(pub)), nil
}

// testPubKey returns a real ed25519 public key in authorized_keys form.
func testPubKey(t *testing.T) string {
	t.Helper()
	pub, _, err := ed25519.GenerateKey(rand.Reader)
	require.NoError(t, err)
	sshPub, err := ssh.NewPublicKey(pub)
	require.NoError(t, err)
	return string(bytes.TrimSpace(ssh.MarshalAuthorizedKey(sshPub)))
}

// reportKey builds the report a host sends for a VM that has generated its host
// key and is waiting for the certificate.
func reportKey(vmID, pubkey string) *pb.Report {
	return &pb.Report{Vms: []*pb.VMStatus{
		{VmId: vmID, Phase: "creating", PowerState: "stopped", SshHostPubkey: pubkey},
	}}
}

func TestReportedHostKeyIsCertifiedForTheNameOnTheRow(t *testing.T) {
	f := setup(t)
	signer := &countingSigner{}
	f.svc.SetHostCertSigner(signer)
	require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a",
		ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))

	pubkey := testPubKey(t)
	f.svc.applyReport(f.host.ID, reportKey("vm1", pubkey))

	// The agent supplies a key, never a name: the principal comes from the row.
	assert.Equal(t, []string{testTenant + ".a"}, signer.principals)

	vm, err := f.st.GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, pubkey, vm.SSHHostPubKey)
	assert.Contains(t, vm.SSHHostCert, "cert-for:"+testTenant+".a:")

	// Every host repeats every VM's key every tick, forever. Steady state must
	// cost nothing.
	for range 5 {
		f.svc.applyReport(f.host.ID, reportKey("vm1", pubkey))
	}
	assert.Equal(t, 1, signer.calls, "an unchanged key must not be re-signed")
}

func TestReportedHostKeyForAnotherHostsVMIsRefused(t *testing.T) {
	f := setup(t)
	signer := &countingSigner{}
	f.svc.SetHostCertSigner(signer)

	tok, _ := f.st.CreateEnrollmentToken(testTenant)
	other, err := f.st.RedeemEnrollmentToken(tok, store.EnrollFacts{Name: "other", OS: "linux", Arch: "amd64", Provisioner: "cloudhv"})
	require.NoError(t, err)
	require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a",
		ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))

	f.svc.applyReport(other.ID, reportKey("vm1", testPubKey(t)))

	assert.Equal(t, 0, signer.calls, "a host may only speak for the VMs it holds")
	vm, err := f.st.GetVM("vm1")
	require.NoError(t, err)
	assert.Empty(t, vm.SSHHostPubKey)
	assert.Empty(t, vm.SSHHostCert)
}

func TestTombstonedVMIsNotCertified(t *testing.T) {
	f := setup(t)
	signer := &countingSigner{}
	f.svc.SetHostCertSigner(signer)
	require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a",
		ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
	require.NoError(t, f.st.TombstoneVM("vm1"))

	f.svc.applyReport(f.host.ID, reportKey("vm1", testPubKey(t)))
	assert.Equal(t, 0, signer.calls)
}

func TestNoSignerWiredIgnoresReportedHostKeys(t *testing.T) {
	f := setup(t)
	require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a",
		ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))

	f.svc.applyReport(f.host.ID, reportKey("vm1", testPubKey(t)))

	vm, err := f.st.GetVM("vm1")
	require.NoError(t, err)
	assert.Empty(t, vm.SSHHostCert)

	// And with no CA, no guest is held waiting for a certificate that will
	// never come.
	snap, err := f.svc.buildSnapshot(f.host.ID)
	require.NoError(t, err)
	require.Len(t, snap.Vms, 1)
	assert.False(t, snap.Vms[0].HostCertRequired)
}

func TestAFailedSigningIsRetriedOnTheNextReport(t *testing.T) {
	f := setup(t)
	signer := &countingSigner{err: errors.New("CA unavailable")}
	f.svc.SetHostCertSigner(signer)
	require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a",
		ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))

	pubkey := testPubKey(t)
	f.svc.applyReport(f.host.ID, reportKey("vm1", pubkey))
	signer.err = nil
	f.svc.applyReport(f.host.ID, reportKey("vm1", pubkey))

	assert.Equal(t, 2, signer.calls, "a rejected signature must not be remembered as done")
	vm, err := f.st.GetVM("vm1")
	require.NoError(t, err)
	assert.NotEmpty(t, vm.SSHHostCert)
}

// TestSnapshotCarriesNoPrivateKeyMaterial is the blunt assertion: whatever else
// changes, a desired-state snapshot must never contain a private key. The
// control plane has none to send, and this is what would notice if it did.
func TestSnapshotCarriesNoPrivateKeyMaterial(t *testing.T) {
	f := setup(t)
	f.svc.SetHostCertSigner(&countingSigner{})
	require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a",
		ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
	f.svc.applyReport(f.host.ID, reportKey("vm1", testPubKey(t)))

	snap, err := f.svc.buildSnapshot(f.host.ID)
	require.NoError(t, err)
	require.Len(t, snap.Vms, 1)
	assert.True(t, snap.Vms[0].HostCertRequired, "a fleet with a CA requires certified guests")
	assert.NotEmpty(t, snap.Vms[0].SshHostCert, "the certificate goes down; the key never came up")

	raw, err := proto.Marshal(snap)
	require.NoError(t, err)
	assert.False(t, bytes.Contains(raw, []byte("PRIVATE KEY")),
		"no private key may appear anywhere in a snapshot")
}