468 lines
17 KiB
Go
468 lines
17 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
cryptorand "crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
|
|
"git.ipao.vip/rogee/go-sip/internal/agent"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/peer"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// ServerOptions contains deployment-bound identity and mock policy inputs.
|
|
// Production credentials are supplied to grpc.Server through TLS credentials.
|
|
type ServerOptions struct {
|
|
Mode string
|
|
Status *agentpb.AgentStatus
|
|
Now func() time.Time
|
|
RequirePeerCertificate bool
|
|
ApprovedDispatcherID string
|
|
PeerAgentIDs map[string]string
|
|
PeerCertificateFingerprints map[string]struct{}
|
|
StatePath string
|
|
// LoadedSIP reports the revision the mock Agent actually loaded; nil fails closed.
|
|
LoadedSIP func(context.Context) (map[string]int64, error)
|
|
// The mock may have issued a call even if its outcome is unknown.
|
|
MockApprovedOriginate func(context.Context, ApprovedExecution) error
|
|
// ApprovedTaskCalls is shared with the approved call runner; nil rejects task controls.
|
|
ApprovedTaskCalls *agent.TaskCalls
|
|
}
|
|
|
|
// Server owns the current Agent session, execution and control RPC boundary.
|
|
// Dispatcher SQLite remains authoritative for quotas and task state.
|
|
type Server struct {
|
|
agentpb.UnimplementedAgentControlServiceServer
|
|
|
|
mode string
|
|
now func() time.Time
|
|
status *agentpb.AgentStatus
|
|
requirePeerCertificate bool
|
|
approvedDispatcherID string
|
|
peerAgentIDs map[string]string
|
|
peerCertificateFingerprints map[string]struct{}
|
|
loadedSIP func(context.Context) (map[string]int64, error)
|
|
mockApprovedOriginate func(context.Context, ApprovedExecution) error
|
|
approvedTaskCalls *agent.TaskCalls
|
|
sessions *SessionRegistry
|
|
|
|
approvedJournalDir string
|
|
approvedMu sync.Mutex
|
|
}
|
|
|
|
// NewServer constructs a handler suitable for registration with a gRPC server.
|
|
func NewServer(options ServerOptions) *Server {
|
|
mode := options.Mode
|
|
if mode == "" {
|
|
mode = "mock"
|
|
}
|
|
now := options.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
statusValue := &agentpb.AgentStatus{}
|
|
if options.Status != nil {
|
|
statusValue = proto.Clone(options.Status).(*agentpb.AgentStatus)
|
|
}
|
|
if statusValue.AdmissionState == agentpb.AdmissionState_ADMISSION_STATE_UNSPECIFIED {
|
|
statusValue.AdmissionState = agentpb.AdmissionState_ADMISSION_STATE_CLOSED
|
|
}
|
|
server := &Server{
|
|
mode: mode,
|
|
now: now,
|
|
status: statusValue,
|
|
requirePeerCertificate: options.RequirePeerCertificate,
|
|
approvedDispatcherID: options.ApprovedDispatcherID,
|
|
peerAgentIDs: cloneStringMap(options.PeerAgentIDs),
|
|
peerCertificateFingerprints: cloneSet(options.PeerCertificateFingerprints),
|
|
loadedSIP: options.LoadedSIP,
|
|
mockApprovedOriginate: options.MockApprovedOriginate,
|
|
approvedTaskCalls: options.ApprovedTaskCalls,
|
|
sessions: NewSessionRegistry(options.StatePath),
|
|
}
|
|
if options.StatePath != "" {
|
|
server.approvedJournalDir = options.StatePath + ".approved"
|
|
}
|
|
return server
|
|
}
|
|
|
|
// SessionRegistry keeps the newest Dispatcher-approved binding for each Agent.
|
|
// A newer generation fences all older requests; it does not release unknown
|
|
// work from an older boot.
|
|
type SessionRegistry struct {
|
|
mu sync.Mutex
|
|
sessions map[string]sessionRecord
|
|
generations map[string]uint64
|
|
statePath string
|
|
loadErr error
|
|
}
|
|
|
|
type sessionRecord struct {
|
|
binding *agentpb.AgentBinding
|
|
activationOperationID string
|
|
digest string
|
|
session *agentpb.Session
|
|
}
|
|
|
|
func NewSessionRegistry(statePath string) *SessionRegistry {
|
|
registry := &SessionRegistry{sessions: make(map[string]sessionRecord), generations: make(map[string]uint64), statePath: statePath}
|
|
if statePath != "" {
|
|
registry.loadErr = registry.load()
|
|
}
|
|
return registry
|
|
}
|
|
|
|
type sessionJournal struct {
|
|
Generations map[string]uint64 `json:"generations"`
|
|
}
|
|
|
|
func (r *SessionRegistry) load() error {
|
|
data, err := os.ReadFile(r.statePath)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var journal sessionJournal
|
|
if err := json.Unmarshal(data, &journal); err != nil {
|
|
return err
|
|
}
|
|
for agentID, generation := range journal.Generations {
|
|
if agentID != "" && generation > 0 {
|
|
r.generations[agentID] = generation
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *SessionRegistry) persistLocked() error {
|
|
if r.statePath == "" {
|
|
return nil
|
|
}
|
|
return writeRPCJournal(r.statePath, sessionJournal{Generations: r.generations})
|
|
}
|
|
|
|
func (r *SessionRegistry) Activate(binding *agentpb.AgentBinding, activationOperationID, digest string, now time.Time) (*agentpb.Session, bool, error) {
|
|
if binding == nil || binding.AgentId == "" || binding.CellId == "" || binding.DispatcherEpoch == "" || activationOperationID == "" {
|
|
return nil, false, status.Error(codes.InvalidArgument, "agent, cell, epoch and activation operation are required")
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.loadErr != nil {
|
|
return nil, false, status.Errorf(codes.Internal, "load session journal: %v", r.loadErr)
|
|
}
|
|
if existing, ok := r.sessions[binding.AgentId]; ok {
|
|
if existing.activationOperationID == activationOperationID && existing.digest == digest {
|
|
return cloneSession(existing.session), true, nil
|
|
}
|
|
if binding.SessionGeneration == 0 {
|
|
if existing.binding.SessionGeneration == ^uint64(0) {
|
|
return nil, false, status.Error(codes.Aborted, "session generation is exhausted")
|
|
}
|
|
binding = proto.Clone(binding).(*agentpb.AgentBinding)
|
|
binding.SessionGeneration = existing.binding.SessionGeneration + 1
|
|
}
|
|
if binding.SessionGeneration <= existing.binding.SessionGeneration {
|
|
return nil, false, status.Error(codes.Aborted, "session generation is fenced")
|
|
}
|
|
}
|
|
if binding.SessionGeneration == 0 {
|
|
binding = proto.Clone(binding).(*agentpb.AgentBinding)
|
|
binding.SessionGeneration = 1
|
|
if previous, ok := r.generations[binding.AgentId]; ok {
|
|
if previous == ^uint64(0) {
|
|
return nil, false, status.Error(codes.Aborted, "session generation is exhausted")
|
|
}
|
|
binding.SessionGeneration = previous + 1
|
|
}
|
|
}
|
|
if previous, ok := r.generations[binding.AgentId]; ok && binding.SessionGeneration <= previous {
|
|
return nil, false, status.Error(codes.Aborted, "persisted session generation is fenced")
|
|
}
|
|
credential := make([]byte, 32)
|
|
if _, err := cryptorand.Read(credential); err != nil {
|
|
return nil, false, status.Errorf(codes.Internal, "create session credential: %v", err)
|
|
}
|
|
session := &agentpb.Session{
|
|
DispatcherEpoch: binding.DispatcherEpoch,
|
|
SessionGeneration: binding.SessionGeneration,
|
|
ExpiresAtUnixMs: now.Add(10 * time.Minute).UnixMilli(),
|
|
SessionCredential: credential,
|
|
}
|
|
previous, hadPrevious := r.sessions[binding.AgentId]
|
|
r.sessions[binding.AgentId] = sessionRecord{
|
|
binding: proto.Clone(binding).(*agentpb.AgentBinding),
|
|
activationOperationID: activationOperationID,
|
|
digest: digest,
|
|
session: cloneSession(session),
|
|
}
|
|
r.generations[binding.AgentId] = binding.SessionGeneration
|
|
if err := r.persistLocked(); err != nil {
|
|
if hadPrevious {
|
|
r.sessions[binding.AgentId] = previous
|
|
} else {
|
|
delete(r.sessions, binding.AgentId)
|
|
}
|
|
return nil, false, status.Errorf(codes.Internal, "persist session journal: %v", err)
|
|
}
|
|
return session, false, nil
|
|
}
|
|
|
|
func (r *SessionRegistry) Authorize(meta *agentpb.RequestMeta, now time.Time) error {
|
|
if meta == nil || meta.AgentId == "" || meta.CellId == "" || meta.BootId == "" || meta.DispatcherEpoch == "" || meta.SessionGeneration == 0 {
|
|
return status.Error(codes.InvalidArgument, "complete session metadata is required")
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
existing, ok := r.sessions[meta.AgentId]
|
|
if !ok {
|
|
return status.Error(codes.Unauthenticated, "agent session is not active")
|
|
}
|
|
if existing.binding.CellId != meta.CellId || existing.binding.ExpectedBootId != meta.BootId || existing.binding.DispatcherEpoch != meta.DispatcherEpoch || existing.binding.SessionGeneration != meta.SessionGeneration {
|
|
return status.Error(codes.Aborted, "agent session is fenced")
|
|
}
|
|
if existing.session.ExpiresAtUnixMs <= now.UnixMilli() {
|
|
return status.Error(codes.Unauthenticated, "agent session expired")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ApprovedDispatcher returns the Dispatcher ID attached to the authenticated
|
|
// active session, not the ID asserted by an individual execution request.
|
|
func (r *SessionRegistry) ApprovedDispatcher(meta *agentpb.RequestMeta, now time.Time) (string, error) {
|
|
if err := r.Authorize(meta, now); err != nil {
|
|
return "", err
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
session, ok := r.sessions[meta.AgentId]
|
|
if !ok || session.binding.SessionGeneration != meta.SessionGeneration || session.session.ExpiresAtUnixMs <= now.UnixMilli() {
|
|
return "", status.Error(codes.Aborted, "agent session changed")
|
|
}
|
|
if session.binding.DispatcherId == "" {
|
|
return "", status.Error(codes.FailedPrecondition, "active Dispatcher identity is missing")
|
|
}
|
|
return session.binding.DispatcherId, nil
|
|
}
|
|
|
|
// CurrentMeta returns only the active, unexpired session identity. It does
|
|
// not expose the session credential; callers use it for Agent→Dispatcher
|
|
// reports and never invent a new boot identity after restart.
|
|
func (r *SessionRegistry) CurrentMeta(agentID string, now time.Time) (*agentpb.RequestMeta, error) {
|
|
if agentID == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "Agent ID is required")
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.loadErr != nil {
|
|
return nil, status.Errorf(codes.Internal, "load session journal: %v", r.loadErr)
|
|
}
|
|
existing, ok := r.sessions[agentID]
|
|
if !ok || existing.session.ExpiresAtUnixMs <= now.UnixMilli() {
|
|
return nil, status.Error(codes.Unauthenticated, "Agent session is not active")
|
|
}
|
|
return &agentpb.RequestMeta{
|
|
ProtocolVersion: "agent.v1", AgentId: agentID, CellId: existing.binding.CellId,
|
|
BootId: existing.binding.ExpectedBootId, DispatcherEpoch: existing.binding.DispatcherEpoch,
|
|
SessionGeneration: existing.binding.SessionGeneration,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) ActiveSessionMeta() (*agentpb.RequestMeta, error) {
|
|
if s.status == nil {
|
|
return nil, status.Error(codes.FailedPrecondition, "Agent status is unavailable")
|
|
}
|
|
return s.sessions.CurrentMeta(s.status.AgentId, s.now())
|
|
}
|
|
|
|
func (s *Server) GetAgentStatus(ctx context.Context, req *agentpb.GetAgentStatusRequest) (*agentpb.GetAgentStatusResponse, error) {
|
|
if req == nil || req.Meta == nil || req.Meta.AgentId == "" || req.Meta.CellId == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "status metadata with agent and cell is required")
|
|
}
|
|
if err := s.checkConfiguredIdentity(req.Meta.AgentId, req.Meta.CellId); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Target != nil {
|
|
if req.Target.AgentId != "" && req.Target.AgentId != req.Meta.AgentId {
|
|
return nil, status.Error(codes.PermissionDenied, "target agent does not match authenticated agent")
|
|
}
|
|
if req.Target.CellId != "" && req.Target.CellId != req.Meta.CellId {
|
|
return nil, status.Error(codes.PermissionDenied, "target Cell does not match authenticated Cell")
|
|
}
|
|
}
|
|
if err := s.checkPeer(ctx, req.Meta.AgentId); err != nil {
|
|
return nil, err
|
|
}
|
|
preActivation := req.Meta.BootId == "" && req.Meta.DispatcherEpoch == "" && req.Meta.SessionGeneration == 0
|
|
if preActivation {
|
|
if req.Target != nil && (req.Target.ExpectedBootId != "" || req.Target.DispatcherEpoch != "" || req.Target.SessionGeneration != 0) {
|
|
return nil, status.Error(codes.InvalidArgument, "pre-activation status cannot include session binding")
|
|
}
|
|
} else if err := s.sessions.Authorize(req.Meta, s.now()); err != nil {
|
|
return nil, err
|
|
}
|
|
result := proto.Clone(s.status).(*agentpb.AgentStatus)
|
|
result.SessionActive = !preActivation
|
|
result.MtlsAuthenticated = s.peerIsAuthenticated(ctx)
|
|
return &agentpb.GetAgentStatusResponse{Meta: s.responseMeta(req.Meta), Status: result}, nil
|
|
}
|
|
|
|
func (s *Server) ActivateAgent(ctx context.Context, req *agentpb.ActivateAgentRequest) (*agentpb.ActivateAgentResponse, error) {
|
|
if req == nil || req.Meta == nil || req.Binding == nil {
|
|
return nil, status.Error(codes.InvalidArgument, "activation metadata and binding are required")
|
|
}
|
|
if req.Meta.AgentId != req.Binding.AgentId || req.Meta.CellId != req.Binding.CellId || req.Meta.BootId == "" || req.Meta.DispatcherEpoch == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "activation identity is inconsistent")
|
|
}
|
|
if err := s.checkConfiguredIdentity(req.Binding.AgentId, req.Binding.CellId); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.checkPeer(ctx, req.Binding.AgentId); err != nil {
|
|
return nil, err
|
|
}
|
|
if s.approvedDispatcherID != "" && req.Binding.DispatcherId != s.approvedDispatcherID {
|
|
return nil, status.Error(codes.PermissionDenied, "activated Dispatcher identity is not authorized")
|
|
}
|
|
binding := proto.Clone(req.Binding).(*agentpb.AgentBinding)
|
|
if binding.ExpectedBootId == "" {
|
|
binding.ExpectedBootId = req.Meta.BootId
|
|
}
|
|
if binding.ExpectedBootId != req.Meta.BootId {
|
|
return nil, status.Error(codes.Aborted, "activation boot identity is fenced")
|
|
}
|
|
if req.ActivationOperationId == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "activation operation is required")
|
|
}
|
|
digest := messageDigest(req)
|
|
session, replay, err := s.sessions.Activate(binding, req.ActivationOperationId, digest, s.now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state := agentpb.ActivationState_ACTIVATION_STATE_ACTIVE
|
|
if replay {
|
|
state = agentpb.ActivationState_ACTIVATION_STATE_ACTIVE
|
|
}
|
|
return &agentpb.ActivateAgentResponse{Meta: s.responseMeta(req.Meta), State: state, Session: session}, nil
|
|
}
|
|
|
|
func (s *Server) authorize(ctx context.Context, meta *agentpb.RequestMeta) error {
|
|
if meta == nil {
|
|
return status.Error(codes.InvalidArgument, "request metadata is required")
|
|
}
|
|
if err := s.checkPeer(ctx, meta.AgentId); err != nil {
|
|
return err
|
|
}
|
|
return s.sessions.Authorize(meta, s.now())
|
|
}
|
|
|
|
func (s *Server) checkConfiguredIdentity(agentID, cellID string) error {
|
|
if s.status.AgentId != "" && s.status.AgentId != agentID {
|
|
return status.Error(codes.PermissionDenied, "request Agent identity is not bound to this endpoint")
|
|
}
|
|
if s.status.CellId != "" && s.status.CellId != cellID {
|
|
return status.Error(codes.PermissionDenied, "request Cell identity is not bound to this endpoint")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) checkPeer(ctx context.Context, agentID string) error {
|
|
if !s.requirePeerCertificate && len(s.peerAgentIDs) == 0 && len(s.peerCertificateFingerprints) == 0 {
|
|
return nil
|
|
}
|
|
p, ok := peer.FromContext(ctx)
|
|
if !ok {
|
|
return status.Error(codes.Unauthenticated, "mTLS peer is missing")
|
|
}
|
|
tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo)
|
|
if !ok || len(tlsInfo.State.VerifiedChains) == 0 || len(tlsInfo.State.VerifiedChains[0]) == 0 {
|
|
return status.Error(codes.Unauthenticated, "verified mTLS peer is required")
|
|
}
|
|
fingerprint := CertificateFingerprint(tlsInfo.State.VerifiedChains[0][0])
|
|
if len(s.peerAgentIDs) == 0 && len(s.peerCertificateFingerprints) == 0 {
|
|
// The shared Agent certificate authenticates the certificate group. The
|
|
// Dispatcher-approved session binding still authorizes the individual
|
|
// agent/cell/boot tuple; no self-reported identity is trusted here.
|
|
return nil
|
|
}
|
|
if len(s.peerCertificateFingerprints) > 0 {
|
|
if _, allowed := s.peerCertificateFingerprints[fingerprint]; !allowed {
|
|
return status.Error(codes.PermissionDenied, "mTLS certificate is not in the endpoint allowlist")
|
|
}
|
|
}
|
|
if len(s.peerAgentIDs) > 0 {
|
|
if expected := s.peerAgentIDs[fingerprint]; expected == "" || expected != agentID {
|
|
return status.Error(codes.PermissionDenied, "mTLS certificate is not bound to this agent")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) peerIsAuthenticated(ctx context.Context) bool {
|
|
p, ok := peer.FromContext(ctx)
|
|
if !ok {
|
|
return false
|
|
}
|
|
_, ok = p.AuthInfo.(credentials.TLSInfo)
|
|
return ok
|
|
}
|
|
|
|
func (s *Server) responseMeta(meta *agentpb.RequestMeta) *agentpb.ResponseMeta {
|
|
if meta == nil {
|
|
return nil
|
|
}
|
|
return &agentpb.ResponseMeta{ProtocolVersion: meta.ProtocolVersion, RequestId: meta.RequestId, TraceId: meta.TraceId, OperationId: meta.OperationId, ObservedAtUnixMs: s.now().UnixMilli(), DispatcherEpoch: meta.DispatcherEpoch, AgentId: meta.AgentId, CellId: meta.CellId, BootId: meta.BootId, SessionGeneration: meta.SessionGeneration}
|
|
}
|
|
|
|
func messageDigest(message proto.Message) string {
|
|
encoded, err := proto.Marshal(message)
|
|
if err != nil {
|
|
return "marshal-error"
|
|
}
|
|
digest := sha256.Sum256(encoded)
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func cloneSession(value *agentpb.Session) *agentpb.Session {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return proto.Clone(value).(*agentpb.Session)
|
|
}
|
|
|
|
func cloneStringMap(values map[string]string) map[string]string {
|
|
if values == nil {
|
|
return nil
|
|
}
|
|
result := make(map[string]string, len(values))
|
|
for key, value := range values {
|
|
result[key] = value
|
|
}
|
|
return result
|
|
}
|
|
|
|
func cloneSet(values map[string]struct{}) map[string]struct{} {
|
|
if values == nil {
|
|
return nil
|
|
}
|
|
result := make(map[string]struct{}, len(values))
|
|
for key := range values {
|
|
result[key] = struct{}{}
|
|
}
|
|
return result
|
|
}
|
|
|
|
var _ agentpb.AgentControlServiceServer = (*Server)(nil)
|
|
var _ = grpc.SupportPackageIsVersion9
|