feat: Add pcap import, file browser, logging, local capture, and stable call ordering

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-19 14:28:56 +01:00
parent 3e5742d353
commit efb50ffc8e
19 changed files with 2660 additions and 45 deletions

180
internal/capture/local.go Normal file
View File

@@ -0,0 +1,180 @@
package capture
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"telephony-inspector/internal/sip"
)
// LocalCapturer handles SIP packet capture locally via tcpdump
type LocalCapturer struct {
cmd *exec.Cmd
cancel context.CancelFunc
running bool
mu sync.Mutex
// Callbacks
OnPacket func(*sip.Packet)
OnError func(error)
}
// NewLocalCapturer creates a new local capturer
func NewLocalCapturer() *LocalCapturer {
return &LocalCapturer{}
}
// Start begins capturing SIP traffic locally
func (c *LocalCapturer) 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()
ctx, cancel := context.WithCancel(context.Background())
c.cancel = cancel
// Build tcpdump command
// -l: line buffered
// -A: print packet payload in ASCII
// -s 0: capture full packets
args := []string{"-l", "-A", "-s", "0", "-i", iface, "port", fmt.Sprintf("%d", port)}
c.cmd = exec.CommandContext(ctx, "tcpdump", args...)
stdout, err := c.cmd.StdoutPipe()
if err != nil {
c.mu.Lock()
c.running = false
c.mu.Unlock()
return fmt.Errorf("failed to get stdout: %w", err)
}
stderr, err := c.cmd.StderrPipe()
if err != nil {
c.mu.Lock()
c.running = false
c.mu.Unlock()
return fmt.Errorf("failed to get stderr: %w", err)
}
if err := c.cmd.Start(); err != nil {
c.mu.Lock()
c.running = false
c.mu.Unlock()
return fmt.Errorf("failed to start tcpdump: %w", err)
}
// Process stdout in goroutine
go c.processStream(stdout)
// Log stderr
go c.processErrors(stderr)
return nil
}
// Stop stops the capture
func (c *LocalCapturer) Stop() {
c.mu.Lock()
defer c.mu.Unlock()
if !c.running {
return
}
c.running = false
if c.cancel != nil {
c.cancel()
c.cancel = nil
}
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
c.cmd.Wait()
}
}
// IsRunning returns whether capture is active
func (c *LocalCapturer) IsRunning() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.running
}
// Close cleans up resources
func (c *LocalCapturer) Close() error {
c.Stop()
return nil
}
func (c *LocalCapturer) 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")
}
}
// Parse remaining buffer
if buffer.Len() > 0 {
c.parseAndEmit(buffer.String())
}
}
func (c *LocalCapturer) processErrors(r io.Reader) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
text := scanner.Text()
// tcpdump prints "listening on..." to stderr, ignore it
if strings.Contains(text, "listening on") {
continue
}
if c.OnError != nil {
c.OnError(fmt.Errorf("tcpdump: %s", text))
}
}
}
func (c *LocalCapturer) 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)
}
}

View File

