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

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)
}