diff --git a/README.md b/README.md index 32b1506..ed516bb 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ a friendlier `ss` / `netstat` for humans. inspect network connections with a clean tui or styled tables. +![snitch demo](demo/demo.gif) + ## install ### go diff --git a/cmd/stats.go b/cmd/stats.go index 24b7c12..05ebaea 100644 --- a/cmd/stats.go +++ b/cmd/stats.go @@ -44,6 +44,10 @@ var ( statsInterval time.Duration statsCount int statsNoHeaders bool + statsTCP bool + statsUDP bool + statsListen bool + statsEstab bool ) var statsCmd = &cobra.Command{ @@ -70,6 +74,18 @@ func runStatsCommand(args []string) { filters.IPv4 = ipv4 filters.IPv6 = ipv6 + // apply shortcut flags + if statsTCP && !statsUDP { + filters.Proto = "tcp" + } else if statsUDP && !statsTCP { + filters.Proto = "udp" + } + if statsListen && !statsEstab { + filters.State = "LISTEN" + } else if statsEstab && !statsListen { + filters.State = "ESTABLISHED" + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -297,4 +313,10 @@ func init() { statsCmd.Flags().BoolVar(&statsNoHeaders, "no-headers", false, "Omit headers for table/csv output") statsCmd.Flags().BoolVarP(&ipv4, "ipv4", "4", false, "Only show IPv4 connections") statsCmd.Flags().BoolVarP(&ipv6, "ipv6", "6", false, "Only show IPv6 connections") + + // shortcut filters + statsCmd.Flags().BoolVarP(&statsTCP, "tcp", "t", false, "Show only TCP connections") + statsCmd.Flags().BoolVarP(&statsUDP, "udp", "u", false, "Show only UDP connections") + statsCmd.Flags().BoolVarP(&statsListen, "listen", "l", false, "Show only listening sockets") + statsCmd.Flags().BoolVarP(&statsEstab, "established", "e", false, "Show only established connections") } diff --git a/cmd/testdata/golden/mixed_protocols_json.golden b/cmd/testdata/golden/mixed_protocols_json.golden index cc77186..8daf789 100644 --- a/cmd/testdata/golden/mixed_protocols_json.golden +++ b/cmd/testdata/golden/mixed_protocols_json.golden @@ -28,7 +28,7 @@ "uid": 0, "proto": "udp", "ipversion": "", - "state": "CONNECTED", + "state": "LISTEN", "laddr": "0.0.0.0", "lport": 53, "raddr": "", diff --git a/cmd/testdata/golden/mixed_protocols_table.golden b/cmd/testdata/golden/mixed_protocols_table.golden index 87ed55f..f5342d9 100644 --- a/cmd/testdata/golden/mixed_protocols_table.golden +++ b/cmd/testdata/golden/mixed_protocols_table.golden @@ -1,4 +1,4 @@ PID PROCESS USER PROTO STATE LADDR LPORT RADDR RPORT 1 tcp-server tcp LISTEN 0.0.0.0 http 0 -2 udp-server udp CONNECTED 0.0.0.0 domain 0 +2 udp-server udp LISTEN 0.0.0.0 domain 0 3 unix-app unix CONNECTED /tmp/test.sock 0 0 diff --git a/cmd/testdata/golden/udp_filter_json.golden b/cmd/testdata/golden/udp_filter_json.golden index 1fc764c..e2bb96d 100644 --- a/cmd/testdata/golden/udp_filter_json.golden +++ b/cmd/testdata/golden/udp_filter_json.golden @@ -7,7 +7,7 @@ "uid": 0, "proto": "udp", "ipversion": "", - "state": "CONNECTED", + "state": "LISTEN", "laddr": "0.0.0.0", "lport": 53, "raddr": "", diff --git a/demo/Dockerfile b/demo/Dockerfile new file mode 100644 index 0000000..700c95a --- /dev/null +++ b/demo/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 + +# build stage - compile snitch +FROM golang:1.25.0-bookworm AS builder +WORKDIR /src +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go build -o snitch . + +# runtime stage - official vhs image has ffmpeg, chromium, ttyd pre-installed +FROM ghcr.io/charmbracelet/vhs + +# install only lightweight tools for fake services +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update --allow-releaseinfo-change && apt-get install -y --no-install-recommends \ + netcat-openbsd \ + procps \ + socat \ + nginx-light + +WORKDIR /app + +# copy built binary from builder +COPY --from=builder /src/snitch /app/snitch + +# copy demo files +COPY demo/demo.tape /app/demo.tape +COPY demo/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +ENV TERM=xterm-256color +ENV COLORTERM=truecolor + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..5536364 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,45 @@ +# Demo Recording + +This directory contains files for recording the snitch demo GIF in a controlled Docker environment. + +## Files + +- `Dockerfile` - builds snitch and sets up fake network services +- `demo.tape` - VHS script that records the demo +- `entrypoint.sh` - starts fake services before recording + +## Recording the Demo + +From the project root: + +```bash +# build the demo image +docker build -f demo/Dockerfile -t snitch-demo . + +# run and output demo.gif to this directory +docker run --rm -v $(pwd)/demo:/output snitch-demo +``` + +The resulting `demo.gif` will be saved to this directory. + +## Fake Services + +The container runs several fake services to demonstrate snitch: + +| Service | Port | Protocol | +|---------|------|----------| +| nginx | 80 | TCP | +| web app | 8080 | TCP | +| node | 3000 | TCP | +| postgres| 5432 | TCP | +| redis | 6379 | TCP | +| mongo | 27017| TCP | +| mdns | 5353 | UDP | +| ssdp | 1900 | UDP | + +Plus some simulated established connections between services. + +## Customizing + +Edit `demo.tape` to change what's shown in the demo. See [VHS documentation](https://github.com/charmbracelet/vhs) for available commands. + diff --git a/demo/demo.gif b/demo/demo.gif new file mode 100644 index 0000000..803b2cb Binary files /dev/null and b/demo/demo.gif differ diff --git a/demo/demo.tape b/demo/demo.tape new file mode 100644 index 0000000..2e3ce51 --- /dev/null +++ b/demo/demo.tape @@ -0,0 +1,99 @@ +# VHS tape file for snitch demo +# run with: docker build -f demo/Dockerfile -t snitch-demo . && docker run -v $(pwd)/demo:/output snitch-demo + +Output demo.gif + +Set Shell "bash" +Set FontSize 14 +Set FontFamily "DejaVu Sans Mono" +Set Width 1400 +Set Height 700 +Set Theme "Catppuccin Frappe" +Set Padding 15 +Set Framerate 24 +Set TypingSpeed 40ms + +# force color output +Env TERM "xterm-256color" +Env COLORTERM "truecolor" +Env CLICOLOR "1" +Env CLICOLOR_FORCE "1" +Env FORCE_COLOR "1" + +# launch snitch +Type "./snitch top" +Enter +Sleep 2s + +# navigate down through connections +Down +Sleep 400ms +Down +Sleep 400ms +Down +Sleep 400ms +Down +Sleep 400ms +Down +Sleep 1s + +# open detail view for selected connection +Enter +Sleep 2s + +# close detail view +Escape +Sleep 1s + +# search for nginx +Type "/" +Sleep 500ms +Type "nginx" +Sleep 1s +Enter +Sleep 2s + +# clear search +Type "/" +Sleep 300ms +Escape +Sleep 1s + +# filter: hide udp, show only tcp +Type "u" +Sleep 1.5s + +# show only listening connections +Type "e" +Sleep 1.5s +Type "o" +Sleep 1.5s + +# reset to show all +Type "a" +Sleep 1.5s + +# cycle through sort options +Type "s" +Sleep 1s +Type "s" +Sleep 1s +Type "s" +Sleep 1s + +# reverse sort order +Type "S" +Sleep 1.5s + +# show help screen +Type "?" +Sleep 3s + +# close help +Escape +Sleep 1s + +# quit +Type "q" +Sleep 300ms + diff --git a/demo/entrypoint.sh b/demo/entrypoint.sh new file mode 100644 index 0000000..9dcf99b --- /dev/null +++ b/demo/entrypoint.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# entrypoint script that creates fake network services for demo + +set -e + +echo "starting demo services..." + +# start nginx on port 80 +nginx & +sleep 0.5 + +# start some listening services with socat (stderr silenced) +socat TCP-LISTEN:8080,fork,reuseaddr SYSTEM:"echo HTTP/1.1 200 OK" 2>/dev/null & +socat TCP-LISTEN:3000,fork,reuseaddr SYSTEM:"echo hello" 2>/dev/null & +socat TCP-LISTEN:5432,fork,reuseaddr SYSTEM:"echo postgres" 2>/dev/null & +socat TCP-LISTEN:6379,fork,reuseaddr SYSTEM:"echo redis" 2>/dev/null & +socat TCP-LISTEN:27017,fork,reuseaddr SYSTEM:"echo mongo" 2>/dev/null & + +# create some "established" connections by connecting to our own services +sleep 0.5 +(while true; do echo "ping" | nc -q 1 localhost 8080 2>/dev/null; sleep 2; done) >/dev/null 2>&1 & +(while true; do echo "ping" | nc -q 1 localhost 3000 2>/dev/null; sleep 2; done) >/dev/null 2>&1 & +(while true; do curl -s http://localhost:80 >/dev/null 2>&1; sleep 3; done) & + +# udp listeners +socat UDP-LISTEN:5353,fork,reuseaddr SYSTEM:"echo mdns" 2>/dev/null & +socat UDP-LISTEN:1900,fork,reuseaddr SYSTEM:"echo ssdp" 2>/dev/null & + +sleep 1 +echo "services started, recording demo..." + +# run vhs to record the demo +cd /app +vhs demo.tape + +echo "demo recorded, copying output..." + +# output will be in /app/demo.gif +cp /app/demo.gif /output/demo.gif 2>/dev/null || echo "output copied" + +echo "done!" diff --git a/flake.nix b/flake.nix index 576fa2d..12d4a66 100644 --- a/flake.nix +++ b/flake.nix @@ -91,7 +91,7 @@ in { default = pkgs.mkShell { - packages = [ pkgs.go_1_25 pkgs.git ]; + packages = [ pkgs.go_1_25 pkgs.git pkgs.vhs ]; GOTOOLCHAIN = "local"; shellHook = '' echo "go toolchain: $(go version)" diff --git a/go.mod b/go.mod index 4edb900..0a67746 100644 --- a/go.mod +++ b/go.mod @@ -11,10 +11,13 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/aymanbagabas/go-udiff v0.3.1 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.9.3 // 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/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect + github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect @@ -43,11 +46,11 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/sync v0.15.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/text v0.28.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c819bd2..0dcb0b7 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,25 @@ 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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= 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/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= 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.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +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/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383 h1:nCaK/2JwS/z7GoS3cIQlNYIC6MMzWLC8zkT6JkGvkn0= +github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -90,8 +100,12 @@ go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -103,6 +117,8 @@ golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/internal/collector/collector_darwin.go b/internal/collector/collector_darwin.go index 4d90c86..90291ef 100644 --- a/internal/collector/collector_darwin.go +++ b/internal/collector/collector_darwin.go @@ -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, diff --git a/internal/collector/collector_linux.go b/internal/collector/collector_linux.go index 7e84e58..8e68477 100644 --- a/internal/collector/collector_linux.go +++ b/internal/collector/collector_linux.go @@ -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 "" } diff --git a/internal/collector/filter.go b/internal/collector/filter.go index eaaf31a..8959e51 100644 --- a/internal/collector/filter.go +++ b/internal/collector/filter.go @@ -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) || diff --git a/internal/collector/mock.go b/internal/collector/mock.go index 0b1789d..e78ae5d 100644 --- a/internal/collector/mock.go +++ b/internal/collector/mock.go @@ -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", diff --git a/internal/collector/sort.go b/internal/collector/sort.go index 2376388..3f0434b 100644 --- a/internal/collector/sort.go +++ b/internal/collector/sort.go @@ -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 { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..c02513f --- /dev/null +++ b/internal/tui/model_test.go @@ -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") + } +} +