@@ -0,0 +1,243 @@
package capture
import (
"fmt"
"os"
"telephony-inspector/internal/logger"
"telephony-inspector/internal/sip"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/pcap"
)
// PcapReader reads and parses pcap files
type PcapReader struct {
path string
handle *pcap.Handle
packets []*sip.Packet
}
// NewPcapReader creates a new pcap reader
func NewPcapReader(path string) *PcapReader {
logger.Info("PcapReader: Creating reader for %s", path)
return &PcapReader{
path: path,
packets: make([]*sip.Packet, 0),
}
}
// ReadAll reads all SIP packets from the pcap file
func (r *PcapReader) ReadAll() ([]*sip.Packet, error) {
logger.Info("PcapReader: Opening file %s", r.path)
// Check if file exists
if _, err := os.Stat(r.path); os.IsNotExist(err) {
logger.Error("PcapReader: File does not exist: %s", r.path)
return nil, fmt.Errorf("file does not exist: %s", r.path)
}
handle, err := pcap.OpenOffline(r.path)
if err != nil {
logger.Error("PcapReader: Failed to open pcap: %v", err)
return nil, fmt.Errorf("failed to open pcap: %w", err)
}
defer handle.Close()
logger.Info("PcapReader: File opened successfully, link type: %v", handle.LinkType())
// Try setting BPF filter (optional)
if err := handle.SetBPFFilter("port 5060 or port 5061"); err != nil {
logger.Warn("PcapReader: Could not set BPF filter: %v (continuing without filter)", err)
} else {
logger.Debug("PcapReader: BPF filter set for SIP ports")
}
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
totalPackets := 0
sipPackets := 0
for packet := range packetSource.Packets() {
totalPackets++
logger.Debug("PcapReader: Processing packet %d, layers: %v", totalPackets, packet.Layers())
sipPacket := r.extractSIPPacket(packet, totalPackets)
if sipPacket != nil {
sipPackets++
r.packets = append(r.packets, sipPacket)
logger.Info("PcapReader: Found SIP packet %d: %s %s", sipPackets,
func() string {
if sipPacket.IsRequest {
return string(sipPacket.Method)
} else {
return fmt.Sprintf("%d", sipPacket.StatusCode)
}
}(),
sipPacket.CallID)
}
}
logger.Info("PcapReader: Finished reading. Total packets: %d, SIP packets: %d", totalPackets, sipPackets)
return r.packets, nil
}
// extractSIPPacket extracts SIP data from a gopacket
func (r *PcapReader) extractSIPPacket(packet gopacket.Packet, packetNum int) *sip.Packet {
// Get network layer for IPs
var srcIP, dstIP string
var srcPort, dstPort int
var payload []byte
if ipLayer := packet.Layer(layers.LayerTypeIPv4); ipLayer != nil {
ip := ipLayer.(*layers.IPv4)
srcIP = ip.SrcIP.String()
dstIP = ip.DstIP.String()
logger.Debug("PcapReader: Packet %d IPv4 %s -> %s", packetNum, srcIP, dstIP)
} else if ipLayer := packet.Layer(layers.LayerTypeIPv6); ipLayer != nil {
ip := ipLayer.(*layers.IPv6)
srcIP = ip.SrcIP.String()
dstIP = ip.DstIP.String()
logger.Debug("PcapReader: Packet %d IPv6 %s -> %s", packetNum, srcIP, dstIP)
} else {
logger.Debug("PcapReader: Packet %d has no IP layer", packetNum)
}
// Get transport layer for ports AND payload
if udpLayer := packet.Layer(layers.LayerTypeUDP); udpLayer != nil {
udp := udpLayer.(*layers.UDP)
srcPort = int(udp.SrcPort)
dstPort = int(udp.DstPort)
payload = udp.Payload
logger.Debug("PcapReader: Packet %d UDP %d -> %d, payload len: %d", packetNum, srcPort, dstPort, len(payload))
} else if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil {
tcp := tcpLayer.(*layers.TCP)
srcPort = int(tcp.SrcPort)
dstPort = int(tcp.DstPort)
payload = tcp.Payload
logger.Debug("PcapReader: Packet %d TCP %d -> %d, payload len: %d", packetNum, srcPort, dstPort, len(payload))
} else {
logger.Debug("PcapReader: Packet %d has no TCP/UDP layer", packetNum)
return nil
}
if len(payload) == 0 {
logger.Debug("PcapReader: Packet %d has empty payload", packetNum)
return nil
}
payloadStr := string(payload)
// Log first 200 chars to see full SIP headers
preview := payloadStr
if len(preview) > 200 {
preview = preview[:200]
}
logger.Debug("PcapReader: Packet %d full payload preview: %q", packetNum, preview)
// Check if it looks like SIP
if !isSIPPayload(payloadStr) {
logger.Debug("PcapReader: Packet %d is not SIP", packetNum)
return nil
}
logger.Debug("PcapReader: Packet %d detected as SIP, parsing...", packetNum)
// Parse the SIP message
sipPacket, err := sip.Parse(payloadStr)
if err != nil {
logger.Warn("PcapReader: Packet %d SIP parse error: %v", packetNum, err)
return nil
}
if sipPacket == nil {
logger.Warn("PcapReader: Packet %d SIP parse returned nil", packetNum)
return nil
}
// Set network info
sipPacket.SourceIP = srcIP
sipPacket.SourcePort = srcPort
sipPacket.DestIP = dstIP
sipPacket.DestPort = dstPort
return sipPacket
}
// isSIPPayload checks if payload looks like a SIP message
func isSIPPayload(payload string) bool {
if len(payload) < 4 {
return false
}
// Check for SIP request methods
methods := []string{"INVITE ", "ACK ", "BYE ", "CANCEL ", "REGISTER ", "OPTIONS ",
"PRACK ", "SUBSCRIBE ", "NOTIFY ", "PUBLISH ", "INFO ", "REFER ", "MESSAGE ", "UPDATE "}
for _, m := range methods {
if len(payload) >= len(m) && payload[:len(m)] == m {
logger.Debug("isSIPPayload: Detected SIP method: %s", m[:len(m)-1])
return true
}
}
// Check for SIP response
if len(payload) >= 7 && payload[:7] == "SIP/2.0" {
logger.Debug("isSIPPayload: Detected SIP response")
return true
}
// Check if this looks like an SDP body (might be reassembled SIP)
if len(payload) >= 4 && payload[:4] == "v=0\r" {
logger.Debug("isSIPPayload: Detected SDP-only payload (no SIP headers)")
// This is SDP without SIP headers - likely a reassembly issue
// Log first 50 chars for debugging
preview := payload
if len(preview) > 50 {
preview = preview[:50]
}
logger.Debug("isSIPPayload: SDP payload: %q", preview)
return false
}
// Log what the payload starts with for debugging
preview := payload
if len(preview) > 20 {
preview = preview[:20]
}
logger.Debug("isSIPPayload: Unrecognized payload start: %q", preview)
return false
}
// GetPacketCount returns number of packets read
func (r *PcapReader) GetPacketCount() int {
return len(r.packets)
}
// Close closes the reader
func (r *PcapReader) Close() error {
if r.handle != nil {
r.handle.Close()
}
return nil
}
// ListPcapFiles lists .pcap files in a directory
func ListPcapFiles(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var files []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if len(name) > 5 && (name[len(name)-5:] == ".pcap" || name[len(name)-7:] == ".pcapng") {
files = append(files, name)
}
}
return files, nil
}

72
internal/config/loader.go Normal file
View File

