internal/server/boot/sshgate_test.go
Ref: Size: 13.4 KiB History
package boot
import (
"bytes"
"crypto/ed25519"
"testing"
"github.com/a73x/eitri/internal/server/seal"
"github.com/a73x/eitri/internal/server/sshca"
"github.com/a73x/eitri/internal/server/store"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
// testKEK stands in for the config's key_encryption_key, which the gate's key
// files rest sealed under.
var testKEK = bytes.Repeat([]byte{0x2b}, seal.KEKSize)
// newStore opens a fresh store on a temp DB. The gate closures resolve VMs and
// tenants against a REAL store — the same one the live gate uses — so these
// tests exercise the actual SQL, not a mock.
func newStore(t *testing.T) *store.Store {
t.Helper()
s, err := store.Open(t.TempDir()+"/eitri.db", "10.77.0.0/16")
require.NoError(t, err)
t.Cleanup(func() { s.Close() })
return s
}
// makeTenantHost provisions a tenant through the real JIT path and enrolls one
// host into it, returning the tenant ID and host. Enrollment tokens require an
// existing tenant (FK enforced), so the tenant must be created first.
func makeTenantHost(t *testing.T, s *store.Store, subject, email string) (string, store.Host) {
t.Helper()
tn, err := s.CreateTenantForIdentity("https://idp", subject, email)
require.NoError(t, err)
tok, err := s.CreateEnrollmentToken(tn.ID)
require.NoError(t, err)
h, err := s.RedeemEnrollmentToken(tok, store.EnrollFacts{Name: "host-" + tn.ID, OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: ""})
require.NoError(t, err)
return tn.ID, h
}
func makeVM(t *testing.T, s *store.Store, host store.Host, name string) store.VM {
t.Helper()
vm := store.VM{
ID: "vm-" + name, HostID: host.ID, Name: name,
ImageURL: "http://img", ImageSHA256: "abc",
VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
}
require.NoError(t, s.CreateVM(vm))
got, err := s.GetVM(vm.ID)
require.NoError(t, err)
return got
}
// TestResolveVMIsTenantScoped pins that the gate resolver looks a bare VM name
// up WITHIN the connection's tenant only: the same name in another tenant does
// not resolve, so one tenant can never tunnel to another's VM by guessing names.
func TestResolveVMIsTenantScoped(t *testing.T) {
s := newStore(t)
tenantA, hostA := makeTenantHost(t, s, "sub-a", "alpha@x.com")
tenantB, _ := makeTenantHost(t, s, "sub-b", "beta@x.com")
vm := makeVM(t, s, hostA, "web")
resolve := resolveVM(s)
hostID, vmID, ok := resolve(tenantA, "web")
require.True(t, ok, "the owning tenant must resolve its own VM")
assert.Equal(t, hostA.ID, hostID)
assert.Equal(t, vm.ID, vmID)
_, _, ok = resolve(tenantB, "web")
assert.False(t, ok, "resolution must not cross tenants")
_, _, ok = resolve(tenantA, "nope")
assert.False(t, ok, "an unknown name does not resolve")
}
// TestResolveVMTombstonedNotResolvable pins that a tombstoned VM stops
// resolving: the gate must not tunnel to a dead VM.
func TestResolveVMTombstonedNotResolvable(t *testing.T) {
s := newStore(t)
tenant, host := makeTenantHost(t, s, "sub-a", "alpha@x.com")
vm := makeVM(t, s, host, "web")
resolve := resolveVM(s)
_, _, ok := resolve(tenant, "web")
require.True(t, ok)
require.NoError(t, s.TombstoneVM(vm.ID))
_, _, ok = resolve(tenant, "web")
assert.False(t, ok, "a tombstoned VM must not resolve")
}
// TestAuthorizeVM pins the second gate check: the connection's tenant must OWN
// the VM (tenant equality), the VM must be live, and any store error refuses.
func TestAuthorizeVM(t *testing.T) {
s := newStore(t)
tenantA, hostA := makeTenantHost(t, s, "sub-a", "alpha@x.com")
tenantB, _ := makeTenantHost(t, s, "sub-b", "beta@x.com")
vm := makeVM(t, s, hostA, "web")
authorize := authorizeVM(s)
assert.True(t, authorize(tenantA, vm.ID), "the owning tenant is authorized")
assert.False(t, authorize(tenantB, vm.ID), "a different tenant must be rejected")
assert.False(t, authorize(tenantA, "no-such-vm"), "an unknown VM must be rejected")
// A tombstoned VM (DeletedAt set) is refused even for its owner.
require.NoError(t, s.TombstoneVM(vm.ID))
assert.False(t, authorize(tenantA, vm.ID), "a tombstoned VM must be rejected")
}
// TestVMLookupAppliesBothChecks pins that the server-side SSH path resolves a
// VM under exactly the gate's rules: within the tenant only, and only while the
// VM is live and still that tenant's.
func TestVMLookupAppliesBothChecks(t *testing.T) {
s := newStore(t)
tenantA, hostA := makeTenantHost(t, s, "sub-a", "alpha@x.com")
tenantB, _ := makeTenantHost(t, s, "sub-b", "beta@x.com")
vm := makeVM(t, s, hostA, "web")
lookup := vmLookup(s)
got, ok := lookup(tenantA, "web")
require.True(t, ok)
assert.Equal(t, hostA.ID, got.HostID)
assert.Equal(t, vm.ID, got.VMID)
assert.False(t, got.HostCertified, "a VM with no host cert reports itself unverifiable")
// Once the control plane has signed the guest's key, it is verifiable.
require.NoError(t, s.RecordVMHostKey(vm.ID, hostA.ID, "ssh-ed25519 AAAApub g", "cert-line"))
got, ok = lookup(tenantA, "web")
require.True(t, ok)
assert.True(t, got.HostCertified)
_, ok = lookup(tenantB, "web")
assert.False(t, ok, "a name must not resolve across tenants")
_, ok = lookup(tenantA, "nope")
assert.False(t, ok, "an unknown name does not resolve")
require.NoError(t, s.TombstoneVM(vm.ID))
_, ok = lookup(tenantA, "web")
assert.False(t, ok, "a tombstoned VM must not resolve")
}
// TestHostCAPublicKeyIsNilWhenTheGateIsOff: with no gate there is no CA, so no
// guest carries a host certificate and there is nothing to verify against.
func TestHostCAPublicKeyIsNilWhenTheGateIsOff(t *testing.T) {
var off *sshGateSetup
assert.Nil(t, off.hostCAPublicKey())
}
// TestHostCAPublicKeyIsTheHostCA pins that the key handed to the server-side
// dialer is the same one clients pin via @cert-authority.
func TestHostCAPublicKeyIsTheHostCA(t *testing.T) {
dir := t.TempDir()
ca, err := sshca.New(dir+"/ca", dir+"/hostkey", testKEK)
require.NoError(t, err)
g := &sshGateSetup{ca: ca}
assert.Equal(t, sshca.AuthorizedKeyLine(g.hostCAPublicKey())+"\n", string(ca.HostCAAuthorizedKey()))
}
// TestRevokedCertFailsClosed pins the revocation gate: a known-revoked serial
// reads revoked, an unknown serial does not, and a store error fails CLOSED
// (reports revoked=true) so a DB hiccup rejects THAT login rather than letting a
// possibly-revoked cert through.
func TestRevokedCertFailsClosed(t *testing.T) {
s := newStore(t)
tenant, _ := makeTenantHost(t, s, "sub-a", "alpha@x.com")
revoked := revokedCert(s)
assert.False(t, revoked(tenant, 42), "an unknown serial is not revoked")
require.NoError(t, s.RevokeSSHCert(tenant, 42, "leaked laptop"))
assert.True(t, revoked(tenant, 42), "a revoked serial reads revoked")
// Force a store error: after Close the DB handle is dead and the lookup
// errors. The gate must fail closed — reject the connection.
require.NoError(t, s.Close())
assert.True(t, revoked(tenant, 43), "a store error must fail closed (reject)")
}
// TestUserCALookupFailsClosed pins tenant attribution: a cert signed by a
// registered tenant CA resolves to that tenant, an unknown CA does not, and a
// store error fails CLOSED (rejects the cert).
func TestUserCALookupFailsClosed(t *testing.T) {
s := newStore(t)
tenant, _ := makeTenantHost(t, s, "sub-a", "alpha@x.com")
_, priv, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
pub, err := ssh.NewPublicKey(priv.Public())
require.NoError(t, err)
caLine := sshca.AuthorizedKeyLine(pub)
require.NoError(t, s.AddTenantUserCA(tenant, caLine, "tenant", "laptop", "admin"))
lookup := userCALookup(s)
gotTenant, ok := lookup(pub)
require.True(t, ok, "a registered CA must resolve")
assert.Equal(t, tenant, gotTenant, "the CA maps to the tenant that registered it")
// An unregistered CA does not resolve (no error, just not found).
_, otherPriv, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
otherPub, err := ssh.NewPublicKey(otherPriv.Public())
require.NoError(t, err)
_, ok = lookup(otherPub)
assert.False(t, ok, "an unregistered CA must not resolve")
// Force a store error: after Close the lookup errors and must fail closed.
require.NoError(t, s.Close())
_, ok = lookup(pub)
assert.False(t, ok, "a store error must fail closed (reject)")
}
// TestGuestHostCertSignerCertifiesTheKeyItIsGiven pins the one thing the sync
// service asks the gate for: a host certificate over a key the control plane
// did not generate, for a principal the control plane chose.
func TestGuestHostCertSignerCertifiesTheKeyItIsGiven(t *testing.T) {
_, ca, err := sshca.GenerateHostKey()
require.NoError(t, err)
_, guest, err := sshca.GenerateHostKey()
require.NoError(t, err)
line, err := guestHostCertSigner{ca: ca}.SignHostCert(guest.PublicKey(), "acme.web")
require.NoError(t, err)
pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
require.NoError(t, err)
cert, ok := pk.(*ssh.Certificate)
require.True(t, ok)
assert.Equal(t, uint32(ssh.HostCert), cert.CertType)
assert.Equal(t, []string{"acme.web"}, cert.ValidPrincipals)
assert.Equal(t, guest.PublicKey().Marshal(), cert.Key.Marshal())
checker := &ssh.CertChecker{IsHostAuthority: func(k ssh.PublicKey, _ string) bool {
return string(k.Marshal()) == string(ca.PublicKey().Marshal())
}}
require.NoError(t, checker.CheckHostKey("acme.web:22", nil, cert))
}
// TestWireSyncIsANoOpWhenTheGateIsOff: with no CA there is nothing to sign
// with, and a nil setup must stay silent rather than panic on the way past.
func TestWireSyncIsANoOpWhenTheGateIsOff(t *testing.T) {
var off *sshGateSetup
assert.NotPanics(t, func() { off.wireSync(nil) })
}
// TestGateAddrNamesTheGateAsItsCertificateDoes pins the address /me hands
// clients against the principal startListener puts on the gate's host cert:
// a client verifies the name it dialed against that certificate, so an address
// spelled any other way cannot pass host verification.
//
// It also pins the one case where they part company. A wildcard bind gets a
// certificate principal, because a certificate must name something, but no
// published address: "localhost" and "0.0.0.0" are both true of the plane's own
// machine and false everywhere a user might read them. Those rows are the
// predicate setupSSHGate refuses to boot on (TestSetupSSHGateNeedsAName), so
// they are configs no running plane holds — pinned here because that refusal
// reads its answer.
func TestGateAddrNamesTheGateAsItsCertificateDoes(t *testing.T) {
for _, tc := range []struct {
name string
listen, domain string
wantDomain string
wantAddr string
}{
{
name: "the configured domain wins, the port comes from ssh_listen",
listen: ":2222", domain: "gate.eitri.sh",
wantDomain: "gate.eitri.sh", wantAddr: "gate.eitri.sh:2222",
},
{
name: "no domain configured falls back to ssh_listen's host",
listen: "gate.example.com:2222", domain: "",
wantDomain: "gate.example.com", wantAddr: "gate.example.com:2222",
},
{
name: "a loopback bind is the truth for a single-machine plane",
listen: "127.0.0.1:2222", domain: "",
wantDomain: "127.0.0.1", wantAddr: "127.0.0.1:2222",
},
{
name: "an IPv4 wildcard bind with no domain publishes nothing",
listen: "0.0.0.0:22", domain: "",
wantDomain: "0.0.0.0", wantAddr: "",
},
{
name: "an IPv6 wildcard bind with no domain publishes nothing",
listen: "[::]:2222", domain: "",
wantDomain: "::", wantAddr: "",
},
{
name: "an all-interfaces listen with no host publishes nothing",
listen: ":2222", domain: "",
wantDomain: "localhost", wantAddr: "",
},
{
name: "a wildcard bind publishes the domain it was given",
listen: "0.0.0.0:2222", domain: "gate.eitri.sh",
wantDomain: "gate.eitri.sh", wantAddr: "gate.eitri.sh:2222",
},
{
name: "an unparsable listen names nothing to publish",
listen: "", domain: "",
wantDomain: "localhost", wantAddr: "",
},
{
name: "an IPv6 domain is bracketed so the port stays readable",
listen: ":2222", domain: "2001:db8::1",
wantDomain: "2001:db8::1", wantAddr: "[2001:db8::1]:2222",
},
} {
t.Run(tc.name, func(t *testing.T) {
g := &sshGateSetup{listen: tc.listen, domain: tc.domain}
assert.Equal(t, tc.wantDomain, g.gateDomain())
assert.Equal(t, tc.wantAddr, g.gateAddr())
})
}
}
// TestVMLookupCarriesTheFrozenTrustSet: the server-side SSH path refuses with a
// story about the VM's CA set, so it needs the set itself. A row that recorded
// none must arrive empty rather than as a set that trusts nothing — the refusal
// reads those as different facts.
func TestVMLookupCarriesTheFrozenTrustSet(t *testing.T) {
s := newStore(t)
tenant, host := makeTenantHost(t, s, "sub-a", "alpha@x.com")
unrecorded := makeVM(t, s, host, "old")
require.NoError(t, s.RecordVMHostKey(unrecorded.ID, host.ID, "ssh-ed25519 AAAApub g", "cert-line"))
frozen := store.VM{
ID: "vm-web", HostID: host.ID, Name: "web",
ImageURL: "http://img", ImageSHA256: "abc",
VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
TrustedCAs: []store.TrustedCA{
{Label: "laptop", Fingerprint: "SHA256:aaa", AuthorizedKey: "ssh-ed25519 AAAAca laptop"},
{Label: "ci", Fingerprint: "SHA256:bbb", AuthorizedKey: "ssh-ed25519 AAAAci ci"},
},
}
require.NoError(t, s.CreateVM(frozen))
lookup := vmLookup(s)
got, ok := lookup(tenant, "web")
require.True(t, ok)
assert.Equal(t, []string{"SHA256:aaa", "SHA256:bbb"}, got.TrustedCAFingerprints)
got, ok = lookup(tenant, "old")
require.True(t, ok)
assert.Empty(t, got.TrustedCAFingerprints, "a row that recorded no set says nothing about what the guest trusts")
}