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