Compare commits

...

30 Commits

Author SHA1 Message Date
Jose Luis Montañes Ojados
713903c463 feat(tui): refine transaction flow with source/dest IPs and improved styling 2026-01-20 22:28:20 +01:00
Jose Luis Montañes Ojados
c052cc72fb fix: Ensure transaction flow navigation works for all calls 2026-01-19 23:37:04 +01:00
Jose Luis Montañes Ojados
1fb7d447a4 fix: Resolve call selection index mismatch by using sorted flows everywhere 2026-01-19 23:29:38 +01:00
Jose Luis Montañes Ojados
65a7cd10f6 fix: Adjust Analysis view layout to prevent overlapping with navigation bar 2026-01-19 23:23:05 +01:00
Jose Luis Montañes Ojados
b7a5bede87 refactor: Use viewport for Analysis interactions to enable proper scrolling 2026-01-19 23:16:02 +01:00
Jose Luis Montañes Ojados
a5ba4d6c11 fix: Implement proper scrolling and sorting for Analysis calls list 2026-01-19 23:07:25 +01:00
Jose Luis Montañes Ojados
d234bfb427 feat: Resolve IP addresses to node names in packet details view and export 2026-01-19 22:46:03 +01:00
Jose Luis Montañes Ojados
6abea047d1 feat: Add zero-padding to transaction flow numbering for better alignment 2026-01-19 22:09:45 +01:00
Jose Luis Montañes Ojados
b2ff043923 feat: Add timestamp to export filename and include network context 2026-01-19 21:49:04 +01:00
Jose Luis Montañes Ojados
bdf6dfc1e1 feat: Add Network Layer details to exported call log 2026-01-19 21:39:57 +01:00
Jose Luis Montañes Ojados
d3b31f02c8 feat: Enhance export with IPs and add controls legend to TUI 2026-01-19 21:34:40 +01:00
Jose Luis Montañes Ojados
3b73d30e27 feat: Add call details export to log file 2026-01-19 20:52:46 +01:00
Jose Luis Montañes Ojados
1df4a48769 fix: Update Call Detail view on new packets for real-time monitoring 2026-01-19 17:22:27 +01:00
Jose Luis Montañes Ojados
23349e2039 fix: Ensure Packet Details and Transaction Flow scroll correctly by updating viewports in Update loop 2026-01-19 17:15:49 +01:00
Jose Luis Montañes Ojados
f5a2730bc3 fix: Update detailsViewport dimensions in Update loop to ensure scrollability 2026-01-19 17:06:09 +01:00
Jose Luis Montañes Ojados
a02b7533d5 fix: Initialize detailsViewport in NewModel to enable scrolling 2026-01-19 16:55:41 +01:00
Jose Luis Montañes Ojados
daefefb5aa style: Restore titles and apply full-height rounded borders to Call Detail view 2026-01-19 16:51:24 +01:00
Jose Luis Montañes Ojados
c4794ad787 feat: Add scrollable packet details viewport and TAB focus switching 2026-01-19 16:43:08 +01:00
Jose Luis Montañes Ojados
0566cb9268 feat: Implement interactive packet selection and split-pane layout in Call Detail view 2026-01-19 16:37:43 +01:00
Jose Luis Montañes Ojados
42d27c0647 fix: Implement proactive packet flushing based on Content-Length to resolve UI update lag 2026-01-19 16:32:57 +01:00
Jose Luis Montañes Ojados
751bf380d7 fix: Refactor Update loop to ensure packets are processed in all views 2026-01-19 16:25:32 +01:00
Jose Luis Montañes Ojados
1344b730af fix: Increase scanner buffer size to 5MB and add error logging to prevent silent capture failures 2026-01-19 16:17:28 +01:00
Jose Luis Montañes Ojados
ee0e827b78 feat: Redesign Call Detail view with split layout and scrollable viewport 2026-01-19 16:12:51 +01:00
Jose Luis Montañes Ojados
ee7201ce57 fix: Correct IP latching order to prevent metadata overwrite 2026-01-19 16:07:18 +01:00
Jose Luis Montañes Ojados
0c61db8f5b fix: Resolve IP direction mismatch by latching metadata and robustifying tcpdump header parsing 2026-01-19 16:00:59 +01:00
Jose Luis Montañes Ojados
cd9b0db44d fix: Implement channel-based packet handling for TUI live updates 2026-01-19 15:51:25 +01:00
Jose Luis Montañes Ojados
f0911737f4 fix: Extract network metadata from tcpdump headers to populate SIP packet IPs 2026-01-19 15:40:11 +01:00
Jose Luis Montañes Ojados
2d99d8ddc4 fix: Robust parsing of SIP messages from tcpdump output containing raw header artifacts 2026-01-19 15:35:13 +01:00
Jose Luis Montañes Ojados
4fa44fb9c7 chore: Add verbose logging to local capture for debugging purposes 2026-01-19 15:28:16 +01:00
Jose Luis Montañes Ojados
b8984f25a0 fix: Add -nn flag to tcpdump commands to resolve macOS capture issues 2026-01-19 15:26:01 +01:00
6 changed files with 1265 additions and 222 deletions

