fix: udp handling and ipv4 and ipv6 filtering

This commit is contained in:
Karol Broda
2025-12-17 17:15:52 +01:00
parent 7f2bd068ad
commit c543a8a4e9
19 changed files with 646 additions and 31 deletions

View File

@@ -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,

View File

@@ -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 ""
}

View File

@@ -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) ||

View File

@@ -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",

View File

@@ -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
View 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")
}
}