2026-01-19 13:46:27 +01:00
|
|
|
package capture
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bufio"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
2026-01-19 15:40:11 +01:00
|
|
|
"time"
|
2026-01-19 13:46:27 +01:00
|
|
|
|
|
|
|
|
"telephony-inspector/internal/sip"
|
|
|
|
|
internalSSH "telephony-inspector/internal/ssh"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Capturer handles SIP packet capture via SSH
|
|
|
|
|
type Capturer struct {
|
2026-01-19 15:40:11 +01:00
|
|
|
sshClient *internalSSH.Client
|
|
|
|
|
cleanup func() error
|
|
|
|
|
running bool
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
currentNetInfo *NetInfo
|
2026-01-19 13:46:27 +01:00
|
|
|
|
|
|
|
|
// 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
|
2026-01-19 15:26:01 +01:00
|
|
|
// -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)
|
2026-01-19 13:46:27 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
for scanner.Scan() {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
running := c.running
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
if !running {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
line := scanner.Text()
|
|
|
|
|
|
2026-01-19 15:40:11 +01:00
|
|
|
// Check for tcpdump header
|
|
|
|
|
if netInfo := parseTcpdumpHeader(line); netInfo != nil {
|
|
|
|
|
c.currentNetInfo = netInfo
|
|
|
|
|
// If we were parsing a message, this header likely means the previous message ended (or it's just info)
|
|
|
|
|
// But tcpdump prints header BEFORE payload.
|
|
|
|
|
// Proceed to next line
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 13:46:27 +01:00
|
|
|
// Detect start of SIP message
|
2026-01-19 15:35:13 +01:00
|
|
|
if idx := findSIPStart(line); idx != -1 {
|
|
|
|
|
// Clean the line (remove prefix garbage)
|
|
|
|
|
line = line[idx:]
|
|
|
|
|
|
2026-01-19 13:46:27 +01:00
|
|
|
// 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")
|
|
|
|
|
|
|
|
|
|
// 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())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
packet, err := sip.Parse(raw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if c.OnError != nil {
|
|
|
|
|
c.OnError(err)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-19 15:40:11 +01:00
|
|
|
if packet != nil {
|
|
|
|
|
// Attach network info if available
|
|
|
|
|
if c.currentNetInfo != nil {
|
|
|
|
|
// Use timestamp from packet header if possible, or keep what parser found?
|
|
|
|
|
// Parser doesn't find time.
|
|
|
|
|
packet.Timestamp = c.currentNetInfo.Timestamp
|
|
|
|
|
packet.SourceIP = c.currentNetInfo.SourceIP
|
|
|
|
|
packet.SourcePort = c.currentNetInfo.SourcePort
|
|
|
|
|
packet.DestIP = c.currentNetInfo.DestIP
|
|
|
|
|
packet.DestPort = c.currentNetInfo.DestPort
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if c.OnPacket != nil {
|
|
|
|
|
c.OnPacket(packet)
|
|
|
|
|
}
|
2026-01-19 13:46:27 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 15:35:13 +01:00
|
|
|
// findSIPStart returns the index of the start of a SIP message, or -1 if not found
|
|
|
|
|
func findSIPStart(line string) int {
|
2026-01-19 13:46:27 +01:00
|
|
|
sipMethods := []string{"INVITE", "ACK", "BYE", "CANCEL", "REGISTER", "OPTIONS", "PRACK", "SUBSCRIBE", "NOTIFY", "PUBLISH", "INFO", "REFER", "MESSAGE", "UPDATE"}
|
|
|
|
|
|
2026-01-19 15:35:13 +01:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-01-19 13:46:27 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-19 15:35:13 +01:00
|
|
|
// Check for Request "METHOD "
|
2026-01-19 13:46:27 +01:00
|
|
|
for _, m := range sipMethods {
|
2026-01-19 15:35:13 +01:00
|
|
|
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
|
|
|
|
|
}
|
2026-01-19 13:46:27 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 15:35:13 +01:00
|
|
|
return -1
|
2026-01-19 13:46:27 +01:00
|
|
|
}
|
2026-01-19 15:40:11 +01:00
|
|
|
|
|
|
|
|
// 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 {
|
|
|
|
|
// Format: 15:35:10.430328 IP 192.168.0.164.51416 > 192.168.0.158.5060: ...
|
|
|
|
|
parts := strings.Fields(line)
|
|
|
|
|
if len(parts) < 5 || parts[1] != "IP" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse Timestamp
|
|
|
|
|
// Using generic date + log time
|
|
|
|
|
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 {
|
|
|
|
|
t = now
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper to extract IP and Port
|
|
|
|
|
parseIPPort := func(s string) (string, int) {
|
|
|
|
|
lastDot := strings.LastIndex(s, ".")
|
|
|
|
|
if lastDot == -1 {
|
|
|
|
|
return s, 0
|
|
|
|
|
}
|
|
|
|
|
ip := s[:lastDot]
|
|
|
|
|
portStr := s[lastDot+1:]
|
|
|
|
|
// Remove trailing colon if present (dest)
|
|
|
|
|
portStr = strings.TrimSuffix(portStr, ":")
|
|
|
|
|
|
|
|
|
|
var port int
|
|
|
|
|
fmt.Sscanf(portStr, "%d", &port)
|
|
|
|
|
return ip, port
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
srcIP, srcPort := parseIPPort(parts[2])
|
|
|
|
|
dstIP, dstPort := parseIPPort(parts[4])
|
|
|
|
|
|
|
|
|
|
return &NetInfo{
|
|
|
|
|
Timestamp: t,
|
|
|
|
|
SourceIP: srcIP,
|
|
|
|
|
SourcePort: srcPort,
|
|
|
|
|
DestIP: dstIP,
|
|
|
|
|
DestPort: dstPort,
|
|
|
|
|
}
|
|
|
|
|
}
|