Files
go-sip/internal/rpc/approved_execution.go
T

246 lines
10 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/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
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,
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 {
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())
}