package sipcall import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "syscall" "time" ) type Evidence struct { PCAP string `json:"pcap"` PCAPSHA256 string `json:"pcap_sha256"` SIPTimeline string `json:"sip_timeline,omitempty"` Packets int `json:"sip_packets"` FinalINVITECode int `json:"final_invite_code,omitempty"` FinalINVITEStatus string `json:"final_invite_status,omitempty"` RTPFromServer int `json:"rtp_from_server"` RTPToServer int `json:"rtp_to_server"` RTPBytes int `json:"rtp_bytes"` MediaScope string `json:"media_scope"` RTPStatus string `json:"rtp_status"` } type packetCapture struct { cmd *exec.Cmd done chan error log *os.File dir, tshark string config Config } var sipCallID = regexp.MustCompile(`^[A-Za-z0-9_.@:+-]{1,256}$`) func startCapture(ctx context.Context, c Config, dir string) (captureSession, error) { tcpdump, err := exec.LookPath("tcpdump") if err != nil { return nil, errors.New("--debug requires tcpdump; no call was dialed") } tshark, err := exec.LookPath("tshark") if err != nil { return nil, errors.New("--debug requires tshark for call-linked evidence; no call was dialed") } log, err := os.OpenFile(filepath.Join(dir, "tcpdump.log"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) if err != nil { return nil, err } // The narrow filter avoids copying unrelated UDP traffic. RTP sent from a // different provider media IP is explicitly outside this evidence's scope. cmd := exec.Command(tcpdump, "-i", c.Interface, "-nn", "-U", "-s", "0", "-w", filepath.Join(dir, "capture.pcap"), "udp and host "+c.Server) cmd.Stdout = log cmd.Stderr = log if err = cmd.Start(); err != nil { return nil, errors.Join(fmt.Errorf("start tcpdump: %w", err), log.Close()) } cap := &packetCapture{cmd: cmd, done: make(chan error, 1), log: log, dir: dir, tshark: tshark, config: c} go func() { cap.done <- cmd.Wait(); close(cap.done) }() deadline := time.NewTimer(5 * time.Second) defer deadline.Stop() tick := time.NewTicker(25 * time.Millisecond) defer tick.Stop() for { select { case e := <-cap.done: return nil, errors.Join(fmt.Errorf("tcpdump stopped before readiness; inspect private %s; run sip-call as configuration owner and grant capture permission only to tcpdump (do not sudo sip-call)", filepath.Join(dir, "tcpdump.log")), e, log.Close()) case <-ctx.Done(): _, stopErr := cap.Stop(context.Background(), "") return nil, errors.Join(ctx.Err(), stopErr) case <-deadline.C: _, stopErr := cap.Stop(context.Background(), "") return nil, errors.Join(errors.New("tcpdump readiness timed out; no call was dialed"), stopErr) case <-tick.C: text, e := os.ReadFile(filepath.Join(dir, "tcpdump.log")) if e != nil { continue } info, e := os.Stat(filepath.Join(dir, "capture.pcap")) if e == nil && info.Size() >= 24 && bytes.Contains(text, []byte("listening on ")) { if e = os.Chmod(filepath.Join(dir, "capture.pcap"), 0600); e != nil { _, stopErr := cap.Stop(context.Background(), "") return nil, errors.Join(e, stopErr) } return cap, nil } } } } func (c *packetCapture) Check() error { select { case <-c.done: return errors.New("tcpdump is no longer active; no new Dial is permitted") default: return nil } } func (c *packetCapture) Stop(ctx context.Context, callID string) (e Evidence, err error) { e = Evidence{PCAP: filepath.Join(c.dir, "capture.pcap"), MediaScope: "UDP to/from configured SIP server IP only; different RTP media IPs are not covered", RTPStatus: "not_observed_within_capture_scope"} select { case waitErr := <-c.done: err = errors.Join(errors.New("tcpdump exited before capture finalization"), waitErr) default: signalErr := c.cmd.Process.Signal(syscall.SIGINT) if signalErr != nil { err = errors.Join(err, signalErr) } timeout := time.NewTimer(5 * time.Second) defer timeout.Stop() select { case waitErr := <-c.done: err = errors.Join(err, waitErr) case <-ctx.Done(): err = errors.Join(err, ctx.Err(), c.cmd.Process.Kill(), <-c.done) case <-timeout.C: err = errors.Join(err, errors.New("tcpdump did not stop within 5 seconds"), c.cmd.Process.Kill(), <-c.done) } } err = errors.Join(err, c.log.Close()) file, readErr := os.Open(e.PCAP) if readErr == nil { hash := sha256.New() _, readErr = io.Copy(hash, file) readErr = errors.Join(readErr, file.Close()) if readErr == nil { e.PCAPSHA256 = hex.EncodeToString(hash.Sum(nil)) } } err = errors.Join(err, readErr) if callID == "" { return e, err } if !sipCallID.MatchString(callID) { return e, errors.Join(err, errors.New("native SIP Call-ID cannot be safely correlated")) } args := []string{"-n", "-r", e.PCAP, "-d", fmt.Sprintf("udp.port==%d,sip", c.config.Port), "-Y", `sip.Call-ID == "` + callID + `"`, "-T", "fields", "-E", "separator=/t", "-E", "occurrence=f"} for _, field := range []string{"frame.number", "frame.time_epoch", "ip.src", "ip.dst", "sip.Request-Line", "sip.Status-Code", "sip.CSeq.method", "sip.CSeq.seq", "sip.Via.branch", "sip.Status-Line", "sdp.connection_info.address", "sdp.media.media", "sdp.media.port"} { args = append(args, "-e", field) } timeline, decodeErr := runTshark(ctx, c.tshark, args...) err = errors.Join(err, decodeErr) e.SIPTimeline = filepath.Join(c.dir, "sip.tsv") err = errors.Join(err, os.WriteFile(e.SIPTimeline, timeline, 0600)) if decodeErr != nil { return e, err } parsed, ports, parseErr := parseTimeline(timeline) e.Packets = parsed.Packets e.FinalINVITECode = parsed.FinalINVITECode e.FinalINVITEStatus = parsed.FinalINVITEStatus err = errors.Join(err, parseErr) if len(ports) == 0 { return e, err } filters := []string{} for _, port := range ports { filters = append(filters, fmt.Sprintf("udp.port == %d", port)) } rtpArgs := []string{"-n", "-r", e.PCAP, "-d", fmt.Sprintf("udp.port==%d,sip", c.config.Port)} for _, port := range ports { rtpArgs = append(rtpArgs, "-d", fmt.Sprintf("udp.port==%d,rtp", port)) } rtpArgs = append(rtpArgs, "-Y", "rtp && ("+strings.Join(filters, " || ")+")", "-T", "fields", "-E", "separator=/t", "-e", "frame.len", "-e", "ip.src", "-e", "ip.dst") rtp, rtpErr := runTshark(ctx, c.tshark, rtpArgs...) err = errors.Join(err, rtpErr, os.WriteFile(filepath.Join(c.dir, "rtp.tsv"), rtp, 0600)) for _, line := range strings.Split(strings.TrimSpace(string(rtp)), "\n") { if line == "" { continue } fields := strings.Split(line, "\t") if len(fields) != 3 { return e, errors.Join(err, errors.New("unexpected tshark RTP fields")) } n, convErr := strconv.Atoi(fields[0]) if convErr != nil { return e, errors.Join(err, convErr) } e.RTPBytes += n if fields[1] == c.config.Server { e.RTPFromServer++ } if fields[2] == c.config.Server { e.RTPToServer++ } } if e.RTPFromServer+e.RTPToServer > 0 { e.RTPStatus = "observed_packets_only; no audio intelligibility or AI test" } return e, err } func runTshark(ctx context.Context, binary string, args ...string) ([]byte, error) { bounded, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() cmd := exec.CommandContext(bounded, binary, args...) var diagnostic bytes.Buffer cmd.Stderr = &diagnostic out, err := cmd.Output() if err != nil { for i, arg := range args { if arg == "-r" && i+1 < len(args) { err = errors.Join(err, os.WriteFile(filepath.Join(filepath.Dir(args[i+1]), "tshark.log"), diagnostic.Bytes(), 0600)) break } } return nil, fmt.Errorf("tshark evidence extraction failed; inspect private tshark.log: %w", err) } return out, nil } // parseTimeline consumes decoded tshark fields, not a hand-written SIP parser. // Final responses are tied to the original INVITE's CSeq and Via transaction. func parseTimeline(data []byte) (Evidence, []int, error) { e := Evidence{} var rows [][]string var seq, branch string ports := []int{} seen := map[int]bool{} for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") { if line == "" { continue } fields := strings.Split(line, "\t") if len(fields) != 13 { return e, nil, errors.New("unexpected tshark SIP fields") } rows = append(rows, fields) e.Packets++ if seq == "" && strings.HasPrefix(fields[4], "INVITE ") { seq, branch = fields[7], fields[8] } if fields[11] == "audio" { p, err := strconv.Atoi(fields[12]) if err == nil && p > 0 && p <= 65535 && !seen[p] { seen[p] = true ports = append(ports, p) } } } if seq == "" || branch == "" { return e, ports, errors.New("capture contains no identifiable original INVITE for this SIP Call-ID") } for _, fields := range rows { if fields[6] != "INVITE" || fields[7] != seq || fields[8] != branch { continue } code, convErr := strconv.Atoi(fields[5]) if convErr != nil || code < 200 { continue } if e.FinalINVITECode != 0 && e.FinalINVITECode != code { return e, ports, errors.New("conflicting final responses for original INVITE transaction") } e.FinalINVITECode = code e.FinalINVITEStatus = fields[9] } return e, ports, nil }