a73x

internal/agent/reconcile/hostkey_test.go

Ref:   Size: 5.5 KiB   History

package reconcile

import (
	"bytes"
	"os"
	"testing"
	"time"

	"github.com/a73x/eitri/internal/agent/seed"
	"github.com/a73x/eitri/internal/pb"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"google.golang.org/protobuf/proto"
)

// needsHostCert marks a desired VM the way a fleet with an SSH CA does.
func needsHostCert(v *pb.VMSpec) { v.HostCertRequired = true }

// withHostCert supplies the certificate the control plane signed.
func withHostCert(cert string) func(*pb.VMSpec) {
	return func(v *pb.VMSpec) { v.HostCertRequired = true; v.SshHostCert = cert }
}

// TestAwaitingHostCertDoesNotSpendRetryBudget is the regression that matters
// most in this exchange. Waiting for a certificate is the round trip working;
// if it were charged to the create budget, three slow ticks would terminal-fail
// every VM on the fleet at once.
func TestAwaitingHostCertDoesNotSpendRetryBudget(t *testing.T) {
	f := setup(t)

	for tick := uint64(1); tick <= 3; tick++ {
		rep := f.step(snap(tick, vm("vm1", needsHostCert)))
		row := findVM(rep, "vm1")
		require.NotNil(t, row)
		assert.Equal(t, "creating", row.Phase, "tick %d", tick)
		assert.NotEmpty(t, row.SshHostPubkey, "the public key must ride every report")
	}

	rec, ok, err := f.st.Get("vm1")
	require.NoError(t, err)
	require.True(t, ok)
	assert.Equal(t, 0, rec.CreateAttempts, "waiting for a certificate is not an attempt")

	// Nothing was built either: no disk, no seed, no boot.
	assert.Empty(t, f.prov.prepared)
	assert.Empty(t, f.prov.booted)
	assert.Equal(t, 0, f.prov.prepCalls)

	// And the budget is intact, so the VM still creates once the cert lands.
	rep := f.step(snap(4, vm("vm1", withHostCert("ssh-ed25519-cert-v01@openssh.com AAAAcert host"))))
	assert.Equal(t, "ready", findVM(rep, "vm1").Phase)
}

// TestHostKeySurvivesAnAgentRestart pins load-or-create: the key the control
// plane signs must be the key this host still holds afterwards.
func TestHostKeySurvivesAnAgentRestart(t *testing.T) {
	f := setup(t)
	first := findVM(f.step(snap(1, vm("vm1", needsHostCert))), "vm1").GetSshHostPubkey()
	require.NotEmpty(t, first)

	// A restart loses every in-memory worker; the record and the key file are
	// all that carry over.
	f.restart(t)
	second := findVM(f.step(snap(2, vm("vm1", needsHostCert))), "vm1").GetSshHostPubkey()
	assert.Equal(t, first, second, "a restart must report the same key, not a new one")
}

// TestSeedReceivesTheOnDiskHostKey proves the private half goes from this
// host's disk straight into the guest's seed, and that the certificate the
// control plane sent is the one installed beside it.
func TestSeedReceivesTheOnDiskHostKey(t *testing.T) {
	f := setup(t)
	var got seed.Params
	f.eng.Seed = func(_ string, p seed.Params) error { got = p; return nil }

	f.step(snap(1, vm("vm1", needsHostCert)))
	onDisk, err := os.ReadFile(f.st.HostKeyPath("vm1"))
	require.NoError(t, err)

	const cert = "ssh-ed25519-cert-v01@openssh.com AAAAcert host"
	f.step(snap(2, vm("vm1", withHostCert(cert))))

	assert.Equal(t, string(onDisk), got.SSHHostKeyPEM)
	assert.Equal(t, cert, got.SSHHostCert)
	assert.Contains(t, got.SSHHostKeyPEM, "OPENSSH PRIVATE KEY")
}

// TestReportCarriesNoPrivateKeyMaterial is the blunt assertion: whatever else
// changes about the report, the guest's private key must never be in it. The
// public half rides every report; the private half rides none.
func TestReportCarriesNoPrivateKeyMaterial(t *testing.T) {
	f := setup(t)
	f.step(snap(1, vm("vm1", needsHostCert)))
	priv, err := os.ReadFile(f.st.HostKeyPath("vm1"))
	require.NoError(t, err)

	rep := f.step(snap(2, vm("vm1", withHostCert("ssh-ed25519-cert-v01@openssh.com AAAAcert host"))))
	raw, err := proto.Marshal(rep)
	require.NoError(t, err)

	assert.False(t, bytes.Contains(raw, priv), "the private key must never reach the wire")
	assert.False(t, bytes.Contains(raw, []byte("PRIVATE KEY")))
	assert.Contains(t, string(raw), findVM(rep, "vm1").GetSshHostPubkey())
}

// TestHostKeyFileIsPrivate: the file the seed reads is the guest's identity.
func TestHostKeyFileIsPrivate(t *testing.T) {
	f := setup(t)
	f.step(snap(1, vm("vm1", needsHostCert)))
	fi, err := os.Stat(f.st.HostKeyPath("vm1"))
	require.NoError(t, err)
	assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
}

// TestReapRemovesTheHostKey: a destroyed VM leaves no key behind.
func TestReapRemovesTheHostKey(t *testing.T) {
	f := setup(t)
	f.step(snap(1, vm("vm1", withHostCert("ssh-ed25519-cert-v01@openssh.com AAAAcert host"))))
	require.FileExists(t, f.st.HostKeyPath("vm1"))

	f.step(snap(2, tombstoned(vm("vm1"))))
	f.now = f.now.Add(10 * time.Minute)
	f.step(snap(3, tombstoned(vm("vm1"))))

	_, err := os.Stat(f.st.HostKeyPath("vm1"))
	assert.True(t, os.IsNotExist(err), "destroying a VM must take its host key with it")
}

// TestGateOffGivesTheGuestNoHostKey: with no CA in the fleet there is no
// certificate to wait for and nothing that could certify a key, so the agent
// generates none and the guest falls back to the one it makes for itself.
func TestGateOffGivesTheGuestNoHostKey(t *testing.T) {
	f := setup(t)
	var got seed.Params
	f.eng.Seed = func(_ string, p seed.Params) error { got = p; return nil }

	rep := f.step(snap(1, vm("vm1")))

	assert.Equal(t, "ready", findVM(rep, "vm1").Phase)
	assert.Empty(t, findVM(rep, "vm1").SshHostPubkey)
	assert.Empty(t, got.SSHHostKeyPEM, "there is no other place a host key could come from")
	assert.Empty(t, got.SSHHostCert)
	_, err := os.Stat(f.st.HostKeyPath("vm1"))
	assert.True(t, os.IsNotExist(err), "no CA in the fleet means no key to generate")
}