fix: udp handling and ipv4 and ipv6 filtering
This commit is contained in:
@@ -246,11 +246,6 @@ func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connecti
|
||||
raddr = ipv6ToString(info.raddr6)
|
||||
}
|
||||
|
||||
state := ""
|
||||
if info.sock_type == C.SOCK_STREAM {
|
||||
state = tcpStateToString(int(info.state))
|
||||
}
|
||||
|
||||
if laddr == "0.0.0.0" || laddr == "::" {
|
||||
laddr = "*"
|
||||
}
|
||||
@@ -258,6 +253,18 @@ func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connecti
|
||||
raddr = "*"
|
||||
}
|
||||
|
||||
state := ""
|
||||
if info.sock_type == C.SOCK_STREAM {
|
||||
state = tcpStateToString(int(info.state))
|
||||
} else if info.sock_type == C.SOCK_DGRAM {
|
||||
// udp is connectionless - infer state from remote address
|
||||
if raddr == "*" && int(info.rport) == 0 {
|
||||
state = "LISTEN"
|
||||
} else {
|
||||
state = "ESTABLISHED"
|
||||
}
|
||||
}
|
||||
|
||||
conn := Connection{
|
||||
TS: time.Now(),
|
||||
Proto: proto,
|
||||
|
||||
@@ -226,6 +226,13 @@ func parseProcNet(path, proto string, ipVersion int, inodeMap map[int64]*process
|
||||
|
||||
inode, _ := strconv.ParseInt(fields[9], 10, 64)
|
||||
|
||||
// refine udp state: if unconnected and remote is wildcard, it's listening
|
||||
if strings.HasPrefix(proto, "udp") && state == "UNCONNECTED" {
|
||||
if remoteAddr == "*" && remotePort == 0 {
|
||||
state = "LISTEN"
|
||||
}
|
||||
}
|
||||
|
||||
conn := Connection{
|
||||
TS: time.Now(),
|
||||
Proto: proto,
|
||||
@@ -277,13 +284,22 @@ func parseState(hexState, proto string) string {
|
||||
if s, exists := tcpStates[state]; exists {
|
||||
return s
|
||||
}
|
||||
} else {
|
||||
if state == 0x07 {
|
||||
return "CLOSE"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// udp states - udp is connectionless so the kernel reuses tcp state values
|
||||
// with different meanings:
|
||||
// 0x07 (TCP_CLOSE) = unconnected socket, typically bound and listening
|
||||
// 0x01 (TCP_ESTABLISHED) = "connected" socket (connect() was called)
|
||||
udpStates := map[int64]string{
|
||||
0x01: "ESTABLISHED",
|
||||
0x07: "UNCONNECTED",
|
||||
}
|
||||
|
||||
if s, exists := udpStates[state]; exists {
|
||||
return s
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ func (f *FilterOptions) IsEmpty() bool {
|
||||
}
|
||||
|
||||
func (f *FilterOptions) Matches(c Connection) bool {
|
||||
if f.Proto != "" && !strings.EqualFold(c.Proto, f.Proto) {
|
||||
if f.Proto != "" && !matchesProto(c.Proto, f.Proto) {
|
||||
return false
|
||||
}
|
||||
if f.State != "" && !strings.EqualFold(c.State, f.State) {
|
||||
@@ -104,6 +104,30 @@ func containsIgnoreCase(s, substr string) bool {
|
||||
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
|
||||
}
|
||||
|
||||
// checks if a connection's protocol matches the filter.
|
||||
// treats "tcp" as matching "tcp" and "tcp6", same for "udp"/"udp6"
|
||||
func matchesProto(connProto, filterProto string) bool {
|
||||
connLower := strings.ToLower(connProto)
|
||||
filterLower := strings.ToLower(filterProto)
|
||||
|
||||
// exact match
|
||||
if connLower == filterLower {
|
||||
return true
|
||||
}
|
||||
|
||||
// "tcp" matches both "tcp" and "tcp6"
|
||||
if filterLower == "tcp" && (connLower == "tcp" || connLower == "tcp6") {
|
||||
return true
|
||||
}
|
||||
|
||||
// "udp" matches both "udp" and "udp6"
|
||||
if filterLower == "udp" && (connLower == "udp" || connLower == "udp6") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func matchesContains(c Connection, query string) bool {
|
||||
q := strings.ToLower(query)
|
||||
return containsIgnoreCase(c.Process, q) ||
|
||||
|
||||
@@ -162,7 +162,7 @@ func getDefaultTestConnections() []Connection {
|
||||
UID: 25,
|
||||
Proto: "udp",
|
||||
IPVersion: "IPv4",
|
||||
State: "CONNECTED",
|
||||
State: "LISTEN",
|
||||
Laddr: "0.0.0.0",
|
||||
Lport: 53,
|
||||
Raddr: "*",
|
||||
@@ -358,7 +358,7 @@ func GetTestFixtures() []TestFixture {
|
||||
PID: 2,
|
||||
Process: "udp-server",
|
||||
Proto: "udp",
|
||||
State: "CONNECTED",
|
||||
State: "LISTEN",
|
||||
Laddr: "0.0.0.0",
|
||||
Lport: 53,
|
||||
Interface: "eth0",
|
||||
|
||||
@@ -111,16 +111,17 @@ func compareConnections(a, b Connection, field SortField) bool {
|
||||
func stateOrder(state string) int {
|
||||
order := map[string]int{
|
||||
"LISTEN": 0,
|
||||
"ESTABLISHED": 1,
|
||||
"SYN_SENT": 2,
|
||||
"SYN_RECV": 3,
|
||||
"FIN_WAIT1": 4,
|
||||
"FIN_WAIT2": 5,
|
||||
"TIME_WAIT": 6,
|
||||
"CLOSE_WAIT": 7,
|
||||
"LAST_ACK": 8,
|
||||
"CLOSING": 9,
|
||||
"CLOSED": 10,
|
||||
"UNCONNECTED": 1, // udp sockets bound but not connected to a specific peer
|
||||
"ESTABLISHED": 2,
|
||||
"SYN_SENT": 3,
|
||||
"SYN_RECV": 4,
|
||||
"FIN_WAIT1": 5,
|
||||
"FIN_WAIT2": 6,
|
||||
"TIME_WAIT": 7,
|
||||
"CLOSE_WAIT": 8,
|
||||
"LAST_ACK": 9,
|
||||
"CLOSING": 10,
|
||||
"CLOSED": 11,
|
||||
}
|
||||
|
||||
if o, exists := order[strings.ToUpper(state)]; exists {
|
||||
|
||||
303
internal/tui/model_test.go
Normal file
303
internal/tui/model_test.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"snitch/internal/collector"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/x/exp/teatest"
|
||||
)
|
||||
|
||||
func TestTUI_InitialState(t *testing.T) {
|
||||
m := New(Options{
|
||||
Theme: "dark",
|
||||
Interval: time.Second,
|
||||
})
|
||||
|
||||
if m.showTCP != true {
|
||||
t.Error("expected showTCP to be true by default")
|
||||
}
|
||||
if m.showUDP != true {
|
||||
t.Error("expected showUDP to be true by default")
|
||||
}
|
||||
if m.showListening != true {
|
||||
t.Error("expected showListening to be true by default")
|
||||
}
|
||||
if m.showEstablished != true {
|
||||
t.Error("expected showEstablished to be true by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_FilterOptions(t *testing.T) {
|
||||
m := New(Options{
|
||||
Theme: "dark",
|
||||
Interval: time.Second,
|
||||
TCP: true,
|
||||
UDP: false,
|
||||
FilterSet: true,
|
||||
})
|
||||
|
||||
if m.showTCP != true {
|
||||
t.Error("expected showTCP to be true")
|
||||
}
|
||||
if m.showUDP != false {
|
||||
t.Error("expected showUDP to be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_MatchesFilters(t *testing.T) {
|
||||
m := New(Options{
|
||||
Theme: "dark",
|
||||
Interval: time.Second,
|
||||
TCP: true,
|
||||
UDP: false,
|
||||
Listening: true,
|
||||
Established: false,
|
||||
FilterSet: true,
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
conn collector.Connection
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "tcp listen matches",
|
||||
conn: collector.Connection{Proto: "tcp", State: "LISTEN"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "tcp6 listen matches",
|
||||
conn: collector.Connection{Proto: "tcp6", State: "LISTEN"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "udp listen does not match",
|
||||
conn: collector.Connection{Proto: "udp", State: "LISTEN"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "tcp established does not match",
|
||||
conn: collector.Connection{Proto: "tcp", State: "ESTABLISHED"},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := m.matchesFilters(tc.conn)
|
||||
if result != tc.expected {
|
||||
t.Errorf("matchesFilters() = %v, want %v", result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_MatchesSearch(t *testing.T) {
|
||||
m := New(Options{Theme: "dark"})
|
||||
m.searchQuery = "firefox"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
conn collector.Connection
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "process name matches",
|
||||
conn: collector.Connection{Process: "firefox"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "process name case insensitive",
|
||||
conn: collector.Connection{Process: "Firefox"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
conn: collector.Connection{Process: "chrome"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "matches in address",
|
||||
conn: collector.Connection{Raddr: "firefox.com"},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := m.matchesSearch(tc.conn)
|
||||
if result != tc.expected {
|
||||
t.Errorf("matchesSearch() = %v, want %v", result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_KeyBindings(t *testing.T) {
|
||||
tm := teatest.NewTestModel(t, New(Options{Theme: "dark", Interval: time.Hour}))
|
||||
|
||||
// test quit with 'q'
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
||||
|
||||
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*3))
|
||||
}
|
||||
|
||||
func TestTUI_ToggleFilters(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
|
||||
// initial state: all filters on
|
||||
if m.showTCP != true || m.showUDP != true {
|
||||
t.Fatal("expected all protocol filters on initially")
|
||||
}
|
||||
|
||||
// toggle TCP with 't'
|
||||
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.showTCP != false {
|
||||
t.Error("expected showTCP to be false after toggle")
|
||||
}
|
||||
|
||||
// toggle UDP with 'u'
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.showUDP != false {
|
||||
t.Error("expected showUDP to be false after toggle")
|
||||
}
|
||||
|
||||
// toggle listening with 'l'
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.showListening != false {
|
||||
t.Error("expected showListening to be false after toggle")
|
||||
}
|
||||
|
||||
// toggle established with 'e'
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.showEstablished != false {
|
||||
t.Error("expected showEstablished to be false after toggle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_HelpToggle(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
|
||||
if m.showHelp != false {
|
||||
t.Fatal("expected showHelp to be false initially")
|
||||
}
|
||||
|
||||
// toggle help with '?'
|
||||
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.showHelp != true {
|
||||
t.Error("expected showHelp to be true after toggle")
|
||||
}
|
||||
|
||||
// toggle help off
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.showHelp != false {
|
||||
t.Error("expected showHelp to be false after second toggle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_CursorNavigation(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
|
||||
// add some test data
|
||||
m.connections = []collector.Connection{
|
||||
{PID: 1, Process: "proc1", Proto: "tcp", State: "LISTEN"},
|
||||
{PID: 2, Process: "proc2", Proto: "tcp", State: "LISTEN"},
|
||||
{PID: 3, Process: "proc3", Proto: "tcp", State: "LISTEN"},
|
||||
}
|
||||
|
||||
if m.cursor != 0 {
|
||||
t.Fatal("expected cursor at 0 initially")
|
||||
}
|
||||
|
||||
// move down with 'j'
|
||||
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.cursor != 1 {
|
||||
t.Errorf("expected cursor at 1 after down, got %d", m.cursor)
|
||||
}
|
||||
|
||||
// move down again
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.cursor != 2 {
|
||||
t.Errorf("expected cursor at 2 after second down, got %d", m.cursor)
|
||||
}
|
||||
|
||||
// move up with 'k'
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.cursor != 1 {
|
||||
t.Errorf("expected cursor at 1 after up, got %d", m.cursor)
|
||||
}
|
||||
|
||||
// go to top with 'g'
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.cursor != 0 {
|
||||
t.Errorf("expected cursor at 0 after 'g', got %d", m.cursor)
|
||||
}
|
||||
|
||||
// go to bottom with 'G'
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.cursor != 2 {
|
||||
t.Errorf("expected cursor at 2 after 'G', got %d", m.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_WindowResize(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
|
||||
newModel, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.width != 120 {
|
||||
t.Errorf("expected width 120, got %d", m.width)
|
||||
}
|
||||
if m.height != 40 {
|
||||
t.Errorf("expected height 40, got %d", m.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_ViewRenders(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
m.width = 120
|
||||
m.height = 40
|
||||
|
||||
m.connections = []collector.Connection{
|
||||
{PID: 1234, Process: "nginx", Proto: "tcp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 80},
|
||||
}
|
||||
|
||||
// main view should render without panic
|
||||
view := m.View()
|
||||
if view == "" {
|
||||
t.Error("expected non-empty view")
|
||||
}
|
||||
|
||||
// help view
|
||||
m.showHelp = true
|
||||
helpView := m.View()
|
||||
if helpView == "" {
|
||||
t.Error("expected non-empty help view")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user