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