Merge branch 'worktree-agent-af35c8bdd4b0bcf64'

This commit is contained in:
beug.k@beugsiud.de
2026-10-13 19:15:16 +02:00
28 changed files with 5460 additions and 13 deletions
+6
View File
@@ -24,3 +24,9 @@ license:
# stand in for it during development.
account_id: "9de54794-8e94-4250-83f6-87e3d5686a0b"
public_key: "40495568fa239e44736e28949ae2ae93f2c9b6e6bc6d917f4c8cc0eb629e3c62"
agent_api:
# gRPC endpoint for the agents (mTLS with the internal CA).
listen: "127.0.0.1:8444"
public_address: "127.0.0.1:8444"
server_names: ["127.0.0.1", "localhost"]
+1 -1
View File
@@ -39,7 +39,7 @@ server="$(find "$work" -type f -name "$bin_name" | head -n1)"
"$server" gen-master-key -out "$work/master.key" >/dev/null
port=18443
export DDI_HTTP_LISTEN="127.0.0.1:${port}" DDI_MASTER_KEY_FILE="$work/master.key" DDI_STATE_DIR="$work/state"
export DDI_HTTP_LISTEN="127.0.0.1:${port}" DDI_AGENT_API_LISTEN="127.0.0.1:$((port + 1))" DDI_MASTER_KEY_FILE="$work/master.key" DDI_STATE_DIR="$work/state"
# An unreachable license server: the server must start anyway (trial).
export KEYGEN_URL="http://127.0.0.1:1"
+4 -1
View File
@@ -146,7 +146,7 @@ func TestServeEndToEnd(t *testing.T) {
}
addr := freeAddr(t)
env := map[string]string{
"DDI_HTTP_LISTEN": addr, "DDI_DATABASE_URL": dbURL, "DDI_MASTER_KEY_FILE": keyFile, "DDI_STATE_DIR": dir,
"DDI_HTTP_LISTEN": addr, "DDI_AGENT_API_LISTEN": freeAddr(t), "DDI_DATABASE_URL": dbURL, "DDI_MASTER_KEY_FILE": keyFile, "DDI_STATE_DIR": dir,
"KEYGEN_URL": "http://127.0.0.1:1", "KEYGEN_ACCOUNT_ID": "9de54794-8e94-4250-83f6-87e3d5686a0b",
"KEYGEN_PUBLIC_KEY": "40495568fa239e44736e28949ae2ae93f2c9b6e6bc6d917f4c8cc0eb629e3c62",
}
@@ -200,6 +200,9 @@ func TestServeEndToEnd(t *testing.T) {
t.Errorf("%s: %d %q", path, code, body)
}
}
if _, body := get("/metrics"); !strings.Contains(body, `ddi_agents_connected{role="dhcp"} 0`) {
t.Errorf("agent metric missing: %q", body)
}
if code, body := get("/api/v1/networks"); code != http.StatusUnauthorized || !strings.Contains(body, "unauthenticated") {
t.Errorf("unauthenticated API access: %d %q", code, body)
}
+330
View File
@@ -0,0 +1,330 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package agentapi
import (
"context"
"errors"
"io"
"log/slog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
)
// maxHealthChecks bounds the checks stored per report.
const maxHealthChecks = 100
func roleName(r agentv1.Role) string {
switch r {
case agentv1.Role_ROLE_DNS:
return service.RoleDNS
case agentv1.Role_ROLE_DHCP:
return service.RoleDHCP
}
return ""
}
func modeProto(m string) agentv1.OperatingMode {
if m == agenthub.ModeManual {
return agentv1.OperatingMode_OPERATING_MODE_MANUAL
}
return agentv1.OperatingMode_OPERATING_MODE_AUTO_PULL
}
func healthStateName(h agentv1.HealthState) string {
switch h {
case agentv1.HealthState_HEALTH_STATE_OK:
return service.HealthOK
case agentv1.HealthState_HEALTH_STATE_DEGRADED:
return service.HealthDegraded
case agentv1.HealthState_HEALTH_STATE_FAILED:
return service.HealthFailed
}
return service.HealthUnknown
}
func healthFromProto(h *agentv1.HealthStatus) *service.AgentHealth {
if h == nil {
return nil
}
out := &service.AgentHealth{State: healthStateName(h.GetState()), Message: h.GetMessage(), Checks: []service.HealthCheck{}}
if h.GetCheckedAt() != nil {
t := h.GetCheckedAt().AsTime()
out.CheckedAt = &t
}
for i, c := range h.GetChecks() {
if i == maxHealthChecks {
break
}
out.Checks = append(out.Checks, service.HealthCheck{Name: c.GetName(), State: healthStateName(c.GetState()), Message: c.GetMessage()})
}
return out
}
// Enroll implements agentv1.AgentServiceServer.
func (s *Server) Enroll(ctx context.Context, req *agentv1.EnrollRequest) (*agentv1.EnrollResponse, error) {
c := callerFrom(ctx)
role := roleName(req.GetRole())
if role == "" {
return nil, status.Error(codes.InvalidArgument, "unknown role")
}
res, err := s.svc.EnrollAgent(ctx, service.EnrollInput{
Token: req.GetToken(), CSR: req.GetCsrDer(), Role: role, Hostname: req.GetHostname(),
AgentVersion: req.GetAgentVersion(), RemoteIP: c.RemoteIP, RequestID: c.RequestID,
})
if err != nil {
return nil, err
}
c.AgentID = res.AgentID
s.log.Info("agent enrolled", slog.String("request_id", c.RequestID), slog.String("agent_id", res.AgentID),
slog.String("role", role), slog.Any("remote", c.RemoteIP))
return &agentv1.EnrollResponse{
AgentId: res.AgentID,
CertificateDer: res.Certificate.Raw,
CaCertificatesDer: [][]byte{s.pki.CACertificate().Raw},
BundleSigningPublicKey: s.pki.BundlePublicKey(),
}, nil
}
// RenewCertificate implements agentv1.AgentServiceServer.
func (s *Server) RenewCertificate(ctx context.Context, req *agentv1.RenewCertificateRequest) (*agentv1.RenewCertificateResponse, error) {
c := callerFrom(ctx)
cert, err := s.svc.RenewAgentCertificate(ctx, c.AgentID, req.GetCsrDer())
if err != nil {
return nil, err
}
s.log.Info("agent certificate renewed", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
slog.Time("not_after", cert.NotAfter))
return &agentv1.RenewCertificateResponse{
CertificateDer: cert.Raw, CaCertificatesDer: [][]byte{s.pki.CACertificate().Raw},
}, nil
}
// GetDesiredState implements agentv1.AgentServiceServer.
func (s *Server) GetDesiredState(ctx context.Context, _ *agentv1.GetDesiredStateRequest) (*agentv1.GetDesiredStateResponse, error) {
c := callerFrom(ctx)
st, err := s.svc.AgentDesiredState(ctx, c.AgentID, c.RemoteIP)
if err != nil {
return nil, err
}
return &agentv1.GetDesiredStateResponse{
DesiredRevision: st.DesiredRevision, Mode: modeProto(st.Mode), PullInterval: durationpb.New(service.PullInterval),
}, nil
}
// FetchBundle implements agentv1.AgentServiceServer. The bundle is signed
// for the calling agent and its role; equal input gives equal bytes.
func (s *Server) FetchBundle(ctx context.Context, req *agentv1.FetchBundleRequest) (*agentv1.FetchBundleResponse, error) {
c := callerFrom(ctx)
createdAt, arts, err := s.svc.RevisionArtifacts(ctx, req.GetRevision(), c.Role)
if errors.Is(err, service.ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "revision %d does not exist", req.GetRevision())
}
if err != nil {
return nil, err
}
manifest, sig, err := bundle.Sign(s.pki.BundleSigningKey(), c.AgentID, c.Role, req.GetRevision(), createdAt, arts)
if err != nil {
return nil, err
}
out := &agentv1.FetchBundleResponse{Manifest: manifest, Signature: sig, Artifacts: make([]*agentv1.Artifact, len(arts))}
for i, a := range arts {
out.Artifacts[i] = &agentv1.Artifact{Path: a.Path, Content: a.Content}
}
return out, nil
}
var (
triggerNames = map[agentv1.DeploymentTrigger]string{
agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PULL: "pull",
agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PUSH: "push",
}
outcomeNames = map[agentv1.DeploymentOutcome]string{
agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_SUCCEEDED: "succeeded",
agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_REJECTED: "rejected",
agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_ROLLED_BACK: "rolled_back",
agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_FAILED: "failed",
}
leaseStateNames = map[agentv1.LeaseState]string{
agentv1.LeaseState_LEASE_STATE_ACTIVE: "active",
agentv1.LeaseState_LEASE_STATE_EXPIRED: "expired",
agentv1.LeaseState_LEASE_STATE_RELEASED: "released",
agentv1.LeaseState_LEASE_STATE_DECLINED: "declined",
}
)
// ReportDeployment implements agentv1.AgentServiceServer.
func (s *Server) ReportDeployment(ctx context.Context, req *agentv1.ReportDeploymentRequest) (*agentv1.ReportDeploymentResponse, error) {
c := callerFrom(ctx)
trigger, ok := triggerNames[req.GetTrigger()]
if !ok {
return nil, status.Error(codes.InvalidArgument, "unknown trigger")
}
outcome, ok := outcomeNames[req.GetOutcome()]
if !ok {
return nil, status.Error(codes.InvalidArgument, "unknown outcome")
}
rep := service.DeploymentReport{
CorrelationID: req.GetDeploymentId(), Revision: req.GetRevision(), Trigger: trigger, Outcome: outcome,
Message: req.GetMessage(), ActiveRevision: req.GetActiveRevision(),
}
if req.GetStartedAt() != nil {
rep.StartedAt = req.GetStartedAt().AsTime()
}
if req.GetFinishedAt() != nil {
rep.FinishedAt = req.GetFinishedAt().AsTime()
}
if err := s.svc.RecordDeployment(ctx, c.AgentID, rep); err != nil {
return nil, err
}
s.log.Info("deployment reported", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
slog.Uint64("revision", req.GetRevision()), slog.String("outcome", outcome),
slog.Uint64("active_revision", req.GetActiveRevision()), slog.String("deployment_id", req.GetDeploymentId()))
return &agentv1.ReportDeploymentResponse{}, nil
}
// ReportLeases implements agentv1.AgentServiceServer.
func (s *Server) ReportLeases(ctx context.Context, req *agentv1.ReportLeasesRequest) (*agentv1.ReportLeasesResponse, error) {
c := callerFrom(ctx)
in := make([]service.LeaseInput, len(req.GetLeases()))
for i, l := range req.GetLeases() {
in[i] = service.LeaseInput{IP: l.GetIp(), MAC: l.GetMac(), Hostname: l.GetHostname(),
State: leaseStateNames[l.GetState()], SubnetID: l.GetSubnetId(), ClientID: l.GetClientId()}
if l.GetExpiresAt() != nil {
t := l.GetExpiresAt().AsTime()
in[i].ExpiresAt = &t
}
if l.GetUpdatedAt() != nil {
t := l.GetUpdatedAt().AsTime()
in[i].UpdatedAt = &t
}
}
res, err := s.svc.StoreLeaseBatch(ctx, c.AgentID, req.GetSequence(), req.GetFullSync(), in)
if err != nil {
return nil, err
}
attrs := []any{slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
slog.Uint64("sequence", req.GetSequence()), slog.Bool("full_sync", req.GetFullSync()), slog.Int("leases", len(in))}
switch {
case res.Duplicate:
s.log.Info("dropped duplicate lease batch", append(attrs, slog.Uint64("acknowledged", res.Acknowledged))...)
case res.Skipped > 0:
s.log.Warn("skipped invalid leases", append(attrs, slog.Int("skipped", res.Skipped))...)
default:
s.log.Debug("stored lease batch", attrs...)
}
return &agentv1.ReportLeasesResponse{AcknowledgedSequence: res.Acknowledged}, nil
}
// Connect implements agentv1.AgentServiceServer: Hello → Welcome, then
// heartbeats and acknowledgements from the agent and commands from the
// server until either side ends the stream.
func (s *Server) Connect(stream agentv1.AgentService_ConnectServer) error {
ctx := stream.Context()
c := callerFrom(ctx)
first, err := stream.Recv()
if err != nil {
return err
}
hello := first.GetHello()
if hello == nil {
return status.Error(codes.InvalidArgument, "the first message must be Hello")
}
// Register before reading the desired revision, so that a revision
// created in between is pushed rather than missed.
conn := s.hub.Attach(c.AgentID, c.Role, c.Mode)
defer s.hub.Detach(conn)
settings, err := s.svc.AgentHello(ctx, c.AgentID, service.HelloInput{
Hostname: hello.GetHostname(), AgentVersion: hello.GetAgentVersion(), ServiceVersion: hello.GetServiceVersion(),
AppliedRevision: hello.GetAppliedRevision(), LastGoodRevision: hello.GetLastGoodRevision(),
Health: healthFromProto(hello.GetHealth()), RemoteIP: c.RemoteIP,
})
if err != nil {
return err
}
if err := stream.Send(&agentv1.ConnectResponse{Message: &agentv1.ConnectResponse_Welcome{Welcome: &agentv1.Welcome{
Mode: modeProto(settings.Mode), PullInterval: durationpb.New(service.PullInterval),
HeartbeatInterval: durationpb.New(service.HeartbeatInterval), DesiredRevision: settings.DesiredRevision,
}}}); err != nil {
return err
}
s.log.Info("agent connected", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
slog.String("role", c.Role), slog.Uint64("applied_revision", hello.GetAppliedRevision()),
slog.Uint64("desired_revision", settings.DesiredRevision))
recvErr := make(chan error, 1)
go func() {
for {
msg, err := stream.Recv()
if err != nil {
recvErr <- err
return
}
s.handleMessage(ctx, c, msg)
}
}()
for {
select {
case cmd := <-conn.Commands():
if err := stream.Send(&agentv1.ConnectResponse{Message: &agentv1.ConnectResponse_Command{Command: cmd}}); err != nil {
return err
}
s.log.Info("command sent", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
slog.String("command_id", cmd.GetCommandId()), slog.Uint64("revision", cmd.GetApplyRevision().GetRevision()),
slog.String("deployment_id", cmd.GetApplyRevision().GetDeploymentId()))
case <-conn.Done():
reason := conn.Reason()
s.log.Info("agent disconnected by server", slog.String("request_id", c.RequestID),
slog.String("agent_id", c.AgentID), slog.String("reason", reason))
switch reason {
case agenthub.ReasonRevoked, agenthub.ReasonDeleted:
return status.Error(codes.PermissionDenied, reason)
case agenthub.ReasonReplaced:
return status.Error(codes.Aborted, reason)
default:
return status.Error(codes.Unavailable, reason)
}
case err := <-recvErr:
if errors.Is(err, io.EOF) {
s.log.Info("agent closed its stream", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID))
return nil
}
return err
case <-ctx.Done():
return ctx.Err()
}
}
}
func (s *Server) handleMessage(ctx context.Context, c *caller, msg *agentv1.ConnectRequest) {
switch m := msg.GetMessage().(type) {
case *agentv1.ConnectRequest_Heartbeat:
hb := m.Heartbeat
err := s.svc.AgentHeartbeat(ctx, c.AgentID, service.HeartbeatInput{
AppliedRevision: hb.GetAppliedRevision(), LastGoodRevision: hb.GetLastGoodRevision(),
Health: healthFromProto(hb.GetHealth()),
})
if errors.Is(err, service.ErrAgentRevoked) {
s.hub.Disconnect(c.AgentID, agenthub.ReasonRevoked)
} else if err != nil && ctx.Err() == nil {
s.log.Warn("heartbeat not stored", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
slog.Any("error", err))
}
case *agentv1.ConnectRequest_CommandAck:
ack := m.CommandAck
s.log.Info("command acknowledged", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
slog.String("command_id", ack.GetCommandId()), slog.Bool("accepted", ack.GetAccepted()),
slog.String("message", ack.GetMessage()))
case *agentv1.ConnectRequest_Hello:
s.log.Warn("ignoring repeated Hello", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID))
default:
s.log.Warn("ignoring unknown stream message", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID))
}
}
+298
View File
@@ -0,0 +1,298 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
// Package agentapi is the gRPC AgentService of the management server
// (proto/ddi/agent/v1, ADR 0007, 0008, 0021). It listens on its own TLS 1.3
// port, requests client certificates and checks them itself: Enroll needs
// none, RenewCertificate accepts a recently expired one, every other call
// needs the agent's current, unrevoked certificate.
package agentapi
import (
"context"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"errors"
"log/slog"
"net"
"net/netip"
"regexp"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/pki"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
)
// MaxRecvMsgSize bounds requests (large full lease syncs).
const MaxRecvMsgSize = 32 << 20
// MaxSendMsgSize allows the largest bundle (bundle.MaxTotalSize) plus the
// manifest and framing.
const MaxSendMsgSize = bundle.MaxTotalSize + 16<<20
// Options configures the server.
type Options struct {
Service *service.Service
PKI *pki.Authority
Hub *agenthub.Hub
// ServerNames become the SANs of the server certificate.
ServerNames []string
Logger *slog.Logger
}
// Server implements agentv1.AgentServiceServer.
type Server struct {
agentv1.UnimplementedAgentServiceServer
svc *service.Service
pki *pki.Authority
hub *agenthub.Hub
log *slog.Logger
grpc *grpc.Server
}
// New creates the server and issues its certificate.
func New(opts Options) (*Server, error) {
if opts.Service == nil || opts.PKI == nil || opts.Hub == nil {
return nil, errors.New("agentapi: service, PKI and hub are required")
}
if opts.Logger == nil {
opts.Logger = slog.New(slog.DiscardHandler)
}
cert, err := opts.PKI.ServerCertificate(opts.ServerNames)
if err != nil {
return nil, err
}
s := &Server{svc: opts.Service, pki: opts.PKI, hub: opts.Hub, log: opts.Logger}
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
Certificates: []tls.Certificate{cert},
// Checked by the interceptors (Enroll has no certificate,
// RenewCertificate may present an expired one).
ClientAuth: tls.RequestClientCert,
}
s.grpc = grpc.NewServer(
grpc.Creds(credentials.NewTLS(tlsCfg)),
grpc.ChainUnaryInterceptor(s.unaryInterceptor),
grpc.ChainStreamInterceptor(s.streamInterceptor),
// Agents ping every 30 s, also without an open stream.
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{MinTime: 20 * time.Second, PermitWithoutStream: true}),
grpc.KeepaliveParams(keepalive.ServerParameters{Time: time.Minute, Timeout: 20 * time.Second}),
grpc.MaxRecvMsgSize(MaxRecvMsgSize),
grpc.MaxSendMsgSize(MaxSendMsgSize),
)
agentv1.RegisterAgentServiceServer(s.grpc, s)
return s, nil
}
// Serve accepts connections on lis until Stop or Shutdown.
func (s *Server) Serve(lis net.Listener) error {
err := s.grpc.Serve(lis)
if errors.Is(err, grpc.ErrServerStopped) {
return nil
}
return err
}
// Shutdown closes the agents' streams, waits for running calls and stops
// hard when ctx ends.
func (s *Server) Shutdown(ctx context.Context) {
s.hub.CloseAll(agenthub.ReasonShutdown)
done := make(chan struct{})
go func() {
s.grpc.GracefulStop()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
s.grpc.Stop()
<-done
}
}
// caller is the authenticated agent of a call.
type caller struct {
AgentID string
Role string
Mode string
RequestID string
RemoteIP *netip.Addr
}
type callerKey struct{}
func callerFrom(ctx context.Context) *caller {
c, _ := ctx.Value(callerKey{}).(*caller)
if c == nil {
return &caller{}
}
return c
}
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
// requestID takes the agent's x-request-id if it is well-formed, so that
// agent and server logs correlate; otherwise it makes one up.
func requestID(ctx context.Context) string {
if md, ok := metadata.FromIncomingContext(ctx); ok {
if v := md.Get("x-request-id"); len(v) == 1 && requestIDPattern.MatchString(v[0]) {
return v[0]
}
}
var b [8]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
func peerIP(ctx context.Context) *netip.Addr {
p, ok := peer.FromContext(ctx)
if !ok || p.Addr == nil {
return nil
}
ap, err := netip.ParseAddrPort(p.Addr.String())
if err != nil {
return nil
}
ip := ap.Addr().Unmap()
return &ip
}
// authenticate checks the client certificate for method.
func (s *Server) authenticate(ctx context.Context, method string) (*caller, error) {
c := &caller{RequestID: requestID(ctx), RemoteIP: peerIP(ctx)}
if method == agentv1.AgentService_Enroll_FullMethodName {
return c, nil
}
p, ok := peer.FromContext(ctx)
if !ok {
return c, status.Error(codes.Unauthenticated, "client certificate required")
}
info, ok := p.AuthInfo.(credentials.TLSInfo)
if !ok || len(info.State.PeerCertificates) == 0 {
return c, status.Error(codes.Unauthenticated, "client certificate required")
}
cert := info.State.PeerCertificates[0]
mode := pki.VerifyCurrent
if method == agentv1.AgentService_RenewCertificate_FullMethodName {
mode = pki.VerifyRenewal
}
id, err := s.pki.VerifyAgentCertificate(cert, mode)
if err != nil {
s.log.Warn("agent certificate rejected", slog.String("request_id", c.RequestID), slog.String("method", method),
slog.Any("remote", c.RemoteIP), slog.Any("error", err))
return c, status.Error(codes.Unauthenticated, "invalid client certificate")
}
c.AgentID = id
ident, err := s.svc.AgentIdentity(ctx, id)
if errors.Is(err, service.ErrNotFound) {
return c, status.Error(codes.Unauthenticated, "unknown agent")
}
if err != nil {
return c, s.toStatus(c, err)
}
if ident.Revoked {
return c, status.Error(codes.PermissionDenied, "agent is revoked")
}
if pki.SerialHex(cert.SerialNumber) != ident.CertSerial {
return c, status.Error(codes.Unauthenticated, "certificate has been replaced")
}
c.Role, c.Mode = ident.Role, ident.Mode
return c, nil
}
func (s *Server) logCall(c *caller, method string, start time.Time, err error) {
code := status.Code(err)
attrs := []any{slog.String("request_id", c.RequestID), slog.String("method", method),
slog.String("code", code.String()), slog.Duration("duration", time.Since(start))}
if c.AgentID != "" {
attrs = append(attrs, slog.String("agent_id", c.AgentID))
}
switch code {
case codes.OK, codes.Canceled:
s.log.Debug("agent call", attrs...)
case codes.Internal, codes.Unknown:
s.log.Error("agent call failed", append(attrs, slog.Any("error", err))...)
default:
s.log.Info("agent call rejected", append(attrs, slog.String("reason", status.Convert(err).Message()))...)
}
}
func (s *Server) unaryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
start := time.Now()
c, err := s.authenticate(ctx, info.FullMethod)
if err == nil {
var resp any
resp, err = handler(context.WithValue(ctx, callerKey{}, c), req)
if err != nil {
err = s.toStatus(c, err)
}
s.logCall(c, info.FullMethod, start, err)
return resp, err
}
s.logCall(c, info.FullMethod, start, err)
return nil, err
}
type wrappedStream struct {
grpc.ServerStream
ctx context.Context
}
func (w *wrappedStream) Context() context.Context { return w.ctx }
func (s *Server) streamInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
start := time.Now()
c, err := s.authenticate(ss.Context(), info.FullMethod)
if err == nil {
err = handler(srv, &wrappedStream{ServerStream: ss, ctx: context.WithValue(ss.Context(), callerKey{}, c)})
if err != nil {
err = s.toStatus(c, err)
}
}
s.logCall(c, info.FullMethod, start, err)
return err
}
var kindCodes = map[service.Kind]codes.Code{
service.KindInvalid: codes.InvalidArgument,
service.KindNotFound: codes.NotFound,
service.KindConflict: codes.FailedPrecondition,
service.KindForbidden: codes.PermissionDenied,
service.KindUnauthorized: codes.Unauthenticated,
service.KindRateLimited: codes.ResourceExhausted,
service.KindUnavailable: codes.Unavailable,
}
// toStatus converts service errors; internal details are logged, not sent.
func (s *Server) toStatus(c *caller, err error) error {
if err == nil {
return nil
}
if _, ok := status.FromError(err); ok {
return err
}
var se *service.Error
if errors.As(err, &se) && se.Kind != 0 {
return status.Error(kindCodes[se.Kind], se.Message)
}
switch {
case errors.Is(err, context.Canceled):
return status.Error(codes.Canceled, "canceled")
case errors.Is(err, context.DeadlineExceeded):
return status.Error(codes.DeadlineExceeded, "deadline exceeded")
}
s.log.Error("agent call failed", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID), slog.Any("error", err))
return status.Error(codes.Internal, "internal error")
}
+618
View File
@@ -0,0 +1,618 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package agentapi
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"io"
"log/slog"
"net"
"sync/atomic"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"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"
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/pki"
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
"git.beugsiud.de/beug.k/DDI/server/internal/store/storetest"
)
const serverName = "agents.test"
type testEnv struct {
t *testing.T
st *store.Store
svc *service.Service
pki *pki.Authority
hub *agenthub.Hub
srv *Server
// offset shifts the PKI clock (certificate issuance and checks).
offset atomic.Int64
addr string
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
e := &testEnv{t: t, st: storetest.New(t)}
keyData, _ := secrets.GenerateKeyFile("k1")
kr, err := secrets.ParseKeyFile(keyData)
if err != nil {
t.Fatal(err)
}
clock := func() time.Time { return time.Now().Add(time.Duration(e.offset.Load())) }
// The CA must predate the backdated agent certificates of the renewal
// tests (chains are checked at the time a certificate was valid).
e.offset.Store(int64(-90 * 24 * time.Hour))
e.pki, err = pki.LoadOrCreate(context.Background(), e.st, kr, nil, clock)
e.offset.Store(0)
if err != nil {
t.Fatal(err)
}
e.hub = agenthub.New()
log := slog.New(slog.DiscardHandler)
e.svc = service.New(service.Options{Store: e.st, Keyring: kr, PKI: e.pki, Hub: e.hub, Logger: log,
AgentAPIAddress: serverName + ":8444", AgentServerNames: []string{serverName}})
e.srv, err = New(Options{Service: e.svc, PKI: e.pki, Hub: e.hub, ServerNames: []string{serverName}, Logger: log})
if err != nil {
t.Fatal(err)
}
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
e.addr = lis.Addr().String()
done := make(chan error, 1)
go func() { done <- e.srv.Serve(lis) }()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
e.srv.Shutdown(ctx)
if err := <-done; err != nil {
t.Errorf("serve: %v", err)
}
})
return e
}
func (e *testEnv) dial(cfg *tls.Config) agentv1.AgentServiceClient {
e.t.Helper()
cc, err := grpc.NewClient("passthrough:///"+e.addr, grpc.WithTransportCredentials(credentials.NewTLS(cfg)))
if err != nil {
e.t.Fatal(err)
}
e.t.Cleanup(func() { _ = cc.Close() })
return agentv1.NewAgentServiceClient(cc)
}
// bootstrapClient trusts the server only through the CA fingerprint, like
// the agent's enroll command.
func (e *testEnv) bootstrapClient(fingerprint string) agentv1.AgentServiceClient {
return e.dial(&tls.Config{
MinVersion: tls.VersionTLS13, ServerName: serverName,
InsecureSkipVerify: true, //nolint:gosec // verified by VerifyPeerCertificate
VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error {
_, err := agentpki.VerifyChainWithFingerprint(raw, fingerprint, serverName)
return err
},
})
}
func (e *testEnv) token(role string) string {
e.t.Helper()
tk, err := e.svc.CreateEnrollmentToken(context.Background(), service.SystemActor, service.TokenInput{Role: role})
if err != nil {
e.t.Fatal(err)
}
return tk.Secret
}
func csr(t *testing.T) (*ecdsa.PrivateKey, []byte) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, key)
if err != nil {
t.Fatal(err)
}
return key, der
}
type testAgent struct {
id string
role string
ca *x509.Certificate
cert *x509.Certificate
bundlePub ed25519.PublicKey
client agentv1.AgentServiceClient
}
func ctxT(t *testing.T) context.Context {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
t.Cleanup(cancel)
return ctx
}
func roleProto(role string) agentv1.Role {
if role == "dhcp" {
return agentv1.Role_ROLE_DHCP
}
return agentv1.Role_ROLE_DNS
}
func (e *testEnv) enroll(role string) *testAgent {
e.t.Helper()
key, req := csr(e.t)
resp, err := e.bootstrapClient(e.pki.Fingerprint()).Enroll(ctxT(e.t), &agentv1.EnrollRequest{
Token: e.token(role), CsrDer: req, Role: roleProto(role), Hostname: "host-" + role, AgentVersion: "1.0.0",
})
if err != nil {
e.t.Fatalf("enroll: %v", err)
}
return e.agentFrom(resp.GetAgentId(), role, key, resp.GetCertificateDer(), resp.GetCaCertificatesDer(), resp.GetBundleSigningPublicKey())
}
func (e *testEnv) agentFrom(id, role string, key *ecdsa.PrivateKey, certDER []byte, chain [][]byte, pub []byte) *testAgent {
e.t.Helper()
cert, err := x509.ParseCertificate(certDER)
if err != nil {
e.t.Fatal(err)
}
if len(chain) != 1 {
e.t.Fatalf("CA chain length %d", len(chain))
}
ca, err := x509.ParseCertificate(chain[len(chain)-1])
if err != nil {
e.t.Fatal(err)
}
client := e.dial(agentpki.ClientTLSConfig(ca, tls.Certificate{Certificate: [][]byte{certDER}, PrivateKey: key}, serverName))
return &testAgent{id: id, role: role, ca: ca, cert: cert, bundlePub: pub, client: client}
}
func wantCode(t *testing.T, err error, want codes.Code) {
t.Helper()
if status.Code(err) != want {
t.Errorf("code %v (%v), want %v", status.Code(err), err, want)
}
}
// eventually polls cond for up to 5 s.
func eventually(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(20 * time.Millisecond)
}
}
func (e *testEnv) configureGenerator(ttl uint32) {
e.t.Helper()
ctx := context.Background()
if _, err := e.st.Pool().Exec(ctx, `INSERT INTO networks (cidr, name, domain) VALUES ('192.0.2.0/24', 'Office', 'office.test')
ON CONFLICT DO NOTHING`); err != nil {
e.t.Fatal(err)
}
g := service.DefaultGeneratorSettings()
g.DNS.PrimaryNS = "ns1.office.test"
g.DNS.Hostmaster = "hostmaster@office.test"
g.DNS.NameServers = []service.GeneratorNS{{Name: "ns1.office.test", Addresses: []string{"192.0.2.53"}}}
g.DNS.DefaultTTL = ttl
if _, err := e.svc.SetGeneratorSettings(ctx, service.SystemActor, g); err != nil {
e.t.Fatal(err)
}
}
func TestEnrollConnectAndBundle(t *testing.T) {
e := newTestEnv(t)
ctx := ctxT(t)
// A server whose CA does not match the fingerprint is not trusted.
other, _ := pki.Generate(nil)
_, req := csr(t)
if _, err := e.bootstrapClient(other.Fingerprint()).Enroll(ctx, &agentv1.EnrollRequest{Token: e.token("dns"), CsrDer: req,
Role: agentv1.Role_ROLE_DNS}); err == nil {
t.Fatal("enrollment with a wrong CA fingerprint succeeded")
}
a := e.enroll("dns")
if id, err := agentpki.AgentID(a.cert); err != nil || id != a.id {
t.Errorf("certificate agent ID %q %v", id, err)
}
if agentpki.Fingerprint(a.ca) != e.pki.Fingerprint() || !a.bundlePub.Equal(e.pki.BundlePublicKey()) {
t.Error("enrollment returned foreign trust anchors")
}
if time.Until(a.cert.NotAfter) > pki.AgentCertLifetime || len(a.cert.ExtKeyUsage) != 1 || a.cert.ExtKeyUsage[0] != x509.ExtKeyUsageClientAuth {
t.Errorf("client certificate %v %v", a.cert.NotAfter, a.cert.ExtKeyUsage)
}
st, err := a.client.GetDesiredState(ctx, &agentv1.GetDesiredStateRequest{})
if err != nil || st.GetDesiredRevision() != 0 || st.GetMode() != agentv1.OperatingMode_OPERATING_MODE_AUTO_PULL ||
st.GetPullInterval().AsDuration() != time.Minute {
t.Fatalf("desired state %v %v", st, err)
}
stream, err := a.client.Connect(ctx)
if err != nil {
t.Fatal(err)
}
if err := stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_Hello{Hello: &agentv1.Hello{
AgentVersion: "1.0.1", Hostname: "ns1.office.test", ServiceVersion: "BIND 9.20.3",
Health: &agentv1.HealthStatus{State: agentv1.HealthState_HEALTH_STATE_OK, CheckedAt: timestamppb.Now(),
Checks: []*agentv1.HealthCheck{{Name: "rndc", State: agentv1.HealthState_HEALTH_STATE_OK}}},
}}}); err != nil {
t.Fatal(err)
}
msg, err := stream.Recv()
if err != nil {
t.Fatal(err)
}
w := msg.GetWelcome()
if w == nil || w.GetDesiredRevision() != 0 || w.GetHeartbeatInterval().AsDuration() != 30*time.Second ||
w.GetPullInterval().AsDuration() != time.Minute || w.GetMode() != agentv1.OperatingMode_OPERATING_MODE_AUTO_PULL {
t.Fatalf("welcome %v", msg)
}
eventually(t, "connected state", func() bool { return e.hub.Connected(a.id) })
ag, err := e.svc.GetAgent(ctx, a.id)
if err != nil || !ag.Connected || ag.Hostname != "ns1.office.test" || ag.ServiceVersion != "BIND 9.20.3" ||
ag.Health.State != "ok" || len(ag.Health.Checks) != 1 || ag.RemoteAddress != "127.0.0.1" {
t.Errorf("agent after hello %+v %v", ag, err)
}
// A new revision is pushed to the connected agent.
e.configureGenerator(3600)
rev, created, err := e.svc.CreateRevision(ctx, service.SystemActor, "first")
if err != nil || !created || rev.Number != 1 {
t.Fatalf("create revision %+v %v %v", rev, created, err)
}
msg, err = stream.Recv()
if err != nil {
t.Fatal(err)
}
cmd := msg.GetCommand()
if cmd.GetApplyRevision().GetRevision() != 1 || cmd.GetCommandId() == "" || cmd.GetApplyRevision().GetDeploymentId() == "" {
t.Fatalf("command %v", msg)
}
if err := stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_CommandAck{CommandAck: &agentv1.CommandAck{
CommandId: cmd.GetCommandId(), Accepted: true}}}); err != nil {
t.Fatal(err)
}
// The bundle verifies with the key from the enrollment.
b, err := a.client.FetchBundle(ctx, &agentv1.FetchBundleRequest{Revision: 1})
if err != nil {
t.Fatal(err)
}
contents := map[string][]byte{}
for _, art := range b.GetArtifacts() {
contents[art.GetPath()] = art.GetContent()
}
m, err := bundle.Verify(a.bundlePub, b.GetManifest(), b.GetSignature(), contents, bundle.Expect{AgentID: a.id, Role: "dns", MinRevision: 1})
if err != nil {
t.Fatalf("verify bundle: %v", err)
}
if m.Revision != 1 || len(m.Files) != len(rev.DNS) || !m.CreatedAt.Equal(rev.CreatedAt) {
t.Errorf("manifest %+v", m)
}
if _, ok := contents["zones/db.office.test"]; !ok {
t.Errorf("zone file missing: %v", m.Files)
}
again, err := a.client.FetchBundle(ctx, &agentv1.FetchBundleRequest{Revision: 1})
if err != nil || !bytes.Equal(again.GetManifest(), b.GetManifest()) || !bytes.Equal(again.GetSignature(), b.GetSignature()) {
t.Errorf("bundle is not deterministic: %v", err)
}
_, err = a.client.FetchBundle(ctx, &agentv1.FetchBundleRequest{Revision: 99})
wantCode(t, err, codes.NotFound)
// Heartbeat and deployment report.
if err := stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_Heartbeat{Heartbeat: &agentv1.Heartbeat{
AppliedRevision: 1, LastGoodRevision: 1,
Health: &agentv1.HealthStatus{State: agentv1.HealthState_HEALTH_STATE_DEGRADED, Message: "slow"}}}}); err != nil {
t.Fatal(err)
}
eventually(t, "heartbeat", func() bool {
ag, err := e.svc.GetAgent(ctx, a.id)
return err == nil && ag.AppliedRevision == 1 && ag.Health.State == "degraded"
})
start := time.Now().Add(-2 * time.Second)
report := &agentv1.ReportDeploymentRequest{
DeploymentId: cmd.GetApplyRevision().GetDeploymentId(), Revision: 1,
Trigger: agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PUSH, Outcome: agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_SUCCEEDED,
ActiveRevision: 1, StartedAt: timestamppb.New(start), FinishedAt: timestamppb.Now(),
}
// The second, identical report (resent after an agent crash) is
// accepted and dropped.
for range 2 {
if _, err := a.client.ReportDeployment(ctx, report); err != nil {
t.Fatal(err)
}
}
if deps, err := e.svc.ListDeployments(ctx, service.DeploymentFilter{AgentID: a.id}); err != nil || len(deps.Items) != 1 {
t.Errorf("deployments %+v %v", deps, err)
}
_, err = a.client.ReportDeployment(ctx, &agentv1.ReportDeploymentRequest{Revision: 1})
wantCode(t, err, codes.InvalidArgument)
ag, _ = e.svc.GetAgent(ctx, a.id)
if ag.LastDeployment == nil || ag.LastDeployment.Trigger != "push" || ag.LastGoodRevision != 1 || ag.DesiredRevision != 1 {
t.Errorf("agent after deployment %+v", ag)
}
// The agent closes its stream.
if err := stream.CloseSend(); err != nil {
t.Fatal(err)
}
if _, err := stream.Recv(); err != io.EOF { //nolint:errorlint // gRPC returns io.EOF as is
t.Errorf("recv after close: %v", err)
}
eventually(t, "disconnect", func() bool { return !e.hub.Connected(a.id) })
}
func TestEnrollmentRejections(t *testing.T) {
e := newTestEnv(t)
ctx := ctxT(t)
client := e.bootstrapClient(e.pki.Fingerprint())
_, req := csr(t)
tok := e.token("dns")
_, err := client.Enroll(ctx, &agentv1.EnrollRequest{Token: tok, CsrDer: req, Role: agentv1.Role_ROLE_DHCP})
wantCode(t, err, codes.PermissionDenied)
_, err = client.Enroll(ctx, &agentv1.EnrollRequest{Token: tok, CsrDer: req})
wantCode(t, err, codes.InvalidArgument)
_, err = client.Enroll(ctx, &agentv1.EnrollRequest{Token: "garbage", CsrDer: req, Role: agentv1.Role_ROLE_DNS})
wantCode(t, err, codes.Unauthenticated)
_, err = client.Enroll(ctx, &agentv1.EnrollRequest{Token: tok, CsrDer: []byte("no csr"), Role: agentv1.Role_ROLE_DNS})
wantCode(t, err, codes.InvalidArgument)
// The failed attempts did not use up the token.
if _, err := client.Enroll(ctx, &agentv1.EnrollRequest{Token: tok, CsrDer: req, Role: agentv1.Role_ROLE_DNS}); err != nil {
t.Fatalf("enroll: %v", err)
}
_, err = client.Enroll(ctx, &agentv1.EnrollRequest{Token: tok, CsrDer: req, Role: agentv1.Role_ROLE_DNS})
wantCode(t, err, codes.Unauthenticated)
expired := e.token("dns")
if _, err := e.st.Pool().Exec(ctx, `UPDATE enrollment_tokens SET created_at = now() - interval '2 hours',
expires_at = now() - interval '1 hour' WHERE id = $1`, expired[:16]); err != nil {
t.Fatal(err)
}
_, err = client.Enroll(ctx, &agentv1.EnrollRequest{Token: expired, CsrDer: req, Role: agentv1.Role_ROLE_DNS})
wantCode(t, err, codes.Unauthenticated)
}
func TestEnrollmentRateLimit(t *testing.T) {
e := newTestEnv(t)
ctx := ctxT(t)
client := e.bootstrapClient(e.pki.Fingerprint())
for i := range 10 {
_, err := client.Enroll(ctx, &agentv1.EnrollRequest{Token: "0123456789abcdef.wrong", Role: agentv1.Role_ROLE_DNS})
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("attempt %d: %v", i, err)
}
}
_, err := client.Enroll(ctx, &agentv1.EnrollRequest{Token: e.token("dns"), Role: agentv1.Role_ROLE_DNS})
wantCode(t, err, codes.ResourceExhausted)
}
func TestClientCertificateRequired(t *testing.T) {
e := newTestEnv(t)
ctx := ctxT(t)
a := e.enroll("dns")
noCert := e.bootstrapClient(e.pki.Fingerprint())
_, err := noCert.GetDesiredState(ctx, &agentv1.GetDesiredStateRequest{})
wantCode(t, err, codes.Unauthenticated)
_, err = noCert.FetchBundle(ctx, &agentv1.FetchBundleRequest{Revision: 1})
wantCode(t, err, codes.Unauthenticated)
stream, err := noCert.Connect(ctx)
if err == nil {
_, err = stream.Recv()
}
wantCode(t, err, codes.Unauthenticated)
// Same agent ID, but issued by another CA.
other, _ := pki.Generate(nil)
key, req := csr(t)
foreign, err := other.SignAgentCSR(req, a.id)
if err != nil {
t.Fatal(err)
}
forged := e.dial(agentpki.ClientTLSConfig(a.ca, tls.Certificate{Certificate: [][]byte{foreign.Raw}, PrivateKey: key}, serverName))
_, err = forged.GetDesiredState(ctx, &agentv1.GetDesiredStateRequest{})
wantCode(t, err, codes.Unauthenticated)
// A certificate of our CA for an agent that does not exist.
ghost, err := e.pki.SignAgentCSR(req, "00000000-0000-4000-8000-000000000001")
if err != nil {
t.Fatal(err)
}
ghostClient := e.dial(agentpki.ClientTLSConfig(a.ca, tls.Certificate{Certificate: [][]byte{ghost.Raw}, PrivateKey: key}, serverName))
_, err = ghostClient.GetDesiredState(ctx, &agentv1.GetDesiredStateRequest{})
wantCode(t, err, codes.Unauthenticated)
}
func TestRevocationClosesStream(t *testing.T) {
e := newTestEnv(t)
ctx := ctxT(t)
a := e.enroll("dns")
stream, err := a.client.Connect(ctx)
if err != nil {
t.Fatal(err)
}
if err := stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_Hello{Hello: &agentv1.Hello{}}}); err != nil {
t.Fatal(err)
}
if msg, err := stream.Recv(); err != nil || msg.GetWelcome() == nil {
t.Fatalf("welcome %v %v", msg, err)
}
if _, err := e.svc.RevokeAgent(ctx, service.SystemActor, a.id); err != nil {
t.Fatal(err)
}
_, err = stream.Recv()
wantCode(t, err, codes.PermissionDenied)
_, err = a.client.GetDesiredState(ctx, &agentv1.GetDesiredStateRequest{})
wantCode(t, err, codes.PermissionDenied)
_, req := csr(t)
_, err = a.client.RenewCertificate(ctx, &agentv1.RenewCertificateRequest{CsrDer: req})
wantCode(t, err, codes.PermissionDenied)
}
func TestFirstMessageMustBeHello(t *testing.T) {
e := newTestEnv(t)
ctx := ctxT(t)
a := e.enroll("dns")
stream, err := a.client.Connect(ctx)
if err != nil {
t.Fatal(err)
}
if err := stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_Heartbeat{Heartbeat: &agentv1.Heartbeat{}}}); err != nil {
t.Fatal(err)
}
_, err = stream.Recv()
wantCode(t, err, codes.InvalidArgument)
}
func TestCertificateRenewal(t *testing.T) {
e := newTestEnv(t)
ctx := ctxT(t)
day := 24 * time.Hour
// Enrolled 15 days ago: the certificate expired yesterday.
e.offset.Store(int64(-15 * day))
old := e.enroll("dns")
e.offset.Store(0)
if !old.cert.NotAfter.Before(time.Now()) {
t.Fatalf("certificate not expired: %v", old.cert.NotAfter)
}
_, err := old.client.GetDesiredState(ctx, &agentv1.GetDesiredStateRequest{})
wantCode(t, err, codes.Unauthenticated)
key, req := csr(t)
resp, err := old.client.RenewCertificate(ctx, &agentv1.RenewCertificateRequest{CsrDer: req})
if err != nil {
t.Fatalf("renew expired certificate: %v", err)
}
renewed := e.agentFrom(old.id, "dns", key, resp.GetCertificateDer(), resp.GetCaCertificatesDer(), old.bundlePub)
if !renewed.cert.NotAfter.After(time.Now().Add(13*day)) || renewed.cert.SerialNumber.Cmp(old.cert.SerialNumber) == 0 {
t.Errorf("renewed certificate %v", renewed.cert.NotAfter)
}
if _, err := renewed.client.GetDesiredState(ctx, &agentv1.GetDesiredStateRequest{}); err != nil {
t.Errorf("renewed certificate rejected: %v", err)
}
// The old certificate is worthless now, even for renewal.
_, err = old.client.RenewCertificate(ctx, &agentv1.RenewCertificateRequest{CsrDer: req})
wantCode(t, err, codes.Unauthenticated)
// Renewal with a valid certificate replaces it as well.
key2, req2 := csr(t)
resp, err = renewed.client.RenewCertificate(ctx, &agentv1.RenewCertificateRequest{CsrDer: req2})
if err != nil {
t.Fatal(err)
}
third := e.agentFrom(old.id, "dns", key2, resp.GetCertificateDer(), resp.GetCaCertificatesDer(), old.bundlePub)
_, err = renewed.client.GetDesiredState(ctx, &agentv1.GetDesiredStateRequest{})
wantCode(t, err, codes.Unauthenticated)
if _, err := third.client.GetDesiredState(ctx, &agentv1.GetDesiredStateRequest{}); err != nil {
t.Errorf("third certificate rejected: %v", err)
}
_, err = third.client.RenewCertificate(ctx, &agentv1.RenewCertificateRequest{CsrDer: []byte("garbage")})
wantCode(t, err, codes.InvalidArgument)
// Expired for more than 30 days: a new enrollment is needed.
e.offset.Store(int64(-45 * day))
stale := e.enroll("dns")
e.offset.Store(0)
_, req3 := csr(t)
_, err = stale.client.RenewCertificate(ctx, &agentv1.RenewCertificateRequest{CsrDer: req3})
wantCode(t, err, codes.Unauthenticated)
}
func TestLeaseReports(t *testing.T) {
e := newTestEnv(t)
ctx := ctxT(t)
dhcp := e.enroll("dhcp")
dns := e.enroll("dns")
lease := func(ip string, state agentv1.LeaseState) *agentv1.Lease {
return &agentv1.Lease{Ip: ip, Mac: "00:11:22:33:44:55", Hostname: "pc", State: state,
ExpiresAt: timestamppb.New(time.Now().Add(time.Hour)), SubnetId: 1}
}
report := func(seq uint64, full bool, ls ...*agentv1.Lease) uint64 {
t.Helper()
resp, err := dhcp.client.ReportLeases(ctx, &agentv1.ReportLeasesRequest{Sequence: seq, FullSync: full, Leases: ls})
if err != nil {
t.Fatalf("report %d: %v", seq, err)
}
return resp.GetAcknowledgedSequence()
}
count := func() int {
t.Helper()
page, err := e.svc.ListLeases(ctx, service.LeaseFilter{})
if err != nil {
t.Fatal(err)
}
return len(page.Items)
}
if ack := report(1, true, lease("192.0.2.10", agentv1.LeaseState_LEASE_STATE_ACTIVE), lease("192.0.2.11", agentv1.LeaseState_LEASE_STATE_ACTIVE)); ack != 1 || count() != 2 {
t.Fatalf("full sync: ack %d, %d leases", ack, count())
}
// A repeated batch is dropped.
if ack := report(1, true); ack != 1 || count() != 2 {
t.Errorf("duplicate: ack %d, %d leases", ack, count())
}
if ack := report(3, false, lease("192.0.2.12", agentv1.LeaseState_LEASE_STATE_ACTIVE)); ack != 3 || count() != 3 {
t.Errorf("delta: ack %d, %d leases", ack, count())
}
if ack := report(2, true); ack != 3 || count() != 3 {
t.Errorf("old batch: ack %d, %d leases", ack, count())
}
if ack := report(4, true, lease("192.0.2.20", agentv1.LeaseState_LEASE_STATE_EXPIRED)); ack != 4 || count() != 1 {
t.Errorf("second full sync: ack %d, %d leases", ack, count())
}
_, err := dhcp.client.ReportLeases(ctx, &agentv1.ReportLeasesRequest{Sequence: 0})
wantCode(t, err, codes.InvalidArgument)
_, err = dns.client.ReportLeases(ctx, &agentv1.ReportLeasesRequest{Sequence: 1, FullSync: true})
wantCode(t, err, codes.PermissionDenied)
}
func TestShutdownClosesStreams(t *testing.T) {
e := newTestEnv(t)
ctx := ctxT(t)
a := e.enroll("dns")
stream, err := a.client.Connect(ctx)
if err != nil {
t.Fatal(err)
}
if err := stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_Hello{Hello: &agentv1.Hello{}}}); err != nil {
t.Fatal(err)
}
if _, err := stream.Recv(); err != nil {
t.Fatal(err)
}
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
e.srv.Shutdown(sctx)
_, err = stream.Recv()
wantCode(t, err, codes.Unavailable)
}
+175
View File
@@ -0,0 +1,175 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
// Package agenthub tracks the open Connect streams of the agents, so that
// the service layer can push commands to them and close the stream of a
// revoked agent (ADR 0021).
package agenthub
import (
"crypto/rand"
"fmt"
"sync"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
)
// queueSize bounds the commands waiting for one stream. A full queue drops
// commands; the agent catches up through GetDesiredState.
const queueSize = 16
// Mode values (agents.mode).
const (
ModeAutoPull = "auto_pull"
ModeManual = "manual"
)
// Reasons for closing a stream from the server side.
const (
ReasonRevoked = "agent revoked"
ReasonDeleted = "agent deleted"
ReasonReplaced = "replaced by a newer connection"
ReasonShutdown = "server shutting down"
reasonEnded = "stream ended"
)
// Hub is the registry of open streams. It is safe for concurrent use.
type Hub struct {
mu sync.Mutex
conns map[string]*Conn
}
// New creates an empty hub.
func New() *Hub { return &Hub{conns: map[string]*Conn{}} }
// Conn is one open stream.
type Conn struct {
AgentID string
Role string
Mode string
cmds chan *agentv1.Command
done chan struct{}
once sync.Once
reason string
}
// Commands delivers the commands to send on the stream.
func (c *Conn) Commands() <-chan *agentv1.Command { return c.cmds }
// Done is closed when the server ends the stream (revocation, deletion or a
// newer stream of the same agent).
func (c *Conn) Done() <-chan struct{} { return c.done }
// Reason says why Done was closed.
func (c *Conn) Reason() string {
<-c.done
return c.reason
}
func (c *Conn) close(reason string) {
c.once.Do(func() {
c.reason = reason
close(c.done)
})
}
// Attach registers a stream. An older stream of the same agent is closed.
func (h *Hub) Attach(agentID, role, mode string) *Conn {
c := &Conn{AgentID: agentID, Role: role, Mode: mode, cmds: make(chan *agentv1.Command, queueSize), done: make(chan struct{})}
h.mu.Lock()
old := h.conns[agentID]
h.conns[agentID] = c
h.mu.Unlock()
if old != nil {
old.close(ReasonReplaced)
}
return c
}
// CloseAll closes every stream (shutdown).
func (h *Hub) CloseAll(reason string) {
h.mu.Lock()
conns := h.conns
h.conns = map[string]*Conn{}
h.mu.Unlock()
for _, c := range conns {
c.close(reason)
}
}
// Detach removes c if it is still the registered stream of its agent.
func (h *Hub) Detach(c *Conn) {
h.mu.Lock()
if h.conns[c.AgentID] == c {
delete(h.conns, c.AgentID)
}
h.mu.Unlock()
c.close(reasonEnded)
}
// Connected reports whether the agent has an open stream.
func (h *Hub) Connected(agentID string) bool {
h.mu.Lock()
defer h.mu.Unlock()
_, ok := h.conns[agentID]
return ok
}
// Disconnect closes the agent's stream, if any.
func (h *Hub) Disconnect(agentID, reason string) {
h.mu.Lock()
c := h.conns[agentID]
delete(h.conns, agentID)
h.mu.Unlock()
if c != nil {
c.close(reason)
}
}
// Count returns the number of open streams per role.
func (h *Hub) Count() map[string]int {
h.mu.Lock()
defer h.mu.Unlock()
out := map[string]int{}
for _, c := range h.conns {
out[c.Role]++
}
return out
}
// NotifyRevision sends an ApplyRevisionCommand to every connected agent in
// auto_pull mode and returns how many were notified.
func (h *Hub) NotifyRevision(revision uint64) int {
h.mu.Lock()
targets := make([]*Conn, 0, len(h.conns))
for _, c := range h.conns {
if c.Mode == ModeAutoPull {
targets = append(targets, c)
}
}
h.mu.Unlock()
n := 0
for _, c := range targets {
cmd := &agentv1.Command{
CommandId: NewID(),
Action: &agentv1.Command_ApplyRevision{ApplyRevision: &agentv1.ApplyRevisionCommand{
Revision: revision, DeploymentId: NewID(),
}},
}
select {
case c.cmds <- cmd:
n++
default: // queue full: the agent will pull
}
}
return n
}
// NewID returns a random UUID (version 4) in canonical lowercase form.
func NewID() string {
var b [16]byte
_, _ = rand.Read(b[:]) // never fails (crypto/rand)
b[6] = b[6]&0x0f | 0x40
b[8] = b[8]&0x3f | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
+63
View File
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package agenthub
import (
"testing"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
)
func TestHub(t *testing.T) {
h := New()
a := h.Attach("a", "dns", ModeAutoPull)
b := h.Attach("b", "dhcp", ModeManual)
if !h.Connected("a") || !h.Connected("b") || h.Connected("c") {
t.Fatal("connected state")
}
if c := h.Count(); c["dns"] != 1 || c["dhcp"] != 1 {
t.Errorf("count %v", c)
}
if n := h.NotifyRevision(7); n != 1 {
t.Errorf("notified %d", n)
}
cmd := <-a.Commands()
if cmd.GetApplyRevision().GetRevision() != 7 || !agentpki.ValidAgentID(cmd.GetCommandId()) ||
!agentpki.ValidAgentID(cmd.GetApplyRevision().GetDeploymentId()) {
t.Errorf("command %v", cmd)
}
select {
case <-b.Commands():
t.Error("manual agent notified")
default:
}
// A full queue drops commands instead of blocking.
for range queueSize + 3 {
h.NotifyRevision(8)
}
if len(a.cmds) != queueSize {
t.Errorf("queue length %d", len(a.cmds))
}
// A newer stream replaces the old one.
a2 := h.Attach("a", "dns", ModeAutoPull)
if a.Reason() == "" {
t.Error("old stream not closed")
}
h.Detach(a) // stale detach must not remove the new stream
if !h.Connected("a") {
t.Error("stale detach removed the new stream")
}
h.Disconnect("a", "revoked")
if a2.Reason() != "revoked" || h.Connected("a") {
t.Errorf("disconnect: %q", a2.Reason())
}
h.Detach(b)
if h.Connected("b") || len(h.Count()) != 0 {
t.Error("detach")
}
h.Disconnect("unknown", "x") // no-op
}
+55 -8
View File
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
@@ -16,10 +17,13 @@ import (
"git.beugsiud.de/beug.k/DDI/internal/brand"
"git.beugsiud.de/beug.k/DDI/internal/buildinfo"
"git.beugsiud.de/beug.k/DDI/server/internal/agentapi"
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/config"
"git.beugsiud.de/beug.k/DDI/server/internal/httpapi"
"git.beugsiud.de/beug.k/DDI/server/internal/httpserver"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
"git.beugsiud.de/beug.k/DDI/server/internal/pki"
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
@@ -60,6 +64,8 @@ type Core struct {
Keyring *secrets.Keyring
License *license.Manager
Service *service.Service
PKI *pki.Authority
Hub *agenthub.Hub
Log *slog.Logger
}
@@ -92,9 +98,16 @@ func Open(ctx context.Context, cfg config.Config, log *slog.Logger, migrate bool
st.Close()
return nil, err
}
core.PKI, err = pki.LoadOrCreate(ctx, st, kr, log.With(slog.String("component", "pki")), nil)
if err != nil {
st.Close()
return nil, fmt.Errorf("agent PKI: %w", err)
}
core.Hub = agenthub.New()
core.Service = service.New(service.Options{
Store: st, Keyring: kr, License: core.License, ProductName: brand.DisplayName,
SessionLifetime: cfg.Session.Lifetime.Std(), SessionIdle: cfg.Session.IdleTimeout.Std(), Logger: log,
PKI: core.PKI, Hub: core.Hub, AgentAPIAddress: cfg.AgentAPI.PublicAddress, AgentServerNames: cfg.AgentAPI.ServerNames,
})
return core, nil
}
@@ -139,7 +152,7 @@ func Serve(ctx context.Context, core *Core) error {
secure = true
}
info := buildinfo.Get()
metrics := newMetrics(core.License)
metrics := newMetrics(core.License, core.Hub)
api := httpapi.New(httpapi.Options{
Service: core.Service, PublicURL: cfg.HTTP.PublicURL, SecureCookies: secure,
ProductName: brand.DisplayName, Version: info.Version, Logger: log,
@@ -150,13 +163,26 @@ func Serve(ctx context.Context, core *Core) error {
Readiness: map[string]httpserver.ReadinessCheck{"database": core.Store.Ping},
})
agentLog := log.With(slog.String("component", "agentapi"))
agentSrv, err := agentapi.New(agentapi.Options{Service: core.Service, PKI: core.PKI, Hub: core.Hub,
ServerNames: cfg.AgentAPI.ServerNames, Logger: agentLog})
if err != nil {
return fmt.Errorf("agent API: %w", err)
}
agentLis, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.AgentAPI.Listen)
if err != nil {
return fmt.Errorf("agent API listener: %w", err)
}
jobs, stopJobs := context.WithCancel(ctx)
defer stopJobs()
go core.License.Run(jobs)
go cleanupSessions(jobs, core)
go cleanup(jobs, core)
log.Info("starting server", slog.String("version", info.Version), slog.String("commit", info.Commit),
slog.String("listen", cfg.HTTP.Listen), slog.Bool("tls", cfg.TLSEnabled()),
slog.String("agent_api_listen", cfg.AgentAPI.Listen), slog.String("agent_api_address", cfg.AgentAPI.PublicAddress),
slog.String("agent_ca_fingerprint", core.PKI.Fingerprint()),
slog.String("license_state", string(core.License.Status().State)))
if !cfg.TLSEnabled() {
log.Warn("TLS is disabled; use only for local development or behind a TLS-terminating proxy")
@@ -170,25 +196,41 @@ func Serve(ctx context.Context, core *Core) error {
errCh <- srv.ListenAndServe()
}
}()
agentErrCh := make(chan error, 1)
go func() { agentErrCh <- agentSrv.Serve(agentLis) }()
var runErr error
select {
case err := <-errCh:
return fmt.Errorf("http listener: %w", err)
runErr = fmt.Errorf("http listener: %w", err)
errCh <- nil // already received; keep the shutdown path uniform
case err := <-agentErrCh:
runErr = fmt.Errorf("agent API listener: %w", err)
agentErrCh <- nil
case <-ctx.Done():
}
log.Info("shutting down", slog.Duration("timeout", cfg.ShutdownTimeout.Std()))
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.ShutdownTimeout.Std())
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
agentSrv.Shutdown(shutdownCtx)
if err := srv.Shutdown(shutdownCtx); err != nil && runErr == nil {
runErr = fmt.Errorf("graceful shutdown: %w", err)
}
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("http listener: %w", err)
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) && runErr == nil {
runErr = fmt.Errorf("http listener: %w", err)
}
if err := <-agentErrCh; err != nil && runErr == nil {
runErr = fmt.Errorf("agent API listener: %w", err)
}
if runErr != nil {
return runErr
}
log.Info("stopped")
return nil
}
func cleanupSessions(ctx context.Context, core *Core) {
// cleanup removes expired sessions and old enrollment tokens periodically.
func cleanup(ctx context.Context, core *Core) {
t := time.NewTicker(15 * time.Minute)
defer t.Stop()
for {
@@ -197,6 +239,11 @@ func cleanupSessions(ctx context.Context, core *Core) {
} else if n > 0 {
core.Log.Debug("removed expired sessions", slog.Int64("count", n))
}
if n, err := core.Service.CleanupEnrollmentTokens(ctx); err != nil {
core.Log.Warn("enrollment token cleanup failed", slog.Any("error", err))
} else if n > 0 {
core.Log.Debug("removed old enrollment tokens", slog.Int64("count", n))
}
select {
case <-ctx.Done():
return
+10 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
"git.beugsiud.de/beug.k/DDI/internal/buildinfo"
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
)
@@ -23,7 +24,7 @@ type metrics struct {
duration *prometheus.HistogramVec
}
func newMetrics(lic *license.Manager) *metrics {
func newMetrics(lic *license.Manager, hub *agenthub.Hub) *metrics {
reg := prometheus.NewRegistry()
reg.MustRegister(collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
m := &metrics{
@@ -50,6 +51,14 @@ func newMetrics(lic *license.Manager) *metrics {
return 0
})
reg.MustRegister(m.requests, m.duration, buildGauge, licenseGauge)
if hub != nil {
for _, role := range []string{"dns", "dhcp"} {
reg.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "ddi_agents_connected", Help: "Agents with an open control stream, by role.",
ConstLabels: prometheus.Labels{"role": role},
}, func() float64 { return float64(hub.Count()[role]) }))
}
}
return m
}
+98
View File
@@ -13,6 +13,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -25,6 +26,7 @@ import (
// Defaults.
const (
DefaultHTTPListen = ":8443"
DefaultAgentAPIListen = ":8444"
DefaultShutdownTimeout = 15 * time.Second
DefaultSessionLifetime = 12 * time.Hour
DefaultSessionIdleTimeout = time.Hour
@@ -70,6 +72,7 @@ func (d Duration) Std() time.Duration { return time.Duration(d) }
// Config is the server configuration.
type Config struct {
HTTP HTTP `yaml:"http"`
AgentAPI AgentAPI `yaml:"agent_api"`
Database Database `yaml:"database"`
MasterKeyFile string `yaml:"master_key_file"`
StateDir string `yaml:"state_dir"`
@@ -88,6 +91,19 @@ type HTTP struct {
PublicURL string `yaml:"public_url"`
}
// AgentAPI configures the gRPC listener for the agents (ADR 0021). Its TLS
// certificate is issued by the internal agent CA at every start.
type AgentAPI struct {
Listen string `yaml:"listen"`
// PublicAddress ("host:port") is what agents connect to; it appears in
// the enrollment command. Default: host of http.public_url (or of the
// machine) and the port of Listen.
PublicAddress string `yaml:"public_address"`
// ServerNames become the SANs of the server certificate. Default: the
// host of PublicAddress.
ServerNames []string `yaml:"server_names"`
}
// Database configures PostgreSQL access.
type Database struct {
// URL is a libpq connection URL or key/value string.
@@ -138,6 +154,7 @@ func EnvName(key string) string { return brand.EnvPrefix + key }
func Defaults() Config {
return Config{
HTTP: HTTP{Listen: DefaultHTTPListen},
AgentAPI: AgentAPI{Listen: DefaultAgentAPIListen},
MasterKeyFile: filepath.Join(brand.ConfigDir, "master.key"),
StateDir: brand.StateDir,
Log: Log{Level: "info", Format: "json"},
@@ -177,9 +194,78 @@ func Load(path string, lookup LookupFunc) (Config, error) {
}
cfg.Database.URL = strings.TrimSpace(string(data))
}
cfg.resolveAgentAPI()
return cfg, cfg.Validate()
}
// hostname is replaced in tests.
var hostname = os.Hostname
// resolveAgentAPI fills in the defaults of the agent API that derive from
// other settings. Invalid inputs are left for Validate to report.
func (c *Config) resolveAgentAPI() {
a := &c.AgentAPI
if a.PublicAddress == "" {
_, port, err := net.SplitHostPort(a.Listen)
if err != nil {
return
}
host := ""
if u, err := url.Parse(c.HTTP.PublicURL); err == nil && c.HTTP.PublicURL != "" {
host = u.Hostname()
}
if host == "" {
if h, _, err := net.SplitHostPort(a.Listen); err == nil && h != "" && !net.ParseIP(h).IsUnspecified() {
host = h
}
}
if host == "" {
if h, err := hostname(); err == nil {
host = strings.ToLower(h)
}
}
if host == "" {
host = "localhost"
}
a.PublicAddress = net.JoinHostPort(host, port)
}
if len(a.ServerNames) == 0 {
if h, _, err := net.SplitHostPort(a.PublicAddress); err == nil && h != "" {
a.ServerNames = []string{h}
}
}
}
// serverName matches DNS names (with an optional leading wildcard label).
var serverName = regexp.MustCompile(`^(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
func validServerName(s string) bool {
if net.ParseIP(s) != nil {
return true
}
return len(s) <= 253 && serverName.MatchString(s)
}
func (a AgentAPI) validate() []error {
var errs []error
if _, _, err := net.SplitHostPort(a.Listen); err != nil {
errs = append(errs, fmt.Errorf("agent_api.listen: invalid address %q: %w", a.Listen, err))
}
host, port, err := net.SplitHostPort(a.PublicAddress)
if n, perr := strconv.Atoi(port); err != nil || host == "" || perr != nil || n < 1 || n > 65535 {
errs = append(errs, fmt.Errorf("agent_api.public_address: want host:port, got %q", a.PublicAddress))
}
if len(a.ServerNames) == 0 {
errs = append(errs, errors.New("agent_api.server_names must not be empty"))
}
for _, n := range a.ServerNames {
if !validServerName(n) {
errs = append(errs, fmt.Errorf("agent_api.server_names: invalid name %q", n))
}
}
return errs
}
func decodeYAML(r io.Reader, cfg *Config) error {
data, err := io.ReadAll(r)
if err != nil {
@@ -219,6 +305,17 @@ var envBindings = []envBinding{
{name: "TLS_CERT_FILE", set: str(func(c *Config) *string { return &c.HTTP.TLSCertFile })},
{name: "TLS_KEY_FILE", set: str(func(c *Config) *string { return &c.HTTP.TLSKeyFile })},
{name: "PUBLIC_URL", set: str(func(c *Config) *string { return &c.HTTP.PublicURL })},
{name: "AGENT_API_LISTEN", set: str(func(c *Config) *string { return &c.AgentAPI.Listen })},
{name: "AGENT_API_PUBLIC_ADDRESS", set: str(func(c *Config) *string { return &c.AgentAPI.PublicAddress })},
{name: "AGENT_API_SERVER_NAMES", set: func(c *Config, v string) error {
c.AgentAPI.ServerNames = nil
for _, n := range strings.Split(v, ",") {
if n = strings.TrimSpace(n); n != "" {
c.AgentAPI.ServerNames = append(c.AgentAPI.ServerNames, n)
}
}
return nil
}},
{name: "DATABASE_URL", set: str(func(c *Config) *string { return &c.Database.URL })},
{name: "DATABASE_URL_FILE", set: str(func(c *Config) *string { return &c.Database.URLFile })},
{name: "MASTER_KEY_FILE", set: str(func(c *Config) *string { return &c.MasterKeyFile })},
@@ -292,6 +389,7 @@ func (c Config) Validate() error {
if c.Database.MaxConns < 1 {
errs = append(errs, errors.New("database.max_conns must be at least 1"))
}
errs = append(errs, c.AgentAPI.validate()...)
return errors.Join(errs...)
}
+49
View File
@@ -127,6 +127,55 @@ func TestValidation(t *testing.T) {
}
}
func TestAgentAPIDefaults(t *testing.T) {
orig := hostname
t.Cleanup(func() { hostname = orig })
hostname = func() (string, error) { return "Mgmt01", nil }
cfg, err := Load("", env(nil))
if err != nil {
t.Fatal(err)
}
a := cfg.AgentAPI
if a.Listen != DefaultAgentAPIListen || a.PublicAddress != "mgmt01:8444" || len(a.ServerNames) != 1 || a.ServerNames[0] != "mgmt01" {
t.Errorf("defaults from hostname: %+v", a)
}
cfg, err = Load("", env(map[string]string{"DDI_PUBLIC_URL": "https://ddi.example.test", "DDI_AGENT_API_LISTEN": "0.0.0.0:9444"}))
if err != nil {
t.Fatal(err)
}
if cfg.AgentAPI.PublicAddress != "ddi.example.test:9444" || cfg.AgentAPI.ServerNames[0] != "ddi.example.test" {
t.Errorf("defaults from public URL: %+v", cfg.AgentAPI)
}
cfg, err = Load("", env(map[string]string{"DDI_AGENT_API_LISTEN": "127.0.0.1:8444"}))
if err != nil || cfg.AgentAPI.PublicAddress != "127.0.0.1:8444" || cfg.AgentAPI.ServerNames[0] != "127.0.0.1" {
t.Errorf("defaults from listen host: %+v %v", cfg.AgentAPI, err)
}
cfg, err = Load("", env(map[string]string{"DDI_AGENT_API_PUBLIC_ADDRESS": "[2001:db8::1]:8444",
"DDI_AGENT_API_SERVER_NAMES": "agents.example.test, 2001:db8::1"}))
if err != nil || cfg.AgentAPI.PublicAddress != "[2001:db8::1]:8444" || len(cfg.AgentAPI.ServerNames) != 2 {
t.Errorf("explicit values: %+v %v", cfg.AgentAPI, err)
}
}
func TestAgentAPIValidation(t *testing.T) {
for name, tt := range map[string]map[string]string{
"bad listen": {"DDI_AGENT_API_LISTEN": "nonsense"},
"public without port": {"DDI_AGENT_API_PUBLIC_ADDRESS": "agents.example.test"},
"public bad port": {"DDI_AGENT_API_PUBLIC_ADDRESS": "agents.example.test:99999"},
"bad server name": {"DDI_AGENT_API_SERVER_NAMES": "bad name"},
} {
t.Run(name, func(t *testing.T) {
if _, err := Load("", env(tt)); err == nil || !strings.Contains(err.Error(), "agent_api") {
t.Errorf("err = %v", err)
}
})
}
}
func TestLoopbackKeygenAllowedOverHTTP(t *testing.T) {
if _, err := Load("", env(map[string]string{"KEYGEN_URL": "http://127.0.0.1:3000"})); err != nil {
t.Errorf("loopback http must be allowed for tests: %v", err)
+420
View File
@@ -0,0 +1,420 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package httpapi
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"errors"
"strconv"
"strings"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/internal/brand"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
)
// enroll registers an agent through the service (the gRPC layer is tested
// in package agentapi).
func (e *env) enroll(token, role string) string {
e.t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
e.t.Fatal(err)
}
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, key)
if err != nil {
e.t.Fatal(err)
}
res, err := e.svc.EnrollAgent(context.Background(), service.EnrollInput{Token: token, CSR: csr, Role: role,
Hostname: "host-" + role, AgentVersion: "1.0.0"})
if err != nil {
e.t.Fatalf("enroll: %v", err)
}
return res.AgentID
}
func (c *client) createToken(role string) service.EnrollmentTokenCreated {
c.e.t.Helper()
var out service.EnrollmentTokenCreated
c.mustStatus(201, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": role, "description": "for " + role}).json(c.e.t, &out)
return out
}
// configureDNS stores generator settings that allow DNS generation.
func (c *client) configureDNS() {
c.e.t.Helper()
var g service.GeneratorSettings
c.mustStatus(200, "GET", "/api/v1/settings/generator", nil).json(c.e.t, &g)
g.DNS.PrimaryNS = "ns1.office.test"
g.DNS.Hostmaster = "hostmaster@office.test"
g.DNS.NameServers = []service.GeneratorNS{{Name: "ns1.office.test", Addresses: []string{"192.0.2.53"}}}
c.mustStatus(200, "PUT", "/api/v1/settings/generator", g)
}
type agentList = listResponse[service.AgentDTO]
func TestEnrollmentTokensAndAgents(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
viewer := e.userClient(admin, "vera", "viewer")
operator := e.userClient(admin, "otto", "operator")
var agents agentList
viewer.mustStatus(200, "GET", "/api/v1/agents", nil).json(t, &agents)
if len(agents.Items) != 0 {
t.Fatalf("agents %+v", agents.Items)
}
viewer.mustStatus(403, "GET", "/api/v1/enrollment-tokens", nil)
operator.mustStatus(403, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": "dns"})
tok := admin.createToken("dns")
id, secret, ok := strings.Cut(tok.Secret, ".")
if !ok || len(id) != 16 || id != tok.Token.ID || len(secret) != 43 {
t.Errorf("token format %q", tok.Secret)
}
if tok.CAFingerprint != e.pki.Fingerprint() || tok.ServerAddress != "ddi.example.test:8444" {
t.Errorf("token %+v", tok)
}
wantCmd := brand.AgentDNSBinary + " enroll --server ddi.example.test:8444 --token " + tok.Secret + " --ca-hash " + e.pki.Fingerprint()
if tok.Command != wantCmd {
t.Errorf("command %q, want %q", tok.Command, wantCmd)
}
if dhcp := admin.createToken("dhcp"); !strings.HasPrefix(dhcp.Command, brand.AgentDHCPBinary+" enroll ") {
t.Errorf("dhcp command %q", dhcp.Command)
}
r := admin.mustStatus(422, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": "dns", "valid_hours": 721},
skipRequestValidation, "1")
if p := r.problem(t).Problems; len(p) != 1 || p[0].Field != "valid_hours" {
t.Errorf("problems %+v", p)
}
var tokens listResponse[service.EnrollmentTokenDTO]
admin.mustStatus(200, "GET", "/api/v1/enrollment-tokens", nil).json(t, &tokens)
if len(tokens.Items) != 2 || strings.Contains(string(admin.do("GET", "/api/v1/enrollment-tokens", nil).Body), secret) {
t.Fatalf("tokens %+v", tokens.Items)
}
// Enrollment through the service: wrong role, wrong secret, success,
// reuse.
ctx := context.Background()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
csr, _ := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, key)
if _, err := e.svc.EnrollAgent(ctx, service.EnrollInput{Token: tok.Secret, CSR: csr, Role: "dhcp"}); !errors.Is(err, service.ErrTokenRoleMismatch) {
t.Errorf("wrong role: %v", err)
}
if _, err := e.svc.EnrollAgent(ctx, service.EnrollInput{Token: id + ".wrong", CSR: csr, Role: "dns"}); !errors.Is(err, service.ErrInvalidToken) {
t.Errorf("wrong secret: %v", err)
}
agentID := e.enroll(tok.Secret, "dns")
if _, err := e.svc.EnrollAgent(ctx, service.EnrollInput{Token: tok.Secret, CSR: csr, Role: "dns"}); !errors.Is(err, service.ErrInvalidToken) {
t.Errorf("reused token: %v", err)
}
admin.mustStatus(200, "GET", "/api/v1/enrollment-tokens", nil).json(t, &tokens)
var used *service.EnrollmentTokenDTO
for i := range tokens.Items {
if tokens.Items[i].ID == id {
used = &tokens.Items[i]
}
}
if used == nil || used.UsedAt == nil || used.AgentID != agentID {
t.Errorf("used token %+v", used)
}
var ag service.AgentDTO
viewer.mustStatus(200, "GET", "/api/v1/agents/"+agentID, nil).json(t, &ag)
if ag.Role != "dns" || ag.Hostname != "host-dns" || ag.Connected || ag.Mode != "auto_pull" || ag.Health.State != "unknown" ||
ag.DesiredRevision != 0 || ag.CertExpiresAt.IsZero() {
t.Errorf("agent %+v", ag)
}
viewer.mustStatus(404, "GET", "/api/v1/agents/not-a-uuid", nil)
viewer.mustStatus(404, "GET", "/api/v1/agents/00000000-0000-4000-8000-000000000000", nil)
// Status from the agent.
if _, err := e.svc.AgentHello(ctx, agentID, service.HelloInput{Hostname: "ns1.office.test", AgentVersion: "1.0.1",
ServiceVersion: "BIND 9.20.3", Health: &service.AgentHealth{State: "ok", Checks: []service.HealthCheck{{Name: "rndc", State: "ok"}}}}); err != nil {
t.Fatal(err)
}
conn := e.hub.Attach(agentID, "dns", "auto_pull")
viewer.mustStatus(200, "GET", "/api/v1/agents", nil).json(t, &agents)
if len(agents.Items) != 1 || !agents.Items[0].Connected || agents.Items[0].ServiceVersion != "BIND 9.20.3" ||
agents.Items[0].Health.State != "ok" || len(agents.Items[0].Health.Checks) != 1 || agents.Items[0].LastSeenAt == nil {
t.Fatalf("agents %+v", agents.Items)
}
// Revocation closes the stream and is audited.
viewer.mustStatus(403, "POST", "/api/v1/agents/"+agentID+"/revoke", nil)
admin.mustStatus(200, "POST", "/api/v1/agents/"+agentID+"/revoke", nil).json(t, &ag)
if ag.RevokedAt == nil || ag.Connected {
t.Errorf("revoked agent %+v", ag)
}
select {
case <-conn.Done():
default:
t.Error("stream of the revoked agent is still open")
}
if _, err := e.svc.AgentHello(ctx, agentID, service.HelloInput{}); !errors.Is(err, service.ErrAgentRevoked) {
t.Errorf("hello after revocation: %v", err)
}
admin.mustStatus(200, "POST", "/api/v1/agents/"+agentID+"/revoke", nil) // idempotent
var audit auditPage
admin.mustStatus(200, "GET", "/api/v1/audit?object_type=agent", nil).json(t, &audit)
if len(audit.Items) != 2 { // enroll, revoke
t.Errorf("agent audit %+v", audit.Items)
}
viewer.mustStatus(403, "DELETE", "/api/v1/agents/"+agentID, nil)
admin.mustStatus(204, "DELETE", "/api/v1/agents/"+agentID, nil)
admin.mustStatus(404, "GET", "/api/v1/agents/"+agentID, nil)
admin.mustStatus(404, "DELETE", "/api/v1/agents/"+agentID, nil)
admin.mustStatus(204, "DELETE", "/api/v1/enrollment-tokens/"+id, nil)
admin.mustStatus(404, "DELETE", "/api/v1/enrollment-tokens/"+id, nil)
admin.mustStatus(404, "DELETE", "/api/v1/enrollment-tokens/nonsense", nil)
admin.mustStatus(200, "GET", "/api/v1/audit?object_type=enrollment_token", nil).json(t, &audit)
if len(audit.Items) != 3 { // two creates, one delete
t.Errorf("token audit %+v", audit.Items)
}
}
func TestRevisionsAndDeployments(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
viewer := e.userClient(admin, "vera", "viewer")
operator := e.userClient(admin, "otto", "operator")
ctx := context.Background()
admin.mustStatus(201, "POST", "/api/v1/networks", map[string]any{
"cidr": "192.0.2.0/24", "name": "Office", "gateway": "192.0.2.1", "domain": "office.test",
"dhcp_enabled": true, "pools": []map[string]string{{"start": "192.0.2.100", "end": "192.0.2.199"}},
})
admin.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.53", "type": "static", "hostname": "ns1"})
// Without DNS settings nothing is stored.
r := operator.mustStatus(422, "POST", "/api/v1/revisions", map[string]any{"comment": "first"})
if p := r.problem(t); p.Code != "dns_settings_missing" || p.Params["part"] != "dns" {
t.Errorf("problem %+v", p)
}
admin.configureDNS()
dnsAgent := e.enroll(admin.createToken("dns").Secret, "dns")
conn := e.hub.Attach(dnsAgent, "dns", "auto_pull")
viewer.mustStatus(403, "POST", "/api/v1/revisions", nil)
var rev service.RevisionDTO
operator.mustStatus(201, "POST", "/api/v1/revisions", map[string]any{"comment": "first"}).json(t, &rev)
if rev.Number != 1 || rev.Comment != "first" || rev.CreatedByName != "otto" || len(rev.DNS) == 0 || len(rev.DHCP) != 1 {
t.Fatalf("revision %+v", rev)
}
select {
case cmd := <-conn.Commands():
if cmd.GetApplyRevision().GetRevision() != 1 {
t.Errorf("command %v", cmd)
}
case <-time.After(time.Second):
t.Error("connected agent was not notified")
}
// Unchanged data: the latest revision with 200, no notification.
rev = service.RevisionDTO{}
operator.mustStatus(200, "POST", "/api/v1/revisions", nil).json(t, &rev)
if rev.Number != 1 || rev.Comment != "first" {
t.Errorf("unchanged %+v", rev)
}
select {
case cmd := <-conn.Commands():
t.Errorf("unexpected command %v", cmd)
default:
}
admin.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.10", "type": "static", "hostname": "www"})
rev = service.RevisionDTO{}
operator.mustStatus(201, "POST", "/api/v1/revisions", map[string]any{}).json(t, &rev)
if rev.Number != 2 {
t.Fatalf("second revision %+v", rev)
}
zone := fileByPath(rev.DNS, "zones/db.office.test")
if zone == nil || !strings.Contains(zone.Content, "192.0.2.10") {
t.Errorf("zone %+v", zone)
}
var page service.RevisionPage
viewer.mustStatus(200, "GET", "/api/v1/revisions", nil).json(t, &page)
if len(page.Items) != 2 || page.Items[0].Number != 2 || page.NextBefore != nil || page.Items[0].DNSFiles != len(rev.DNS) || page.Items[0].DHCPFiles != 1 {
t.Fatalf("revisions %+v", page)
}
page = service.RevisionPage{}
viewer.mustStatus(200, "GET", "/api/v1/revisions?limit=1", nil).json(t, &page)
if len(page.Items) != 1 || page.NextBefore == nil || *page.NextBefore != 2 {
t.Fatalf("first page %+v", page)
}
page = service.RevisionPage{}
viewer.mustStatus(200, "GET", "/api/v1/revisions?limit=1&before=2", nil).json(t, &page)
if len(page.Items) != 1 || page.Items[0].Number != 1 || page.NextBefore != nil {
t.Fatalf("second page %+v", page)
}
rev = service.RevisionDTO{}
viewer.mustStatus(200, "GET", "/api/v1/revisions/1", nil).json(t, &rev)
if rev.Number != 1 || fileByPath(rev.DNS, "zones/db.office.test") == nil {
t.Errorf("revision 1 %+v", rev)
}
viewer.mustStatus(404, "GET", "/api/v1/revisions/99", nil)
var audit auditPage
admin.mustStatus(200, "GET", "/api/v1/audit?action=revision.create", nil).json(t, &audit)
if len(audit.Items) != 2 {
t.Errorf("revision audit %+v", audit.Items)
}
// Deployment reports.
started := e.now.Add(-time.Minute)
for _, rep := range []service.DeploymentReport{
{Revision: 1, Trigger: "push", Outcome: "succeeded", ActiveRevision: 1, StartedAt: started, FinishedAt: started.Add(time.Second)},
{Revision: 2, Trigger: "pull", Outcome: "rolled_back", Message: "SOA serial mismatch", ActiveRevision: 1, StartedAt: started},
} {
if err := e.svc.RecordDeployment(ctx, dnsAgent, rep); err != nil {
t.Fatal(err)
}
}
if err := e.svc.RecordDeployment(ctx, dnsAgent, service.DeploymentReport{Revision: 0, Trigger: "pull", Outcome: "succeeded"}); err == nil {
t.Error("revision 0 accepted")
}
var deps service.DeploymentPage
viewer.mustStatus(200, "GET", "/api/v1/deployments", nil).json(t, &deps)
if len(deps.Items) != 2 || deps.Items[0].Outcome != "rolled_back" || deps.Items[0].AgentHostname != "host-dns" || deps.NextBeforeID != nil {
t.Fatalf("deployments %+v", deps)
}
deps = service.DeploymentPage{}
viewer.mustStatus(200, "GET", "/api/v1/deployments?limit=1&revision=1&agent_id="+dnsAgent, nil).json(t, &deps)
if len(deps.Items) != 1 || deps.Items[0].Revision != 1 || deps.NextBeforeID != nil {
t.Errorf("filtered %+v", deps)
}
deps = service.DeploymentPage{}
viewer.mustStatus(200, "GET", "/api/v1/deployments?limit=1", nil).json(t, &deps)
if deps.NextBeforeID == nil {
t.Fatalf("paging %+v", deps)
}
before := *deps.NextBeforeID
deps = service.DeploymentPage{}
viewer.mustStatus(200, "GET", "/api/v1/deployments?before_id="+strconv.FormatInt(before, 10), nil).json(t, &deps)
if len(deps.Items) != 1 || deps.Items[0].Outcome != "succeeded" {
t.Errorf("before_id %+v", deps)
}
viewer.mustStatus(200, "GET", "/api/v1/deployments?agent_id=unknown", nil)
var ag service.AgentDTO
viewer.mustStatus(200, "GET", "/api/v1/agents/"+dnsAgent, nil).json(t, &ag)
if ag.AppliedRevision != 1 || ag.LastGoodRevision != 1 || ag.DesiredRevision != 2 || ag.LastDeployment == nil ||
ag.LastDeployment.Outcome != "rolled_back" {
t.Errorf("agent after deployments %+v", ag)
}
// Read-only mode blocks revisions but not the agents.
e.advance(31 * 24 * time.Hour)
admin.login("admin", "admin-password-123")
if p := admin.mustStatus(403, "POST", "/api/v1/revisions", nil).problem(t); p.Code != "license_read_only" {
t.Errorf("read-only %+v", p)
}
if err := e.svc.AgentHeartbeat(ctx, dnsAgent, service.HeartbeatInput{AppliedRevision: 2, LastGoodRevision: 2}); err != nil {
t.Errorf("heartbeat in read-only mode: %v", err)
}
}
func TestLeases(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
viewer := e.userClient(admin, "vera", "viewer")
ctx := context.Background()
var netw service.NetworkDTO
admin.mustStatus(201, "POST", "/api/v1/networks", map[string]any{"cidr": "192.0.2.0/24", "name": "Office"}).json(t, &netw)
ids, err := e.st.Q().SubnetIDs(ctx)
if err != nil {
t.Fatal(err)
}
subnet := ids[netw.ID]
dhcpAgent := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
dnsAgent := e.enroll(admin.createToken("dns").Secret, "dns")
exp := e.now.Add(time.Hour)
leases := []service.LeaseInput{
{IP: "192.0.2.100", MAC: "00:11:22:33:44:55", Hostname: "laptop", State: "active", ExpiresAt: &exp, SubnetID: subnet},
{IP: "192.0.2.101", MAC: "00:11:22:33:44:66", Hostname: "phone", State: "released", SubnetID: subnet},
{IP: "198.51.100.7", MAC: "00:11:22:33:44:77", State: "active", SubnetID: 9999},
{IP: "not-an-ip", State: "active"},
}
res, err := e.svc.StoreLeaseBatch(ctx, dhcpAgent, 1, true, leases)
if err != nil || res.Acknowledged != 1 || res.Duplicate || res.Skipped != 1 {
t.Fatalf("batch %+v %v", res, err)
}
// A duplicate batch is dropped and acknowledged with the stored sequence.
res, err = e.svc.StoreLeaseBatch(ctx, dhcpAgent, 1, true, nil)
if err != nil || !res.Duplicate || res.Acknowledged != 1 {
t.Errorf("duplicate %+v %v", res, err)
}
if _, err := e.svc.StoreLeaseBatch(ctx, dnsAgent, 1, true, leases); !errors.Is(err, service.ErrNotDHCPAgent) {
t.Errorf("DNS agent leases: %v", err)
}
var page service.LeasePage
viewer.mustStatus(200, "GET", "/api/v1/leases", nil).json(t, &page)
if len(page.Items) != 3 || page.NextAfter != nil || page.Items[0].NetworkID != netw.ID || page.Items[2].NetworkID != "" ||
page.Items[0].AgentID != dhcpAgent || page.Items[0].ExpiresAt == nil {
t.Fatalf("leases %+v", page)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?network_id="+netw.ID+"&state=active", nil).json(t, &page)
if len(page.Items) != 1 || page.Items[0].Hostname != "laptop" {
t.Errorf("filtered %+v", page)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?q=44:66", nil).json(t, &page)
if len(page.Items) != 1 || page.Items[0].Hostname != "phone" {
t.Errorf("search %+v", page)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?limit=2", nil).json(t, &page)
if len(page.Items) != 2 || page.NextAfter == nil || *page.NextAfter != "192.0.2.101" {
t.Fatalf("first page %+v", page)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?limit=2&after=192.0.2.101", nil).json(t, &page)
if len(page.Items) != 1 || page.Items[0].IP.String() != "198.51.100.7" || page.NextAfter != nil {
t.Errorf("second page %+v", page)
}
viewer.mustStatus(422, "GET", "/api/v1/leases?after=nonsense", nil)
// Changes are merged; a full sync replaces the set.
if _, err := e.svc.StoreLeaseBatch(ctx, dhcpAgent, 2, false, []service.LeaseInput{
{IP: "192.0.2.101", MAC: "00:11:22:33:44:66", Hostname: "phone", State: "active", SubnetID: subnet},
}); err != nil {
t.Fatal(err)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?state=active", nil).json(t, &page)
if len(page.Items) != 3 {
t.Errorf("after delta %+v", page)
}
if _, err := e.svc.StoreLeaseBatch(ctx, dhcpAgent, 3, true, leases[:1]); err != nil {
t.Fatal(err)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases", nil).json(t, &page)
if len(page.Items) != 1 {
t.Errorf("after full sync %+v", page)
}
// Deleting the agent removes its leases.
admin.mustStatus(204, "DELETE", "/api/v1/agents/"+dhcpAgent, nil)
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases", nil).json(t, &page)
if len(page.Items) != 0 {
t.Errorf("leases of deleted agent %+v", page)
}
}
+206
View File
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package httpapi
import (
"bytes"
"io"
"net/http"
"net/netip"
"net/url"
"strconv"
"strings"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
// intParam parses an optional integer query parameter within [lo, hi]
// (0 if absent).
func intParam(q url.Values, name string, lo, hi int64) (int64, error) {
v := q.Get(name)
if v == "" {
return 0, nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < lo || n > hi {
return 0, errBadRequest("invalid_parameter", "invalid "+name)
}
return n, nil
}
func (a *API) listAgents(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
agents, err := a.svc.ListAgents(r.Context())
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, listResponse[service.AgentDTO]{Items: agents})
}
func (a *API) getAgent(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
ag, err := a.svc.GetAgent(r.Context(), r.PathValue("id"))
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, ag)
}
func (a *API) revokeAgent(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
ag, err := a.svc.RevokeAgent(r.Context(), rc.Actor, r.PathValue("id"))
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, ag)
}
func (a *API) deleteAgent(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
if err := a.svc.DeleteAgent(r.Context(), rc.Actor, r.PathValue("id")); err != nil {
return err
}
return noContent(w)
}
func (a *API) listEnrollmentTokens(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
tokens, err := a.svc.ListEnrollmentTokens(r.Context())
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, listResponse[service.EnrollmentTokenDTO]{Items: tokens})
}
type enrollmentTokenRequest struct {
Role string `json:"role"`
Description string `json:"description"`
ValidHours *int `json:"valid_hours"`
}
func (a *API) createEnrollmentToken(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
var req enrollmentTokenRequest
if err := decode(w, r, &req); err != nil {
return err
}
out, err := a.svc.CreateEnrollmentToken(r.Context(), rc.Actor, service.TokenInput{
Role: req.Role, Description: req.Description, ValidHours: req.ValidHours,
})
if err != nil {
return err
}
return writeJSON(w, http.StatusCreated, out)
}
func (a *API) deleteEnrollmentToken(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
if err := a.svc.DeleteEnrollmentToken(r.Context(), rc.Actor, r.PathValue("id")); err != nil {
return err
}
return noContent(w)
}
func (a *API) listRevisions(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
q := r.URL.Query()
limit, err := intParam(q, "limit", 1, service.MaxRevisionPage)
if err != nil {
return err
}
before, err := intParam(q, "before", 1, 1<<62)
if err != nil {
return err
}
page, err := a.svc.ListRevisions(r.Context(), int(limit), before)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, page)
}
type revisionRequest struct {
Comment string `json:"comment"`
}
func (a *API) createRevision(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
var req revisionRequest
// The body is optional.
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
if err != nil {
return errBadRequest("body_too_large", "request body too large")
}
if len(bytes.TrimSpace(body)) > 0 {
r.Body = io.NopCloser(bytes.NewReader(body))
if err := decode(w, r, &req); err != nil {
return err
}
}
rev, created, err := a.svc.CreateRevision(r.Context(), rc.Actor, req.Comment)
if err != nil {
return err
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
return writeJSON(w, status, rev)
}
func (a *API) getRevision(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
n, err := strconv.ParseInt(r.PathValue("number"), 10, 64)
if err != nil {
return service.ErrNotFound
}
rev, err := a.svc.GetRevision(r.Context(), n)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, rev)
}
func (a *API) listDeployments(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
q := r.URL.Query()
f := service.DeploymentFilter{AgentID: q.Get("agent_id")}
var err error
if f.Revision, err = intParam(q, "revision", 1, store.MaxRevision); err != nil {
return err
}
limit, err := intParam(q, "limit", 1, service.MaxDeploymentPage)
if err != nil {
return err
}
f.Limit = int(limit)
if f.BeforeID, err = intParam(q, "before_id", 1, 1<<62); err != nil {
return err
}
page, err := a.svc.ListDeployments(r.Context(), f)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, page)
}
var leaseStates = map[string]bool{"active": true, "expired": true, "released": true, "declined": true}
func (a *API) listLeases(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
q := r.URL.Query()
f := service.LeaseFilter{NetworkID: q.Get("network_id"), State: q.Get("state"), Query: strings.TrimSpace(q.Get("q"))}
if f.State != "" && !leaseStates[f.State] {
return errBadRequest("invalid_parameter", "unknown lease state "+strconv.Quote(f.State))
}
if len(f.Query) > 200 {
return errBadRequest("invalid_parameter", "q is too long")
}
limit, err := intParam(q, "limit", 1, store.MaxLeasePage)
if err != nil {
return err
}
f.Limit = int(limit)
if v := q.Get("after"); v != "" {
ip, err := netip.ParseAddr(v)
if err != nil {
return errBadRequest("invalid_parameter", "after must be an IP address")
}
ip = ip.Unmap()
f.After = &ip
}
page, err := a.svc.ListLeases(r.Context(), f)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, page)
}
+12 -2
View File
@@ -22,8 +22,10 @@ import (
"github.com/getkin/kin-openapi/routers/legacy"
"git.beugsiud.de/beug.k/DDI/server/api"
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/httpserver"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
"git.beugsiud.de/beug.k/DDI/server/internal/pki"
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
@@ -121,6 +123,8 @@ type env struct {
st *store.Store
lic *license.Manager
now *time.Time
pki *pki.Authority
hub *agenthub.Hub
}
type stubProvider struct{}
@@ -173,8 +177,14 @@ func newEnvWith(t *testing.T, adjust func(*Options)) *env {
if err := lic.Load(context.Background()); err != nil {
t.Fatal(err)
}
authority, err := pki.LoadOrCreate(context.Background(), st, kr, log, nil)
if err != nil {
t.Fatal(err)
}
hub := agenthub.New()
svc := service.New(service.Options{Store: st, Keyring: kr, License: lic, ProductName: "DDI-Suite", Logger: log,
Now: func() time.Time { return now }})
Now: func() time.Time { return now }, PKI: authority, Hub: hub, AgentAPIAddress: "ddi.example.test:8444",
AgentServerNames: []string{"ddi.example.test"}})
opts := Options{Service: svc, ProductName: "DDI-Suite", Version: "test", Logger: log}
srv := httptest.NewUnstartedServer(nil)
if adjust != nil {
@@ -185,7 +195,7 @@ func newEnvWith(t *testing.T, adjust func(*Options)) *env {
srv.Config.Handler = httpserver.RequestID(contractHandler(t, a.Handler()))
srv.Start()
t.Cleanup(srv.Close)
return &env{t: t, srv: srv, svc: svc, st: st, lic: lic, now: &now}
return &env{t: t, srv: srv, svc: svc, st: st, lic: lic, now: &now, pki: authority, hub: hub}
}
// advance moves the shared clock (sessions, license).
+16
View File
@@ -59,6 +59,22 @@ func (a *API) Routes() []Route {
// Generated configuration (read-only preview until agents arrive).
perm(get, "/api/v1/config/preview", auth.PermIPAMRead, false, a.previewConfig),
// Agents and enrollment (ADR 0021).
perm(get, "/api/v1/agents", auth.PermIPAMRead, false, a.listAgents),
perm(get, "/api/v1/agents/{id}", auth.PermIPAMRead, false, a.getAgent),
perm(del, "/api/v1/agents/{id}", auth.PermSettingsAdm, true, a.deleteAgent),
perm(post, "/api/v1/agents/{id}/revoke", auth.PermSettingsAdm, true, a.revokeAgent),
perm(get, "/api/v1/enrollment-tokens", auth.PermSettingsAdm, false, a.listEnrollmentTokens),
perm(post, "/api/v1/enrollment-tokens", auth.PermSettingsAdm, true, a.createEnrollmentToken),
perm(del, "/api/v1/enrollment-tokens/{id}", auth.PermSettingsAdm, true, a.deleteEnrollmentToken),
// Revisions, deployments and leases.
perm(get, "/api/v1/revisions", auth.PermIPAMRead, false, a.listRevisions),
perm(post, "/api/v1/revisions", auth.PermDeploy, true, a.createRevision),
perm(get, "/api/v1/revisions/{number}", auth.PermIPAMRead, false, a.getRevision),
perm(get, "/api/v1/deployments", auth.PermIPAMRead, false, a.listDeployments),
perm(get, "/api/v1/leases", auth.PermIPAMRead, false, a.listLeases),
// Networks.
perm(get, "/api/v1/networks", auth.PermIPAMRead, false, a.listNetworks),
perm(post, "/api/v1/networks", auth.PermIPAMWrite, true, a.createNetwork),
+421
View File
@@ -0,0 +1,421 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
// Package pki is the server side of the agent PKI (ADR 0008, 0009, 0021):
// the agent CA, the bundle signing key, the agent-port server certificate
// and the signing of agent certificate requests.
package pki
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"fmt"
"log/slog"
"math/big"
"net"
"net/url"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
"git.beugsiud.de/beug.k/DDI/internal/brand"
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
// Lifetimes (ADR 0008/0021).
const (
CALifetime = 10 * 365 * 24 * time.Hour
ServerCertLifetime = 365 * 24 * time.Hour
AgentCertLifetime = 14 * 24 * time.Hour
// clockSkew backdates NotBefore so that hosts with a slightly late
// clock accept fresh certificates.
clockSkew = 5 * time.Minute
)
// Authority holds the CA and the bundle signing key.
type Authority struct {
caCert *x509.Certificate
caKey *ecdsa.PrivateKey
bundleKey ed25519.PrivateKey
now func() time.Time
}
// New creates an Authority from existing key material.
func New(caCert *x509.Certificate, caKey *ecdsa.PrivateKey, bundleKey ed25519.PrivateKey, now func() time.Time) (*Authority, error) {
if now == nil {
now = time.Now
}
if caCert == nil || caKey == nil || !caCert.IsCA {
return nil, errors.New("invalid CA")
}
pub, ok := caCert.PublicKey.(*ecdsa.PublicKey)
if !ok || !pub.Equal(caKey.Public()) {
return nil, errors.New("CA certificate does not match its key")
}
if len(bundleKey) != ed25519.PrivateKeySize {
return nil, errors.New("invalid bundle signing key")
}
return &Authority{caCert: caCert, caKey: caKey, bundleKey: bundleKey, now: now}, nil
}
// Generate creates a new CA and bundle signing key in memory.
func Generate(now func() time.Time) (*Authority, error) {
if now == nil {
now = time.Now
}
caCert, caKey, err := newCA(now())
if err != nil {
return nil, err
}
_, bundleKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
return New(caCert, caKey, bundleKey, now)
}
// RandomSerial returns a random positive 128-bit serial number.
func RandomSerial() (*big.Int, error) {
limit := new(big.Int).Lsh(big.NewInt(1), 128)
for {
n, err := rand.Int(rand.Reader, limit)
if err != nil {
return nil, err
}
if n.Sign() > 0 {
return n, nil
}
}
}
// SerialHex formats a serial number as stored in the database.
func SerialHex(n *big.Int) string { return n.Text(16) }
func newCA(now time.Time) (*x509.Certificate, *ecdsa.PrivateKey, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
serial, err := RandomSerial()
if err != nil {
return nil, nil, err
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: brand.DisplayName + " Agent CA"},
NotBefore: now.Add(-clockSkew),
NotAfter: now.Add(CALifetime),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLenZero: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
if err != nil {
return nil, nil, fmt.Errorf("create CA certificate: %w", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, nil, err
}
return cert, key, nil
}
// CACertificate returns the CA certificate.
func (a *Authority) CACertificate() *x509.Certificate { return a.caCert }
// Fingerprint returns "sha256:<hex>" of the CA certificate.
func (a *Authority) Fingerprint() string { return agentpki.Fingerprint(a.caCert) }
// CAPool returns a pool holding only the CA.
func (a *Authority) CAPool() *x509.CertPool {
p := x509.NewCertPool()
p.AddCert(a.caCert)
return p
}
// BundleSigningKey returns the Ed25519 key that signs bundles.
func (a *Authority) BundleSigningKey() ed25519.PrivateKey { return a.bundleKey }
// BundlePublicKey returns the public half of the bundle signing key.
func (a *Authority) BundlePublicKey() ed25519.PublicKey {
return a.bundleKey.Public().(ed25519.PublicKey)
}
// notAfter caps a lifetime at the CA's expiry.
func (a *Authority) notAfter(now time.Time, lifetime time.Duration) time.Time {
end := now.Add(lifetime)
if end.After(a.caCert.NotAfter) {
end = a.caCert.NotAfter
}
return end
}
// ServerCertificate issues a fresh server certificate for the agent port
// with a new key. The chain contains the leaf and the CA, so that agents can
// check the CA fingerprint during enrollment.
func (a *Authority) ServerCertificate(names []string) (tls.Certificate, error) {
if len(names) == 0 {
return tls.Certificate{}, errors.New("no server names")
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, err
}
serial, err := RandomSerial()
if err != nil {
return tls.Certificate{}, err
}
now := a.now()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: names[0]},
NotBefore: now.Add(-clockSkew),
NotAfter: a.notAfter(now, ServerCertLifetime),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
for _, n := range names {
if ip := net.ParseIP(strings.Trim(n, "[]")); ip != nil {
tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
} else {
tmpl.DNSNames = append(tmpl.DNSNames, n)
}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, a.caCert, key.Public(), a.caKey)
if err != nil {
return tls.Certificate{}, fmt.Errorf("create server certificate: %w", err)
}
leaf, err := x509.ParseCertificate(der)
if err != nil {
return tls.Certificate{}, err
}
return tls.Certificate{Certificate: [][]byte{der, a.caCert.Raw}, PrivateKey: key, Leaf: leaf}, nil
}
// ErrInvalidCSR is returned for unusable certificate requests.
var ErrInvalidCSR = errors.New("invalid certificate signing request")
// SignAgentCSR verifies a DER-encoded CSR and issues a client certificate
// for agentID. Only the CSR's public key is used; subject and extensions are
// set by the server. Accepted keys: ECDSA P-256 and Ed25519.
func (a *Authority) SignAgentCSR(csrDER []byte, agentID string) (*x509.Certificate, error) {
if !agentpki.ValidAgentID(agentID) {
return nil, errors.New("invalid agent ID")
}
csr, err := x509.ParseCertificateRequest(csrDER)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidCSR, err)
}
if err := csr.CheckSignature(); err != nil {
return nil, fmt.Errorf("%w: bad signature", ErrInvalidCSR)
}
if err := checkAgentKey(csr.PublicKey); err != nil {
return nil, err
}
serial, err := RandomSerial()
if err != nil {
return nil, err
}
now := a.now()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: agentID},
URIs: []*url.URL{agentpki.AgentURI(agentID)},
NotBefore: now.Add(-clockSkew),
NotAfter: a.notAfter(now, AgentCertLifetime),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, a.caCert, csr.PublicKey, a.caKey)
if err != nil {
return nil, fmt.Errorf("create agent certificate: %w", err)
}
return x509.ParseCertificate(der)
}
func checkAgentKey(pub crypto.PublicKey) error {
switch k := pub.(type) {
case *ecdsa.PublicKey:
if k.Curve != elliptic.P256() {
return fmt.Errorf("%w: only ECDSA P-256 and Ed25519 keys are accepted", ErrInvalidCSR)
}
return nil
case ed25519.PublicKey:
return nil
default:
return fmt.Errorf("%w: only ECDSA P-256 and Ed25519 keys are accepted", ErrInvalidCSR)
}
}
// VerifyMode selects how VerifyAgentCertificate treats expiry.
type VerifyMode int
// Verify modes.
const (
// VerifyCurrent requires a currently valid certificate.
VerifyCurrent VerifyMode = iota
// VerifyRenewal also accepts a certificate that expired at most
// RenewalGrace ago (RenewCertificate).
VerifyRenewal
)
// RenewalGrace is how long after expiry a certificate may still renew.
const RenewalGrace = 30 * 24 * time.Hour
// VerifyAgentCertificate checks that cert was issued by the CA for client
// authentication, is (still) valid and carries an agent ID, which it
// returns. Serial and revocation are checked by the caller against the
// database.
func (a *Authority) VerifyAgentCertificate(cert *x509.Certificate, mode VerifyMode) (string, error) {
now := a.now()
at := now
if mode == VerifyRenewal && now.After(cert.NotAfter) {
if now.Sub(cert.NotAfter) > RenewalGrace {
return "", errors.New("certificate expired more than 30 days ago")
}
// Check the chain at a time the certificate was valid.
at = cert.NotAfter
}
if _, err := cert.Verify(x509.VerifyOptions{
Roots: a.CAPool(), CurrentTime: at, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}); err != nil {
return "", fmt.Errorf("verify client certificate: %w", err)
}
return agentpki.AgentID(cert)
}
// Purpose-bound associated data for the sealed private keys.
func keyAAD(purpose string) []byte { return secrets.AAD("pki_keys", "private_key", purpose) }
// LoadOrCreate reads the CA and the bundle signing key from the database
// and creates them on first start. Concurrent first starts are safe: the
// first insert wins and every server reads back the stored key.
func LoadOrCreate(ctx context.Context, st *store.Store, kr *secrets.Keyring, log *slog.Logger, now func() time.Time) (*Authority, error) {
if now == nil {
now = time.Now
}
if log == nil {
log = slog.New(slog.DiscardHandler)
}
caCert, caKey, err := loadCA(ctx, st, kr, log, now)
if err != nil {
return nil, err
}
bundleKey, err := loadBundleKey(ctx, st, kr, log)
if err != nil {
return nil, err
}
return New(caCert, caKey, bundleKey, now)
}
func loadCA(ctx context.Context, st *store.Store, kr *secrets.Keyring, log *slog.Logger, now func() time.Time) (*x509.Certificate, *ecdsa.PrivateKey, error) {
k, err := st.Q().GetPKIKey(ctx, store.PKIAgentCA)
if errors.Is(err, store.ErrNotFound) {
if err := createCA(ctx, st, kr, log, now); err != nil {
return nil, nil, err
}
k, err = st.Q().GetPKIKey(ctx, store.PKIAgentCA)
}
if err != nil {
return nil, nil, fmt.Errorf("load agent CA: %w", err)
}
cert, err := x509.ParseCertificate(k.Certificate)
if err != nil {
return nil, nil, fmt.Errorf("parse agent CA certificate: %w", err)
}
der, err := kr.Open(k.PrivateKey, keyAAD(store.PKIAgentCA))
if err != nil {
return nil, nil, fmt.Errorf("decrypt agent CA key: %w", err)
}
parsed, err := x509.ParsePKCS8PrivateKey(der)
if err != nil {
return nil, nil, fmt.Errorf("parse agent CA key: %w", err)
}
key, ok := parsed.(*ecdsa.PrivateKey)
if !ok {
return nil, nil, errors.New("agent CA key is not an ECDSA key")
}
return cert, key, nil
}
func loadBundleKey(ctx context.Context, st *store.Store, kr *secrets.Keyring, log *slog.Logger) (ed25519.PrivateKey, error) {
k, err := st.Q().GetPKIKey(ctx, store.PKIBundleSigning)
if errors.Is(err, store.ErrNotFound) {
if err := createBundleKey(ctx, st, kr, log); err != nil {
return nil, err
}
k, err = st.Q().GetPKIKey(ctx, store.PKIBundleSigning)
}
if err != nil {
return nil, fmt.Errorf("load bundle signing key: %w", err)
}
seed, err := kr.Open(k.PrivateKey, keyAAD(store.PKIBundleSigning))
if err != nil {
return nil, fmt.Errorf("decrypt bundle signing key: %w", err)
}
if len(seed) != ed25519.SeedSize {
return nil, errors.New("bundle signing key has an invalid size")
}
key := ed25519.NewKeyFromSeed(seed)
if !key.Public().(ed25519.PublicKey).Equal(ed25519.PublicKey(k.PublicKey)) {
return nil, errors.New("bundle signing key does not match its public key")
}
return key, nil
}
// createCA generates the CA and stores it unless another server was faster.
func createCA(ctx context.Context, st *store.Store, kr *secrets.Keyring, log *slog.Logger, now func() time.Time) error {
cert, key, err := newCA(now())
if err != nil {
return err
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return err
}
sealed, err := kr.Seal(der, keyAAD(store.PKIAgentCA))
if err != nil {
return err
}
inserted, err := st.Q().InsertPKIKeyIfAbsent(ctx, &store.PKIKey{Purpose: store.PKIAgentCA, Certificate: cert.Raw,
PublicKey: cert.RawSubjectPublicKeyInfo, PrivateKey: sealed})
if err != nil {
return fmt.Errorf("store agent CA: %w", err)
}
if inserted {
log.Info("created agent CA", slog.String("fingerprint", agentpki.Fingerprint(cert)))
}
return nil
}
// createBundleKey generates the bundle signing key and stores it unless
// another server was faster.
func createBundleKey(ctx context.Context, st *store.Store, kr *secrets.Keyring, log *slog.Logger) error {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return err
}
sealed, err := kr.Seal(priv.Seed(), keyAAD(store.PKIBundleSigning))
if err != nil {
return err
}
inserted, err := st.Q().InsertPKIKeyIfAbsent(ctx, &store.PKIKey{Purpose: store.PKIBundleSigning, PublicKey: pub, PrivateKey: sealed})
if err != nil {
return fmt.Errorf("store bundle signing key: %w", err)
}
if inserted {
log.Info("created bundle signing key")
}
return nil
}
+219
View File
@@ -0,0 +1,219 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package pki_test
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"sync"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
"git.beugsiud.de/beug.k/DDI/server/internal/pki"
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
"git.beugsiud.de/beug.k/DDI/server/internal/store/storetest"
)
const agentID = "0f8fad5b-d9cb-469f-a165-70867728950e"
func csrFor(t *testing.T, key crypto.Signer) []byte {
t.Helper()
der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
Subject: pkix.Name{CommonName: "ignored"}, DNSNames: []string{"evil.example"},
}, key)
if err != nil {
t.Fatal(err)
}
return der
}
func TestCAAndServerCertificate(t *testing.T) {
now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
a, err := pki.Generate(func() time.Time { return now })
if err != nil {
t.Fatal(err)
}
ca := a.CACertificate()
if !ca.IsCA || ca.NotAfter.Sub(now) != pki.CALifetime || ca.PublicKeyAlgorithm != x509.ECDSA {
t.Errorf("CA %v %v %v", ca.IsCA, ca.NotAfter, ca.PublicKeyAlgorithm)
}
if fp, err := agentpki.ParseFingerprint(a.Fingerprint()); err != nil || fp != a.Fingerprint() {
t.Errorf("fingerprint %q: %v", a.Fingerprint(), err)
}
tc, err := a.ServerCertificate([]string{"ddi.example.test", "192.0.2.1", "[2001:db8::1]"})
if err != nil {
t.Fatal(err)
}
if len(tc.Certificate) != 2 {
t.Fatalf("chain length %d", len(tc.Certificate))
}
leaf := tc.Leaf
if leaf.NotAfter.Sub(now) != pki.ServerCertLifetime || len(leaf.DNSNames) != 1 || len(leaf.IPAddresses) != 2 {
t.Errorf("leaf %v %v %v", leaf.NotAfter, leaf.DNSNames, leaf.IPAddresses)
}
if _, err := leaf.Verify(x509.VerifyOptions{Roots: a.CAPool(), DNSName: "192.0.2.1", CurrentTime: now}); err != nil {
t.Errorf("verify IP SAN: %v", err)
}
if _, err := a.ServerCertificate(nil); err == nil {
t.Error("server certificate without names")
}
}
func TestServerChainWithRealClock(t *testing.T) {
a, err := pki.Generate(nil)
if err != nil {
t.Fatal(err)
}
tc, err := a.ServerCertificate([]string{"agents.example.test"})
if err != nil {
t.Fatal(err)
}
ca, err := agentpki.VerifyChainWithFingerprint(tc.Certificate, a.Fingerprint(), "agents.example.test")
if err != nil || !ca.Equal(a.CACertificate()) {
t.Fatalf("fingerprint verification: %v", err)
}
other, _ := pki.Generate(nil)
if _, err := agentpki.VerifyChainWithFingerprint(tc.Certificate, other.Fingerprint(), "agents.example.test"); err == nil {
t.Error("wrong fingerprint accepted")
}
}
func TestSignAgentCSR(t *testing.T) {
clock := time.Now()
a, err := pki.Generate(func() time.Time { return clock })
if err != nil {
t.Fatal(err)
}
ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
_, edKey, _ := ed25519.GenerateKey(rand.Reader)
seen := map[string]bool{}
for name, key := range map[string]crypto.Signer{"ecdsa": ecKey, "ed25519": edKey} {
cert, err := a.SignAgentCSR(csrFor(t, key), agentID)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
id, err := agentpki.AgentID(cert)
if err != nil || id != agentID {
t.Errorf("%s: agent ID %q %v", name, id, err)
}
if len(cert.DNSNames) != 0 || len(cert.ExtKeyUsage) != 1 || cert.ExtKeyUsage[0] != x509.ExtKeyUsageClientAuth || cert.IsCA {
t.Errorf("%s: unexpected certificate contents", name)
}
if cert.NotAfter.Sub(clock) != pki.AgentCertLifetime {
t.Errorf("%s: lifetime %v", name, cert.NotAfter.Sub(clock))
}
if cert.SerialNumber.BitLen() > 128 || seen[pki.SerialHex(cert.SerialNumber)] {
t.Errorf("%s: serial %v", name, cert.SerialNumber)
}
seen[pki.SerialHex(cert.SerialNumber)] = true
if got, err := a.VerifyAgentCertificate(cert, pki.VerifyCurrent); err != nil || got != agentID {
t.Errorf("%s: verify %q %v", name, got, err)
}
}
// Rejected requests.
rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
p384, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
for name, csr := range map[string][]byte{
"rsa": csrFor(t, rsaKey),
"p384": csrFor(t, p384),
"garbage": []byte("not a csr"),
} {
if _, err := a.SignAgentCSR(csr, agentID); !errors.Is(err, pki.ErrInvalidCSR) {
t.Errorf("%s: %v", name, err)
}
}
tampered := csrFor(t, ecKey)
tampered[len(tampered)-5] ^= 0xff
if _, err := a.SignAgentCSR(tampered, agentID); err == nil {
t.Error("tampered CSR accepted")
}
if _, err := a.SignAgentCSR(csrFor(t, ecKey), "not-a-uuid"); err == nil {
t.Error("invalid agent ID accepted")
}
}
func TestVerifyAgentCertificateExpiry(t *testing.T) {
clock := time.Now()
a, _ := pki.Generate(func() time.Time { return clock })
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
cert, err := a.SignAgentCSR(csrFor(t, key), agentID)
if err != nil {
t.Fatal(err)
}
clock = cert.NotAfter.Add(time.Hour)
if _, err := a.VerifyAgentCertificate(cert, pki.VerifyCurrent); err == nil {
t.Error("expired certificate accepted")
}
if _, err := a.VerifyAgentCertificate(cert, pki.VerifyRenewal); err != nil {
t.Errorf("renewal within grace: %v", err)
}
clock = cert.NotAfter.Add(pki.RenewalGrace + time.Hour)
if _, err := a.VerifyAgentCertificate(cert, pki.VerifyRenewal); err == nil {
t.Error("renewal after grace accepted")
}
// A certificate from another CA is rejected.
clock = time.Now()
other, _ := pki.Generate(nil)
foreign, _ := other.SignAgentCSR(csrFor(t, key), agentID)
if _, err := a.VerifyAgentCertificate(foreign, pki.VerifyCurrent); err == nil {
t.Error("foreign certificate accepted")
}
}
func TestLoadOrCreate(t *testing.T) {
st := storetest.New(t)
keyData, _ := secrets.GenerateKeyFile("k1")
kr, _ := secrets.ParseKeyFile(keyData)
ctx := context.Background()
// Concurrent first starts end up with the same keys.
const n = 4
auths := make([]*pki.Authority, n)
errs := make([]error, n)
var wg sync.WaitGroup
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
auths[i], errs[i] = pki.LoadOrCreate(ctx, st, kr, nil, nil)
}()
}
wg.Wait()
for i := range n {
if errs[i] != nil {
t.Fatalf("start %d: %v", i, errs[i])
}
if auths[i].Fingerprint() != auths[0].Fingerprint() || !auths[i].BundlePublicKey().Equal(auths[0].BundlePublicKey()) {
t.Errorf("start %d created different keys", i)
}
}
again, err := pki.LoadOrCreate(ctx, st, kr, nil, nil)
if err != nil || again.Fingerprint() != auths[0].Fingerprint() {
t.Fatalf("reload: %v", err)
}
msg := []byte("bundle")
if !ed25519.Verify(auths[0].BundlePublicKey(), msg, ed25519.Sign(again.BundleSigningKey(), msg)) {
t.Error("reloaded bundle key differs")
}
// Another master key cannot open the keys.
otherData, _ := secrets.GenerateKeyFile("k2")
otherKR, _ := secrets.ParseKeyFile(otherData)
if _, err := pki.LoadOrCreate(ctx, st, otherKR, nil, nil); err == nil {
t.Error("keys opened with a foreign master key")
}
}
+459
View File
@@ -0,0 +1,459 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package service
import (
"context"
"crypto/subtle"
"crypto/x509"
"encoding/json"
"errors"
"math"
"net/netip"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/pki"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
// The methods in this file serve the agent API (data plane). They never
// depend on the license: agents keep working in read-only mode.
// Errors of the agent API.
var (
ErrInvalidToken = newError(KindUnauthorized, "invalid_token", "invalid, used or expired enrollment token")
ErrTokenRoleMismatch = newError(KindForbidden, "token_role_mismatch", "the enrollment token is for another role")
ErrInvalidCSR = newError(KindInvalid, "invalid_csr", "invalid certificate signing request")
ErrAgentRevoked = newError(KindForbidden, "agent_revoked", "the agent is revoked or unknown")
ErrNotDHCPAgent = newError(KindForbidden, "not_dhcp_agent", "only DHCP agents report leases")
)
// Limits for values reported by agents.
const (
maxHostnameLen = 253
maxVersionLen = 100
maxMessageLen = 64 << 10
)
// Agent intervals sent in Welcome/GetDesiredState (ADR 0021).
const (
PullInterval = 60 * time.Second
HeartbeatInterval = 30 * time.Second
)
// EnrollInput is an enrollment request of an agent.
type EnrollInput struct {
Token string
CSR []byte
Role string
Hostname string
AgentVersion string
RemoteIP *netip.Addr
RequestID string
}
// EnrollResult is the outcome of a successful enrollment.
type EnrollResult struct {
AgentID string
Certificate *x509.Certificate
}
// EnrollAgent checks the one-time token, signs the CSR and registers the
// agent. Attempts are rate limited per source IP.
func (s *Service) EnrollAgent(ctx context.Context, in EnrollInput) (*EnrollResult, error) {
if s.opts.PKI == nil {
return nil, ErrAgentAPIUnavailable
}
ipKey := "unknown"
if in.RemoteIP != nil {
ipKey = in.RemoteIP.String()
}
if ok, wait := s.enrollLimiter.Allow(ipKey); !ok {
e := newError(KindRateLimited, "rate_limited", "too many enrollment attempts")
e.RetryAfter = int(math.Ceil(wait.Seconds()))
return nil, e
}
if !validRole(in.Role) {
return nil, invalidField("invalid_role", "role")
}
id, secret, ok := strings.Cut(in.Token, ".")
if !ok || !tokenIDPattern.MatchString(id) || secret == "" || len(secret) > 128 {
return nil, ErrInvalidToken
}
hash := hashTokenSecret(secret)
now := s.now()
var res EnrollResult
err := s.inTx(ctx, func(tx *store.Tx) error {
tk, err := tx.GetEnrollmentToken(ctx, id, true)
if errors.Is(err, store.ErrNotFound) {
return ErrInvalidToken
}
if err != nil {
return err
}
if subtle.ConstantTimeCompare(tk.SecretHash, hash) != 1 || tk.UsedAt != nil || !now.Before(tk.ExpiresAt) {
return ErrInvalidToken
}
if tk.Role != in.Role {
return ErrTokenRoleMismatch
}
agentID := agenthub.NewID()
cert, err := s.opts.PKI.SignAgentCSR(in.CSR, agentID)
if errors.Is(err, pki.ErrInvalidCSR) {
e := *ErrInvalidCSR
e.err = err
return &e
}
if err != nil {
return err
}
a := &store.Agent{ID: agentID, Role: in.Role, Hostname: cleanText(in.Hostname, maxHostnameLen),
AgentVersion: cleanText(in.AgentVersion, maxVersionLen), RemoteAddress: in.RemoteIP,
CertSerial: pki.SerialHex(cert.SerialNumber), CertNotAfter: cert.NotAfter}
if err := tx.InsertAgent(ctx, a); err != nil {
return err
}
if err := tx.MarkEnrollmentTokenUsed(ctx, id, agentID, now); err != nil {
if errors.Is(err, store.ErrNotFound) {
return ErrInvalidToken
}
return err
}
actor := &Actor{Username: "enrollment-token:" + id, IP: in.RemoteIP, RequestID: in.RequestID}
res = EnrollResult{AgentID: agentID, Certificate: cert}
return s.audit(ctx, tx, actor, "agent.enroll", "agent", agentID, a.Hostname, nil, s.agentDTO(a, 0, nil))
})
if err != nil {
return nil, err
}
return &res, nil
}
// AgentIdentity is what the agent API checks on every call.
type AgentIdentity struct {
ID string
Role string
Mode string
CertSerial string
Revoked bool
}
// AgentIdentity loads the identity of an agent.
func (s *Service) AgentIdentity(ctx context.Context, id string) (*AgentIdentity, error) {
if !ValidUUID(id) {
return nil, ErrNotFound
}
a, err := s.st.Q().GetAgent(ctx, id, false)
if err != nil {
return nil, translate(err)
}
return &AgentIdentity{ID: a.ID, Role: a.Role, Mode: a.Mode, CertSerial: a.CertSerial, Revoked: a.RevokedAt != nil}, nil
}
// RenewAgentCertificate signs a new CSR for an agent and makes the new
// certificate the only valid one.
func (s *Service) RenewAgentCertificate(ctx context.Context, agentID string, csr []byte) (*x509.Certificate, error) {
if s.opts.PKI == nil {
return nil, ErrAgentAPIUnavailable
}
var cert *x509.Certificate
err := s.inTx(ctx, func(tx *store.Tx) error {
a, err := tx.GetAgent(ctx, agentID, true)
if errors.Is(err, store.ErrNotFound) {
return ErrAgentRevoked
}
if err != nil {
return err
}
if a.RevokedAt != nil {
return ErrAgentRevoked
}
cert, err = s.opts.PKI.SignAgentCSR(csr, agentID)
if errors.Is(err, pki.ErrInvalidCSR) {
e := *ErrInvalidCSR
e.err = err
return &e
}
if err != nil {
return err
}
return tx.UpdateAgentCertificate(ctx, agentID, pki.SerialHex(cert.SerialNumber), cert.NotAfter)
})
if err != nil {
return nil, err
}
return cert, nil
}
// AgentSettings tell an agent what to do.
type AgentSettings struct {
Mode string
DesiredRevision uint64
}
// HelloInput is the status an agent reports when it opens its stream.
type HelloInput struct {
Hostname string
AgentVersion string
ServiceVersion string
AppliedRevision uint64
LastGoodRevision uint64
Health *AgentHealth
RemoteIP *netip.Addr
}
func revisionPtr(n uint64) *int64 {
v := int64(min(n, store.MaxRevision)) //nolint:gosec // capped
return &v
}
func healthJSON(h *AgentHealth) json.RawMessage {
if h == nil {
return nil
}
c := *h
c.Message = cleanText(c.Message, 1000)
if c.Checks == nil {
c.Checks = []HealthCheck{}
}
for i := range c.Checks {
c.Checks[i].Name = cleanText(c.Checks[i].Name, 100)
c.Checks[i].Message = cleanText(c.Checks[i].Message, 1000)
}
raw, err := json.Marshal(c)
if err != nil {
return nil
}
return raw
}
// settings returns the agent's mode and desired revision.
func (s *Service) settings(ctx context.Context, tx *store.Tx, a *store.Agent) (*AgentSettings, error) {
desired, err := latestRevisionNumber(ctx, tx)
if err != nil {
return nil, translate(err)
}
return &AgentSettings{Mode: a.Mode, DesiredRevision: uint64(desired)}, nil //nolint:gosec // non-negative
}
func agentStatusErr(err error) error {
if errors.Is(err, store.ErrNotFound) {
return ErrAgentRevoked
}
return translate(err)
}
// AgentHello records the status sent with Hello and returns the settings
// for Welcome.
func (s *Service) AgentHello(ctx context.Context, agentID string, in HelloInput) (*AgentSettings, error) {
host := cleanText(in.Hostname, maxHostnameLen)
av := cleanText(in.AgentVersion, maxVersionLen)
sv := cleanText(in.ServiceVersion, maxVersionLen)
st := store.AgentStatus{AgentVersion: &av, ServiceVersion: &sv, AppliedRevision: revisionPtr(in.AppliedRevision),
LastGoodRevision: revisionPtr(in.LastGoodRevision), Health: healthJSON(in.Health), RemoteAddress: in.RemoteIP,
SeenAt: s.now()}
if host != "" {
st.Hostname = &host
}
q := s.st.Q()
a, err := q.UpdateAgentStatus(ctx, agentID, st)
if err != nil {
return nil, agentStatusErr(err)
}
return s.settings(ctx, q, a)
}
// HeartbeatInput is a periodic status report.
type HeartbeatInput struct {
AppliedRevision uint64
LastGoodRevision uint64
Health *AgentHealth
}
// AgentHeartbeat records a heartbeat.
func (s *Service) AgentHeartbeat(ctx context.Context, agentID string, in HeartbeatInput) error {
_, err := s.st.Q().UpdateAgentStatus(ctx, agentID, store.AgentStatus{AppliedRevision: revisionPtr(in.AppliedRevision),
LastGoodRevision: revisionPtr(in.LastGoodRevision), Health: healthJSON(in.Health), SeenAt: s.now()})
if err != nil {
return agentStatusErr(err)
}
return nil
}
// AgentDesiredState records the contact and returns the agent's settings
// (polling fallback).
func (s *Service) AgentDesiredState(ctx context.Context, agentID string, remote *netip.Addr) (*AgentSettings, error) {
q := s.st.Q()
a, err := q.UpdateAgentStatus(ctx, agentID, store.AgentStatus{RemoteAddress: remote, SeenAt: s.now()})
if err != nil {
return nil, agentStatusErr(err)
}
return s.settings(ctx, q, a)
}
// RevisionArtifacts returns the files of a revision for one role and the
// revision time (the bundle's created_at).
func (s *Service) RevisionArtifacts(ctx context.Context, number uint64, role string) (time.Time, []bundle.Artifact, error) {
if number < 1 || number > store.MaxRevision {
return time.Time{}, nil, ErrNotFound
}
q := s.st.Q()
r, err := q.GetRevision(ctx, int64(number))
if err != nil {
return time.Time{}, nil, translate(err)
}
files, err := q.RevisionFiles(ctx, int64(number), role)
if err != nil {
return time.Time{}, nil, translate(err)
}
arts := make([]bundle.Artifact, len(files))
for i, f := range files {
arts[i] = bundle.Artifact{Path: f.Path, Mode: f.Mode, Content: f.Content, CreateOnly: f.CreateOnly}
}
return r.CreatedAt, arts, nil
}
// DeploymentReport is the outcome of applying a revision on an agent.
type DeploymentReport struct {
CorrelationID string
Revision uint64
Trigger string // pull, push
Outcome string // succeeded, rejected, rolled_back, failed
Message string
ActiveRevision uint64
StartedAt time.Time
FinishedAt time.Time
}
var (
validTriggers = map[string]bool{"pull": true, "push": true}
validOutcomes = map[string]bool{"succeeded": true, "rejected": true, "rolled_back": true, "failed": true}
)
// RecordDeployment stores a deployment report and updates the agent's
// applied and last good revision.
func (s *Service) RecordDeployment(ctx context.Context, agentID string, in DeploymentReport) error {
if in.Revision < 1 || in.Revision > store.MaxRevision || in.ActiveRevision > store.MaxRevision {
return invalidField("invalid_revision", "revision")
}
if !validTriggers[in.Trigger] || !validOutcomes[in.Outcome] {
return invalidField("invalid_value", "outcome")
}
now := s.now()
if in.FinishedAt.IsZero() {
in.FinishedAt = now
}
if in.StartedAt.IsZero() || in.StartedAt.After(in.FinishedAt) {
in.StartedAt = in.FinishedAt
}
st := store.AgentStatus{AppliedRevision: revisionPtr(in.ActiveRevision), SeenAt: now}
if in.Outcome == "succeeded" {
st.LastGoodRevision = revisionPtr(in.Revision)
}
return s.inTx(ctx, func(tx *store.Tx) error {
d := &store.Deployment{
AgentID: agentID, CorrelationID: cleanText(in.CorrelationID, 100), Trigger: in.Trigger, Outcome: in.Outcome,
Message: cleanMessage(in.Message), StartedAt: in.StartedAt, FinishedAt: in.FinishedAt,
Revision: int64(in.Revision), //nolint:gosec // at most MaxRevision (checked above)
ActiveRevision: int64(in.ActiveRevision), //nolint:gosec // at most MaxRevision (checked above)
}
inserted, err := tx.InsertDeployment(ctx, d)
if err != nil || !inserted {
// A resent report (same agent, revision and start) changes
// nothing; it must not roll back the agent's state either.
return err
}
if _, err := tx.UpdateAgentStatus(ctx, agentID, st); err != nil {
return agentStatusErr(err)
}
return nil
})
}
// cleanMessage keeps line breaks (checker output) but drops other control
// characters and bounds the size.
func cleanMessage(s string) string {
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' {
return r
}
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, strings.ToValidUTF8(s, "?"))
if len(s) > maxMessageLen {
s = strings.ToValidUTF8(s[:maxMessageLen], "")
}
return s
}
// LeaseInput is one lease of a batch.
type LeaseInput struct {
IP string
MAC string
Hostname string
State string // active, expired, released, declined
ExpiresAt *time.Time
UpdatedAt *time.Time
SubnetID uint32
ClientID string
}
var validLeaseStates = map[string]bool{"active": true, "expired": true, "released": true, "declined": true}
// LeaseBatchResult says what happened to a batch.
type LeaseBatchResult struct {
Acknowledged uint64
// Duplicate is true if the batch was dropped as already processed.
Duplicate bool
// Skipped counts invalid leases that were ignored.
Skipped int
}
// StoreLeaseBatch stores a lease batch of a DHCP agent. Batches with a
// sequence not above the last processed one are dropped; a full sync
// replaces the agent's lease set.
func (s *Service) StoreLeaseBatch(ctx context.Context, agentID string, seq uint64, fullSync bool, in []LeaseInput) (*LeaseBatchResult, error) {
if seq == 0 || seq > math.MaxInt64 {
return nil, invalidField("invalid_sequence", "sequence")
}
res := &LeaseBatchResult{}
leases := make([]store.Lease, 0, len(in))
seen := map[netip.Addr]bool{}
for _, l := range in {
ip, err := netip.ParseAddr(strings.TrimSpace(l.IP))
if err != nil || ip.Zone() != "" || !validLeaseStates[l.State] || seen[ip.Unmap()] {
res.Skipped++
continue
}
ip = ip.Unmap()
seen[ip] = true
leases = append(leases, store.Lease{IP: ip, MAC: strings.ToLower(cleanText(l.MAC, 128)),
Hostname: cleanText(l.Hostname, maxHostnameLen), State: l.State, ExpiresAt: l.ExpiresAt, UpdatedAt: l.UpdatedAt,
SubnetID: l.SubnetID, ClientID: cleanText(l.ClientID, 255)})
}
err := s.inTx(ctx, func(tx *store.Tx) error {
stored, role, err := tx.LockLeaseSequence(ctx, agentID)
if err != nil {
return agentStatusErr(err)
}
if role != RoleDHCP {
return ErrNotDHCPAgent
}
if int64(seq) <= stored { //nolint:gosec // checked above
res.Duplicate = true
res.Acknowledged = uint64(stored) //nolint:gosec // non-negative by constraint
return nil
}
res.Acknowledged = seq
return tx.StoreLeases(ctx, agentID, int64(seq), fullSync, leases, s.now()) //nolint:gosec // checked above
})
if err != nil {
return nil, err
}
return res, nil
}
+453
View File
@@ -0,0 +1,453 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net"
"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 result reported by an agent.
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"`
}
func deploymentDTO(d *store.Deployment) DeploymentDTO {
return 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}
}
// 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"`
Health AgentHealth `json:"health"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
LastDeployment *DeploymentDTO `json:"last_deployment,omitempty"`
}
func (s *Service) agentDTO(a *store.Agent, desired 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: desired,
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
}
if a.RevokedAt != nil {
d.DesiredRevision = 0
}
return d
}
// latestRevisionNumber returns the newest revision number, 0 if none. In
// M3 it is the desired revision of every agent (ADR 0021).
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)
}
out := make([]AgentDTO, 0, len(agents))
for _, a := range agents {
out = append(out, s.agentDTO(a, desired, last[a.ID]))
}
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)
}
return s.agentDTO(a, desired, last), nil
}
// RevokeAgent revokes the agent's certificate and closes its stream.
func (s *Service) RevokeAgent(ctx context.Context, a *Actor, id string) (AgentDTO, error) {
if err := s.requireWritable(); err != nil {
return AgentDTO{}, err
}
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
}
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 err := s.requireWritable(); err != nil {
return err
}
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)
}
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 err := s.requireWritable(); err != nil {
return err
}
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
}
+348
View File
@@ -0,0 +1,348 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/netip"
"strconv"
"strings"
"time"
"unicode/utf8"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
// Page sizes.
const (
defaultRevisionPage = 50
MaxRevisionPage = 500
defaultDeploymentPage = 50
MaxDeploymentPage = 500
defaultLeasePage = 100
)
// ErrRevisionLimit is returned when the revision numbers are exhausted.
var ErrRevisionLimit = newError(KindConflict, "revision_limit", "the highest revision number has been reached")
// RevisionSummaryDTO is a revision without files.
type RevisionSummaryDTO struct {
Number int64 `json:"number"`
CreatedAt time.Time `json:"created_at"`
CreatedByName string `json:"created_by_name"`
Comment string `json:"comment"`
DNSFiles int `json:"dns_files"`
DHCPFiles int `json:"dhcp_files"`
Warnings int `json:"warnings"`
}
// RevisionDTO is a revision with its files.
type RevisionDTO struct {
Number int64 `json:"number"`
CreatedAt time.Time `json:"created_at"`
CreatedByName string `json:"created_by_name"`
Comment string `json:"comment"`
DNS []GeneratedFile `json:"dns"`
DHCP []GeneratedFile `json:"dhcp"`
Warnings []configgen.Warning `json:"warnings"`
}
// RevisionPage is a page of revisions, newest first.
type RevisionPage struct {
Items []RevisionSummaryDTO `json:"items"`
NextBefore *int64 `json:"next_before"`
}
func revisionDTO(r *store.Revision, files []store.RevisionFile) RevisionDTO {
d := RevisionDTO{Number: r.Number, CreatedAt: r.CreatedAt, CreatedByName: r.CreatedByName, Comment: r.Comment,
DNS: []GeneratedFile{}, DHCP: []GeneratedFile{}, Warnings: []configgen.Warning{}}
if len(r.Warnings) > 0 {
_ = json.Unmarshal(r.Warnings, &d.Warnings) // written by us; a broken value shows no warnings
if d.Warnings == nil {
d.Warnings = []configgen.Warning{}
}
}
for _, f := range files {
g := GeneratedFile{Path: f.Path, Content: string(f.Content), CreateOnly: f.CreateOnly}
if f.Role == RoleDHCP {
d.DHCP = append(d.DHCP, g)
} else {
d.DNS = append(d.DNS, g)
}
}
return d
}
// revisionFiles flattens a generator run into stored files (DNS first, as
// the database returns them).
func revisionFiles(g *Generated) []store.RevisionFile {
out := make([]store.RevisionFile, 0, len(g.DNS)+len(g.DHCP))
for _, a := range g.DNS {
out = append(out, store.RevisionFile{Role: RoleDNS, Path: a.Path, Mode: a.Mode, CreateOnly: a.CreateOnly, Content: a.Content})
}
for _, a := range g.DHCP {
out = append(out, store.RevisionFile{Role: RoleDHCP, Path: a.Path, Mode: a.Mode, CreateOnly: a.CreateOnly, Content: a.Content})
}
return out
}
func sameFiles(a, b []store.RevisionFile) bool {
if len(a) != len(b) {
return false
}
for i := range a {
x, y := a[i], b[i]
if x.Role != y.Role || x.Path != y.Path || x.Mode != y.Mode || x.CreateOnly != y.CreateOnly || !bytes.Equal(x.Content, y.Content) {
return false
}
}
return true
}
// generationProblem reports the first part that could not be generated.
func generationProblem(g *Generated) error {
for _, p := range []struct {
part string
err *GeneratorProblem
}{{RoleDNS, g.DNSError}, {RoleDHCP, g.DHCPError}} {
if p.err == nil {
continue
}
params := map[string]string{"part": p.part}
for k, v := range p.err.Params {
params[k] = v
}
return &Error{Kind: KindInvalid, Code: p.err.Code, Message: "the " + p.part + " configuration cannot be generated",
Params: params}
}
return nil
}
// CreateRevision generates the configuration and stores it as a new
// revision (ADR 0021). If the output equals the latest revision, that one
// is returned with created false. Agents in auto_pull mode are notified.
func (s *Service) CreateRevision(ctx context.Context, a *Actor, comment string) (*RevisionDTO, bool, error) {
if err := s.requireWritable(); err != nil {
return nil, false, err
}
comment = strings.TrimSpace(comment)
if utf8.RuneCountInString(comment) > 500 {
return nil, false, invalidField("too_long", "comment")
}
var out RevisionDTO
created := false
err := s.inTx(ctx, func(tx *store.Tx) error {
if err := tx.LockRevisions(ctx); err != nil {
return err
}
latest, err := tx.LatestRevision(ctx)
if err != nil && !errors.Is(err, store.ErrNotFound) {
return err
}
next := int64(1)
if latest != nil {
// Generate with the latest number first: identical output
// means nothing changed since then.
g, err := s.generate(ctx, uint32(latest.Number)) //nolint:gosec // 1..2^32-1 by constraint
if err != nil {
return err
}
if err := generationProblem(g); err != nil {
return err
}
stored, err := tx.RevisionFiles(ctx, latest.Number, "")
if err != nil {
return err
}
if sameFiles(revisionFiles(g), stored) {
out = revisionDTO(latest, stored)
return nil
}
if latest.Number >= store.MaxRevision {
return ErrRevisionLimit
}
next = latest.Number + 1
}
g, err := s.generate(ctx, uint32(next)) //nolint:gosec // 1..2^32-1, checked above
if err != nil {
return err
}
if err := generationProblem(g); err != nil {
return err
}
warnings, err := json.Marshal(g.Warnings)
if err != nil {
return err
}
r := &store.Revision{Number: next, CreatedAt: s.now().UTC().Truncate(time.Microsecond), CreatedByName: a.Username,
Comment: comment, Warnings: warnings}
if a.UserID != "" {
uid := a.UserID
r.CreatedBy = &uid
}
files := revisionFiles(g)
if err := tx.InsertRevision(ctx, r, files); err != nil {
return err
}
out = revisionDTO(r, files)
created = true
summary := RevisionSummaryDTO{Number: r.Number, CreatedAt: r.CreatedAt, CreatedByName: r.CreatedByName,
Comment: r.Comment, DNSFiles: len(g.DNS), DHCPFiles: len(g.DHCP), Warnings: len(g.Warnings)}
return s.audit(ctx, tx, a, "revision.create", "revision", strconv.FormatInt(r.Number, 10),
"#"+strconv.FormatInt(r.Number, 10), nil, summary)
})
if err != nil {
return nil, false, err
}
if created && s.opts.Hub != nil {
n := s.opts.Hub.NotifyRevision(uint64(out.Number)) //nolint:gosec // positive
s.log.Info("revision created", "revision", out.Number, "notified_agents", n, "request_id", a.RequestID)
}
return &out, created, nil
}
// ListRevisions returns revisions newest first. before (0 = none) pages.
func (s *Service) ListRevisions(ctx context.Context, limit int, before int64) (*RevisionPage, error) {
if limit <= 0 || limit > MaxRevisionPage {
limit = defaultRevisionPage
}
list, err := s.st.Q().ListRevisions(ctx, limit+1, before)
if err != nil {
return nil, translate(err)
}
page := &RevisionPage{Items: []RevisionSummaryDTO{}}
for i, r := range list {
if i == limit {
n := list[limit-1].Number
page.NextBefore = &n
break
}
page.Items = append(page.Items, RevisionSummaryDTO{Number: r.Number, CreatedAt: r.CreatedAt,
CreatedByName: r.CreatedByName, Comment: r.Comment, DNSFiles: r.DNSFiles, DHCPFiles: r.DHCPFiles,
Warnings: r.WarningCount})
}
return page, nil
}
// GetRevision returns a revision with its files.
func (s *Service) GetRevision(ctx context.Context, number int64) (*RevisionDTO, error) {
if number < 1 || number > store.MaxRevision {
return nil, ErrNotFound
}
q := s.st.Q()
r, err := q.GetRevision(ctx, number)
if err != nil {
return nil, translate(err)
}
files, err := q.RevisionFiles(ctx, number, "")
if err != nil {
return nil, translate(err)
}
d := revisionDTO(r, files)
return &d, nil
}
// DeploymentPage is a page of deployments, newest first.
type DeploymentPage struct {
Items []DeploymentDTO `json:"items"`
NextBeforeID *int64 `json:"next_before_id"`
}
// DeploymentFilter selects deployments.
type DeploymentFilter struct {
AgentID string
Revision int64
BeforeID int64
Limit int
}
// ListDeployments returns deployment reports newest first.
func (s *Service) ListDeployments(ctx context.Context, f DeploymentFilter) (*DeploymentPage, error) {
page := &DeploymentPage{Items: []DeploymentDTO{}}
if f.AgentID != "" && !ValidUUID(f.AgentID) {
return page, nil // no such agent
}
limit := f.Limit
if limit <= 0 || limit > MaxDeploymentPage {
limit = defaultDeploymentPage
}
list, err := s.st.Q().ListDeployments(ctx, store.DeploymentFilter{AgentID: f.AgentID, Revision: f.Revision,
BeforeID: f.BeforeID, Limit: limit + 1})
if err != nil {
return nil, translate(err)
}
for i, d := range list {
if i == limit {
id := list[limit-1].ID
page.NextBeforeID = &id
break
}
page.Items = append(page.Items, deploymentDTO(d))
}
return page, nil
}
// LeaseDTO is a lease reported by a DHCP agent.
type LeaseDTO struct {
IP netip.Addr `json:"ip"`
MAC string `json:"mac"`
Hostname string `json:"hostname"`
State string `json:"state"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
SubnetID uint32 `json:"subnet_id"`
NetworkID string `json:"network_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
AgentID string `json:"agent_id"`
ReportedAt time.Time `json:"reported_at"`
}
// LeasePage is a page of leases sorted by IP.
type LeasePage struct {
Items []LeaseDTO `json:"items"`
NextAfter *string `json:"next_after"`
}
// LeaseFilter selects leases.
type LeaseFilter struct {
NetworkID string
State string
Query string
After *netip.Addr
Limit int
}
// ListLeases returns leases sorted by IP. Leases of one IP reported by
// several agents always share a page.
func (s *Service) ListLeases(ctx context.Context, f LeaseFilter) (*LeasePage, error) {
page := &LeasePage{Items: []LeaseDTO{}}
if f.NetworkID != "" && !ValidUUID(f.NetworkID) {
return page, nil // no such network
}
limit := f.Limit
if limit <= 0 || limit > store.MaxLeasePage {
limit = defaultLeasePage
}
list, more, err := s.st.Q().ListLeases(ctx, store.LeaseFilter{NetworkID: f.NetworkID, State: f.State, Query: f.Query,
After: f.After, Limit: limit})
if err != nil {
return nil, translate(err)
}
for _, l := range list {
d := LeaseDTO{IP: l.IP, MAC: l.MAC, Hostname: l.Hostname, State: l.State, ExpiresAt: l.ExpiresAt,
UpdatedAt: l.UpdatedAt, SubnetID: l.SubnetID, ClientID: l.ClientID, AgentID: l.AgentID, ReportedAt: l.ReportedAt}
if l.NetworkID != nil {
d.NetworkID = *l.NetworkID
}
page.Items = append(page.Items, d)
}
if more && len(list) > 0 {
next := list[len(list)-1].IP.String()
page.NextAfter = &next
}
return page, nil
}
+16
View File
@@ -9,8 +9,10 @@ import (
"net/netip"
"time"
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/auth"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
"git.beugsiud.de/beug.k/DDI/server/internal/pki"
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
@@ -40,6 +42,16 @@ type Options struct {
SessionIdle time.Duration
Now func() time.Time
Logger *slog.Logger
// Agent API (ADR 0021). Without PKI, enrollment tokens cannot be
// created and agents cannot enroll.
PKI *pki.Authority
// Hub tracks the agents' open streams (optional).
Hub *agenthub.Hub
// AgentAPIAddress is the host:port shown in the enrollment command.
AgentAPIAddress string
// AgentServerNames are the SANs of the agent-port certificate.
AgentServerNames []string
}
// Service implements the use cases.
@@ -51,6 +63,8 @@ type Service struct {
ipLimiter *auth.Limiter
userLimiter *auth.Limiter
// enrollLimiter bounds enrollment attempts per source IP.
enrollLimiter *auth.Limiter
}
// New creates a Service.
@@ -73,6 +87,8 @@ func New(opts Options) *Service {
// generous because several users may share an address.
ipLimiter: auth.NewLimiter(20, 15*time.Second),
userLimiter: auth.NewLimiter(5, time.Minute),
// Enrollment: 10 attempts, then one every 30 s per source IP.
enrollLimiter: auth.NewLimiter(10, 30*time.Second),
}
}
+293
View File
@@ -0,0 +1,293 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package store
import (
"context"
"encoding/json"
"net/netip"
"time"
"github.com/jackc/pgx/v5"
)
// PKI key purposes (table pki_keys).
const (
PKIAgentCA = "agent_ca"
PKIBundleSigning = "bundle_signing"
)
// PKIKey is a row of pki_keys. PrivateKey is sealed with the master key.
type PKIKey struct {
Purpose string
Certificate []byte
PublicKey []byte
PrivateKey []byte
CreatedAt time.Time
}
// GetPKIKey returns the key material for purpose.
func (t *Tx) GetPKIKey(ctx context.Context, purpose string) (*PKIKey, error) {
var k PKIKey
err := t.q.QueryRow(ctx, `SELECT purpose, certificate, public_key, private_key, created_at FROM pki_keys WHERE purpose = $1`,
purpose).Scan(&k.Purpose, &k.Certificate, &k.PublicKey, &k.PrivateKey, &k.CreatedAt)
if err != nil {
return nil, mapErr(err)
}
return &k, nil
}
// InsertPKIKeyIfAbsent stores k unless a key for its purpose exists already
// (concurrent first starts: the first insert wins, everyone reads it back).
func (t *Tx) InsertPKIKeyIfAbsent(ctx context.Context, k *PKIKey) (inserted bool, err error) {
tag, err := t.q.Exec(ctx, `INSERT INTO pki_keys (purpose, certificate, public_key, private_key) VALUES ($1, $2, $3, $4)
ON CONFLICT (purpose) DO NOTHING`, k.Purpose, k.Certificate, k.PublicKey, k.PrivateKey)
if err != nil {
return false, mapErr(err)
}
return tag.RowsAffected() == 1, nil
}
// Agent is a row of the agents table.
type Agent struct {
ID string
Role string
Hostname string
AgentVersion string
ServiceVersion string
Mode string
EnrolledAt time.Time
LastSeenAt *time.Time
RemoteAddress *netip.Addr
CertSerial string
CertNotAfter time.Time
AppliedRevision int64
LastGoodRevision int64
Health json.RawMessage
RevokedAt *time.Time
LeaseSequence int64
}
const agentColumns = `id, role, hostname, agent_version, service_version, mode, enrolled_at, last_seen_at,
remote_address, cert_serial, cert_not_after, applied_revision, last_good_revision, health, revoked_at, lease_sequence`
func scanAgent(row pgx.Row) (*Agent, error) {
var a Agent
var health []byte
if err := row.Scan(&a.ID, &a.Role, &a.Hostname, &a.AgentVersion, &a.ServiceVersion, &a.Mode, &a.EnrolledAt,
&a.LastSeenAt, &a.RemoteAddress, &a.CertSerial, &a.CertNotAfter, &a.AppliedRevision, &a.LastGoodRevision,
&health, &a.RevokedAt, &a.LeaseSequence); err != nil {
return nil, mapErr(err)
}
a.Health = health
if a.RemoteAddress != nil {
ip := a.RemoteAddress.Unmap()
a.RemoteAddress = &ip
}
return &a, nil
}
// InsertAgent creates an agent; the enrollment time is set.
func (t *Tx) InsertAgent(ctx context.Context, a *Agent) error {
row := t.q.QueryRow(ctx, `
INSERT INTO agents (id, role, hostname, agent_version, remote_address, cert_serial, cert_not_after, last_seen_at)
VALUES ($1, $2::agent_role, $3, $4, $5, $6, $7, now())
RETURNING `+agentColumns,
a.ID, a.Role, a.Hostname, a.AgentVersion, a.RemoteAddress, a.CertSerial, a.CertNotAfter)
got, err := scanAgent(row)
if err != nil {
return err
}
*a = *got
return nil
}
// GetAgent returns one agent.
func (t *Tx) GetAgent(ctx context.Context, id string, forUpdate bool) (*Agent, error) {
sql := `SELECT ` + agentColumns + ` FROM agents WHERE id = $1`
if forUpdate {
sql += ` FOR UPDATE`
}
return scanAgent(t.q.QueryRow(ctx, sql, id))
}
// ListAgents returns all agents ordered by role and hostname.
func (t *Tx) ListAgents(ctx context.Context) ([]*Agent, error) {
rows, err := t.q.Query(ctx, `SELECT `+agentColumns+` FROM agents ORDER BY role, lower(hostname), id`)
if err != nil {
return nil, mapErr(err)
}
defer rows.Close()
var out []*Agent
for rows.Next() {
a, err := scanAgent(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, mapErr(rows.Err())
}
// RevokeAgent marks the agent revoked (keeps an earlier revocation time).
func (t *Tx) RevokeAgent(ctx context.Context, id string, at time.Time) (*Agent, error) {
return scanAgent(t.q.QueryRow(ctx, `UPDATE agents SET revoked_at = coalesce(revoked_at, $2) WHERE id = $1 RETURNING `+agentColumns, id, at))
}
// DeleteAgent removes an agent with its deployments and leases.
func (t *Tx) DeleteAgent(ctx context.Context, id string) error {
tag, err := t.q.Exec(ctx, `DELETE FROM agents WHERE id = $1`, id)
if err != nil {
return mapErr(err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// UpdateAgentCertificate records the serial of a renewed certificate. Only
// non-revoked agents are updated.
func (t *Tx) UpdateAgentCertificate(ctx context.Context, id, serial string, notAfter time.Time) error {
tag, err := t.q.Exec(ctx, `UPDATE agents SET cert_serial = $2, cert_not_after = $3 WHERE id = $1 AND revoked_at IS NULL`,
id, serial, notAfter)
if err != nil {
return mapErr(err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AgentStatus is reported by an agent. Nil fields keep the stored value.
type AgentStatus struct {
Hostname *string
AgentVersion *string
ServiceVersion *string
AppliedRevision *int64
LastGoodRevision *int64
Health json.RawMessage
RemoteAddress *netip.Addr
SeenAt time.Time
}
// UpdateAgentStatus stores reported status and the last contact. It returns
// ErrNotFound for unknown and revoked agents.
func (t *Tx) UpdateAgentStatus(ctx context.Context, id string, s AgentStatus) (*Agent, error) {
return scanAgent(t.q.QueryRow(ctx, `
UPDATE agents SET
hostname = coalesce($2, hostname),
agent_version = coalesce($3, agent_version),
service_version = coalesce($4, service_version),
applied_revision = coalesce($5, applied_revision),
last_good_revision = coalesce($6, last_good_revision),
health = coalesce($7::jsonb, health),
remote_address = coalesce($8, remote_address),
last_seen_at = $9
WHERE id = $1 AND revoked_at IS NULL
RETURNING `+agentColumns,
id, s.Hostname, s.AgentVersion, s.ServiceVersion, s.AppliedRevision, s.LastGoodRevision,
nullJSON(s.Health), s.RemoteAddress, s.SeenAt))
}
// EnrollmentToken is a row of enrollment_tokens.
type EnrollmentToken struct {
ID string
SecretHash []byte
Role string
Description string
CreatedBy *string
CreatedByName string
CreatedAt time.Time
ExpiresAt time.Time
UsedAt *time.Time
AgentID *string
}
const tokenColumns = //nolint:gosec // a column list, not a credential
`id, secret_hash, role, description, created_by, created_by_name, created_at, expires_at, used_at, agent_id`
func scanToken(row pgx.Row) (*EnrollmentToken, error) {
var tk EnrollmentToken
if err := row.Scan(&tk.ID, &tk.SecretHash, &tk.Role, &tk.Description, &tk.CreatedBy, &tk.CreatedByName,
&tk.CreatedAt, &tk.ExpiresAt, &tk.UsedAt, &tk.AgentID); err != nil {
return nil, mapErr(err)
}
return &tk, nil
}
// InsertEnrollmentToken stores a new token (CreatedAt is taken from tk).
func (t *Tx) InsertEnrollmentToken(ctx context.Context, tk *EnrollmentToken) error {
_, err := t.q.Exec(ctx, `
INSERT INTO enrollment_tokens (id, secret_hash, role, description, created_by, created_by_name, created_at, expires_at)
VALUES ($1, $2, $3::agent_role, $4, $5, $6, $7, $8)`,
tk.ID, tk.SecretHash, tk.Role, tk.Description, tk.CreatedBy, tk.CreatedByName, tk.CreatedAt, tk.ExpiresAt)
return mapErr(err)
}
// GetEnrollmentToken returns one token.
func (t *Tx) GetEnrollmentToken(ctx context.Context, id string, forUpdate bool) (*EnrollmentToken, error) {
sql := `SELECT ` + tokenColumns + ` FROM enrollment_tokens WHERE id = $1`
if forUpdate {
sql += ` FOR UPDATE`
}
return scanToken(t.q.QueryRow(ctx, sql, id))
}
// ListEnrollmentTokens returns tokens that were used or expired after since
// or are still unused and valid, newest first.
func (t *Tx) ListEnrollmentTokens(ctx context.Context, since time.Time) ([]*EnrollmentToken, error) {
rows, err := t.q.Query(ctx, `SELECT `+tokenColumns+` FROM enrollment_tokens
WHERE (used_at IS NULL AND expires_at > $1) OR used_at > $1
ORDER BY created_at DESC, id`, since)
if err != nil {
return nil, mapErr(err)
}
defer rows.Close()
var out []*EnrollmentToken
for rows.Next() {
tk, err := scanToken(rows)
if err != nil {
return nil, err
}
out = append(out, tk)
}
return out, mapErr(rows.Err())
}
// DeleteEnrollmentToken removes a token.
func (t *Tx) DeleteEnrollmentToken(ctx context.Context, id string) error {
tag, err := t.q.Exec(ctx, `DELETE FROM enrollment_tokens WHERE id = $1`, id)
if err != nil {
return mapErr(err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// MarkEnrollmentTokenUsed records the enrollment. It fails with ErrNotFound
// if the token was used in the meantime.
func (t *Tx) MarkEnrollmentTokenUsed(ctx context.Context, id, agentID string, at time.Time) error {
tag, err := t.q.Exec(ctx, `UPDATE enrollment_tokens SET used_at = $3, agent_id = $2 WHERE id = $1 AND used_at IS NULL`,
id, agentID, at)
if err != nil {
return mapErr(err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// DeleteEnrollmentTokensBefore removes tokens that were used or expired
// before t0 (housekeeping).
func (t *Tx) DeleteEnrollmentTokensBefore(ctx context.Context, t0 time.Time) (int64, error) {
tag, err := t.q.Exec(ctx, `DELETE FROM enrollment_tokens WHERE coalesce(used_at, expires_at) < $1`, t0)
if err != nil {
return 0, mapErr(err)
}
return tag.RowsAffected(), nil
}
+292
View File
@@ -0,0 +1,292 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package store_test
import (
"context"
"encoding/json"
"errors"
"net/netip"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
"git.beugsiud.de/beug.k/DDI/server/internal/store/storetest"
)
const (
agentA = "00000000-0000-4000-8000-00000000000a"
agentB = "00000000-0000-4000-8000-00000000000b"
)
func newAgent(t *testing.T, st *store.Store, id, role, host, serial string) *store.Agent {
t.Helper()
ip := netip.MustParseAddr("192.0.2.7")
a := &store.Agent{ID: id, Role: role, Hostname: host, AgentVersion: "1.0", RemoteAddress: &ip, CertSerial: serial,
CertNotAfter: time.Now().Add(14 * 24 * time.Hour)}
if err := st.Q().InsertAgent(context.Background(), a); err != nil {
t.Fatalf("insert agent: %v", err)
}
return a
}
func TestPKIKeysInsertOnce(t *testing.T) {
st := storetest.New(t)
ctx := context.Background()
k := &store.PKIKey{Purpose: store.PKIBundleSigning, PublicKey: []byte("pub1"), PrivateKey: []byte("sealed1")}
if ok, err := st.Q().InsertPKIKeyIfAbsent(ctx, k); err != nil || !ok {
t.Fatalf("first insert: %v %v", ok, err)
}
k2 := &store.PKIKey{Purpose: store.PKIBundleSigning, PublicKey: []byte("pub2"), PrivateKey: []byte("sealed2")}
if ok, err := st.Q().InsertPKIKeyIfAbsent(ctx, k2); err != nil || ok {
t.Fatalf("second insert: %v %v", ok, err)
}
got, err := st.Q().GetPKIKey(ctx, store.PKIBundleSigning)
if err != nil || string(got.PublicKey) != "pub1" {
t.Fatalf("get: %+v %v", got, err)
}
if _, err := st.Q().InsertPKIKeyIfAbsent(ctx, &store.PKIKey{Purpose: store.PKIAgentCA, PublicKey: []byte("x"), PrivateKey: []byte("y")}); err == nil {
t.Error("CA without certificate accepted")
}
if _, err := st.Q().GetPKIKey(ctx, store.PKIAgentCA); !errors.Is(err, store.ErrNotFound) {
t.Errorf("missing key: %v", err)
}
}
func TestAgentsAndTokens(t *testing.T) {
st := storetest.New(t)
ctx := context.Background()
q := st.Q()
now := time.Now().UTC().Truncate(time.Microsecond)
tk := &store.EnrollmentToken{ID: "0123456789abcdef", SecretHash: make([]byte, 32), Role: "dns", CreatedByName: "admin",
CreatedAt: now, ExpiresAt: now.Add(time.Hour)}
if err := q.InsertEnrollmentToken(ctx, tk); err != nil {
t.Fatal(err)
}
bad := *tk
bad.ID = "XYZ"
if err := q.InsertEnrollmentToken(ctx, &bad); !store.IsConstraint(err, "enrollment_tokens_id_format") {
t.Errorf("bad token id: %v", err)
}
a := newAgent(t, st, agentA, "dns", "ns1", "0a")
if a.Mode != "auto_pull" || a.EnrolledAt.IsZero() || a.RemoteAddress == nil || a.RemoteAddress.String() != "192.0.2.7" {
t.Errorf("inserted agent %+v", a)
}
var h map[string]any
if err := json.Unmarshal(a.Health, &h); err != nil || h["state"] != "unknown" {
t.Errorf("default health %s", a.Health)
}
if err := st.Q().InsertAgent(ctx, &store.Agent{ID: agentB, Role: "dhcp", CertSerial: "0a", CertNotAfter: now}); !store.IsConstraint(err, "agents_cert_serial_unique") {
t.Errorf("duplicate serial: %v", err)
}
newAgent(t, st, agentB, "dhcp", "dhcp1", "0b")
if err := q.MarkEnrollmentTokenUsed(ctx, tk.ID, agentA, now); err != nil {
t.Fatal(err)
}
if err := q.MarkEnrollmentTokenUsed(ctx, tk.ID, agentB, now); !errors.Is(err, store.ErrNotFound) {
t.Errorf("second use: %v", err)
}
list, err := q.ListEnrollmentTokens(ctx, now.Add(-time.Minute))
if err != nil || len(list) != 1 || list[0].AgentID == nil || *list[0].AgentID != agentA {
t.Fatalf("tokens %+v %v", list, err)
}
if list, _ := q.ListEnrollmentTokens(ctx, now.Add(time.Minute)); len(list) != 0 {
t.Errorf("old used token listed: %+v", list)
}
host := "ns1.example.test"
applied := int64(3)
got, err := q.UpdateAgentStatus(ctx, agentA, store.AgentStatus{Hostname: &host, AppliedRevision: &applied,
Health: json.RawMessage(`{"state":"ok","message":"","checks":[]}`), SeenAt: now})
if err != nil || got.Hostname != host || got.AppliedRevision != 3 || got.AgentVersion != "1.0" || got.LastSeenAt == nil {
t.Fatalf("status update %+v %v", got, err)
}
if err := q.UpdateAgentCertificate(ctx, agentA, "ff", now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
if _, err := q.RevokeAgent(ctx, agentA, now); err != nil {
t.Fatal(err)
}
if _, err := q.UpdateAgentStatus(ctx, agentA, store.AgentStatus{SeenAt: now}); !errors.Is(err, store.ErrNotFound) {
t.Errorf("status of revoked agent: %v", err)
}
if err := q.UpdateAgentCertificate(ctx, agentA, "fe", now); !errors.Is(err, store.ErrNotFound) {
t.Errorf("renewal of revoked agent: %v", err)
}
agents, err := q.ListAgents(ctx)
if err != nil || len(agents) != 2 || agents[0].ID != agentA || agents[0].RevokedAt == nil || agents[0].CertSerial != "ff" {
t.Fatalf("agents %+v %v", agents, err)
}
if err := q.DeleteAgent(ctx, agentA); err != nil {
t.Fatal(err)
}
if got, _ := q.GetEnrollmentToken(ctx, tk.ID, false); got == nil || got.AgentID != nil || got.UsedAt == nil {
t.Errorf("token after agent deletion %+v", got)
}
if err := q.DeleteEnrollmentToken(ctx, tk.ID); err != nil {
t.Fatal(err)
}
if err := q.DeleteEnrollmentToken(ctx, tk.ID); !errors.Is(err, store.ErrNotFound) {
t.Errorf("second delete: %v", err)
}
}
func TestRevisionsAndDeployments(t *testing.T) {
st := storetest.New(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
if _, err := st.Q().LatestRevision(ctx); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("latest of none: %v", err)
}
files := []store.RevisionFile{
{Role: "dns", Path: "ddi-suite.conf", Mode: 0o644, Content: []byte("conf")},
{Role: "dns", Path: "zones/db.a.test", Mode: 0o644, CreateOnly: true, Content: []byte("zone")},
{Role: "dhcp", Path: "kea-dhcp4.conf", Mode: 0o640, Content: []byte("{}")},
}
err := st.InTx(ctx, func(tx *store.Tx) error {
if err := tx.LockRevisions(ctx); err != nil {
return err
}
return tx.InsertRevision(ctx, &store.Revision{Number: 1, CreatedAt: now, CreatedByName: "admin",
Warnings: json.RawMessage(`[{"code":"w"}]`)}, files)
})
if err != nil {
t.Fatal(err)
}
err = st.InTx(ctx, func(tx *store.Tx) error {
return tx.InsertRevision(ctx, &store.Revision{Number: 2, CreatedAt: now, CreatedByName: "admin"},
[]store.RevisionFile{{Role: "dns", Path: "../x", Content: []byte("x")}})
})
if !store.IsConstraint(err, "config_revision_files_path") {
t.Errorf("bad path: %v", err)
}
if err := st.Q().InsertRevision(ctx, &store.Revision{Number: 1 << 32, CreatedAt: now, CreatedByName: "admin"}, nil); !store.IsConstraint(err, "config_revisions_number") {
t.Errorf("number out of range: %v", err)
}
if _, err := st.Pool().Exec(ctx, `UPDATE config_revisions SET comment = 'x'`); err == nil {
t.Error("revision is mutable")
}
if _, err := st.Pool().Exec(ctx, `UPDATE config_revision_files SET content = 'x'`); err == nil {
t.Error("revision file is mutable")
}
got, err := st.Q().RevisionFiles(ctx, 1, "dns")
if err != nil || len(got) != 2 || got[1].Path != "zones/db.a.test" || !got[1].CreateOnly || got[0].Mode != 0o644 {
t.Fatalf("dns files %+v %v", got, err)
}
if all, _ := st.Q().RevisionFiles(ctx, 1, ""); len(all) != 3 || all[2].Role != "dhcp" {
t.Errorf("all files %+v", all)
}
revs, err := st.Q().ListRevisions(ctx, 10, 0)
if err != nil || len(revs) != 1 || revs[0].DNSFiles != 2 || revs[0].DHCPFiles != 1 || revs[0].WarningCount != 1 {
t.Fatalf("list %+v %v", revs, err)
}
if revs, _ := st.Q().ListRevisions(ctx, 10, 1); len(revs) != 0 {
t.Errorf("before=1: %+v", revs)
}
newAgent(t, st, agentA, "dns", "ns1", "01")
for i, outcome := range []string{"rejected", "succeeded"} {
start := now.Add(time.Duration(i) * time.Minute)
d := &store.Deployment{AgentID: agentA, Revision: 1, Trigger: "pull", Outcome: outcome, ActiveRevision: int64(i),
StartedAt: start, FinishedAt: start.Add(time.Second)}
if ok, err := st.Q().InsertDeployment(ctx, d); err != nil || !ok || d.ID == 0 {
t.Fatalf("insert deployment: %v %v", ok, err)
}
}
// A resent report is dropped without error.
dup := &store.Deployment{AgentID: agentA, Revision: 1, Trigger: "pull", Outcome: "rejected", StartedAt: now, FinishedAt: now.Add(time.Second)}
if ok, err := st.Q().InsertDeployment(ctx, dup); err != nil || ok {
t.Errorf("duplicate report: %v %v", ok, err)
}
bad := &store.Deployment{AgentID: agentA, Revision: 2, Trigger: "pull", Outcome: "succeeded", StartedAt: now, FinishedAt: now.Add(-time.Second)}
if _, err := st.Q().InsertDeployment(ctx, bad); !store.IsConstraint(err, "deployments_times") {
t.Errorf("finished before started: %v", err)
}
ds, err := st.Q().ListDeployments(ctx, store.DeploymentFilter{AgentID: agentA, Limit: 1})
if err != nil || len(ds) != 1 || ds[0].Outcome != "succeeded" || ds[0].AgentHostname != "ns1" {
t.Fatalf("deployments %+v %v", ds, err)
}
if older, _ := st.Q().ListDeployments(ctx, store.DeploymentFilter{BeforeID: ds[0].ID}); len(older) != 1 || older[0].Outcome != "rejected" {
t.Errorf("older %+v", older)
}
latest, err := st.Q().LatestDeployments(ctx)
if err != nil || latest[agentA] == nil || latest[agentA].ID != ds[0].ID {
t.Errorf("latest %+v %v", latest, err)
}
if err := st.Q().DeleteAgent(ctx, agentA); err != nil {
t.Fatal(err)
}
if ds, _ := st.Q().ListDeployments(ctx, store.DeploymentFilter{}); len(ds) != 0 {
t.Errorf("deployments survived agent deletion: %+v", ds)
}
}
func TestLeases(t *testing.T) {
st := storetest.New(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
newAgent(t, st, agentA, "dhcp", "dhcp1", "01")
newAgent(t, st, agentB, "dhcp", "dhcp2", "02")
var netID string
var subnet int32
if err := st.Pool().QueryRow(ctx, `INSERT INTO networks (cidr, name) VALUES ('192.0.2.0/24', 'Office') RETURNING id, kea_subnet_id`).Scan(&netID, &subnet); err != nil {
t.Fatal(err)
}
lease := func(ip, mac, host, state string) store.Lease {
return store.Lease{IP: netip.MustParseAddr(ip), MAC: mac, Hostname: host, State: state, ExpiresAt: &now, SubnetID: uint32(subnet)}
}
store1 := func(agent string, seq int64, replace bool, ls ...store.Lease) {
t.Helper()
err := st.InTx(ctx, func(tx *store.Tx) error {
got, role, err := tx.LockLeaseSequence(ctx, agent)
if err != nil {
return err
}
if role != "dhcp" || got >= seq {
t.Fatalf("sequence %d role %s", got, role)
}
return tx.StoreLeases(ctx, agent, seq, replace, ls, now)
})
if err != nil {
t.Fatal(err)
}
}
store1(agentA, 1, true, lease("192.0.2.10", "00:11:22:33:44:55", "pc1", "active"), lease("192.0.2.11", "00:11:22:33:44:56", "pc2", "active"))
store1(agentA, 2, false, lease("192.0.2.11", "00:11:22:33:44:56", "pc2", "released"), lease("192.0.2.12", "", "", "declined"))
store1(agentB, 1, true, lease("192.0.2.10", "00:11:22:33:44:55", "pc1", "active"))
all, more, err := st.Q().ListLeases(ctx, store.LeaseFilter{})
if err != nil || more || len(all) != 4 || all[0].NetworkID == nil || *all[0].NetworkID != netID {
t.Fatalf("all leases %+v %v %v", all, more, err)
}
// The page holds two IPs: both agents' leases of .10 and the one of .11.
page, more, err := st.Q().ListLeases(ctx, store.LeaseFilter{Limit: 2})
if err != nil || !more || len(page) != 3 || page[2].IP.String() != "192.0.2.11" || page[2].State != "released" {
t.Fatalf("page %+v %v %v", page, more, err)
}
after := page[2].IP
rest, more, _ := st.Q().ListLeases(ctx, store.LeaseFilter{Limit: 2, After: &after})
if more || len(rest) != 1 || rest[0].IP.String() != "192.0.2.12" {
t.Errorf("rest %+v %v", rest, more)
}
if ls, _, _ := st.Q().ListLeases(ctx, store.LeaseFilter{State: "active", Query: "pc1"}); len(ls) != 2 {
t.Errorf("filtered %+v", ls)
}
if ls, _, _ := st.Q().ListLeases(ctx, store.LeaseFilter{NetworkID: netID, Query: "44:56"}); len(ls) != 1 {
t.Errorf("network+mac %+v", ls)
}
if ls, _, _ := st.Q().ListLeases(ctx, store.LeaseFilter{NetworkID: "00000000-0000-4000-8000-000000000000"}); len(ls) != 0 {
t.Errorf("other network %+v", ls)
}
// A full sync replaces the agent's set.
store1(agentA, 3, true, lease("192.0.2.20", "00:11:22:33:44:57", "pc3", "active"))
if ls, _, _ := st.Q().ListLeases(ctx, store.LeaseFilter{}); len(ls) != 2 {
t.Errorf("after full sync %+v", ls)
}
}
+141
View File
@@ -0,0 +1,141 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package store
import (
"context"
"fmt"
"net/netip"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// MaxLeasePage bounds a page of ListLeases.
const MaxLeasePage = 1000
// Lease is a DHCP lease reported by an agent. NetworkID is resolved through
// the Kea subnet ID by ListLeases.
type Lease struct {
AgentID string
IP netip.Addr
MAC string
Hostname string
State string
ExpiresAt *time.Time
UpdatedAt *time.Time
SubnetID uint32
ClientID string
ReportedAt time.Time
NetworkID *string
}
// LockLeaseSequence locks the agent row and returns its lease sequence and
// role. Use it inside a transaction before StoreLeases.
func (t *Tx) LockLeaseSequence(ctx context.Context, agentID string) (seq int64, role string, err error) {
err = t.q.QueryRow(ctx, `SELECT lease_sequence, role FROM agents WHERE id = $1 AND revoked_at IS NULL FOR UPDATE`,
agentID).Scan(&seq, &role)
return seq, role, mapErr(err)
}
// StoreLeases writes a lease batch of an agent and records its sequence.
// With replace, the batch is the complete lease set of the agent.
func (t *Tx) StoreLeases(ctx context.Context, agentID string, seq int64, replace bool, leases []Lease, at time.Time) error {
if replace {
if _, err := t.q.Exec(ctx, `DELETE FROM dhcp_leases WHERE agent_id = $1`, agentID); err != nil {
return mapErr(err)
}
}
b := &pgx.Batch{}
for _, l := range leases {
b.Queue(`
INSERT INTO dhcp_leases (agent_id, ip, mac, hostname, state, expires_at, updated_at, subnet_id, client_id, reported_at)
VALUES ($1, $2, $3, $4, $5::lease_state, $6, $7, $8, $9, $10)
ON CONFLICT (agent_id, ip) DO UPDATE SET mac = EXCLUDED.mac, hostname = EXCLUDED.hostname,
state = EXCLUDED.state, expires_at = EXCLUDED.expires_at, updated_at = EXCLUDED.updated_at,
subnet_id = EXCLUDED.subnet_id, client_id = EXCLUDED.client_id, reported_at = EXCLUDED.reported_at`,
agentID, l.IP, l.MAC, l.Hostname, l.State, l.ExpiresAt, l.UpdatedAt, int64(l.SubnetID), l.ClientID, at)
}
if err := t.execBatch(ctx, b); err != nil {
return err
}
_, err := t.q.Exec(ctx, `UPDATE agents SET lease_sequence = $2 WHERE id = $1`, agentID, seq)
return mapErr(err)
}
// LeaseFilter selects leases. Zero values mean "no restriction".
type LeaseFilter struct {
NetworkID string
State string
Query string // substring of IP, MAC or hostname
After *netip.Addr
Limit int
}
// ListLeases returns leases ordered by IP and agent. Limit counts distinct
// IPs, so leases of the same IP from several agents are never split across
// pages; more reports whether further IPs follow.
func (t *Tx) ListLeases(ctx context.Context, f LeaseFilter) (out []*Lease, more bool, err error) {
var where []string
var args []any
add := func(cond string, v any) {
args = append(args, v)
where = append(where, fmt.Sprintf(cond, len(args)))
}
if f.NetworkID != "" {
add("n.id = $%d::uuid", f.NetworkID)
}
if f.State != "" {
add("l.state = $%d::lease_state", f.State)
}
if f.Query != "" {
args = append(args, "%"+escapeLike(f.Query)+"%")
n := len(args)
where = append(where, fmt.Sprintf("(host(l.ip) ILIKE $%[1]d OR l.mac ILIKE $%[1]d OR l.hostname ILIKE $%[1]d)", n))
}
if f.After != nil {
add("l.ip > $%d", *f.After)
}
limit := f.Limit
if limit <= 0 || limit > MaxLeasePage {
limit = 100
}
from := ` FROM dhcp_leases l LEFT JOIN networks n ON n.kea_subnet_id = l.subnet_id AND l.subnet_id > 0`
cond := ""
if len(where) > 0 {
cond = " WHERE " + strings.Join(where, " AND ")
}
// The page holds the first limit+1 distinct IPs; the extra one only
// tells whether there is a next page.
sql := `WITH page AS (SELECT DISTINCT l.ip` + from + cond + fmt.Sprintf(` ORDER BY l.ip LIMIT %d)`, limit+1) + `
SELECT l.agent_id, l.ip, l.mac, l.hostname, l.state, l.expires_at, l.updated_at, l.subnet_id, l.client_id,
l.reported_at, n.id` + from + ` JOIN page p ON p.ip = l.ip` + cond + ` ORDER BY l.ip, l.agent_id`
rows, err := t.q.Query(ctx, sql, args...)
if err != nil {
return nil, false, mapErr(err)
}
defer rows.Close()
ips := 0
var last netip.Addr
for rows.Next() {
var l Lease
var subnet int64
if err := rows.Scan(&l.AgentID, &l.IP, &l.MAC, &l.Hostname, &l.State, &l.ExpiresAt, &l.UpdatedAt, &subnet,
&l.ClientID, &l.ReportedAt, &l.NetworkID); err != nil {
return nil, false, mapErr(err)
}
l.IP = l.IP.Unmap()
l.SubnetID = uint32(subnet) //nolint:gosec // 0..2^32-1 by constraint
if ips == 0 || l.IP != last {
ips++
last = l.IP
}
if ips > limit {
more = true
continue
}
out = append(out, &l)
}
return out, more, mapErr(rows.Err())
}
@@ -0,0 +1,174 @@
-- SPDX-License-Identifier: LicenseRef-DDI-Proprietary
-- Agents (M3, ADR 0021): PKI keys, enrollment tokens, agents, immutable
-- configuration revisions, deployment reports and DHCP leases.
-- +goose Up
-- Key material of the agent PKI: the agent CA (ADR 0008) and the bundle
-- signing key (ADR 0009). Private keys are sealed with the master key; the
-- associated data binds them to their row (ADR 0015). Rows are created once
-- (INSERT ... ON CONFLICT DO NOTHING, so concurrent first starts agree).
CREATE TABLE pki_keys (
purpose text PRIMARY KEY,
certificate bytea,
public_key bytea NOT NULL,
private_key bytea NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pki_keys_purpose CHECK (purpose IN ('agent_ca', 'bundle_signing')),
CONSTRAINT pki_keys_ca_certificate CHECK (purpose <> 'agent_ca' OR certificate IS NOT NULL)
);
CREATE TYPE agent_role AS ENUM ('dns', 'dhcp');
CREATE TYPE agent_mode AS ENUM ('auto_pull', 'manual');
CREATE TABLE agents (
id uuid PRIMARY KEY,
role agent_role NOT NULL,
hostname text NOT NULL DEFAULT '',
agent_version text NOT NULL DEFAULT '',
service_version text NOT NULL DEFAULT '',
mode agent_mode NOT NULL DEFAULT 'auto_pull',
enrolled_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz,
remote_address inet,
-- Serial number (lowercase hex) of the only certificate the agent may use.
cert_serial text NOT NULL,
cert_not_after timestamptz NOT NULL,
applied_revision bigint NOT NULL DEFAULT 0,
last_good_revision bigint NOT NULL DEFAULT 0,
health jsonb NOT NULL DEFAULT '{"state": "unknown", "message": "", "checks": []}',
revoked_at timestamptz,
-- Highest lease batch sequence processed (ReportLeases).
lease_sequence bigint NOT NULL DEFAULT 0,
CONSTRAINT agents_cert_serial CHECK (cert_serial ~ '^[0-9a-f]{1,40}$'),
CONSTRAINT agents_revisions CHECK (applied_revision BETWEEN 0 AND 4294967295
AND last_good_revision BETWEEN 0 AND 4294967295),
CONSTRAINT agents_lease_sequence CHECK (lease_sequence >= 0),
CONSTRAINT agents_health_object CHECK (jsonb_typeof(health) = 'object')
);
CREATE UNIQUE INDEX agents_cert_serial_unique ON agents (cert_serial);
CREATE TABLE enrollment_tokens (
id text PRIMARY KEY,
-- SHA-256 of the secret part; the secret has 256 bits of entropy.
secret_hash bytea NOT NULL,
role agent_role NOT NULL,
description text NOT NULL DEFAULT '',
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
created_by_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
used_at timestamptz,
agent_id uuid REFERENCES agents (id) ON DELETE SET NULL,
CONSTRAINT enrollment_tokens_id_format CHECK (id ~ '^[0-9a-f]{16}$'),
CONSTRAINT enrollment_tokens_secret_hash CHECK (length(secret_hash) = 32),
CONSTRAINT enrollment_tokens_expiry CHECK (expires_at > created_at),
CONSTRAINT enrollment_tokens_agent_used CHECK (agent_id IS NULL OR used_at IS NOT NULL)
);
CREATE INDEX enrollment_tokens_created ON enrollment_tokens (created_at DESC);
-- A revision is immutable; its number is also the SOA serial (uint32).
CREATE TABLE config_revisions (
number bigint PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
created_by_name text NOT NULL,
comment text NOT NULL DEFAULT '',
warnings jsonb NOT NULL DEFAULT '[]',
CONSTRAINT config_revisions_number CHECK (number BETWEEN 1 AND 4294967295),
CONSTRAINT config_revisions_warnings_array CHECK (jsonb_typeof(warnings) = 'array')
);
CREATE TABLE config_revision_files (
revision bigint NOT NULL REFERENCES config_revisions (number) ON DELETE CASCADE,
role agent_role NOT NULL,
-- Position in the generator output (bundles list files in this order).
ordinal integer NOT NULL,
path text NOT NULL,
mode integer NOT NULL,
create_only boolean NOT NULL DEFAULT false,
content bytea NOT NULL,
PRIMARY KEY (revision, role, ordinal),
CONSTRAINT config_revision_files_path_unique UNIQUE (revision, role, path),
CONSTRAINT config_revision_files_ordinal CHECK (ordinal >= 0),
CONSTRAINT config_revision_files_mode CHECK (mode BETWEEN 0 AND 511),
CONSTRAINT config_revision_files_path CHECK (path <> '' AND length(path) <= 255
AND left(path, 1) <> '/' AND path !~ '(^|/)\.')
);
-- Revisions and their files never change once written.
-- +goose StatementBegin
CREATE FUNCTION config_revisions_immutable() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'configuration revisions are immutable'
USING ERRCODE = 'check_violation', CONSTRAINT = 'config_revisions_immutable';
END;
$$;
-- +goose StatementEnd
CREATE TRIGGER config_revisions_immutable
BEFORE UPDATE ON config_revisions
FOR EACH ROW EXECUTE FUNCTION config_revisions_immutable();
CREATE TRIGGER config_revision_files_immutable
BEFORE UPDATE ON config_revision_files
FOR EACH ROW EXECUTE FUNCTION config_revisions_immutable();
CREATE TYPE deployment_trigger AS ENUM ('pull', 'push');
CREATE TYPE deployment_outcome AS ENUM ('succeeded', 'rejected', 'rolled_back', 'failed');
CREATE TABLE deployments (
id bigserial PRIMARY KEY,
agent_id uuid NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
-- deployment_id of the ApplyRevisionCommand, empty for pulls.
correlation_id text NOT NULL DEFAULT '',
revision bigint NOT NULL,
trigger_kind deployment_trigger NOT NULL,
outcome deployment_outcome NOT NULL,
message text NOT NULL DEFAULT '',
active_revision bigint NOT NULL DEFAULT 0,
started_at timestamptz NOT NULL,
finished_at timestamptz NOT NULL,
reported_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT deployments_revision CHECK (revision BETWEEN 1 AND 4294967295),
CONSTRAINT deployments_active_revision CHECK (active_revision BETWEEN 0 AND 4294967295),
CONSTRAINT deployments_times CHECK (finished_at >= started_at),
-- An agent may resend a report after a crash; the copy is dropped.
CONSTRAINT deployments_report_unique UNIQUE (agent_id, revision, started_at)
);
CREATE INDEX deployments_agent ON deployments (agent_id, id DESC);
CREATE INDEX deployments_revision ON deployments (revision, id DESC);
-- Leases as last reported by each DHCP agent.
CREATE TABLE dhcp_leases (
agent_id uuid NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
ip inet NOT NULL,
mac text NOT NULL DEFAULT '',
hostname text NOT NULL DEFAULT '',
state lease_state NOT NULL,
expires_at timestamptz,
updated_at timestamptz,
subnet_id bigint NOT NULL DEFAULT 0,
client_id text NOT NULL DEFAULT '',
reported_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (agent_id, ip),
CONSTRAINT dhcp_leases_host_ip CHECK (masklen(ip) = CASE family(ip) WHEN 4 THEN 32 ELSE 128 END),
CONSTRAINT dhcp_leases_subnet_id CHECK (subnet_id BETWEEN 0 AND 4294967295)
);
CREATE INDEX dhcp_leases_ip ON dhcp_leases (ip);
CREATE INDEX dhcp_leases_subnet ON dhcp_leases (subnet_id);
-- +goose Down
DROP TABLE dhcp_leases;
DROP TABLE deployments;
DROP TYPE deployment_outcome;
DROP TYPE deployment_trigger;
DROP TRIGGER config_revision_files_immutable ON config_revision_files;
DROP TRIGGER config_revisions_immutable ON config_revisions;
DROP FUNCTION config_revisions_immutable();
DROP TABLE config_revision_files;
DROP TABLE config_revisions;
DROP TABLE enrollment_tokens;
DROP TABLE agents;
DROP TYPE agent_mode;
DROP TYPE agent_role;
DROP TABLE pki_keys;
+274
View File
@@ -0,0 +1,274 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// MaxRevision is the highest revision number (it doubles as SOA serial).
const MaxRevision = 1<<32 - 1
// revisionLockKey serializes revision creation (pg_advisory_xact_lock).
const revisionLockKey = 0x64646901
// Revision is a row of config_revisions. DNSFiles, DHCPFiles and
// WarningCount are filled by ListRevisions.
type Revision struct {
Number int64
CreatedAt time.Time
CreatedBy *string
CreatedByName string
Comment string
Warnings json.RawMessage
DNSFiles int
DHCPFiles int
WarningCount int
}
// RevisionFile is one artifact of a revision.
type RevisionFile struct {
Role string
Path string
Mode uint32
CreateOnly bool
Content []byte
}
// LockRevisions takes a transaction-scoped lock that serializes revision
// creation. It must run inside a transaction.
func (t *Tx) LockRevisions(ctx context.Context) error {
_, err := t.q.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, int64(revisionLockKey))
return mapErr(err)
}
// LatestRevision returns the newest revision or ErrNotFound.
func (t *Tx) LatestRevision(ctx context.Context) (*Revision, error) {
return t.getRevision(ctx, `ORDER BY number DESC LIMIT 1`)
}
// GetRevision returns one revision without files.
func (t *Tx) GetRevision(ctx context.Context, number int64) (*Revision, error) {
return t.getRevision(ctx, `WHERE number = $1`, number)
}
func (t *Tx) getRevision(ctx context.Context, cond string, args ...any) (*Revision, error) {
var r Revision
var warnings []byte
err := t.q.QueryRow(ctx, `SELECT number, created_at, created_by, created_by_name, comment, warnings
FROM config_revisions `+cond, args...).Scan(&r.Number, &r.CreatedAt, &r.CreatedBy, &r.CreatedByName, &r.Comment, &warnings)
if err != nil {
return nil, mapErr(err)
}
r.Warnings = warnings
return &r, nil
}
// InsertRevision stores a revision with its files (in the given order per
// role). CreatedAt is taken from r.
func (t *Tx) InsertRevision(ctx context.Context, r *Revision, files []RevisionFile) error {
warnings := r.Warnings
if len(warnings) == 0 {
warnings = json.RawMessage("[]")
}
if _, err := t.q.Exec(ctx, `INSERT INTO config_revisions (number, created_at, created_by, created_by_name, comment, warnings)
VALUES ($1, $2, $3, $4, $5, $6)`, r.Number, r.CreatedAt, r.CreatedBy, r.CreatedByName, r.Comment, []byte(warnings)); err != nil {
return mapErr(err)
}
b := &pgx.Batch{}
ordinal := map[string]int32{}
for _, f := range files {
n := ordinal[f.Role]
ordinal[f.Role] = n + 1
b.Queue(`INSERT INTO config_revision_files (revision, role, ordinal, path, mode, create_only, content)
VALUES ($1, $2::agent_role, $3, $4, $5, $6, $7)`,
r.Number, f.Role, n, f.Path, int32(f.Mode), f.CreateOnly, f.Content) //nolint:gosec // modes are at most 0o777 (checked by the database)
}
return t.execBatch(ctx, b)
}
// RevisionFiles returns the files of a revision ordered by role and
// generator order. role "" returns both roles.
func (t *Tx) RevisionFiles(ctx context.Context, number int64, role string) ([]RevisionFile, error) {
sql := `SELECT role, path, mode, create_only, content FROM config_revision_files WHERE revision = $1`
args := []any{number}
if role != "" {
sql += ` AND role = $2::agent_role`
args = append(args, role)
}
rows, err := t.q.Query(ctx, sql+` ORDER BY role, ordinal`, args...)
if err != nil {
return nil, mapErr(err)
}
defer rows.Close()
out := []RevisionFile{}
for rows.Next() {
var f RevisionFile
var mode int32
if err := rows.Scan(&f.Role, &f.Path, &mode, &f.CreateOnly, &f.Content); err != nil {
return nil, mapErr(err)
}
f.Mode = uint32(mode) //nolint:gosec // 0..511 by constraint
out = append(out, f)
}
return out, mapErr(rows.Err())
}
// ListRevisions returns up to limit revisions with a number below before
// (0 = no bound), newest first.
func (t *Tx) ListRevisions(ctx context.Context, limit int, before int64) ([]*Revision, error) {
sql := `SELECT r.number, r.created_at, r.created_by, r.created_by_name, r.comment, jsonb_array_length(r.warnings),
(SELECT count(*) FROM config_revision_files f WHERE f.revision = r.number AND f.role = 'dns'),
(SELECT count(*) FROM config_revision_files f WHERE f.revision = r.number AND f.role = 'dhcp')
FROM config_revisions r`
args := []any{}
if before > 0 {
sql += ` WHERE r.number < $1`
args = append(args, before)
}
sql += fmt.Sprintf(` ORDER BY r.number DESC LIMIT %d`, limit)
rows, err := t.q.Query(ctx, sql, args...)
if err != nil {
return nil, mapErr(err)
}
defer rows.Close()
var out []*Revision
for rows.Next() {
var r Revision
if err := rows.Scan(&r.Number, &r.CreatedAt, &r.CreatedBy, &r.CreatedByName, &r.Comment, &r.WarningCount,
&r.DNSFiles, &r.DHCPFiles); err != nil {
return nil, mapErr(err)
}
out = append(out, &r)
}
return out, mapErr(rows.Err())
}
// Deployment is a row of deployments; AgentHostname comes from the agent.
type Deployment struct {
ID int64
AgentID string
AgentHostname string
CorrelationID string
Revision int64
Trigger string
Outcome string
Message string
ActiveRevision int64
StartedAt time.Time
FinishedAt time.Time
ReportedAt time.Time
}
const deploymentColumns = `d.id, d.agent_id, a.hostname, d.correlation_id, d.revision, d.trigger_kind, d.outcome, d.message,
d.active_revision, d.started_at, d.finished_at, d.reported_at`
func scanDeployment(row pgx.Row) (*Deployment, error) {
var d Deployment
if err := row.Scan(&d.ID, &d.AgentID, &d.AgentHostname, &d.CorrelationID, &d.Revision, &d.Trigger, &d.Outcome,
&d.Message, &d.ActiveRevision, &d.StartedAt, &d.FinishedAt, &d.ReportedAt); err != nil {
return nil, mapErr(err)
}
return &d, nil
}
// InsertDeployment stores a deployment report; ID and ReportedAt are set.
// A report with the same agent, revision and start time exists already if
// inserted is false (resent after an agent crash).
func (t *Tx) InsertDeployment(ctx context.Context, d *Deployment) (inserted bool, err error) {
err = mapErr(t.q.QueryRow(ctx, `
INSERT INTO deployments (agent_id, correlation_id, revision, trigger_kind, outcome, message, active_revision,
started_at, finished_at)
VALUES ($1, $2, $3, $4::deployment_trigger, $5::deployment_outcome, $6, $7, $8, $9)
ON CONFLICT ON CONSTRAINT deployments_report_unique DO NOTHING
RETURNING id, reported_at`,
d.AgentID, d.CorrelationID, d.Revision, d.Trigger, d.Outcome, d.Message, d.ActiveRevision,
d.StartedAt, d.FinishedAt).Scan(&d.ID, &d.ReportedAt))
if errors.Is(err, ErrNotFound) {
return false, nil
}
return err == nil, err
}
// DeploymentFilter selects deployments. Zero values mean "no restriction".
type DeploymentFilter struct {
AgentID string
Revision int64
BeforeID int64
Limit int
}
// ListDeployments returns deployments newest first.
func (t *Tx) ListDeployments(ctx context.Context, f DeploymentFilter) ([]*Deployment, error) {
var where []string
var args []any
add := func(cond string, v any) {
args = append(args, v)
where = append(where, fmt.Sprintf(cond, len(args)))
}
if f.AgentID != "" {
add("d.agent_id = $%d", f.AgentID)
}
if f.Revision > 0 {
add("d.revision = $%d", f.Revision)
}
if f.BeforeID > 0 {
add("d.id < $%d", f.BeforeID)
}
limit := f.Limit
if limit <= 0 {
limit = 100
}
sql := `SELECT ` + deploymentColumns + ` FROM deployments d JOIN agents a ON a.id = d.agent_id`
if len(where) > 0 {
sql += " WHERE " + strings.Join(where, " AND ")
}
sql += fmt.Sprintf(" ORDER BY d.id DESC LIMIT %d", limit)
rows, err := t.q.Query(ctx, sql, args...)
if err != nil {
return nil, mapErr(err)
}
defer rows.Close()
var out []*Deployment
for rows.Next() {
d, err := scanDeployment(rows)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, mapErr(rows.Err())
}
// LatestDeployments returns the newest deployment of every agent that has
// one, keyed by agent ID.
func (t *Tx) LatestDeployments(ctx context.Context) (map[string]*Deployment, error) {
rows, err := t.q.Query(ctx, `SELECT DISTINCT ON (d.agent_id) `+deploymentColumns+`
FROM deployments d JOIN agents a ON a.id = d.agent_id ORDER BY d.agent_id, d.id DESC`)
if err != nil {
return nil, mapErr(err)
}
defer rows.Close()
out := map[string]*Deployment{}
for rows.Next() {
d, err := scanDeployment(rows)
if err != nil {
return nil, err
}
out[d.AgentID] = d
}
return out, mapErr(rows.Err())
}
// LatestDeployment returns the newest deployment of an agent or ErrNotFound.
func (t *Tx) LatestDeployment(ctx context.Context, agentID string) (*Deployment, error) {
return scanDeployment(t.q.QueryRow(ctx, `SELECT `+deploymentColumns+`
FROM deployments d JOIN agents a ON a.id = d.agent_id WHERE d.agent_id = $1 ORDER BY d.id DESC LIMIT 1`, agentID))
}
+9
View File
@@ -134,6 +134,15 @@ type querier interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults
}
// execBatch sends b and returns the first error.
func (t *Tx) execBatch(ctx context.Context, b *pgx.Batch) error {
if b.Len() == 0 {
return nil
}
return mapErr(t.q.SendBatch(ctx, b).Close())
}
// Tx is a repository bound to a transaction.