- CLI: enroll, run, status, version, help (core.Main with a role factory); the agent binaries use a placeholder factory until the BIND and Kea roles are wired in. - Enrollment: local ECDSA P-256 key and CSR, TLS 1.3 to the agent API with the server chain pinned to the CA fingerprint. - ServerSource: mTLS gRPC with keepalive, Connect stream (Hello, Welcome, heartbeats, apply and renew commands), reconnect with exponential backoff and jitter, GetDesiredState polling as fallback, certificate renewal after half of the lifetime. - Apply pipeline: fetch, bundle verification with replay protection, store, Validate, Activate, Health with retries, rollback to the last good revision; outcomes succeeded/rejected/rolled_back/failed are queued in state.json and reported; failed revisions are retried only by a newer revision or an explicit push. - Lease reporting with change detection, persistent sequence and full snapshots at start, per connection and periodically. - Tests with fake roles, a fake source and an in-process fake AgentService over real mTLS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
413 lines
14 KiB
Go
413 lines
14 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package core
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
|
|
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
|
|
)
|
|
|
|
// enrollWith enrolls a new store against the fake server.
|
|
func enrollWith(t *testing.T, srv *fakeServer, addr string) (*Store, *Identity) {
|
|
t.Helper()
|
|
store := openTestStore(t)
|
|
id, err := Enroll(context.Background(), store, EnrollOptions{
|
|
Kind: srv.role, Server: addr, Token: srv.token, CAHash: srv.fingerprint(),
|
|
ServerName: "localhost", Hostname: "test-host",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enroll: %v", err)
|
|
}
|
|
return store, id
|
|
}
|
|
|
|
func TestEnroll(t *testing.T) {
|
|
srv := newFakeServer(t, KindDNS)
|
|
addr := srv.start(t)
|
|
store, id := enrollWith(t, srv, addr)
|
|
if id.AgentID != srv.agentID || id.Server != addr || id.ServerName != "localhost" || !id.CA.Equal(srv.pki.ca) {
|
|
t.Fatalf("identity = %+v", id)
|
|
}
|
|
loaded, err := store.LoadIdentity()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if loaded.AgentID != srv.agentID || !loaded.BundleKey.Equal(srv.signKey.Public()) || loaded.Role != KindDNS {
|
|
t.Errorf("stored identity = %+v", loaded)
|
|
}
|
|
if got, err := agentpki.AgentID(loaded.Certificate.Leaf); err != nil || got != srv.agentID {
|
|
t.Errorf("certificate agent ID = %q, %v", got, err)
|
|
}
|
|
|
|
// Enrolling again needs --force.
|
|
opts := EnrollOptions{Kind: KindDNS, Server: addr, Token: srv.token, CAHash: srv.fingerprint(), ServerName: "localhost"}
|
|
if _, err := Enroll(context.Background(), store, opts); !errors.Is(err, errAlreadyEnrolled) {
|
|
t.Fatalf("second Enroll = %v", err)
|
|
}
|
|
srv.with(func(f *fakeServer) { f.agentID = "22222222-2222-4333-8444-555555555555" })
|
|
st := &State{Applied: 3, PendingReports: []DeploymentReport{{Revision: 3}}}
|
|
if err := store.SaveState(st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
opts.Force = true
|
|
id2, err := Enroll(context.Background(), store, opts)
|
|
if err != nil || id2.AgentID != "22222222-2222-4333-8444-555555555555" {
|
|
t.Fatalf("forced Enroll = %+v, %v", id2, err)
|
|
}
|
|
if st, _ := store.LoadState(); st.Applied != 3 || len(st.PendingReports) != 0 {
|
|
t.Errorf("state after forced enroll = %+v", st)
|
|
}
|
|
}
|
|
|
|
func TestEnrollDefaultsServerNameToHost(t *testing.T) {
|
|
srv := newFakeServer(t, KindDHCP)
|
|
addr := srv.start(t) // 127.0.0.1:port; the server certificate has the IP SAN
|
|
store := openTestStore(t)
|
|
id, err := Enroll(context.Background(), store, EnrollOptions{
|
|
Kind: KindDHCP, Server: "https://" + addr + "/", Token: srv.token, CAHash: strings.ToUpper(srv.fingerprint()[7:]),
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("fingerprint without prefix accepted: %+v", id)
|
|
}
|
|
id, err = Enroll(context.Background(), store, EnrollOptions{
|
|
Kind: KindDHCP, Server: "https://" + addr + "/", Token: srv.token, CAHash: "sha256:" + strings.ToUpper(srv.fingerprint()[7:]),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if id.ServerName != "127.0.0.1" || id.Server != addr {
|
|
t.Errorf("identity = %+v", id)
|
|
}
|
|
}
|
|
|
|
func TestEnrollFailures(t *testing.T) {
|
|
srv := newFakeServer(t, KindDNS)
|
|
addr := srv.start(t)
|
|
other := newTestPKI(t)
|
|
cases := map[string]EnrollOptions{
|
|
"wrong fingerprint": {Server: addr, Token: srv.token, CAHash: agentpki.Fingerprint(other.ca), ServerName: "localhost"},
|
|
"wrong server name": {Server: addr, Token: srv.token, CAHash: srv.fingerprint(), ServerName: "evil.example.com"},
|
|
"wrong token": {Server: addr, Token: "0123456789abcdef.wrong", CAHash: srv.fingerprint(), ServerName: "localhost"},
|
|
"bad token": {Server: addr, Token: "nodot", CAHash: srv.fingerprint()},
|
|
"bad address": {Server: "localhost", Token: srv.token, CAHash: srv.fingerprint()},
|
|
"bad fingerprint": {Server: addr, Token: srv.token, CAHash: "sha256:abc"},
|
|
}
|
|
for name, opts := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
opts.Kind = KindDNS
|
|
store := openTestStore(t)
|
|
if _, err := Enroll(context.Background(), store, opts); err == nil {
|
|
t.Fatal("accepted")
|
|
}
|
|
if store.Enrolled() {
|
|
t.Error("identity stored after a failed enrollment")
|
|
}
|
|
})
|
|
}
|
|
// Role mismatch: the server refuses.
|
|
store := openTestStore(t)
|
|
if _, err := Enroll(context.Background(), store, EnrollOptions{
|
|
Kind: KindDHCP, Server: addr, Token: srv.token, CAHash: srv.fingerprint(), ServerName: "localhost",
|
|
}); err == nil {
|
|
t.Error("wrong role accepted")
|
|
}
|
|
if _, err := Enroll(context.Background(), store, EnrollOptions{
|
|
Kind: Kind("ntp"), Server: addr, Token: srv.token, CAHash: srv.fingerprint(),
|
|
}); err == nil {
|
|
t.Error("unknown role accepted")
|
|
}
|
|
srv.with(func(f *fakeServer) {
|
|
if f.enrollments == 0 {
|
|
t.Error("server never reached")
|
|
}
|
|
})
|
|
}
|
|
|
|
// runAgentAgainst runs an agent with a ServerSource until the test ends.
|
|
func runAgentAgainst(t *testing.T, store *Store, id *Identity, kind Kind) (*fakeRole, *ServerSource) {
|
|
t.Helper()
|
|
role := newFakeRole(kind)
|
|
src := NewServerSource(store, id, "test-host", testLogger())
|
|
src.backoffMin, src.backoffMax = 10*time.Millisecond, 100*time.Millisecond
|
|
a, err := NewAgent(kind, testConfig(store.Dir()), role, store, id, src, testLogger())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.healthRetry = 20 * time.Millisecond
|
|
a.reportRetry = 50 * time.Millisecond
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- a.Run(ctx) }()
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Errorf("Run = %v", err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Error("Run did not return after cancel")
|
|
}
|
|
})
|
|
return role, src
|
|
}
|
|
|
|
func serverReports(srv *fakeServer) []*agentv1.ReportDeploymentRequest {
|
|
var out []*agentv1.ReportDeploymentRequest
|
|
srv.with(func(f *fakeServer) { out = append(out, f.reports...) })
|
|
return out
|
|
}
|
|
|
|
func TestServerSourceStreamAndPush(t *testing.T) {
|
|
srv := newFakeServer(t, KindDNS)
|
|
addr := srv.start(t)
|
|
store, id := enrollWith(t, srv, addr)
|
|
srv.with(func(f *fakeServer) { f.desired = 1 })
|
|
role, _ := runAgentAgainst(t, store, id, KindDNS)
|
|
|
|
waitFor(t, "report of revision 1", func() bool { return len(serverReports(srv)) == 1 })
|
|
r := serverReports(srv)[0]
|
|
if r.GetRevision() != 1 || r.GetOutcome() != agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_SUCCEEDED ||
|
|
r.GetTrigger() != agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PULL || r.GetActiveRevision() != 1 ||
|
|
r.GetStartedAt() == nil || r.GetFinishedAt() == nil {
|
|
t.Fatalf("report = %v", r)
|
|
}
|
|
srv.with(func(f *fakeServer) {
|
|
if len(f.hellos) == 0 || f.hellos[0].GetHostname() != "test-host" || f.hellos[0].GetAgentVersion() == "" {
|
|
t.Errorf("hellos = %v", f.hellos)
|
|
}
|
|
})
|
|
waitFor(t, "heartbeats", func() bool {
|
|
n := 0
|
|
srv.with(func(f *fakeServer) { n = f.heartbeats })
|
|
return n >= 2
|
|
})
|
|
|
|
// Push over the stream.
|
|
srv.commands <- &agentv1.Command{CommandId: "c1", Action: &agentv1.Command_ApplyRevision{
|
|
ApplyRevision: &agentv1.ApplyRevisionCommand{Revision: 2, DeploymentId: "dep-2"},
|
|
}}
|
|
waitFor(t, "report of revision 2", func() bool { return len(serverReports(srv)) == 2 })
|
|
r = serverReports(srv)[1]
|
|
if r.GetRevision() != 2 || r.GetDeploymentId() != "dep-2" || r.GetTrigger() != agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PUSH ||
|
|
r.GetOutcome() != agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_SUCCEEDED {
|
|
t.Fatalf("report = %v", r)
|
|
}
|
|
srv.with(func(f *fakeServer) {
|
|
if len(f.acks) != 1 || f.acks[0].GetCommandId() != "c1" || !f.acks[0].GetAccepted() {
|
|
t.Errorf("acks = %v", f.acks)
|
|
}
|
|
})
|
|
if n := role.activeRevision(); n != 2 {
|
|
t.Errorf("active = %d", n)
|
|
}
|
|
|
|
// Invalid and unknown commands are refused.
|
|
srv.commands <- &agentv1.Command{CommandId: "c2", Action: &agentv1.Command_ApplyRevision{ApplyRevision: &agentv1.ApplyRevisionCommand{}}}
|
|
srv.commands <- &agentv1.Command{CommandId: "c3"}
|
|
waitFor(t, "refusals", func() bool {
|
|
n := 0
|
|
srv.with(func(f *fakeServer) { n = len(f.acks) })
|
|
return n == 3
|
|
})
|
|
srv.with(func(f *fakeServer) {
|
|
if f.acks[1].GetAccepted() || f.acks[2].GetAccepted() {
|
|
t.Errorf("acks = %v", f.acks)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServerSourceManualMode(t *testing.T) {
|
|
srv := newFakeServer(t, KindDNS)
|
|
addr := srv.start(t)
|
|
store, id := enrollWith(t, srv, addr)
|
|
srv.with(func(f *fakeServer) {
|
|
f.desired = 1
|
|
f.mode = agentv1.OperatingMode_OPERATING_MODE_MANUAL
|
|
})
|
|
runAgentAgainst(t, store, id, KindDNS)
|
|
waitFor(t, "hello", func() bool {
|
|
n := 0
|
|
srv.with(func(f *fakeServer) { n = f.heartbeats })
|
|
return n >= 3
|
|
})
|
|
if n := len(serverReports(srv)); n != 0 {
|
|
t.Errorf("manual mode applied the desired revision (%d reports)", n)
|
|
}
|
|
}
|
|
|
|
func TestServerSourcePollingFallback(t *testing.T) {
|
|
srv := newFakeServer(t, KindDNS)
|
|
addr := srv.start(t)
|
|
store, id := enrollWith(t, srv, addr)
|
|
srv.with(func(f *fakeServer) {
|
|
f.desired = 1
|
|
f.streamDisabled = true
|
|
})
|
|
runAgentAgainst(t, store, id, KindDNS)
|
|
waitFor(t, "report via polling", func() bool { return len(serverReports(srv)) == 1 })
|
|
srv.with(func(f *fakeServer) {
|
|
if f.polls == 0 {
|
|
t.Error("GetDesiredState never called")
|
|
}
|
|
})
|
|
// A newer revision is picked up by a later poll.
|
|
srv.with(func(f *fakeServer) { f.desired = 2 })
|
|
waitFor(t, "report of revision 2", func() bool { return len(serverReports(srv)) == 2 })
|
|
if st, _ := store.LoadState(); st.Applied != 2 {
|
|
t.Errorf("state = %+v", st)
|
|
}
|
|
}
|
|
|
|
func TestServerSourceRenewsCertificate(t *testing.T) {
|
|
srv := newFakeServer(t, KindDNS)
|
|
// More than half of the certificate's lifetime has passed.
|
|
srv.enrollValidity = [2]time.Duration{-3 * time.Hour, time.Hour}
|
|
addr := srv.start(t)
|
|
store, id := enrollWith(t, srv, addr)
|
|
oldCert, _ := os.ReadFile(filepath.Join(store.Dir(), "identity", "cert.pem"))
|
|
runAgentAgainst(t, store, id, KindDNS)
|
|
|
|
waitFor(t, "renewal and hello", func() bool {
|
|
ok := false
|
|
srv.with(func(f *fakeServer) { ok = f.renewals == 1 && len(f.hellos) >= 1 })
|
|
return ok
|
|
})
|
|
newCert, _ := os.ReadFile(filepath.Join(store.Dir(), "identity", "cert.pem"))
|
|
if string(newCert) == string(oldCert) {
|
|
t.Error("cert.pem unchanged after renewal")
|
|
}
|
|
loaded, err := store.LoadIdentity()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if time.Until(loaded.Certificate.Leaf.NotAfter) < 24*time.Hour {
|
|
t.Errorf("renewed certificate expires %v", loaded.Certificate.Leaf.NotAfter)
|
|
}
|
|
}
|
|
|
|
func TestServerSourceRenewCommand(t *testing.T) {
|
|
srv := newFakeServer(t, KindDNS)
|
|
addr := srv.start(t)
|
|
store, id := enrollWith(t, srv, addr)
|
|
runAgentAgainst(t, store, id, KindDNS)
|
|
waitFor(t, "first hello", func() bool {
|
|
n := 0
|
|
srv.with(func(f *fakeServer) { n = len(f.hellos) })
|
|
return n == 1
|
|
})
|
|
srv.commands <- &agentv1.Command{CommandId: "r1", Action: &agentv1.Command_RenewCertificate{RenewCertificate: &agentv1.RenewCertificateCommand{}}}
|
|
// The old certificate is no longer accepted, so the second Hello proves
|
|
// that the agent reconnected with the new one.
|
|
waitFor(t, "reconnect with the renewed certificate", func() bool {
|
|
ok := false
|
|
srv.with(func(f *fakeServer) { ok = f.renewals == 1 && len(f.hellos) >= 2 })
|
|
return ok
|
|
})
|
|
}
|
|
|
|
func TestServerSourceReportsLeases(t *testing.T) {
|
|
srv := newFakeServer(t, KindDHCP)
|
|
addr := srv.start(t)
|
|
store, id := enrollWith(t, srv, addr)
|
|
role, _ := runAgentAgainst(t, store, id, KindDHCP)
|
|
_ = role
|
|
waitFor(t, "full lease sync", func() bool {
|
|
ok := false
|
|
srv.with(func(f *fakeServer) {
|
|
for _, r := range f.leaseReports {
|
|
if r.GetFullSync() {
|
|
ok = true
|
|
}
|
|
}
|
|
})
|
|
return ok
|
|
})
|
|
}
|
|
|
|
func TestLeaseToProto(t *testing.T) {
|
|
l := lease("10.0.0.9", "aa:bb:cc:dd:ee:ff")
|
|
l.ClientID = "01:aa"
|
|
p := leaseToProto(l)
|
|
if p.GetIp() != "10.0.0.9" || p.GetMac() != l.MAC || p.GetState() != agentv1.LeaseState_LEASE_STATE_ACTIVE ||
|
|
!p.GetExpiresAt().AsTime().Equal(l.Expires) || p.GetSubnetId() != 1 || p.GetClientId() != "01:aa" {
|
|
t.Errorf("lease = %v", p)
|
|
}
|
|
if p := leaseToProto(Lease{IP: mustAddr("10.0.0.1")}); p.ExpiresAt != nil || p.UpdatedAt != nil {
|
|
t.Errorf("zero times encoded: %v", p)
|
|
}
|
|
for s, want := range map[LeaseState]agentv1.LeaseState{
|
|
LeaseUnknown: agentv1.LeaseState_LEASE_STATE_UNSPECIFIED, LeaseExpired: agentv1.LeaseState_LEASE_STATE_EXPIRED,
|
|
LeaseReleased: agentv1.LeaseState_LEASE_STATE_RELEASED, LeaseDeclined: agentv1.LeaseState_LEASE_STATE_DECLINED,
|
|
} {
|
|
if got := leaseStateToProto(s); got != want {
|
|
t.Errorf("%v -> %v, want %v", s, got, want)
|
|
}
|
|
}
|
|
for o, want := range map[Outcome]agentv1.DeploymentOutcome{
|
|
OutcomeRejected: agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_REJECTED, OutcomeRolledBack: agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_ROLLED_BACK,
|
|
OutcomeFailed: agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_FAILED, Outcome("x"): agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_UNSPECIFIED,
|
|
} {
|
|
if got := outcomeToProto(o); got != want {
|
|
t.Errorf("%v -> %v, want %v", o, got, want)
|
|
}
|
|
}
|
|
if triggerToProto(Trigger("x")) != agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_UNSPECIFIED {
|
|
t.Error("unknown trigger mapped")
|
|
}
|
|
h := healthToProto(Health{State: HealthDegraded, Checks: []HealthCheck{{Name: "a", State: HealthFailed}}}, time.Time{})
|
|
if h.GetState() != agentv1.HealthState_HEALTH_STATE_DEGRADED || h.CheckedAt != nil || h.GetChecks()[0].GetState() != agentv1.HealthState_HEALTH_STATE_FAILED {
|
|
t.Errorf("health = %v", h)
|
|
}
|
|
}
|
|
|
|
func TestServerSourceUnreachable(t *testing.T) {
|
|
srv := newFakeServer(t, KindDNS)
|
|
addr := srv.start(t)
|
|
store, id := enrollWith(t, srv, addr)
|
|
id.Server = "127.0.0.1:1" // nothing listens there
|
|
src := NewServerSource(store, id, "h", testLogger())
|
|
defer src.closeConn()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if err := src.ReportDeployment(ctx, DeploymentReport{Revision: 1}); err == nil {
|
|
t.Error("report to an unreachable server succeeded")
|
|
}
|
|
if _, err := src.FetchBundle(ctx, 1); err == nil {
|
|
t.Error("fetch from an unreachable server succeeded")
|
|
}
|
|
if code := status.Code(src.ReportLeases(ctx, LeaseBatch{Sequence: 1})); code == codes.OK {
|
|
t.Error("lease report to an unreachable server succeeded")
|
|
}
|
|
}
|
|
|
|
func TestJitterAndRenewalDue(t *testing.T) {
|
|
for i := 0; i < 100; i++ {
|
|
if d := jitter(time.Second); d < 800*time.Millisecond || d > 1200*time.Millisecond {
|
|
t.Fatalf("jitter = %v", d)
|
|
}
|
|
}
|
|
pki := newTestPKI(t)
|
|
id, _ := testIdentity(t, pki)
|
|
leaf := id.Certificate.Leaf
|
|
if due := renewalDue(leaf); !due.Equal(leaf.NotBefore.Add(leaf.NotAfter.Sub(leaf.NotBefore) / 2)) {
|
|
t.Errorf("renewalDue = %v", due)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if sleepCtx(ctx, time.Hour) {
|
|
t.Error("sleepCtx ignored the context")
|
|
}
|
|
}
|