@@ -0,0 +1,72 @@
package config
import (
"encoding/json"
"os"
)
// LoadNetworkMap loads a network map from a JSON file
func LoadNetworkMap(path string) (*NetworkMap, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var nm NetworkMap
if err := json.Unmarshal(data, &nm); err != nil {
return nil, err
}
return &nm, nil
}
// SaveNetworkMap saves a network map to a JSON file
func SaveNetworkMap(nm *NetworkMap, path string) error {
data, err := json.MarshalIndent(nm, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// DefaultNetworkMapPath returns a default path for the network map file
func DefaultNetworkMapPath() string {
return "network_map.json"
}
// CreateSampleNetworkMap creates a sample network map for testing
func CreateSampleNetworkMap() *NetworkMap {
nm := NewNetworkMap()
nm.AddNode(NetworkNode{
Name: "Asterisk PBX",
IP: "192.168.1.10",
Type: NodeTypePBX,
Description: "Main PBX server",
})
nm.AddNode(NetworkNode{
Name: "Kamailio Proxy",
IP: "192.168.1.20",
Type: NodeTypeProxy,
Aliases: []string{"10.0.0.20"},
Description: "SIP proxy/load balancer",
})
nm.AddNode(NetworkNode{
Name: "RTPEngine",
IP: "192.168.1.30",
Type: NodeTypeMediaServer,
Description: "Media relay server",
})
nm.AddNode(NetworkNode{
Name: "PSTN Gateway",
IP: "192.168.1.40",
Type: NodeTypeGateway,
Description: "Gateway to PSTN",
})
return nm
}

113
internal/logger/logger.go Normal file
View File

@@ -0,0 +1,113 @@
package logger
import (
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"
)
var (
instance *Logger
once sync.Once
)
// Logger handles application logging
type Logger struct {
file *os.File
mu sync.Mutex
path string
}
// Init initializes the logger with timestamp-based filename
func Init() error {
var err error
once.Do(func() {
// Create logs directory
logsDir := "logs"
if mkErr := os.MkdirAll(logsDir, 0755); mkErr != nil {
err = mkErr
return
}
// Create log file with timestamp
timestamp := time.Now().Format("2006-01-02_15-04-05")
filename := filepath.Join(logsDir, fmt.Sprintf("%s.log", timestamp))
f, openErr := os.Create(filename)
if openErr != nil {
err = openErr
return
}
instance = &Logger{
file: f,
path: filename,
}
Info("Logger initialized: %s", filename)
})
return err
}
// Close closes the logger
func Close() {
if instance != nil && instance.file != nil {
instance.file.Close()
}
}
// GetPath returns the log file path
func GetPath() string {
if instance != nil {
return instance.path
}
return ""
}
// log writes a log entry
func log(level, format string, args ...interface{}) {
if instance == nil {
return
}
instance.mu.Lock()
defer instance.mu.Unlock()
timestamp := time.Now().Format("15:04:05.000")
msg := fmt.Sprintf(format, args...)
line := fmt.Sprintf("[%s] [%s] %s\n", timestamp, level, msg)
instance.file.WriteString(line)
instance.file.Sync()
}
// Debug logs a debug message
func Debug(format string, args ...interface{}) {
log("DEBUG", format, args...)
}
// Info logs an info message
func Info(format string, args ...interface{}) {
log("INFO", format, args...)
}
// Warn logs a warning message
func Warn(format string, args ...interface{}) {
log("WARN", format, args...)
}
// Error logs an error message
func Error(format string, args ...interface{}) {
log("ERROR", format, args...)
}
// Writer returns an io.Writer that logs to the file
func Writer() io.Writer {
if instance != nil {
return instance.file
}
return os.Stdout
}

199
internal/sip/callflow.go Normal file
View File

@@ -0,0 +1,199 @@
package sip
import (
"sync"
"time"
)
// CallFlow represents a SIP call with all its packets
type CallFlow struct {
CallID string
Packets []*Packet
StartTime time.Time
EndTime time.Time
// Summary info
From string
To string
State CallState
}
// CallState represents the current state of a call
type CallState string
const (
CallStateInitial CallState = "Initial"
CallStateRinging CallState = "Ringing"
CallStateConnected CallState = "Connected"
CallStateTerminated CallState = "Terminated"
CallStateFailed CallState = "Failed"
)
// CallFlowStore stores and manages call flows
type CallFlowStore struct {
mu sync.RWMutex
flows map[string]*CallFlow
}
// NewCallFlowStore creates a new call flow store
func NewCallFlowStore() *CallFlowStore {
return &CallFlowStore{
flows: make(map[string]*CallFlow),
}
}
// AddPacket adds a packet to the appropriate call flow
func (s *CallFlowStore) AddPacket(p *Packet) *CallFlow {
s.mu.Lock()
defer s.mu.Unlock()
if p.CallID == "" {
return nil
}
flow, exists := s.flows[p.CallID]
if !exists {
flow = &CallFlow{
CallID: p.CallID,
Packets: make([]*Packet, 0),
StartTime: time.Now(),
From: p.From,
To: p.To,
State: CallStateInitial,
}
s.flows[p.CallID] = flow
}
flow.Packets = append(flow.Packets, p)
flow.EndTime = time.Now()
// Update call state based on packet
s.updateState(flow, p)
return flow
}
// updateState updates the call state based on the packet
func (s *CallFlowStore) updateState(flow *CallFlow, p *Packet) {
if p.IsRequest {
switch p.Method {
case MethodINVITE:
if flow.State == CallStateInitial {
flow.State = CallStateInitial
}
case MethodBYE, MethodCANCEL:
flow.State = CallStateTerminated
}
} else {
// Response
switch {
case p.StatusCode >= 100 && p.StatusCode < 200:
if p.StatusCode == 180 || p.StatusCode == 183 {
flow.State = CallStateRinging
}
case p.StatusCode >= 200 && p.StatusCode < 300:
flow.State = CallStateConnected
case p.StatusCode >= 400:
flow.State = CallStateFailed
}
}
}
// GetFlow returns a call flow by Call-ID
func (s *CallFlowStore) GetFlow(callID string) *CallFlow {
s.mu.RLock()
defer s.mu.RUnlock()
return s.flows[callID]
}
// GetAllFlows returns all call flows
func (s *CallFlowStore) GetAllFlows() []*CallFlow {
s.mu.RLock()
defer s.mu.RUnlock()
flows := make([]*CallFlow, 0, len(s.flows))
for _, f := range s.flows {
flows = append(flows, f)
}
return flows
}
// GetRecentFlows returns the N most recent call flows sorted by StartTime (oldest first)
func (s *CallFlowStore) GetRecentFlows(n int) []*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]
}
}
}
}
if len(flows) > n {
flows = flows[:n]
}
return flows
}
// Count returns the number of call flows
func (s *CallFlowStore) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.flows)
}
// Summary returns a string summary of a packet for display
func (p *Packet) Summary() string {
if p.IsRequest {
return string(p.Method)
}
return formatStatusCode(p.StatusCode, p.StatusText)
}
func formatStatusCode(code int, text string) string {
if text != "" {
return text
}
switch code {
case 100:
return "100 Trying"
case 180:
return "180 Ringing"
case 183:
return "183 Session Progress"
case 200:
return "200 OK"
case 400:
return "400 Bad Request"
case 401:
return "401 Unauthorized"
case 403:
return "403 Forbidden"
case 404:
return "404 Not Found"
case 408:
return "408 Request Timeout"
case 480:
return "480 Temporarily Unavailable"
case 486:
return "486 Busy Here"
case 487:
return "487 Request Terminated"
case 488:
return "488 Not Acceptable Here"
case 500:
return "500 Server Internal Error"
case 503:
return "503 Service Unavailable"
default:
return string(rune('0'+code/100)) + "xx"
}
}

View File

