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 } if _, err := privateConfigInfo(filepath.Join(c.ConfigDir, "pjsip.conf"), os.Geteuid()); err != nil { return nil, fmt.Errorf("configuration preflight: %w", 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 }