- 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>
178 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|