feat: Initial project structure with TUI, SSH, SIP parser, and capture modules
This commit is contained in:
18
cmd/inspector/main.go
Normal file
18
cmd/inspector/main.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"telephony-inspector/internal/tui"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func main() {
|
||||
p := tea.NewProgram(tui.NewModel(), tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
31
go.mod
Normal file
31
go.mod
Normal file
@@ -0,0 +1,31 @@
|
||||
module telephony-inspector
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.12
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
)
|
||||
47
go.sum
Normal file
47
go.sum
Normal file
@@ -0,0 +1,47 @@
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
BIN
inspector.exe
Normal file
BIN
inspector.exe
Normal file
Binary file not shown.
180
internal/capture/capturer.go
Normal file
180
internal/capture/capturer.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"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
|
||||
|
||||
// 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
|
||||
cmd := fmt.Sprintf("sudo tcpdump -l -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
|
||||
|
||||
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")
|
||||
|
||||
// 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
|
||||
}
|
||||
if packet != nil && c.OnPacket != nil {
|
||||
c.OnPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
// isSIPStart checks if a line looks like the start of a SIP message
|
||||
func isSIPStart(line string) bool {
|
||||
sipMethods := []string{"INVITE", "ACK", "BYE", "CANCEL", "REGISTER", "OPTIONS", "PRACK", "SUBSCRIBE", "NOTIFY", "PUBLISH", "INFO", "REFER", "MESSAGE", "UPDATE"}
|
||||
|
||||
// Response
|
||||
if strings.HasPrefix(line, "SIP/2.0") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Request
|
||||
for _, m := range sipMethods {
|
||||
if strings.HasPrefix(line, m+" ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
62
internal/config/network_map.go
Normal file
62
internal/config/network_map.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
// NodeType represents the type of network node
|
||||
type NodeType string
|
||||
|
||||
const (
|
||||
NodeTypePBX NodeType = "PBX"
|
||||
NodeTypeProxy NodeType = "Proxy"
|
||||
NodeTypeMediaServer NodeType = "MediaServer"
|
||||
NodeTypeGateway NodeType = "Gateway"
|
||||
NodeTypeEndpoint NodeType = "Endpoint"
|
||||
NodeTypeUnknown NodeType = "Unknown"
|
||||
)
|
||||
|
||||
// NetworkNode represents a network entity in the SIP infrastructure
|
||||
type NetworkNode struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Type NodeType `json:"type"`
|
||||
Aliases []string `json:"aliases,omitempty"` // Alternative IPs or hostnames
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// NetworkMap holds the configured network topology
|
||||
type NetworkMap struct {
|
||||
Nodes []NetworkNode `json:"nodes"`
|
||||
}
|
||||
|
||||
// NewNetworkMap creates an empty network map
|
||||
func NewNetworkMap() *NetworkMap {
|
||||
return &NetworkMap{
|
||||
Nodes: make([]NetworkNode, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// AddNode adds a new node to the network map
|
||||
func (nm *NetworkMap) AddNode(node NetworkNode) {
|
||||
nm.Nodes = append(nm.Nodes, node)
|
||||
}
|
||||
|
||||
// FindByIP looks up a node by IP address
|
||||
func (nm *NetworkMap) FindByIP(ip string) *NetworkNode {
|
||||
for i := range nm.Nodes {
|
||||
if nm.Nodes[i].IP == ip {
|
||||
return &nm.Nodes[i]
|
||||
}
|
||||
for _, alias := range nm.Nodes[i].Aliases {
|
||||
if alias == ip {
|
||||
return &nm.Nodes[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LabelForIP returns a human-readable label for an IP, or the IP itself if unknown
|
||||
func (nm *NetworkMap) LabelForIP(ip string) string {
|
||||
if node := nm.FindByIP(ip); node != nil {
|
||||
return node.Name + " (" + string(node.Type) + ")"
|
||||
}
|
||||
return ip
|
||||
}
|
||||
257
internal/sip/parser.go
Normal file
257
internal/sip/parser.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package sip
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Method represents a SIP method
|
||||
type Method string
|
||||
|
||||
const (
|
||||
MethodINVITE Method = "INVITE"
|
||||
MethodACK Method = "ACK"
|
||||
MethodBYE Method = "BYE"
|
||||
MethodCANCEL Method = "CANCEL"
|
||||
MethodREGISTER Method = "REGISTER"
|
||||
MethodOPTIONS Method = "OPTIONS"
|
||||
MethodPRACK Method = "PRACK"
|
||||
MethodSUBSCRIBE Method = "SUBSCRIBE"
|
||||
MethodNOTIFY Method = "NOTIFY"
|
||||
MethodPUBLISH Method = "PUBLISH"
|
||||
MethodINFO Method = "INFO"
|
||||
MethodREFER Method = "REFER"
|
||||
MethodMESSAGE Method = "MESSAGE"
|
||||
MethodUPDATE Method = "UPDATE"
|
||||
)
|
||||
|
||||
// Packet represents a parsed SIP packet
|
||||
type Packet struct {
|
||||
Raw string
|
||||
IsRequest bool
|
||||
Method Method
|
||||
StatusCode int
|
||||
StatusText string
|
||||
RequestURI string
|
||||
|
||||
// Common headers
|
||||
CallID string
|
||||
From string
|
||||
To string
|
||||
Via []string
|
||||
CSeq string
|
||||
Contact string
|
||||
ContentType string
|
||||
|
||||
// Network info
|
||||
SourceIP string
|
||||
SourcePort int
|
||||
DestIP string
|
||||
DestPort int
|
||||
|
||||
// SDP body if present
|
||||
SDP *SDP
|
||||
}
|
||||
|
||||
// SDP represents a parsed SDP body
|
||||
type SDP struct {
|
||||
Raw string
|
||||
SessionName string
|
||||
Origin string
|
||||
Connection string // c= line (IP for media)
|
||||
MediaSections []MediaSection
|
||||
}
|
||||
|
||||
// MediaSection represents an m= line in SDP
|
||||
type MediaSection struct {
|
||||
MediaType string // audio, video, etc
|
||||
Port int
|
||||
Protocol string // RTP/AVP, etc
|
||||
Formats []string
|
||||
Attributes map[string]string
|
||||
}
|
||||
|
||||
// Parse parses a raw SIP message
|
||||
func Parse(raw string) (*Packet, error) {
|
||||
p := &Packet{Raw: raw}
|
||||
|
||||
lines := strings.Split(raw, "\r\n")
|
||||
if len(lines) == 0 {
|
||||
lines = strings.Split(raw, "\n")
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Parse first line (request or response)
|
||||
firstLine := lines[0]
|
||||
if strings.HasPrefix(firstLine, "SIP/2.0") {
|
||||
p.IsRequest = false
|
||||
parts := strings.SplitN(firstLine, " ", 3)
|
||||
if len(parts) >= 2 {
|
||||
p.StatusCode = parseStatusCode(parts[1])
|
||||
}
|
||||
if len(parts) >= 3 {
|
||||
p.StatusText = parts[2]
|
||||
}
|
||||
} else {
|
||||
p.IsRequest = true
|
||||
parts := strings.SplitN(firstLine, " ", 3)
|
||||
if len(parts) >= 1 {
|
||||
p.Method = Method(parts[0])
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
p.RequestURI = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
headerEnd := 0
|
||||
for i, line := range lines[1:] {
|
||||
if line == "" {
|
||||
headerEnd = i + 2
|
||||
break
|
||||
}
|
||||
p.parseHeader(line)
|
||||
}
|
||||
|
||||
// Parse SDP body if present
|
||||
if headerEnd > 0 && headerEnd < len(lines) && strings.Contains(p.ContentType, "application/sdp") {
|
||||
body := strings.Join(lines[headerEnd:], "\r\n")
|
||||
p.SDP = ParseSDP(body)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *Packet) parseHeader(line string) {
|
||||
idx := strings.Index(line, ":")
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(line[:idx])
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
|
||||
switch strings.ToLower(name) {
|
||||
case "call-id", "i":
|
||||
p.CallID = value
|
||||
case "from", "f":
|
||||
p.From = value
|
||||
case "to", "t":
|
||||
p.To = value
|
||||
case "via", "v":
|
||||
p.Via = append(p.Via, value)
|
||||
case "cseq":
|
||||
p.CSeq = value
|
||||
case "contact", "m":
|
||||
p.Contact = value
|
||||
case "content-type", "c":
|
||||
p.ContentType = value
|
||||
}
|
||||
}
|
||||
|
||||
func parseStatusCode(s string) int {
|
||||
var code int
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
code = code*10 + int(c-'0')
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// ParseSDP parses an SDP body
|
||||
func ParseSDP(body string) *SDP {
|
||||
sdp := &SDP{Raw: body}
|
||||
lines := strings.Split(body, "\r\n")
|
||||
if len(lines) <= 1 {
|
||||
lines = strings.Split(body, "\n")
|
||||
}
|
||||
|
||||
var currentMedia *MediaSection
|
||||
for _, line := range lines {
|
||||
if len(line) < 2 || line[1] != '=' {
|
||||
continue
|
||||
}
|
||||
typ := line[0]
|
||||
value := line[2:]
|
||||
|
||||
switch typ {
|
||||
case 'o':
|
||||
sdp.Origin = value
|
||||
case 's':
|
||||
sdp.SessionName = value
|
||||
case 'c':
|
||||
sdp.Connection = value
|
||||
case 'm':
|
||||
if currentMedia != nil {
|
||||
sdp.MediaSections = append(sdp.MediaSections, *currentMedia)
|
||||
}
|
||||
currentMedia = parseMediaLine(value)
|
||||
case 'a':
|
||||
if currentMedia != nil {
|
||||
parseAttribute(currentMedia, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentMedia != nil {
|
||||
sdp.MediaSections = append(sdp.MediaSections, *currentMedia)
|
||||
}
|
||||
|
||||
return sdp
|
||||
}
|
||||
|
||||
func parseMediaLine(value string) *MediaSection {
|
||||
parts := strings.Fields(value)
|
||||
if len(parts) < 3 {
|
||||
return &MediaSection{}
|
||||
}
|
||||
m := &MediaSection{
|
||||
MediaType: parts[0],
|
||||
Protocol: parts[2],
|
||||
Attributes: make(map[string]string),
|
||||
}
|
||||
if port := parsePort(parts[1]); port > 0 {
|
||||
m.Port = port
|
||||
}
|
||||
if len(parts) > 3 {
|
||||
m.Formats = parts[3:]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func parsePort(s string) int {
|
||||
var port int
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
port = port*10 + int(c-'0')
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func parseAttribute(m *MediaSection, value string) {
|
||||
if idx := strings.Index(value, ":"); idx > 0 {
|
||||
m.Attributes[value[:idx]] = value[idx+1:]
|
||||
} else {
|
||||
m.Attributes[value] = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Regular expressions for extracting IP addresses
|
||||
var ipRegex = regexp.MustCompile(`\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}`)
|
||||
|
||||
// GetSDPMediaIP extracts the media IP from SDP
|
||||
func (s *SDP) GetSDPMediaIP() string {
|
||||
if s.Connection != "" {
|
||||
matches := ipRegex.FindString(s.Connection)
|
||||
if matches != "" {
|
||||
return matches
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
121
internal/ssh/client.go
Normal file
121
internal/ssh/client.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Client wraps the SSH connection
|
||||
type Client struct {
|
||||
config *ssh.ClientConfig
|
||||
address string
|
||||
conn *ssh.Client
|
||||
}
|
||||
|
||||
// Config contains SSH connection parameters
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string // TODO: Add key-based auth support
|
||||
}
|
||||
|
||||
// NewClient creates a new SSH client
|
||||
func NewClient(cfg Config) *Client {
|
||||
config := &ssh.ClientConfig{
|
||||
User: cfg.User,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(cfg.Password),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Implement proper host key verification
|
||||
}
|
||||
|
||||
return &Client{
|
||||
config: config,
|
||||
address: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes the SSH connection
|
||||
func (c *Client) Connect() error {
|
||||
conn, err := ssh.Dial("tcp", c.address, c.config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
c.conn = conn
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the SSH connection
|
||||
func (c *Client) Close() error {
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunCommand runs a command and returns stdout
|
||||
func (c *Client) RunCommand(cmd string) (string, error) {
|
||||
session, err := c.conn.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
output, err := session.CombinedOutput(cmd)
|
||||
if err != nil {
|
||||
return string(output), fmt.Errorf("command failed: %w", err)
|
||||
}
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// StreamCommand runs a command and streams stdout to a writer
|
||||
func (c *Client) StreamCommand(cmd string, stdout io.Writer, stderr io.Writer) error {
|
||||
session, err := c.conn.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
session.Stdout = stdout
|
||||
session.Stderr = stderr
|
||||
|
||||
if err := session.Run(cmd); err != nil {
|
||||
return fmt.Errorf("command failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartCommand starts a command and returns pipes for streaming
|
||||
func (c *Client) StartCommand(cmd string) (io.Reader, io.Reader, func() error, error) {
|
||||
session, err := c.conn.NewSession()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
session.Close()
|
||||
return nil, nil, nil, fmt.Errorf("failed to get stdout: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
session.Close()
|
||||
return nil, nil, nil, fmt.Errorf("failed to get stderr: %w", err)
|
||||
}
|
||||
|
||||
if err := session.Start(cmd); err != nil {
|
||||
session.Close()
|
||||
return nil, nil, nil, fmt.Errorf("failed to start command: %w", err)
|
||||
}
|
||||
|
||||
cleanup := func() error {
|
||||
session.Signal(ssh.SIGTERM)
|
||||
return session.Close()
|
||||
}
|
||||
|
||||
return stdout, stderr, cleanup, nil
|
||||
}
|
||||
216
internal/tui/model.go
Normal file
216
internal/tui/model.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"telephony-inspector/internal/config"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// View represents the current screen in the TUI
|
||||
type View int
|
||||
|
||||
const (
|
||||
ViewDashboard View = iota
|
||||
ViewCapture
|
||||
ViewAnalysis
|
||||
ViewNetworkMap
|
||||
)
|
||||
|
||||
// Model holds the application state
|
||||
type Model struct {
|
||||
currentView View
|
||||
width int
|
||||
height int
|
||||
|
||||
// Network map configuration
|
||||
networkMap *config.NetworkMap
|
||||
|
||||
// Capture state
|
||||
capturing bool
|
||||
packetCount int
|
||||
lastPackets []string // Last N packet summaries
|
||||
|
||||
// Style definitions
|
||||
styles Styles
|
||||
}
|
||||
|
||||
// Styles holds the lipgloss styles for the TUI
|
||||
type Styles struct {
|
||||
Title lipgloss.Style
|
||||
Subtitle lipgloss.Style
|
||||
Active lipgloss.Style
|
||||
Inactive lipgloss.Style
|
||||
Help lipgloss.Style
|
||||
StatusBar lipgloss.Style
|
||||
}
|
||||
|
||||
func defaultStyles() Styles {
|
||||
return Styles{
|
||||
Title: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#7D56F4")).
|
||||
MarginBottom(1),
|
||||
Subtitle: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#AFAFAF")),
|
||||
Active: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#04B575")),
|
||||
Inactive: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262")),
|
||||
Help: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262")).
|
||||
MarginTop(1),
|
||||
StatusBar: lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("#7D56F4")).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Padding(0, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// NewModel creates a new TUI model with default values
|
||||
func NewModel() Model {
|
||||
return Model{
|
||||
currentView: ViewDashboard,
|
||||
networkMap: config.NewNetworkMap(),
|
||||
lastPackets: make([]string, 0, 20),
|
||||
styles: defaultStyles(),
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the model
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages and updates the model
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "1":
|
||||
m.currentView = ViewDashboard
|
||||
case "2":
|
||||
m.currentView = ViewCapture
|
||||
case "3":
|
||||
m.currentView = ViewAnalysis
|
||||
case "4":
|
||||
m.currentView = ViewNetworkMap
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders the TUI
|
||||
func (m Model) View() string {
|
||||
var content string
|
||||
|
||||
switch m.currentView {
|
||||
case ViewDashboard:
|
||||
content = m.viewDashboard()
|
||||
case ViewCapture:
|
||||
content = m.viewCapture()
|
||||
case ViewAnalysis:
|
||||
content = m.viewAnalysis()
|
||||
case ViewNetworkMap:
|
||||
content = m.viewNetworkMap()
|
||||
}
|
||||
|
||||
// Navigation bar
|
||||
nav := m.renderNav()
|
||||
|
||||
// Status bar
|
||||
status := m.styles.StatusBar.Render(" Telephony Inspector v0.1.0 ")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, nav, content, status)
|
||||
}
|
||||
|
||||
func (m Model) renderNav() string {
|
||||
tabs := []string{"[1] Dashboard", "[2] Capture", "[3] Analysis", "[4] Network Map"}
|
||||
var rendered []string
|
||||
|
||||
for i, tab := range tabs {
|
||||
if View(i) == m.currentView {
|
||||
rendered = append(rendered, m.styles.Active.Render(tab))
|
||||
} else {
|
||||
rendered = append(rendered, m.styles.Inactive.Render(tab))
|
||||
}
|
||||
}
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...) + "\n"
|
||||
}
|
||||
|
||||
func (m Model) viewDashboard() string {
|
||||
title := m.styles.Title.Render("📞 Dashboard")
|
||||
|
||||
info := lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.styles.Subtitle.Render("SIP Telephony Inspector"),
|
||||
"",
|
||||
"• Capture SIP traffic via SSH + tcpdump",
|
||||
"• Analyze SIP/SDP packets in real-time",
|
||||
"• Map network topology for better debugging",
|
||||
"",
|
||||
m.styles.Help.Render("Press 1-4 to navigate, q to quit"),
|
||||
)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, info)
|
||||
}
|
||||
|
||||
func (m Model) viewCapture() string {
|
||||
title := m.styles.Title.Render("🔍 Capture")
|
||||
|
||||
status := "Status: "
|
||||
if m.capturing {
|
||||
status += m.styles.Active.Render("● Capturing")
|
||||
} else {
|
||||
status += m.styles.Inactive.Render("○ Stopped")
|
||||
}
|
||||
|
||||
packetInfo := fmt.Sprintf("Packets captured: %d", m.packetCount)
|
||||
|
||||
help := m.styles.Help.Render("[c] Connect SSH [s] Start/Stop Capture [q] Quit")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, status, packetInfo, "", help)
|
||||
}
|
||||
|
||||
func (m Model) viewAnalysis() string {
|
||||
title := m.styles.Title.Render("📊 Analysis")
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left,
|
||||
"Call flows will appear here once packets are captured.",
|
||||
"",
|
||||
"Features:",
|
||||
"• Group packets by Call-ID",
|
||||
"• Visualize SIP transaction flow",
|
||||
"• Decode SDP offers/answers",
|
||||
)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, content)
|
||||
}
|
||||
|
||||
func (m Model) viewNetworkMap() string {
|
||||
title := m.styles.Title.Render("🗺️ Network Map")
|
||||
|
||||
if len(m.networkMap.Nodes) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title,
|
||||
"No network nodes configured.",
|
||||
"",
|
||||
m.styles.Help.Render("[a] Add node [l] Load from file"),
|
||||
)
|
||||
}
|
||||
|
||||
var nodes []string
|
||||
for _, node := range m.networkMap.Nodes {
|
||||
nodes = append(nodes, fmt.Sprintf("• %s (%s): %s", node.Name, node.Type, node.IP))
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, nodes...))
|
||||
}
|
||||
Reference in New Issue
Block a user