272 lines
11 KiB
Go
272 lines
11 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/go-sip/internal/agent"
|
|
"git.ipao.vip/rogee/go-sip/internal/ai"
|
|
"git.ipao.vip/rogee/go-sip/internal/asterisk"
|
|
"git.ipao.vip/rogee/go-sip/internal/callflow"
|
|
)
|
|
|
|
// ApprovedRecordedRealCall uses the signed Dispatcher instruction to create
|
|
// exactly one native ARI call. Unlike the isolated Mock it never supplies a
|
|
// synthetic transcript, media frame, outcome, or recording.
|
|
type ApprovedRecordedRealCall struct {
|
|
Loader asterisk.Loader
|
|
MediaPayloadType uint8
|
|
EvidenceRoot string
|
|
Capture bool
|
|
MaxWAVBytes int64
|
|
ReportTimeout time.Duration
|
|
Delivery *agent.RecordingDelivery
|
|
Mirror *asterisk.HEPMirror
|
|
originator func(context.Context, asterisk.NativeDial) (realMedia, error) // isolated tests only
|
|
}
|
|
|
|
type realMedia struct {
|
|
ctx context.Context
|
|
session callflow.MediaSession
|
|
close func() error
|
|
}
|
|
|
|
// Prepare rejects missing signed identity and recovery capacity before the
|
|
// Agent acknowledges the call. No SIP channel is created during preparation.
|
|
func (r *ApprovedRecordedRealCall) Prepare(approved ApprovedExecution) (func(context.Context) error, error) {
|
|
if r == nil || r.Delivery == nil || r.Mirror == nil || r.ReportTimeout <= 0 || r.MaxWAVBytes <= 44 || r.MediaPayloadType < 96 || r.MediaPayloadType > 127 {
|
|
return nil, errors.New("real call requires bounded media and per-call delivery")
|
|
}
|
|
if approved.DispatcherID == "" || approved.TenantID <= 0 || approved.SourceEventID == "" || approved.CallID != approved.SourceEventID || approved.TaskID == "" ||
|
|
approved.CallerID == "" || approved.Callee == "" || approved.DialedCallee == "" || approved.SelectedTrunkID == "" ||
|
|
approved.RingTimeout <= 0 || approved.MaxCallDuration <= 0 || approved.DialBefore.IsZero() || (approved.AI.Mode != string(ai.ModeASROnly) && approved.AI.Mode != string(ai.ModeFullAI)) {
|
|
return nil, errors.New("approved real call identity, timing or AI mode is incomplete")
|
|
}
|
|
if r.Delivery.Call.Client == nil || r.Delivery.Call.Session == nil || r.Delivery.Call.DispatcherID != approved.DispatcherID ||
|
|
r.Delivery.Call.TenantID != approved.TenantID || r.Delivery.Call.SourceEventID != approved.SourceEventID || r.Delivery.Recovery == nil ||
|
|
strings.TrimSpace(r.Delivery.Recovery.Root) == "" {
|
|
return nil, errors.New("real recording recovery or Dispatcher delivery unavailable")
|
|
}
|
|
info, err := os.Stat(r.Delivery.Recovery.Root)
|
|
if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 {
|
|
return nil, errors.New("real recording recovery directory must exist with mode 0700")
|
|
}
|
|
if r.originator == nil {
|
|
if r.Loader.ConfigDir == "" || r.Loader.Asterisk == "" || r.Loader.LibraryDir == "" {
|
|
return nil, errors.New("native Asterisk configuration required for real call")
|
|
}
|
|
}
|
|
if r.Capture {
|
|
if err := verifyCallEvidence(r.EvidenceRoot, approved.SourceEventID, approved.SelectedTrunkID, approved.Callee); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if _, err := ai.NewCall(approved.AI, func(context.Context) error { return nil }); err != nil {
|
|
return nil, err
|
|
}
|
|
originate := r.originator
|
|
if originate == nil {
|
|
originate = func(ctx context.Context, dial asterisk.NativeDial) (realMedia, error) {
|
|
call, err := r.Loader.Originate(ctx, dial)
|
|
if err != nil {
|
|
return realMedia{}, err
|
|
}
|
|
return realMedia{ctx: call.Context, session: call.Media, close: call.Close}, nil
|
|
}
|
|
}
|
|
dial := asterisk.NativeDial{
|
|
ExecutionID: approved.SourceEventID, TrunkID: approved.SelectedTrunkID,
|
|
DialedCallee: approved.DialedCallee, CallerID: approved.CallerID,
|
|
AnswerTimeout: approved.RingTimeout, MediaPayloadType: r.MediaPayloadType,
|
|
BindSIPCall: r.Mirror.Bind,
|
|
}
|
|
addSIPResponse := func(ctx context.Context, facts *callflow.FinalCallFacts) {
|
|
mirrorCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
|
|
defer cancel()
|
|
response, err := r.Mirror.Final(mirrorCtx, approved.SourceEventID)
|
|
if err != nil {
|
|
facts.SIPCaptureError = err.Error() // static failure text; never raw SIP bytes
|
|
return
|
|
}
|
|
facts.ReasonCode, facts.SIPStatusLine, facts.SIPRaw = &response.Code, response.StatusLine, response.Raw
|
|
}
|
|
var started atomic.Bool
|
|
return func(ctx context.Context) error {
|
|
if !started.CompareAndSwap(false, true) {
|
|
return errors.New("approved real call already started; refusing a second dial")
|
|
}
|
|
defer r.Mirror.Release(approved.SourceEventID)
|
|
if ctx == nil || ctx.Err() != nil || !time.Now().Before(approved.DialBefore) {
|
|
return errors.New("real call instruction expired or cancelled before origination")
|
|
}
|
|
if r.Capture {
|
|
if err := verifyCallEvidence(r.EvidenceRoot, approved.SourceEventID, approved.SelectedTrunkID, approved.Callee); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
attemptedAt := time.Now().UTC()
|
|
call, err := originate(ctx, dial)
|
|
if err != nil {
|
|
if !asterisk.NativeCallConfirmedPreAnswerEnd(err) {
|
|
return err // uncertain origination or end is never retried or reported as completed
|
|
}
|
|
facts := callflow.FinalCallFacts{
|
|
TaskID: approved.TaskID,
|
|
Callee: approved.Callee, TrunkID: approved.SelectedTrunkID,
|
|
StartedAt: attemptedAt, EndedAt: time.Now().UTC(), Outcome: "failed", ReasonMessage: "call ended before answer",
|
|
}
|
|
addSIPResponse(ctx, &facts)
|
|
payload, payloadErr := callflow.FinalResultPayload(facts, callflow.Result{})
|
|
if payloadErr != nil {
|
|
return errors.Join(err, payloadErr)
|
|
}
|
|
reportCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), r.ReportTimeout)
|
|
defer cancel()
|
|
return errors.Join(err, r.Delivery.Complete(reportCtx, agent.CompletedRecording{ResultPayload: payload}))
|
|
}
|
|
startedAt := time.Now().UTC() // Dial returned and the channel entered Stasis and became Up.
|
|
// A real channel may have answered even if media/AI setup subsequently
|
|
// fails. Report a failed call only after its hangup is confirmed; never
|
|
// keep a known-ended call occupying capacity or invent a recording.
|
|
reportSetupFailure := func(reason string, cause error) error {
|
|
if call.close == nil {
|
|
return errors.Join(cause, errors.New("real call has no hangup function; outcome unknown"))
|
|
}
|
|
if err := call.close(); err != nil {
|
|
return errors.Join(cause, err)
|
|
}
|
|
facts := callflow.FinalCallFacts{
|
|
TaskID: approved.TaskID,
|
|
Callee: approved.Callee, TrunkID: approved.SelectedTrunkID,
|
|
StartedAt: startedAt, EndedAt: time.Now().UTC(), Outcome: "failed", ReasonMessage: reason,
|
|
}
|
|
addSIPResponse(ctx, &facts)
|
|
payload, err := callflow.FinalResultPayload(facts, callflow.Result{})
|
|
if err != nil {
|
|
return errors.Join(cause, err)
|
|
}
|
|
reportCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), r.ReportTimeout)
|
|
defer cancel()
|
|
return errors.Join(cause, r.Delivery.Complete(reportCtx, agent.CompletedRecording{ResultPayload: payload}))
|
|
}
|
|
if call.ctx == nil || call.session == nil {
|
|
return reportSetupFailure("real media was unavailable", errors.New("real call media unavailable"))
|
|
}
|
|
if call.close == nil {
|
|
return errors.New("real call has no hangup function; outcome unknown")
|
|
}
|
|
capture, err := callflow.NewRecordingSession(call.session, r.MaxWAVBytes)
|
|
if err != nil {
|
|
return reportSetupFailure("real recording setup failed", err)
|
|
}
|
|
hangup := func(context.Context) error { return call.close() }
|
|
pipeline, err := ai.NewCall(approved.AI, hangup)
|
|
if err != nil {
|
|
return reportSetupFailure("real AI instance setup failed", err)
|
|
}
|
|
observed, runErr := RunApprovedCall(call.ctx, approved, capture, hangup, pipeline)
|
|
if err := call.close(); err != nil {
|
|
return errors.Join(runErr, err) // do not report an unconfirmed end or release its occupancy
|
|
}
|
|
endedAt := time.Now().UTC()
|
|
outcome, reason := "answered", "real call ended"
|
|
if runErr != nil {
|
|
outcome, reason = "failed", "real AI or media flow failed"
|
|
}
|
|
facts := callflow.FinalCallFacts{
|
|
TaskID: approved.TaskID,
|
|
Callee: approved.Callee, TrunkID: approved.SelectedTrunkID,
|
|
StartedAt: startedAt, EndedAt: endedAt, Outcome: outcome, ReasonMessage: reason,
|
|
}
|
|
addSIPResponse(ctx, &facts)
|
|
payload, err := callflow.FinalResultPayload(facts, observed)
|
|
if err != nil {
|
|
return errors.Join(runErr, err)
|
|
}
|
|
wav, durationMS, captureErr := capture.WAV()
|
|
completed := agent.CompletedRecording{ResultPayload: payload, Expected: true, CaptureError: captureErr}
|
|
if captureErr == nil {
|
|
completed.RecordingID = "recording-" + approved.SourceEventID
|
|
completed.UploadID = "upload-" + approved.SourceEventID
|
|
completed.WAV, completed.DurationMS = wav, durationMS
|
|
}
|
|
reportCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), r.ReportTimeout)
|
|
defer cancel()
|
|
return errors.Join(runErr, r.Delivery.Complete(reportCtx, completed))
|
|
}, nil
|
|
}
|
|
|
|
// verifyCallEvidence accepts only a current, live capture armed by the root
|
|
// nonproduction host script for this exact accepted call and trunk.
|
|
func verifyCallEvidence(root, id, trunk, target string) error {
|
|
if !filepath.IsAbs(root) || id == "" || id == "." || id == ".." || filepath.Base(id) != id || strings.ContainsAny(id, "/\\") {
|
|
return errors.New("real call evidence path or identity is invalid")
|
|
}
|
|
marker := filepath.Join(root, id+".active")
|
|
info, err := os.Lstat(marker)
|
|
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0640 || time.Since(info.ModTime()) > 10*time.Minute {
|
|
return errors.New("real call capture is not armed")
|
|
}
|
|
proof, err := os.ReadFile(marker)
|
|
if err != nil || len(proof) > 160 {
|
|
return errors.New("real call capture proof is unreadable")
|
|
}
|
|
fields := strings.Split(strings.TrimSuffix(string(proof), "\n"), "\t")
|
|
if len(fields) != 3 || fields[1] != trunk || fields[2] != target {
|
|
return errors.New("real call capture does not match signed call identity")
|
|
}
|
|
pid, err := strconv.Atoi(fields[0])
|
|
if err != nil || pid <= 1 {
|
|
return errors.New("real call capture PID invalid")
|
|
}
|
|
if err := syscall.Kill(pid, 0); err != nil && !errors.Is(err, syscall.EPERM) {
|
|
return errors.New("real call capture process is not running")
|
|
}
|
|
state, err := os.ReadFile(filepath.Join("/proc", fields[0], "stat"))
|
|
if err != nil {
|
|
return errors.New("real call capture process state is unavailable")
|
|
}
|
|
end := strings.LastIndex(string(state), ") ")
|
|
if end == -1 || len(state) <= end+2 || state[end+2] == 'Z' || state[end+2] == 'X' {
|
|
return errors.New("real call capture process has ended")
|
|
}
|
|
// /proc/<pid>/exe is not readable by the unprivileged Agent when
|
|
// the host evidence script starts tcpdump as root.
|
|
comm, err := os.ReadFile(filepath.Join("/proc", fields[0], "comm"))
|
|
if err != nil || strings.TrimSpace(string(comm)) != "tcpdump" {
|
|
return errors.New("real call capture process is not tcpdump")
|
|
}
|
|
args, err := os.ReadFile(filepath.Join("/proc", fields[0], "cmdline"))
|
|
if err != nil || !capturesCall(strings.Split(string(args), "\x00"), id) {
|
|
return errors.New("real call capture process does not record this call")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func capturesCall(args []string, id string) bool {
|
|
for i := 0; i+1 < len(args); i++ {
|
|
if args[i] == "-w" && filepath.IsAbs(args[i+1]) && filepath.Base(args[i+1]) == "capture.pcap" && filepath.Base(filepath.Dir(args[i+1])) == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (r *ApprovedRecordedRealCall) Run(ctx context.Context, approved ApprovedExecution) error {
|
|
if ctx == nil {
|
|
return errors.New("real call requires a context")
|
|
}
|
|
prepared, err := r.Prepare(approved)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return prepared(ctx)
|
|
}
|