View File

@@ -0,0 +1,223 @@
Call Detail Export
==================
Date: Tue, 20 Jan 2026 22:27:28 CET
Call-ID: e06cb346194a4f9295d3a325b185912f
From: <sip:1001@192.168.0.162>;tag=d715109fcadd492b8fa1ce009ae2c095
To: <sip:123456@192.168.0.162>
State: Connected
Duration: 2.81879s
Packets: 8
Network Layer:
Source: Windows (Carrier) (192.168.0.164:51416)
Destination: Asterisk (PBX) (192.168.0.162:5060)
Network Context:
- Windows (Carrier): 192.168.0.164
- Asterisk (PBX): 192.168.0.162
Transaction Flow:
-----------------
01. [14:24:30.477] Windows (Carrier) (192.168.0.164) -> Asterisk (PBX) (192.168.0.162) (INVITE line 1153)
02. [14:24:30.478] Asterisk (PBX) (192.168.0.162) <- Windows (Carrier) (192.168.0.164) (Trying line 382)
03. [14:24:30.479] Asterisk (PBX) (192.168.0.162) <- Windows (Carrier) (192.168.0.164) (OK line 1075)
04. [14:24:30.479] Windows (Carrier) (192.168.0.164) -> Asterisk (PBX) (192.168.0.162) (ACK line 371)
05. [14:24:30.479] Windows (Carrier) (192.168.0.164) -> Asterisk (PBX) (192.168.0.162) (UPDATE line 870)
06. [14:24:30.480] Asterisk (PBX) (192.168.0.162) <- Windows (Carrier) (192.168.0.164) (OK line 939)
07. [14:24:33.296] Windows (Carrier) (192.168.0.164) -> Asterisk (PBX) (192.168.0.162) (BYE line 400)
08. [14:24:33.296] Asterisk (PBX) (192.168.0.162) <- Windows (Carrier) (192.168.0.164) (OK line 416)
Packet Details:
---------------
--- Packet 1 [14:24:30.477] ---
Windows (Carrier) (192.168.0.164) -> Asterisk (PBX) (192.168.0.162)
INVITE sip:123456@192.168.0.162 SIP/2.0
Via: SIP/2.0/UDP 192.168.0.164:51416;rport;branch=z9hG4bKPjc988f0aca12d47aa900f472ea93fc0d6
Max-Forwards: 70
From: <sip:1001@192.168.0.162>;tag=d715109fcadd492b8fa1ce009ae2c095
To: <sip:123456@192.168.0.162>
Contact: <sip:1001@192.168.0.164:51416;ob>
Call-ID: e06cb346194a4f9295d3a325b185912f
CSeq: 12831 INVITE
Allow: PRACK, INVITE, ACK, BYE, CANCEL, UPDATE, INFO, SUBSCRIBE, NOTIFY, REFER, MESSAGE, OPTIONS
Supported: replaces, 100rel, timer, norefersub
Session-Expires: 1800
Min-SE: 90
User-Agent: MicroSIP/3.21.6
Content-Type: application/sdp
Content-Length: 527
v=0
o=- 3977821470 3977821470 IN IP4 192.168.0.164
s=pjmedia
b=AS:84
t=0 0
a=X-nat:0
m=audio 4040 RTP/AVP 0 8 96 101 102
c=IN IP4 192.168.0.164
b=TIAS:64000
a=rtcp:4041 IN IP4 192.168.0.164
a=sendrecv
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:96 opus/48000/2
a=fmtp:96 maxplaybackrate=24000;sprop-maxcapturerate=24000;maxaveragebitrate=64000;useinbandfec=1
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-16
a=rtpmap:102 telephone-event/48000
a=fmtp:102 0-16
a=ssrc:825569477 cname:1f442b6f1a4c18c5
--- Packet 2 [14:24:30.478] ---
Asterisk (PBX) (192.168.0.162) -> Windows (Carrier) (192.168.0.164)
SIP/2.0 100 Trying
Via: SIP/2.0/UDP 192.168.0.164:51416;rport=51416;received=192.168.0.164;branch=z9hG4bKPjc988f0aca12d47aa900f472ea93fc0d6
Call-ID: e06cb346194a4f9295d3a325b185912f
From: <sip:1001@192.168.0.162>;tag=d715109fcadd492b8fa1ce009ae2c095
To: <sip:123456@192.168.0.162>
CSeq: 12831 INVITE
Server: Asterisk PBX 18.10.0~dfsg+~cs6.10.40431411-2
Content-Length: 0
--- Packet 3 [14:24:30.479] ---
Asterisk (PBX) (192.168.0.162) -> Windows (Carrier) (192.168.0.164)
SIP/2.0 200 OK
Via: SIP/2.0/UDP 192.168.0.164:51416;rport=51416;received=192.168.0.164;branch=z9hG4bKPjc988f0aca12d47aa900f472ea93fc0d6
Call-ID: e06cb346194a4f9295d3a325b185912f
From: <sip:1001@192.168.0.162>;tag=d715109fcadd492b8fa1ce009ae2c095
To: <sip:123456@192.168.0.162>;tag=643ebe4c-3476-4110-98e6-1b14f62996e1
CSeq: 12831 INVITE
Server: Asterisk PBX 18.10.0~dfsg+~cs6.10.40431411-2
Contact: <sip:192.168.0.162:5060>
Allow: OPTIONS, REGISTER, SUBSCRIBE, NOTIFY, PUBLISH, INVITE, ACK, BYE, CANCEL, UPDATE, PRACK, MESSAGE, REFER
Supported: 100rel, timer, replaces, norefersub
Session-Expires: 1800;refresher=uac
Require: timer
Content-Type: application/sdp
Content-Length: 375
v=0
o=- 3977821470 3977821472 IN IP4 192.168.0.162
s=Asterisk
c=IN IP4 192.168.0.162
t=0 0
m=audio 12862 RTP/AVP 0 8 96 101
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:96 opus/48000/2
a=fmtp:96 maxplaybackrate=24000;sprop-maxcapturerate=24000;maxaveragebitrate=64000
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-16
a=ptime:20
a=maxptime:60
a=sendrecv
--- Packet 4 [14:24:30.479] ---
Windows (Carrier) (192.168.0.164) -> Asterisk (PBX) (192.168.0.162)
ACK sip:192.168.0.162:5060 SIP/2.0
Via: SIP/2.0/UDP 192.168.0.164:51416;rport;branch=z9hG4bKPjf0cf112e8b5d4759a3417bc63cd5481e
Max-Forwards: 70
From: <sip:1001@192.168.0.162>;tag=d715109fcadd492b8fa1ce009ae2c095
To: <sip:123456@192.168.0.162>;tag=643ebe4c-3476-4110-98e6-1b14f62996e1
Call-ID: e06cb346194a4f9295d3a325b185912f
CSeq: 12831 ACK
Content-Length: 0
--- Packet 5 [14:24:30.479] ---
Windows (Carrier) (192.168.0.164) -> Asterisk (PBX) (192.168.0.162)
UPDATE sip:192.168.0.162:5060 SIP/2.0
Via: SIP/2.0/UDP 192.168.0.164:51416;rport;branch=z9hG4bKPj1453a8f281f04023b1a9d246fb587ca3
Max-Forwards: 70
From: <sip:1001@192.168.0.162>;tag=d715109fcadd492b8fa1ce009ae2c095
To: <sip:123456@192.168.0.162>;tag=643ebe4c-3476-4110-98e6-1b14f62996e1
Contact: <sip:1001@192.168.0.164:51416;ob>
Call-ID: e06cb346194a4f9295d3a325b185912f
CSeq: 12832 UPDATE
Supported: replaces, 100rel, timer, norefersub
Session-Expires: 1800;refresher=uac
Min-SE: 90
Content-Type: application/sdp
Content-Length: 318
v=0
o=- 3977821470 3977821471 IN IP4 192.168.0.164
s=pjmedia
b=AS:84
t=0 0
a=X-nat:0
m=audio 4040 RTP/AVP 0 101
c=IN IP4 192.168.0.164
b=TIAS:64000
a=rtcp:4041 IN IP4 192.168.0.164
a=ssrc:825569477 cname:1f442b6f1a4c18c5
a=rtpmap:0 PCMU/8000
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-16
a=sendrecv
--- Packet 6 [14:24:30.480] ---
Asterisk (PBX) (192.168.0.162) -> Windows (Carrier) (192.168.0.164)
SIP/2.0 200 OK
Via: SIP/2.0/UDP 192.168.0.164:51416;rport=51416;received=192.168.0.164;branch=z9hG4bKPj1453a8f281f04023b1a9d246fb587ca3
Call-ID: e06cb346194a4f9295d3a325b185912f
From: <sip:1001@192.168.0.162>;tag=d715109fcadd492b8fa1ce009ae2c095
To: <sip:123456@192.168.0.162>;tag=643ebe4c-3476-4110-98e6-1b14f62996e1
CSeq: 12832 UPDATE
Session-Expires: 1800;refresher=uac
Require: timer
Contact: <sip:192.168.0.162:5060>
Allow: OPTIONS, REGISTER, SUBSCRIBE, NOTIFY, PUBLISH, INVITE, ACK, BYE, CANCEL, UPDATE, PRACK, MESSAGE, REFER
Supported: 100rel, timer, replaces, norefersub
Server: Asterisk PBX 18.10.0~dfsg+~cs6.10.40431411-2
Content-Type: application/sdp
Content-Length: 239
v=0
o=- 3977821470 3977821473 IN IP4 192.168.0.162
s=Asterisk
c=IN IP4 192.168.0.162
t=0 0
m=audio 12862 RTP/AVP 0 101
a=rtpmap:0 PCMU/8000
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-16
a=ptime:20
a=maxptime:150
a=sendrecv
--- Packet 7 [14:24:33.296] ---
Windows (Carrier) (192.168.0.164) -> Asterisk (PBX) (192.168.0.162)
BYE sip:192.168.0.162:5060 SIP/2.0
Via: SIP/2.0/UDP 192.168.0.164:51416;rport;branch=z9hG4bKPj15f5d8bca6884d6794f27c43437f1201
Max-Forwards: 70
From: <sip:1001@192.168.0.162>;tag=d715109fcadd492b8fa1ce009ae2c095
To: <sip:123456@192.168.0.162>;tag=643ebe4c-3476-4110-98e6-1b14f62996e1
Call-ID: e06cb346194a4f9295d3a325b185912f
CSeq: 12833 BYE
User-Agent: MicroSIP/3.21.6
Content-Length: 0
--- Packet 8 [14:24:33.296] ---
Asterisk (PBX) (192.168.0.162) -> Windows (Carrier) (192.168.0.164)
SIP/2.0 200 OK
Via: SIP/2.0/UDP 192.168.0.164:51416;rport=51416;received=192.168.0.164;branch=z9hG4bKPj15f5d8bca6884d6794f27c43437f1201
Call-ID: e06cb346194a4f9295d3a325b185912f
From: <sip:1001@192.168.0.162>;tag=d715109fcadd492b8fa1ce009ae2c095
To: <sip:123456@192.168.0.162>;tag=643ebe4c-3476-4110-98e6-1b14f62996e1
CSeq: 12833 BYE
Server: Asterisk PBX 18.10.0~dfsg+~cs6.10.40431411-2
Content-Length: 0

