feat: Add pcap import, file browser, logging, local capture, and stable call ordering
This commit is contained in:
180
internal/capture/local.go
Normal file
180
internal/capture/local.go
Normal 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)
|
||||
}
|
||||
}
|
||||
243
internal/capture/pcap_reader.go
Normal file
243
internal/capture/pcap_reader.go
Normal 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
72
internal/config/loader.go
Normal 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
113
internal/logger/logger.go
Normal 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
199
internal/sip/callflow.go
Normal 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"
|
||||
}
|
||||
}
|
||||
256
internal/tui/file_browser.go
Normal file
256
internal/tui/file_browser.go
Normal 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
|
||||
}
|
||||
@@ -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
180
internal/tui/ssh_config.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user