205 lines
5.9 KiB
Go
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
|
|
}
|