TLS 1.3 listener that requests client certificates and checks them in interceptors: Enroll needs a valid one-time token (rate limited per source IP), RenewCertificate accepts certificates expired up to 30 days, every other call needs the agent's current, unrevoked certificate. Connect registers the stream in the hub (pushes, revocation, shutdown); FetchBundle signs the role's artifacts per agent; lease batches are deduplicated by sequence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
293 lines
8.7 KiB
Go
293 lines
8.7 KiB
Go
// 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"
|
|
|
|
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
|
|
|
|
// 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; allow some slack.
|
|
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{MinTime: 15 * time.Second, PermitWithoutStream: true}),
|
|
grpc.KeepaliveParams(keepalive.ServerParameters{Time: time.Minute, Timeout: 20 * time.Second}),
|
|
grpc.MaxRecvMsgSize(MaxRecvMsgSize),
|
|
)
|
|
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")
|
|
}
|