- CLI: enroll, run, status, version, help (core.Main with a role factory); the agent binaries use a placeholder factory until the BIND and Kea roles are wired in. - Enrollment: local ECDSA P-256 key and CSR, TLS 1.3 to the agent API with the server chain pinned to the CA fingerprint. - ServerSource: mTLS gRPC with keepalive, Connect stream (Hello, Welcome, heartbeats, apply and renew commands), reconnect with exponential backoff and jitter, GetDesiredState polling as fallback, certificate renewal after half of the lifetime. - Apply pipeline: fetch, bundle verification with replay protection, store, Validate, Activate, Health with retries, rollback to the last good revision; outcomes succeeded/rejected/rolled_back/failed are queued in state.json and reported; failed revisions are retried only by a newer revision or an explicit push. - Lease reporting with change detection, persistent sequence and full snapshots at start, per connection and periodically. - Tests with fake roles, a fake source and an in-process fake AgentService over real mTLS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
4.5 KiB
Go
163 lines
4.5 KiB
Go
// 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
|
|
}
|