package capture import ( "bufio" "context" "fmt" "io" "os/exec" "strings" "sync" "telephony-inspector/internal/logger" "telephony-inspector/internal/sip" ) // LocalCapturer handles SIP packet capture locally via tcpdump type LocalCapturer struct { cmd *exec.Cmd cancel context.CancelFunc running bool mu sync.Mutex currentNetInfo *NetInfo // Callbacks OnPacket func(*sip.Packet) OnError func(error) } // NewLocalCapturer creates a new local capturer func NewLocalCapturer() *LocalCapturer { return &LocalCapturer{} } // Start begins capturing SIP traffic locally func (c *LocalCapturer) 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() ctx, cancel := context.WithCancel(context.Background()) c.cancel = cancel // Build tcpdump command // -l: line buffered // -A: print packet payload in ASCII // -s 0: capture full packets // -nn: don't resolve hostnames or port names args := []string{"-l", "-nn", "-A", "-s", "0", "-i", iface, "port", fmt.Sprintf("%d", port)} c.cmd = exec.CommandContext(ctx, "tcpdump", args...) logger.Info("Starting local capture: tcpdump %v", args) stdout, err := c.cmd.StdoutPipe() if err != nil { c.mu.Lock() c.running = false c.mu.Unlock() return fmt.Errorf("failed to get stdout: %w", err) } stderr, err := c.cmd.StderrPipe() if err != nil { c.mu.Lock() c.running = false c.mu.Unlock() return fmt.Errorf("failed to get stderr: %w", err) } if err := c.cmd.Start(); err != nil { c.mu.Lock() c.running = false c.mu.Unlock() return fmt.Errorf("failed to start tcpdump: %w", err) } logger.Info("Local capture started successfully") // Process stdout in goroutine go c.processStream(stdout) // Log stderr go c.processErrors(stderr) return nil } // Stop stops the capture func (c *LocalCapturer) Stop() { c.mu.Lock() defer c.mu.Unlock() if !c.running { return } logger.Info("Stopping local capture") c.running = false if c.cancel != nil { c.cancel() c.cancel = nil } if c.cmd != nil && c.cmd.Process != nil { c.cmd.Process.Kill() c.cmd.Wait() } } // IsRunning returns whether capture is active func (c *LocalCapturer) IsRunning() bool { c.mu.Lock() defer c.mu.Unlock() return c.running } // Close cleans up resources func (c *LocalCapturer) Close() error { c.Stop() return nil } func (c *LocalCapturer) 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 { logger.Debug("SIP Start detected: %s", line) // Clean the line (remove prefix garbage) line = line[idx:] // If we were building a message, parse it with its OWN net info (which was latched previously) // Note: This edge case (buffer > 0 but new start) means previous message ended implicitly. // But wait, the msgNetInfo we just latched is for the NEW message. // The OLD message should have already been emitted or we are in a weird state. // Use the PREVIOUS msgNetInfo for the existing buffer if any. // Actually, single buffer logic is simple: emit what we have. if buffer.Len() > 0 { // We need to pass the net info that belongs to the buffered content. // But we just overwrote msgNetInfo. // Realistically, we should emit before latching new info. // But tcpdump header comes BEFORE the message. // So c.currentNetInfo is already the NEW info. // And the buffer contains the OLD message. // So when we started the OLD message, we latched OLD info. // We should persist that OLD info until emit. // This implies we need `pendingNetInfo` vs `currentNetInfo`. // Simplified approach: msgNetInfo stores the info for the message currently being built in buffer. // When we start a NEW message, the buffer contains the PREVIOUS message. // So we emit the buffer with the OLD msgNetInfo. // THEN we start the new message and update msgNetInfo to the NEW c.currentNetInfo. 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") } } // Parse remaining buffer if buffer.Len() > 0 { c.parseAndEmit(buffer.String(), msgNetInfo) } } func (c *LocalCapturer) processErrors(r io.Reader) { scanner := bufio.NewScanner(r) for scanner.Scan() { text := scanner.Text() // tcpdump prints "listening on..." to stderr, ignore it if strings.Contains(text, "listening on") { logger.Info("tcpdump: %s", text) continue } logger.Error("tcpdump stderr: %s", text) if c.OnError != nil { c.OnError(fmt.Errorf("tcpdump: %s", text)) } } } func (c *LocalCapturer) parseAndEmit(raw string, netInfo *NetInfo) { packet, err := sip.Parse(raw) if err != nil { // Suppress verbose error logging for partial packets unless debug // logger.Error("Error parsing SIP packet: %v", err) 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 } logger.Debug("Packet parsed: %s %s -> %s", packet.Method, packet.SourceIP, packet.DestIP) if c.OnPacket != nil { c.OnPacket(packet) } } }