feat: Add pcap import, file browser, logging, local capture, and stable call ordering
This commit is contained in:
180
internal/capture/local.go
Normal file
180
internal/capture/local.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"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
|
||||
|
||||
// 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
|
||||
args := []string{"-l", "-A", "-s", "0", "-i", iface, "port", fmt.Sprintf("%d", port)}
|
||||
c.cmd = exec.CommandContext(ctx, "tcpdump", 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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
|
||||
|
||||
for scanner.Scan() {
|
||||
c.mu.Lock()
|
||||
running := c.running
|
||||
c.mu.Unlock()
|
||||
if !running {
|
||||
break
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
|
||||
// Detect start of SIP message
|
||||
if isSIPStart(line) {
|
||||
// If we were building a message, parse it
|
||||
if buffer.Len() > 0 {
|
||||
c.parseAndEmit(buffer.String())
|
||||
buffer.Reset()
|
||||
}
|
||||
inSIPMessage = true
|
||||
}
|
||||
|
||||
if inSIPMessage {
|
||||
buffer.WriteString(line)
|
||||
buffer.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Parse remaining buffer
|
||||
if buffer.Len() > 0 {
|
||||
c.parseAndEmit(buffer.String())
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
continue
|
||||
}
|
||||
if c.OnError != nil {
|
||||
c.OnError(fmt.Errorf("tcpdump: %s", text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LocalCapturer) parseAndEmit(raw string) {
|
||||
packet, err := sip.Parse(raw)
|
||||
if err != nil {
|
||||
if c.OnError != nil {
|
||||
c.OnError(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if packet != nil && c.OnPacket != nil {
|
||||
c.OnPacket(packet)
|
||||
}
|
||||
}
|
||||
243
internal/capture/pcap_reader.go
Normal file
243
internal/capture/pcap_reader.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"telephony-inspector/internal/logger"
|
||||
"telephony-inspector/internal/sip"
|
||||
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/google/gopacket/pcap"
|
||||
)
|
||||
|
||||
// PcapReader reads and parses pcap files
|
||||
type PcapReader struct {
|
||||
path string
|
||||
handle *pcap.Handle
|
||||
packets []*sip.Packet
|
||||
}
|
||||
|
||||
// NewPcapReader creates a new pcap reader
|
||||
func NewPcapReader(path string) *PcapReader {
|
||||
logger.Info("PcapReader: Creating reader for %s", path)
|
||||
return &PcapReader{
|
||||
path: path,
|
||||
packets: make([]*sip.Packet, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadAll reads all SIP packets from the pcap file
|
||||
func (r *PcapReader) ReadAll() ([]*sip.Packet, error) {
|
||||
logger.Info("PcapReader: Opening file %s", r.path)
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(r.path); os.IsNotExist(err) {
|
||||
logger.Error("PcapReader: File does not exist: %s", r.path)
|
||||
return nil, fmt.Errorf("file does not exist: %s", r.path)
|
||||
}
|
||||
|
||||
handle, err := pcap.OpenOffline(r.path)
|
||||
if err != nil {
|
||||
logger.Error("PcapReader: Failed to open pcap: %v", err)
|
||||
return nil, fmt.Errorf("failed to open pcap: %w", err)
|
||||
}
|
||||
defer handle.Close()
|
||||
|
||||
logger.Info("PcapReader: File opened successfully, link type: %v", handle.LinkType())
|
||||
|
||||
// Try setting BPF filter (optional)
|
||||
if err := handle.SetBPFFilter("port 5060 or port 5061"); err != nil {
|
||||
logger.Warn("PcapReader: Could not set BPF filter: %v (continuing without filter)", err)
|
||||
} else {
|
||||
logger.Debug("PcapReader: BPF filter set for SIP ports")
|
||||
}
|
||||
|
||||
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
|
||||
|
||||
totalPackets := 0
|
||||
sipPackets := 0
|
||||
|
||||
for packet := range packetSource.Packets() {
|
||||
totalPackets++
|
||||
logger.Debug("PcapReader: Processing packet %d, layers: %v", totalPackets, packet.Layers())
|
||||
|
||||
sipPacket := r.extractSIPPacket(packet, totalPackets)
|
||||
if sipPacket != nil {
|
||||
sipPackets++
|
||||
r.packets = append(r.packets, sipPacket)
|
||||
logger.Info("PcapReader: Found SIP packet %d: %s %s", sipPackets,
|
||||
func() string {
|
||||
if sipPacket.IsRequest {
|
||||
return string(sipPacket.Method)
|
||||
} else {
|
||||
return fmt.Sprintf("%d", sipPacket.StatusCode)
|
||||
}
|
||||
}(),
|
||||
sipPacket.CallID)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("PcapReader: Finished reading. Total packets: %d, SIP packets: %d", totalPackets, sipPackets)
|
||||
return r.packets, nil
|
||||
}
|
||||
|
||||
// extractSIPPacket extracts SIP data from a gopacket
|
||||
func (r *PcapReader) extractSIPPacket(packet gopacket.Packet, packetNum int) *sip.Packet {
|
||||
// Get network layer for IPs
|
||||
var srcIP, dstIP string
|
||||
var srcPort, dstPort int
|
||||
var payload []byte
|
||||
|
||||
if ipLayer := packet.Layer(layers.LayerTypeIPv4); ipLayer != nil {
|
||||
ip := ipLayer.(*layers.IPv4)
|
||||
srcIP = ip.SrcIP.String()
|
||||
dstIP = ip.DstIP.String()
|
||||
logger.Debug("PcapReader: Packet %d IPv4 %s -> %s", packetNum, srcIP, dstIP)
|
||||
} else if ipLayer := packet.Layer(layers.LayerTypeIPv6); ipLayer != nil {
|
||||
ip := ipLayer.(*layers.IPv6)
|
||||
srcIP = ip.SrcIP.String()
|
||||
dstIP = ip.DstIP.String()
|
||||
logger.Debug("PcapReader: Packet %d IPv6 %s -> %s", packetNum, srcIP, dstIP)
|
||||
} else {
|
||||
logger.Debug("PcapReader: Packet %d has no IP layer", packetNum)
|
||||
}
|
||||
|
||||
// Get transport layer for ports AND payload
|
||||
if udpLayer := packet.Layer(layers.LayerTypeUDP); udpLayer != nil {
|
||||
udp := udpLayer.(*layers.UDP)
|
||||
srcPort = int(udp.SrcPort)
|
||||
dstPort = int(udp.DstPort)
|
||||
payload = udp.Payload
|
||||
logger.Debug("PcapReader: Packet %d UDP %d -> %d, payload len: %d", packetNum, srcPort, dstPort, len(payload))
|
||||
} else if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil {
|
||||
tcp := tcpLayer.(*layers.TCP)
|
||||
srcPort = int(tcp.SrcPort)
|
||||
dstPort = int(tcp.DstPort)
|
||||
payload = tcp.Payload
|
||||
logger.Debug("PcapReader: Packet %d TCP %d -> %d, payload len: %d", packetNum, srcPort, dstPort, len(payload))
|
||||
} else {
|
||||
logger.Debug("PcapReader: Packet %d has no TCP/UDP layer", packetNum)
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(payload) == 0 {
|
||||
logger.Debug("PcapReader: Packet %d has empty payload", packetNum)
|
||||
return nil
|
||||
}
|
||||
|
||||
payloadStr := string(payload)
|
||||
|
||||
// Log first 200 chars to see full SIP headers
|
||||
preview := payloadStr
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200]
|
||||
}
|
||||
logger.Debug("PcapReader: Packet %d full payload preview: %q", packetNum, preview)
|
||||
|
||||
// Check if it looks like SIP
|
||||
if !isSIPPayload(payloadStr) {
|
||||
logger.Debug("PcapReader: Packet %d is not SIP", packetNum)
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("PcapReader: Packet %d detected as SIP, parsing...", packetNum)
|
||||
|
||||
// Parse the SIP message
|
||||
sipPacket, err := sip.Parse(payloadStr)
|
||||
if err != nil {
|
||||
logger.Warn("PcapReader: Packet %d SIP parse error: %v", packetNum, err)
|
||||
return nil
|
||||
}
|
||||
if sipPacket == nil {
|
||||
logger.Warn("PcapReader: Packet %d SIP parse returned nil", packetNum)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set network info
|
||||
sipPacket.SourceIP = srcIP
|
||||
sipPacket.SourcePort = srcPort
|
||||
sipPacket.DestIP = dstIP
|
||||
sipPacket.DestPort = dstPort
|
||||
|
||||
return sipPacket
|
||||
}
|
||||
|
||||
// isSIPPayload checks if payload looks like a SIP message
|
||||
func isSIPPayload(payload string) bool {
|
||||
if len(payload) < 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for SIP request methods
|
||||
methods := []string{"INVITE ", "ACK ", "BYE ", "CANCEL ", "REGISTER ", "OPTIONS ",
|
||||
"PRACK ", "SUBSCRIBE ", "NOTIFY ", "PUBLISH ", "INFO ", "REFER ", "MESSAGE ", "UPDATE "}
|
||||
for _, m := range methods {
|
||||
if len(payload) >= len(m) && payload[:len(m)] == m {
|
||||
logger.Debug("isSIPPayload: Detected SIP method: %s", m[:len(m)-1])
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for SIP response
|
||||
if len(payload) >= 7 && payload[:7] == "SIP/2.0" {
|
||||
logger.Debug("isSIPPayload: Detected SIP response")
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if this looks like an SDP body (might be reassembled SIP)
|
||||
if len(payload) >= 4 && payload[:4] == "v=0\r" {
|
||||
logger.Debug("isSIPPayload: Detected SDP-only payload (no SIP headers)")
|
||||
// This is SDP without SIP headers - likely a reassembly issue
|
||||
// Log first 50 chars for debugging
|
||||
preview := payload
|
||||
if len(preview) > 50 {
|
||||
preview = preview[:50]
|
||||
}
|
||||
logger.Debug("isSIPPayload: SDP payload: %q", preview)
|
||||
return false
|
||||
}
|
||||
|
||||
// Log what the payload starts with for debugging
|
||||
preview := payload
|
||||
if len(preview) > 20 {
|
||||
preview = preview[:20]
|
||||
}
|
||||
logger.Debug("isSIPPayload: Unrecognized payload start: %q", preview)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetPacketCount returns number of packets read
|
||||
func (r *PcapReader) GetPacketCount() int {
|
||||
return len(r.packets)
|
||||
}
|
||||
|
||||
// Close closes the reader
|
||||
func (r *PcapReader) Close() error {
|
||||
if r.handle != nil {
|
||||
r.handle.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPcapFiles lists .pcap files in a directory
|
||||
func ListPcapFiles(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if len(name) > 5 && (name[len(name)-5:] == ".pcap" || name[len(name)-7:] == ".pcapng") {
|
||||
files = append(files, name)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
Reference in New Issue
Block a user