Binary file not shown.

View File

@@ -4,8 +4,10 @@ import (
"bufio" "bufio"
"fmt" "fmt"
"io" "io"
"strconv"
"strings" "strings"
"sync" "sync"
"time"
"telephony-inspector/internal/sip" "telephony-inspector/internal/sip"
internalSSH "telephony-inspector/internal/ssh" internalSSH "telephony-inspector/internal/ssh"
@@ -17,6 +19,7 @@ type Capturer struct {
cleanup func() error cleanup func() error
running bool running bool
mu sync.Mutex mu sync.Mutex
currentNetInfo *NetInfo
// Callbacks // Callbacks
OnPacket func(*sip.Packet) OnPacket func(*sip.Packet)
@@ -55,7 +58,8 @@ func (c *Capturer) Start(iface string, port int) error {
// -l: line buffered for real-time output // -l: line buffered for real-time output
// -A: print packet payload in ASCII // -A: print packet payload in ASCII
// -s 0: capture full packets // -s 0: capture full packets
cmd := fmt.Sprintf("sudo tcpdump -l -A -s 0 -i %s port %d 2>/dev/null", iface, port) // -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) stdout, stderr, cleanup, err := c.sshClient.StartCommand(cmd)
if err != nil { if err != nil {
@@ -100,8 +104,15 @@ func (c *Capturer) IsRunning() bool {
func (c *Capturer) processStream(r io.Reader) { func (c *Capturer) processStream(r io.Reader) {
scanner := bufio.NewScanner(r) scanner := bufio.NewScanner(r)
// Increase buffer size to handle large packets
const maxCapacity = 5 * 1024 * 1024 // 5MB
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, maxCapacity)
var buffer strings.Builder var buffer strings.Builder
inSIPMessage := false inSIPMessage := false
var msgNetInfo *NetInfo
contentLength := -1
for scanner.Scan() { for scanner.Scan() {
c.mu.Lock() c.mu.Lock()
@@ -113,28 +124,77 @@ func (c *Capturer) processStream(r io.Reader) {
line := scanner.Text() line := scanner.Text()
// Check for tcpdump header
if netInfo := parseTcpdumpHeader(line); netInfo != nil {
c.currentNetInfo = netInfo
continue
}
// Detect start of SIP message // Detect start of SIP message
if isSIPStart(line) { if idx := findSIPStart(line); idx != -1 {
// If we were building a message, parse it
// 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 { if buffer.Len() > 0 {
c.parseAndEmit(buffer.String()) c.parseAndEmit(buffer.String(), msgNetInfo)
buffer.Reset() buffer.Reset()
} }
// NOW update msgNetInfo for the new message
if c.currentNetInfo != nil {
info := *c.currentNetInfo
msgNetInfo = &info
} else {
msgNetInfo = nil
}
inSIPMessage = true inSIPMessage = true
contentLength = -1 // Reset
} }
if inSIPMessage { if inSIPMessage {
buffer.WriteString(line) buffer.WriteString(line)
buffer.WriteString("\r\n") buffer.WriteString("\r\n")
// Detect end of SIP message (double CRLF or content complete) // Check for Content-Length
// This is simplified - real implementation would track Content-Length lowerLine := strings.ToLower(line)
if strings.HasPrefix(lowerLine, "content-length:") || strings.HasPrefix(lowerLine, "l:") {
parts := strings.Split(line, ":")
if len(parts) >= 2 {
val := strings.TrimSpace(parts[1])
if val != "" {
if cl, err := strconv.Atoi(val); err == nil {
contentLength = cl
}
}
}
}
// Check for end of headers (empty line)
if line == "" {
// If Content-Length is 0 (or not found, treating as 0)
// Flush immediately
if contentLength <= 0 {
c.parseAndEmit(buffer.String(), msgNetInfo)
buffer.Reset()
inSIPMessage = false
contentLength = -1
}
}
} }
} }
// Parse remaining buffer // Parse remaining buffer
if buffer.Len() > 0 { if buffer.Len() > 0 {
c.parseAndEmit(buffer.String()) c.parseAndEmit(buffer.String(), msgNetInfo)
}
if err := scanner.Err(); err != nil {
if c.OnError != nil {
c.OnError(fmt.Errorf("scanner error: %w", err))
}
} }
} }
@@ -147,7 +207,7 @@ func (c *Capturer) processErrors(r io.Reader) {
} }
} }
func (c *Capturer) parseAndEmit(raw string) { func (c *Capturer) parseAndEmit(raw string, netInfo *NetInfo) {
packet, err := sip.Parse(raw) packet, err := sip.Parse(raw)
if err != nil { if err != nil {
if c.OnError != nil { if c.OnError != nil {
@@ -155,26 +215,140 @@ func (c *Capturer) parseAndEmit(raw string) {
} }
return return
} }
if packet != nil && c.OnPacket != nil { 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) c.OnPacket(packet)
} }
}
} }
// isSIPStart checks if a line looks like the start of a SIP message // findSIPStart returns the index of the start of a SIP message, or -1 if not found
func isSIPStart(line string) bool { func findSIPStart(line string) int {
sipMethods := []string{"INVITE", "ACK", "BYE", "CANCEL", "REGISTER", "OPTIONS", "PRACK", "SUBSCRIBE", "NOTIFY", "PUBLISH", "INFO", "REFER", "MESSAGE", "UPDATE"} sipMethods := []string{"INVITE", "ACK", "BYE", "CANCEL", "REGISTER", "OPTIONS", "PRACK", "SUBSCRIBE", "NOTIFY", "PUBLISH", "INFO", "REFER", "MESSAGE", "UPDATE"}
// Response // Check for Response "SIP/2.0"
if strings.HasPrefix(line, "SIP/2.0") { if idx := strings.Index(line, "SIP/2.0 "); idx != -1 {
return true // 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
}
} }
// Request // Check for Request "METHOD "
for _, m := range sipMethods { for _, m := range sipMethods {
if strings.HasPrefix(line, m+" ") { target := m + " "
return true 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 false 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,
}
} }

View File

@@ -6,9 +6,11 @@ import (
"fmt" "fmt"
"io" "io"
"os/exec" "os/exec"
"strconv"
"strings" "strings"
"sync" "sync"
"telephony-inspector/internal/logger"
"telephony-inspector/internal/sip" "telephony-inspector/internal/sip"
) )
@@ -18,6 +20,7 @@ type LocalCapturer struct {
cancel context.CancelFunc cancel context.CancelFunc
running bool running bool
mu sync.Mutex mu sync.Mutex
currentNetInfo *NetInfo
// Callbacks // Callbacks
OnPacket func(*sip.Packet) OnPacket func(*sip.Packet)
@@ -46,9 +49,12 @@ func (c *LocalCapturer) Start(iface string, port int) error {
// -l: line buffered // -l: line buffered
// -A: print packet payload in ASCII // -A: print packet payload in ASCII
// -s 0: capture full packets // -s 0: capture full packets
args := []string{"-l", "-A", "-s", "0", "-i", iface, "port", fmt.Sprintf("%d", port)} // -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...) c.cmd = exec.CommandContext(ctx, "tcpdump", args...)
logger.Info("Starting local capture: tcpdump %v", args)
stdout, err := c.cmd.StdoutPipe() stdout, err := c.cmd.StdoutPipe()
if err != nil { if err != nil {
c.mu.Lock() c.mu.Lock()
@@ -72,6 +78,8 @@ func (c *LocalCapturer) Start(iface string, port int) error {
return fmt.Errorf("failed to start tcpdump: %w", err) return fmt.Errorf("failed to start tcpdump: %w", err)
} }
logger.Info("Local capture started successfully")
// Process stdout in goroutine // Process stdout in goroutine
go c.processStream(stdout) go c.processStream(stdout)
@@ -89,6 +97,7 @@ func (c *LocalCapturer) Stop() {
if !c.running { if !c.running {
return return
} }
logger.Info("Stopping local capture")
c.running = false c.running = false
if c.cancel != nil { if c.cancel != nil {
@@ -117,8 +126,15 @@ func (c *LocalCapturer) Close() error {
func (c *LocalCapturer) processStream(r io.Reader) { func (c *LocalCapturer) processStream(r io.Reader) {
scanner := bufio.NewScanner(r) scanner := bufio.NewScanner(r)
// Increase buffer size to handle large packets (default is 64KB)
const maxCapacity = 5 * 1024 * 1024 // 5MB
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, maxCapacity)
var buffer strings.Builder var buffer strings.Builder
inSIPMessage := false inSIPMessage := false
var msgNetInfo *NetInfo
contentLength := -1
for scanner.Scan() { for scanner.Scan() {
c.mu.Lock() c.mu.Lock()
@@ -130,25 +146,99 @@ func (c *LocalCapturer) processStream(r io.Reader) {
line := scanner.Text() line := scanner.Text()
// Check for tcpdump header
if netInfo := parseTcpdumpHeader(line); netInfo != nil {
c.currentNetInfo = netInfo
continue
}
// Detect start of SIP message // Detect start of SIP message
if isSIPStart(line) { if idx := findSIPStart(line); idx != -1 {
// If we were building a message, parse it 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 { if buffer.Len() > 0 {
c.parseAndEmit(buffer.String()) // 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() buffer.Reset()
} }
// NOW update msgNetInfo for the new message
if c.currentNetInfo != nil {
info := *c.currentNetInfo
msgNetInfo = &info
} else {
msgNetInfo = nil
}
inSIPMessage = true inSIPMessage = true
contentLength = -1 // Reset for new message
} }
if inSIPMessage { if inSIPMessage {
buffer.WriteString(line) buffer.WriteString(line)
buffer.WriteString("\r\n") buffer.WriteString("\r\n")
// Check for Content-Length
lowerLine := strings.ToLower(line)
if strings.HasPrefix(lowerLine, "content-length:") || strings.HasPrefix(lowerLine, "l:") {
parts := strings.Split(line, ":")
if len(parts) >= 2 {
val := strings.TrimSpace(parts[1])
if val != "" {
if cl, err := strconv.Atoi(val); err == nil {
contentLength = cl
}
}
}
}
// Check for end of headers (empty line)
if line == "" {
// If Content-Length is 0 (or not found, treating as 0 for safety in this context usually 0 for BYE/ACK)
// Flush immediately
if contentLength <= 0 {
c.parseAndEmit(buffer.String(), msgNetInfo)
buffer.Reset()
inSIPMessage = false
contentLength = -1
}
}
} }
} }
// Parse remaining buffer // Parse remaining buffer
if buffer.Len() > 0 { if buffer.Len() > 0 {
c.parseAndEmit(buffer.String()) c.parseAndEmit(buffer.String(), msgNetInfo)
}
if err := scanner.Err(); err != nil {
logger.Error("Scanner error: %v", err)
if c.OnError != nil {
c.OnError(fmt.Errorf("scanner error: %w", err))
}
} }
} }
@@ -158,23 +248,38 @@ func (c *LocalCapturer) processErrors(r io.Reader) {
text := scanner.Text() text := scanner.Text()
// tcpdump prints "listening on..." to stderr, ignore it // tcpdump prints "listening on..." to stderr, ignore it
if strings.Contains(text, "listening on") { if strings.Contains(text, "listening on") {
logger.Info("tcpdump: %s", text)
continue continue
} }
logger.Error("tcpdump stderr: %s", text)
if c.OnError != nil { if c.OnError != nil {
c.OnError(fmt.Errorf("tcpdump: %s", text)) c.OnError(fmt.Errorf("tcpdump: %s", text))
} }
} }
} }
func (c *LocalCapturer) parseAndEmit(raw string) { func (c *LocalCapturer) parseAndEmit(raw string, netInfo *NetInfo) {
packet, err := sip.Parse(raw) packet, err := sip.Parse(raw)
if err != nil { if err != nil {
// Suppress verbose error logging for partial packets unless debug
// logger.Error("Error parsing SIP packet: %v", err)
if c.OnError != nil { if c.OnError != nil {
c.OnError(err) c.OnError(err)
} }
return return
} }
if packet != nil && c.OnPacket != nil { 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) c.OnPacket(packet)
} }
}
} }

View File

@@ -153,6 +153,27 @@ func (s *CallFlowStore) GetRecentFlows(n int) []*CallFlow {
return flows return flows
} }
// GetSortedFlows returns all call flows sorted by StartTime (oldest first)
func (s *CallFlowStore) GetSortedFlows() []*CallFlow {
flows := s.GetAllFlows()
// Sort by start time ascending (oldest first), then by CallID for stable order
for i := 0; i < len(flows)-1; i++ {
for j := i + 1; j < len(flows); j++ {
// Compare by StartTime first
if flows[i].StartTime.After(flows[j].StartTime) {
flows[i], flows[j] = flows[j], flows[i]
} else if flows[i].StartTime.Equal(flows[j].StartTime) {
// If same time, sort by CallID for stable order
if flows[i].CallID > flows[j].CallID {
flows[i], flows[j] = flows[j], flows[i]
}
}
}
}
return flows
}
// Count returns the number of call flows // Count returns the number of call flows
func (s *CallFlowStore) Count() int { func (s *CallFlowStore) Count() int {
s.mu.RLock() s.mu.RLock()

File diff suppressed because it is too large Load Diff