Files
telephony-inspector/internal/capture/capturer.go

323 lines
7.4 KiB
Go

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,
}
}