Files
DDI/server/internal/service/agents.go
T
sir_melonchenandClaude Opus 5.5 2b04661792 feat: clusters, TSIG keys, zones and records in service and REST API
Implements the M5 endpoints of the contract (ADR 0023): clusters with
member validation, transfer keys and HA credentials, TSIG keys with
audited secret access and rotation, explicit zones and typed records,
the DHCP cluster of networks, the cluster of agents and the default
clusters and HA password file of the generator settings. Revisions are
stored per target with sealed secret files and sealed source secrets
(restored for rollbacks); bundles carry an agent's own target or the
default group. Active leases are adopted as dynamic addresses.

The derivation is called through a temporary adapter until derive
implements the M5 contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:47:37 +02:00

499 lines
15 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net"
"net/netip"
"regexp"
"slices"
"strings"
"time"
"unicode"
"unicode/utf8"
"git.beugsiud.de/beug.k/DDI/internal/brand"
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
// Agent roles.
const (
RoleDNS = "dns"
RoleDHCP = "dhcp"
)
func validRole(r string) bool { return r == RoleDNS || r == RoleDHCP }
// uuidPattern matches UUIDs as PostgreSQL prints them; IDs that do not
// match cannot exist and are reported as not found (instead of a database
// cast error).
var uuidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
// ValidUUID reports whether s is a canonical lowercase UUID.
func ValidUUID(s string) bool { return uuidPattern.MatchString(s) }
// ErrAgentAPIUnavailable is returned when the agent PKI is not set up.
var ErrAgentAPIUnavailable = newError(KindUnavailable, "agent_api_unavailable", "the agent API is not configured")
// HealthCheck is one named check reported by an agent.
type HealthCheck struct {
Name string `json:"name"`
State string `json:"state"`
Message string `json:"message"`
}
// AgentHealth is the health of the managed service as reported by an agent.
type AgentHealth struct {
State string `json:"state"`
Message string `json:"message"`
CheckedAt *time.Time `json:"checked_at,omitempty"`
Checks []HealthCheck `json:"checks"`
}
// Health states.
const (
HealthUnknown = "unknown"
HealthOK = "ok"
HealthDegraded = "degraded"
HealthFailed = "failed"
)
func decodeHealth(raw []byte) AgentHealth {
var h AgentHealth
if len(raw) == 0 || json.Unmarshal(raw, &h) != nil || h.State == "" {
h = AgentHealth{State: HealthUnknown}
}
if h.Checks == nil {
h.Checks = []HealthCheck{}
}
return h
}
// DeploymentDTO is a deployment: a result reported by an agent or a push
// requested by a user (pending until the agent reports).
type DeploymentDTO struct {
ID int64 `json:"id"`
AgentID string `json:"agent_id"`
AgentHostname string `json:"agent_hostname"`
Revision int64 `json:"revision"`
Trigger string `json:"trigger"`
Outcome string `json:"outcome"`
Message string `json:"message"`
ActiveRevision int64 `json:"active_revision"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
RequestedByName string `json:"requested_by_name,omitempty"`
}
func deploymentDTO(d *store.Deployment) DeploymentDTO {
out := DeploymentDTO{ID: d.ID, AgentID: d.AgentID, AgentHostname: d.AgentHostname, Revision: d.Revision,
Trigger: d.Trigger, Outcome: d.Outcome, Message: d.Message, ActiveRevision: d.ActiveRevision,
StartedAt: d.StartedAt, FinishedAt: d.FinishedAt}
if d.RequestedByName != nil {
out.RequestedByName = *d.RequestedByName
}
return out
}
// AgentDTO is an enrolled agent.
type AgentDTO struct {
ID string `json:"id"`
Role string `json:"role"`
Hostname string `json:"hostname"`
AgentVersion string `json:"agent_version"`
ServiceVersion string `json:"service_version"`
Connected bool `json:"connected"`
RemoteAddress string `json:"remote_address,omitempty"`
Mode string `json:"mode"`
EnrolledAt time.Time `json:"enrolled_at"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
CertExpiresAt time.Time `json:"cert_expires_at"`
AppliedRevision int64 `json:"applied_revision"`
LastGoodRevision int64 `json:"last_good_revision"`
DesiredRevision int64 `json:"desired_revision"`
TargetRevision int64 `json:"target_revision"`
Health AgentHealth `json:"health"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
LastDeployment *DeploymentDTO `json:"last_deployment,omitempty"`
// Cluster is set for members of a cluster (ADR 0023).
Cluster *AgentClusterDTO `json:"cluster,omitempty"`
}
// AgentClusterDTO is the cluster membership of an agent.
type AgentClusterDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Address netip.Addr `json:"address"`
}
func agentCluster(ms map[string]store.Membership, agentID string) *AgentClusterDTO {
m, ok := ms[agentID]
if !ok {
return nil
}
return &AgentClusterDTO{ID: m.ClusterID, Name: m.ClusterName, Role: m.MemberRole, Address: m.Address}
}
// desiredRevision is the revision an agent should run (ADR 0022): the
// latest in auto_pull mode, the last pushed one in manual mode, none when
// revoked.
func desiredRevision(a *store.Agent, latest int64) int64 {
switch {
case a.RevokedAt != nil:
return 0
case a.Mode == agenthub.ModeManual:
return a.TargetRevision
default:
return latest
}
}
// agentDTO converts a; latest is the newest revision number.
func (s *Service) agentDTO(a *store.Agent, latest int64, last *store.Deployment) AgentDTO {
d := AgentDTO{
ID: a.ID, Role: a.Role, Hostname: a.Hostname, AgentVersion: a.AgentVersion, ServiceVersion: a.ServiceVersion,
Mode: a.Mode, EnrolledAt: a.EnrolledAt, LastSeenAt: a.LastSeenAt, CertExpiresAt: a.CertNotAfter,
AppliedRevision: a.AppliedRevision, LastGoodRevision: a.LastGoodRevision,
DesiredRevision: desiredRevision(a, latest), TargetRevision: a.TargetRevision,
Health: decodeHealth(a.Health), RevokedAt: a.RevokedAt,
}
if a.RemoteAddress != nil {
d.RemoteAddress = a.RemoteAddress.String()
}
if s.opts.Hub != nil {
d.Connected = s.opts.Hub.Connected(a.ID)
}
if last != nil {
dd := deploymentDTO(last)
d.LastDeployment = &dd
}
return d
}
// latestRevisionNumber returns the newest revision number, 0 if none.
func latestRevisionNumber(ctx context.Context, tx *store.Tx) (int64, error) {
r, err := tx.LatestRevision(ctx)
if errors.Is(err, store.ErrNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
return r.Number, nil
}
// ListAgents returns all agents, including revoked ones.
func (s *Service) ListAgents(ctx context.Context) ([]AgentDTO, error) {
q := s.st.Q()
agents, err := q.ListAgents(ctx)
if err != nil {
return nil, translate(err)
}
desired, err := latestRevisionNumber(ctx, q)
if err != nil {
return nil, translate(err)
}
last, err := q.LatestDeployments(ctx)
if err != nil {
return nil, translate(err)
}
ms, err := q.Memberships(ctx)
if err != nil {
return nil, translate(err)
}
out := make([]AgentDTO, 0, len(agents))
for _, a := range agents {
d := s.agentDTO(a, desired, last[a.ID])
d.Cluster = agentCluster(ms, a.ID)
out = append(out, d)
}
return out, nil
}
// GetAgent returns one agent.
func (s *Service) GetAgent(ctx context.Context, id string) (AgentDTO, error) {
if !ValidUUID(id) {
return AgentDTO{}, ErrNotFound
}
return s.loadAgentDTO(ctx, s.st.Q(), id)
}
func (s *Service) loadAgentDTO(ctx context.Context, tx *store.Tx, id string) (AgentDTO, error) {
a, err := tx.GetAgent(ctx, id, false)
if err != nil {
return AgentDTO{}, translate(err)
}
desired, err := latestRevisionNumber(ctx, tx)
if err != nil {
return AgentDTO{}, translate(err)
}
last, err := tx.LatestDeployment(ctx, id)
if err != nil && !errors.Is(err, store.ErrNotFound) {
return AgentDTO{}, translate(err)
}
ms, err := tx.Memberships(ctx)
if err != nil {
return AgentDTO{}, translate(err)
}
d := s.agentDTO(a, desired, last)
d.Cluster = agentCluster(ms, id)
return d, nil
}
// RevokeAgent revokes the agent's certificate and closes its stream. Like
// DeleteAgent and DeleteEnrollmentToken it works in license read-only mode:
// locking out an agent is a security function.
func (s *Service) RevokeAgent(ctx context.Context, a *Actor, id string) (AgentDTO, error) {
if !ValidUUID(id) {
return AgentDTO{}, ErrNotFound
}
var out AgentDTO
err := s.inTx(ctx, func(tx *store.Tx) error {
before, err := tx.GetAgent(ctx, id, true)
if err != nil {
return err
}
if _, err := tx.RevokeAgent(ctx, id, s.now()); err != nil {
return err
}
if out, err = s.loadAgentDTO(ctx, tx, id); err != nil {
return err
}
if before.RevokedAt != nil {
return nil // already revoked: nothing changed
}
return s.audit(ctx, tx, a, "agent.revoke", "agent", id, before.Hostname, s.agentDTO(before, 0, nil), out)
})
if err != nil {
return AgentDTO{}, err
}
if s.opts.Hub != nil {
s.opts.Hub.Disconnect(id, agenthub.ReasonRevoked)
out.Connected = false
}
s.publishAgent(id)
return out, nil
}
// DeleteAgent removes an agent with its deployments and leases. Its
// certificate becomes worthless with the row.
func (s *Service) DeleteAgent(ctx context.Context, a *Actor, id string) error {
if !ValidUUID(id) {
return ErrNotFound
}
err := s.inTx(ctx, func(tx *store.Tx) error {
before, err := tx.GetAgent(ctx, id, true)
if err != nil {
return err
}
if err := tx.DeleteAgent(ctx, id); err != nil {
return err
}
return s.audit(ctx, tx, a, "agent.delete", "agent", id, before.Hostname, s.agentDTO(before, 0, nil), nil)
})
if err != nil {
return err
}
if s.opts.Hub != nil {
s.opts.Hub.Disconnect(id, agenthub.ReasonDeleted)
}
s.publishAgent(id)
return nil
}
// Enrollment tokens (ADR 0021): "<id>.<secret>", id 16 hex digits, secret
// 32 random bytes in base64url. Only SHA-256 of the secret is stored.
const (
defaultTokenHours = 24
maxTokenHours = 720
// tokenListWindow keeps used and expired tokens visible for a while.
tokenListWindow = 7 * 24 * time.Hour
)
var tokenIDPattern = regexp.MustCompile(`^[0-9a-f]{16}$`)
// EnrollmentTokenDTO is a token without its secret.
type EnrollmentTokenDTO struct {
ID string `json:"id"`
Role string `json:"role"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
CreatedByName string `json:"created_by_name"`
ExpiresAt time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty"`
AgentID string `json:"agent_id,omitempty"`
}
func tokenDTO(t *store.EnrollmentToken) EnrollmentTokenDTO {
d := EnrollmentTokenDTO{ID: t.ID, Role: t.Role, Description: t.Description, CreatedAt: t.CreatedAt,
CreatedByName: t.CreatedByName, ExpiresAt: t.ExpiresAt, UsedAt: t.UsedAt}
if t.AgentID != nil {
d.AgentID = *t.AgentID
}
return d
}
// EnrollmentTokenCreated is returned once when a token is created.
type EnrollmentTokenCreated struct {
Token EnrollmentTokenDTO `json:"token"`
Secret string `json:"secret"`
CAFingerprint string `json:"ca_fingerprint"`
ServerAddress string `json:"server_address"`
Command string `json:"command"`
}
// TokenInput describes a new enrollment token.
type TokenInput struct {
Role string
Description string
// ValidHours defaults to 24.
ValidHours *int
}
func hashTokenSecret(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// AgentBinary returns the agent binary name of a role.
func AgentBinary(role string) string {
if role == RoleDHCP {
return brand.AgentDHCPBinary
}
return brand.AgentDNSBinary
}
// enrollCommand builds the command shown to the administrator.
func (s *Service) enrollCommand(role, token string) string {
addr := s.opts.AgentAPIAddress
cmd := AgentBinary(role) + " enroll --server " + addr + " --token " + token + " --ca-hash " + s.opts.PKI.Fingerprint()
host, _, err := net.SplitHostPort(addr)
if err == nil && len(s.opts.AgentServerNames) > 0 && !slices.Contains(s.opts.AgentServerNames, host) {
cmd += " --server-name " + s.opts.AgentServerNames[0]
}
return cmd
}
// CreateEnrollmentToken creates a one-time token for a role.
func (s *Service) CreateEnrollmentToken(ctx context.Context, a *Actor, in TokenInput) (*EnrollmentTokenCreated, error) {
if err := s.requireWritable(); err != nil {
return nil, err
}
if s.opts.PKI == nil || s.opts.AgentAPIAddress == "" {
return nil, ErrAgentAPIUnavailable
}
var problems []Problem
if !validRole(in.Role) {
problems = append(problems, Problem{Code: "invalid_role", Field: "role"})
}
desc := strings.TrimSpace(in.Description)
if utf8.RuneCountInString(desc) > 200 {
problems = append(problems, Problem{Code: "too_long", Field: "description"})
}
hours := defaultTokenHours
if in.ValidHours != nil {
hours = *in.ValidHours
}
if hours < 1 || hours > maxTokenHours {
problems = append(problems, Problem{Code: "out_of_range", Field: "valid_hours"})
}
if len(problems) > 0 {
return nil, invalid(problems...)
}
var idBytes [8]byte
var secretBytes [32]byte
if _, err := rand.Read(idBytes[:]); err != nil {
return nil, err
}
if _, err := rand.Read(secretBytes[:]); err != nil {
return nil, err
}
secret := base64.RawURLEncoding.EncodeToString(secretBytes[:])
now := s.now().UTC().Truncate(time.Microsecond)
tk := &store.EnrollmentToken{
ID: hex.EncodeToString(idBytes[:]), SecretHash: hashTokenSecret(secret), Role: in.Role, Description: desc,
CreatedByName: a.Username, CreatedAt: now, ExpiresAt: now.Add(time.Duration(hours) * time.Hour),
}
if a.UserID != "" {
uid := a.UserID
tk.CreatedBy = &uid
}
err := s.inTx(ctx, func(tx *store.Tx) error {
if err := tx.InsertEnrollmentToken(ctx, tk); err != nil {
return err
}
return s.audit(ctx, tx, a, "enrollment_token.create", "enrollment_token", tk.ID, tk.Description, nil, tokenDTO(tk))
})
if err != nil {
return nil, err
}
full := tk.ID + "." + secret
return &EnrollmentTokenCreated{
Token: tokenDTO(tk), Secret: full, CAFingerprint: s.opts.PKI.Fingerprint(),
ServerAddress: s.opts.AgentAPIAddress, Command: s.enrollCommand(tk.Role, full),
}, nil
}
// ListEnrollmentTokens returns open tokens and those used or expired within
// the last 7 days, newest first.
func (s *Service) ListEnrollmentTokens(ctx context.Context) ([]EnrollmentTokenDTO, error) {
list, err := s.st.Q().ListEnrollmentTokens(ctx, s.now().Add(-tokenListWindow))
if err != nil {
return nil, translate(err)
}
out := make([]EnrollmentTokenDTO, 0, len(list))
for _, t := range list {
out = append(out, tokenDTO(t))
}
return out, nil
}
// DeleteEnrollmentToken removes a token; an unused token becomes invalid.
func (s *Service) DeleteEnrollmentToken(ctx context.Context, a *Actor, id string) error {
if !tokenIDPattern.MatchString(id) {
return ErrNotFound
}
return s.inTx(ctx, func(tx *store.Tx) error {
tk, err := tx.GetEnrollmentToken(ctx, id, true)
if err != nil {
return err
}
if err := tx.DeleteEnrollmentToken(ctx, id); err != nil {
return err
}
return s.audit(ctx, tx, a, "enrollment_token.delete", "enrollment_token", id, tk.Description, tokenDTO(tk), nil)
})
}
// CleanupEnrollmentTokens removes tokens used or expired more than 30 days
// ago.
func (s *Service) CleanupEnrollmentTokens(ctx context.Context) (int64, error) {
n, err := s.st.Q().DeleteEnrollmentTokensBefore(ctx, s.now().Add(-30*24*time.Hour))
return n, translate(err)
}
// cleanText trims s, removes control characters and cuts it to max runes
// (values reported by agents).
func cleanText(s string, maxRunes int) string {
s = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, strings.TrimSpace(s))
if utf8.RuneCountInString(s) > maxRunes {
s = string([]rune(s)[:maxRunes])
}
return s
}