Files
DDI/agent/core/enroll.go
T
beug.k@beugsiud.deandClaude Opus 5.5 3065175673 feat(agent): enrollment, server connection and apply pipeline
- 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>
2026-10-13 19:06:05 +02:00

178 lines
5.1 KiB
Go

// 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)
}
}