Merge branch 'worktree-agent-af35c8bdd4b0bcf64'
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user