@@ -0,0 +1,256 @@
package tui
import (
"os"
"path/filepath"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// FileBrowserModel handles file selection
type FileBrowserModel struct {
currentDir string
entries []os.DirEntry
selected int
filter string // file extension filter (e.g., ".pcap")
height int
offset int
// Results
selectedFile string
cancelled bool
err error
}
// NewFileBrowser creates a new file browser starting at dir
func NewFileBrowser(startDir string, filter string) FileBrowserModel {
if startDir == "" {
startDir, _ = os.Getwd()
}
fb := FileBrowserModel{
currentDir: startDir,
filter: filter,
height: 15,
}
fb.loadDir()
return fb
}
func (m *FileBrowserModel) loadDir() {
entries, err := os.ReadDir(m.currentDir)
if err != nil {
m.err = err
return
}
// Filter and sort entries
var filtered []os.DirEntry
for _, e := range entries {
if e.IsDir() {
filtered = append(filtered, e)
} else if m.filter == "" {
filtered = append(filtered, e)
} else {
name := strings.ToLower(e.Name())
if strings.HasSuffix(name, m.filter) || strings.HasSuffix(name, ".pcapng") {
filtered = append(filtered, e)
}
}
}
// Sort: directories first, then alphabetically
sort.Slice(filtered, func(i, j int) bool {
iDir := filtered[i].IsDir()
jDir := filtered[j].IsDir()
if iDir != jDir {
return iDir
}
return strings.ToLower(filtered[i].Name()) < strings.ToLower(filtered[j].Name())
})
m.entries = filtered
m.selected = 0
m.offset = 0
}
// Init initializes the model
func (m FileBrowserModel) Init() tea.Cmd {
return nil
}
// Update handles messages
func (m FileBrowserModel) Update(msg tea.Msg) (FileBrowserModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "q":
m.cancelled = true
return m, nil
case "up", "k":
if m.selected > 0 {
m.selected--
if m.selected < m.offset {
m.offset = m.selected
}
}
case "down", "j":
if m.selected < len(m.entries)-1 {
m.selected++
if m.selected >= m.offset+m.height {
m.offset = m.selected - m.height + 1
}
}
case "enter":
if len(m.entries) == 0 {
return m, nil
}
entry := m.entries[m.selected]
fullPath := filepath.Join(m.currentDir, entry.Name())
if entry.IsDir() {
m.currentDir = fullPath
m.loadDir()
} else {
m.selectedFile = fullPath
}
case "backspace", "h":
// Go to parent directory
parent := filepath.Dir(m.currentDir)
if parent != m.currentDir {
m.currentDir = parent
m.loadDir()
}
case "home":
home, err := os.UserHomeDir()
if err == nil {
m.currentDir = home
m.loadDir()
}
}
case tea.WindowSizeMsg:
m.height = msg.Height - 10
if m.height < 5 {
m.height = 5
}
}
return m, nil
}
// View renders the file browser
func (m FileBrowserModel) View() string {
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
MarginBottom(1)
pathStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AFAFAF")).
MarginBottom(1)
dirStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#8BE9FD"))
fileStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#F8F8F2"))
selectedStyle := lipgloss.NewStyle().
Bold(true).
Background(lipgloss.Color("#44475A")).
Foreground(lipgloss.Color("#50FA7B"))
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262")).
MarginTop(1)
var b strings.Builder
b.WriteString(titleStyle.Render("📁 Select PCAP File"))
b.WriteString("\n")
b.WriteString(pathStyle.Render(m.currentDir))
b.WriteString("\n\n")
if m.err != nil {
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")).Render("Error: " + m.err.Error()))
b.WriteString("\n")
}
if len(m.entries) == 0 {
b.WriteString("(empty directory)\n")
} else {
// Show entries with scrolling
end := m.offset + m.height
if end > len(m.entries) {
end = len(m.entries)
}
for i := m.offset; i < end; i++ {
entry := m.entries[i]
name := entry.Name()
var style lipgloss.Style
prefix := " "
if entry.IsDir() {
name = "📂 " + name + "/"
style = dirStyle
} else {
name = "📄 " + name
style = fileStyle
}
if i == m.selected {
prefix = "▶ "
style = selectedStyle
}
b.WriteString(prefix + style.Render(name) + "\n")
}
// Show scroll indicator
if len(m.entries) > m.height {
b.WriteString(pathStyle.Render(
strings.Repeat("─", 20) +
" " + string(rune('0'+m.offset/10)) + string(rune('0'+m.offset%10)) +
"/" + string(rune('0'+len(m.entries)/10)) + string(rune('0'+len(m.entries)%10)) + " " +
strings.Repeat("─", 20)))
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓ navigate • Enter select/open • Backspace parent • Esc cancel"))
return b.String()
}
// IsSelected returns true if a file was selected
func (m FileBrowserModel) IsSelected() bool {
return m.selectedFile != ""
}
// IsCancelled returns true if cancelled
func (m FileBrowserModel) IsCancelled() bool {
return m.cancelled
}
// GetSelectedFile returns the selected file path
func (m FileBrowserModel) GetSelectedFile() string {
return m.selectedFile
}
// GetCurrentDir returns current directory
func (m FileBrowserModel) GetCurrentDir() string {
return m.currentDir
}

View File

@@ -2,8 +2,16 @@ package tui
import (
"fmt"
"telephony-inspector/internal/config"
"strconv"
"strings"
"telephony-inspector/internal/capture"
"telephony-inspector/internal/config"
"telephony-inspector/internal/sip"
internalSSH "telephony-inspector/internal/ssh"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
@@ -18,9 +26,31 @@ const (
ViewNetworkMap
)
// SubView for modal states
type SubView int
const (
SubViewNone SubView = iota
SubViewSSHConfig
SubViewAddNode
SubViewCallDetail
SubViewCaptureMenu
SubViewFileBrowser
)
// CaptureMode defines local vs SSH capture
type CaptureMode int
const (
CaptureModeNone CaptureMode = iota
CaptureModeLocal
CaptureModeSSH
)
// Model holds the application state
type Model struct {
currentView View
subView SubView
width int
height int
@@ -28,9 +58,29 @@ type Model struct {
networkMap *config.NetworkMap
// Capture state
capturing bool
packetCount int
lastPackets []string // Last N packet summaries
captureMode CaptureMode
sshConfig SSHConfigModel
capturer *capture.Capturer
localCapturer *capture.LocalCapturer
connected bool
capturing bool
packetCount int
lastPackets []string
captureError string
captureIface string
// Call flow analysis
callFlowStore *sip.CallFlowStore
selectedFlow int
flowList list.Model
// File browser for pcap import
fileBrowser FileBrowserModel
loadedPcapPath string
// Network node input
nodeInput []textinput.Model
nodeInputFocus int
// Style definitions
styles Styles
@@ -44,6 +94,11 @@ type Styles struct {
Inactive lipgloss.Style
Help lipgloss.Style
StatusBar lipgloss.Style
Error lipgloss.Style
Success lipgloss.Style
Box lipgloss.Style
PacketRow lipgloss.Style
CallFlow lipgloss.Style
}
func defaultStyles() Styles {
@@ -66,17 +121,67 @@ func defaultStyles() Styles {
Background(lipgloss.Color("#7D56F4")).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1),
Error: lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF5555")),
Success: lipgloss.NewStyle().
Foreground(lipgloss.Color("#50FA7B")),
Box: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, 2),
PacketRow: lipgloss.NewStyle().
Foreground(lipgloss.Color("#F8F8F2")),
CallFlow: lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#44475A")).
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(),
// Try to load existing network map
nm, err := config.LoadNetworkMap(config.DefaultNetworkMapPath())
if err != nil {
nm = config.NewNetworkMap()
}
return Model{
currentView: ViewDashboard,
subView: SubViewNone,
networkMap: nm,
callFlowStore: sip.NewCallFlowStore(),
lastPackets: make([]string, 0, 50),
sshConfig: NewSSHConfigModel(),
nodeInput: createNodeInputs(),
styles: defaultStyles(),
}
}
func createNodeInputs() []textinput.Model {
inputs := make([]textinput.Model, 4)
inputs[0] = textinput.New()
inputs[0].Placeholder = "Node Name"
inputs[0].Prompt = "Name: "
inputs[0].CharLimit = 64
inputs[1] = textinput.New()
inputs[1].Placeholder = "192.168.1.x"
inputs[1].Prompt = "IP: "
inputs[1].CharLimit = 45
inputs[2] = textinput.New()
inputs[2].Placeholder = "PBX/Proxy/MediaServer/Gateway"
inputs[2].Prompt = "Type: "
inputs[2].CharLimit = 20
inputs[3] = textinput.New()
inputs[3].Placeholder = "Optional description"
inputs[3].Prompt = "Desc: "
inputs[3].CharLimit = 256
return inputs
}
// Init initializes the model
@@ -84,12 +189,30 @@ func (m Model) Init() tea.Cmd {
return nil
}
// PacketMsg is sent when a new packet is received
type PacketMsg struct {
Packet *sip.Packet
}
// ErrorMsg is sent when an error occurs
type ErrorMsg struct {
Error error
}
// Update handles messages and updates the model
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
// Handle subview updates first
if m.subView != SubViewNone {
return m.updateSubView(msg)
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
m.cleanup()
return m, tea.Quit
case "1":
m.currentView = ViewDashboard
@@ -99,18 +222,336 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.currentView = ViewAnalysis
case "4":
m.currentView = ViewNetworkMap
default:
cmd = m.handleViewKeys(msg)
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case PacketMsg:
m.packetCount++
summary := formatPacketSummary(msg.Packet, m.networkMap)
m.lastPackets = append(m.lastPackets, summary)
if len(m.lastPackets) > 50 {
m.lastPackets = m.lastPackets[1:]
}
m.callFlowStore.AddPacket(msg.Packet)
case ErrorMsg:
m.captureError = msg.Error.Error()
}
return m, cmd
}
func (m *Model) handleViewKeys(msg tea.KeyMsg) tea.Cmd {
switch m.currentView {
case ViewCapture:
switch msg.String() {
case "c":
// Show capture mode menu if not capturing
if !m.capturing && m.captureMode == CaptureModeNone {
m.subView = SubViewCaptureMenu
}
case "l":
// Start local capture directly
if !m.capturing {
m.captureMode = CaptureModeLocal
m.captureIface = "any"
return m.startLocalCapture()
}
case "r":
// SSH remote capture
if !m.capturing {
m.subView = SubViewSSHConfig
m.sshConfig = NewSSHConfigModel()
return m.sshConfig.Init()
}
case "s":
if m.capturing {
m.stopCapture()
} else if m.captureMode != CaptureModeNone {
if m.captureMode == CaptureModeLocal {
return m.startLocalCapture()
} else if m.connected {
return m.startSSHCapture()
}
}
case "d":
m.disconnect()
}
case ViewAnalysis:
switch msg.String() {
case "up", "k":
if m.selectedFlow > 0 {
m.selectedFlow--
}
case "down", "j":
flows := m.callFlowStore.GetRecentFlows(20)
if m.selectedFlow < len(flows)-1 {
m.selectedFlow++
}
case "enter":
m.subView = SubViewCallDetail
}
case ViewNetworkMap:
switch msg.String() {
case "a":
m.subView = SubViewAddNode
m.nodeInput = createNodeInputs()
m.nodeInput[0].Focus()
m.nodeInputFocus = 0
case "l":
if nm, err := config.LoadNetworkMap(config.DefaultNetworkMapPath()); err == nil {
m.networkMap = nm
}
case "s":
config.SaveNetworkMap(m.networkMap, config.DefaultNetworkMapPath())
case "g":
m.networkMap = config.CreateSampleNetworkMap()
}
}
return nil
}
func (m *Model) updateSubView(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.subView {
case SubViewCaptureMenu:
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "l", "1":
m.captureMode = CaptureModeLocal
m.captureIface = "any"
m.subView = SubViewNone
return m, m.startLocalCapture()
case "r", "2":
m.subView = SubViewSSHConfig
m.sshConfig = NewSSHConfigModel()
return m, m.sshConfig.Init()
case "p", "3":
// Open file browser for pcap
m.fileBrowser = NewFileBrowser("", ".pcap")
m.subView = SubViewFileBrowser
return m, nil
case "esc", "q":
m.subView = SubViewNone
}
}
return m, nil
case SubViewFileBrowser:
var cmd tea.Cmd
m.fileBrowser, cmd = m.fileBrowser.Update(msg)
if m.fileBrowser.IsSelected() {
// Load the pcap file
pcapPath := m.fileBrowser.GetSelectedFile()
m.loadedPcapPath = pcapPath
m.subView = SubViewNone
// Load packets from pcap
reader := capture.NewPcapReader(pcapPath)
packets, err := reader.ReadAll()
if err != nil {
m.captureError = err.Error()
} else {
m.captureError = ""
m.packetCount = len(packets)
for _, pkt := range packets {
m.callFlowStore.AddPacket(pkt)
summary := formatPacketSummary(pkt, m.networkMap)
m.lastPackets = append(m.lastPackets, summary)
}
}
return m, nil
} else if m.fileBrowser.IsCancelled() {
m.subView = SubViewNone
}
return m, cmd
case SubViewSSHConfig:
var cmd tea.Cmd
m.sshConfig, cmd = m.sshConfig.Update(msg)
if m.sshConfig.IsSubmitted() {
host, port, user, password := m.sshConfig.GetConfig()
portInt, _ := strconv.Atoi(port)
if portInt == 0 {
portInt = 22
}
cfg := internalSSH.Config{
Host: host,
Port: portInt,
User: user,
Password: password,
}
m.capturer = capture.NewCapturer(cfg)
if err := m.capturer.Connect(); err != nil {
m.captureError = err.Error()
} else {
m.connected = true
m.captureMode = CaptureModeSSH
m.captureError = ""
}
m.subView = SubViewNone
} else if m.sshConfig.IsCancelled() {
m.subView = SubViewNone
}
return m, cmd
case SubViewAddNode:
return m.updateNodeInput(msg)
case SubViewCallDetail:
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" || keyMsg.String() == "q" {
m.subView = SubViewNone
}
}
return m, nil
}
return m, nil
}
func (m *Model) updateNodeInput(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "esc":
m.subView = SubViewNone
return m, nil
case "tab", "down":
m.nodeInput[m.nodeInputFocus].Blur()
m.nodeInputFocus = (m.nodeInputFocus + 1) % len(m.nodeInput)
return m, m.nodeInput[m.nodeInputFocus].Focus()
case "shift+tab", "up":
m.nodeInput[m.nodeInputFocus].Blur()
m.nodeInputFocus--
if m.nodeInputFocus < 0 {
m.nodeInputFocus = len(m.nodeInput) - 1
}
return m, m.nodeInput[m.nodeInputFocus].Focus()
case "enter":
if m.nodeInputFocus == len(m.nodeInput)-1 {
// Submit
name := m.nodeInput[0].Value()
ip := m.nodeInput[1].Value()
nodeType := m.nodeInput[2].Value()
desc := m.nodeInput[3].Value()
if name != "" && ip != "" {
m.networkMap.AddNode(config.NetworkNode{
Name: name,
IP: ip,
Type: config.NodeType(nodeType),
Description: desc,
})
}
m.subView = SubViewNone
return m, nil
}
m.nodeInput[m.nodeInputFocus].Blur()
m.nodeInputFocus++
return m, m.nodeInput[m.nodeInputFocus].Focus()
}
}
var cmd tea.Cmd
m.nodeInput[m.nodeInputFocus], cmd = m.nodeInput[m.nodeInputFocus].Update(msg)
return m, cmd
}
func (m *Model) startLocalCapture() tea.Cmd {
m.capturing = true
m.captureError = ""
m.packetCount = 0
m.lastPackets = m.lastPackets[:0]
m.captureMode = CaptureModeLocal
m.localCapturer = capture.NewLocalCapturer()
m.localCapturer.OnPacket = func(p *sip.Packet) {
// Note: In real implementation, use channel + tea.Cmd
}
m.localCapturer.OnError = func(err error) {
m.captureError = err.Error()
}
iface := m.captureIface
if iface == "" {
iface = "any"
}
if err := m.localCapturer.Start(iface, 5060); err != nil {
m.captureError = err.Error()
m.capturing = false
}
return nil
}
func (m *Model) startSSHCapture() tea.Cmd {
m.capturing = true
m.captureError = ""
m.packetCount = 0
m.lastPackets = m.lastPackets[:0]
m.capturer.OnPacket = func(p *sip.Packet) {
// Note: In real implementation, use channel + tea.Cmd
}
m.capturer.OnError = func(err error) {
m.captureError = err.Error()
}
if err := m.capturer.Start("any", 5060); err != nil {
m.captureError = err.Error()
m.capturing = false
}
return nil
}
func (m *Model) stopCapture() {
if m.localCapturer != nil {
m.localCapturer.Stop()
}
if m.capturer != nil {
m.capturer.Stop()
}
m.capturing = false
}
func (m *Model) disconnect() {
m.stopCapture()
if m.localCapturer != nil {
m.localCapturer.Close()
m.localCapturer = nil
}
if m.capturer != nil {
m.capturer.Close()
m.capturer = nil
}
m.connected = false
m.captureMode = CaptureModeNone
}
func (m *Model) cleanup() {
m.disconnect()
}
// View renders the TUI
func (m Model) View() string {
// Handle subview modals
if m.subView != SubViewNone {
return m.renderSubView()
}
var content string
switch m.currentView {
@@ -124,15 +565,94 @@ func (m Model) View() string {
content = m.viewNetworkMap()
}
// Navigation bar
nav := m.renderNav()
// Status bar
status := m.styles.StatusBar.Render(" Telephony Inspector v0.1.0 ")
status := m.renderStatusBar()
return lipgloss.JoinVertical(lipgloss.Left, nav, content, status)
}
func (m Model) renderSubView() string {
switch m.subView {
case SubViewCaptureMenu:
return m.styles.Box.Render(m.renderCaptureMenu())
case SubViewFileBrowser:
return m.styles.Box.Render(m.fileBrowser.View())
case SubViewSSHConfig:
return m.styles.Box.Render(m.sshConfig.View())
case SubViewAddNode:
return m.styles.Box.Render(m.renderAddNodeForm())
case SubViewCallDetail:
return m.renderCallDetail()
}
return ""
}
func (m Model) renderCaptureMenu() string {
var b strings.Builder
b.WriteString(m.styles.Title.Render("📡 Select Capture Mode"))
b.WriteString("\n\n")
b.WriteString(" [1] [L]ocal - Capture on this machine (requires tcpdump)\n")
b.WriteString(" [2] [R]emote - Capture via SSH on remote server\n")
b.WriteString(" [3] [P]cap - Import pcap file from disk\n")
b.WriteString("\n")
b.WriteString(m.styles.Help.Render("Press 1/L, 2/R, or 3/P to select • Esc to cancel"))
return b.String()
}
func (m Model) renderAddNodeForm() string {
var b strings.Builder
b.WriteString(m.styles.Title.Render(" Add Network Node"))
b.WriteString("\n\n")
for _, input := range m.nodeInput {
b.WriteString(input.View())
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(m.styles.Help.Render("Tab navigate • Enter submit • Esc cancel"))
return b.String()
}
func (m Model) renderCallDetail() string {
flows := m.callFlowStore.GetRecentFlows(20)
if m.selectedFlow >= len(flows) || len(flows) == 0 {
return m.styles.Box.Render("No call selected\n\nPress Esc to go back")
}
flow := flows[m.selectedFlow]
var b strings.Builder
b.WriteString(m.styles.Title.Render("📞 Call Detail"))
b.WriteString("\n\n")
b.WriteString(fmt.Sprintf("Call-ID: %s\n", flow.CallID))
b.WriteString(fmt.Sprintf("From: %s\n", flow.From))
b.WriteString(fmt.Sprintf("To: %s\n", flow.To))
b.WriteString(fmt.Sprintf("State: %s\n", flow.State))
b.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets)))
b.WriteString("Transaction Flow:\n")
for i, pkt := range flow.Packets {
arrow := "→"
if !pkt.IsRequest {
arrow = "←"
}
b.WriteString(fmt.Sprintf(" %d. %s %s\n", i+1, arrow, pkt.Summary()))
// Show SDP info if present
if pkt.SDP != nil {
mediaIP := pkt.SDP.GetSDPMediaIP()
if mediaIP != "" {
label := m.networkMap.LabelForIP(mediaIP)
b.WriteString(fmt.Sprintf(" SDP Media: %s\n", label))
}
}
}
b.WriteString("\n")
b.WriteString(m.styles.Help.Render("Press Esc to go back"))
return m.styles.Box.Render(b.String())
}
func (m Model) renderNav() string {
tabs := []string{"[1] Dashboard", "[2] Capture", "[3] Analysis", "[4] Network Map"}
var rendered []string
@@ -148,52 +668,163 @@ func (m Model) renderNav() string {
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...) + "\n"
}
func (m Model) renderStatusBar() string {
var parts []string
parts = append(parts, " Telephony Inspector v0.1.0 ")
if m.connected {
parts = append(parts, m.styles.Success.Render(" SSH: Connected "))
}
if m.capturing {
parts = append(parts, m.styles.Active.Render(fmt.Sprintf(" Capturing: %d pkts ", m.packetCount)))
}
if m.captureError != "" {
parts = append(parts, m.styles.Error.Render(" Error: "+m.captureError+" "))
}
return m.styles.StatusBar.Render(strings.Join(parts, "|"))
}
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"),
)
var stats []string
stats = append(stats, m.styles.Subtitle.Render("SIP Telephony Inspector"))
stats = append(stats, "")
return lipgloss.JoinVertical(lipgloss.Left, title, info)
if m.connected {
stats = append(stats, m.styles.Success.Render("✓ SSH Connected"))
} else {
stats = append(stats, m.styles.Inactive.Render("○ SSH Disconnected"))
}
stats = append(stats, fmt.Sprintf("Network Nodes: %d", len(m.networkMap.Nodes)))
stats = append(stats, fmt.Sprintf("Active Calls: %d", m.callFlowStore.Count()))
stats = append(stats, fmt.Sprintf("Packets Captured: %d", m.packetCount))
stats = append(stats, "")
stats = append(stats, "Quick Start:")
stats = append(stats, " 1. Go to [2] Capture → Press 'c' to connect SSH")
stats = append(stats, " 2. Press 's' to start capturing SIP traffic")
stats = append(stats, " 3. Go to [3] Analysis to view call flows")
stats = append(stats, " 4. Go to [4] Network Map to label IPs")
stats = append(stats, "")
stats = append(stats, m.styles.Help.Render("Press 1-4 to navigate, q to quit"))
return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, stats...))
}
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")
var lines []string
// Mode indicator
switch m.captureMode {
case CaptureModeLocal:
lines = append(lines, m.styles.Success.Render("● Mode: Local capture"))
case CaptureModeSSH:
if m.connected {
lines = append(lines, m.styles.Success.Render("● Mode: SSH (connected)"))
} else {
lines = append(lines, m.styles.Inactive.Render("○ Mode: SSH (disconnected)"))
}
default:
lines = append(lines, m.styles.Inactive.Render("○ No capture mode selected"))
}
packetInfo := fmt.Sprintf("Packets captured: %d", m.packetCount)
// Capture status
if m.capturing {
mode := "local"
if m.captureMode == CaptureModeSSH {
mode = "SSH"
}
lines = append(lines, m.styles.Active.Render(fmt.Sprintf("● Capturing on port 5060 (%s)", mode)))
} else if m.captureMode != CaptureModeNone {
lines = append(lines, m.styles.Inactive.Render("○ Capture stopped"))
}
help := m.styles.Help.Render("[c] Connect SSH [s] Start/Stop Capture [q] Quit")
lines = append(lines, fmt.Sprintf("Packets: %d", m.packetCount))
lines = append(lines, "")
return lipgloss.JoinVertical(lipgloss.Left, title, status, packetInfo, "", help)
// Last packets
if len(m.lastPackets) > 0 {
lines = append(lines, "Recent Packets:")
start := 0
if len(m.lastPackets) > 15 {
start = len(m.lastPackets) - 15
}
for _, pkt := range m.lastPackets[start:] {
lines = append(lines, " "+pkt)
}
}
lines = append(lines, "")
// Help
var help string
if m.captureMode == CaptureModeNone {
help = "[c] Choose mode [l] Local [r] Remote SSH [q] Quit"
} else if !m.capturing {
help = "[s] Start [c] Change mode [d] Disconnect [q] Quit"
} else {
help = "[s] Stop [d] Disconnect [q] Quit"
}
lines = append(lines, m.styles.Help.Render(help))
return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, lines...))
}
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",
)
flows := m.callFlowStore.GetRecentFlows(20)
return lipgloss.JoinVertical(lipgloss.Left, title, content)
if len(flows) == 0 {
return lipgloss.JoinVertical(lipgloss.Left, title,
"No calls captured yet.",
"",
"Start capturing on the Capture tab to see call flows here.",
"",
m.styles.Help.Render("Press 2 to go to Capture"))
}
var lines []string
lines = append(lines, fmt.Sprintf("Calls: %d", len(flows)))
lines = append(lines, "")
for i, flow := range flows {
prefix := " "
style := m.styles.Inactive
if i == m.selectedFlow {
prefix = "▶ "
style = m.styles.Active
}
stateIcon := "○"
switch flow.State {
case sip.CallStateRinging:
stateIcon = "◐"
case sip.CallStateConnected:
stateIcon = "●"
case sip.CallStateTerminated:
stateIcon = "◯"
case sip.CallStateFailed:
stateIcon = "✕"
}
summary := fmt.Sprintf("%s%s %s → %s [%d pkts]",
prefix, stateIcon,
truncate(extractUser(flow.From), 15),
truncate(extractUser(flow.To), 15),
len(flow.Packets))
lines = append(lines, style.Render(summary))
}
lines = append(lines, "")
lines = append(lines, m.styles.Help.Render("↑/↓ select • Enter details • q quit"))
return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, lines...))
}
func (m Model) viewNetworkMap() string {
@@ -203,14 +834,71 @@ func (m Model) viewNetworkMap() string {
return lipgloss.JoinVertical(lipgloss.Left, title,
"No network nodes configured.",
"",
m.styles.Help.Render("[a] Add node [l] Load from file"),
)
"Add nodes to label IPs in your SIP infrastructure.",
"",
m.styles.Help.Render("[a] Add node [l] Load file [g] Generate sample"))
}
var nodes []string
var lines []string
for _, node := range m.networkMap.Nodes {
nodes = append(nodes, fmt.Sprintf("• %s (%s): %s", node.Name, node.Type, node.IP))
icon := "○"
switch node.Type {
case config.NodeTypePBX:
icon = "☎"
case config.NodeTypeProxy:
icon = "⇄"
case config.NodeTypeMediaServer:
icon = "♪"
case config.NodeTypeGateway:
icon = "⬚"
}
line := fmt.Sprintf(" %s %s (%s): %s", icon, node.Name, node.Type, node.IP)
if node.Description != "" {
line += " - " + node.Description
}
lines = append(lines, line)
}
return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, nodes...))
lines = append(lines, "")
lines = append(lines, m.styles.Help.Render("[a] Add [s] Save [l] Load [g] Sample [q] Quit"))
return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, lines...))
}
// Helper functions
func formatPacketSummary(p *sip.Packet, nm *config.NetworkMap) string {
src := nm.LabelForIP(p.SourceIP)
dst := nm.LabelForIP(p.DestIP)
if p.IsRequest {
return fmt.Sprintf("%s → %s: %s", src, dst, p.Method)
}
return fmt.Sprintf("%s → %s: %d %s", src, dst, p.StatusCode, p.StatusText)
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-1] + "…"
}
func extractUser(sipAddr string) string {
// Extract user from "Display Name" <sip:user@host>
if idx := strings.Index(sipAddr, "<sip:"); idx >= 0 {
start := idx + 5
end := strings.Index(sipAddr[start:], "@")
if end > 0 {
return sipAddr[start : start+end]
}
}
if idx := strings.Index(sipAddr, "sip:"); idx >= 0 {
start := idx + 4
end := strings.Index(sipAddr[start:], "@")
if end > 0 {
return sipAddr[start : start+end]
}
}
return sipAddr
}

