feat: Initial project structure with TUI, SSH, SIP parser, and capture modules

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-19 13:46:27 +01:00
commit 3e5742d353
9 changed files with 932 additions and 0 deletions

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

View 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
View 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
View 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
View 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...))
}