package capture import ( "bufio" "fmt" "io" "strings" "sync" "time" "telephony-inspector/internal/sip" internalSSH "telephony-inspector/internal/ssh" ) // Capturer handles SIP packet capture via SSH type Capturer struct { sshClient *internalSSH.Client cleanup func() error running bool mu sync.Mutex currentNetInfo *NetInfo // Callbacks OnPacket func(*sip.Packet) OnError func(error) } // NewCapturer creates a new capturer with the given SSH config func NewCapturer(cfg internalSSH.Config) *Capturer { return &Capturer{ sshClient: internalSSH.NewClient(cfg), } } // Connect establishes the SSH connection func (c *Capturer) Connect() error { return c.sshClient.Connect() } // Close closes the connection and stops capture func (c *Capturer) Close() error { c.Stop() return c.sshClient.Close() } // Start begins capturing SIP traffic func (c *Capturer) Start(iface string, port int) error { c.mu.Lock() if c.running { c.mu.Unlock() return fmt.Errorf("capture already running") } c.running = true c.mu.Unlock() // Build tcpdump command // -l: line buffered for real-time output // -A: print packet payload in ASCII // -s 0: capture full packets // -nn: don't resolve hostnames or port names cmd := fmt.Sprintf("sudo tcpdump -l -nn -A -s 0 -i %s port %d 2>/dev/null", iface, port) stdout, stderr, cleanup, err := c.sshClient.StartCommand(cmd) if err != nil { c.mu.Lock() c.running = false c.mu.Unlock() return err } c.cleanup = cleanup // Process stdout in goroutine go c.processStream(stdout) // Log stderr go c.processErrors(stderr) return nil } // Stop stops the capture func (c *Capturer) Stop() { c.mu.Lock() defer c.mu.Unlock() if !c.running { return } c.running = false if c.cleanup != nil { c.cleanup() c.cleanup = nil } } // IsRunning returns whether capture is active func (c *Capturer) IsRunning() bool { c.mu.Lock() defer c.mu.Unlock() return c.running } func (c *Capturer) processStream(r io.Reader) { scanner := bufio.NewScanner(r) var buffer strings.Builder inSIPMessage := false var msgNetInfo *NetInfo for scanner.Scan() { c.mu.Lock() running := c.running c.mu.Unlock() if !running { break } line := scanner.Text() // Check for tcpdump header if netInfo := parseTcpdumpHeader(line); netInfo != nil { c.currentNetInfo = netInfo continue } // Detect start of SIP message if idx := findSIPStart(line); idx != -1 { // Latch the current network info for this message if c.currentNetInfo != nil { info := *c.currentNetInfo msgNetInfo = &info } // Clean the line (remove prefix garbage) line = line[idx:] // If we were building a message, parse it with its OWN net info if buffer.Len() > 0 { c.parseAndEmit(buffer.String(), msgNetInfo) buffer.Reset() } // NOW update msgNetInfo for the new message if c.currentNetInfo != nil { info := *c.currentNetInfo msgNetInfo = &info } else { msgNetInfo = nil } inSIPMessage = true } if inSIPMessage { buffer.WriteString(line) buffer.WriteString("\r\n") // Detect end of SIP message (double CRLF or content complete) // This is simplified - real implementation would track Content-Length } } // Parse remaining buffer if buffer.Len() > 0 { c.parseAndEmit(buffer.String(), msgNetInfo) } } func (c *Capturer) processErrors(r io.Reader) { scanner := bufio.NewScanner(r) for scanner.Scan() { if c.OnError != nil { c.OnError(fmt.Errorf("tcpdump: %s", scanner.Text())) } } } func (c *Capturer) parseAndEmit(raw string, netInfo *NetInfo) { packet, err := sip.Parse(raw) if err != nil { if c.OnError != nil { c.OnError(err) } return } if packet != nil { // Attach network info if available if netInfo != nil { packet.Timestamp = netInfo.Timestamp packet.SourceIP = netInfo.SourceIP packet.SourcePort = netInfo.SourcePort packet.DestIP = netInfo.DestIP packet.DestPort = netInfo.DestPort } if c.OnPacket != nil { c.OnPacket(packet) } } } // findSIPStart returns the index of the start of a SIP message, or -1 if not found func findSIPStart(line string) int { sipMethods := []string{"INVITE", "ACK", "BYE", "CANCEL", "REGISTER", "OPTIONS", "PRACK", "SUBSCRIBE", "NOTIFY", "PUBLISH", "INFO", "REFER", "MESSAGE", "UPDATE"} // Check for Response "SIP/2.0" if idx := strings.Index(line, "SIP/2.0 "); idx != -1 { // Verify it's not part of a header like Via or Record-Route // We look at what comes before. If it's the start of the line or preceded by garbage (nulls etc), it's likely a start. // If it is preceded by "Via: " or "Route: ", it is a header. prefix := strings.ToUpper(line[:idx]) if !strings.HasSuffix(prefix, "VIA: ") && !strings.HasSuffix(prefix, "ROUTE: ") && !strings.HasSuffix(prefix, "VIA:") { // Handle varying spacing return idx } } // Check for Request "METHOD " for _, m := range sipMethods { target := m + " " if idx := strings.Index(line, target); idx != -1 { // Verify it's not CSeq, Allow, Rack, etc. prefix := strings.ToUpper(line[:idx]) if !strings.HasSuffix(prefix, "CSEQ: ") && !strings.HasSuffix(prefix, "ALLOW: ") && !strings.HasSuffix(prefix, "RACK: ") && !strings.HasSuffix(prefix, "SUPPORTED: ") { return idx } } } return -1 } // NetInfo stores network layer information from tcpdump headers type NetInfo struct { Timestamp time.Time SourceIP string SourcePort int DestIP string DestPort int } func parseTcpdumpHeader(line string) *NetInfo { // Robust parsing // Look for " IP " or " IP6 " parts := strings.Fields(line) // Need at least: Time IP Src > Dst: if len(parts) < 5 { return nil } // Find the direction arrow ">" arrowIdx := -1 for i, p := range parts { if p == ">" { arrowIdx = i break } } if arrowIdx == -1 || arrowIdx < 2 { return nil } // Verify "IP" or "IP6" exists before the source (usually parts[1] or parts[2]) // Example: 15:35... IP src > dst: ... // Example: 15:35... IP6 src > dst: ... // Example (verbose): 15:35... IP (tos 0x0...) src > dst: ... // We'll trust the arrow for now, but double check IPs. // Assume SRC is before arrow, DST is after srcStr := parts[arrowIdx-1] dstStr := parts[arrowIdx+1] // Find timestamp (usually first field) now := time.Now() t, err := time.Parse("15:04:05.000000", parts[0]) if err == nil { t = time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), now.Location()) } else { // Try without microseconds t, err = time.Parse("15:04:05", parts[0]) if err == nil { t = time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), t.Second(), 0, now.Location()) } else { t = now } } // Helper to extract IP and Port parseIPPort := func(s string) (string, int) { lastDot := strings.LastIndex(s, ".") if lastDot == -1 { // IPv6 might use different separation or just be IP return s, 0 } ip := s[:lastDot] portStr := s[lastDot+1:] // Remove trailing colon if present (dest) portStr = strings.TrimSuffix(portStr, ":") // Remove trailing comma if present portStr = strings.TrimSuffix(portStr, ",") var port int fmt.Sscanf(portStr, "%d", &port) return ip, port } srcIP, srcPort := parseIPPort(srcStr) dstIP, dstPort := parseIPPort(dstStr) return &NetInfo{ Timestamp: t, SourceIP: srcIP, SourcePort: srcPort, DestIP: dstIP, DestPort: dstPort, } }