180
internal/tui/ssh_config.go Normal file
View File

@@ -0,0 +1,180 @@
package tui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// SSHConfigModel handles SSH connection input
type SSHConfigModel struct {
inputs []textinput.Model
focusIndex int
submitted bool
cancelled bool
// Parsed values
Host string
Port string
User string
Password string
}
const (
inputHost = iota
inputPort
inputUser
inputPassword
)
// NewSSHConfigModel creates a new SSH config input model
func NewSSHConfigModel() SSHConfigModel {
inputs := make([]textinput.Model, 4)
// Host input
inputs[inputHost] = textinput.New()
inputs[inputHost].Placeholder = "192.168.1.100"
inputs[inputHost].Focus()
inputs[inputHost].CharLimit = 256
inputs[inputHost].Width = 30
inputs[inputHost].Prompt = "Host: "
// Port input
inputs[inputPort] = textinput.New()
inputs[inputPort].Placeholder = "22"
inputs[inputPort].CharLimit = 5
inputs[inputPort].Width = 10
inputs[inputPort].Prompt = "Port: "
inputs[inputPort].SetValue("22")
// User input
inputs[inputUser] = textinput.New()
inputs[inputUser].Placeholder = "root"
inputs[inputUser].CharLimit = 64
inputs[inputUser].Width = 20
inputs[inputUser].Prompt = "User: "
// Password input
inputs[inputPassword] = textinput.New()
inputs[inputPassword].Placeholder = "password"
inputs[inputPassword].CharLimit = 128
inputs[inputPassword].Width = 30
inputs[inputPassword].Prompt = "Password: "
inputs[inputPassword].EchoMode = textinput.EchoPassword
inputs[inputPassword].EchoCharacter = '•'
return SSHConfigModel{
inputs: inputs,
focusIndex: 0,
}
}
// Init initializes the model
func (m SSHConfigModel) Init() tea.Cmd {
return textinput.Blink
}
// Update handles messages
func (m SSHConfigModel) Update(msg tea.Msg) (SSHConfigModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
m.cancelled = true
return m, nil
case "tab", "down":
m.focusIndex++
if m.focusIndex >= len(m.inputs) {
m.focusIndex = 0
}
return m, m.updateFocus()
case "shift+tab", "up":
m.focusIndex--
if m.focusIndex < 0 {
m.focusIndex = len(m.inputs) - 1
}
return m, m.updateFocus()
case "enter":
if m.focusIndex == len(m.inputs)-1 {
// Submit on last field
m.submitted = true
m.Host = m.inputs[inputHost].Value()
m.Port = m.inputs[inputPort].Value()
m.User = m.inputs[inputUser].Value()
m.Password = m.inputs[inputPassword].Value()
return m, nil
}
// Move to next field
m.focusIndex++
return m, m.updateFocus()
}
}
// Update focused input
cmd := m.updateInputs(msg)
return m, cmd
}
func (m *SSHConfigModel) updateFocus() tea.Cmd {
cmds := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
if i == m.focusIndex {
cmds[i] = m.inputs[i].Focus()
} else {
m.inputs[i].Blur()
}
}
return tea.Batch(cmds...)
}
func (m *SSHConfigModel) updateInputs(msg tea.Msg) tea.Cmd {
cmds := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
}
return tea.Batch(cmds...)
}
// View renders the SSH config form
func (m SSHConfigModel) View() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
MarginBottom(1)
b.WriteString(titleStyle.Render("🔌 SSH Connection"))
b.WriteString("\n\n")
for i := range m.inputs {
b.WriteString(m.inputs[i].View())
b.WriteString("\n")
}
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#626262"))
b.WriteString("\n")
b.WriteString(helpStyle.Render("Tab/↑↓ navigate • Enter submit • Esc cancel"))
return b.String()
}
// IsSubmitted returns true if the form was submitted
func (m SSHConfigModel) IsSubmitted() bool {
return m.submitted
}
// IsCancelled returns true if the form was cancelled
func (m SSHConfigModel) IsCancelled() bool {
return m.cancelled
}
// GetConfig returns the SSH config values
func (m SSHConfigModel) GetConfig() (host, port, user, password string) {
return m.Host, m.Port, m.User, m.Password
}