Merge branch 'worktree-agent-a2c569cc905081672'

This commit is contained in:
beug.k@beugsiud.de
2026-10-13 19:10:13 +02:00
20 changed files with 5326 additions and 17 deletions
+7 -1
View File
@@ -4,6 +4,7 @@
package main
import (
"errors"
"os"
"git.beugsiud.de/beug.k/DDI/agent/core"
@@ -11,5 +12,10 @@ import (
)
func main() {
os.Exit(core.RunCLI(brand.AgentDHCPBinary, core.KindDHCP, os.Args[1:], os.Stdout, os.Stderr))
os.Exit(core.Main(brand.AgentDHCPBinary, core.KindDHCP, newRole, os.Args[1:], os.Stdout, os.Stderr))
}
// newRole is a placeholder until the DHCP role package is wired in.
func newRole(func(v any) error) (core.Role, error) {
return nil, errors.New("DHCP role not available in this build")
}
+7 -1
View File
@@ -4,6 +4,7 @@
package main
import (
"errors"
"os"
"git.beugsiud.de/beug.k/DDI/agent/core"
@@ -11,5 +12,10 @@ import (
)
func main() {
os.Exit(core.RunCLI(brand.AgentDNSBinary, core.KindDNS, os.Args[1:], os.Stdout, os.Stderr))
os.Exit(core.Main(brand.AgentDNSBinary, core.KindDNS, newRole, os.Args[1:], os.Stdout, os.Stderr))
}
// newRole is a placeholder until the DNS role package is wired in.
func newRole(func(v any) error) (core.Role, error) {
return nil, errors.New("DNS role not available in this build")
}
+612
View File
@@ -0,0 +1,612 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"log/slog"
"reflect"
"slices"
"strings"
"sync"
"time"
"unicode/utf8"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
// Timing defaults of the agent that are not configurable.
const (
healthRetryInterval = time.Second
reportRetryInterval = 30 * time.Second
reportTimeout = 30 * time.Second
statusTimeout = 10 * time.Second
serviceVersionMaxAge = 10 * time.Minute
// maxMessageLen bounds the message of a deployment report.
maxMessageLen = 4096
)
// Agent drives a role: it applies the revisions announced by a Source and
// reports outcomes, health and leases.
type Agent struct {
kind Kind
cfg *Config
role Role
store *Store
agentID string
bundleKey ed25519.PublicKey
source Source
log *slog.Logger
// Intervals, shortened by tests.
healthRetry time.Duration
reportRetry time.Duration
// roleMu serializes calls into the role: only one apply at a time, and
// health checks for heartbeats are skipped while an apply runs.
roleMu sync.Mutex
mu sync.Mutex
st *State
current *Revision // applied revision, nil if none or not loadable
health Health
healthAt time.Time
version string
versionAt time.Time
qmu sync.Mutex
queue []applyRequest
wake chan struct{}
flushCh chan struct{}
leaseCh chan struct{}
}
// applyRequest asks for an apply of a revision.
type applyRequest struct {
revision uint64
trigger Trigger
deploymentID string
}
// NewAgent prepares an agent. The identity provides the agent ID and the
// bundle signing key.
func NewAgent(kind Kind, cfg *Config, role Role, store *Store, id *Identity, src Source, log *slog.Logger) (*Agent, error) {
if role.Kind() != kind {
return nil, fmt.Errorf("role %q does not match agent role %q", role.Kind(), kind)
}
st, err := store.LoadState()
if err != nil {
return nil, err
}
a := &Agent{
kind: kind, cfg: cfg, role: role, store: store, agentID: id.AgentID,
bundleKey: id.BundleKey, source: src, log: log, st: st,
healthRetry: healthRetryInterval, reportRetry: reportRetryInterval,
wake: make(chan struct{}, 1), flushCh: make(chan struct{}, 1), leaseCh: make(chan struct{}, 1),
}
if st.Applied != 0 {
rev, err := store.LoadRevision(st.Applied)
if err != nil {
log.Warn("applied revision is not available locally", "revision", st.Applied, "error", err)
} else {
a.current = &rev
}
}
return a, nil
}
// Run works until ctx is canceled. The managed service keeps running with
// its current configuration afterwards.
func (a *Agent) Run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
a.prune()
var wg sync.WaitGroup
wg.Go(func() { a.applyLoop(ctx) })
wg.Go(func() { a.reportLoop(ctx) })
if ls, ok := a.role.(LeaseSource); ok {
wg.Go(func() { a.leaseLoop(ctx, ls) })
}
err := a.source.Run(ctx, a)
stopped := ctx.Err() != nil
cancel()
wg.Wait()
if stopped {
return nil
}
return err
}
// state returns a copy of the state (without the report queue).
func (a *Agent) state() State {
a.mu.Lock()
defer a.mu.Unlock()
st := *a.st
st.PendingReports = nil
return st
}
// Connected implements SourceHandler.
func (a *Agent) Connected() {
notify(a.flushCh)
notify(a.leaseCh)
}
// Desired implements SourceHandler.
func (a *Agent) Desired(revision uint64, autoApply bool) {
if revision == 0 {
return
}
if !autoApply {
a.log.Debug("desired revision not applied in manual mode", "revision", revision)
return
}
a.enqueue(applyRequest{revision: revision, trigger: TriggerPull})
}
// Push implements SourceHandler.
func (a *Agent) Push(revision uint64, deploymentID string) {
a.enqueue(applyRequest{revision: revision, trigger: TriggerPush, deploymentID: deploymentID})
}
// Status implements SourceHandler. It runs a health check unless an apply
// is in progress, in which case the last result is returned.
func (a *Agent) Status(ctx context.Context) AgentStatus {
if a.roleMu.TryLock() {
a.refreshStatus(ctx)
a.roleMu.Unlock()
}
a.mu.Lock()
defer a.mu.Unlock()
return AgentStatus{
Applied: a.st.Applied, LastGood: a.st.LastGood,
Health: a.health, CheckedAt: a.healthAt, ServiceVersion: a.version,
}
}
// refreshStatus runs a health check and, if stale, reads the service
// version. The caller holds roleMu.
func (a *Agent) refreshStatus(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, statusTimeout)
defer cancel()
a.mu.Lock()
cur := a.current
needVersion := a.version == "" || time.Since(a.versionAt) > serviceVersionMaxAge
a.mu.Unlock()
h := a.role.Health(ctx, cur)
var version string
if needVersion {
v, err := a.role.ServiceVersion(ctx)
if err != nil {
a.log.Warn("cannot determine the service version", "error", err)
}
version = v
}
a.mu.Lock()
a.health, a.healthAt = h, time.Now()
if needVersion && version != "" {
a.version, a.versionAt = version, time.Now()
}
a.mu.Unlock()
}
// enqueue adds a request. A pull is dropped if an equal or newer revision
// is already queued; a push replaces queued pulls up to its revision.
func (a *Agent) enqueue(r applyRequest) {
a.qmu.Lock()
if r.trigger == TriggerPull {
for _, q := range a.queue {
if q.revision >= r.revision {
a.qmu.Unlock()
return
}
}
} else {
a.queue = slices.DeleteFunc(a.queue, func(q applyRequest) bool {
return q.trigger == TriggerPull && q.revision <= r.revision
})
}
a.queue = append(a.queue, r)
a.qmu.Unlock()
notify(a.wake)
}
func (a *Agent) dequeue() (applyRequest, bool) {
a.qmu.Lock()
defer a.qmu.Unlock()
if len(a.queue) == 0 {
return applyRequest{}, false
}
r := a.queue[0]
a.queue = a.queue[1:]
return r, true
}
// applyLoop processes requests one at a time.
func (a *Agent) applyLoop(ctx context.Context) {
for {
r, ok := a.dequeue()
if !ok {
select {
case <-ctx.Done():
return
case <-a.wake:
continue
}
}
if ctx.Err() != nil {
return
}
a.handle(ctx, r)
}
}
// handle decides whether a request leads to an apply.
func (a *Agent) handle(ctx context.Context, r applyRequest) {
st := a.state()
log := a.log.With("revision", r.revision, "trigger", r.trigger, "deployment_id", r.deploymentID)
switch {
case r.trigger == TriggerPull && r.revision <= st.Applied:
log.Debug("revision already applied or older", "applied", st.Applied)
return
case r.trigger == TriggerPull && r.revision <= st.LastUnsuccessful:
log.Debug("revision failed before; waiting for a newer revision or an explicit push",
"last_unsuccessful", st.LastUnsuccessful)
return
case r.revision == 0 || r.revision < st.Applied:
now := time.Now()
log.Warn("refusing to apply a revision older than the applied one", "applied", st.Applied)
a.finish(log, DeploymentReport{
DeploymentID: r.deploymentID, Revision: r.revision, Trigger: r.trigger, Outcome: OutcomeRejected,
Message: fmt.Sprintf("revision %d is older than the applied revision %d", r.revision, st.Applied),
ActiveRevision: st.Applied, StartedAt: now, FinishedAt: now,
}, nil)
return
}
a.apply(ctx, r, st, log)
}
// apply runs the pipeline of docs/adr/0021: fetch, verify, store, Validate,
// Activate, Health, rollback. Shutdown aborts it until Activate starts; from
// then on it is completed (bounded by the configured timeouts) so that the
// service is never left between two revisions.
func (a *Agent) apply(ctx context.Context, r applyRequest, st State, log *slog.Logger) {
started := time.Now()
report := DeploymentReport{
DeploymentID: r.deploymentID, Revision: r.revision, Trigger: r.trigger,
ActiveRevision: st.Applied, StartedAt: started,
}
log.Info("applying revision", "applied", st.Applied, "last_good", st.LastGood)
reject := func(msg string, err error) {
report.Outcome = OutcomeRejected
report.Message = fmt.Sprintf("%s: %v", msg, err)
log.Error("revision rejected", "error", report.Message)
a.finish(log, report, func(s *State) { s.LastUnsuccessful = max(s.LastUnsuccessful, r.revision) })
}
fctx, cancel := context.WithTimeout(ctx, a.cfg.ApplyTimeout)
b, err := a.source.FetchBundle(fctx, r.revision)
cancel()
if err != nil {
if ctx.Err() != nil {
return
}
log.Warn("cannot fetch bundle", "error", err)
if r.trigger == TriggerPush {
// Transport problems do not block later pulls of this revision.
report.Outcome = OutcomeRejected
report.Message = "fetch bundle: " + err.Error()
a.finish(log, report, nil)
}
return
}
minRev := st.Applied + 1
if r.trigger == TriggerPush {
minRev = st.Applied // an explicit push may re-apply the current revision
}
m, contents, err := a.verify(b, r.revision, minRev)
if err != nil {
reject("bundle verification failed", err)
return
}
rev, err := a.store.StoreRevision(m, b.Manifest, b.Signature, contents)
if err != nil {
reject("store revision", err)
return
}
if ctx.Err() != nil {
log.Info("shutdown before validation; revision not applied")
return
}
a.roleMu.Lock()
defer a.roleMu.Unlock()
rctx := context.WithoutCancel(ctx)
vctx, cancel := context.WithTimeout(rctx, a.cfg.ApplyTimeout)
err = a.role.Validate(vctx, rev)
cancel()
if err != nil {
reject("validation failed", err)
return
}
if ctx.Err() != nil {
log.Info("shutdown before activation; revision not applied")
return
}
a.mu.Lock()
prev := a.current
a.mu.Unlock()
actx, cancel := context.WithTimeout(rctx, a.cfg.ApplyTimeout)
err = a.role.Activate(actx, rev, prev)
cancel()
activated := err == nil
var reason string
if activated {
h := a.waitHealthy(rctx, &rev)
if h.State == HealthOK {
report.Outcome = OutcomeSucceeded
report.ActiveRevision = rev.Number
log.Info("revision applied")
a.finish(log, report, func(s *State) {
s.Applied, s.LastGood = rev.Number, rev.Number
a.current = &rev
a.health, a.healthAt = h, time.Now()
})
return
}
reason = "health check failed: " + describeHealth(h)
} else {
reason = "activation failed: " + err.Error()
}
log.Error("revision unhealthy, rolling back", "error", reason, "last_good", st.LastGood)
a.rollback(rctx, log, report, rev, activated, reason, st)
}
// rollback restores the last good revision after a failed activation.
func (a *Agent) rollback(ctx context.Context, log *slog.Logger, report DeploymentReport, rev Revision, activated bool, reason string, st State) {
// What is installed if the rollback cannot restore anything: the new
// revision if it was activated, otherwise (the role undid its own
// changes) the previous one.
installed := st.Applied
if activated {
installed = rev.Number
}
fail := func(msg string) {
report.Outcome = OutcomeFailed
report.Message = reason + "; " + msg
report.ActiveRevision = installed
log.Error("deployment failed", "error", report.Message)
a.finish(log, report, func(s *State) {
s.LastUnsuccessful = max(s.LastUnsuccessful, rev.Number)
if installed != s.Applied {
s.Applied = installed
a.current = &rev
}
})
}
if st.LastGood == 0 || st.LastGood == rev.Number {
fail("no last good revision to roll back to")
return
}
good, err := a.store.LoadRevision(st.LastGood)
if err != nil {
fail(fmt.Sprintf("rollback to revision %d impossible: %v", st.LastGood, err))
return
}
actx, cancel := context.WithTimeout(ctx, a.cfg.ApplyTimeout)
err = a.role.Activate(actx, good, &rev)
cancel()
if err != nil {
fail(fmt.Sprintf("rollback to revision %d failed: %v", good.Number, err))
return
}
installed = good.Number
h := a.waitHealthy(ctx, &good)
if h.State != HealthOK {
report.Outcome = OutcomeFailed
report.Message = fmt.Sprintf("%s; revision %d restored but unhealthy: %s", reason, good.Number, describeHealth(h))
} else {
report.Outcome = OutcomeRolledBack
report.Message = fmt.Sprintf("%s; rolled back to revision %d", reason, good.Number)
}
report.ActiveRevision = good.Number
log.Warn("deployment rolled back", "outcome", report.Outcome, "active_revision", good.Number)
a.finish(log, report, func(s *State) {
s.LastUnsuccessful = max(s.LastUnsuccessful, rev.Number)
s.Applied = good.Number
a.current = &good
a.health, a.healthAt = h, time.Now()
})
}
// verify checks a fetched bundle against the bundle signing key.
func (a *Agent) verify(b *Bundle, revision, minRev uint64) (*bundle.Manifest, map[string][]byte, error) {
contents := make(map[string][]byte, len(b.Artifacts))
for _, art := range b.Artifacts {
if _, dup := contents[art.Path]; dup {
return nil, nil, fmt.Errorf("duplicate artifact %s", art.Path)
}
contents[art.Path] = art.Content
}
m, err := bundle.Verify(a.bundleKey, b.Manifest, b.Signature, contents, bundle.Expect{
AgentID: a.agentID, Role: string(a.kind), MinRevision: minRev,
})
if err != nil {
return nil, nil, err
}
if m.Revision != revision {
return nil, nil, fmt.Errorf("bundle holds revision %d instead of %d", m.Revision, revision)
}
for _, f := range m.Files {
if reservedArtifact(f.Path) {
return nil, nil, fmt.Errorf("artifact path %s is reserved", f.Path)
}
}
return m, contents, nil
}
// waitHealthy calls Health every healthRetry until it reports OK or the
// health timeout has passed, and returns the last result.
func (a *Agent) waitHealthy(ctx context.Context, rev *Revision) Health {
ctx, cancel := context.WithTimeout(ctx, a.cfg.HealthTimeout)
defer cancel()
t := time.NewTimer(0)
defer t.Stop()
for {
h := a.role.Health(ctx, rev)
if h.State == HealthOK {
return h
}
t.Reset(a.healthRetry)
select {
case <-ctx.Done():
return h
case <-t.C:
}
}
}
// finish records the outcome: it updates the state (update runs under the
// state lock), queues the report, persists both and triggers the report
// delivery.
func (a *Agent) finish(log *slog.Logger, r DeploymentReport, update func(*State)) {
r.Message = truncate(r.Message, maxMessageLen)
if r.FinishedAt.IsZero() {
r.FinishedAt = time.Now()
}
a.mu.Lock()
if update != nil {
update(a.st)
}
last := r
a.st.LastResult = &last
a.st.PendingReports = append(a.st.PendingReports, r)
a.st.UpdatedAt = time.Now().UTC()
err := a.store.SaveState(a.st)
a.mu.Unlock()
if err != nil {
log.Error("cannot save state", "error", err)
}
a.prune()
notify(a.flushCh)
}
// prune removes old revisions, keeping the applied and last good one.
func (a *Agent) prune() {
st := a.state()
if err := a.store.Prune(KeepRevisions, st.Applied, st.LastGood); err != nil {
a.log.Warn("cannot prune old revisions", "error", err)
}
}
// reportLoop delivers queued deployment reports.
func (a *Agent) reportLoop(ctx context.Context) {
t := time.NewTicker(a.reportRetry)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-a.flushCh:
case <-t.C:
}
a.flushReports(ctx)
}
}
// flushReports sends queued reports in order until one fails.
func (a *Agent) flushReports(ctx context.Context) {
for ctx.Err() == nil {
a.mu.Lock()
if len(a.st.PendingReports) == 0 {
a.mu.Unlock()
return
}
r := a.st.PendingReports[0]
a.mu.Unlock()
rctx, cancel := context.WithTimeout(ctx, reportTimeout)
err := a.source.ReportDeployment(rctx, r)
cancel()
if err != nil {
a.log.Debug("cannot deliver deployment report; will retry", "revision", r.Revision,
"deployment_id", r.DeploymentID, "error", err)
return
}
a.log.Info("deployment reported", "revision", r.Revision, "deployment_id", r.DeploymentID, "outcome", r.Outcome)
a.mu.Lock()
if i := slices.IndexFunc(a.st.PendingReports, func(p DeploymentReport) bool { return reflect.DeepEqual(p, r) }); i >= 0 {
a.st.PendingReports = slices.Delete(a.st.PendingReports, i, i+1)
}
err = a.store.SaveState(a.st)
a.mu.Unlock()
if err != nil {
a.log.Error("cannot save state", "error", err)
}
}
}
// describeHealth renders a non-OK health result for reports.
func describeHealth(h Health) string {
var b strings.Builder
b.WriteString(h.State.String())
if h.Message != "" {
b.WriteString(": " + h.Message)
}
for _, c := range h.Checks {
if c.State == HealthOK {
continue
}
fmt.Fprintf(&b, "; %s: %s", c.Name, c.State)
if c.Message != "" {
b.WriteString(" (" + c.Message + ")")
}
}
return b.String()
}
// String returns the lowercase name of the state.
func (s HealthState) String() string {
switch s {
case HealthOK:
return "ok"
case HealthDegraded:
return "degraded"
case HealthFailed:
return "failed"
default:
return "unknown"
}
}
// truncate shortens s to at most n bytes without splitting a UTF-8
// sequence.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
const marker = " [truncated]"
cut := n - len(marker)
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + marker
}
// notify performs a non-blocking send on a 1-buffered channel.
func notify(ch chan struct{}) {
select {
case ch <- struct{}{}:
default:
}
}
// errRoleNotAvailable is returned by the placeholder role factory.
var errRoleNotAvailable = errors.New("role not available in this build")
+548
View File
@@ -0,0 +1,548 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
)
func TestApplySucceeded(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.pull(1)
r := f.lastResult(t)
if r.Outcome != OutcomeSucceeded || r.Revision != 1 || r.ActiveRevision != 1 || r.Trigger != TriggerPull {
t.Fatalf("result = %+v", r)
}
if r.StartedAt.IsZero() || r.FinishedAt.Before(r.StartedAt) {
t.Errorf("timestamps = %v .. %v", r.StartedAt, r.FinishedAt)
}
st := f.agent.state()
if st.Applied != 1 || st.LastGood != 1 || st.LastUnsuccessful != 0 {
t.Errorf("state = %+v", st)
}
if got, want := f.role.callLog(), []string{"validate 1", "activate 1 prev none"}; !slices.Equal(got, want) {
t.Errorf("calls = %q, want %q", got, want)
}
// Stored revision: manifest, signature and artifacts.
dir := filepath.Join(f.store.Dir(), "revisions", "1")
for _, name := range []string{"manifest.json", "manifest.sig", "ddi-suite.conf", filepath.Join("zones", "db.example.com")} {
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Errorf("stored revision lacks %s: %v", name, err)
}
}
// The report is persisted until it is delivered.
saved, err := f.store.LoadState()
if err != nil {
t.Fatal(err)
}
if len(saved.PendingReports) != 1 || saved.Applied != 1 {
t.Fatalf("saved state = %+v", saved)
}
f.agent.flushReports(context.Background())
if rep := f.src.reported(); len(rep) != 1 || rep[0].Outcome != OutcomeSucceeded {
t.Fatalf("reports = %+v", rep)
}
if saved, _ := f.store.LoadState(); len(saved.PendingReports) != 0 {
t.Errorf("pending reports after delivery = %d", len(saved.PendingReports))
}
// Next revision: Activate gets the previous one.
f.addBundle(t, 2)
f.role.resetCalls()
f.pull(2)
if got, want := f.role.callLog(), []string{"validate 2", "activate 2 prev 1"}; !slices.Equal(got, want) {
t.Errorf("calls = %q, want %q", got, want)
}
if st := f.agent.state(); st.Applied != 2 || st.LastGood != 2 {
t.Errorf("state = %+v", st)
}
}
func TestApplyRejectedOnValidate(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.addBundle(t, 2)
f.pull(1)
f.role.validateErr[2] = errors.New("zone example.com: bad SOA")
f.role.resetCalls()
f.pull(2)
r := f.lastResult(t)
if r.Outcome != OutcomeRejected || r.ActiveRevision != 1 || !strings.Contains(r.Message, "bad SOA") {
t.Fatalf("result = %+v", r)
}
if got := f.role.callLog(); !slices.Equal(got, []string{"validate 2"}) {
t.Errorf("calls = %q", got)
}
st := f.agent.state()
if st.Applied != 1 || st.LastGood != 1 || st.LastUnsuccessful != 2 {
t.Errorf("state = %+v", st)
}
// No automatic retry of the same revision.
f.role.resetCalls()
f.src.fetches = 0
f.pull(2)
if len(f.role.callLog()) != 0 || f.src.fetches != 0 {
t.Errorf("rejected revision was retried: calls %q, fetches %d", f.role.callLog(), f.src.fetches)
}
// An explicit push retries it.
delete(f.role.validateErr, 2)
f.push(2, "dep-7")
r = f.lastResult(t)
if r.Outcome != OutcomeSucceeded || r.DeploymentID != "dep-7" || r.Trigger != TriggerPush {
t.Fatalf("push result = %+v", r)
}
// A newer revision is pulled again.
f.addBundle(t, 3)
f.pull(3)
if st := f.agent.state(); st.Applied != 3 {
t.Errorf("state = %+v", st)
}
}
func TestRollbackOnActivateError(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.addBundle(t, 2)
f.pull(1)
f.role.activateErr[2] = errors.New("rndc reconfig failed")
f.role.resetCalls()
f.pull(2)
r := f.lastResult(t)
if r.Outcome != OutcomeRolledBack || r.ActiveRevision != 1 || !strings.Contains(r.Message, "rndc reconfig failed") {
t.Fatalf("result = %+v", r)
}
want := []string{"validate 2", "activate 2 prev 1", "activate 1 prev 2"}
if got := f.role.callLog(); !slices.Equal(got, want) {
t.Errorf("calls = %q, want %q", got, want)
}
st := f.agent.state()
if st.Applied != 1 || st.LastGood != 1 || st.LastUnsuccessful != 2 {
t.Errorf("state = %+v", st)
}
// Not retried by a later poll.
f.role.resetCalls()
f.pull(2)
if len(f.role.callLog()) != 0 {
t.Errorf("rolled back revision retried: %q", f.role.callLog())
}
}
func TestRollbackOnHealthFailure(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.addBundle(t, 2)
f.pull(1)
f.role.unhealthy[2] = true
f.pull(2)
r := f.lastResult(t)
if r.Outcome != OutcomeRolledBack || r.ActiveRevision != 1 {
t.Fatalf("result = %+v", r)
}
if !strings.Contains(r.Message, "serial mismatch") || !strings.Contains(r.Message, "rolled back to revision 1") {
t.Errorf("message = %q", r.Message)
}
if n := f.role.healthCalls[2]; n < 2 {
t.Errorf("health of revision 2 checked %d times, want retries", n)
}
if f.role.active != 1 {
t.Errorf("active = %d", f.role.active)
}
if st := f.agent.state(); st.Applied != 1 || st.LastGood != 1 {
t.Errorf("state = %+v", st)
}
}
func TestFailedWithoutLastGood(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.role.unhealthy[1] = true
f.pull(1)
r := f.lastResult(t)
if r.Outcome != OutcomeFailed || r.ActiveRevision != 1 || !strings.Contains(r.Message, "no last good revision") {
t.Fatalf("result = %+v", r)
}
st := f.agent.state()
if st.Applied != 1 || st.LastGood != 0 || st.LastUnsuccessful != 1 {
t.Errorf("state = %+v", st)
}
// Activation error without last good: the previous state stays.
g := newFixture(t, KindDNS)
g.addBundle(t, 1)
g.role.activateErr[1] = errors.New("boom")
g.pull(1)
if r := g.lastResult(t); r.Outcome != OutcomeFailed || r.ActiveRevision != 0 {
t.Fatalf("result = %+v", r)
}
if st := g.agent.state(); st.Applied != 0 {
t.Errorf("state = %+v", st)
}
}
func TestFailedWhenRollbackFails(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.addBundle(t, 2)
f.pull(1)
f.role.unhealthy[2] = true
f.role.activateErr[1] = errors.New("restore failed")
f.pull(2)
r := f.lastResult(t)
if r.Outcome != OutcomeFailed || r.ActiveRevision != 2 || !strings.Contains(r.Message, "rollback to revision 1 failed: restore failed") {
t.Fatalf("result = %+v", r)
}
if st := f.agent.state(); st.Applied != 2 || st.LastGood != 1 {
t.Errorf("state = %+v", st)
}
}
func TestFailedWhenRollbackUnhealthy(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.addBundle(t, 2)
f.pull(1)
f.role.unhealthy[1] = true
f.role.unhealthy[2] = true
f.pull(2)
r := f.lastResult(t)
if r.Outcome != OutcomeFailed || r.ActiveRevision != 1 || !strings.Contains(r.Message, "restored but unhealthy") {
t.Fatalf("result = %+v", r)
}
if st := f.agent.state(); st.Applied != 1 || st.LastGood != 1 || st.LastUnsuccessful != 2 {
t.Errorf("state = %+v", st)
}
}
func TestReplayProtection(t *testing.T) {
f := newFixture(t, KindDNS)
for rev := uint64(1); rev <= 3; rev++ {
f.addBundle(t, rev)
}
f.pull(3)
if st := f.agent.state(); st.Applied != 3 {
t.Fatalf("state = %+v", st)
}
// Older revisions: a pull is ignored, a push is rejected.
f.role.resetCalls()
f.pull(2)
if len(f.role.callLog()) != 0 || len(f.agent.state().PendingReports) != 0 {
t.Errorf("older pull was processed")
}
f.push(2, "dep-old")
r := f.lastResult(t)
if r.Outcome != OutcomeRejected || r.Revision != 2 || !strings.Contains(r.Message, "older") || r.ActiveRevision != 3 {
t.Fatalf("result = %+v", r)
}
if len(f.role.callLog()) != 0 {
t.Errorf("role called for an older revision: %q", f.role.callLog())
}
// The server answers revision 4 with the (validly signed) bundle of 2.
f.src.bundles[4] = f.src.bundles[2]
f.pull(4)
if r := f.lastResult(t); r.Outcome != OutcomeRejected || !strings.Contains(r.Message, "older than 4") {
t.Fatalf("result = %+v", r)
}
// An explicit push of the applied revision re-applies it.
f.push(3, "dep-again")
if r := f.lastResult(t); r.Outcome != OutcomeSucceeded || r.Revision != 3 {
t.Fatalf("result = %+v", r)
}
if st := f.agent.state(); st.Applied != 3 {
t.Errorf("state = %+v", st)
}
}
func TestRejectedBundles(t *testing.T) {
cases := map[string]func(f *fixture) *Bundle{
"bad signature": func(f *fixture) *Bundle {
b := signBundle(t, f.signer, testAgentID, KindDNS, 1)
b.Signature[0] ^= 1
return b
},
"other agent": func(f *fixture) *Bundle {
return signBundle(t, f.signer, "11111111-2222-4333-8444-555555555555", KindDNS, 1)
},
"other role": func(f *fixture) *Bundle { return signBundle(t, f.signer, testAgentID, KindDHCP, 1) },
"other key": func(*fixture) *Bundle {
_, other := newSigningKey(t)
return signBundle(t, other, testAgentID, KindDNS, 1)
},
"tampered content": func(f *fixture) *Bundle {
b := signBundle(t, f.signer, testAgentID, KindDNS, 1)
b.Artifacts[0].Content = []byte("evil")
return b
},
"duplicate artifact": func(f *fixture) *Bundle {
b := signBundle(t, f.signer, testAgentID, KindDNS, 1)
b.Artifacts = append(b.Artifacts, b.Artifacts[0])
return b
},
}
for name, mk := range cases {
t.Run(name, func(t *testing.T) {
f := newFixture(t, KindDNS)
f.src.bundles[1] = mk(f)
f.pull(1)
r := f.lastResult(t)
if r.Outcome != OutcomeRejected || !strings.Contains(r.Message, "verification failed") {
t.Fatalf("result = %+v", r)
}
if len(f.role.callLog()) != 0 {
t.Errorf("role called: %q", f.role.callLog())
}
if revs, _ := f.store.ListRevisions(); len(revs) != 0 {
t.Errorf("rejected bundle stored: %v", revs)
}
})
}
}
func TestFetchError(t *testing.T) {
f := newFixture(t, KindDNS)
f.src.fetchErr = errors.New("unavailable")
f.pull(1)
if st := f.agent.state(); st.LastResult != nil || st.LastUnsuccessful != 0 {
t.Fatalf("pull fetch error recorded: %+v", st)
}
f.push(1, "dep-1")
r := f.lastResult(t)
if r.Outcome != OutcomeRejected || !strings.Contains(r.Message, "fetch bundle: unavailable") {
t.Fatalf("result = %+v", r)
}
// A transport problem does not block a later pull.
f.src.fetchErr = nil
f.addBundle(t, 1)
f.pull(1)
if st := f.agent.state(); st.Applied != 1 {
t.Errorf("state = %+v", st)
}
}
func TestShutdownBeforeValidation(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
ctx, cancel := context.WithCancel(context.Background())
f.src.onFetch = cancel
f.agent.handle(ctx, applyRequest{revision: 1, trigger: TriggerPull})
if len(f.role.callLog()) != 0 {
t.Errorf("role called after shutdown: %q", f.role.callLog())
}
if st := f.agent.state(); st.LastResult != nil || st.Applied != 0 {
t.Errorf("state = %+v", st)
}
}
func TestReportQueueWhileServerDown(t *testing.T) {
f := newFixture(t, KindDNS)
f.src.setReportErr(errors.New("server down"))
f.addBundle(t, 1)
f.addBundle(t, 2)
f.pull(1)
f.role.validateErr[2] = errors.New("bad")
f.pull(2)
f.agent.flushReports(context.Background())
if n := len(f.src.reported()); n != 0 {
t.Fatalf("%d reports delivered while down", n)
}
// After a restart the queue is still there and is sent in order.
g := newFixtureWithStore(t, KindDNS, f.store, f.signer)
if st, _ := g.store.LoadState(); len(st.PendingReports) != 2 {
t.Fatalf("pending reports after restart = %d", len(st.PendingReports))
}
g.agent.flushReports(context.Background())
rep := g.src.reported()
if len(rep) != 2 || rep[0].Revision != 1 || rep[1].Revision != 2 || rep[1].Outcome != OutcomeRejected {
t.Fatalf("reports = %+v", rep)
}
if st, _ := g.store.LoadState(); len(st.PendingReports) != 0 {
t.Errorf("pending after delivery = %d", len(st.PendingReports))
}
}
func TestPendingReportsBounded(t *testing.T) {
f := newFixture(t, KindDNS)
f.src.setReportErr(errors.New("down"))
for i := 0; i < maxPendingReports+5; i++ {
f.push(1, "dep") // fetch fails: rejected report each time
}
st, err := f.store.LoadState()
if err != nil {
t.Fatal(err)
}
if len(st.PendingReports) != maxPendingReports {
t.Errorf("pending = %d, want %d", len(st.PendingReports), maxPendingReports)
}
}
func TestPruneKeepsAppliedAndLastGood(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.pull(1)
for rev := uint64(2); rev <= 14; rev++ {
f.addBundle(t, rev)
f.role.validateErr[rev] = errors.New("bad")
f.push(rev, "")
}
revs, err := f.store.ListRevisions()
if err != nil {
t.Fatal(err)
}
if !slices.Contains(revs, 1) || len(revs) != KeepRevisions+1 || revs[len(revs)-1] != 14 {
t.Errorf("revisions = %v", revs)
}
}
func TestEnqueue(t *testing.T) {
f := newFixture(t, KindDNS)
a := f.agent
a.enqueue(applyRequest{revision: 5, trigger: TriggerPull})
a.enqueue(applyRequest{revision: 4, trigger: TriggerPull}) // dropped: 5 queued
a.enqueue(applyRequest{revision: 5, trigger: TriggerPull}) // dropped: duplicate
a.enqueue(applyRequest{revision: 6, trigger: TriggerPush, deploymentID: "d"})
a.enqueue(applyRequest{revision: 7, trigger: TriggerPull})
var got []applyRequest
for {
r, ok := a.dequeue()
if !ok {
break
}
got = append(got, r)
}
want := []applyRequest{{revision: 6, trigger: TriggerPush, deploymentID: "d"}, {revision: 7, trigger: TriggerPull}}
if !slices.Equal(got, want) {
t.Errorf("queue = %+v, want %+v", got, want)
}
}
func TestDesiredAndPush(t *testing.T) {
f := newFixture(t, KindDNS)
f.agent.Desired(0, true)
f.agent.Desired(3, false) // manual mode
if _, ok := f.agent.dequeue(); ok {
t.Fatal("request queued for revision 0 or manual mode")
}
f.agent.Desired(3, true)
f.agent.Push(4, "dep")
r1, _ := f.agent.dequeue()
r2, _ := f.agent.dequeue()
if r1.revision != 3 || r1.trigger != TriggerPull || r2.revision != 4 || r2.deploymentID != "dep" {
t.Errorf("requests = %+v %+v", r1, r2)
}
}
func TestRunAppliesAndReports(t *testing.T) {
f := newFixture(t, KindDHCP)
f.addBundle(t, 1)
f.role.setLeases([]Lease{{IP: mustAddr("10.0.0.5"), MAC: "aa:bb:cc:dd:ee:ff", State: LeaseActive}})
f.src.run = func(ctx context.Context, h SourceHandler) error {
h.Connected()
h.Desired(1, true)
<-ctx.Done()
return ctx.Err()
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- f.agent.Run(ctx) }()
waitFor(t, "deployment report", func() bool { return len(f.src.reported()) == 1 })
waitFor(t, "lease snapshot", func() bool { return len(f.src.leaseBatches()) > 0 })
if b := f.src.leaseBatches()[0]; !b.FullSync || len(b.Leases) != 1 {
t.Errorf("first lease batch = %+v", b)
}
cancel()
select {
case err := <-done:
if err != nil {
t.Errorf("Run = %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("Run did not return")
}
}
func TestRunSourceError(t *testing.T) {
f := newFixture(t, KindDNS)
f.src.run = func(context.Context, SourceHandler) error { return errors.New("broken source") }
if err := f.agent.Run(context.Background()); err == nil || !strings.Contains(err.Error(), "broken source") {
t.Errorf("Run = %v", err)
}
}
func TestStatus(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.pull(1)
st := f.agent.Status(context.Background())
if st.Applied != 1 || st.LastGood != 1 || st.Health.State != HealthOK || st.ServiceVersion != "Fake 1.0" || st.CheckedAt.IsZero() {
t.Errorf("status = %+v", st)
}
// While the role is busy the cached result is returned.
f.role.unhealthy[1] = true
f.agent.roleMu.Lock()
cached := f.agent.Status(context.Background())
f.agent.roleMu.Unlock()
if cached.Health.State != HealthOK {
t.Errorf("cached status = %+v", cached)
}
if fresh := f.agent.Status(context.Background()); fresh.Health.State != HealthFailed {
t.Errorf("fresh status = %+v", fresh)
}
}
func TestNewAgentLoadsAppliedRevision(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.pull(1)
g := newFixtureWithStore(t, KindDNS, f.store, f.signer)
if g.agent.current == nil || g.agent.current.Number != 1 || len(g.agent.current.Files) != 2 {
t.Fatalf("current = %+v", g.agent.current)
}
// Role mismatch.
id := &Identity{AgentID: testAgentID}
if _, err := NewAgent(KindDHCP, testConfig(f.store.Dir()), newFakeRole(KindDNS), f.store, id, f.src, testLogger()); err == nil {
t.Error("role mismatch accepted")
}
}
func TestDescribeHealthAndTruncate(t *testing.T) {
h := Health{State: HealthDegraded, Message: "slow", Checks: []HealthCheck{
{Name: "a", State: HealthOK}, {Name: "b", State: HealthFailed, Message: "down"}, {Name: "c", State: HealthUnknown},
}}
if got, want := describeHealth(h), "degraded: slow; b: failed (down); c: unknown"; got != want {
t.Errorf("describeHealth = %q, want %q", got, want)
}
if got := truncate("short", 10); got != "short" {
t.Errorf("truncate = %q", got)
}
long := strings.Repeat("ä", 100) // 200 bytes
got := truncate(long, 50)
if len(got) > 50 || !strings.HasSuffix(got, "[truncated]") || !strings.HasPrefix(got, "ää") {
t.Errorf("truncate = %q (%d bytes)", got, len(got))
}
for _, r := range strings.TrimSuffix(got, " [truncated]") {
if r != 'ä' {
t.Fatalf("truncate split a rune: %q", got)
}
}
}
+272 -12
View File
@@ -1,24 +1,37 @@
// SPDX-License-Identifier: Apache-2.0
// Package core is the role-independent part of the agents: command line,
// enrollment, configuration source, verification and apply pipeline
// (see docs/ARCHITECTURE.md, section 4). M0 provides the command line frame;
// enrollment and the apply pipeline follow in M3.
// configuration, local state, enrollment, the connection to the management
// server and the apply pipeline (see docs/ARCHITECTURE.md, section 4, and
// docs/adr/0021-agent-strecke-m3.md). The roles (BIND, Kea) implement Role.
package core
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
"git.beugsiud.de/beug.k/DDI/internal/buildinfo"
"git.beugsiud.de/beug.k/DDI/internal/logging"
)
// RunCLI dispatches the agent command line and returns the process exit code.
func RunCLI(binary string, role Kind, args []string, stdout, stderr io.Writer) int {
// Main dispatches the agent command line and returns the process exit code.
// factory creates the role for the run command.
func Main(binary string, kind Kind, factory RoleFactory, args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
usage(stderr, binary, role)
usage(stderr, binary, kind)
return 2
}
c := &cli{binary: binary, kind: kind, factory: factory, stdout: stdout, stderr: stderr}
switch args[0] {
case "version", "--version":
if err := buildinfo.Print(stdout, binary); err != nil {
@@ -26,22 +39,269 @@ func RunCLI(binary string, role Kind, args []string, stdout, stderr io.Writer) i
}
return 0
case "help", "-h", "--help":
usage(stdout, binary, role)
usage(stdout, binary, kind)
return 0
case "enroll":
return c.enroll(args[1:])
case "run":
return c.run(args[1:])
case "status":
return c.status(args[1:])
default:
_, _ = fmt.Fprintf(stderr, "%s: unknown command %q\n\n", binary, args[0])
usage(stderr, binary, role)
usage(stderr, binary, kind)
return 2
}
}
func usage(w io.Writer, binary string, role Kind) {
_, _ = fmt.Fprintf(w, `Usage: %s <command>
func usage(w io.Writer, binary string, kind Kind) {
_, _ = fmt.Fprintf(w, `Usage: %[1]s <command> [flags]
Agent role: %s
Agent role: %[2]s
Commands:
enroll register with the management server
--server <host:port> --token <token> --ca-hash sha256:<hex>
[--server-name <name>] [--force]
run run the agent (until SIGINT/SIGTERM)
status show identity, revisions and the last result
version print version information
help show this help
`, binary, role)
Common flags:
--config <file> configuration file (default %[3]s)
`, binary, kind, DefaultConfigPath(kind))
}
type cli struct {
binary string
kind Kind
factory RoleFactory
stdout io.Writer
stderr io.Writer
}
func (c *cli) flags(name string) (*flag.FlagSet, *string) {
fs := flag.NewFlagSet(c.binary+" "+name, flag.ContinueOnError)
fs.SetOutput(c.stderr)
cfg := fs.String("config", DefaultConfigPath(c.kind), "configuration file")
return fs, cfg
}
func (c *cli) fail(err error) int {
_, _ = fmt.Fprintf(c.stderr, "%s: %v\n", c.binary, err)
return 1
}
// parse parses flags and rejects positional arguments.
func parse(fs *flag.FlagSet, args []string) int {
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if fs.NArg() > 0 {
_, _ = fmt.Fprintf(fs.Output(), "unexpected argument %q\n", fs.Arg(0))
return 2
}
return -1
}
func (c *cli) enroll(args []string) int {
fs, cfgPath := c.flags("enroll")
var opts EnrollOptions
fs.StringVar(&opts.Server, "server", "", "agent API address of the management server (host:port)")
fs.StringVar(&opts.Token, "token", "", "one-time enrollment token")
fs.StringVar(&opts.CAHash, "ca-hash", "", "fingerprint of the agent CA (sha256:<hex>)")
fs.StringVar(&opts.ServerName, "server-name", "", "TLS server name (default: host of --server)")
fs.BoolVar(&opts.Force, "force", false, "replace an existing identity")
if code := parse(fs, args); code >= 0 {
return code
}
if opts.Server == "" || opts.Token == "" || opts.CAHash == "" {
_, _ = fmt.Fprintln(c.stderr, "enroll needs --server, --token and --ca-hash")
return 2
}
cfg, err := LoadConfig(*cfgPath, c.kind)
if err != nil {
return c.fail(err)
}
store, unlock, err := openLocked(cfg)
if err != nil {
return c.fail(err)
}
defer unlock()
opts.Kind = c.kind
opts.Hostname, _ = os.Hostname()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
id, err := Enroll(ctx, store, opts)
if err != nil {
return c.fail(err)
}
_, _ = fmt.Fprintf(c.stdout, "Enrolled as agent %s (%s) with %s.\nIdentity stored in %s.\n",
id.AgentID, c.kind, id.Server, store.Dir())
return 0
}
func openLocked(cfg *Config) (*Store, func(), error) {
store, err := OpenStore(cfg.StateDir)
if err != nil {
return nil, nil, err
}
unlock, err := store.Lock()
if err != nil {
return nil, nil, err
}
return store, unlock, nil
}
func (c *cli) run(args []string) int {
fs, cfgPath := c.flags("run")
if code := parse(fs, args); code >= 0 {
return code
}
cfg, err := LoadConfig(*cfgPath, c.kind)
if err != nil {
return c.fail(err)
}
log, err := logging.New(c.stderr, cfg.Log)
if err != nil {
return c.fail(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := runAgent(ctx, c.kind, c.factory, cfg, log); err != nil {
log.Error("agent stopped", "error", err)
return 1
}
return 0
}
// runAgent runs the agent until ctx is canceled.
func runAgent(ctx context.Context, kind Kind, factory RoleFactory, cfg *Config, log *slog.Logger) error {
if factory == nil {
return errRoleNotAvailable
}
store, unlock, err := openLocked(cfg)
if err != nil {
return err
}
defer unlock()
id, err := store.LoadIdentity()
if err != nil {
return err
}
if id.Role != kind {
return fmt.Errorf("identity in %s belongs to a %s agent", store.Dir(), id.Role)
}
role, err := factory(cfg.DecodeRole)
if err != nil {
return fmt.Errorf("%s role: %w", kind, err)
}
log = log.With("agent_id", id.AgentID, "role", string(kind))
hostname, _ := os.Hostname()
src := NewServerSource(store, id, hostname, log)
agent, err := NewAgent(kind, cfg, role, store, id, src, log)
if err != nil {
return err
}
log.Info("agent started", "version", buildinfo.Get().Version, "server", id.Server, "state_dir", store.Dir())
err = agent.Run(ctx)
log.Info("agent stopped; the managed service keeps its configuration")
return err
}
func (c *cli) status(args []string) int {
fs, cfgPath := c.flags("status")
if code := parse(fs, args); code >= 0 {
return code
}
cfg, err := LoadConfig(*cfgPath, c.kind)
if err != nil {
return c.fail(err)
}
store, err := OpenStore(cfg.StateDir)
if err != nil {
return c.fail(err)
}
if err := writeStatus(c.stdout, store); err != nil {
return c.fail(err)
}
return 0
}
// writeStatus prints identity, revisions and the last result.
func writeStatus(w io.Writer, store *Store) error {
var b strings.Builder
line := func(k, format string, a ...any) { fmt.Fprintf(&b, "%-20s %s\n", k+":", fmt.Sprintf(format, a...)) }
line("State directory", "%s", store.Dir())
id, err := store.LoadIdentity()
switch {
case errors.Is(err, ErrNotEnrolled):
line("Identity", "not enrolled")
case err != nil:
return err
default:
leaf := id.Certificate.Leaf
line("Agent ID", "%s", id.AgentID)
line("Role", "%s", id.Role)
line("Server", "%s (TLS name %s)", id.Server, id.ServerName)
line("Enrolled", "%s", id.EnrolledAt.Format(time.RFC3339))
line("Certificate", "valid until %s, renewal from %s", leaf.NotAfter.UTC().Format(time.RFC3339),
renewalDue(leaf).UTC().Format(time.RFC3339))
line("CA fingerprint", "%s", agentpki.Fingerprint(id.CA))
}
st, err := store.LoadState()
if err != nil {
return err
}
revs, err := store.ListRevisions()
if err != nil {
return err
}
line("Applied revision", "%s", revOrNone(st.Applied))
line("Last good revision", "%s", revOrNone(st.LastGood))
if st.LastUnsuccessful != 0 {
line("Last unsuccessful", "%d (retried only by a newer revision or an explicit push)", st.LastUnsuccessful)
}
nums := make([]string, len(revs))
for i, n := range revs {
nums[i] = fmt.Sprint(n)
}
line("Stored revisions", "%s", orNone(strings.Join(nums, ", ")))
if r := st.LastResult; r != nil {
dep := ""
if r.DeploymentID != "" {
dep = ", deployment " + r.DeploymentID
}
line("Last result", "revision %d %s at %s (%s%s)", r.Revision, r.Outcome,
r.FinishedAt.UTC().Format(time.RFC3339), r.Trigger, dep)
if r.Message != "" {
line("Message", "%s", r.Message)
}
} else {
line("Last result", "none")
}
line("Pending reports", "%d", len(st.PendingReports))
if st.LeaseSequence != 0 {
line("Lease sequence", "%d", st.LeaseSequence)
}
_, err = io.WriteString(w, b.String())
return err
}
func revOrNone(n uint64) string {
if n == 0 {
return "none"
}
return fmt.Sprint(n)
}
func orNone(s string) string {
if s == "" {
return "none"
}
return s
}
+156 -3
View File
@@ -4,11 +4,16 @@ package core
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRunCLI(t *testing.T) {
func TestMainBasics(t *testing.T) {
tests := []struct {
name string
args []string
@@ -20,14 +25,19 @@ func TestRunCLI(t *testing.T) {
{name: "version", args: []string{"version"}, wantCode: 0, wantStdout: "ddi-agent-test "},
{name: "--version", args: []string{"--version"}, wantCode: 0, wantStdout: "ddi-agent-test "},
{name: "help", args: []string{"help"}, wantCode: 0, wantStdout: "Agent role: dns"},
{name: "help lists enroll", args: []string{"--help"}, wantCode: 0, wantStdout: "--ca-hash sha256:<hex>"},
{name: "unknown", args: []string{"explode"}, wantCode: 2, wantStderr: `unknown command "explode"`},
{name: "enroll without flags", args: []string{"enroll"}, wantCode: 2, wantStderr: "enroll needs --server"},
{name: "bad flag", args: []string{"run", "--bogus"}, wantCode: 2, wantStderr: "flag provided but not defined"},
{name: "extra argument", args: []string{"status", "extra"}, wantCode: 2, wantStderr: `unexpected argument "extra"`},
{name: "flag help", args: []string{"status", "-h"}, wantCode: 0, wantStderr: "-config"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var out, errOut bytes.Buffer
code := RunCLI("ddi-agent-test", KindDNS, tt.args, &out, &errOut)
code := Main("ddi-agent-test", KindDNS, nil, tt.args, &out, &errOut)
if code != tt.wantCode {
t.Errorf("code = %d, want %d", code, tt.wantCode)
t.Errorf("code = %d, want %d (stderr %q)", code, tt.wantCode, errOut.String())
}
if tt.wantStdout != "" && !strings.Contains(out.String(), tt.wantStdout) {
t.Errorf("stdout %q does not contain %q", out.String(), tt.wantStdout)
@@ -38,3 +48,146 @@ func TestRunCLI(t *testing.T) {
})
}
}
// writeConfig writes a configuration with its own state directory.
func writeConfig(t *testing.T, extra string) (cfgPath, stateDir string) {
t.Helper()
dir := t.TempDir()
stateDir = filepath.Join(dir, "state")
cfgPath = filepath.Join(dir, "agent.yaml")
data := "state_dir: " + filepath.ToSlash(stateDir) + "\nlog:\n format: text\n" + extra
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
return cfgPath, stateDir
}
func TestMainEnrollStatusRun(t *testing.T) {
srv := newFakeServer(t, KindDNS)
addr := srv.start(t)
cfgPath, stateDir := writeConfig(t, "dns:\n zone_dir: /var/lib/bind/x\n")
var out, errOut bytes.Buffer
if code := Main("ddi-agent-dns", KindDNS, nil, []string{"status", "--config", cfgPath}, &out, &errOut); code != 0 {
t.Fatalf("status before enroll = %d: %s", code, errOut.String())
}
if !strings.Contains(out.String(), "not enrolled") {
t.Errorf("status = %q", out.String())
}
// run without identity fails.
out.Reset()
errOut.Reset()
factory := func(decode func(any) error) (Role, error) { return newFakeRole(KindDNS), nil }
if code := Main("ddi-agent-dns", KindDNS, factory, []string{"run", "--config", cfgPath}, &out, &errOut); code != 1 {
t.Fatalf("run before enroll = %d", code)
}
if !strings.Contains(errOut.String(), "not enrolled") {
t.Errorf("stderr = %q", errOut.String())
}
args := []string{"enroll", "--config", cfgPath, "--server", addr, "--token", srv.token,
"--ca-hash", srv.fingerprint(), "--server-name", "localhost"}
out.Reset()
errOut.Reset()
if code := Main("ddi-agent-dns", KindDNS, nil, args, &out, &errOut); code != 0 {
t.Fatalf("enroll = %d: %s", code, errOut.String())
}
if !strings.Contains(out.String(), srv.agentID) {
t.Errorf("enroll output = %q", out.String())
}
out.Reset()
errOut.Reset()
if code := Main("ddi-agent-dns", KindDNS, nil, args, &out, &errOut); code != 1 || !strings.Contains(errOut.String(), "--force") {
t.Errorf("second enroll = %d: %s", code, errOut.String())
}
// Run the agent until revision 1 is reported.
srv.with(func(f *fakeServer) { f.desired = 1 })
cfg, err := LoadConfig(cfgPath, KindDNS)
if err != nil {
t.Fatal(err)
}
var gotSection struct {
ZoneDir string `yaml:"zone_dir"`
}
role := newFakeRole(KindDNS)
factory = func(decode func(any) error) (Role, error) {
if err := decode(&gotSection); err != nil {
return nil, err
}
return role, nil
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runAgent(ctx, KindDNS, factory, cfg, testLogger()) }()
waitFor(t, "deployment report", func() bool { return len(serverReports(srv)) == 1 })
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("runAgent = %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("runAgent did not return")
}
if gotSection.ZoneDir != "/var/lib/bind/x" {
t.Errorf("role section = %+v", gotSection)
}
out.Reset()
errOut.Reset()
if code := Main("ddi-agent-dns", KindDNS, nil, []string{"status", "--config", cfgPath}, &out, &errOut); code != 0 {
t.Fatalf("status = %d: %s", code, errOut.String())
}
for _, want := range []string{srv.agentID, "Applied revision:", "revision 1 succeeded", "Stored revisions: 1", stateDir} {
if !strings.Contains(out.String(), want) {
t.Errorf("status lacks %q:\n%s", want, out.String())
}
}
}
func TestRunAgentErrors(t *testing.T) {
cfgPath, _ := writeConfig(t, "")
cfg, err := LoadConfig(cfgPath, KindDNS)
if err != nil {
t.Fatal(err)
}
if err := runAgent(context.Background(), KindDNS, nil, cfg, testLogger()); !errors.Is(err, errRoleNotAvailable) {
t.Errorf("nil factory: %v", err)
}
// Enrolled as a DHCP agent, run as DNS agent.
store, err := OpenStore(cfg.StateDir)
if err != nil {
t.Fatal(err)
}
pki := newTestPKI(t)
id, key := testIdentity(t, pki)
id.Role = KindDHCP
if err := store.SaveIdentity(id, key, false); err != nil {
t.Fatal(err)
}
factory := func(func(any) error) (Role, error) { return newFakeRole(KindDNS), nil }
if err := runAgent(context.Background(), KindDNS, factory, cfg, testLogger()); err == nil || !strings.Contains(err.Error(), "dhcp agent") {
t.Errorf("role mismatch: %v", err)
}
if err := runAgent(context.Background(), KindDHCP, func(func(any) error) (Role, error) {
return nil, errors.New("kea missing")
}, cfg, testLogger()); err == nil || !strings.Contains(err.Error(), "kea missing") {
t.Errorf("factory error: %v", err)
}
}
func TestMainConfigErrors(t *testing.T) {
cfgPath, _ := writeConfig(t, "bogus: 1\n")
for _, args := range [][]string{
{"run", "--config", cfgPath},
{"status", "--config", cfgPath},
{"enroll", "--config", cfgPath, "--server", "x:1", "--token", "a.b", "--ca-hash", "sha256:00"},
} {
var out, errOut bytes.Buffer
if code := Main("ddi-agent-dns", KindDNS, nil, args, &out, &errOut); code != 1 || !strings.Contains(errOut.String(), "bogus") {
t.Errorf("%v: code %d, stderr %q", args[0], code, errOut.String())
}
}
}
+178
View File
@@ -0,0 +1,178 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"time"
"go.yaml.in/yaml/v3"
"git.beugsiud.de/beug.k/DDI/internal/brand"
"git.beugsiud.de/beug.k/DDI/internal/logging"
)
// Configuration defaults (docs/adr/0021-agent-strecke-m3.md).
const (
DefaultApplyTimeout = 5 * time.Minute
DefaultHealthTimeout = 30 * time.Second
DefaultLeaseInterval = 30 * time.Second
DefaultLeaseFullSync = time.Hour
)
// Config is the agent configuration file (<ConfigDir>/agent-<role>.yaml).
// Server address, server name and the identity are not configured here; the
// enroll command stores them in the state directory.
type Config struct {
// StateDir holds identity, revisions and state.json (ADR 0011).
StateDir string `yaml:"state_dir"`
// Log configures the structured logger (written to stderr).
Log logging.Options `yaml:"log"`
// ApplyTimeout bounds each role call (Validate, Activate) of an apply.
ApplyTimeout time.Duration `yaml:"apply_timeout"`
// HealthTimeout is how long the health check of a freshly activated
// revision may take until it reports OK.
HealthTimeout time.Duration `yaml:"health_timeout"`
// LeaseInterval is the lease polling interval (DHCP).
LeaseInterval time.Duration `yaml:"lease_interval"`
// LeaseFullSync is the interval of full lease snapshots.
LeaseFullSync time.Duration `yaml:"lease_full_sync"`
// role holds the undecoded section of the agent's role.
role yaml.Node
}
// fileConfig mirrors Config for strict decoding, including the role
// sections that are handed to the role factory undecoded.
type fileConfig struct {
StateDir string `yaml:"state_dir"`
Log logging.Options `yaml:"log"`
ApplyTimeout time.Duration `yaml:"apply_timeout"`
HealthTimeout time.Duration `yaml:"health_timeout"`
LeaseInterval time.Duration `yaml:"lease_interval"`
LeaseFullSync time.Duration `yaml:"lease_full_sync"`
DNS yaml.Node `yaml:"dns"`
DHCP yaml.Node `yaml:"dhcp"`
}
// DefaultConfigPath returns <brand.ConfigDir>/agent-<role>.yaml.
func DefaultConfigPath(kind Kind) string {
return filepath.Join(brand.ConfigDir, "agent-"+string(kind)+".yaml")
}
// DefaultStateDir returns <brand.StateDir>/agent-<role>.
func DefaultStateDir(kind Kind) string {
return filepath.Join(brand.StateDir, "agent-"+string(kind))
}
// LoadConfig reads the configuration file of the role. A missing file yields
// the defaults.
func LoadConfig(path string, kind Kind) (*Config, error) {
data, err := os.ReadFile(path) //nolint:gosec // path is given by the operator
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("read configuration: %w", err)
}
cfg, err := ParseConfig(data, kind)
if err != nil {
return nil, fmt.Errorf("configuration %s: %w", path, err)
}
return cfg, nil
}
// ParseConfig decodes a configuration (strictly: unknown keys are errors),
// applies defaults and validates it.
func ParseConfig(data []byte, kind Kind) (*Config, error) {
var fc fileConfig
if len(bytes.TrimSpace(data)) > 0 {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&fc); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
}
cfg := &Config{
StateDir: fc.StateDir, Log: fc.Log, ApplyTimeout: fc.ApplyTimeout,
HealthTimeout: fc.HealthTimeout, LeaseInterval: fc.LeaseInterval, LeaseFullSync: fc.LeaseFullSync,
}
switch kind {
case KindDNS:
cfg.role = fc.DNS
if !isEmptyNode(&fc.DHCP) {
return nil, errors.New("section dhcp is not used by the DNS agent")
}
case KindDHCP:
cfg.role = fc.DHCP
if !isEmptyNode(&fc.DNS) {
return nil, errors.New("section dns is not used by the DHCP agent")
}
default:
return nil, fmt.Errorf("unknown role %q", kind)
}
if cfg.StateDir == "" {
cfg.StateDir = DefaultStateDir(kind)
}
setDefault(&cfg.ApplyTimeout, DefaultApplyTimeout)
setDefault(&cfg.HealthTimeout, DefaultHealthTimeout)
setDefault(&cfg.LeaseInterval, DefaultLeaseInterval)
setDefault(&cfg.LeaseFullSync, DefaultLeaseFullSync)
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
func setDefault(d *time.Duration, def time.Duration) {
if *d == 0 {
*d = def
}
}
func (c *Config) validate() error {
for _, d := range []struct {
name string
v time.Duration
}{
{"apply_timeout", c.ApplyTimeout}, {"health_timeout", c.HealthTimeout},
{"lease_interval", c.LeaseInterval}, {"lease_full_sync", c.LeaseFullSync},
} {
if d.v < time.Second {
return fmt.Errorf("%s must be at least 1s", d.name)
}
}
if c.LeaseFullSync < c.LeaseInterval {
return errors.New("lease_full_sync must not be shorter than lease_interval")
}
if _, err := logging.New(io.Discard, c.Log); err != nil {
return err
}
return nil
}
// isEmptyNode reports whether a section is absent or null.
func isEmptyNode(n *yaml.Node) bool {
return n.Kind == 0 || (n.Kind == yaml.ScalarNode && n.Tag == "!!null")
}
// DecodeRole decodes the role's section into v (strictly). An absent section
// leaves v unchanged so that roles keep their defaults.
func (c *Config) DecodeRole(v any) error {
if isEmptyNode(&c.role) {
return nil
}
data, err := yaml.Marshal(&c.role)
if err != nil {
return fmt.Errorf("encode role section: %w", err)
}
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(v); err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("role section: %w", err)
}
return nil
}
+128
View File
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestParseConfigDefaults(t *testing.T) {
for _, data := range []string{"", " \n", "# only a comment\n"} {
cfg, err := ParseConfig([]byte(data), KindDNS)
if err != nil {
t.Fatalf("%q: %v", data, err)
}
if cfg.StateDir != DefaultStateDir(KindDNS) || cfg.ApplyTimeout != DefaultApplyTimeout ||
cfg.HealthTimeout != 30*time.Second || cfg.LeaseInterval != 30*time.Second || cfg.LeaseFullSync != time.Hour {
t.Errorf("defaults = %+v", cfg)
}
var role struct{ X int }
role.X = 7
if err := cfg.DecodeRole(&role); err != nil || role.X != 7 {
t.Errorf("absent role section: %v, %+v", err, role)
}
}
if !strings.HasSuffix(filepath.ToSlash(DefaultConfigPath(KindDHCP)), "/agent-dhcp.yaml") {
t.Errorf("DefaultConfigPath = %s", DefaultConfigPath(KindDHCP))
}
}
func TestParseConfigFull(t *testing.T) {
data := `
state_dir: /tmp/agent
log:
level: debug
format: text
apply_timeout: 2m
health_timeout: 45s
lease_interval: 10s
lease_full_sync: 30m
dhcp:
config_file: /etc/kea/kea-dhcp4.conf
control_socket: /run/kea/ctrl.sock
`
cfg, err := ParseConfig([]byte(data), KindDHCP)
if err != nil {
t.Fatal(err)
}
if cfg.StateDir != "/tmp/agent" || cfg.Log.Level != "debug" || cfg.Log.Format != "text" ||
cfg.ApplyTimeout != 2*time.Minute || cfg.HealthTimeout != 45*time.Second ||
cfg.LeaseInterval != 10*time.Second || cfg.LeaseFullSync != 30*time.Minute {
t.Errorf("config = %+v", cfg)
}
var role struct {
ConfigFile string `yaml:"config_file"`
ControlSocket string `yaml:"control_socket"`
}
if err := cfg.DecodeRole(&role); err != nil {
t.Fatal(err)
}
if role.ConfigFile != "/etc/kea/kea-dhcp4.conf" || role.ControlSocket != "/run/kea/ctrl.sock" {
t.Errorf("role = %+v", role)
}
// Strict decoding of the role section.
var narrow struct {
ConfigFile string `yaml:"config_file"`
}
if err := cfg.DecodeRole(&narrow); err == nil {
t.Error("unknown role key accepted")
}
}
func TestParseConfigErrors(t *testing.T) {
cases := map[string]string{
"unknown key": "flavor: blue\n",
"other role": "dhcp:\n config_file: x\n",
"short timeout": "health_timeout: 10ms\n",
"full sync": "lease_interval: 2m\nlease_full_sync: 1m\n",
"bad level": "log:\n level: loud\n",
"bad duration": "apply_timeout: soon\n",
"not a mapping": "- a\n- b\n",
"unknown log key": "log:\n flavor: x\n",
"negative timeout": "apply_timeout: -5s\n",
}
for name, data := range cases {
if _, err := ParseConfig([]byte(data), KindDNS); err == nil {
t.Errorf("%s: accepted", name)
}
}
if _, err := ParseConfig(nil, Kind("ntp")); err == nil {
t.Error("unknown role accepted")
}
if _, err := ParseConfig([]byte("dns:\n zone_dir: x\n"), KindDHCP); err == nil {
t.Error("dns section accepted by the DHCP agent")
}
// A null section is fine.
if _, err := ParseConfig([]byte("dhcp:\n"), KindDNS); err != nil {
t.Errorf("null section: %v", err)
}
}
func TestLoadConfig(t *testing.T) {
dir := t.TempDir()
cfg, err := LoadConfig(filepath.Join(dir, "missing.yaml"), KindDNS)
if err != nil || cfg.StateDir != DefaultStateDir(KindDNS) {
t.Fatalf("missing file: %+v, %v", cfg, err)
}
path := filepath.Join(dir, "agent-dns.yaml")
if err := os.WriteFile(path, []byte("state_dir: "+filepath.ToSlash(dir)+"\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg, err = LoadConfig(path, KindDNS)
if err != nil || cfg.StateDir != filepath.ToSlash(dir) {
t.Fatalf("file: %+v, %v", cfg, err)
}
if err := os.WriteFile(path, []byte("bogus: 1\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadConfig(path, KindDNS); err == nil || !strings.Contains(err.Error(), path) {
t.Errorf("error does not name the file: %v", err)
}
if _, err := LoadConfig(dir, KindDNS); err == nil {
t.Error("directory accepted as configuration file")
}
}
+177
View File
@@ -0,0 +1,177 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"crypto/ed25519"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"strings"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
"git.beugsiud.de/beug.k/DDI/internal/buildinfo"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
)
// EnrollOptions are the parameters of the enroll command.
type EnrollOptions struct {
Kind Kind
// Server is the agent API address (host:port; an https:// prefix is
// tolerated).
Server string
// Token is the one-time enrollment token "<id>.<secret>".
Token string
// CAHash is the fingerprint "sha256:<hex>" of the agent CA.
CAHash string
// ServerName is the TLS server name (default: host part of Server).
ServerName string
// Force replaces an existing identity.
Force bool
Hostname string
}
// normalizeServer validates host:port and returns the address and its host.
func normalizeServer(s string) (addr, host string, err error) {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "https://")
s = strings.TrimSuffix(s, "/")
host, port, err := net.SplitHostPort(s)
if err != nil || host == "" || port == "" {
return "", "", fmt.Errorf("server address %q must have the form host:port", s)
}
return s, host, nil
}
// Enroll exchanges the token for a client certificate and stores the
// identity. The server is authenticated by the CA fingerprint: the TLS chain
// it presents must contain a CA with that fingerprint which issued the
// server certificate for ServerName.
func Enroll(ctx context.Context, store *Store, opts EnrollOptions) (*Identity, error) {
addr, host, err := normalizeServer(opts.Server)
if err != nil {
return nil, err
}
if opts.Token == "" || !strings.Contains(opts.Token, ".") {
return nil, errors.New("enrollment token must have the form <id>.<secret>")
}
fingerprint, err := agentpki.ParseFingerprint(opts.CAHash)
if err != nil {
return nil, err
}
serverName := opts.ServerName
if serverName == "" {
serverName = host
}
if store.Enrolled() && !opts.Force {
return nil, errAlreadyEnrolled
}
role, err := roleToProto(opts.Kind)
if err != nil {
return nil, err
}
var (
caMu sync.Mutex
pinnedCA *x509.Certificate
)
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
ServerName: serverName,
// The server is verified against the CA fingerprint below instead
// of a system trust store.
InsecureSkipVerify: true, //nolint:gosec // verified in VerifyPeerCertificate
// No session resumption, so every handshake runs the check below.
SessionTicketsDisabled: true,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
ca, err := agentpki.VerifyChainWithFingerprint(rawCerts, fingerprint, serverName)
if err != nil {
return err
}
caMu.Lock()
pinnedCA = ca
caMu.Unlock()
return nil
},
}
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", addr, err)
}
defer func() { _ = conn.Close() }()
key, csr, err := newKeyAndCSR(opts.Hostname)
if err != nil {
return nil, err
}
rctx, cancel := context.WithTimeout(ctx, rpcTimeout)
defer cancel()
resp, err := agentv1.NewAgentServiceClient(conn).Enroll(rctx, &agentv1.EnrollRequest{
Token: opts.Token, CsrDer: csr, Role: role, Hostname: opts.Hostname,
AgentVersion: buildinfo.Get().Version,
})
if err != nil {
return nil, fmt.Errorf("enroll: %w", err)
}
caMu.Lock()
ca := pinnedCA
caMu.Unlock()
if ca == nil {
return nil, errors.New("enroll: server CA was not verified")
}
if chain := resp.GetCaCertificatesDer(); len(chain) > 0 {
root, err := x509.ParseCertificate(chain[len(chain)-1])
if err != nil || !root.Equal(ca) {
return nil, errors.New("enroll: CA in the response differs from the verified server CA")
}
}
if !agentpki.ValidAgentID(resp.GetAgentId()) {
return nil, fmt.Errorf("enroll: server assigned an invalid agent ID %q", resp.GetAgentId())
}
cert, err := checkIssued(resp.GetCertificateDer(), ca, resp.GetAgentId(), key)
if err != nil {
return nil, fmt.Errorf("enroll: %w", err)
}
if len(resp.GetBundleSigningPublicKey()) != ed25519.PublicKeySize {
return nil, errors.New("enroll: invalid bundle signing key")
}
id := &Identity{
AgentID: resp.GetAgentId(), Role: opts.Kind, Server: addr, ServerName: serverName,
EnrolledAt: time.Now().UTC(), CA: ca, Certificate: keyPair(key, cert),
BundleKey: ed25519.PublicKey(resp.GetBundleSigningPublicKey()),
}
if err := store.SaveIdentity(id, key, opts.Force); err != nil {
return nil, err
}
if opts.Force {
// Reports of the previous identity must not be sent with the new one.
st, err := store.LoadState()
if err == nil && len(st.PendingReports) > 0 {
st.PendingReports = nil
err = store.SaveState(st)
}
if err != nil {
return nil, err
}
}
return id, nil
}
func roleToProto(k Kind) (agentv1.Role, error) {
switch k {
case KindDNS:
return agentv1.Role_ROLE_DNS, nil
case KindDHCP:
return agentv1.Role_ROLE_DHCP, nil
default:
return agentv1.Role_ROLE_UNSPECIFIED, fmt.Errorf("unknown role %q", k)
}
}
+697
View File
@@ -0,0 +1,697 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"fmt"
"io"
"log/slog"
"math/big"
"net"
"net/url"
"os"
"path/filepath"
"slices"
"sync"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
)
const testAgentID = "0b6f3c1e-5d2a-4c8e-9f10-123456789abc"
func testLogger() *slog.Logger {
if testing.Verbose() {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
}
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// waitFor polls cond until it holds or the timeout expires.
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("timeout waiting for %s", what)
}
time.Sleep(10 * time.Millisecond)
}
}
// ---- PKI ----
type testPKI struct {
ca *x509.Certificate
caKey *ecdsa.PrivateKey
serverCert tls.Certificate
}
func newTestPKI(t *testing.T) *testPKI {
t.Helper()
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test agent CA"},
NotBefore: time.Now().Add(-24 * time.Hour), NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &caKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
ca, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
p := &testPKI{ca: ca, caKey: caKey}
srvKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
srvDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "localhost"},
DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}, ca, &srvKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
p.serverCert = tls.Certificate{Certificate: [][]byte{srvDER, ca.Raw}, PrivateKey: srvKey}
return p
}
func (p *testPKI) issueClient(pub crypto.PublicKey, agentID string, notBefore, notAfter time.Time) (*x509.Certificate, error) {
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
if err != nil {
return nil, err
}
der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
SerialNumber: serial, Subject: pkix.Name{CommonName: agentID},
URIs: []*url.URL{agentpki.AgentURI(agentID)},
NotBefore: notBefore, NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}, p.ca, pub, p.caKey)
if err != nil {
return nil, err
}
return x509.ParseCertificate(der)
}
// ---- bundles ----
func newSigningKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return pub, priv
}
// testArtifacts returns the artifacts of a test revision.
func testArtifacts(rev uint64) []bundle.Artifact {
return []bundle.Artifact{
{Path: "ddi-suite.conf", Mode: 0o640, Content: fmt.Appendf(nil, "// revision %d\n", rev)},
{Path: "zones/db.example.com", Mode: 0o644, Content: fmt.Appendf(nil, "example.com. SOA ns 1 %d\n", rev)},
}
}
func signBundle(t testing.TB, key ed25519.PrivateKey, agentID string, role Kind, rev uint64) *Bundle {
t.Helper()
arts := testArtifacts(rev)
manifest, sig, err := bundle.Sign(key, agentID, string(role), rev, time.Unix(1_700_000_000, 0), arts)
if err != nil {
t.Fatal(err)
}
b := &Bundle{Manifest: manifest, Signature: sig}
for _, a := range arts {
b.Artifacts = append(b.Artifacts, BundleArtifact{Path: a.Path, Content: a.Content})
}
return b
}
// ---- fake role ----
type fakeRole struct {
kind Kind
mu sync.Mutex
validateErr map[uint64]error
activateErr map[uint64]error
unhealthy map[uint64]bool
active uint64
calls []string
healthCalls map[uint64]int
leases []Lease
leasesErr error
versionErr error
}
func newFakeRole(kind Kind) *fakeRole {
return &fakeRole{
kind: kind, validateErr: map[uint64]error{}, activateErr: map[uint64]error{},
unhealthy: map[uint64]bool{}, healthCalls: map[uint64]int{},
}
}
func (r *fakeRole) Kind() Kind { return r.kind }
func (r *fakeRole) ServiceVersion(context.Context) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.versionErr != nil {
return "", r.versionErr
}
return "Fake 1.0", nil
}
// checkFiles verifies that the stored revision holds its artifacts.
func checkFiles(rev Revision) error {
for _, f := range rev.Files {
data, err := os.ReadFile(filepath.Join(rev.Dir, filepath.FromSlash(f.Path)))
if err != nil {
return err
}
if int64(len(data)) != f.Size {
return fmt.Errorf("%s: size %d, want %d", f.Path, len(data), f.Size)
}
}
return nil
}
func (r *fakeRole) Validate(_ context.Context, rev Revision) error {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, fmt.Sprintf("validate %d", rev.Number))
if err := checkFiles(rev); err != nil {
return err
}
return r.validateErr[rev.Number]
}
func (r *fakeRole) Activate(_ context.Context, rev Revision, prev *Revision) error {
r.mu.Lock()
defer r.mu.Unlock()
p := "none"
if prev != nil {
p = fmt.Sprint(prev.Number)
}
r.calls = append(r.calls, fmt.Sprintf("activate %d prev %s", rev.Number, p))
if err := r.activateErr[rev.Number]; err != nil {
return err
}
r.active = rev.Number
return nil
}
func (r *fakeRole) Health(_ context.Context, expected *Revision) Health {
r.mu.Lock()
defer r.mu.Unlock()
var n uint64
if expected != nil {
n = expected.Number
}
r.healthCalls[n]++
if r.unhealthy[r.active] {
return Health{State: HealthFailed, Message: "service down",
Checks: []HealthCheck{{Name: "soa", State: HealthFailed, Message: "serial mismatch"}, {Name: "status", State: HealthOK}}}
}
return Health{State: HealthOK}
}
func (r *fakeRole) Leases(context.Context) ([]Lease, error) {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.leases), r.leasesErr
}
func (r *fakeRole) setLeases(l []Lease) {
r.mu.Lock()
defer r.mu.Unlock()
r.leases = l
}
func (r *fakeRole) callLog() []string {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.calls)
}
func (r *fakeRole) activeRevision() uint64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.active
}
func (r *fakeRole) resetCalls() {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = nil
}
// fakeLeaseRole is a role with a LeaseSource; fakeRole itself has the
// method too, so wrap it to hide Leases for the DNS case.
type dnsOnlyRole struct{ *fakeRole }
func (d dnsOnlyRole) Leases() {} // shadows LeaseSource.Leases with another signature
// ---- fake source ----
type fakeSource struct {
mu sync.Mutex
bundles map[uint64]*Bundle
fetchErr error
reportErr error
leaseErr error
reports []DeploymentReport
batches []LeaseBatch
fetches int
onFetch func()
run func(ctx context.Context, h SourceHandler) error
}
func newFakeSource() *fakeSource { return &fakeSource{bundles: map[uint64]*Bundle{}} }
func (s *fakeSource) Run(ctx context.Context, h SourceHandler) error {
if s.run != nil {
return s.run(ctx, h)
}
<-ctx.Done()
return ctx.Err()
}
func (s *fakeSource) FetchBundle(_ context.Context, rev uint64) (*Bundle, error) {
s.mu.Lock()
s.fetches++
b, err, hook := s.bundles[rev], s.fetchErr, s.onFetch
s.mu.Unlock()
if hook != nil {
hook()
}
if err != nil {
return nil, err
}
if b == nil {
return nil, fmt.Errorf("revision %d not found", rev)
}
return b, nil
}
func (s *fakeSource) ReportDeployment(_ context.Context, r DeploymentReport) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.reportErr != nil {
return s.reportErr
}
s.reports = append(s.reports, r)
return nil
}
func (s *fakeSource) ReportLeases(_ context.Context, b LeaseBatch) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.leaseErr != nil {
return s.leaseErr
}
b.Leases = slices.Clone(b.Leases)
s.batches = append(s.batches, b)
return nil
}
func (s *fakeSource) setReportErr(err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.reportErr = err
}
func (s *fakeSource) reported() []DeploymentReport {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Clone(s.reports)
}
func (s *fakeSource) leaseBatches() []LeaseBatch {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Clone(s.batches)
}
// ---- agent fixture ----
type fixture struct {
agent *Agent
role *fakeRole
src *fakeSource
store *Store
signer ed25519.PrivateKey
}
func testConfig(dir string) *Config {
return &Config{
StateDir: dir, ApplyTimeout: 5 * time.Second, HealthTimeout: 300 * time.Millisecond,
LeaseInterval: time.Second, LeaseFullSync: time.Hour,
}
}
func newFixture(t *testing.T, kind Kind) *fixture {
t.Helper()
store, err := OpenStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
return newFixtureWithStore(t, kind, store, nil)
}
func newFixtureWithStore(t *testing.T, kind Kind, store *Store, signer ed25519.PrivateKey) *fixture {
t.Helper()
if signer == nil {
_, signer = newSigningKey(t)
}
role := newFakeRole(kind)
src := newFakeSource()
id := &Identity{AgentID: testAgentID, Role: kind, BundleKey: signer.Public().(ed25519.PublicKey)}
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
return &fixture{agent: a, role: role, src: src, store: store, signer: signer}
}
// addBundle makes revision rev available from the fake source.
func (f *fixture) addBundle(t *testing.T, rev uint64) {
t.Helper()
f.src.mu.Lock()
defer f.src.mu.Unlock()
f.src.bundles[rev] = signBundle(t, f.signer, testAgentID, f.agent.kind, rev)
}
func (f *fixture) pull(rev uint64) {
f.agent.handle(context.Background(), applyRequest{revision: rev, trigger: TriggerPull})
}
func (f *fixture) push(rev uint64, dep string) {
f.agent.handle(context.Background(), applyRequest{revision: rev, trigger: TriggerPush, deploymentID: dep})
}
func (f *fixture) lastResult(t *testing.T) DeploymentReport {
t.Helper()
st := f.agent.state()
if st.LastResult == nil {
t.Fatal("no result recorded")
}
return *st.LastResult
}
// ---- fake gRPC server ----
type fakeServer struct {
agentv1.UnimplementedAgentServiceServer
pki *testPKI
signKey ed25519.PrivateKey
role Kind
mu sync.Mutex
token string
agentID string
serial *big.Int
enrollValidity [2]time.Duration // NotBefore offset, NotAfter offset for Enroll
desired uint64
mode agentv1.OperatingMode
pullInterval time.Duration
heartbeat time.Duration
streamDisabled bool
hellos []*agentv1.Hello
heartbeats int
acks []*agentv1.CommandAck
reports []*agentv1.ReportDeploymentRequest
leaseReports []*agentv1.ReportLeasesRequest
renewals int
polls int
commands chan *agentv1.Command
enrollments int
}
func newFakeServer(t *testing.T, role Kind) *fakeServer {
t.Helper()
_, key := newSigningKey(t)
return &fakeServer{
pki: newTestPKI(t), signKey: key, role: role, token: "0123456789abcdef.secret",
agentID: "11111111-2222-4333-8444-555555555555",
enrollValidity: [2]time.Duration{-time.Minute, 14 * 24 * time.Hour},
mode: agentv1.OperatingMode_OPERATING_MODE_AUTO_PULL,
pullInterval: 50 * time.Millisecond, heartbeat: 50 * time.Millisecond,
commands: make(chan *agentv1.Command, 10),
}
}
// start serves the fake on 127.0.0.1 with mTLS and returns its address.
func (f *fakeServer) start(t *testing.T) string {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
pool := x509.NewCertPool()
pool.AddCert(f.pki.ca)
srv := grpc.NewServer(
grpc.Creds(credentials.NewTLS(&tls.Config{
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{f.pki.serverCert},
ClientAuth: tls.VerifyClientCertIfGiven, ClientCAs: pool,
})),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{MinTime: 10 * time.Second, PermitWithoutStream: true}),
)
agentv1.RegisterAgentServiceServer(srv, f)
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
return lis.Addr().String()
}
func (f *fakeServer) fingerprint() string { return agentpki.Fingerprint(f.pki.ca) }
func (f *fakeServer) authenticate(ctx context.Context) error {
p, ok := peer.FromContext(ctx)
if !ok {
return status.Error(codes.Unauthenticated, "no peer")
}
ti, ok := p.AuthInfo.(credentials.TLSInfo)
if !ok || len(ti.State.PeerCertificates) == 0 {
return status.Error(codes.Unauthenticated, "client certificate required")
}
cert := ti.State.PeerCertificates[0]
id, err := agentpki.AgentID(cert)
if err != nil {
return status.Error(codes.Unauthenticated, err.Error())
}
f.mu.Lock()
defer f.mu.Unlock()
if id != f.agentID || f.serial == nil || cert.SerialNumber.Cmp(f.serial) != 0 {
return status.Error(codes.Unauthenticated, "unknown certificate")
}
return nil
}
func (f *fakeServer) issue(csrDER []byte, notBefore, notAfter time.Time) (*x509.Certificate, error) {
csr, err := x509.ParseCertificateRequest(csrDER)
if err != nil {
return nil, err
}
if err := csr.CheckSignature(); err != nil {
return nil, err
}
f.mu.Lock()
id := f.agentID
f.mu.Unlock()
cert, err := f.pki.issueClient(csr.PublicKey, id, notBefore, notAfter)
if err != nil {
return nil, err
}
f.mu.Lock()
f.serial = cert.SerialNumber
f.mu.Unlock()
return cert, nil
}
func (f *fakeServer) Enroll(_ context.Context, req *agentv1.EnrollRequest) (*agentv1.EnrollResponse, error) {
f.mu.Lock()
token, validity := f.token, f.enrollValidity
f.enrollments++
f.mu.Unlock()
if req.GetToken() != token {
return nil, status.Error(codes.PermissionDenied, "invalid enrollment token")
}
want, _ := roleToProto(f.role)
if req.GetRole() != want {
return nil, status.Error(codes.InvalidArgument, "wrong role")
}
now := time.Now()
cert, err := f.issue(req.GetCsrDer(), now.Add(validity[0]), now.Add(validity[1]))
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
f.mu.Lock()
defer f.mu.Unlock()
return &agentv1.EnrollResponse{
AgentId: f.agentID, CertificateDer: cert.Raw, CaCertificatesDer: [][]byte{f.pki.ca.Raw},
BundleSigningPublicKey: f.signKey.Public().(ed25519.PublicKey),
}, nil
}
func (f *fakeServer) RenewCertificate(ctx context.Context, req *agentv1.RenewCertificateRequest) (*agentv1.RenewCertificateResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
now := time.Now()
cert, err := f.issue(req.GetCsrDer(), now.Add(-time.Minute), now.Add(14*24*time.Hour))
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
f.mu.Lock()
f.renewals++
f.mu.Unlock()
return &agentv1.RenewCertificateResponse{CertificateDer: cert.Raw, CaCertificatesDer: [][]byte{f.pki.ca.Raw}}, nil
}
func (f *fakeServer) Connect(stream agentv1.AgentService_ConnectServer) error {
f.mu.Lock()
disabled := f.streamDisabled
f.mu.Unlock()
if disabled {
return status.Error(codes.Unimplemented, "streams disabled")
}
if err := f.authenticate(stream.Context()); err != nil {
return err
}
first, err := stream.Recv()
if err != nil {
return err
}
if first.GetHello() == nil {
return status.Error(codes.InvalidArgument, "Hello expected")
}
f.mu.Lock()
f.hellos = append(f.hellos, first.GetHello())
w := &agentv1.Welcome{
Mode: f.mode, PullInterval: durationpb.New(f.pullInterval),
HeartbeatInterval: durationpb.New(f.heartbeat), DesiredRevision: f.desired,
}
f.mu.Unlock()
if err := stream.Send(&agentv1.ConnectResponse{Message: &agentv1.ConnectResponse_Welcome{Welcome: w}}); err != nil {
return err
}
recvErr := make(chan error, 1)
go func() {
for {
msg, err := stream.Recv()
if err != nil {
recvErr <- err
return
}
f.mu.Lock()
switch {
case msg.GetHeartbeat() != nil:
f.heartbeats++
case msg.GetCommandAck() != nil:
f.acks = append(f.acks, msg.GetCommandAck())
}
f.mu.Unlock()
}
}()
for {
select {
case err := <-recvErr:
if errors.Is(err, io.EOF) {
return nil
}
return err
case <-stream.Context().Done():
return nil
case c := <-f.commands:
if err := stream.Send(&agentv1.ConnectResponse{Message: &agentv1.ConnectResponse_Command{Command: c}}); err != nil {
return err
}
}
}
}
func (f *fakeServer) GetDesiredState(ctx context.Context, _ *agentv1.GetDesiredStateRequest) (*agentv1.GetDesiredStateResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
f.mu.Lock()
defer f.mu.Unlock()
f.polls++
return &agentv1.GetDesiredStateResponse{DesiredRevision: f.desired, Mode: f.mode, PullInterval: durationpb.New(f.pullInterval)}, nil
}
func (f *fakeServer) FetchBundle(ctx context.Context, req *agentv1.FetchBundleRequest) (*agentv1.FetchBundleResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
f.mu.Lock()
id := f.agentID
f.mu.Unlock()
arts := testArtifacts(req.GetRevision())
manifest, sig, err := bundle.Sign(f.signKey, id, string(f.role), req.GetRevision(), time.Unix(1_700_000_000, 0), arts)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
resp := &agentv1.FetchBundleResponse{Manifest: manifest, Signature: sig}
for _, a := range arts {
resp.Artifacts = append(resp.Artifacts, &agentv1.Artifact{Path: a.Path, Content: a.Content})
}
return resp, nil
}
func (f *fakeServer) ReportDeployment(ctx context.Context, req *agentv1.ReportDeploymentRequest) (*agentv1.ReportDeploymentResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
f.mu.Lock()
defer f.mu.Unlock()
f.reports = append(f.reports, req)
return &agentv1.ReportDeploymentResponse{}, nil
}
func (f *fakeServer) ReportLeases(ctx context.Context, req *agentv1.ReportLeasesRequest) (*agentv1.ReportLeasesResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
f.mu.Lock()
defer f.mu.Unlock()
f.leaseReports = append(f.leaseReports, req)
return &agentv1.ReportLeasesResponse{AcknowledgedSequence: req.GetSequence()}, nil
}
// snapshot returns a copy of the recorded data under the lock.
func (f *fakeServer) with(fn func(f *fakeServer)) {
f.mu.Lock()
defer f.mu.Unlock()
fn(f)
}
+320
View File
@@ -0,0 +1,320 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"time"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
)
// Files in identity/.
const (
keyFile = "key.pem"
certFile = "cert.pem"
caFile = "ca.pem"
bundleKeyFile = "bundle-signing.pub"
agentFile = "agent.json"
newSuffix = ".new"
)
// ErrNotEnrolled is returned by LoadIdentity if the agent has no identity.
var ErrNotEnrolled = errors.New("agent is not enrolled (run the enroll command first)")
// errAlreadyEnrolled is returned by SaveIdentity without force.
var errAlreadyEnrolled = errors.New("agent is already enrolled (use --force to enroll again)")
// Identity is the enrolled identity of the agent.
type Identity struct {
AgentID string
Role Kind
Server string // host:port of the agent API
ServerName string // TLS server name
EnrolledAt time.Time
CA *x509.Certificate
// Certificate holds the client certificate (Leaf set) and its key.
Certificate tls.Certificate
BundleKey ed25519.PublicKey
}
// agentInfo is identity/agent.json.
type agentInfo struct {
AgentID string `json:"agent_id"`
Role Kind `json:"role"`
Server string `json:"server"`
ServerName string `json:"server_name"`
EnrolledAt time.Time `json:"enrolled_at"`
CAFingerprint string `json:"ca_fingerprint"`
}
func (s *Store) identityPath(name string) string {
return filepath.Join(s.dir, identityDir, name)
}
// Enrolled reports whether an identity exists.
func (s *Store) Enrolled() bool {
_, err := os.Stat(s.identityPath(agentFile))
return err == nil
}
// LoadIdentity reads identity/. It completes an interrupted certificate
// renewal (see SaveCertificate).
func (s *Store) LoadIdentity() (*Identity, error) {
data, err := os.ReadFile(s.identityPath(agentFile))
if errors.Is(err, fs.ErrNotExist) {
return nil, ErrNotEnrolled
}
if err != nil {
return nil, fmt.Errorf("read identity: %w", err)
}
var info agentInfo
if err := json.Unmarshal(data, &info); err != nil {
return nil, fmt.Errorf("decode %s: %w", agentFile, err)
}
if !agentpki.ValidAgentID(info.AgentID) {
return nil, fmt.Errorf("%s holds an invalid agent ID", agentFile)
}
id := &Identity{
AgentID: info.AgentID, Role: info.Role, Server: info.Server,
ServerName: info.ServerName, EnrolledAt: info.EnrolledAt,
}
if id.CA, err = readCertPEM(s.identityPath(caFile)); err != nil {
return nil, err
}
if id.BundleKey, err = readBundleKey(s.identityPath(bundleKeyFile)); err != nil {
return nil, err
}
if id.Certificate, err = s.loadKeyPair(); err != nil {
return nil, err
}
return id, nil
}
// loadKeyPair loads key.pem and cert.pem. If they do not match and a
// cert.pem.new matching the key exists, a renewal was interrupted after the
// key was replaced; the new certificate is moved into place.
func (s *Store) loadKeyPair() (tls.Certificate, error) {
keyPEM, err := os.ReadFile(s.identityPath(keyFile))
if err != nil {
return tls.Certificate{}, fmt.Errorf("read identity: %w", err)
}
certPEM, err := os.ReadFile(s.identityPath(certFile))
if err != nil {
return tls.Certificate{}, fmt.Errorf("read identity: %w", err)
}
pair, err := tls.X509KeyPair(certPEM, keyPEM)
if err == nil {
s.removeStaleNew()
return pair, nil
}
newCert, nerr := os.ReadFile(s.identityPath(certFile + newSuffix))
if nerr != nil {
return tls.Certificate{}, fmt.Errorf("load client certificate: %w", err)
}
pair, nerr = tls.X509KeyPair(newCert, keyPEM)
if nerr != nil {
return tls.Certificate{}, fmt.Errorf("load client certificate: %w", err)
}
if err := os.Rename(s.identityPath(certFile+newSuffix), s.identityPath(certFile)); err != nil {
return tls.Certificate{}, fmt.Errorf("complete certificate renewal: %w", err)
}
syncDir(filepath.Join(s.dir, identityDir))
return pair, nil
}
func (s *Store) removeStaleNew() {
_ = os.Remove(s.identityPath(keyFile + newSuffix))
_ = os.Remove(s.identityPath(certFile + newSuffix))
}
// SaveIdentity writes a new identity directory. Without force it refuses to
// replace an existing identity.
func (s *Store) SaveIdentity(id *Identity, key crypto.Signer, force bool) error {
if s.Enrolled() && !force {
return errAlreadyEnrolled
}
keyPEM, err := encodeKey(key)
if err != nil {
return err
}
pubDER, err := x509.MarshalPKIXPublicKey(id.BundleKey)
if err != nil {
return fmt.Errorf("encode bundle signing key: %w", err)
}
info, err := json.MarshalIndent(agentInfo{
AgentID: id.AgentID, Role: id.Role, Server: id.Server, ServerName: id.ServerName,
EnrolledAt: id.EnrolledAt.UTC(), CAFingerprint: agentpki.Fingerprint(id.CA),
}, "", " ")
if err != nil {
return fmt.Errorf("encode identity: %w", err)
}
tmp, err := os.MkdirTemp(s.dir, tmpPrefix+identityDir+"-")
if err != nil {
return fmt.Errorf("create identity directory: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
if err := os.Chmod(tmp, 0o700); err != nil { //nolint:gosec // directories need the search bit
return fmt.Errorf("create identity directory: %w", err)
}
files := []struct {
name string
data []byte
perm fs.FileMode
}{
{keyFile, keyPEM, 0o600},
{certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: id.Certificate.Leaf.Raw}), 0o644},
{caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: id.CA.Raw}), 0o644},
{bundleKeyFile, pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}), 0o644},
{agentFile, append(info, '\n'), 0o644},
}
for _, f := range files {
if err := writeFileSync(filepath.Join(tmp, f.name), f.data, f.perm); err != nil {
return err
}
}
syncDir(tmp)
if err := replaceDir(tmp, filepath.Join(s.dir, identityDir)); err != nil {
return err
}
syncDir(s.dir)
return nil
}
// SaveCertificate stores a renewed key and certificate. Both are written as
// *.new first; key.pem is replaced before cert.pem, and LoadIdentity
// completes the second rename if the process died in between.
func (s *Store) SaveCertificate(key crypto.Signer, cert *x509.Certificate) error {
keyPEM, err := encodeKey(key)
if err != nil {
return err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
keyNew, certNew := s.identityPath(keyFile+newSuffix), s.identityPath(certFile+newSuffix)
if err := writeFileAtomic(keyNew, keyPEM, 0o600); err != nil {
return err
}
if err := writeFileAtomic(certNew, certPEM, 0o644); err != nil {
return err
}
if err := os.Rename(keyNew, s.identityPath(keyFile)); err != nil {
return fmt.Errorf("replace key: %w", err)
}
if err := os.Rename(certNew, s.identityPath(certFile)); err != nil {
return fmt.Errorf("replace certificate: %w", err)
}
syncDir(filepath.Join(s.dir, identityDir))
return nil
}
func readCertPEM(path string) (*x509.Certificate, error) {
data, err := os.ReadFile(path) //nolint:gosec // path built from the state directory
if err != nil {
return nil, fmt.Errorf("read identity: %w", err)
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("%s holds no certificate", filepath.Base(path))
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", filepath.Base(path), err)
}
return cert, nil
}
func readBundleKey(path string) (ed25519.PublicKey, error) {
data, err := os.ReadFile(path) //nolint:gosec // path built from the state directory
if err != nil {
return nil, fmt.Errorf("read identity: %w", err)
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "PUBLIC KEY" {
return nil, fmt.Errorf("%s holds no public key", filepath.Base(path))
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", filepath.Base(path), err)
}
key, ok := pub.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("%s is not an Ed25519 key", filepath.Base(path))
}
return key, nil
}
func encodeKey(key crypto.Signer) ([]byte, error) {
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, fmt.Errorf("encode private key: %w", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil
}
// newKeyAndCSR generates an ECDSA P-256 key and a CSR for it. The server
// sets subject and SANs itself; commonName is informational.
func newKeyAndCSR(commonName string) (*ecdsa.PrivateKey, []byte, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("generate key: %w", err)
}
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
Subject: pkix.Name{CommonName: commonName},
}, key)
if err != nil {
return nil, nil, fmt.Errorf("create certificate request: %w", err)
}
return key, csr, nil
}
// checkIssued verifies a certificate issued by the server: it must chain to
// ca for client authentication, carry agentID and certify key.
func checkIssued(der []byte, ca *x509.Certificate, agentID string, key *ecdsa.PrivateKey) (*x509.Certificate, error) {
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, fmt.Errorf("parse issued certificate: %w", err)
}
roots := x509.NewCertPool()
roots.AddCert(ca)
// Tolerate a server clock that is slightly ahead of ours.
at := time.Now()
if at.Before(cert.NotBefore) {
at = cert.NotBefore
}
if _, err := cert.Verify(x509.VerifyOptions{
Roots: roots, CurrentTime: at, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}); err != nil {
return nil, fmt.Errorf("verify issued certificate: %w", err)
}
id, err := agentpki.AgentID(cert)
if err != nil {
return nil, err
}
if id != agentID {
return nil, fmt.Errorf("issued certificate is for agent %s, not %s", id, agentID)
}
pub, ok := cert.PublicKey.(*ecdsa.PublicKey)
if !ok || !pub.Equal(&key.PublicKey) {
return nil, errors.New("issued certificate does not certify the generated key")
}
return cert, nil
}
// keyPair combines a key and its certificate.
func keyPair(key *ecdsa.PrivateKey, cert *x509.Certificate) tls.Certificate {
return tls.Certificate{Certificate: [][]byte{cert.Raw}, PrivateKey: key, Leaf: cert}
}
+162
View File
@@ -0,0 +1,162 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"fmt"
"net/netip"
"slices"
"time"
)
// maxLeasesPerBatch bounds one ReportLeases request (about 100 bytes per
// lease, far below the default gRPC message limit of 4 MiB).
const maxLeasesPerBatch = 5000
// leasesTimeout bounds one LeaseSource.Leases call.
const leasesTimeout = 30 * time.Second
// leaseTracker remembers what the source was last told.
type leaseTracker struct {
// known holds the leases as last reported, by address.
known map[netip.Addr]Lease
// needFull forces the next report to be a full snapshot (at start,
// after a new connection and after a failed report).
needFull bool
lastFull time.Time
}
// leaseLoop polls the role for leases and reports changes. A full snapshot
// is sent at start, after each new connection, after a failed report and
// every LeaseFullSync.
func (a *Agent) leaseLoop(ctx context.Context, ls LeaseSource) {
t := time.NewTicker(a.cfg.LeaseInterval)
defer t.Stop()
tr := &leaseTracker{needFull: true}
for {
a.pollLeases(ctx, ls, tr)
select {
case <-ctx.Done():
return
case <-t.C:
case <-a.leaseCh:
tr.needFull = true
}
}
}
// pollLeases runs one polling round.
func (a *Agent) pollLeases(ctx context.Context, ls LeaseSource, tr *leaseTracker) {
if !a.roleMu.TryLock() {
return // an apply is running; try again next round
}
lctx, cancel := context.WithTimeout(ctx, leasesTimeout)
cur, err := ls.Leases(lctx)
cancel()
a.roleMu.Unlock()
if err != nil {
if ctx.Err() == nil {
a.log.Warn("cannot read leases", "error", err)
}
return
}
full := tr.needFull || time.Since(tr.lastFull) >= a.cfg.LeaseFullSync
changes, next := diffLeases(tr.known, cur)
toSend := changes
if full {
toSend = sortedLeases(next)
} else if len(changes) == 0 {
return
}
if err := a.sendLeases(ctx, toSend, full); err != nil {
if ctx.Err() == nil {
a.log.Warn("cannot report leases; next report is a full snapshot", "error", err)
}
tr.needFull = true
return
}
tr.known = next
tr.needFull = false
if full {
tr.lastFull = time.Now()
}
}
// sendLeases sends leases in batches of maxLeasesPerBatch. For a full
// snapshot only the first batch carries FullSync; the server replaces its
// view with it and applies the following batches as changes, so a snapshot
// split into several requests ends in the same state. If any batch fails,
// the caller sends a new full snapshot later.
func (a *Agent) sendLeases(ctx context.Context, leases []Lease, full bool) error {
first := true
for len(leases) > 0 || first {
n := min(len(leases), maxLeasesPerBatch)
seq, err := a.nextLeaseSequence()
if err != nil {
return err
}
b := LeaseBatch{Sequence: seq, FullSync: full && first, Leases: leases[:n]}
if err := a.source.ReportLeases(ctx, b); err != nil {
return err
}
a.log.Debug("leases reported", "sequence", seq, "full_sync", b.FullSync, "count", n)
leases = leases[n:]
first = false
}
return nil
}
// nextLeaseSequence increments and persists the lease sequence before it is
// used, so that a restart never reuses a number.
func (a *Agent) nextLeaseSequence() (uint64, error) {
a.mu.Lock()
defer a.mu.Unlock()
a.st.LeaseSequence++
a.st.UpdatedAt = time.Now().UTC()
if err := a.store.SaveState(a.st); err != nil {
return 0, fmt.Errorf("persist lease sequence: %w", err)
}
return a.st.LeaseSequence, nil
}
// diffLeases compares the current leases with the known ones. It returns the
// changes (new or modified leases, and known leases that disappeared, marked
// expired) and the new known set.
func diffLeases(known map[netip.Addr]Lease, cur []Lease) ([]Lease, map[netip.Addr]Lease) {
next := make(map[netip.Addr]Lease, len(cur))
for _, l := range cur {
if l.IP.IsValid() {
next[l.IP] = l
}
}
var changes []Lease
for ip, l := range next {
if old, ok := known[ip]; !ok || !leaseEqual(old, l) {
changes = append(changes, l)
}
}
for ip, l := range known {
if _, ok := next[ip]; !ok {
l.State = LeaseExpired
changes = append(changes, l)
}
}
slices.SortFunc(changes, func(x, y Lease) int { return x.IP.Compare(y.IP) })
return changes, next
}
func sortedLeases(m map[netip.Addr]Lease) []Lease {
out := make([]Lease, 0, len(m))
for _, l := range m {
out = append(out, l)
}
slices.SortFunc(out, func(x, y Lease) int { return x.IP.Compare(y.IP) })
return out
}
func leaseEqual(x, y Lease) bool {
return x.IP == y.IP && x.MAC == y.MAC && x.Hostname == y.Hostname && x.State == y.State &&
x.Expires.Equal(y.Expires) && x.Updated.Equal(y.Updated) && x.SubnetID == y.SubnetID &&
x.ClientID == y.ClientID
}
+192
View File
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"fmt"
"net/netip"
"testing"
"time"
)
func mustAddr(s string) netip.Addr { return netip.MustParseAddr(s) }
func lease(ip, mac string) Lease {
return Lease{
IP: mustAddr(ip), MAC: mac, Hostname: "host-" + ip, State: LeaseActive,
Expires: time.Unix(1_800_000_000, 0), Updated: time.Unix(1_700_000_000, 0), SubnetID: 1,
}
}
func TestDiffLeases(t *testing.T) {
a, b, c := lease("10.0.0.1", "aa:00:00:00:00:01"), lease("10.0.0.2", "aa:00:00:00:00:02"), lease("10.0.0.3", "aa:00:00:00:00:03")
changes, known := diffLeases(nil, []Lease{b, a, {}})
if len(changes) != 2 || changes[0].IP != a.IP || changes[1].IP != b.IP || len(known) != 2 {
t.Fatalf("initial changes = %+v", changes)
}
if changes, _ := diffLeases(known, []Lease{a, b}); len(changes) != 0 {
t.Errorf("unchanged leases reported: %+v", changes)
}
b2 := b
b2.Expires = b.Expires.Add(time.Hour)
changes, next := diffLeases(known, []Lease{b2, c})
if len(changes) != 3 {
t.Fatalf("changes = %+v", changes)
}
if changes[0].IP != a.IP || changes[0].State != LeaseExpired {
t.Errorf("disappeared lease = %+v", changes[0])
}
if !changes[1].Expires.Equal(b2.Expires) || changes[2].IP != c.IP {
t.Errorf("changes = %+v", changes)
}
if len(next) != 2 {
t.Errorf("next = %v", next)
}
// Equal instants in different locations are not a change.
b3 := b2
b3.Expires = b2.Expires.In(time.FixedZone("x", 3600))
if changes, _ := diffLeases(next, []Lease{b3, c}); len(changes) != 0 {
t.Errorf("time zone change reported: %+v", changes)
}
}
func TestPollLeases(t *testing.T) {
f := newFixture(t, KindDHCP)
ctx := context.Background()
tr := &leaseTracker{needFull: true}
a, b := lease("10.0.0.1", "aa:00:00:00:00:01"), lease("10.0.0.2", "aa:00:00:00:00:02")
f.role.setLeases([]Lease{a, b})
f.agent.pollLeases(ctx, f.role, tr)
batches := f.src.leaseBatches()
if len(batches) != 1 || !batches[0].FullSync || batches[0].Sequence != 1 || len(batches[0].Leases) != 2 {
t.Fatalf("batches = %+v", batches)
}
// No change: nothing is sent.
f.agent.pollLeases(ctx, f.role, tr)
if n := len(f.src.leaseBatches()); n != 1 {
t.Fatalf("%d batches after an unchanged poll", n)
}
// b released, a gone, c new: one delta.
b.State = LeaseReleased
c := lease("10.0.0.3", "aa:00:00:00:00:03")
f.role.setLeases([]Lease{b, c})
f.agent.pollLeases(ctx, f.role, tr)
batches = f.src.leaseBatches()
if len(batches) != 2 {
t.Fatalf("batches = %+v", batches)
}
d := batches[1]
if d.FullSync || d.Sequence != 2 || len(d.Leases) != 3 || d.Leases[0].State != LeaseExpired || d.Leases[1].State != LeaseReleased {
t.Fatalf("delta = %+v", d)
}
// A failed report forces a full snapshot next time.
f.src.mu.Lock()
f.src.leaseErr = errors.New("down")
f.src.mu.Unlock()
c.Hostname = "renamed"
f.role.setLeases([]Lease{b, c})
f.agent.pollLeases(ctx, f.role, tr)
if !tr.needFull {
t.Fatal("failure did not request a full snapshot")
}
f.src.mu.Lock()
f.src.leaseErr = nil
f.src.mu.Unlock()
f.agent.pollLeases(ctx, f.role, tr)
batches = f.src.leaseBatches()
last := batches[len(batches)-1]
if !last.FullSync || len(last.Leases) != 2 || last.Sequence != 4 {
t.Fatalf("snapshot after failure = %+v", last)
}
// The sequence survives a restart.
st, err := f.store.LoadState()
if err != nil {
t.Fatal(err)
}
if st.LeaseSequence != 4 {
t.Errorf("persisted sequence = %d", st.LeaseSequence)
}
// The full sync interval triggers a snapshot.
tr.lastFull = time.Now().Add(-2 * time.Hour)
f.agent.pollLeases(ctx, f.role, tr)
batches = f.src.leaseBatches()
if last := batches[len(batches)-1]; !last.FullSync || last.Sequence != 5 {
t.Errorf("periodic snapshot = %+v", last)
}
// Errors of the role and a busy role skip the round.
f.role.mu.Lock()
f.role.leasesErr = errors.New("socket gone")
f.role.mu.Unlock()
n := len(f.src.leaseBatches())
tr.needFull = true
f.agent.pollLeases(ctx, f.role, tr)
f.role.mu.Lock()
f.role.leasesErr = nil
f.role.mu.Unlock()
f.agent.roleMu.Lock()
f.agent.pollLeases(ctx, f.role, tr)
f.agent.roleMu.Unlock()
if len(f.src.leaseBatches()) != n {
t.Error("leases reported despite an error or a running apply")
}
}
func TestPollLeasesEmptySnapshot(t *testing.T) {
f := newFixture(t, KindDHCP)
tr := &leaseTracker{needFull: true}
f.agent.pollLeases(context.Background(), f.role, tr)
b := f.src.leaseBatches()
if len(b) != 1 || !b[0].FullSync || len(b[0].Leases) != 0 {
t.Fatalf("batches = %+v", b)
}
}
func TestLeaseBatching(t *testing.T) {
f := newFixture(t, KindDHCP)
var leases []Lease
for i := 0; i < 2*maxLeasesPerBatch+1; i++ {
ip := netip.AddrFrom4([4]byte{10, byte(i >> 16), byte(i >> 8), byte(i)})
leases = append(leases, Lease{IP: ip, MAC: fmt.Sprintf("aa:00:00:%02x:%02x:%02x", byte(i>>16), byte(i>>8), byte(i)), State: LeaseActive})
}
f.role.setLeases(leases)
f.agent.pollLeases(context.Background(), f.role, &leaseTracker{needFull: true})
b := f.src.leaseBatches()
if len(b) != 3 {
t.Fatalf("%d batches", len(b))
}
if !b[0].FullSync || b[1].FullSync || b[2].FullSync {
t.Errorf("full_sync flags = %v %v %v", b[0].FullSync, b[1].FullSync, b[2].FullSync)
}
if len(b[0].Leases) != maxLeasesPerBatch || len(b[2].Leases) != 1 || b[0].Sequence != 1 || b[2].Sequence != 3 {
t.Errorf("batches: %d/%d/%d leases, sequences %d..%d", len(b[0].Leases), len(b[1].Leases), len(b[2].Leases), b[0].Sequence, b[2].Sequence)
}
}
func TestLeaseLoopNotStartedForDNS(t *testing.T) {
f := newFixture(t, KindDNS)
role := dnsOnlyRole{f.role}
a, err := NewAgent(KindDNS, f.agent.cfg, role, f.store, &Identity{AgentID: testAgentID, BundleKey: f.agent.bundleKey}, f.src, testLogger())
if err != nil {
t.Fatal(err)
}
if _, ok := a.role.(LeaseSource); ok {
t.Fatal("DNS test role implements LeaseSource")
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if err := a.Run(ctx); err != nil {
t.Fatal(err)
}
if n := len(f.src.leaseBatches()); n != 0 {
t.Errorf("%d lease batches from a DNS agent", n)
}
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: Apache-2.0
//go:build linux || darwin || freebsd || netbsd || openbsd || dragonfly
package core
import (
"errors"
"fmt"
"os"
"syscall"
)
// lockPath takes an exclusive, non-blocking flock on path. The lock is
// released by the returned function or when the process exits.
func lockPath(path string) (func(), error) {
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec // path built from the state directory
if err != nil {
return nil, fmt.Errorf("open lock file: %w", err)
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { //nolint:gosec // file descriptors fit into int
_ = f.Close()
if errors.Is(err, syscall.EWOULDBLOCK) {
return nil, fmt.Errorf("state directory is locked by another agent process (%s)", path)
}
return nil, fmt.Errorf("lock state directory: %w", err)
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) //nolint:gosec // file descriptors fit into int
_ = f.Close()
}, nil
}
+11
View File
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: Apache-2.0
//go:build !(linux || darwin || freebsd || netbsd || openbsd || dragonfly)
package core
// lockPath is a no-op on platforms without flock. The agents only run on
// Linux; this keeps the package buildable elsewhere (e.g. for development).
func lockPath(string) (func(), error) {
return func() {}, nil
}
+599
View File
@@ -0,0 +1,599 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
"google.golang.org/protobuf/types/known/timestamppb"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
"git.beugsiud.de/beug.k/DDI/internal/buildinfo"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
)
// Defaults of the server connection (docs/adr/0021-agent-strecke-m3.md).
const (
defaultPullInterval = 60 * time.Second
defaultHeartbeatInterval = 30 * time.Second
keepaliveInterval = 30 * time.Second
keepaliveTimeout = 10 * time.Second
backoffMin = time.Second
backoffMax = 60 * time.Second
welcomeTimeout = 30 * time.Second
rpcTimeout = 30 * time.Second
// renewRetryInterval spaces failed renewal attempts (also with an
// expired certificate, which the server accepts for 30 days).
renewRetryInterval = 15 * time.Minute
// oldConnGrace keeps the connection of a replaced certificate open for
// calls in flight.
oldConnGrace = 30 * time.Second
// maxBundleMessage allows bundles up to the bundle size limit.
maxBundleMessage = bundle.MaxTotalSize + 16<<20
)
// errReconnect ends a session after the certificate was renewed.
var errReconnect = errors.New("reconnecting with the renewed certificate")
// ServerSource is the Source backed by the management server: an mTLS gRPC
// connection with the Connect control stream and GetDesiredState polling as
// fallback (docs/adr/0007-server-agent-grpc-mtls.md). It also renews the
// client certificate.
type ServerSource struct {
store *Store
agentID string
ca *x509.Certificate
server string
serverName string
hostname string
log *slog.Logger
// Timing, shortened by tests.
backoffMin, backoffMax time.Duration
welcomeTimeout time.Duration
renewRetry time.Duration
// dialOptions are appended to the defaults (tests).
dialOptions []grpc.DialOption
mu sync.Mutex
cert tls.Certificate
conn *grpc.ClientConn
pullInterval time.Duration
heartbeatInterval time.Duration
lastRenewTry time.Time
renewMu sync.Mutex
}
// NewServerSource creates the source for an enrolled identity.
func NewServerSource(store *Store, id *Identity, hostname string, log *slog.Logger) *ServerSource {
return &ServerSource{
store: store, agentID: id.AgentID, ca: id.CA, server: id.Server, serverName: id.ServerName,
hostname: hostname, log: log, cert: id.Certificate,
backoffMin: backoffMin, backoffMax: backoffMax, welcomeTimeout: welcomeTimeout,
renewRetry: renewRetryInterval, pullInterval: defaultPullInterval,
heartbeatInterval: defaultHeartbeatInterval,
}
}
// client returns the gRPC client, creating the connection on first use.
// Connections are established lazily by gRPC.
func (s *ServerSource) client() (agentv1.AgentServiceClient, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn == nil {
opts := append([]grpc.DialOption{
grpc.WithTransportCredentials(credentials.NewTLS(agentpki.ClientTLSConfig(s.ca, s.cert, s.serverName))),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: keepaliveInterval, Timeout: keepaliveTimeout, PermitWithoutStream: true,
}),
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxBundleMessage)),
}, s.dialOptions...)
conn, err := grpc.NewClient(s.server, opts...)
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", s.server, err)
}
s.conn = conn
}
return agentv1.NewAgentServiceClient(s.conn), nil
}
// replaceConn drops the current connection (after a certificate renewal);
// the next call dials with the new certificate.
func (s *ServerSource) replaceConn() {
s.mu.Lock()
old := s.conn
s.conn = nil
s.mu.Unlock()
if old != nil {
time.AfterFunc(oldConnGrace, func() { _ = old.Close() })
}
}
func (s *ServerSource) closeConn() {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn != nil {
_ = s.conn.Close()
s.conn = nil
}
}
func (s *ServerSource) intervals() (pull, heartbeat time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
return s.pullInterval, s.heartbeatInterval
}
func (s *ServerSource) setIntervals(pull, heartbeat time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
if pull > 0 {
s.pullInterval = pull
}
if heartbeat > 0 {
s.heartbeatInterval = heartbeat
}
}
// Run implements Source: it keeps a Connect stream open, reconnecting with
// exponential backoff and jitter, and polls GetDesiredState every pull
// interval while no stream can be established.
func (s *ServerSource) Run(ctx context.Context, h SourceHandler) error {
defer s.closeConn()
backoff := s.backoffMin
var lastPoll time.Time
reachable := false
for ctx.Err() == nil {
s.maybeRenew(ctx)
established, err := s.session(ctx, h)
if ctx.Err() != nil {
break
}
wait := true
switch {
case errors.Is(err, errReconnect):
wait = false
backoff = s.backoffMin
case established:
s.log.Warn("control stream to the server closed", "error", err)
backoff = s.backoffMin
reachable = false
default:
s.log.Warn("cannot open control stream", "server", s.server, "error", err)
pull, _ := s.intervals()
if time.Since(lastPoll) >= pull {
lastPoll = time.Now()
if err := s.poll(ctx, h); err != nil {
s.log.Warn("cannot poll the desired state", "error", err)
reachable = false
} else if !reachable {
reachable = true
h.Connected()
}
}
}
if wait {
if !sleepCtx(ctx, jitter(backoff)) {
break
}
backoff = min(backoff*2, s.backoffMax)
}
}
return ctx.Err()
}
// session runs one Connect stream. established reports whether the server
// answered the Hello with a Welcome.
func (s *ServerSource) session(ctx context.Context, h SourceHandler) (established bool, err error) {
cl, err := s.client()
if err != nil {
return false, err
}
sctx, cancel := context.WithCancel(ctx)
defer cancel()
stream, err := cl.Connect(sctx)
if err != nil {
return false, err
}
defer func() { _ = stream.CloseSend() }()
st := h.Status(sctx)
if err := stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_Hello{Hello: &agentv1.Hello{
AgentVersion: buildinfo.Get().Version, AppliedRevision: st.Applied, LastGoodRevision: st.LastGood,
Health: healthToProto(st.Health, st.CheckedAt), ServiceVersion: st.ServiceVersion, Hostname: s.hostname,
}}}); err != nil {
return false, err
}
msgs := make(chan *agentv1.ConnectResponse)
recvErr := make(chan error, 1)
go func() {
for {
msg, err := stream.Recv()
if err != nil {
recvErr <- err
return
}
select {
case msgs <- msg:
case <-sctx.Done():
return
}
}
}()
timer := time.NewTimer(s.welcomeTimeout)
defer timer.Stop()
select {
case <-ctx.Done():
return false, ctx.Err()
case err := <-recvErr:
return false, err
case <-timer.C:
return false, errors.New("server did not answer the Hello in time")
case msg := <-msgs:
w := msg.GetWelcome()
if w == nil {
return false, errors.New("server did not answer the Hello with a Welcome")
}
s.log.Info("connected to the server", "server", s.server, "mode", w.GetMode().String(),
"desired_revision", w.GetDesiredRevision())
s.welcome(w, h, true)
}
_, hbInterval := s.intervals()
heartbeat := time.NewTicker(hbInterval)
defer heartbeat.Stop()
renew := time.NewTimer(s.untilRenewal())
defer renew.Stop()
for {
select {
case <-ctx.Done():
return true, ctx.Err()
case err := <-recvErr:
return true, err
case msg := <-msgs:
switch m := msg.GetMessage().(type) {
case *agentv1.ConnectResponse_Welcome:
s.welcome(m.Welcome, h, false)
_, hb := s.intervals()
heartbeat.Reset(hb)
case *agentv1.ConnectResponse_Command:
if err := s.command(ctx, stream, m.Command, h); err != nil {
return true, err
}
}
case <-heartbeat.C:
st := h.Status(sctx)
if err := stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_Heartbeat{Heartbeat: &agentv1.Heartbeat{
AppliedRevision: st.Applied, LastGoodRevision: st.LastGood, Health: healthToProto(st.Health, st.CheckedAt),
}}}); err != nil {
return true, err
}
case <-renew.C:
if s.maybeRenew(ctx) {
return true, errReconnect
}
renew.Reset(s.untilRenewal())
}
}
}
// welcome applies the settings of a Welcome and announces its desired
// revision.
func (s *ServerSource) welcome(w *agentv1.Welcome, h SourceHandler, first bool) {
s.setIntervals(w.GetPullInterval().AsDuration(), w.GetHeartbeatInterval().AsDuration())
if first {
h.Connected()
}
h.Desired(w.GetDesiredRevision(), w.GetMode() != agentv1.OperatingMode_OPERATING_MODE_MANUAL)
}
// command acknowledges and executes a server command.
func (s *ServerSource) command(ctx context.Context, stream agentv1.AgentService_ConnectClient, c *agentv1.Command, h SourceHandler) error {
ack := func(accepted bool, msg string) error {
return stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_CommandAck{CommandAck: &agentv1.CommandAck{
CommandId: c.GetCommandId(), Accepted: accepted, Message: msg,
}}})
}
log := s.log.With("command_id", c.GetCommandId())
switch a := c.GetAction().(type) {
case *agentv1.Command_ApplyRevision:
rev, dep := a.ApplyRevision.GetRevision(), a.ApplyRevision.GetDeploymentId()
if rev == 0 {
log.Warn("apply command without revision")
return ack(false, "revision is missing")
}
log.Info("apply command received", "revision", rev, "deployment_id", dep)
if err := ack(true, ""); err != nil {
return err
}
h.Push(rev, dep)
return nil
case *agentv1.Command_RenewCertificate:
log.Info("certificate renewal requested by the server")
if err := ack(true, ""); err != nil {
return err
}
if err := s.renew(ctx); err != nil {
log.Error("certificate renewal failed", "error", err)
return nil
}
return errReconnect
default:
log.Warn("unknown command")
return ack(false, "unknown command")
}
}
// poll asks for the desired state once.
func (s *ServerSource) poll(ctx context.Context, h SourceHandler) error {
cl, err := s.client()
if err != nil {
return err
}
st := h.Status(ctx)
rctx, cancel := context.WithTimeout(ctx, rpcTimeout)
defer cancel()
resp, err := cl.GetDesiredState(rctx, &agentv1.GetDesiredStateRequest{AppliedRevision: st.Applied})
if err != nil {
return err
}
s.setIntervals(resp.GetPullInterval().AsDuration(), 0)
s.log.Debug("desired state polled", "desired_revision", resp.GetDesiredRevision(), "mode", resp.GetMode().String())
h.Desired(resp.GetDesiredRevision(), resp.GetMode() != agentv1.OperatingMode_OPERATING_MODE_MANUAL)
return nil
}
// leaf returns the current client certificate.
func (s *ServerSource) leaf() *x509.Certificate {
s.mu.Lock()
defer s.mu.Unlock()
return s.cert.Leaf
}
// renewalDue returns when the certificate should be renewed: when half of
// its lifetime has passed.
func renewalDue(c *x509.Certificate) time.Time {
return c.NotBefore.Add(c.NotAfter.Sub(c.NotBefore) / 2)
}
// untilRenewal returns the time until the next renewal attempt.
func (s *ServerSource) untilRenewal() time.Duration {
due := renewalDue(s.leaf())
s.mu.Lock()
last := s.lastRenewTry
s.mu.Unlock()
if !last.IsZero() && time.Now().After(due) {
due = last.Add(s.renewRetry)
}
return max(time.Until(due), 0)
}
// maybeRenew renews the certificate if half of its lifetime has passed
// (or it expired), spacing failed attempts by renewRetry.
func (s *ServerSource) maybeRenew(ctx context.Context) bool {
leaf := s.leaf()
now := time.Now()
if now.Before(renewalDue(leaf)) {
return false
}
s.mu.Lock()
if !s.lastRenewTry.IsZero() && now.Sub(s.lastRenewTry) < s.renewRetry {
s.mu.Unlock()
return false
}
s.lastRenewTry = now
s.mu.Unlock()
if err := s.renew(ctx); err != nil {
if now.After(leaf.NotAfter) {
s.log.Error("client certificate expired and renewal failed; the agent must be enrolled again if this persists",
"not_after", leaf.NotAfter, "error", err)
} else {
s.log.Warn("certificate renewal failed", "not_after", leaf.NotAfter, "error", err)
}
return false
}
return true
}
// renew obtains a certificate for a new key and stores it.
func (s *ServerSource) renew(ctx context.Context) error {
s.renewMu.Lock()
defer s.renewMu.Unlock()
key, csr, err := newKeyAndCSR(s.agentID)
if err != nil {
return err
}
cl, err := s.client()
if err != nil {
return err
}
rctx, cancel := context.WithTimeout(ctx, rpcTimeout)
defer cancel()
resp, err := cl.RenewCertificate(rctx, &agentv1.RenewCertificateRequest{CsrDer: csr})
if err != nil {
return err
}
if chain := resp.GetCaCertificatesDer(); len(chain) > 0 {
if root, err := x509.ParseCertificate(chain[len(chain)-1]); err != nil || !root.Equal(s.ca) {
s.log.Warn("server sent a different CA certificate; keeping the enrolled CA")
}
}
cert, err := checkIssued(resp.GetCertificateDer(), s.ca, s.agentID, key)
if err != nil {
return err
}
if err := s.store.SaveCertificate(key, cert); err != nil {
return err
}
s.mu.Lock()
s.cert = keyPair(key, cert)
s.lastRenewTry = time.Time{}
s.mu.Unlock()
s.replaceConn()
s.log.Info("client certificate renewed", "not_after", cert.NotAfter)
return nil
}
// FetchBundle implements Source.
func (s *ServerSource) FetchBundle(ctx context.Context, revision uint64) (*Bundle, error) {
cl, err := s.client()
if err != nil {
return nil, err
}
resp, err := cl.FetchBundle(ctx, &agentv1.FetchBundleRequest{Revision: revision})
if err != nil {
return nil, err
}
b := &Bundle{Manifest: resp.GetManifest(), Signature: resp.GetSignature()}
for _, a := range resp.GetArtifacts() {
b.Artifacts = append(b.Artifacts, BundleArtifact{Path: a.GetPath(), Content: a.GetContent()})
}
return b, nil
}
// ReportDeployment implements Source.
func (s *ServerSource) ReportDeployment(ctx context.Context, r DeploymentReport) error {
cl, err := s.client()
if err != nil {
return err
}
_, err = cl.ReportDeployment(ctx, &agentv1.ReportDeploymentRequest{
DeploymentId: r.DeploymentID, Revision: r.Revision, Trigger: triggerToProto(r.Trigger),
Outcome: outcomeToProto(r.Outcome), Message: r.Message, ActiveRevision: r.ActiveRevision,
StartedAt: timestamppb.New(r.StartedAt), FinishedAt: timestamppb.New(r.FinishedAt),
})
return err
}
// ReportLeases implements Source.
func (s *ServerSource) ReportLeases(ctx context.Context, b LeaseBatch) error {
cl, err := s.client()
if err != nil {
return err
}
req := &agentv1.ReportLeasesRequest{Sequence: b.Sequence, FullSync: b.FullSync, Leases: make([]*agentv1.Lease, len(b.Leases))}
for i, l := range b.Leases {
req.Leases[i] = leaseToProto(l)
}
rctx, cancel := context.WithTimeout(ctx, rpcTimeout)
defer cancel()
resp, err := cl.ReportLeases(rctx, req)
if err != nil {
return err
}
if ack := resp.GetAcknowledgedSequence(); ack != b.Sequence {
s.log.Debug("lease report acknowledged with another sequence", "sequence", b.Sequence, "acknowledged", ack)
}
return nil
}
func healthToProto(h Health, at time.Time) *agentv1.HealthStatus {
out := &agentv1.HealthStatus{State: healthStateToProto(h.State), Message: h.Message}
if !at.IsZero() {
out.CheckedAt = timestamppb.New(at)
}
for _, c := range h.Checks {
out.Checks = append(out.Checks, &agentv1.HealthCheck{Name: c.Name, State: healthStateToProto(c.State), Message: c.Message})
}
return out
}
func healthStateToProto(s HealthState) agentv1.HealthState {
switch s {
case HealthOK:
return agentv1.HealthState_HEALTH_STATE_OK
case HealthDegraded:
return agentv1.HealthState_HEALTH_STATE_DEGRADED
case HealthFailed:
return agentv1.HealthState_HEALTH_STATE_FAILED
default:
return agentv1.HealthState_HEALTH_STATE_UNSPECIFIED
}
}
func triggerToProto(t Trigger) agentv1.DeploymentTrigger {
switch t {
case TriggerPull:
return agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PULL
case TriggerPush:
return agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PUSH
default:
return agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_UNSPECIFIED
}
}
func outcomeToProto(o Outcome) agentv1.DeploymentOutcome {
switch o {
case OutcomeSucceeded:
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_SUCCEEDED
case OutcomeRejected:
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_REJECTED
case OutcomeRolledBack:
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_ROLLED_BACK
case OutcomeFailed:
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_FAILED
default:
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_UNSPECIFIED
}
}
func leaseToProto(l Lease) *agentv1.Lease {
out := &agentv1.Lease{
Ip: l.IP.String(), Mac: l.MAC, Hostname: l.Hostname, State: leaseStateToProto(l.State),
SubnetId: l.SubnetID, ClientId: l.ClientID,
}
if !l.Expires.IsZero() {
out.ExpiresAt = timestamppb.New(l.Expires)
}
if !l.Updated.IsZero() {
out.UpdatedAt = timestamppb.New(l.Updated)
}
return out
}
func leaseStateToProto(s LeaseState) agentv1.LeaseState {
switch s {
case LeaseActive:
return agentv1.LeaseState_LEASE_STATE_ACTIVE
case LeaseExpired:
return agentv1.LeaseState_LEASE_STATE_EXPIRED
case LeaseReleased:
return agentv1.LeaseState_LEASE_STATE_RELEASED
case LeaseDeclined:
return agentv1.LeaseState_LEASE_STATE_DECLINED
default:
return agentv1.LeaseState_LEASE_STATE_UNSPECIFIED
}
}
// jitter returns d randomized by ±20 %.
func jitter(d time.Duration) time.Duration {
return time.Duration(float64(d) * (0.8 + 0.4*rand.Float64())) //nolint:gosec // jitter needs no cryptographic randomness
}
// sleepCtx waits for d; it returns false if ctx ended first.
func sleepCtx(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
+412
View File
@@ -0,0 +1,412 @@
// 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")
}
}
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"time"
)
// Source is where the agent learns its desired revision, fetches bundles and
// reports results (docs/adr/0010-agent-binaries-pro-rolle.md). M3 provides
// ServerSource (gRPC); a FileSource for standalone operation (M7) implements
// the same interface without changes to the apply pipeline.
type Source interface {
// Run delivers events to h until ctx is canceled. It returns ctx.Err()
// on shutdown or an error if the source cannot work at all.
Run(ctx context.Context, h SourceHandler) error
// FetchBundle returns the signed bundle of a revision. The caller
// verifies it.
FetchBundle(ctx context.Context, revision uint64) (*Bundle, error)
// ReportDeployment delivers an apply outcome.
ReportDeployment(ctx context.Context, r DeploymentReport) error
// ReportLeases delivers a batch of lease observations.
ReportLeases(ctx context.Context, b LeaseBatch) error
}
// SourceHandler receives the events of a Source. Its methods must not
// block for long.
type SourceHandler interface {
// Connected is called whenever the source (re)established contact;
// the agent then sends queued reports and a full lease snapshot.
Connected()
// Desired announces the revision the agent should run. autoApply is
// false in manual mode, where only explicit pushes are applied.
Desired(revision uint64, autoApply bool)
// Push asks for an explicit apply of a revision. Unlike Desired, it
// also retries a revision that failed before.
Push(revision uint64, deploymentID string)
// Status returns what the agent reports in Hello and heartbeats.
Status(ctx context.Context) AgentStatus
}
// AgentStatus is the agent's view of itself.
type AgentStatus struct {
Applied uint64
LastGood uint64
Health Health
CheckedAt time.Time
ServiceVersion string
}
// Bundle is a signed bundle as delivered by a Source.
type Bundle struct {
Manifest []byte
Signature []byte
Artifacts []BundleArtifact
}
// BundleArtifact is one artifact of a Bundle.
type BundleArtifact struct {
Path string
Content []byte
}
// LeaseBatch is one lease report. Sequence is monotonic per agent. A batch
// with FullSync replaces the server's view of all leases of this agent; the
// following batches (higher sequence) are changes on top of it.
type LeaseBatch struct {
Sequence uint64
FullSync bool
Leases []Lease
}
+362
View File
@@ -0,0 +1,362 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
// Layout of the state directory (docs/adr/0011-agent-zustand-in-dateien.md).
const (
identityDir = "identity"
revisionsDir = "revisions"
stateFile = "state.json"
lockFile = "agent.lock"
manifestFile = "manifest.json"
signatureFile = "manifest.sig"
tmpPrefix = ".tmp-"
oldPrefix = ".old-"
)
// KeepRevisions is the number of stored revisions kept by Prune (the applied
// and the last good revision are kept in addition).
const KeepRevisions = 10
// maxPendingReports bounds the queue of unsent deployment reports.
const maxPendingReports = 100
// Store manages the agent's state directory. Writes are atomic: files are
// written to a temporary file, synced and renamed; directories are built
// under a temporary name and renamed into place.
type Store struct {
dir string
}
// OpenStore creates the state directory if necessary.
func OpenStore(dir string) (*Store, error) {
if dir == "" {
return nil, errors.New("state directory is not set")
}
if err := os.MkdirAll(filepath.Join(dir, revisionsDir), 0o750); err != nil {
return nil, fmt.Errorf("create state directory: %w", err)
}
return &Store{dir: dir}, nil
}
// Dir returns the state directory.
func (s *Store) Dir() string { return s.dir }
// Lock takes the exclusive lock of the state directory. The returned
// function releases it.
func (s *Store) Lock() (func(), error) {
return lockPath(filepath.Join(s.dir, lockFile))
}
// Trigger says why a revision was applied.
type Trigger string
// Triggers.
const (
TriggerPull Trigger = "pull"
TriggerPush Trigger = "push"
)
// Outcome is the result of applying a revision.
type Outcome string
// Outcomes (see DeploymentOutcome in the protocol).
const (
OutcomeSucceeded Outcome = "succeeded"
OutcomeRejected Outcome = "rejected"
OutcomeRolledBack Outcome = "rolled_back"
OutcomeFailed Outcome = "failed"
)
// DeploymentReport is the outcome of one apply, queued in state.json until
// the source accepted it.
type DeploymentReport struct {
DeploymentID string `json:"deployment_id,omitempty"`
Revision uint64 `json:"revision"`
Trigger Trigger `json:"trigger"`
Outcome Outcome `json:"outcome"`
Message string `json:"message,omitempty"`
ActiveRevision uint64 `json:"active_revision"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
}
// State is persisted in state.json.
type State struct {
// Applied is the revision whose configuration is installed (0 = none).
Applied uint64 `json:"applied_revision"`
// LastGood is the last revision that passed its health check.
LastGood uint64 `json:"last_good_revision"`
// LastUnsuccessful is the highest revision that was rejected, rolled
// back or failed. Pulls never retry it; only a newer revision or an
// explicit push does.
LastUnsuccessful uint64 `json:"last_unsuccessful_revision,omitempty"`
// LastResult is the most recent apply outcome.
LastResult *DeploymentReport `json:"last_result,omitempty"`
// LeaseSequence is the sequence number of the last lease report.
LeaseSequence uint64 `json:"lease_sequence,omitempty"`
// PendingReports are deployment reports not yet accepted by the server.
PendingReports []DeploymentReport `json:"pending_reports,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// LoadState reads state.json; a missing file yields the zero state.
func (s *Store) LoadState() (*State, error) {
var st State
data, err := os.ReadFile(filepath.Join(s.dir, stateFile))
if errors.Is(err, fs.ErrNotExist) {
return &st, nil
}
if err != nil {
return nil, fmt.Errorf("read state: %w", err)
}
if err := json.Unmarshal(data, &st); err != nil {
return nil, fmt.Errorf("decode %s: %w", stateFile, err)
}
return &st, nil
}
// SaveState writes state.json atomically.
func (s *Store) SaveState(st *State) error {
if n := len(st.PendingReports); n > maxPendingReports {
st.PendingReports = slices.Clone(st.PendingReports[n-maxPendingReports:])
}
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
return fmt.Errorf("encode state: %w", err)
}
return writeFileAtomic(filepath.Join(s.dir, stateFile), append(data, '\n'), 0o600)
}
// revisionPath returns the directory of revision n.
func (s *Store) revisionPath(n uint64) string {
return filepath.Join(s.dir, revisionsDir, strconv.FormatUint(n, 10))
}
// reservedArtifact reports whether an artifact path would collide with the
// files the store keeps next to the artifacts.
func reservedArtifact(p string) bool {
return p == manifestFile || p == signatureFile
}
// StoreRevision writes a verified bundle to revisions/<n>/ (manifest,
// signature and the artifacts at their manifest paths) and returns it. An
// existing directory for the same number is replaced.
func (s *Store) StoreRevision(m *bundle.Manifest, manifest, signature []byte, contents map[string][]byte) (Revision, error) {
parent := filepath.Join(s.dir, revisionsDir)
tmp, err := os.MkdirTemp(parent, tmpPrefix)
if err != nil {
return Revision{}, fmt.Errorf("create revision directory: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
if err := writeFileSync(filepath.Join(tmp, manifestFile), manifest, 0o640); err != nil {
return Revision{}, err
}
if err := writeFileSync(filepath.Join(tmp, signatureFile), signature, 0o640); err != nil {
return Revision{}, err
}
dirs := map[string]bool{tmp: true}
for _, f := range m.Files {
if err := bundle.CheckPath(f.Path); err != nil {
return Revision{}, err
}
if reservedArtifact(f.Path) {
return Revision{}, fmt.Errorf("artifact path %s is reserved", f.Path)
}
target := filepath.Join(tmp, filepath.FromSlash(f.Path))
dir := filepath.Dir(target)
if !dirs[dir] {
if err := os.MkdirAll(dir, 0o750); err != nil {
return Revision{}, fmt.Errorf("create directory for %s: %w", f.Path, err)
}
dirs[dir] = true
}
if err := writeFileSync(target, contents[f.Path], fs.FileMode(f.Mode&0o777)); err != nil {
return Revision{}, err
}
}
for d := range dirs {
syncDir(d)
}
final := s.revisionPath(m.Revision)
if err := replaceDir(tmp, final); err != nil {
return Revision{}, err
}
syncDir(parent)
return Revision{Number: m.Revision, Dir: final, Files: slices.Clone(m.Files)}, nil
}
// LoadRevision reads a stored revision. Stored revisions were verified
// before they were written (ADR 0009), so only the manifest is parsed.
func (s *Store) LoadRevision(n uint64) (Revision, error) {
dir := s.revisionPath(n)
data, err := os.ReadFile(filepath.Join(dir, manifestFile)) //nolint:gosec // path built from the state directory
if err != nil {
return Revision{}, fmt.Errorf("load revision %d: %w", n, err)
}
var m bundle.Manifest
if err := json.Unmarshal(data, &m); err != nil {
return Revision{}, fmt.Errorf("load revision %d: %w", n, err)
}
if m.Revision != n {
return Revision{}, fmt.Errorf("load revision %d: manifest is for revision %d", n, m.Revision)
}
return Revision{Number: n, Dir: dir, Files: m.Files}, nil
}
// ListRevisions returns the stored revision numbers in ascending order.
func (s *Store) ListRevisions() ([]uint64, error) {
entries, err := os.ReadDir(filepath.Join(s.dir, revisionsDir))
if err != nil {
return nil, fmt.Errorf("list revisions: %w", err)
}
var out []uint64
for _, e := range entries {
if !e.IsDir() {
continue
}
n, err := strconv.ParseUint(e.Name(), 10, 64)
if err != nil || n == 0 || strconv.FormatUint(n, 10) != e.Name() {
continue
}
out = append(out, n)
}
slices.Sort(out)
return out, nil
}
// Prune keeps the newest keep revisions plus the protected ones, removes the
// others and cleans up leftovers of interrupted writes.
func (s *Store) Prune(keep int, protect ...uint64) error {
parent := filepath.Join(s.dir, revisionsDir)
entries, err := os.ReadDir(parent)
if err != nil {
return fmt.Errorf("prune revisions: %w", err)
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), tmpPrefix) || strings.HasPrefix(e.Name(), oldPrefix) {
_ = os.RemoveAll(filepath.Join(parent, e.Name()))
}
}
revs, err := s.ListRevisions()
if err != nil {
return err
}
var errs []error
for i, n := range revs {
if len(revs)-i <= keep || slices.Contains(protect, n) {
continue
}
if err := os.RemoveAll(s.revisionPath(n)); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// writeFileSync creates path with data and syncs it.
func writeFileSync(path string, data []byte, perm fs.FileMode) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) //nolint:gosec // path built from the state directory
if err != nil {
return fmt.Errorf("write %s: %w", filepath.Base(path), err)
}
if err := finishFile(f, data, perm); err != nil {
return fmt.Errorf("write %s: %w", filepath.Base(path), err)
}
return nil
}
func finishFile(f *os.File, data []byte, perm fs.FileMode) error {
_, err := f.Write(data)
if err == nil {
err = f.Chmod(perm) // not subject to the umask
if errors.Is(err, errors.ErrUnsupported) {
err = nil
}
}
if err == nil {
err = f.Sync()
}
if cerr := f.Close(); err == nil {
err = cerr
}
return err
}
// writeFileAtomic replaces path with data: temporary file, fsync, rename,
// fsync of the directory.
func writeFileAtomic(target string, data []byte, perm fs.FileMode) error {
dir := filepath.Dir(target)
f, err := os.CreateTemp(dir, tmpPrefix+filepath.Base(target)+"-")
if err != nil {
return fmt.Errorf("write %s: %w", filepath.Base(target), err)
}
tmp := f.Name()
if err := finishFile(f, data, perm); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("write %s: %w", filepath.Base(target), err)
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("write %s: %w", filepath.Base(target), err)
}
syncDir(dir)
return nil
}
// replaceDir moves the fully written directory tmp to final. An existing
// final directory is moved aside first and removed afterwards.
func replaceDir(tmp, final string) error {
parent := filepath.Dir(final)
var old string
if _, err := os.Stat(final); err == nil {
old = filepath.Join(parent, oldPrefix+filepath.Base(final)+"-"+randomSuffix())
if err := os.Rename(final, old); err != nil {
return fmt.Errorf("replace %s: %w", filepath.Base(final), err)
}
}
if err := os.Rename(tmp, final); err != nil {
if old != "" {
_ = os.Rename(old, final)
}
return fmt.Errorf("replace %s: %w", filepath.Base(final), err)
}
if old != "" {
_ = os.RemoveAll(old)
}
return nil
}
func randomSuffix() string {
var b [6]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// syncDir flushes directory metadata (renames). Errors are ignored because
// some platforms cannot sync directories; the data itself was synced.
func syncDir(dir string) {
d, err := os.Open(dir) //nolint:gosec // path built from the state directory
if err != nil {
return
}
_ = d.Sync()
_ = d.Close()
}
+382
View File
@@ -0,0 +1,382 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"io/fs"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
func openTestStore(t *testing.T) *Store {
t.Helper()
s, err := OpenStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
return s
}
// storeTestRevision verifies and stores a signed test bundle.
func storeTestRevision(t *testing.T, s *Store, key ed25519.PrivateKey, rev uint64) Revision {
t.Helper()
b := signBundle(t, key, testAgentID, KindDNS, rev)
contents := map[string][]byte{}
for _, a := range b.Artifacts {
contents[a.Path] = a.Content
}
m, err := bundle.Verify(key.Public().(ed25519.PublicKey), b.Manifest, b.Signature, contents,
bundle.Expect{AgentID: testAgentID, Role: string(KindDNS)})
if err != nil {
t.Fatal(err)
}
r, err := s.StoreRevision(m, b.Manifest, b.Signature, contents)
if err != nil {
t.Fatal(err)
}
return r
}
func TestOpenStoreEmpty(t *testing.T) {
if _, err := OpenStore(""); err == nil {
t.Error("empty state directory accepted")
}
}
func TestStoreRevision(t *testing.T) {
s := openTestStore(t)
_, key := newSigningKey(t)
r := storeTestRevision(t, s, key, 7)
if r.Number != 7 || r.Dir != filepath.Join(s.Dir(), "revisions", "7") || len(r.Files) != 2 {
t.Fatalf("revision = %+v", r)
}
data, err := os.ReadFile(filepath.Join(r.Dir, "zones", "db.example.com"))
if err != nil || !strings.Contains(string(data), "SOA ns 1 7") {
t.Fatalf("artifact = %q, %v", data, err)
}
if runtime.GOOS != "windows" {
fi, err := os.Stat(filepath.Join(r.Dir, "ddi-suite.conf"))
if err != nil || fi.Mode().Perm() != 0o640 {
t.Errorf("mode = %v, %v", fi.Mode(), err)
}
}
loaded, err := s.LoadRevision(7)
if err != nil || loaded.Number != 7 || loaded.Dir != r.Dir || len(loaded.Files) != 2 {
t.Fatalf("LoadRevision = %+v, %v", loaded, err)
}
// Storing the same number again replaces the directory.
if err := os.WriteFile(filepath.Join(r.Dir, "stale"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
storeTestRevision(t, s, key, 7)
if _, err := os.Stat(filepath.Join(r.Dir, "stale")); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("old directory content survived: %v", err)
}
if _, err := s.LoadRevision(8); err == nil {
t.Error("missing revision loaded")
}
entries, _ := os.ReadDir(filepath.Join(s.Dir(), "revisions"))
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") {
t.Errorf("leftover %s", e.Name())
}
}
}
func TestStoreRevisionReservedPath(t *testing.T) {
s := openTestStore(t)
m := &bundle.Manifest{Revision: 1, Files: []bundle.File{{Path: "manifest.json", Mode: 0o644}}}
if _, err := s.StoreRevision(m, []byte("{}"), []byte("sig"), map[string][]byte{"manifest.json": nil}); err == nil {
t.Error("reserved artifact path accepted")
}
if revs, _ := s.ListRevisions(); len(revs) != 0 {
t.Errorf("revisions = %v", revs)
}
}
func TestLoadRevisionMismatch(t *testing.T) {
s := openTestStore(t)
_, key := newSigningKey(t)
r := storeTestRevision(t, s, key, 3)
if err := os.Rename(r.Dir, filepath.Join(s.Dir(), "revisions", "4")); err != nil {
t.Fatal(err)
}
if _, err := s.LoadRevision(4); err == nil {
t.Error("manifest of another revision accepted")
}
}
func TestPrune(t *testing.T) {
s := openTestStore(t)
_, key := newSigningKey(t)
for rev := uint64(1); rev <= 15; rev++ {
storeTestRevision(t, s, key, rev)
}
for _, name := range []string{".tmp-123", ".old-5-abc", "notanumber", "007"} {
if err := os.Mkdir(filepath.Join(s.Dir(), "revisions", name), 0o750); err != nil {
t.Fatal(err)
}
}
if err := s.Prune(KeepRevisions, 2, 3); err != nil {
t.Fatal(err)
}
revs, err := s.ListRevisions()
if err != nil {
t.Fatal(err)
}
want := []uint64{2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
if !slices.Equal(revs, want) {
t.Errorf("revisions = %v, want %v", revs, want)
}
for _, name := range []string{".tmp-123", ".old-5-abc"} {
if _, err := os.Stat(filepath.Join(s.Dir(), "revisions", name)); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("%s not removed", name)
}
}
if _, err := os.Stat(filepath.Join(s.Dir(), "revisions", "notanumber")); err != nil {
t.Error("foreign directory removed")
}
}
func TestStateRoundTrip(t *testing.T) {
s := openTestStore(t)
st, err := s.LoadState()
if err != nil || st.Applied != 0 {
t.Fatalf("empty state = %+v, %v", st, err)
}
now := time.Now().UTC().Truncate(time.Second)
st = &State{
Applied: 5, LastGood: 4, LastUnsuccessful: 5, LeaseSequence: 9,
LastResult: &DeploymentReport{Revision: 5, Outcome: OutcomeFailed, Trigger: TriggerPush, StartedAt: now, FinishedAt: now},
PendingReports: []DeploymentReport{{Revision: 5, Outcome: OutcomeFailed, DeploymentID: "d"}},
}
if err := s.SaveState(st); err != nil {
t.Fatal(err)
}
got, err := s.LoadState()
if err != nil {
t.Fatal(err)
}
if got.Applied != 5 || got.LastGood != 4 || got.LeaseSequence != 9 || got.LastResult.Outcome != OutcomeFailed ||
len(got.PendingReports) != 1 || got.PendingReports[0].DeploymentID != "d" {
t.Errorf("state = %+v", got)
}
// Human-readable JSON with the documented keys.
raw, _ := os.ReadFile(filepath.Join(s.Dir(), "state.json"))
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, k := range []string{"applied_revision", "last_good_revision", "pending_reports", "lease_sequence"} {
if _, ok := m[k]; !ok {
t.Errorf("state.json lacks %s", k)
}
}
entries, _ := os.ReadDir(s.Dir())
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".tmp-") {
t.Errorf("leftover temporary file %s", e.Name())
}
}
if err := os.WriteFile(filepath.Join(s.Dir(), "state.json"), []byte("{broken"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := s.LoadState(); err == nil {
t.Error("broken state.json accepted")
}
}
func TestLock(t *testing.T) {
s := openTestStore(t)
unlock, err := s.Lock()
if err != nil {
t.Fatal(err)
}
if runtime.GOOS == "linux" {
if _, err := s.Lock(); err == nil || !strings.Contains(err.Error(), "locked") {
t.Errorf("second lock = %v", err)
}
}
unlock()
unlock2, err := s.Lock()
if err != nil {
t.Fatalf("lock after unlock: %v", err)
}
unlock2()
}
// testIdentity creates an identity signed by pki.
func testIdentity(t *testing.T, pki *testPKI) (*Identity, *ecdsa.PrivateKey) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
cert, err := pki.issueClient(&key.PublicKey, testAgentID, time.Now().Add(-time.Hour), time.Now().Add(time.Hour))
if err != nil {
t.Fatal(err)
}
pub, _ := newSigningKey(t)
return &Identity{
AgentID: testAgentID, Role: KindDNS, Server: "ddi.example.com:8444", ServerName: "ddi.example.com",
EnrolledAt: time.Now().UTC().Truncate(time.Second), CA: pki.ca, Certificate: keyPair(key, cert), BundleKey: pub,
}, key
}
func TestIdentityRoundTrip(t *testing.T) {
s := openTestStore(t)
if _, err := s.LoadIdentity(); !errors.Is(err, ErrNotEnrolled) {
t.Fatalf("LoadIdentity = %v", err)
}
pki := newTestPKI(t)
id, key := testIdentity(t, pki)
if err := s.SaveIdentity(id, key, false); err != nil {
t.Fatal(err)
}
if !s.Enrolled() {
t.Error("not enrolled after SaveIdentity")
}
got, err := s.LoadIdentity()
if err != nil {
t.Fatal(err)
}
if got.AgentID != id.AgentID || got.Server != id.Server || got.ServerName != id.ServerName || got.Role != KindDNS ||
!got.CA.Equal(pki.ca) || !got.BundleKey.Equal(id.BundleKey) || !got.Certificate.Leaf.Equal(id.Certificate.Leaf) ||
!got.EnrolledAt.Equal(id.EnrolledAt) {
t.Errorf("identity = %+v", got)
}
if runtime.GOOS != "windows" {
fi, err := os.Stat(filepath.Join(s.Dir(), "identity", "key.pem"))
if err != nil || fi.Mode().Perm() != 0o600 {
t.Errorf("key.pem mode = %v, %v", fi.Mode(), err)
}
di, err := os.Stat(filepath.Join(s.Dir(), "identity"))
if err != nil || di.Mode().Perm() != 0o700 {
t.Errorf("identity dir mode = %v, %v", di.Mode(), err)
}
}
// Already enrolled.
if err := s.SaveIdentity(id, key, false); !errors.Is(err, errAlreadyEnrolled) {
t.Errorf("second SaveIdentity = %v", err)
}
id2, key2 := testIdentity(t, pki)
id2.Server = "other.example.com:8444"
if err := s.SaveIdentity(id2, key2, true); err != nil {
t.Fatal(err)
}
if got, err := s.LoadIdentity(); err != nil || got.Server != "other.example.com:8444" {
t.Errorf("forced identity = %+v, %v", got, err)
}
entries, _ := os.ReadDir(s.Dir())
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") {
t.Errorf("leftover %s", e.Name())
}
}
}
func TestSaveCertificateAndRecovery(t *testing.T) {
s := openTestStore(t)
pki := newTestPKI(t)
id, key := testIdentity(t, pki)
if err := s.SaveIdentity(id, key, false); err != nil {
t.Fatal(err)
}
newKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
newCert, err := pki.issueClient(&newKey.PublicKey, testAgentID, time.Now().Add(-time.Minute), time.Now().Add(2*time.Hour))
if err != nil {
t.Fatal(err)
}
if err := s.SaveCertificate(newKey, newCert); err != nil {
t.Fatal(err)
}
got, err := s.LoadIdentity()
if err != nil || !got.Certificate.Leaf.Equal(newCert) {
t.Fatalf("renewed identity = %v", err)
}
// Simulate a crash between the two renames: key.pem is new, cert.pem
// old, cert.pem.new holds the matching certificate.
key3, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
cert3, err := pki.issueClient(&key3.PublicKey, testAgentID, time.Now().Add(-time.Minute), time.Now().Add(3*time.Hour))
if err != nil {
t.Fatal(err)
}
keyPEM, _ := encodeKey(key3)
dir := filepath.Join(s.Dir(), "identity")
if err := os.WriteFile(filepath.Join(dir, "key.pem"), keyPEM, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "cert.pem.new"), pemCert(cert3), 0o600); err != nil {
t.Fatal(err)
}
got, err = s.LoadIdentity()
if err != nil || !got.Certificate.Leaf.Equal(cert3) {
t.Fatalf("recovered identity: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "cert.pem.new")); !errors.Is(err, fs.ErrNotExist) {
t.Error("cert.pem.new left behind")
}
// A mismatch without a recoverable certificate is an error.
key4, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
keyPEM, _ = encodeKey(key4)
if err := os.WriteFile(filepath.Join(dir, "key.pem"), keyPEM, 0o600); err != nil {
t.Fatal(err)
}
if _, err := s.LoadIdentity(); err == nil {
t.Error("mismatched key pair accepted")
}
}
func pemCert(c *x509.Certificate) []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw})
}
func TestCheckIssued(t *testing.T) {
pki := newTestPKI(t)
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
cert, err := pki.issueClient(&key.PublicKey, testAgentID, time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
if err != nil {
t.Fatal(err)
}
if _, err := checkIssued(cert.Raw, pki.ca, testAgentID, key); err != nil {
t.Fatalf("checkIssued = %v", err)
}
if _, err := checkIssued(cert.Raw, pki.ca, "11111111-2222-4333-8444-555555555555", key); err == nil {
t.Error("other agent ID accepted")
}
other, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if _, err := checkIssued(cert.Raw, pki.ca, testAgentID, other); err == nil {
t.Error("certificate for another key accepted")
}
if _, err := checkIssued(cert.Raw, newTestPKI(t).ca, testAgentID, key); err == nil {
t.Error("certificate of another CA accepted")
}
if _, err := checkIssued([]byte("junk"), pki.ca, testAgentID, key); err == nil {
t.Error("junk accepted")
}
// A server clock slightly ahead is tolerated.
future, err := pki.issueClient(&key.PublicKey, testAgentID, time.Now().Add(2*time.Minute), time.Now().Add(time.Hour))
if err != nil {
t.Fatal(err)
}
if _, err := checkIssued(future.Raw, pki.ca, testAgentID, key); err != nil {
t.Errorf("future NotBefore: %v", err)
}
}