257 lines
11 KiB
Go
257 lines
11 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
|
|
"git.ipao.vip/rogee/go-sip/internal/agent"
|
|
"git.ipao.vip/rogee/go-sip/internal/ai"
|
|
"git.ipao.vip/rogee/go-sip/internal/configread"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// ApprovedExecution is the frozen Agent-side call handed to the isolated
|
|
// adapter. It contains plaintext provider credentials in AI; never log it.
|
|
type ApprovedExecution struct {
|
|
DispatcherID string
|
|
TenantID int64
|
|
TaskID string
|
|
CallerProfileID string
|
|
SourceEventID string
|
|
CallID string
|
|
SelectedTrunkID string
|
|
CallerID string
|
|
Callee string
|
|
DialedCallee string
|
|
SIPRevision int64
|
|
RingTimeout time.Duration
|
|
MaxCallDuration time.Duration
|
|
DialBefore time.Time
|
|
AI ai.CurrentBound
|
|
}
|
|
|
|
// GetLoadedSIP reports the adapter's observed state, not the desired static
|
|
// configuration. Missing or stale observation cannot authorize origination.
|
|
func (s *Server) GetLoadedSIP(ctx context.Context, req *agentpb.GetLoadedSIPRequest) (*agentpb.GetLoadedSIPResponse, error) {
|
|
if req == nil {
|
|
return nil, status.Error(codes.InvalidArgument, "SIP inspection request is required")
|
|
}
|
|
if err := s.authorize(ctx, req.Meta); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.sessions.ApprovedDispatcher(req.Meta, s.now()); err != nil {
|
|
return nil, err
|
|
}
|
|
if s.loadedSIP == nil {
|
|
return nil, status.Error(codes.FailedPrecondition, "observed SIP load state is unavailable")
|
|
}
|
|
loaded, err := s.loadedSIP(ctx)
|
|
if err != nil || len(loaded) == 0 {
|
|
return nil, status.Error(codes.Unavailable, "observed SIP load state failed")
|
|
}
|
|
copyOfLoaded := make(map[string]int64, len(loaded))
|
|
for trunk, revision := range loaded {
|
|
if trunk == "" || revision <= 0 {
|
|
return nil, status.Error(codes.FailedPrecondition, "observed SIP load state is invalid")
|
|
}
|
|
copyOfLoaded[trunk] = revision
|
|
}
|
|
return &agentpb.GetLoadedSIPResponse{TrunkRevision: copyOfLoaded}, nil
|
|
}
|
|
|
|
// ExecuteApproved is mock-only until real Agent/Asterisk loading and supplier
|
|
// contracts have been separately verified. It persists a one-shot unknown
|
|
// execution BEFORE calling the mock adapter. Ambiguous attempts never redial.
|
|
func (s *Server) ExecuteApproved(ctx context.Context, req *agentpb.ExecuteApprovedRequest) (*agentpb.ExecuteApprovedResponse, error) {
|
|
if req == nil || req.Meta == nil {
|
|
return nil, status.Error(codes.InvalidArgument, "approved execution metadata is required")
|
|
}
|
|
if err := s.authorize(ctx, req.Meta); err != nil {
|
|
return nil, err
|
|
}
|
|
dispatcherID, err := s.sessions.ApprovedDispatcher(req.Meta, s.now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if req.DispatcherId != dispatcherID {
|
|
return nil, status.Error(codes.PermissionDenied, "Dispatcher does not own the active Agent session")
|
|
}
|
|
if s.mode != "mock" || s.mockApprovedOriginate == nil {
|
|
return nil, status.Error(codes.FailedPrecondition, "approved origination requires the isolated mock adapter")
|
|
}
|
|
const maxTimeoutMS = int64(math.MaxInt64 / int64(time.Millisecond))
|
|
if req.TenantId <= 0 || req.TaskId == "" || req.SourceEventId == "" || req.CallId == "" || req.SelectedTrunkId == "" || req.CallerId == "" || req.Callee == "" || req.DialedCallee == "" || req.RingTimeoutMs <= 0 || req.RingTimeoutMs > maxTimeoutMS || req.MaxCallDurationMs <= 0 || req.MaxCallDurationMs > maxTimeoutMS || req.SipRevision <= 0 || req.Meta.IdempotencyKey != req.SourceEventId {
|
|
return nil, status.Error(codes.InvalidArgument, "approved execution identity or dial decision is incomplete")
|
|
}
|
|
if req.DialBeforeUnixMs <= s.now().UnixMilli() {
|
|
return nil, status.Error(codes.DeadlineExceeded, "Dispatcher dial authorization expired")
|
|
}
|
|
hash, err := configread.ExecutionBindingSHA256(req.TaskConfigJson, req.ProvidersJson, req.SipRevision)
|
|
if err != nil || hash != req.BindingSha256 {
|
|
return nil, status.Error(codes.FailedPrecondition, "bound execution snapshot does not match")
|
|
}
|
|
var task configread.CurrentTask
|
|
if err := json.Unmarshal(req.TaskConfigJson, &task); err != nil {
|
|
return nil, status.Error(codes.InvalidArgument, "approved task JSON is invalid")
|
|
}
|
|
if task.DispatcherID != dispatcherID || task.TenantID != req.TenantId || task.TaskID != req.TaskId {
|
|
return nil, status.Error(codes.FailedPrecondition, "approved task identity does not match")
|
|
}
|
|
var providers map[string]configread.CurrentProvider
|
|
if err := json.Unmarshal(req.ProvidersJson, &providers); err != nil {
|
|
return nil, status.Error(codes.InvalidArgument, "approved provider JSON is invalid")
|
|
}
|
|
bound, err := ai.BindCurrent(task, providers)
|
|
if err != nil {
|
|
// Errors from externally supplied snapshots must not echo a prompt,
|
|
// conversation, provider credential, or vendor endpoint into logs.
|
|
return nil, status.Error(codes.FailedPrecondition, "approved AI settings cannot run on this Agent")
|
|
}
|
|
if err := s.requireApprovedSIP(ctx, req.SelectedTrunkId, req.SipRevision); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.recordApprovedAttempt(req); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.DialBeforeUnixMs <= s.now().UnixMilli() {
|
|
return nil, status.Error(codes.DeadlineExceeded, "Dispatcher dial authorization expired before issuing")
|
|
}
|
|
if err := s.requireApprovedSIP(ctx, req.SelectedTrunkId, req.SipRevision); err != nil {
|
|
return nil, err
|
|
}
|
|
approved := ApprovedExecution{
|
|
DispatcherID: dispatcherID, TenantID: req.TenantId, TaskID: req.TaskId,
|
|
CallerProfileID: task.CallerProfileID, SourceEventID: req.SourceEventId, CallID: req.CallId,
|
|
SelectedTrunkID: req.SelectedTrunkId, CallerID: req.CallerId,
|
|
Callee: req.Callee, DialedCallee: req.DialedCallee, SIPRevision: req.SipRevision,
|
|
RingTimeout: time.Duration(req.RingTimeoutMs) * time.Millisecond,
|
|
MaxCallDuration: time.Duration(req.MaxCallDurationMs) * time.Millisecond,
|
|
DialBefore: time.UnixMilli(req.DialBeforeUnixMs), AI: bound,
|
|
}
|
|
if err := s.mockApprovedOriginate(ctx, approved); err != nil {
|
|
switch {
|
|
case errors.Is(err, ErrApprovedDialExpired):
|
|
log.Printf("Agent approved execution refused before dial: event_id=%q task_id=%q cause_type=%T", req.SourceEventId, req.TaskId, err)
|
|
return nil, status.Error(codes.DeadlineExceeded, "Dispatcher dial authorization expired before issuing")
|
|
case errors.Is(err, agent.ErrTaskAdmissionClosed), errors.Is(err, agent.ErrTaskStopped), errors.Is(err, ErrApprovedCallPreparation):
|
|
log.Printf("Agent approved execution refused before dial: event_id=%q task_id=%q cause_type=%T", req.SourceEventId, req.TaskId, err)
|
|
return nil, status.Error(codes.FailedPrecondition, "approved call was not ready before dial")
|
|
default:
|
|
log.Printf("Agent approved execution outcome unknown: event_id=%q task_id=%q cause_type=%T", req.SourceEventId, req.TaskId, err)
|
|
return nil, status.Error(codes.Unavailable, "approved mock execution outcome unknown")
|
|
}
|
|
}
|
|
return &agentpb.ExecuteApprovedResponse{CallId: req.CallId, Accepted: true}, nil
|
|
}
|
|
|
|
func (s *Server) requireApprovedSIP(ctx context.Context, trunk string, revision int64) error {
|
|
if s.loadedSIP == nil {
|
|
return status.Error(codes.FailedPrecondition, "observed SIP load state is unavailable")
|
|
}
|
|
loaded, err := s.loadedSIP(ctx)
|
|
if err != nil {
|
|
return status.Error(codes.Unavailable, "observed SIP load state failed")
|
|
}
|
|
if loaded[trunk] != revision {
|
|
return status.Error(codes.FailedPrecondition, "selected SIP revision is not actually loaded")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type approvedAttempt struct {
|
|
Digest string `json:"digest"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
// recordApprovedAttempt writes only a digest. The request body (including
|
|
// plaintext provider credentials) is never placed in the Agent journal.
|
|
func (s *Server) recordApprovedAttempt(req *agentpb.ExecuteApprovedRequest) error {
|
|
if s.approvedJournalDir == "" {
|
|
return status.Error(codes.FailedPrecondition, "durable Agent execution directory is required")
|
|
}
|
|
copyOfRequest := proto.Clone(req).(*agentpb.ExecuteApprovedRequest)
|
|
copyOfRequest.Meta = nil // a refreshed session must not change call identity
|
|
body, err := (proto.MarshalOptions{Deterministic: true}).Marshal(copyOfRequest)
|
|
if err != nil {
|
|
return status.Error(codes.Internal, "compute approved execution digest")
|
|
}
|
|
digest := sha256.Sum256(body)
|
|
key, err := json.Marshal(struct {
|
|
Dispatcher string `json:"dispatcher"`
|
|
Tenant int64 `json:"tenant"`
|
|
Task string `json:"task"`
|
|
Event string `json:"event"`
|
|
}{req.DispatcherId, req.TenantId, req.TaskId, req.SourceEventId})
|
|
if err != nil {
|
|
return status.Error(codes.Internal, "compute approved execution identity")
|
|
}
|
|
identity := sha256.Sum256(key)
|
|
path := filepath.Join(s.approvedJournalDir, hex.EncodeToString(identity[:])+".json")
|
|
record, err := json.Marshal(approvedAttempt{Digest: hex.EncodeToString(digest[:]), State: "unknown"})
|
|
if err != nil {
|
|
return status.Error(codes.Internal, "serialize approved execution identity")
|
|
}
|
|
|
|
s.approvedMu.Lock()
|
|
defer s.approvedMu.Unlock()
|
|
if err := os.MkdirAll(s.approvedJournalDir, 0700); err != nil {
|
|
return status.Errorf(codes.Internal, "create Agent execution directory: %v", err)
|
|
}
|
|
if err := syncApprovedDir(filepath.Dir(s.approvedJournalDir)); err != nil {
|
|
return status.Errorf(codes.Internal, "sync Agent execution parent directory: %v", err)
|
|
}
|
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
|
if errors.Is(err, os.ErrExist) {
|
|
existingBytes, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
return status.Error(codes.FailedPrecondition, "prior Agent execution state is unreadable")
|
|
}
|
|
var existing approvedAttempt
|
|
if json.Unmarshal(existingBytes, &existing) != nil || existing.State != "unknown" || existing.Digest == "" {
|
|
return status.Error(codes.FailedPrecondition, "prior Agent execution state is incomplete")
|
|
}
|
|
if existing.Digest != hex.EncodeToString(digest[:]) {
|
|
return status.Error(codes.AlreadyExists, "execution identity has conflicting immutable content")
|
|
}
|
|
return status.Error(codes.FailedPrecondition, "prior Agent execution outcome is unknown; no redial")
|
|
}
|
|
if err != nil {
|
|
return status.Errorf(codes.Internal, "create Agent execution identity: %v", err)
|
|
}
|
|
if _, err := file.Write(record); err != nil {
|
|
_ = file.Close()
|
|
return status.Errorf(codes.Internal, "persist Agent execution identity: %v", err)
|
|
}
|
|
if err := file.Sync(); err != nil {
|
|
_ = file.Close()
|
|
return status.Errorf(codes.Internal, "sync Agent execution identity: %v", err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return status.Errorf(codes.Internal, "close Agent execution identity: %v", err)
|
|
}
|
|
if err := syncApprovedDir(s.approvedJournalDir); err != nil {
|
|
return status.Errorf(codes.Internal, "sync Agent execution directory: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func syncApprovedDir(path string) error {
|
|
dir, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = dir.Sync()
|
|
return errors.Join(err, dir.Close())
|
|
}
|