Files
go-sip/internal/sipcall/run.go
T

205 lines
5.9 KiB
Go

package sipcall
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"git.ipao.vip/rogee/go-sip/internal/asterisk"
"os"
"path/filepath"
"time"
)
type Report struct {
ID string `json:"id"`
Directory string `json:"directory"`
Line string `json:"line"`
Server string `json:"server"`
CallerID string `json:"caller_id"`
Callee string `json:"callee"`
DialedCallee string `json:"dialed_callee"`
CallID string `json:"sip_call_id,omitempty"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
DialBoundAt *time.Time `json:"pre_dial_bound_at,omitempty"`
AnsweredAt *time.Time `json:"answered_at,omitempty"`
EndedAt *time.Time `json:"end_confirmed_at,omitempty"`
FinishedAt time.Time `json:"finished_at"`
EndConfirmed bool `json:"end_confirmed"`
Debug bool `json:"debug"`
Failure string `json:"failure,omitempty"`
Evidence *Evidence `json:"evidence,omitempty"`
}
type testCall interface {
Close() error
EndConfirmed() bool
Done() context.Context
}
type host interface {
CheckIdle(context.Context) error
Apply(context.Context, Config) (func() error, error)
Dial(context.Context, asterisk.NativeDial) (testCall, error)
Close() error
}
type captureSession interface {
Stop(context.Context, string) (Evidence, error)
Check() error
}
type captureStarter func(context.Context, Config, string) (captureSession, error)
// Run invokes exactly one call. Authorization is the operator's explicit
// command; this standalone tool never discovers, queues or retries tasks.
func Run(ctx context.Context, c Config, number string, debug bool) (*Report, error) {
if _, err := c.Route(number); err != nil {
return nil, err
}
h := &nativeHost{config: c}
return run(ctx, c, number, debug, h, startCapture)
}
func run(ctx context.Context, c Config, number string, debug bool, h host, start captureStarter) (r *Report, err error) {
route, err := c.Route(number)
if err != nil {
return nil, err
}
random := make([]byte, 12)
if _, err = rand.Read(random); err != nil {
return nil, err
}
id := "siptest-" + hex.EncodeToString(random)
if err = ensureOutput(c.OutputDir); err != nil {
return nil, err
}
dir := filepath.Join(c.OutputDir, id)
if err = os.Mkdir(dir, 0700); err != nil {
return nil, err
}
r = &Report{ID: id, Directory: dir, Line: c.ID, Server: fmt.Sprintf("%s:%d", c.Server, c.Port), CallerID: c.CallerID, Callee: number, DialedCallee: route, Status: "not_dialed", StartedAt: time.Now().UTC(), Debug: debug}
var restore func() error
var capture captureSession
safeToRestore := true
defer func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if capture != nil {
e, capErr := capture.Stop(cleanupCtx, r.CallID)
r.Evidence = &e
err = errors.Join(err, capErr)
if r.AnsweredAt != nil && (e.FinalINVITECode < 200 || e.FinalINVITECode >= 300) {
err = errors.Join(err, errors.New("debug evidence has no matching original INVITE 2xx final response for the observed native answer"))
}
}
if restore != nil && safeToRestore {
err = errors.Join(err, restore())
}
err = errors.Join(err, h.Close())
r.FinishedAt = time.Now().UTC()
if err != nil {
r.Failure = err.Error()
if r.Status == "connected" {
r.Status = "failed"
}
}
data, jsonErr := json.MarshalIndent(r, "", " ")
if jsonErr == nil {
jsonErr = os.WriteFile(filepath.Join(dir, "result.json"), append(data, '\n'), 0600)
}
err = errors.Join(err, jsonErr)
}()
if err = ctx.Err(); err != nil {
return r, err
}
if err = h.CheckIdle(ctx); err != nil {
return r, fmt.Errorf("preflight: %w", err)
}
if debug {
if c.Interface == "" {
return r, errors.New("DEBUG_INTERFACE is required with --debug/-D")
}
capture, err = start(ctx, c, dir)
if err != nil {
return r, fmt.Errorf("capture preflight: %w", err)
}
}
c.resultDir = dir
restore, err = h.Apply(ctx, c)
if err != nil {
return r, fmt.Errorf("apply test line: %w", err)
}
if err = ctx.Err(); err != nil {
return r, err
}
if err = h.CheckIdle(ctx); err != nil {
return r, fmt.Errorf("pre-dial idle check: %w", err)
}
req := asterisk.NativeDial{ExecutionID: id, TrunkID: c.Endpoint(), DialedCallee: route, CallerID: c.CallerID, AnswerTimeout: c.Ring, BindSIPCall: func(_ string, callID string) error {
if callID == "" {
return errors.New("missing native SIP Call-ID")
}
if capture != nil {
if e := capture.Check(); e != nil {
return e
}
}
r.CallID = callID
now := time.Now().UTC()
r.DialBoundAt = &now
return nil
}}
r.Status = "not_connected"
call, callErr := h.Dial(ctx, req)
if callErr != nil {
var failure *asterisk.NativeCallFailure
if errors.As(callErr, &failure) && failure.ConfirmedEnd {
r.EndConfirmed = true
observed := time.Now().UTC()
r.EndedAt = &observed
} else if asterisk.NativeCallNeverSubmitted(callErr) {
r.Status = "not_dialed"
} else {
safeToRestore = false
r.Status = "unknown"
}
return r, callErr
}
now := time.Now().UTC()
r.AnsweredAt = &now
timer := time.NewTimer(c.Hold)
defer timer.Stop()
select {
case <-timer.C:
case <-call.Done().Done():
case <-ctx.Done():
err = ctx.Err()
}
err = errors.Join(err, call.Close())
r.EndConfirmed = call.EndConfirmed()
if !r.EndConfirmed {
safeToRestore = false
r.Status = "unknown"
return r, errors.Join(err, errors.New("call termination unconfirmed; test configuration retained for manual diagnosis; do not retry"))
}
observed := time.Now().UTC()
r.EndedAt = &observed
r.Status = "connected"
return r, err
}
func ensureOutput(path string) error {
if err := os.MkdirAll(path, 0700); err != nil {
return err
}
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0700 {
return errors.New("OUTPUT_DIR must be a private real directory (0700)")
}
return nil
}