├── .envrc ├── cmd ├── testdata │ └── golden │ │ ├── empty_json.golden │ │ ├── stats_empty_table.golden │ │ ├── empty_table.golden │ │ ├── csv_output.golden │ │ ├── listen_state_table.golden │ │ ├── tcp_filter_table.golden │ │ ├── wide_table.golden │ │ ├── single_tcp_table.golden │ │ ├── stats_mixed_table.golden │ │ ├── mixed_protocols_table.golden │ │ ├── stats_mixed_csv.golden │ │ ├── README.md │ │ ├── stats_mixed_json.golden │ │ ├── udp_filter_json.golden │ │ ├── single_tcp_json.golden │ │ └── mixed_protocols_json.golden ├── json.go ├── themes.go ├── version.go ├── root.go ├── top.go ├── watch.go ├── trace.go ├── runtime.go ├── ls_test.go └── stats.go ├── demo ├── demo.gif ├── Dockerfile ├── README.md ├── entrypoint.sh └── demo.tape ├── main.go ├── .gitignore ├── Makefile ├── internal ├── collector │ ├── collector_test_helpers_linux.go │ ├── types.go │ ├── filter_test.go │ ├── collector.go │ ├── collector_test.go │ ├── sort.go │ ├── sort_test.go │ ├── query.go │ ├── filter.go │ ├── query_test.go │ ├── collector_darwin.go │ └── mock.go ├── theme │ ├── dracula.go │ ├── readme.md │ ├── one_dark.go │ ├── ansi.go │ ├── nord.go │ ├── gruvbox.go │ ├── solarized.go │ ├── mono.go │ ├── tokyo_night.go │ ├── catppuccin.go │ ├── palette.go │ └── theme.go ├── tui │ ├── helpers.go │ ├── symbols.go │ ├── messages.go │ ├── keys.go │ └── model.go ├── color │ ├── color_test.go │ └── color.go ├── errutil │ └── errutil.go ├── resolver │ ├── resolver_bench_test.go │ ├── resolver.go │ └── resolver_test.go ├── testutil │ └── testutil.go └── config │ └── config.go ├── flake.lock ├── .goreleaser-darwin.yaml ├── LICENSE ├── .github └── workflows │ ├── release.yaml │ └── ci.yaml ├── .goreleaser.yaml ├── go.mod ├── install.sh ├── flake.nix └── README.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake . --impure 2 | -------------------------------------------------------------------------------- /cmd/testdata/golden/empty_json.golden: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /demo/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karol-broda/snitch/HEAD/demo/demo.gif -------------------------------------------------------------------------------- /cmd/testdata/golden/stats_empty_table.golden: -------------------------------------------------------------------------------- 1 | TIMESTAMP NORMALIZED_TIMESTAMP 2 | TOTAL CONNECTIONS 0 3 | 4 | -------------------------------------------------------------------------------- /cmd/testdata/golden/empty_table.golden: -------------------------------------------------------------------------------- 1 | PID PROCESS USER PROTO STATE LADDR LPORT RADDR RPORT 2 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/karol-broda/snitch/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } -------------------------------------------------------------------------------- /cmd/testdata/golden/csv_output.golden: -------------------------------------------------------------------------------- 1 | PID,PROCESS,USER,PROTO,STATE,LADDR,LPORT,RADDR,RPORT 2 | 1234,test-app,test-user,tcp,ESTABLISHED,localhost,8080,localhost,9090 3 | -------------------------------------------------------------------------------- /cmd/testdata/golden/listen_state_table.golden: -------------------------------------------------------------------------------- 1 | PID PROCESS USER PROTO STATE LADDR LPORT RADDR RPORT 2 | 1 tcp-server tcp LISTEN 0.0.0.0 http 0 3 | -------------------------------------------------------------------------------- /cmd/testdata/golden/tcp_filter_table.golden: -------------------------------------------------------------------------------- 1 | PID PROCESS USER PROTO STATE LADDR LPORT RADDR RPORT 2 | 1 tcp-server tcp LISTEN 0.0.0.0 http 0 3 | -------------------------------------------------------------------------------- /cmd/testdata/golden/wide_table.golden: -------------------------------------------------------------------------------- 1 | PID PROCESS USER PROTO STATE LADDR LPORT RADDR RPORT 2 | 1234 test-app test-user tcp ESTABLISHED localhost 8080 localhost 9090 3 | -------------------------------------------------------------------------------- /cmd/testdata/golden/single_tcp_table.golden: -------------------------------------------------------------------------------- 1 | PID PROCESS USER PROTO STATE LADDR LPORT RADDR RPORT 2 | 1234 test-app test-user tcp ESTABLISHED localhost 8080 localhost 9090 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # binaries 2 | snitch 3 | dist/ 4 | 5 | # build 6 | *.o 7 | *.a 8 | *.so 9 | 10 | # test 11 | *.test 12 | *.out 13 | coverage.txt 14 | coverage.html 15 | 16 | # ide 17 | .idea/ 18 | .vscode/ 19 | *.swp 20 | *.swo 21 | *~ 22 | 23 | # os 24 | .DS_Store 25 | Thumbs.db 26 | 27 | # go 28 | vendor/ 29 | 30 | # nix 31 | result 32 | 33 | # misc 34 | *.log 35 | *.tmp 36 | 37 | -------------------------------------------------------------------------------- /cmd/testdata/golden/stats_mixed_table.golden: -------------------------------------------------------------------------------- 1 | TIMESTAMP NORMALIZED_TIMESTAMP 2 | TOTAL CONNECTIONS 3 3 | 4 | BY PROTOCOL: 5 | PROTO COUNT 6 | TCP 1 7 | UDP 1 8 | UNIX 1 9 | 10 | BY STATE: 11 | STATE COUNT 12 | CONNECTED 2 13 | LISTEN 1 14 | 15 | BY PROCESS (TOP 10): 16 | PID PROCESS COUNT 17 | 1 tcp-server 1 18 | 2 udp-server 1 19 | 3 unix-app 1 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build test lint demo demo-build demo-run clean 2 | 3 | build: 4 | go build -o snitch . 5 | 6 | test: 7 | go test ./... 8 | 9 | lint: 10 | golangci-lint run 11 | 12 | demo: demo-build demo-run 13 | 14 | demo-build: 15 | docker build -f demo/Dockerfile -t snitch-demo . 16 | 17 | demo-run: 18 | docker run --rm -v $(PWD)/demo:/output snitch-demo 19 | 20 | clean: 21 | rm -f snitch 22 | rm -f demo/demo.gif 23 | 24 | -------------------------------------------------------------------------------- /cmd/json.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var jsonCmd = &cobra.Command{ 8 | Use: "json [filters...]", 9 | Short: "One-shot json output of connections", 10 | Long: `One-shot json output of connections. This is an alias for "ls -o json".`, 11 | Run: func(cmd *cobra.Command, args []string) { 12 | runListCommand("json", args) 13 | }, 14 | } 15 | 16 | func init() { 17 | rootCmd.AddCommand(jsonCmd) 18 | addFilterFlags(jsonCmd) 19 | } -------------------------------------------------------------------------------- /internal/collector/collector_test_helpers_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package collector 4 | 5 | // clearUserCache clears the user lookup cache for testing 6 | func clearUserCache() { 7 | userCache.Lock() 8 | userCache.m = make(map[int]string) 9 | userCache.Unlock() 10 | } 11 | 12 | // userCacheSize returns the number of cached user entries 13 | func userCacheSize() int { 14 | userCache.RLock() 15 | defer userCache.RUnlock() 16 | return len(userCache.m) 17 | } 18 | 19 | -------------------------------------------------------------------------------- /cmd/testdata/golden/mixed_protocols_table.golden: -------------------------------------------------------------------------------- 1 | PID PROCESS USER PROTO STATE LADDR LPORT RADDR RPORT 2 | 1 tcp-server tcp LISTEN 0.0.0.0 http 0 3 | 2 udp-server udp LISTEN 0.0.0.0 domain 0 4 | 3 unix-app unix CONNECTED /tmp/test.sock 0 0 5 | -------------------------------------------------------------------------------- /cmd/testdata/golden/stats_mixed_csv.golden: -------------------------------------------------------------------------------- 1 | timestamp,metric,key,value 2 | NORMALIZED_TIMESTAMP,total,,3 3 | NORMALIZED_TIMESTAMP,proto,tcp,1 4 | NORMALIZED_TIMESTAMP,proto,udp,1 5 | NORMALIZED_TIMESTAMP,proto,unix,1 6 | NORMALIZED_TIMESTAMP,state,LISTEN,1 7 | NORMALIZED_TIMESTAMP,state,CONNECTED,2 8 | NORMALIZED_TIMESTAMP,process,tcp-server,1 9 | NORMALIZED_TIMESTAMP,process,udp-server,1 10 | NORMALIZED_TIMESTAMP,process,unix-app,1 11 | NORMALIZED_TIMESTAMP,interface,eth0,2 12 | NORMALIZED_TIMESTAMP,interface,unix,1 13 | -------------------------------------------------------------------------------- /cmd/themes.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/karol-broda/snitch/internal/theme" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var themesCmd = &cobra.Command{ 11 | Use: "themes", 12 | Short: "List available themes", 13 | Run: func(cmd *cobra.Command, args []string) { 14 | fmt.Printf("Available themes (default: %s):\n\n", theme.DefaultTheme) 15 | for _, name := range theme.ListThemes() { 16 | fmt.Printf(" %s\n", name) 17 | } 18 | }, 19 | } 20 | 21 | func init() { 22 | rootCmd.AddCommand(themesCmd) 23 | } 24 | 25 | -------------------------------------------------------------------------------- /cmd/testdata/golden/README.md: -------------------------------------------------------------------------------- 1 | # Golden Files 2 | 3 | This directory contains golden files for output contract verification tests. 4 | 5 | These files are automatically generated and should not be edited manually. 6 | To regenerate them, run: 7 | 8 | go test ./cmd -update-golden 9 | 10 | ## Files 11 | 12 | - *_table.golden: Table format output 13 | - *_json.golden: JSON format output 14 | - *_csv.golden: CSV format output 15 | - *_wide.golden: Wide table format output 16 | - stats_*.golden: Statistics command output 17 | 18 | Each file represents expected output for specific test scenarios. 19 | -------------------------------------------------------------------------------- /internal/theme/dracula.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | // dracula theme 4 | // https://draculatheme.com/ 5 | var paletteDracula = Palette{ 6 | Name: "dracula", 7 | 8 | Fg: "#f8f8f2", // foreground 9 | FgMuted: "#f8f8f2", // foreground 10 | FgSubtle: "#6272a4", // comment 11 | Bg: "#282a36", // background 12 | BgMuted: "#44475a", // selection 13 | Border: "#44475a", // selection 14 | 15 | Red: "#ff5555", 16 | Green: "#50fa7b", 17 | Yellow: "#f1fa8c", 18 | Blue: "#6272a4", // dracula uses comment color for blue tones 19 | Magenta: "#bd93f9", // purple 20 | Cyan: "#8be9fd", 21 | Orange: "#ffb86c", 22 | Gray: "#6272a4", // comment 23 | } 24 | 25 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1766201043, 6 | "narHash": "sha256-eplAP+rorKKd0gNjV3rA6+0WMzb1X1i16F5m5pASnjA=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "b3aad468604d3e488d627c0b43984eb60e75e782", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixos-25.11", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /internal/theme/readme.md: -------------------------------------------------------------------------------- 1 | # theme Palettes 2 | 3 | the color palettes in this directory were generated by an LLM agent (Claude Opus 4.5) using web search to fetch the official color specifications from each themes documentation 4 | as it is with llm agents its possible the colors may be wrong 5 | 6 | Sources: 7 | - [Catppuccin](https://github.com/catppuccin/catppuccin) 8 | - [Dracula](https://draculatheme.com/) 9 | - [Gruvbox](https://github.com/morhetz/gruvbox) 10 | - [Nord](https://www.nordtheme.com/) 11 | - [One Dark](https://github.com/atom/one-dark-syntax) 12 | - [Solarized](https://ethanschoonover.com/solarized/) 13 | - [Tokyo Night](https://github.com/enkia/tokyo-night-vscode-theme) 14 | 15 | -------------------------------------------------------------------------------- /internal/theme/one_dark.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | // one dark theme (atom editor) 4 | // https://github.com/atom/atom/tree/master/packages/one-dark-syntax 5 | var paletteOneDark = Palette{ 6 | Name: "one-dark", 7 | 8 | Fg: "#abb2bf", // foreground 9 | FgMuted: "#9da5b4", // foreground muted 10 | FgSubtle: "#5c6370", // comment 11 | Bg: "#282c34", // background 12 | BgMuted: "#21252b", // gutter background 13 | Border: "#3e4451", // selection 14 | 15 | Red: "#e06c75", 16 | Green: "#98c379", 17 | Yellow: "#e5c07b", 18 | Blue: "#61afef", 19 | Magenta: "#c678dd", // purple 20 | Cyan: "#56b6c2", 21 | Orange: "#d19a66", 22 | Gray: "#5c6370", // comment 23 | } 24 | 25 | -------------------------------------------------------------------------------- /internal/theme/ansi.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | // ANSI palette uses standard terminal colors (0-15) 4 | // this allows the theme to inherit from the user's terminal color scheme 5 | var paletteANSI = Palette{ 6 | Name: "ansi", 7 | 8 | Fg: "15", // bright white 9 | FgMuted: "7", // white 10 | FgSubtle: "8", // bright black (gray) 11 | Bg: "0", // black 12 | BgMuted: "0", // black 13 | Border: "8", // bright black (gray) 14 | 15 | Red: "1", // red 16 | Green: "2", // green 17 | Yellow: "3", // yellow 18 | Blue: "4", // blue 19 | Magenta: "5", // magenta 20 | Cyan: "6", // cyan 21 | Orange: "3", // yellow (ansi has no orange, fallback to yellow) 22 | Gray: "8", // bright black 23 | } 24 | 25 | -------------------------------------------------------------------------------- /cmd/testdata/golden/stats_mixed_json.golden: -------------------------------------------------------------------------------- 1 | { 2 | "ts": "2025-08-25T19:24:18.541531+02:00", 3 | "total": 3, 4 | "by_proto": { 5 | "tcp": 1, 6 | "udp": 1, 7 | "unix": 1 8 | }, 9 | "by_state": { 10 | "CONNECTED": 2, 11 | "LISTEN": 1 12 | }, 13 | "by_proc": [ 14 | { 15 | "pid": 1, 16 | "process": "tcp-server", 17 | "count": 1 18 | }, 19 | { 20 | "pid": 2, 21 | "process": "udp-server", 22 | "count": 1 23 | }, 24 | { 25 | "pid": 3, 26 | "process": "unix-app", 27 | "count": 1 28 | } 29 | ], 30 | "by_if": [ 31 | { 32 | "if": "eth0", 33 | "count": 2 34 | }, 35 | { 36 | "if": "unix", 37 | "count": 1 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.goreleaser-darwin.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | project_name: snitch 4 | 5 | builds: 6 | - id: darwin 7 | env: 8 | - CGO_ENABLED=1 9 | goos: 10 | - darwin 11 | goarch: 12 | - amd64 13 | - arm64 14 | ldflags: 15 | - -s -w 16 | - -X snitch/cmd.Version={{.Version}} 17 | - -X snitch/cmd.Commit={{.ShortCommit}} 18 | - -X snitch/cmd.Date={{.Date}} 19 | 20 | archives: 21 | - formats: 22 | - tar.gz 23 | name_template: >- 24 | {{ .ProjectName }}_ 25 | {{- .Version }}_ 26 | {{- .Os }}_ 27 | {{- .Arch }} 28 | files: 29 | - LICENSE 30 | - README.md 31 | 32 | release: 33 | github: 34 | owner: karol-broda 35 | name: snitch 36 | draft: false 37 | prerelease: auto 38 | mode: append 39 | 40 | -------------------------------------------------------------------------------- /internal/theme/nord.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | // nord theme 4 | // https://www.nordtheme.com/ 5 | var paletteNord = Palette{ 6 | Name: "nord", 7 | 8 | Fg: "#eceff4", // snow storm - nord6 9 | FgMuted: "#d8dee9", // snow storm - nord4 10 | FgSubtle: "#4c566a", // polar night - nord3 11 | Bg: "#2e3440", // polar night - nord0 12 | BgMuted: "#3b4252", // polar night - nord1 13 | Border: "#434c5e", // polar night - nord2 14 | 15 | Red: "#bf616a", // aurora - nord11 16 | Green: "#a3be8c", // aurora - nord14 17 | Yellow: "#ebcb8b", // aurora - nord13 18 | Blue: "#81a1c1", // frost - nord9 19 | Magenta: "#b48ead", // aurora - nord15 20 | Cyan: "#88c0d0", // frost - nord8 21 | Orange: "#d08770", // aurora - nord12 22 | Gray: "#4c566a", // polar night - nord3 23 | } 24 | 25 | -------------------------------------------------------------------------------- /internal/collector/types.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import "time" 4 | 5 | type Connection struct { 6 | TS time.Time `json:"ts"` 7 | PID int `json:"pid"` 8 | Process string `json:"process"` 9 | User string `json:"user"` 10 | UID int `json:"uid"` 11 | Proto string `json:"proto"` 12 | IPVersion string `json:"ipversion"` 13 | State string `json:"state"` 14 | Laddr string `json:"laddr"` 15 | Lport int `json:"lport"` 16 | Raddr string `json:"raddr"` 17 | Rport int `json:"rport"` 18 | Interface string `json:"interface"` 19 | RxBytes int64 `json:"rx_bytes"` 20 | TxBytes int64 `json:"tx_bytes"` 21 | RttMs float64 `json:"rtt_ms"` 22 | Mark string `json:"mark"` 23 | Namespace string `json:"namespace"` 24 | Inode int64 `json:"inode"` 25 | } 26 | -------------------------------------------------------------------------------- /internal/tui/helpers.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "regexp" 5 | "github.com/karol-broda/snitch/internal/collector" 6 | "strings" 7 | ) 8 | 9 | func truncate(s string, max int) string { 10 | if len(s) <= max { 11 | return s 12 | } 13 | if max <= 2 { 14 | return s[:max] 15 | } 16 | return s[:max-1] + SymbolEllipsis 17 | } 18 | 19 | var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) 20 | 21 | func stripAnsi(s string) string { 22 | return ansiRegex.ReplaceAllString(s, "") 23 | } 24 | 25 | func containsIgnoreCase(s, substr string) bool { 26 | return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) 27 | } 28 | 29 | func sortFieldLabel(f collector.SortField) string { 30 | switch f { 31 | case collector.SortByLport: 32 | return "port" 33 | case collector.SortByProcess: 34 | return "proc" 35 | case collector.SortByPID: 36 | return "pid" 37 | case collector.SortByState: 38 | return "state" 39 | case collector.SortByProto: 40 | return "proto" 41 | default: 42 | return "port" 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/fatih/color" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/karol-broda/snitch/internal/errutil" 11 | ) 12 | 13 | var ( 14 | Version = "dev" 15 | Commit = "none" 16 | Date = "unknown" 17 | ) 18 | 19 | var versionCmd = &cobra.Command{ 20 | Use: "version", 21 | Short: "Show version/build info", 22 | Run: func(cmd *cobra.Command, args []string) { 23 | bold := color.New(color.Bold) 24 | cyan := color.New(color.FgCyan) 25 | faint := color.New(color.Faint) 26 | 27 | errutil.Print(bold, "snitch ") 28 | errutil.Println(cyan, Version) 29 | fmt.Println() 30 | 31 | errutil.Print(faint, " commit ") 32 | fmt.Println(Commit) 33 | 34 | errutil.Print(faint, " built ") 35 | fmt.Println(Date) 36 | 37 | errutil.Print(faint, " go ") 38 | fmt.Println(runtime.Version()) 39 | 40 | errutil.Print(faint, " os ") 41 | fmt.Printf("%s/%s\n", runtime.GOOS, runtime.GOARCH) 42 | }, 43 | } 44 | 45 | func init() { 46 | rootCmd.AddCommand(versionCmd) 47 | } 48 | -------------------------------------------------------------------------------- /internal/theme/gruvbox.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | // gruvbox dark 4 | // https://github.com/morhetz/gruvbox 5 | var paletteGruvboxDark = Palette{ 6 | Name: "gruvbox-dark", 7 | 8 | Fg: "#ebdbb2", // fg 9 | FgMuted: "#d5c4a1", // fg2 10 | FgSubtle: "#a89984", // fg4 11 | Bg: "#282828", // bg 12 | BgMuted: "#3c3836", // bg1 13 | Border: "#504945", // bg2 14 | 15 | Red: "#fb4934", 16 | Green: "#b8bb26", 17 | Yellow: "#fabd2f", 18 | Blue: "#83a598", 19 | Magenta: "#d3869b", // purple 20 | Cyan: "#8ec07c", // aqua 21 | Orange: "#fe8019", 22 | Gray: "#928374", 23 | } 24 | 25 | // gruvbox light 26 | var paletteGruvboxLight = Palette{ 27 | Name: "gruvbox-light", 28 | 29 | Fg: "#3c3836", // fg 30 | FgMuted: "#504945", // fg2 31 | FgSubtle: "#7c6f64", // fg4 32 | Bg: "#fbf1c7", // bg 33 | BgMuted: "#ebdbb2", // bg1 34 | Border: "#d5c4a1", // bg2 35 | 36 | Red: "#cc241d", 37 | Green: "#98971a", 38 | Yellow: "#d79921", 39 | Blue: "#458588", 40 | Magenta: "#b16286", // purple 41 | Cyan: "#689d6a", // aqua 42 | Orange: "#d65d0e", 43 | Gray: "#928374", 44 | } 45 | 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Karol Broda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /demo/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | # build stage - compile snitch 4 | FROM golang:1.25.0-bookworm AS builder 5 | WORKDIR /src 6 | COPY . . 7 | RUN --mount=type=cache,target=/go/pkg/mod \ 8 | --mount=type=cache,target=/root/.cache/go-build \ 9 | go build -o snitch . 10 | 11 | # runtime stage - official vhs image has ffmpeg, chromium, ttyd pre-installed 12 | FROM ghcr.io/charmbracelet/vhs 13 | 14 | # install only lightweight tools for fake services 15 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 16 | --mount=type=cache,target=/var/lib/apt,sharing=locked \ 17 | apt-get update --allow-releaseinfo-change && apt-get install -y --no-install-recommends \ 18 | netcat-openbsd \ 19 | procps \ 20 | socat \ 21 | nginx-light 22 | 23 | WORKDIR /app 24 | 25 | # copy built binary from builder 26 | COPY --from=builder /src/snitch /app/snitch 27 | 28 | # copy demo files 29 | COPY demo/demo.tape /app/demo.tape 30 | COPY demo/entrypoint.sh /app/entrypoint.sh 31 | RUN chmod +x /app/entrypoint.sh 32 | 33 | ENV TERM=xterm-256color 34 | ENV COLORTERM=truecolor 35 | 36 | ENTRYPOINT ["/app/entrypoint.sh"] 37 | -------------------------------------------------------------------------------- /internal/theme/solarized.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | // solarized dark theme 4 | // https://ethanschoonover.com/solarized/ 5 | var paletteSolarizedDark = Palette{ 6 | Name: "solarized-dark", 7 | 8 | Fg: "#839496", // base0 9 | FgMuted: "#93a1a1", // base1 10 | FgSubtle: "#586e75", // base01 11 | Bg: "#002b36", // base03 12 | BgMuted: "#073642", // base02 13 | Border: "#073642", // base02 14 | 15 | Red: "#dc322f", 16 | Green: "#859900", 17 | Yellow: "#b58900", 18 | Blue: "#268bd2", 19 | Magenta: "#d33682", 20 | Cyan: "#2aa198", 21 | Orange: "#cb4b16", 22 | Gray: "#657b83", // base00 23 | } 24 | 25 | // solarized light theme 26 | var paletteSolarizedLight = Palette{ 27 | Name: "solarized-light", 28 | 29 | Fg: "#657b83", // base00 30 | FgMuted: "#586e75", // base01 31 | FgSubtle: "#93a1a1", // base1 32 | Bg: "#fdf6e3", // base3 33 | BgMuted: "#eee8d5", // base2 34 | Border: "#eee8d5", // base2 35 | 36 | Red: "#dc322f", 37 | Green: "#859900", 38 | Yellow: "#b58900", 39 | Blue: "#268bd2", 40 | Magenta: "#d33682", 41 | Cyan: "#2aa198", 42 | Orange: "#cb4b16", 43 | Gray: "#839496", // base0 44 | } 45 | 46 | -------------------------------------------------------------------------------- /cmd/testdata/golden/udp_filter_json.golden: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "ts": "2025-01-15T10:30:01Z", 4 | "pid": 2, 5 | "process": "udp-server", 6 | "user": "", 7 | "uid": 0, 8 | "proto": "udp", 9 | "ipversion": "", 10 | "state": "LISTEN", 11 | "laddr": "0.0.0.0", 12 | "lport": 53, 13 | "raddr": "", 14 | "rport": 0, 15 | "interface": "eth0", 16 | "rx_bytes": 0, 17 | "tx_bytes": 0, 18 | "rtt_ms": 0, 19 | "mark": "", 20 | "namespace": "", 21 | "inode": 0 22 | } 23 | ] 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release-linux: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: actions/setup-go@v6 20 | with: 21 | go-version: "1.25.0" 22 | 23 | - name: release linux 24 | uses: goreleaser/goreleaser-action@v6 25 | with: 26 | version: "~> v2" 27 | args: release --clean 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | AUR_KEY: ${{ secrets.AUR_KEY }} 31 | 32 | release-darwin: 33 | needs: release-linux 34 | runs-on: macos-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | with: 38 | fetch-depth: 0 39 | 40 | - uses: actions/setup-go@v6 41 | with: 42 | go-version: "1.25.0" 43 | 44 | - name: release darwin 45 | uses: goreleaser/goreleaser-action@v6 46 | with: 47 | version: "~> v2" 48 | args: release --clean --config .goreleaser-darwin.yaml --skip=validate 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | -------------------------------------------------------------------------------- /internal/color/color_test.go: -------------------------------------------------------------------------------- 1 | package color 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/fatih/color" 8 | 9 | "github.com/karol-broda/snitch/internal/errutil" 10 | ) 11 | 12 | func TestInit(t *testing.T) { 13 | testCases := []struct { 14 | name string 15 | mode string 16 | noColor string 17 | term string 18 | expected bool 19 | }{ 20 | {"Always", "always", "", "", false}, 21 | {"Never", "never", "", "", true}, 22 | {"Auto no env", "auto", "", "xterm-256color", false}, 23 | {"Auto with NO_COLOR", "auto", "1", "xterm-256color", true}, 24 | {"Auto with TERM=dumb", "auto", "", "dumb", true}, 25 | } 26 | 27 | for _, tc := range testCases { 28 | t.Run(tc.name, func(t *testing.T) { 29 | // Save original env vars 30 | origNoColor := os.Getenv("NO_COLOR") 31 | origTerm := os.Getenv("TERM") 32 | 33 | // Set test env vars 34 | errutil.Setenv("NO_COLOR", tc.noColor) 35 | errutil.Setenv("TERM", tc.term) 36 | 37 | Init(tc.mode) 38 | 39 | if color.NoColor != tc.expected { 40 | t.Errorf("Expected color.NoColor to be %v, but got %v", tc.expected, color.NoColor) 41 | } 42 | 43 | // Restore original env vars 44 | errutil.Setenv("NO_COLOR", origNoColor) 45 | errutil.Setenv("TERM", origTerm) 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /cmd/testdata/golden/single_tcp_json.golden: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "ts": "2025-08-25T19:24:18.530991+02:00", 4 | "pid": 1234, 5 | "process": "test-app", 6 | "user": "test-user", 7 | "uid": 1000, 8 | "proto": "tcp", 9 | "ipversion": "IPv4", 10 | "state": "ESTABLISHED", 11 | "laddr": "127.0.0.1", 12 | "lport": 8080, 13 | "raddr": "127.0.0.1", 14 | "rport": 9090, 15 | "interface": "lo", 16 | "rx_bytes": 1024, 17 | "tx_bytes": 512, 18 | "rtt_ms": 1, 19 | "mark": "0x0", 20 | "namespace": "init", 21 | "inode": 99999 22 | } 23 | ] 24 | -------------------------------------------------------------------------------- /internal/theme/mono.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | // createMonoTheme creates a monochrome theme (no colors) 6 | // useful for accessibility, piping output, or minimal terminals 7 | func createMonoTheme() *Theme { 8 | baseStyle := lipgloss.NewStyle() 9 | boldStyle := lipgloss.NewStyle().Bold(true) 10 | 11 | return &Theme{ 12 | Name: "mono", 13 | Styles: Styles{ 14 | Header: boldStyle, 15 | Border: baseStyle, 16 | Selected: boldStyle, 17 | Watched: boldStyle, 18 | Normal: baseStyle, 19 | Error: boldStyle, 20 | Success: boldStyle, 21 | Warning: boldStyle, 22 | Footer: baseStyle, 23 | Background: baseStyle, 24 | 25 | Proto: ProtoStyles{ 26 | TCP: baseStyle, 27 | UDP: baseStyle, 28 | Unix: baseStyle, 29 | TCP6: baseStyle, 30 | UDP6: baseStyle, 31 | }, 32 | 33 | State: StateStyles{ 34 | Listen: baseStyle, 35 | Established: baseStyle, 36 | TimeWait: baseStyle, 37 | CloseWait: baseStyle, 38 | SynSent: baseStyle, 39 | SynRecv: baseStyle, 40 | FinWait1: baseStyle, 41 | FinWait2: baseStyle, 42 | Closing: baseStyle, 43 | LastAck: baseStyle, 44 | Closed: baseStyle, 45 | }, 46 | }, 47 | } 48 | } 49 | 50 | -------------------------------------------------------------------------------- /internal/errutil/errutil.go: -------------------------------------------------------------------------------- 1 | package errutil 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/fatih/color" 8 | ) 9 | 10 | func Ignore[T any](val T, _ error) T { 11 | return val 12 | } 13 | 14 | func IgnoreErr(_ error) {} 15 | 16 | func Close(c io.Closer) { 17 | if c != nil { 18 | _ = c.Close() 19 | } 20 | } 21 | 22 | // color.Color wrappers - these discard the (int, error) return values 23 | 24 | func Print(c *color.Color, a ...any) { 25 | _, _ = c.Print(a...) 26 | } 27 | 28 | func Println(c *color.Color, a ...any) { 29 | _, _ = c.Println(a...) 30 | } 31 | 32 | func Printf(c *color.Color, format string, a ...any) { 33 | _, _ = c.Printf(format, a...) 34 | } 35 | 36 | func Fprintf(c *color.Color, w io.Writer, format string, a ...any) { 37 | _, _ = c.Fprintf(w, format, a...) 38 | } 39 | 40 | // os function wrappers for test cleanup where errors are non-critical 41 | 42 | func Setenv(key, value string) { 43 | _ = os.Setenv(key, value) 44 | } 45 | 46 | func Unsetenv(key string) { 47 | _ = os.Unsetenv(key) 48 | } 49 | 50 | func Remove(name string) { 51 | _ = os.Remove(name) 52 | } 53 | 54 | func RemoveAll(path string) { 55 | _ = os.RemoveAll(path) 56 | } 57 | 58 | // Flush calls Flush on a tabwriter and discards the error 59 | type Flusher interface { 60 | Flush() error 61 | } 62 | 63 | func Flush(f Flusher) { 64 | _ = f.Flush() 65 | } 66 | -------------------------------------------------------------------------------- /internal/color/color.go: -------------------------------------------------------------------------------- 1 | package color 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/fatih/color" 8 | ) 9 | 10 | var ( 11 | Header = color.New(color.FgGreen, color.Bold) 12 | Bold = color.New(color.Bold) 13 | Faint = color.New(color.Faint) 14 | TCP = color.New(color.FgCyan) 15 | UDP = color.New(color.FgMagenta) 16 | LISTEN = color.New(color.FgYellow) 17 | ESTABLISHED = color.New(color.FgGreen) 18 | Default = color.New(color.FgWhite) 19 | ) 20 | 21 | func Init(mode string) { 22 | switch mode { 23 | case "always": 24 | color.NoColor = false 25 | case "never": 26 | color.NoColor = true 27 | case "auto": 28 | if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" { 29 | color.NoColor = true 30 | } else { 31 | color.NoColor = false 32 | } 33 | } 34 | } 35 | 36 | func IsColorDisabled() bool { 37 | return color.NoColor 38 | } 39 | 40 | func GetProtoColor(proto string) *color.Color { 41 | switch strings.ToLower(proto) { 42 | case "tcp": 43 | return TCP 44 | case "udp": 45 | return UDP 46 | default: 47 | return Default 48 | } 49 | } 50 | 51 | func GetStateColor(state string) *color.Color { 52 | switch strings.ToUpper(state) { 53 | case "LISTEN", "LISTENING": 54 | return LISTEN 55 | case "ESTABLISHED": 56 | return ESTABLISHED 57 | default: 58 | return Default 59 | } 60 | } -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Demo Recording 2 | 3 | This directory contains files for recording the snitch demo GIF in a controlled Docker environment. 4 | 5 | ## Files 6 | 7 | - `Dockerfile` - builds snitch and sets up fake network services 8 | - `demo.tape` - VHS script that records the demo 9 | - `entrypoint.sh` - starts fake services before recording 10 | 11 | ## Recording the Demo 12 | 13 | From the project root: 14 | 15 | ```bash 16 | # build the demo image 17 | docker build -f demo/Dockerfile -t snitch-demo . 18 | 19 | # run and output demo.gif to this directory 20 | docker run --rm -v $(pwd)/demo:/output snitch-demo 21 | ``` 22 | 23 | The resulting `demo.gif` will be saved to this directory. 24 | 25 | ## Fake Services 26 | 27 | The container runs several fake services to demonstrate snitch: 28 | 29 | | Service | Port | Protocol | 30 | |---------|------|----------| 31 | | nginx | 80 | TCP | 32 | | web app | 8080 | TCP | 33 | | node | 3000 | TCP | 34 | | postgres| 5432 | TCP | 35 | | redis | 6379 | TCP | 36 | | mongo | 27017| TCP | 37 | | mdns | 5353 | UDP | 38 | | ssdp | 1900 | UDP | 39 | 40 | Plus some simulated established connections between services. 41 | 42 | ## Customizing 43 | 44 | Edit `demo.tape` to change what's shown in the demo. See [VHS documentation](https://github.com/charmbracelet/vhs) for available commands. 45 | 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - uses: actions/setup-go@v6 16 | with: 17 | go-version: "1.25.0" 18 | 19 | - name: build 20 | run: go build -v ./... 21 | 22 | - name: test 23 | run: go test -v ./... 24 | 25 | lint: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: actions/setup-go@v6 31 | with: 32 | go-version: "1.25.0" 33 | 34 | - name: lint 35 | uses: golangci/golangci-lint-action@v8 36 | with: 37 | version: v2.5.0 38 | 39 | nix-build: 40 | strategy: 41 | matrix: 42 | os: [ubuntu-latest, macos-14] 43 | runs-on: ${{ matrix.os }} 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - uses: DeterminateSystems/nix-installer-action@v17 48 | 49 | - uses: nix-community/cache-nix-action@v6 50 | with: 51 | primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock') }} 52 | restore-prefixes-first-match: nix-${{ runner.os }}- 53 | 54 | - name: nix flake check 55 | run: nix flake check 56 | 57 | - name: nix build 58 | run: nix build 59 | 60 | -------------------------------------------------------------------------------- /demo/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # entrypoint script that creates fake network services for demo 3 | 4 | set -e 5 | 6 | echo "starting demo services..." 7 | 8 | # start nginx on port 80 9 | nginx & 10 | sleep 0.5 11 | 12 | # start some listening services with socat (stderr silenced) 13 | socat TCP-LISTEN:8080,fork,reuseaddr SYSTEM:"echo HTTP/1.1 200 OK" 2>/dev/null & 14 | socat TCP-LISTEN:3000,fork,reuseaddr SYSTEM:"echo hello" 2>/dev/null & 15 | socat TCP-LISTEN:5432,fork,reuseaddr SYSTEM:"echo postgres" 2>/dev/null & 16 | socat TCP-LISTEN:6379,fork,reuseaddr SYSTEM:"echo redis" 2>/dev/null & 17 | socat TCP-LISTEN:27017,fork,reuseaddr SYSTEM:"echo mongo" 2>/dev/null & 18 | 19 | # create some "established" connections by connecting to our own services 20 | sleep 0.5 21 | (while true; do echo "ping" | nc -q 1 localhost 8080 2>/dev/null; sleep 2; done) >/dev/null 2>&1 & 22 | (while true; do echo "ping" | nc -q 1 localhost 3000 2>/dev/null; sleep 2; done) >/dev/null 2>&1 & 23 | (while true; do curl -s http://localhost:80 >/dev/null 2>&1; sleep 3; done) & 24 | 25 | # udp listeners 26 | socat UDP-LISTEN:5353,fork,reuseaddr SYSTEM:"echo mdns" 2>/dev/null & 27 | socat UDP-LISTEN:1900,fork,reuseaddr SYSTEM:"echo ssdp" 2>/dev/null & 28 | 29 | sleep 1 30 | echo "services started, recording demo..." 31 | 32 | # run vhs to record the demo 33 | cd /app 34 | vhs demo.tape 35 | 36 | echo "demo recorded, copying output..." 37 | 38 | # output will be in /app/demo.gif 39 | cp /app/demo.gif /output/demo.gif 2>/dev/null || echo "output copied" 40 | 41 | echo "done!" 42 | -------------------------------------------------------------------------------- /demo/demo.tape: -------------------------------------------------------------------------------- 1 | Output demo.gif 2 | 3 | Set Shell "bash" 4 | Set FontSize 14 5 | Set FontFamily "DejaVu Sans Mono" 6 | Set Width 1400 7 | Set Height 700 8 | Set Theme "Catppuccin Frappe" 9 | Set Padding 15 10 | Set Framerate 24 11 | Set TypingSpeed 30ms 12 | Set PlaybackSpeed 1.5 13 | 14 | # force color output 15 | Env TERM "xterm-256color" 16 | Env COLORTERM "truecolor" 17 | Env CLICOLOR "1" 18 | Env CLICOLOR_FORCE "1" 19 | Env FORCE_COLOR "1" 20 | 21 | # launch snitch 22 | Type "./snitch top" 23 | Enter 24 | Sleep 1s 25 | 26 | # navigate down through connections 27 | Down 28 | Sleep 200ms 29 | Down 30 | Sleep 200ms 31 | Down 32 | Sleep 200ms 33 | Down 34 | Sleep 200ms 35 | Down 36 | Sleep 600ms 37 | 38 | # open detail view for selected connection 39 | Enter 40 | Sleep 1.5s 41 | 42 | # close detail view 43 | Escape 44 | Sleep 500ms 45 | 46 | # search for nginx 47 | Type "/" 48 | Sleep 300ms 49 | Type "nginx" 50 | Sleep 600ms 51 | Enter 52 | Sleep 1.2s 53 | 54 | # clear search 55 | Type "/" 56 | Sleep 200ms 57 | Escape 58 | Sleep 500ms 59 | 60 | # filter: hide udp, show only tcp 61 | Type "u" 62 | Sleep 800ms 63 | 64 | # show only listening connections 65 | Type "e" 66 | Sleep 800ms 67 | Type "o" 68 | Sleep 800ms 69 | 70 | # reset to show all 71 | Type "a" 72 | Sleep 800ms 73 | 74 | # cycle through sort options 75 | Type "s" 76 | Sleep 500ms 77 | Type "s" 78 | Sleep 500ms 79 | 80 | # reverse sort order 81 | Type "S" 82 | Sleep 800ms 83 | 84 | # show help screen 85 | Type "?" 86 | Sleep 2s 87 | 88 | # close help 89 | Escape 90 | Sleep 500ms 91 | 92 | # quit 93 | Type "q" 94 | Sleep 200ms 95 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/karol-broda/snitch/internal/config" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | 12 | var ( 13 | cfgFile string 14 | ) 15 | 16 | var rootCmd = &cobra.Command{ 17 | Use: "snitch", 18 | Short: "snitch is a tool for inspecting network connections", 19 | Long: `snitch is a tool for inspecting network connections 20 | 21 | A modern, unix-y tool for inspecting network connections, with a focus on a clear usage API and a solid testing strategy.`, 22 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 23 | if _, err := config.Load(); err != nil { 24 | fmt.Fprintf(os.Stderr, "Warning: Error loading config: %v\n", err) 25 | } 26 | }, 27 | Run: func(cmd *cobra.Command, args []string) { 28 | // default to top - flags are shared so they work here too 29 | topCmd.Run(cmd, args) 30 | }, 31 | } 32 | 33 | func Execute() { 34 | if err := rootCmd.Execute(); err != nil { 35 | fmt.Println(err) 36 | os.Exit(1) 37 | } 38 | } 39 | 40 | func init() { 41 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/snitch/snitch.toml)") 42 | rootCmd.PersistentFlags().Bool("debug", false, "enable debug logs to stderr") 43 | 44 | // add top's flags to root so `snitch -l` works (defaults to top command) 45 | cfg := config.Get() 46 | rootCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (see 'snitch themes')") 47 | rootCmd.Flags().DurationVarP(&topInterval, "interval", "i", 0, "Refresh interval (default 1s)") 48 | 49 | // shared flags for root command 50 | addFilterFlags(rootCmd) 51 | addResolutionFlags(rootCmd) 52 | } -------------------------------------------------------------------------------- /internal/tui/symbols.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | // unicode symbols used throughout the TUI 4 | const ( 5 | // indicators 6 | SymbolSelected = string('\u25B8') // black right-pointing small triangle 7 | SymbolWatched = string('\u2605') // black star 8 | SymbolWarning = string('\u26A0') // warning sign 9 | SymbolSuccess = string('\u2713') // check mark 10 | SymbolError = string('\u2717') // ballot x 11 | SymbolBullet = string('\u2022') // bullet 12 | SymbolArrowRight = string('\u2192') // rightwards arrow 13 | SymbolArrowLeft = string('\u2190') // leftwards arrow 14 | SymbolArrowUp = string('\u2191') // upwards arrow 15 | SymbolArrowDown = string('\u2193') // downwards arrow 16 | SymbolRefresh = string('\u21BB') // clockwise open circle arrow 17 | SymbolEllipsis = string('\u2026') // horizontal ellipsis 18 | SymbolDownload = string('\u21E9') // downwards white arrow 19 | 20 | // box drawing rounded 21 | BoxTopLeft = string('\u256D') // light arc down and right 22 | BoxTopRight = string('\u256E') // light arc down and left 23 | BoxBottomLeft = string('\u2570') // light arc up and right 24 | BoxBottomRight = string('\u256F') // light arc up and left 25 | BoxHorizontal = string('\u2500') // light horizontal 26 | BoxVertical = string('\u2502') // light vertical 27 | 28 | // box drawing connectors 29 | BoxTeeDown = string('\u252C') // light down and horizontal 30 | BoxTeeUp = string('\u2534') // light up and horizontal 31 | BoxTeeRight = string('\u251C') // light vertical and right 32 | BoxTeeLeft = string('\u2524') // light vertical and left 33 | BoxCross = string('\u253C') // light vertical and horizontal 34 | 35 | // misc 36 | SymbolDash = string('\u2013') // en dash 37 | ) 38 | 39 | -------------------------------------------------------------------------------- /internal/theme/tokyo_night.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | // tokyo night theme 4 | // https://github.com/enkia/tokyo-night-vscode-theme 5 | var paletteTokyoNight = Palette{ 6 | Name: "tokyo-night", 7 | 8 | Fg: "#c0caf5", // foreground 9 | FgMuted: "#a9b1d6", // foreground dark 10 | FgSubtle: "#565f89", // comment 11 | Bg: "#1a1b26", // background 12 | BgMuted: "#24283b", // background highlight 13 | Border: "#414868", // border 14 | 15 | Red: "#f7768e", 16 | Green: "#9ece6a", 17 | Yellow: "#e0af68", 18 | Blue: "#7aa2f7", 19 | Magenta: "#bb9af7", // purple 20 | Cyan: "#7dcfff", 21 | Orange: "#ff9e64", 22 | Gray: "#565f89", // comment 23 | } 24 | 25 | // tokyo night storm variant 26 | var paletteTokyoNightStorm = Palette{ 27 | Name: "tokyo-night-storm", 28 | 29 | Fg: "#c0caf5", // foreground 30 | FgMuted: "#a9b1d6", // foreground dark 31 | FgSubtle: "#565f89", // comment 32 | Bg: "#24283b", // background (storm is slightly lighter) 33 | BgMuted: "#1f2335", // background dark 34 | Border: "#414868", // border 35 | 36 | Red: "#f7768e", 37 | Green: "#9ece6a", 38 | Yellow: "#e0af68", 39 | Blue: "#7aa2f7", 40 | Magenta: "#bb9af7", // purple 41 | Cyan: "#7dcfff", 42 | Orange: "#ff9e64", 43 | Gray: "#565f89", // comment 44 | } 45 | 46 | // tokyo night light variant 47 | var paletteTokyoNightLight = Palette{ 48 | Name: "tokyo-night-light", 49 | 50 | Fg: "#343b58", // foreground 51 | FgMuted: "#565a6e", // foreground dark 52 | FgSubtle: "#9699a3", // comment 53 | Bg: "#d5d6db", // background 54 | BgMuted: "#cbccd1", // background highlight 55 | Border: "#b4b5b9", // border 56 | 57 | Red: "#8c4351", 58 | Green: "#485e30", 59 | Yellow: "#8f5e15", 60 | Blue: "#34548a", 61 | Magenta: "#5a4a78", // purple 62 | Cyan: "#0f4b6e", 63 | Orange: "#965027", 64 | Gray: "#9699a3", // comment 65 | } 66 | 67 | -------------------------------------------------------------------------------- /internal/collector/filter_test.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFilterConnections(t *testing.T) { 8 | conns := []Connection{ 9 | {PID: 1, Process: "proc1", User: "user1", Proto: "tcp", State: "ESTABLISHED", Laddr: "1.1.1.1", Lport: 80, Raddr: "2.2.2.2", Rport: 1234}, 10 | {PID: 2, Process: "proc2", User: "user2", Proto: "udp", State: "LISTEN", Laddr: "3.3.3.3", Lport: 53, Raddr: "*", Rport: 0}, 11 | {PID: 3, Process: "proc1_extra", User: "user1", Proto: "tcp", State: "ESTABLISHED", Laddr: "4.4.4.4", Lport: 443, Raddr: "5.5.5.5", Rport: 5678}, 12 | } 13 | 14 | testCases := []struct { 15 | name string 16 | filters FilterOptions 17 | expected int 18 | }{ 19 | {"No filters", FilterOptions{}, 3}, 20 | {"Filter by proto tcp", FilterOptions{Proto: "tcp"}, 2}, 21 | {"Filter by proto udp", FilterOptions{Proto: "udp"}, 1}, 22 | {"Filter by state", FilterOptions{State: "ESTABLISHED"}, 2}, 23 | {"Filter by pid", FilterOptions{Pid: 2}, 1}, 24 | {"Filter by proc", FilterOptions{Proc: "proc1"}, 2}, 25 | {"Filter by lport", FilterOptions{Lport: 80}, 1}, 26 | {"Filter by rport", FilterOptions{Rport: 1234}, 1}, 27 | {"Filter by user", FilterOptions{User: "user1"}, 2}, 28 | {"Filter by laddr", FilterOptions{Laddr: "1.1.1.1"}, 1}, 29 | {"Filter by raddr", FilterOptions{Raddr: "5.5.5.5"}, 1}, 30 | {"Filter by contains proc", FilterOptions{Contains: "proc2"}, 1}, 31 | {"Filter by contains addr", FilterOptions{Contains: "3.3.3.3"}, 1}, 32 | {"Combined filter", FilterOptions{Proto: "tcp", State: "ESTABLISHED"}, 2}, 33 | {"No match", FilterOptions{Proto: "tcp", State: "LISTEN"}, 0}, 34 | } 35 | 36 | for _, tc := range testCases { 37 | t.Run(tc.name, func(t *testing.T) { 38 | filtered := FilterConnections(conns, tc.filters) 39 | if len(filtered) != tc.expected { 40 | t.Errorf("Expected %d connections, but got %d", tc.expected, len(filtered)) 41 | } 42 | }) 43 | } 44 | } -------------------------------------------------------------------------------- /cmd/top.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/karol-broda/snitch/internal/config" 9 | "github.com/karol-broda/snitch/internal/resolver" 10 | "github.com/karol-broda/snitch/internal/tui" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // top-specific flags 15 | var ( 16 | topTheme string 17 | topInterval time.Duration 18 | ) 19 | 20 | var topCmd = &cobra.Command{ 21 | Use: "top", 22 | Short: "Live TUI for inspecting connections", 23 | Run: func(cmd *cobra.Command, args []string) { 24 | cfg := config.Get() 25 | 26 | theme := topTheme 27 | if theme == "" { 28 | theme = cfg.Defaults.Theme 29 | } 30 | 31 | // configure resolver with cache setting 32 | effectiveNoCache := noCache || !cfg.Defaults.DNSCache 33 | resolver.SetNoCache(effectiveNoCache) 34 | 35 | opts := tui.Options{ 36 | Theme: theme, 37 | Interval: topInterval, 38 | ResolveAddrs: resolveAddrs, 39 | ResolvePorts: resolvePorts, 40 | NoCache: effectiveNoCache, 41 | } 42 | 43 | // if any filter flag is set, use exclusive mode 44 | if filterTCP || filterUDP || filterListen || filterEstab { 45 | opts.TCP = filterTCP 46 | opts.UDP = filterUDP 47 | opts.Listening = filterListen 48 | opts.Established = filterEstab 49 | opts.Other = false 50 | opts.FilterSet = true 51 | } 52 | 53 | m := tui.New(opts) 54 | 55 | p := tea.NewProgram(m, tea.WithAltScreen()) 56 | if _, err := p.Run(); err != nil { 57 | log.Fatal(err) 58 | } 59 | }, 60 | } 61 | 62 | func init() { 63 | rootCmd.AddCommand(topCmd) 64 | cfg := config.Get() 65 | 66 | // top-specific flags 67 | topCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (see 'snitch themes')") 68 | topCmd.Flags().DurationVarP(&topInterval, "interval", "i", time.Second, "Refresh interval") 69 | 70 | // shared flags 71 | addFilterFlags(topCmd) 72 | addResolutionFlags(topCmd) 73 | } -------------------------------------------------------------------------------- /internal/tui/messages.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "github.com/karol-broda/snitch/internal/collector" 6 | "github.com/karol-broda/snitch/internal/resolver" 7 | "syscall" 8 | "time" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | ) 12 | 13 | type tickMsg time.Time 14 | 15 | type dataMsg struct { 16 | connections []collector.Connection 17 | } 18 | 19 | type errMsg struct { 20 | err error 21 | } 22 | 23 | type killResultMsg struct { 24 | pid int 25 | process string 26 | success bool 27 | err error 28 | } 29 | 30 | type clearStatusMsg struct{} 31 | 32 | func (m model) tick() tea.Cmd { 33 | return tea.Tick(m.interval, func(t time.Time) tea.Msg { 34 | return tickMsg(t) 35 | }) 36 | } 37 | 38 | func (m model) fetchData() tea.Cmd { 39 | resolveAddrs := m.resolveAddrs 40 | return func() tea.Msg { 41 | conns, err := collector.GetConnections() 42 | if err != nil { 43 | return errMsg{err} 44 | } 45 | // pre-warm dns cache in parallel if resolution is enabled 46 | if resolveAddrs { 47 | addrs := make([]string, 0, len(conns)*2) 48 | for _, c := range conns { 49 | addrs = append(addrs, c.Laddr, c.Raddr) 50 | } 51 | resolver.ResolveAddrsParallel(addrs) 52 | } 53 | return dataMsg{connections: conns} 54 | } 55 | } 56 | 57 | func killProcess(pid int, process string) tea.Cmd { 58 | return func() tea.Msg { 59 | if pid <= 0 { 60 | return killResultMsg{ 61 | pid: pid, 62 | process: process, 63 | success: false, 64 | err: fmt.Errorf("invalid pid"), 65 | } 66 | } 67 | 68 | // send SIGTERM first (graceful shutdown) 69 | err := syscall.Kill(pid, syscall.SIGTERM) 70 | if err != nil { 71 | return killResultMsg{ 72 | pid: pid, 73 | process: process, 74 | success: false, 75 | err: err, 76 | } 77 | } 78 | 79 | return killResultMsg{ 80 | pid: pid, 81 | process: process, 82 | success: true, 83 | err: nil, 84 | } 85 | } 86 | } 87 | 88 | func clearStatusAfter(d time.Duration) tea.Cmd { 89 | return tea.Tick(d, func(t time.Time) tea.Msg { 90 | return clearStatusMsg{} 91 | }) 92 | } 93 | 94 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | project_name: snitch 4 | 5 | before: 6 | hooks: 7 | - go mod tidy 8 | 9 | builds: 10 | - id: linux 11 | env: 12 | - CGO_ENABLED=0 13 | goos: 14 | - linux 15 | goarch: 16 | - amd64 17 | - arm64 18 | - arm 19 | goarm: 20 | - "7" 21 | ldflags: 22 | - -s -w 23 | - -X snitch/cmd.Version={{.Version}} 24 | - -X snitch/cmd.Commit={{.ShortCommit}} 25 | - -X snitch/cmd.Date={{.Date}} 26 | 27 | archives: 28 | - formats: 29 | - tar.gz 30 | name_template: >- 31 | {{ .ProjectName }}_ 32 | {{- .Version }}_ 33 | {{- .Os }}_ 34 | {{- .Arch }} 35 | {{- if .Arm }}v{{ .Arm }}{{ end }} 36 | files: 37 | - LICENSE 38 | - README.md 39 | 40 | checksum: 41 | name_template: "checksums.txt" 42 | 43 | changelog: 44 | sort: asc 45 | filters: 46 | exclude: 47 | - "^docs:" 48 | - "^test:" 49 | - "^ci:" 50 | - "^chore:" 51 | - Merge pull request 52 | - Merge branch 53 | 54 | nfpms: 55 | - id: packages 56 | package_name: snitch 57 | vendor: karol broda 58 | homepage: https://github.com/karol-broda/snitch 59 | maintainer: karol broda 60 | description: a friendlier ss/netstat for humans 61 | license: MIT 62 | formats: 63 | - deb 64 | - rpm 65 | - apk 66 | 67 | aurs: 68 | - name: snitch-bin 69 | homepage: https://github.com/karol-broda/snitch 70 | description: a friendlier ss/netstat for humans 71 | maintainers: 72 | - "Karol Broda " 73 | license: MIT 74 | private_key: "{{ .Env.AUR_KEY }}" 75 | git_url: "ssh://aur@aur.archlinux.org/snitch-bin.git" 76 | depends: 77 | - glibc 78 | provides: 79 | - snitch 80 | conflicts: 81 | - snitch 82 | package: |- 83 | install -Dm755 "./snitch" "${pkgdir}/usr/bin/snitch" 84 | install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/snitch/LICENSE" 85 | commit_msg_template: "Update to {{ .Tag }}" 86 | skip_upload: auto 87 | 88 | release: 89 | github: 90 | owner: karol-broda 91 | name: snitch 92 | draft: false 93 | prerelease: auto 94 | -------------------------------------------------------------------------------- /internal/theme/catppuccin.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | // catppuccin mocha (dark) 4 | // https://github.com/catppuccin/catppuccin 5 | var paletteCatppuccinMocha = Palette{ 6 | Name: "catppuccin-mocha", 7 | 8 | Fg: "#cdd6f4", // text 9 | FgMuted: "#a6adc8", // subtext0 10 | FgSubtle: "#6c7086", // overlay0 11 | Bg: "#1e1e2e", // base 12 | BgMuted: "#313244", // surface0 13 | Border: "#45475a", // surface1 14 | 15 | Red: "#f38ba8", 16 | Green: "#a6e3a1", 17 | Yellow: "#f9e2af", 18 | Blue: "#89b4fa", 19 | Magenta: "#cba6f7", // mauve 20 | Cyan: "#94e2d5", // teal 21 | Orange: "#fab387", // peach 22 | Gray: "#585b70", // surface2 23 | } 24 | 25 | // catppuccin macchiato (medium-dark) 26 | var paletteCatppuccinMacchiato = Palette{ 27 | Name: "catppuccin-macchiato", 28 | 29 | Fg: "#cad3f5", // text 30 | FgMuted: "#a5adcb", // subtext0 31 | FgSubtle: "#6e738d", // overlay0 32 | Bg: "#24273a", // base 33 | BgMuted: "#363a4f", // surface0 34 | Border: "#494d64", // surface1 35 | 36 | Red: "#ed8796", 37 | Green: "#a6da95", 38 | Yellow: "#eed49f", 39 | Blue: "#8aadf4", 40 | Magenta: "#c6a0f6", // mauve 41 | Cyan: "#8bd5ca", // teal 42 | Orange: "#f5a97f", // peach 43 | Gray: "#5b6078", // surface2 44 | } 45 | 46 | // catppuccin frappe (medium) 47 | var paletteCatppuccinFrappe = Palette{ 48 | Name: "catppuccin-frappe", 49 | 50 | Fg: "#c6d0f5", // text 51 | FgMuted: "#a5adce", // subtext0 52 | FgSubtle: "#737994", // overlay0 53 | Bg: "#303446", // base 54 | BgMuted: "#414559", // surface0 55 | Border: "#51576d", // surface1 56 | 57 | Red: "#e78284", 58 | Green: "#a6d189", 59 | Yellow: "#e5c890", 60 | Blue: "#8caaee", 61 | Magenta: "#ca9ee6", // mauve 62 | Cyan: "#81c8be", // teal 63 | Orange: "#ef9f76", // peach 64 | Gray: "#626880", // surface2 65 | } 66 | 67 | // catppuccin latte (light) 68 | var paletteCatppuccinLatte = Palette{ 69 | Name: "catppuccin-latte", 70 | 71 | Fg: "#4c4f69", // text 72 | FgMuted: "#6c6f85", // subtext0 73 | FgSubtle: "#9ca0b0", // overlay0 74 | Bg: "#eff1f5", // base 75 | BgMuted: "#ccd0da", // surface0 76 | Border: "#bcc0cc", // surface1 77 | 78 | Red: "#d20f39", 79 | Green: "#40a02b", 80 | Yellow: "#df8e1d", 81 | Blue: "#1e66f5", 82 | Magenta: "#8839ef", // mauve 83 | Cyan: "#179299", // teal 84 | Orange: "#fe640b", // peach 85 | Gray: "#acb0be", // surface2 86 | } 87 | 88 | -------------------------------------------------------------------------------- /cmd/watch.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var ( 17 | watchInterval time.Duration 18 | watchCount int 19 | ) 20 | 21 | var watchCmd = &cobra.Command{ 22 | Use: "watch [filters...]", 23 | Short: "Stream connection events as json frames", 24 | Long: `Stream connection events as json frames. 25 | 26 | Filters are specified in key=value format. For example: 27 | snitch watch proto=tcp state=established 28 | 29 | Available filters: 30 | proto, state, pid, proc, lport, rport, user, laddr, raddr, contains 31 | `, 32 | Run: func(cmd *cobra.Command, args []string) { 33 | runWatchCommand(args) 34 | }, 35 | } 36 | 37 | func runWatchCommand(args []string) { 38 | filters, err := BuildFilters(args) 39 | if err != nil { 40 | log.Fatalf("Error parsing filters: %v", err) 41 | } 42 | 43 | ctx, cancel := context.WithCancel(context.Background()) 44 | defer cancel() 45 | 46 | sigChan := make(chan os.Signal, 1) 47 | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 48 | go func() { 49 | <-sigChan 50 | cancel() 51 | }() 52 | 53 | ticker := time.NewTicker(watchInterval) 54 | defer ticker.Stop() 55 | 56 | count := 0 57 | for { 58 | select { 59 | case <-ctx.Done(): 60 | return 61 | case <-ticker.C: 62 | connections, err := FetchConnections(filters) 63 | if err != nil { 64 | log.Printf("Error getting connections: %v", err) 65 | continue 66 | } 67 | 68 | frame := map[string]interface{}{ 69 | "timestamp": time.Now().Format(time.RFC3339Nano), 70 | "connections": connections, 71 | "count": len(connections), 72 | } 73 | 74 | jsonOutput, err := json.Marshal(frame) 75 | if err != nil { 76 | log.Printf("Error marshaling JSON: %v", err) 77 | continue 78 | } 79 | 80 | fmt.Println(string(jsonOutput)) 81 | 82 | count++ 83 | if watchCount > 0 && count >= watchCount { 84 | return 85 | } 86 | } 87 | } 88 | } 89 | 90 | func init() { 91 | rootCmd.AddCommand(watchCmd) 92 | 93 | // watch-specific flags 94 | watchCmd.Flags().DurationVarP(&watchInterval, "interval", "i", time.Second, "Refresh interval (e.g., 500ms, 2s)") 95 | watchCmd.Flags().IntVarP(&watchCount, "count", "c", 0, "Number of frames to emit (0 = unlimited)") 96 | 97 | // shared filter flags 98 | addFilterFlags(watchCmd) 99 | } 100 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/karol-broda/snitch 2 | 3 | go 1.25.0 4 | 5 | require ( 6 | github.com/charmbracelet/bubbletea v1.3.6 7 | github.com/charmbracelet/lipgloss v1.1.0 8 | github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383 9 | github.com/fatih/color v1.18.0 10 | github.com/mattn/go-runewidth v0.0.16 11 | github.com/spf13/cobra v1.9.1 12 | github.com/spf13/viper v1.19.0 13 | github.com/tidwall/pretty v1.2.1 14 | golang.org/x/term v0.38.0 15 | ) 16 | 17 | require ( 18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 19 | github.com/aymanbagabas/go-udiff v0.3.1 // indirect 20 | github.com/charmbracelet/colorprofile v0.3.2 // indirect 21 | github.com/charmbracelet/x/ansi v0.10.1 // indirect 22 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 23 | github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect 24 | github.com/charmbracelet/x/term v0.2.1 // indirect 25 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 26 | github.com/fsnotify/fsnotify v1.7.0 // indirect 27 | github.com/hashicorp/hcl v1.0.0 // indirect 28 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 29 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 30 | github.com/magiconair/properties v1.8.7 // indirect 31 | github.com/mattn/go-colorable v0.1.13 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/mattn/go-localereader v0.0.1 // indirect 34 | github.com/mitchellh/mapstructure v1.5.0 // indirect 35 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 36 | github.com/muesli/cancelreader v0.2.2 // indirect 37 | github.com/muesli/termenv v0.16.0 // indirect 38 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 39 | github.com/rivo/uniseg v0.4.7 // indirect 40 | github.com/sagikazarmark/locafero v0.4.0 // indirect 41 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 42 | github.com/sourcegraph/conc v0.3.0 // indirect 43 | github.com/spf13/afero v1.11.0 // indirect 44 | github.com/spf13/cast v1.6.0 // indirect 45 | github.com/spf13/pflag v1.0.6 // indirect 46 | github.com/subosito/gotenv v1.6.0 // indirect 47 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 48 | go.uber.org/atomic v1.9.0 // indirect 49 | go.uber.org/multierr v1.9.0 // indirect 50 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 51 | golang.org/x/sync v0.16.0 // indirect 52 | golang.org/x/sys v0.39.0 // indirect 53 | golang.org/x/text v0.28.0 // indirect 54 | gopkg.in/ini.v1 v1.67.0 // indirect 55 | gopkg.in/yaml.v3 v3.0.1 // indirect 56 | ) 57 | -------------------------------------------------------------------------------- /internal/collector/collector.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "net" 5 | "strings" 6 | ) 7 | 8 | // Collector interface defines methods for collecting connection data 9 | type Collector interface { 10 | GetConnections() ([]Connection, error) 11 | } 12 | 13 | // Global collector instance (can be overridden for testing) 14 | var globalCollector Collector = &DefaultCollector{} 15 | 16 | // SetCollector sets the global collector instance 17 | func SetCollector(collector Collector) { 18 | globalCollector = collector 19 | } 20 | 21 | // GetCollector returns the current global collector instance 22 | func GetCollector() Collector { 23 | return globalCollector 24 | } 25 | 26 | // GetConnections fetches all network connections using the global collector 27 | func GetConnections() ([]Connection, error) { 28 | return globalCollector.GetConnections() 29 | } 30 | 31 | func FilterConnections(conns []Connection, filters FilterOptions) []Connection { 32 | if filters.IsEmpty() { 33 | return conns 34 | } 35 | 36 | filtered := make([]Connection, 0) 37 | for _, conn := range conns { 38 | if filters.Matches(conn) { 39 | filtered = append(filtered, conn) 40 | } 41 | } 42 | return filtered 43 | } 44 | 45 | func guessNetworkInterface(addr string) string { 46 | if addr == "127.0.0.1" || addr == "::1" { 47 | return "lo" 48 | } 49 | 50 | ip := net.ParseIP(addr) 51 | if ip == nil { 52 | return "" 53 | } 54 | 55 | if ip.IsLoopback() { 56 | return "lo" 57 | } 58 | 59 | // default interface name varies by OS but we return a generic value 60 | // actual interface detection would require routing table analysis 61 | return "" 62 | } 63 | 64 | func simplifyIPv6(addr string) string { 65 | parts := strings.Split(addr, ":") 66 | for i, part := range parts { 67 | // parse as hex then format back to remove leading zeros 68 | var val int64 69 | for _, c := range part { 70 | val = val*16 + int64(hexCharToInt(c)) 71 | } 72 | parts[i] = formatHex(val) 73 | } 74 | return strings.Join(parts, ":") 75 | } 76 | 77 | func hexCharToInt(c rune) int { 78 | switch { 79 | case c >= '0' && c <= '9': 80 | return int(c - '0') 81 | case c >= 'a' && c <= 'f': 82 | return int(c - 'a' + 10) 83 | case c >= 'A' && c <= 'F': 84 | return int(c - 'A' + 10) 85 | default: 86 | return 0 87 | } 88 | } 89 | 90 | func formatHex(val int64) string { 91 | if val == 0 { 92 | return "0" 93 | } 94 | const hexDigits = "0123456789abcdef" 95 | var result []byte 96 | for val > 0 { 97 | result = append([]byte{hexDigits[val%16]}, result...) 98 | val /= 16 99 | } 100 | return string(result) 101 | } 102 | -------------------------------------------------------------------------------- /internal/collector/collector_test.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package collector 4 | 5 | import ( 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestGetConnections(t *testing.T) { 11 | // integration test to verify /proc parsing works 12 | conns, err := GetConnections() 13 | if err != nil { 14 | t.Fatalf("GetConnections() returned an error: %v", err) 15 | } 16 | 17 | // connections are dynamic, so just verify function succeeded 18 | t.Logf("Successfully got %d connections", len(conns)) 19 | } 20 | 21 | func TestGetConnectionsPerformance(t *testing.T) { 22 | // measures performance to catch regressions 23 | // run with: go test -v -run TestGetConnectionsPerformance 24 | 25 | const maxDuration = 500 * time.Millisecond 26 | const iterations = 5 27 | 28 | // warm up caches first 29 | _, err := GetConnections() 30 | if err != nil { 31 | t.Fatalf("warmup failed: %v", err) 32 | } 33 | 34 | var total time.Duration 35 | var maxSeen time.Duration 36 | 37 | for i := 0; i < iterations; i++ { 38 | start := time.Now() 39 | conns, err := GetConnections() 40 | elapsed := time.Since(start) 41 | 42 | if err != nil { 43 | t.Fatalf("iteration %d failed: %v", i, err) 44 | } 45 | 46 | total += elapsed 47 | if elapsed > maxSeen { 48 | maxSeen = elapsed 49 | } 50 | 51 | t.Logf("iteration %d: %v (%d connections)", i+1, elapsed, len(conns)) 52 | } 53 | 54 | avg := total / time.Duration(iterations) 55 | t.Logf("average: %v, max: %v", avg, maxSeen) 56 | 57 | if maxSeen > maxDuration { 58 | t.Errorf("slowest iteration took %v, expected < %v", maxSeen, maxDuration) 59 | } 60 | } 61 | 62 | func TestGetConnectionsColdCache(t *testing.T) { 63 | // tests performance with cold user cache 64 | // this simulates first run or after cache invalidation 65 | 66 | const maxDuration = 2 * time.Second 67 | 68 | clearUserCache() 69 | 70 | start := time.Now() 71 | conns, err := GetConnections() 72 | elapsed := time.Since(start) 73 | 74 | if err != nil { 75 | t.Fatalf("GetConnections() failed: %v", err) 76 | } 77 | 78 | t.Logf("cold cache: %v (%d connections, %d cached users after)", 79 | elapsed, len(conns), userCacheSize()) 80 | 81 | if elapsed > maxDuration { 82 | t.Errorf("cold cache took %v, expected < %v", elapsed, maxDuration) 83 | } 84 | } 85 | 86 | func BenchmarkGetConnections(b *testing.B) { 87 | // warm cache benchmark - measures typical runtime 88 | // run with: go test -bench=BenchmarkGetConnections -benchtime=5s 89 | 90 | // warm up 91 | _, _ = GetConnections() 92 | 93 | b.ResetTimer() 94 | for i := 0; i < b.N; i++ { 95 | _, _ = GetConnections() 96 | } 97 | } 98 | 99 | func BenchmarkGetConnectionsColdCache(b *testing.B) { 100 | // cold cache benchmark - measures worst-case with cache cleared each iteration 101 | // run with: go test -bench=BenchmarkGetConnectionsColdCache -benchtime=10s 102 | 103 | b.ResetTimer() 104 | for i := 0; i < b.N; i++ { 105 | clearUserCache() 106 | _, _ = GetConnections() 107 | } 108 | } 109 | 110 | func BenchmarkBuildInodeMap(b *testing.B) { 111 | // benchmarks just the inode map building (most expensive part) 112 | 113 | b.ResetTimer() 114 | for i := 0; i < b.N; i++ { 115 | _, _ = buildInodeToProcessMap() 116 | } 117 | } -------------------------------------------------------------------------------- /internal/theme/palette.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | // Palette defines the semantic colors for a theme 10 | type Palette struct { 11 | Name string 12 | 13 | // base colors 14 | Fg string // primary foreground 15 | FgMuted string // secondary/muted foreground 16 | FgSubtle string // subtle/disabled foreground 17 | Bg string // primary background 18 | BgMuted string // secondary background (selections, highlights) 19 | Border string // border color 20 | 21 | // semantic colors 22 | Red string 23 | Green string 24 | Yellow string 25 | Blue string 26 | Magenta string 27 | Cyan string 28 | Orange string 29 | Gray string 30 | } 31 | 32 | // Color converts a palette color string to a lipgloss.TerminalColor. 33 | // If the string is 1-2 characters, it's treated as an ANSI color code. 34 | // Otherwise, it's treated as a hex color. 35 | func (p *Palette) Color(c string) lipgloss.TerminalColor { 36 | if c == "" { 37 | return lipgloss.NoColor{} 38 | } 39 | 40 | if len(c) <= 2 { 41 | n, err := strconv.Atoi(c) 42 | if err == nil { 43 | return lipgloss.ANSIColor(n) 44 | } 45 | } 46 | 47 | return lipgloss.Color(c) 48 | } 49 | 50 | // ToTheme converts a Palette to a Theme with lipgloss styles 51 | func (p *Palette) ToTheme() *Theme { 52 | return &Theme{ 53 | Name: p.Name, 54 | Styles: Styles{ 55 | Header: lipgloss.NewStyle(). 56 | Bold(true). 57 | Foreground(p.Color(p.Fg)), 58 | 59 | Border: lipgloss.NewStyle(). 60 | Foreground(p.Color(p.Border)), 61 | 62 | Selected: lipgloss.NewStyle(). 63 | Bold(true). 64 | Foreground(p.Color(p.Fg)), 65 | 66 | Watched: lipgloss.NewStyle(). 67 | Bold(true). 68 | Foreground(p.Color(p.Orange)), 69 | 70 | Normal: lipgloss.NewStyle(). 71 | Foreground(p.Color(p.FgMuted)), 72 | 73 | Error: lipgloss.NewStyle(). 74 | Foreground(p.Color(p.Red)), 75 | 76 | Success: lipgloss.NewStyle(). 77 | Foreground(p.Color(p.Green)), 78 | 79 | Warning: lipgloss.NewStyle(). 80 | Foreground(p.Color(p.Yellow)), 81 | 82 | Footer: lipgloss.NewStyle(). 83 | Foreground(p.Color(p.FgSubtle)), 84 | 85 | Background: lipgloss.NewStyle(), 86 | 87 | Proto: ProtoStyles{ 88 | TCP: lipgloss.NewStyle().Foreground(p.Color(p.Green)), 89 | UDP: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)), 90 | Unix: lipgloss.NewStyle().Foreground(p.Color(p.Gray)), 91 | TCP6: lipgloss.NewStyle().Foreground(p.Color(p.Cyan)), 92 | UDP6: lipgloss.NewStyle().Foreground(p.Color(p.Blue)), 93 | }, 94 | 95 | State: StateStyles{ 96 | Listen: lipgloss.NewStyle().Foreground(p.Color(p.Green)), 97 | Established: lipgloss.NewStyle().Foreground(p.Color(p.Blue)), 98 | TimeWait: lipgloss.NewStyle().Foreground(p.Color(p.Yellow)), 99 | CloseWait: lipgloss.NewStyle().Foreground(p.Color(p.Orange)), 100 | SynSent: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)), 101 | SynRecv: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)), 102 | FinWait1: lipgloss.NewStyle().Foreground(p.Color(p.Red)), 103 | FinWait2: lipgloss.NewStyle().Foreground(p.Color(p.Red)), 104 | Closing: lipgloss.NewStyle().Foreground(p.Color(p.Red)), 105 | LastAck: lipgloss.NewStyle().Foreground(p.Color(p.Red)), 106 | Closed: lipgloss.NewStyle().Foreground(p.Color(p.Gray)), 107 | }, 108 | }, 109 | } 110 | } 111 | 112 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | REPO="karol-broda/snitch" 5 | BINARY_NAME="snitch" 6 | 7 | # allow override via environment 8 | INSTALL_DIR="${INSTALL_DIR:-}" 9 | KEEP_QUARANTINE="${KEEP_QUARANTINE:-}" 10 | 11 | detect_install_dir() { 12 | if [ -n "$INSTALL_DIR" ]; then 13 | echo "$INSTALL_DIR" 14 | return 15 | fi 16 | 17 | # prefer user-local directory if it exists and is in PATH 18 | if [ -d "$HOME/.local/bin" ] && echo "$PATH" | grep -q "$HOME/.local/bin"; then 19 | echo "$HOME/.local/bin" 20 | return 21 | fi 22 | 23 | # fallback to /usr/local/bin 24 | echo "/usr/local/bin" 25 | } 26 | 27 | detect_os() { 28 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 29 | case "$os" in 30 | darwin) echo "darwin" ;; 31 | linux) echo "linux" ;; 32 | *) 33 | echo "error: unsupported operating system: $os" >&2 34 | exit 1 35 | ;; 36 | esac 37 | } 38 | 39 | detect_arch() { 40 | arch=$(uname -m) 41 | case "$arch" in 42 | x86_64|amd64) echo "amd64" ;; 43 | aarch64|arm64) echo "arm64" ;; 44 | armv7l) echo "armv7" ;; 45 | *) 46 | echo "error: unsupported architecture: $arch" >&2 47 | exit 1 48 | ;; 49 | esac 50 | } 51 | 52 | fetch_latest_version() { 53 | version=$(curl -sL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | head -1 | cut -d'"' -f4) 54 | if [ -z "$version" ]; then 55 | echo "error: failed to fetch latest version" >&2 56 | exit 1 57 | fi 58 | echo "$version" 59 | } 60 | 61 | main() { 62 | os=$(detect_os) 63 | arch=$(detect_arch) 64 | install_dir=$(detect_install_dir) 65 | version=$(fetch_latest_version) 66 | version_no_v="${version#v}" 67 | 68 | archive_name="${BINARY_NAME}_${version_no_v}_${os}_${arch}.tar.gz" 69 | download_url="https://github.com/${REPO}/releases/download/${version}/${archive_name}" 70 | 71 | echo "installing ${BINARY_NAME} ${version} for ${os}/${arch}..." 72 | 73 | tmp_dir=$(mktemp -d) 74 | trap 'rm -rf "$tmp_dir"' EXIT 75 | 76 | echo "downloading ${download_url}..." 77 | if ! curl -sL --fail "$download_url" -o "${tmp_dir}/${archive_name}"; then 78 | echo "error: failed to download ${download_url}" >&2 79 | exit 1 80 | fi 81 | 82 | echo "extracting..." 83 | tar -xzf "${tmp_dir}/${archive_name}" -C "$tmp_dir" 84 | 85 | if [ ! -f "${tmp_dir}/${BINARY_NAME}" ]; then 86 | echo "error: binary not found in archive" >&2 87 | exit 1 88 | fi 89 | 90 | # remove macos quarantine attribute unless disabled 91 | if [ "$os" = "darwin" ] && [ -z "$KEEP_QUARANTINE" ]; then 92 | if xattr -d com.apple.quarantine "${tmp_dir}/${BINARY_NAME}" 2>/dev/null; then 93 | echo "warning: removed macOS quarantine attribute from binary" 94 | fi 95 | fi 96 | 97 | # install binary 98 | if [ -w "$install_dir" ]; then 99 | mv "${tmp_dir}/${BINARY_NAME}" "${install_dir}/${BINARY_NAME}" 100 | else 101 | echo "elevated permissions required to install to ${install_dir}" 102 | sudo mv "${tmp_dir}/${BINARY_NAME}" "${install_dir}/${BINARY_NAME}" 103 | fi 104 | 105 | chmod +x "${install_dir}/${BINARY_NAME}" 106 | 107 | echo "installed ${BINARY_NAME} to ${install_dir}/${BINARY_NAME}" 108 | echo "" 109 | echo "run '${BINARY_NAME} --help' to get started" 110 | } 111 | 112 | main 113 | 114 | -------------------------------------------------------------------------------- /cmd/testdata/golden/mixed_protocols_json.golden: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "ts": "2025-01-15T10:30:00Z", 4 | "pid": 1, 5 | "process": "tcp-server", 6 | "user": "", 7 | "uid": 0, 8 | "proto": "tcp", 9 | "ipversion": "", 10 | "state": "LISTEN", 11 | "laddr": "0.0.0.0", 12 | "lport": 80, 13 | "raddr": "", 14 | "rport": 0, 15 | "interface": "eth0", 16 | "rx_bytes": 0, 17 | "tx_bytes": 0, 18 | "rtt_ms": 0, 19 | "mark": "", 20 | "namespace": "", 21 | "inode": 0 22 | }, 23 | { 24 | "ts": "2025-01-15T10:30:01Z", 25 | "pid": 2, 26 | "process": "udp-server", 27 | "user": "", 28 | "uid": 0, 29 | "proto": "udp", 30 | "ipversion": "", 31 | "state": "LISTEN", 32 | "laddr": "0.0.0.0", 33 | "lport": 53, 34 | "raddr": "", 35 | "rport": 0, 36 | "interface": "eth0", 37 | "rx_bytes": 0, 38 | "tx_bytes": 0, 39 | "rtt_ms": 0, 40 | "mark": "", 41 | "namespace": "", 42 | "inode": 0 43 | }, 44 | { 45 | "ts": "2025-01-15T10:30:02Z", 46 | "pid": 3, 47 | "process": "unix-app", 48 | "user": "", 49 | "uid": 0, 50 | "proto": "unix", 51 | "ipversion": "", 52 | "state": "CONNECTED", 53 | "laddr": "/tmp/test.sock", 54 | "lport": 0, 55 | "raddr": "", 56 | "rport": 0, 57 | "interface": "unix", 58 | "rx_bytes": 0, 59 | "tx_bytes": 0, 60 | "rtt_ms": 0, 61 | "mark": "", 62 | "namespace": "", 63 | "inode": 0 64 | } 65 | ] 66 | -------------------------------------------------------------------------------- /internal/collector/sort.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | ) 7 | 8 | // SortField represents a field to sort by 9 | type SortField string 10 | 11 | const ( 12 | SortByPID SortField = "pid" 13 | SortByProcess SortField = "process" 14 | SortByUser SortField = "user" 15 | SortByProto SortField = "proto" 16 | SortByState SortField = "state" 17 | SortByLaddr SortField = "laddr" 18 | SortByLport SortField = "lport" 19 | SortByRaddr SortField = "raddr" 20 | SortByRport SortField = "rport" 21 | SortByInterface SortField = "if" 22 | SortByRxBytes SortField = "rx_bytes" 23 | SortByTxBytes SortField = "tx_bytes" 24 | SortByRttMs SortField = "rtt_ms" 25 | SortByTimestamp SortField = "ts" 26 | ) 27 | 28 | // SortDirection represents ascending or descending order 29 | type SortDirection int 30 | 31 | const ( 32 | SortAsc SortDirection = iota 33 | SortDesc 34 | ) 35 | 36 | // SortOptions configures how connections are sorted 37 | type SortOptions struct { 38 | Field SortField 39 | Direction SortDirection 40 | } 41 | 42 | // ParseSortOptions parses a sort string like "pid:desc" or "lport" 43 | func ParseSortOptions(s string) SortOptions { 44 | if s == "" { 45 | return SortOptions{Field: SortByLport, Direction: SortAsc} 46 | } 47 | 48 | parts := strings.SplitN(s, ":", 2) 49 | field := SortField(strings.ToLower(parts[0])) 50 | direction := SortAsc 51 | 52 | if len(parts) > 1 && strings.ToLower(parts[1]) == "desc" { 53 | direction = SortDesc 54 | } 55 | 56 | return SortOptions{Field: field, Direction: direction} 57 | } 58 | 59 | // SortConnections sorts a slice of connections in place 60 | func SortConnections(conns []Connection, opts SortOptions) { 61 | if len(conns) < 2 { 62 | return 63 | } 64 | 65 | sort.SliceStable(conns, func(i, j int) bool { 66 | less := compareConnections(conns[i], conns[j], opts.Field) 67 | if opts.Direction == SortDesc { 68 | return !less 69 | } 70 | return less 71 | }) 72 | } 73 | 74 | func compareConnections(a, b Connection, field SortField) bool { 75 | switch field { 76 | case SortByPID: 77 | return a.PID < b.PID 78 | case SortByProcess: 79 | return strings.ToLower(a.Process) < strings.ToLower(b.Process) 80 | case SortByUser: 81 | return strings.ToLower(a.User) < strings.ToLower(b.User) 82 | case SortByProto: 83 | return a.Proto < b.Proto 84 | case SortByState: 85 | return stateOrder(a.State) < stateOrder(b.State) 86 | case SortByLaddr: 87 | return a.Laddr < b.Laddr 88 | case SortByLport: 89 | return a.Lport < b.Lport 90 | case SortByRaddr: 91 | return a.Raddr < b.Raddr 92 | case SortByRport: 93 | return a.Rport < b.Rport 94 | case SortByInterface: 95 | return a.Interface < b.Interface 96 | case SortByRxBytes: 97 | return a.RxBytes < b.RxBytes 98 | case SortByTxBytes: 99 | return a.TxBytes < b.TxBytes 100 | case SortByRttMs: 101 | return a.RttMs < b.RttMs 102 | case SortByTimestamp: 103 | return a.TS.Before(b.TS) 104 | default: 105 | return a.Lport < b.Lport 106 | } 107 | } 108 | 109 | // stateOrder returns a numeric order for connection states 110 | // puts LISTEN first, then ESTABLISHED, then others 111 | func stateOrder(state string) int { 112 | order := map[string]int{ 113 | "LISTEN": 0, 114 | "UNCONNECTED": 1, // udp sockets bound but not connected to a specific peer 115 | "ESTABLISHED": 2, 116 | "SYN_SENT": 3, 117 | "SYN_RECV": 4, 118 | "FIN_WAIT1": 5, 119 | "FIN_WAIT2": 6, 120 | "TIME_WAIT": 7, 121 | "CLOSE_WAIT": 8, 122 | "LAST_ACK": 9, 123 | "CLOSING": 10, 124 | "CLOSED": 11, 125 | } 126 | 127 | if o, exists := order[strings.ToUpper(state)]; exists { 128 | return o 129 | } 130 | return 99 131 | } 132 | 133 | -------------------------------------------------------------------------------- /internal/resolver/resolver_bench_test.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func BenchmarkResolveAddr_CacheHit(b *testing.B) { 10 | r := New(100 * time.Millisecond) 11 | addr := "127.0.0.1" 12 | 13 | // pre-populate cache 14 | r.ResolveAddr(addr) 15 | 16 | b.ResetTimer() 17 | for i := 0; i < b.N; i++ { 18 | r.ResolveAddr(addr) 19 | } 20 | } 21 | 22 | func BenchmarkResolveAddr_CacheMiss(b *testing.B) { 23 | r := New(10 * time.Millisecond) // short timeout for faster benchmarks 24 | 25 | b.ResetTimer() 26 | for i := 0; i < b.N; i++ { 27 | // use different addresses to avoid cache hits 28 | addr := fmt.Sprintf("127.0.0.%d", i%256) 29 | r.ClearCache() // clear cache to force miss 30 | r.ResolveAddr(addr) 31 | } 32 | } 33 | 34 | func BenchmarkResolveAddr_NoCache(b *testing.B) { 35 | r := New(10 * time.Millisecond) 36 | r.SetNoCache(true) 37 | addr := "127.0.0.1" 38 | 39 | b.ResetTimer() 40 | for i := 0; i < b.N; i++ { 41 | r.ResolveAddr(addr) 42 | } 43 | } 44 | 45 | func BenchmarkResolvePort_CacheHit(b *testing.B) { 46 | r := New(100 * time.Millisecond) 47 | 48 | // pre-populate cache 49 | r.ResolvePort(80, "tcp") 50 | 51 | b.ResetTimer() 52 | for i := 0; i < b.N; i++ { 53 | r.ResolvePort(80, "tcp") 54 | } 55 | } 56 | 57 | func BenchmarkResolvePort_WellKnown(b *testing.B) { 58 | r := New(100 * time.Millisecond) 59 | 60 | b.ResetTimer() 61 | for i := 0; i < b.N; i++ { 62 | r.ClearCache() 63 | r.ResolvePort(443, "tcp") 64 | } 65 | } 66 | 67 | func BenchmarkGetServiceName(b *testing.B) { 68 | for i := 0; i < b.N; i++ { 69 | getServiceName(80, "tcp") 70 | } 71 | } 72 | 73 | func BenchmarkGetServiceName_NotFound(b *testing.B) { 74 | for i := 0; i < b.N; i++ { 75 | getServiceName(12345, "tcp") 76 | } 77 | } 78 | 79 | func BenchmarkResolveAddrsParallel_10(b *testing.B) { 80 | benchmarkResolveAddrsParallel(b, 10) 81 | } 82 | 83 | func BenchmarkResolveAddrsParallel_100(b *testing.B) { 84 | benchmarkResolveAddrsParallel(b, 100) 85 | } 86 | 87 | func BenchmarkResolveAddrsParallel_1000(b *testing.B) { 88 | benchmarkResolveAddrsParallel(b, 1000) 89 | } 90 | 91 | func benchmarkResolveAddrsParallel(b *testing.B, count int) { 92 | addrs := make([]string, count) 93 | for i := 0; i < count; i++ { 94 | addrs[i] = fmt.Sprintf("127.0.%d.%d", i/256, i%256) 95 | } 96 | 97 | b.ResetTimer() 98 | for i := 0; i < b.N; i++ { 99 | r := New(10 * time.Millisecond) 100 | r.ResolveAddrsParallel(addrs) 101 | } 102 | } 103 | 104 | func BenchmarkConcurrentResolveAddr(b *testing.B) { 105 | r := New(100 * time.Millisecond) 106 | addr := "127.0.0.1" 107 | 108 | // pre-populate cache 109 | r.ResolveAddr(addr) 110 | 111 | b.ResetTimer() 112 | b.RunParallel(func(pb *testing.PB) { 113 | for pb.Next() { 114 | r.ResolveAddr(addr) 115 | } 116 | }) 117 | } 118 | 119 | func BenchmarkConcurrentResolvePort(b *testing.B) { 120 | r := New(100 * time.Millisecond) 121 | 122 | // pre-populate cache 123 | r.ResolvePort(80, "tcp") 124 | 125 | b.ResetTimer() 126 | b.RunParallel(func(pb *testing.PB) { 127 | for pb.Next() { 128 | r.ResolvePort(80, "tcp") 129 | } 130 | }) 131 | } 132 | 133 | func BenchmarkGetCacheSize(b *testing.B) { 134 | r := New(100 * time.Millisecond) 135 | 136 | // populate with some entries 137 | for i := 0; i < 100; i++ { 138 | r.ResolvePort(i+1, "tcp") 139 | } 140 | 141 | b.ResetTimer() 142 | for i := 0; i < b.N; i++ { 143 | r.GetCacheSize() 144 | } 145 | } 146 | 147 | func BenchmarkClearCache(b *testing.B) { 148 | r := New(100 * time.Millisecond) 149 | 150 | b.ResetTimer() 151 | for i := 0; i < b.N; i++ { 152 | // populate and clear 153 | for j := 0; j < 10; j++ { 154 | r.ResolvePort(j+1, "tcp") 155 | } 156 | r.ClearCache() 157 | } 158 | } 159 | 160 | -------------------------------------------------------------------------------- /internal/collector/sort_test.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestSortConnections(t *testing.T) { 9 | conns := []Connection{ 10 | {PID: 3, Process: "nginx", Lport: 80, State: "ESTABLISHED"}, 11 | {PID: 1, Process: "sshd", Lport: 22, State: "LISTEN"}, 12 | {PID: 2, Process: "postgres", Lport: 5432, State: "LISTEN"}, 13 | } 14 | 15 | t.Run("sort by PID ascending", func(t *testing.T) { 16 | c := make([]Connection, len(conns)) 17 | copy(c, conns) 18 | 19 | SortConnections(c, SortOptions{Field: SortByPID, Direction: SortAsc}) 20 | 21 | if c[0].PID != 1 || c[1].PID != 2 || c[2].PID != 3 { 22 | t.Errorf("expected PIDs [1,2,3], got [%d,%d,%d]", c[0].PID, c[1].PID, c[2].PID) 23 | } 24 | }) 25 | 26 | t.Run("sort by PID descending", func(t *testing.T) { 27 | c := make([]Connection, len(conns)) 28 | copy(c, conns) 29 | 30 | SortConnections(c, SortOptions{Field: SortByPID, Direction: SortDesc}) 31 | 32 | if c[0].PID != 3 || c[1].PID != 2 || c[2].PID != 1 { 33 | t.Errorf("expected PIDs [3,2,1], got [%d,%d,%d]", c[0].PID, c[1].PID, c[2].PID) 34 | } 35 | }) 36 | 37 | t.Run("sort by port ascending", func(t *testing.T) { 38 | c := make([]Connection, len(conns)) 39 | copy(c, conns) 40 | 41 | SortConnections(c, SortOptions{Field: SortByLport, Direction: SortAsc}) 42 | 43 | if c[0].Lport != 22 || c[1].Lport != 80 || c[2].Lport != 5432 { 44 | t.Errorf("expected ports [22,80,5432], got [%d,%d,%d]", c[0].Lport, c[1].Lport, c[2].Lport) 45 | } 46 | }) 47 | 48 | t.Run("sort by state puts LISTEN first", func(t *testing.T) { 49 | c := make([]Connection, len(conns)) 50 | copy(c, conns) 51 | 52 | SortConnections(c, SortOptions{Field: SortByState, Direction: SortAsc}) 53 | 54 | if c[0].State != "LISTEN" || c[1].State != "LISTEN" || c[2].State != "ESTABLISHED" { 55 | t.Errorf("expected LISTEN states first, got [%s,%s,%s]", c[0].State, c[1].State, c[2].State) 56 | } 57 | }) 58 | 59 | t.Run("sort by process case insensitive", func(t *testing.T) { 60 | c := []Connection{ 61 | {Process: "Nginx"}, 62 | {Process: "apache"}, 63 | {Process: "SSHD"}, 64 | } 65 | 66 | SortConnections(c, SortOptions{Field: SortByProcess, Direction: SortAsc}) 67 | 68 | if c[0].Process != "apache" { 69 | t.Errorf("expected apache first, got %s", c[0].Process) 70 | } 71 | }) 72 | } 73 | 74 | func TestParseSortOptions(t *testing.T) { 75 | tests := []struct { 76 | input string 77 | wantField SortField 78 | wantDir SortDirection 79 | }{ 80 | {"pid", SortByPID, SortAsc}, 81 | {"pid:asc", SortByPID, SortAsc}, 82 | {"pid:desc", SortByPID, SortDesc}, 83 | {"lport", SortByLport, SortAsc}, 84 | {"LPORT:DESC", SortByLport, SortDesc}, 85 | {"", SortByLport, SortAsc}, // default 86 | } 87 | 88 | for _, tt := range tests { 89 | t.Run(tt.input, func(t *testing.T) { 90 | opts := ParseSortOptions(tt.input) 91 | if opts.Field != tt.wantField { 92 | t.Errorf("field: got %v, want %v", opts.Field, tt.wantField) 93 | } 94 | if opts.Direction != tt.wantDir { 95 | t.Errorf("direction: got %v, want %v", opts.Direction, tt.wantDir) 96 | } 97 | }) 98 | } 99 | } 100 | 101 | func TestStateOrder(t *testing.T) { 102 | if stateOrder("LISTEN") >= stateOrder("ESTABLISHED") { 103 | t.Error("LISTEN should come before ESTABLISHED") 104 | } 105 | if stateOrder("ESTABLISHED") >= stateOrder("TIME_WAIT") { 106 | t.Error("ESTABLISHED should come before TIME_WAIT") 107 | } 108 | if stateOrder("UNKNOWN") != 99 { 109 | t.Error("unknown states should return 99") 110 | } 111 | } 112 | 113 | func TestSortByTimestamp(t *testing.T) { 114 | now := time.Now() 115 | conns := []Connection{ 116 | {TS: now.Add(2 * time.Second)}, 117 | {TS: now}, 118 | {TS: now.Add(1 * time.Second)}, 119 | } 120 | 121 | SortConnections(conns, SortOptions{Field: SortByTimestamp, Direction: SortAsc}) 122 | 123 | if !conns[0].TS.Equal(now) { 124 | t.Error("oldest timestamp should be first") 125 | } 126 | if !conns[2].TS.Equal(now.Add(2 * time.Second)) { 127 | t.Error("newest timestamp should be last") 128 | } 129 | } 130 | 131 | -------------------------------------------------------------------------------- /internal/collector/query.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | // Query combines filtering, sorting, and limiting into a single operation 4 | type Query struct { 5 | Filter FilterOptions 6 | Sort SortOptions 7 | Limit int 8 | } 9 | 10 | // NewQuery creates a query with sensible defaults 11 | func NewQuery() *Query { 12 | return &Query{ 13 | Filter: FilterOptions{}, 14 | Sort: SortOptions{Field: SortByLport, Direction: SortAsc}, 15 | Limit: 0, 16 | } 17 | } 18 | 19 | // WithFilter sets the filter options 20 | func (q *Query) WithFilter(f FilterOptions) *Query { 21 | q.Filter = f 22 | return q 23 | } 24 | 25 | // WithSort sets the sort options 26 | func (q *Query) WithSort(s SortOptions) *Query { 27 | q.Sort = s 28 | return q 29 | } 30 | 31 | // WithSortString parses and sets sort options from a string like "pid:desc" 32 | func (q *Query) WithSortString(s string) *Query { 33 | q.Sort = ParseSortOptions(s) 34 | return q 35 | } 36 | 37 | // WithLimit sets the maximum number of results 38 | func (q *Query) WithLimit(n int) *Query { 39 | q.Limit = n 40 | return q 41 | } 42 | 43 | // Proto filters by protocol 44 | func (q *Query) Proto(proto string) *Query { 45 | q.Filter.Proto = proto 46 | return q 47 | } 48 | 49 | // State filters by connection state 50 | func (q *Query) State(state string) *Query { 51 | q.Filter.State = state 52 | return q 53 | } 54 | 55 | // Process filters by process name (substring match) 56 | func (q *Query) Process(proc string) *Query { 57 | q.Filter.Proc = proc 58 | return q 59 | } 60 | 61 | // PID filters by process ID 62 | func (q *Query) PID(pid int) *Query { 63 | q.Filter.Pid = pid 64 | return q 65 | } 66 | 67 | // LocalPort filters by local port 68 | func (q *Query) LocalPort(port int) *Query { 69 | q.Filter.Lport = port 70 | return q 71 | } 72 | 73 | // RemotePort filters by remote port 74 | func (q *Query) RemotePort(port int) *Query { 75 | q.Filter.Rport = port 76 | return q 77 | } 78 | 79 | // IPv4Only filters to only IPv4 connections 80 | func (q *Query) IPv4Only() *Query { 81 | q.Filter.IPv4 = true 82 | q.Filter.IPv6 = false 83 | return q 84 | } 85 | 86 | // IPv6Only filters to only IPv6 connections 87 | func (q *Query) IPv6Only() *Query { 88 | q.Filter.IPv4 = false 89 | q.Filter.IPv6 = true 90 | return q 91 | } 92 | 93 | // Listening filters to only listening sockets 94 | func (q *Query) Listening() *Query { 95 | q.Filter.State = "LISTEN" 96 | return q 97 | } 98 | 99 | // Established filters to only established connections 100 | func (q *Query) Established() *Query { 101 | q.Filter.State = "ESTABLISHED" 102 | return q 103 | } 104 | 105 | // Contains filters by substring in process, local addr, or remote addr 106 | func (q *Query) Contains(s string) *Query { 107 | q.Filter.Contains = s 108 | return q 109 | } 110 | 111 | // Execute runs the query and returns results 112 | func (q *Query) Execute() ([]Connection, error) { 113 | conns, err := GetConnections() 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | return q.Apply(conns), nil 119 | } 120 | 121 | // Apply applies the query to a slice of connections 122 | func (q *Query) Apply(conns []Connection) []Connection { 123 | result := FilterConnections(conns, q.Filter) 124 | SortConnections(result, q.Sort) 125 | 126 | if q.Limit > 0 && len(result) > q.Limit { 127 | result = result[:q.Limit] 128 | } 129 | 130 | return result 131 | } 132 | 133 | // common pre-built queries 134 | 135 | // ListeningTCP returns a query for TCP listeners 136 | func ListeningTCP() *Query { 137 | return NewQuery().Proto("tcp").Listening() 138 | } 139 | 140 | // ListeningAll returns a query for all listeners 141 | func ListeningAll() *Query { 142 | return NewQuery().Listening() 143 | } 144 | 145 | // EstablishedTCP returns a query for established TCP connections 146 | func EstablishedTCP() *Query { 147 | return NewQuery().Proto("tcp").Established() 148 | } 149 | 150 | // ByProcess returns a query filtered by process name 151 | func ByProcess(name string) *Query { 152 | return NewQuery().Process(name) 153 | } 154 | 155 | // ByPort returns a query filtered by local port 156 | func ByPort(port int) *Query { 157 | return NewQuery().LocalPort(port) 158 | } 159 | 160 | -------------------------------------------------------------------------------- /internal/collector/filter.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | ) 7 | 8 | type FilterOptions struct { 9 | Proto string 10 | State string 11 | Pid int 12 | Proc string 13 | Lport int 14 | Rport int 15 | User string 16 | UID int 17 | Laddr string 18 | Raddr string 19 | Contains string 20 | IPv4 bool 21 | IPv6 bool 22 | Interface string 23 | Mark string 24 | Namespace string 25 | Inode int64 26 | Since time.Time 27 | SinceRel time.Duration 28 | } 29 | 30 | func (f *FilterOptions) IsEmpty() bool { 31 | return f.Proto == "" && f.State == "" && f.Pid == 0 && f.Proc == "" && 32 | f.Lport == 0 && f.Rport == 0 && f.User == "" && f.UID == 0 && 33 | f.Laddr == "" && f.Raddr == "" && f.Contains == "" && 34 | f.Interface == "" && f.Mark == "" && f.Namespace == "" && f.Inode == 0 && 35 | f.Since.IsZero() && f.SinceRel == 0 && !f.IPv4 && !f.IPv6 36 | } 37 | 38 | func (f *FilterOptions) Matches(c Connection) bool { 39 | if f.Proto != "" && !matchesProto(c.Proto, f.Proto) { 40 | return false 41 | } 42 | if f.State != "" && !strings.EqualFold(c.State, f.State) { 43 | return false 44 | } 45 | if f.Pid != 0 && c.PID != f.Pid { 46 | return false 47 | } 48 | if f.Proc != "" && !containsIgnoreCase(c.Process, f.Proc) { 49 | return false 50 | } 51 | if f.Lport != 0 && c.Lport != f.Lport { 52 | return false 53 | } 54 | if f.Rport != 0 && c.Rport != f.Rport { 55 | return false 56 | } 57 | if f.User != "" && !strings.EqualFold(c.User, f.User) { 58 | return false 59 | } 60 | if f.UID != 0 && c.UID != f.UID { 61 | return false 62 | } 63 | if f.Laddr != "" && !strings.EqualFold(c.Laddr, f.Laddr) { 64 | return false 65 | } 66 | if f.Raddr != "" && !strings.EqualFold(c.Raddr, f.Raddr) { 67 | return false 68 | } 69 | if f.Contains != "" && !matchesContains(c, f.Contains) { 70 | return false 71 | } 72 | if f.IPv4 && c.IPVersion != "IPv4" { 73 | return false 74 | } 75 | if f.IPv6 && c.IPVersion != "IPv6" { 76 | return false 77 | } 78 | if f.Interface != "" && !strings.EqualFold(c.Interface, f.Interface) { 79 | return false 80 | } 81 | if f.Mark != "" && !strings.EqualFold(c.Mark, f.Mark) { 82 | return false 83 | } 84 | if f.Namespace != "" && !strings.EqualFold(c.Namespace, f.Namespace) { 85 | return false 86 | } 87 | if f.Inode != 0 && c.Inode != f.Inode { 88 | return false 89 | } 90 | if !f.Since.IsZero() && c.TS.Before(f.Since) { 91 | return false 92 | } 93 | if f.SinceRel != 0 { 94 | threshold := time.Now().Add(-f.SinceRel) 95 | if c.TS.Before(threshold) { 96 | return false 97 | } 98 | } 99 | 100 | return true 101 | } 102 | 103 | func containsIgnoreCase(s, substr string) bool { 104 | return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) 105 | } 106 | 107 | // checks if a connection's protocol matches the filter. 108 | // treats "tcp" as matching "tcp" and "tcp6", same for "udp"/"udp6" 109 | func matchesProto(connProto, filterProto string) bool { 110 | connLower := strings.ToLower(connProto) 111 | filterLower := strings.ToLower(filterProto) 112 | 113 | // exact match 114 | if connLower == filterLower { 115 | return true 116 | } 117 | 118 | // "tcp" matches both "tcp" and "tcp6" 119 | if filterLower == "tcp" && (connLower == "tcp" || connLower == "tcp6") { 120 | return true 121 | } 122 | 123 | // "udp" matches both "udp" and "udp6" 124 | if filterLower == "udp" && (connLower == "udp" || connLower == "udp6") { 125 | return true 126 | } 127 | 128 | return false 129 | } 130 | 131 | func matchesContains(c Connection, query string) bool { 132 | q := strings.ToLower(query) 133 | return containsIgnoreCase(c.Process, q) || 134 | containsIgnoreCase(c.Laddr, q) || 135 | containsIgnoreCase(c.Raddr, q) || 136 | containsIgnoreCase(c.User, q) 137 | } 138 | 139 | // ParseTimeFilter parses a time filter string (RFC3339 or relative like "5s", "2m", "1h") 140 | func ParseTimeFilter(timeStr string) (time.Time, time.Duration, error) { 141 | // Try parsing as RFC3339 first 142 | if t, err := time.Parse(time.RFC3339, timeStr); err == nil { 143 | return t, 0, nil 144 | } 145 | 146 | // Try parsing as relative duration 147 | if dur, err := time.ParseDuration(timeStr); err == nil { 148 | return time.Time{}, dur, nil 149 | } 150 | 151 | return time.Time{}, 0, nil // Invalid format, but don't error 152 | } 153 | 154 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "snitch - a friendlier ss/netstat for humans"; 3 | 4 | inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; 5 | 6 | outputs = { self, nixpkgs }: 7 | let 8 | systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; 9 | eachSystem = nixpkgs.lib.genAttrs systems; 10 | 11 | # go 1.25 binary derivation (required until nixpkgs ships it) 12 | mkGo125 = pkgs: 13 | let 14 | version = "1.25.0"; 15 | platform = { 16 | "x86_64-linux" = { suffix = "linux-amd64"; hash = "sha256-KFKvDLIKExObNEiZLmm4aOUO0Pih5ZQO4d6eGaEjthM="; GOOS = "linux"; GOARCH = "amd64"; }; 17 | "aarch64-linux" = { suffix = "linux-arm64"; hash = "sha256-Bd511plKJ4NpmBXuVTvVqTJ9i3mZHeNuOLZoYngvVK4="; GOOS = "linux"; GOARCH = "arm64"; }; 18 | "x86_64-darwin" = { suffix = "darwin-amd64"; hash = "sha256-W9YOgjA3BiwjB8cegRGAmGURZxTW9rQQWXz1B139gO8="; GOOS = "darwin"; GOARCH = "amd64"; }; 19 | "aarch64-darwin" = { suffix = "darwin-arm64"; hash = "sha256-VEkyhEFW2Bcveij3fyrJwVojBGaYtiQ/YzsKCwDAdJw="; GOOS = "darwin"; GOARCH = "arm64"; }; 20 | }.${pkgs.stdenv.hostPlatform.system} or (throw "unsupported system: ${pkgs.stdenv.hostPlatform.system}"); 21 | in 22 | pkgs.stdenv.mkDerivation { 23 | pname = "go"; 24 | inherit version; 25 | src = pkgs.fetchurl { 26 | url = "https://go.dev/dl/go${version}.${platform.suffix}.tar.gz"; 27 | inherit (platform) hash; 28 | }; 29 | dontBuild = true; 30 | dontPatchELF = true; 31 | dontStrip = true; 32 | installPhase = '' 33 | runHook preInstall 34 | mkdir -p $out/{bin,share/go} 35 | tar -xzf $src --strip-components=1 -C $out/share/go 36 | ln -s $out/share/go/bin/go $out/bin/go 37 | ln -s $out/share/go/bin/gofmt $out/bin/gofmt 38 | runHook postInstall 39 | ''; 40 | passthru = { 41 | inherit (platform) GOOS GOARCH; 42 | }; 43 | }; 44 | 45 | pkgsFor = system: import nixpkgs { inherit system; }; 46 | 47 | mkSnitch = pkgs: 48 | let 49 | rev = self.shortRev or self.dirtyShortRev or "unknown"; 50 | version = "nix-${rev}"; 51 | isDarwin = pkgs.stdenv.isDarwin; 52 | go = mkGo125 pkgs; 53 | buildGoModule = pkgs.buildGoModule.override { inherit go; }; 54 | in 55 | buildGoModule { 56 | pname = "snitch"; 57 | inherit version; 58 | src = self; 59 | vendorHash = "sha256-fX3wOqeOgjH7AuWGxPQxJ+wbhp240CW8tiF4rVUUDzk="; 60 | # darwin requires cgo for libproc, linux uses pure go with /proc 61 | env.CGO_ENABLED = if isDarwin then "1" else "0"; 62 | env.GOTOOLCHAIN = "local"; 63 | # darwin: use macOS 15 SDK for SecTrustCopyCertificateChain (Go 1.25 crypto/x509) 64 | buildInputs = pkgs.lib.optionals isDarwin [ pkgs.apple-sdk_15 ]; 65 | ldflags = [ 66 | "-s" 67 | "-w" 68 | "-X snitch/cmd.Version=${version}" 69 | "-X snitch/cmd.Commit=${rev}" 70 | "-X snitch/cmd.Date=${self.lastModifiedDate or "unknown"}" 71 | ]; 72 | meta = { 73 | description = "a friendlier ss/netstat for humans"; 74 | homepage = "https://github.com/karol-broda/snitch"; 75 | license = pkgs.lib.licenses.mit; 76 | platforms = pkgs.lib.platforms.linux ++ pkgs.lib.platforms.darwin; 77 | mainProgram = "snitch"; 78 | }; 79 | }; 80 | in 81 | { 82 | packages = eachSystem (system: 83 | let pkgs = pkgsFor system; in 84 | { 85 | default = mkSnitch pkgs; 86 | snitch = mkSnitch pkgs; 87 | } 88 | ); 89 | 90 | devShells = eachSystem (system: 91 | let 92 | pkgs = pkgsFor system; 93 | go = mkGo125 pkgs; 94 | in 95 | { 96 | default = pkgs.mkShell { 97 | packages = [ go pkgs.git pkgs.vhs ]; 98 | env.GOTOOLCHAIN = "local"; 99 | shellHook = '' 100 | echo "go toolchain: $(go version)" 101 | ''; 102 | }; 103 | } 104 | ); 105 | 106 | overlays.default = final: _prev: { 107 | snitch = mkSnitch final; 108 | }; 109 | }; 110 | } 111 | -------------------------------------------------------------------------------- /internal/theme/theme.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | // Theme represents the visual styling for the TUI 11 | type Theme struct { 12 | Name string 13 | Styles Styles 14 | } 15 | 16 | // Styles contains all the styling definitions 17 | type Styles struct { 18 | Header lipgloss.Style 19 | Border lipgloss.Style 20 | Selected lipgloss.Style 21 | Watched lipgloss.Style 22 | Normal lipgloss.Style 23 | Error lipgloss.Style 24 | Success lipgloss.Style 25 | Warning lipgloss.Style 26 | Proto ProtoStyles 27 | State StateStyles 28 | Footer lipgloss.Style 29 | Background lipgloss.Style 30 | } 31 | 32 | // ProtoStyles contains protocol-specific colors 33 | type ProtoStyles struct { 34 | TCP lipgloss.Style 35 | UDP lipgloss.Style 36 | Unix lipgloss.Style 37 | TCP6 lipgloss.Style 38 | UDP6 lipgloss.Style 39 | } 40 | 41 | // StateStyles contains connection state-specific colors 42 | type StateStyles struct { 43 | Listen lipgloss.Style 44 | Established lipgloss.Style 45 | TimeWait lipgloss.Style 46 | CloseWait lipgloss.Style 47 | SynSent lipgloss.Style 48 | SynRecv lipgloss.Style 49 | FinWait1 lipgloss.Style 50 | FinWait2 lipgloss.Style 51 | Closing lipgloss.Style 52 | LastAck lipgloss.Style 53 | Closed lipgloss.Style 54 | } 55 | 56 | var themes map[string]*Theme 57 | 58 | func init() { 59 | themes = make(map[string]*Theme) 60 | 61 | // ansi theme (default) - inherits from terminal colors 62 | themes["ansi"] = paletteANSI.ToTheme() 63 | 64 | // catppuccin variants 65 | themes["catppuccin-mocha"] = paletteCatppuccinMocha.ToTheme() 66 | themes["catppuccin-macchiato"] = paletteCatppuccinMacchiato.ToTheme() 67 | themes["catppuccin-frappe"] = paletteCatppuccinFrappe.ToTheme() 68 | themes["catppuccin-latte"] = paletteCatppuccinLatte.ToTheme() 69 | 70 | // gruvbox variants 71 | themes["gruvbox-dark"] = paletteGruvboxDark.ToTheme() 72 | themes["gruvbox-light"] = paletteGruvboxLight.ToTheme() 73 | 74 | // dracula 75 | themes["dracula"] = paletteDracula.ToTheme() 76 | 77 | // nord 78 | themes["nord"] = paletteNord.ToTheme() 79 | 80 | // tokyo night variants 81 | themes["tokyo-night"] = paletteTokyoNight.ToTheme() 82 | themes["tokyo-night-storm"] = paletteTokyoNightStorm.ToTheme() 83 | themes["tokyo-night-light"] = paletteTokyoNightLight.ToTheme() 84 | 85 | // solarized variants 86 | themes["solarized-dark"] = paletteSolarizedDark.ToTheme() 87 | themes["solarized-light"] = paletteSolarizedLight.ToTheme() 88 | 89 | // one dark 90 | themes["one-dark"] = paletteOneDark.ToTheme() 91 | 92 | // monochrome (no colors) 93 | themes["mono"] = createMonoTheme() 94 | } 95 | 96 | // DefaultTheme is the theme used when none is specified 97 | const DefaultTheme = "ansi" 98 | 99 | // GetTheme returns a theme by name 100 | func GetTheme(name string) *Theme { 101 | if name == "" || name == "auto" || name == "default" { 102 | return themes[DefaultTheme] 103 | } 104 | 105 | if theme, exists := themes[name]; exists { 106 | return theme 107 | } 108 | 109 | // fallback to default 110 | return themes[DefaultTheme] 111 | } 112 | 113 | // ListThemes returns available theme names sorted alphabetically 114 | func ListThemes() []string { 115 | names := make([]string, 0, len(themes)) 116 | for name := range themes { 117 | names = append(names, name) 118 | } 119 | sort.Strings(names) 120 | return names 121 | } 122 | 123 | // GetProtoStyle returns the appropriate style for a protocol 124 | func (s *Styles) GetProtoStyle(proto string) lipgloss.Style { 125 | switch strings.ToLower(proto) { 126 | case "tcp": 127 | return s.Proto.TCP 128 | case "udp": 129 | return s.Proto.UDP 130 | case "unix": 131 | return s.Proto.Unix 132 | case "tcp6": 133 | return s.Proto.TCP6 134 | case "udp6": 135 | return s.Proto.UDP6 136 | default: 137 | return s.Normal 138 | } 139 | } 140 | 141 | // GetStateStyle returns the appropriate style for a connection state 142 | func (s *Styles) GetStateStyle(state string) lipgloss.Style { 143 | switch strings.ToUpper(state) { 144 | case "LISTEN": 145 | return s.State.Listen 146 | case "ESTABLISHED": 147 | return s.State.Established 148 | case "TIME_WAIT": 149 | return s.State.TimeWait 150 | case "CLOSE_WAIT": 151 | return s.State.CloseWait 152 | case "SYN_SENT": 153 | return s.State.SynSent 154 | case "SYN_RECV": 155 | return s.State.SynRecv 156 | case "FIN_WAIT1": 157 | return s.State.FinWait1 158 | case "FIN_WAIT2": 159 | return s.State.FinWait2 160 | case "CLOSING": 161 | return s.State.Closing 162 | case "LAST_ACK": 163 | return s.State.LastAck 164 | case "CLOSED": 165 | return s.State.Closed 166 | default: 167 | return s.Normal 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /internal/collector/query_test.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestQueryBuilder(t *testing.T) { 8 | t.Run("fluent builder pattern", func(t *testing.T) { 9 | q := NewQuery(). 10 | Proto("tcp"). 11 | State("LISTEN"). 12 | WithLimit(10) 13 | 14 | if q.Filter.Proto != "tcp" { 15 | t.Errorf("expected proto tcp, got %s", q.Filter.Proto) 16 | } 17 | if q.Filter.State != "LISTEN" { 18 | t.Errorf("expected state LISTEN, got %s", q.Filter.State) 19 | } 20 | if q.Limit != 10 { 21 | t.Errorf("expected limit 10, got %d", q.Limit) 22 | } 23 | }) 24 | 25 | t.Run("convenience methods", func(t *testing.T) { 26 | q := NewQuery().Listening() 27 | if q.Filter.State != "LISTEN" { 28 | t.Errorf("Listening() should set state to LISTEN") 29 | } 30 | 31 | q = NewQuery().Established() 32 | if q.Filter.State != "ESTABLISHED" { 33 | t.Errorf("Established() should set state to ESTABLISHED") 34 | } 35 | 36 | q = NewQuery().IPv4Only() 37 | if q.Filter.IPv4 != true || q.Filter.IPv6 != false { 38 | t.Error("IPv4Only() should set IPv4=true, IPv6=false") 39 | } 40 | 41 | q = NewQuery().IPv6Only() 42 | if q.Filter.IPv4 != false || q.Filter.IPv6 != true { 43 | t.Error("IPv6Only() should set IPv4=false, IPv6=true") 44 | } 45 | }) 46 | 47 | t.Run("sort options", func(t *testing.T) { 48 | q := NewQuery().WithSortString("pid:desc") 49 | 50 | if q.Sort.Field != SortByPID { 51 | t.Errorf("expected sort by pid, got %v", q.Sort.Field) 52 | } 53 | if q.Sort.Direction != SortDesc { 54 | t.Errorf("expected sort desc, got %v", q.Sort.Direction) 55 | } 56 | }) 57 | } 58 | 59 | func TestQueryApply(t *testing.T) { 60 | conns := []Connection{ 61 | {PID: 1, Process: "nginx", Proto: "tcp", State: "LISTEN", Lport: 80}, 62 | {PID: 2, Process: "nginx", Proto: "tcp", State: "ESTABLISHED", Lport: 80}, 63 | {PID: 3, Process: "sshd", Proto: "tcp", State: "LISTEN", Lport: 22}, 64 | {PID: 4, Process: "postgres", Proto: "tcp", State: "LISTEN", Lport: 5432}, 65 | {PID: 5, Process: "dnsmasq", Proto: "udp", State: "", Lport: 53}, 66 | } 67 | 68 | t.Run("filter by state", func(t *testing.T) { 69 | q := NewQuery().Listening() 70 | result := q.Apply(conns) 71 | 72 | if len(result) != 3 { 73 | t.Errorf("expected 3 listening connections, got %d", len(result)) 74 | } 75 | }) 76 | 77 | t.Run("filter by proto", func(t *testing.T) { 78 | q := NewQuery().Proto("udp") 79 | result := q.Apply(conns) 80 | 81 | if len(result) != 1 { 82 | t.Errorf("expected 1 udp connection, got %d", len(result)) 83 | } 84 | if result[0].Process != "dnsmasq" { 85 | t.Errorf("expected dnsmasq, got %s", result[0].Process) 86 | } 87 | }) 88 | 89 | t.Run("filter and sort", func(t *testing.T) { 90 | q := NewQuery().Listening().WithSortString("lport:asc") 91 | result := q.Apply(conns) 92 | 93 | if len(result) != 3 { 94 | t.Fatalf("expected 3, got %d", len(result)) 95 | } 96 | if result[0].Lport != 22 { 97 | t.Errorf("expected port 22 first, got %d", result[0].Lport) 98 | } 99 | }) 100 | 101 | t.Run("filter sort and limit", func(t *testing.T) { 102 | q := NewQuery().Proto("tcp").WithSortString("lport:asc").WithLimit(2) 103 | result := q.Apply(conns) 104 | 105 | if len(result) != 2 { 106 | t.Errorf("expected 2 (limit), got %d", len(result)) 107 | } 108 | }) 109 | 110 | t.Run("process filter substring", func(t *testing.T) { 111 | q := NewQuery().Process("nginx") 112 | result := q.Apply(conns) 113 | 114 | if len(result) != 2 { 115 | t.Errorf("expected 2 nginx connections, got %d", len(result)) 116 | } 117 | }) 118 | 119 | t.Run("contains filter", func(t *testing.T) { 120 | q := NewQuery().Contains("post") 121 | result := q.Apply(conns) 122 | 123 | if len(result) != 1 || result[0].Process != "postgres" { 124 | t.Errorf("expected postgres, got %v", result) 125 | } 126 | }) 127 | } 128 | 129 | func TestPrebuiltQueries(t *testing.T) { 130 | t.Run("ListeningTCP", func(t *testing.T) { 131 | q := ListeningTCP() 132 | if q.Filter.Proto != "tcp" || q.Filter.State != "LISTEN" { 133 | t.Error("ListeningTCP should filter tcp + LISTEN") 134 | } 135 | }) 136 | 137 | t.Run("ListeningAll", func(t *testing.T) { 138 | q := ListeningAll() 139 | if q.Filter.State != "LISTEN" { 140 | t.Error("ListeningAll should filter LISTEN state") 141 | } 142 | }) 143 | 144 | t.Run("EstablishedTCP", func(t *testing.T) { 145 | q := EstablishedTCP() 146 | if q.Filter.Proto != "tcp" || q.Filter.State != "ESTABLISHED" { 147 | t.Error("EstablishedTCP should filter tcp + ESTABLISHED") 148 | } 149 | }) 150 | 151 | t.Run("ByProcess", func(t *testing.T) { 152 | q := ByProcess("nginx") 153 | if q.Filter.Proc != "nginx" { 154 | t.Error("ByProcess should set process filter") 155 | } 156 | }) 157 | 158 | t.Run("ByPort", func(t *testing.T) { 159 | q := ByPort(8080) 160 | if q.Filter.Lport != 8080 { 161 | t.Error("ByPort should set lport filter") 162 | } 163 | }) 164 | } 165 | 166 | -------------------------------------------------------------------------------- /internal/testutil/testutil.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/karol-broda/snitch/internal/collector" 9 | "github.com/karol-broda/snitch/internal/errutil" 10 | ) 11 | 12 | // TestCollector wraps MockCollector for use in tests 13 | type TestCollector struct { 14 | *collector.MockCollector 15 | } 16 | 17 | // NewTestCollector creates a new test collector with default data 18 | func NewTestCollector() *TestCollector { 19 | return &TestCollector{ 20 | MockCollector: collector.NewMockCollector(), 21 | } 22 | } 23 | 24 | // NewTestCollectorWithFixture creates a test collector with a specific fixture 25 | func NewTestCollectorWithFixture(fixtureName string) *TestCollector { 26 | fixtures := collector.GetTestFixtures() 27 | for _, fixture := range fixtures { 28 | if fixture.Name == fixtureName { 29 | mock := collector.NewMockCollector() 30 | mock.SetConnections(fixture.Connections) 31 | return &TestCollector{MockCollector: mock} 32 | } 33 | } 34 | 35 | // Fallback to default if fixture not found 36 | return NewTestCollector() 37 | } 38 | 39 | // SetupTestEnvironment sets up a clean test environment 40 | func SetupTestEnvironment(t *testing.T) (string, func()) { 41 | // Create temporary directory for test files 42 | tempDir, err := os.MkdirTemp("", "snitch-test-*") 43 | if err != nil { 44 | t.Fatalf("Failed to create temp dir: %v", err) 45 | } 46 | 47 | // Set test environment variables 48 | oldConfig := os.Getenv("SNITCH_CONFIG") 49 | oldNoColor := os.Getenv("SNITCH_NO_COLOR") 50 | 51 | errutil.Setenv("SNITCH_NO_COLOR", "1") 52 | 53 | // Cleanup function 54 | cleanup := func() { 55 | errutil.RemoveAll(tempDir) 56 | errutil.Setenv("SNITCH_CONFIG", oldConfig) 57 | errutil.Setenv("SNITCH_NO_COLOR", oldNoColor) 58 | } 59 | 60 | return tempDir, cleanup 61 | } 62 | 63 | // CreateFixtureFile creates a JSON fixture file with the given connections 64 | func CreateFixtureFile(t *testing.T, dir string, name string, connections []collector.Connection) string { 65 | mock := collector.NewMockCollector() 66 | mock.SetConnections(connections) 67 | 68 | filePath := filepath.Join(dir, name+".json") 69 | if err := mock.SaveToFile(filePath); err != nil { 70 | t.Fatalf("Failed to create fixture file %s: %v", filePath, err) 71 | } 72 | 73 | return filePath 74 | } 75 | 76 | // LoadFixtureFile loads connections from a JSON fixture file 77 | func LoadFixtureFile(t *testing.T, filePath string) []collector.Connection { 78 | mock, err := collector.NewMockCollectorFromFile(filePath) 79 | if err != nil { 80 | t.Fatalf("Failed to load fixture file %s: %v", filePath, err) 81 | } 82 | 83 | connections, err := mock.GetConnections() 84 | if err != nil { 85 | t.Fatalf("Failed to get connections from fixture: %v", err) 86 | } 87 | 88 | return connections 89 | } 90 | 91 | // AssertConnectionsEqual compares two slices of connections for equality 92 | func AssertConnectionsEqual(t *testing.T, expected, actual []collector.Connection) { 93 | if len(expected) != len(actual) { 94 | t.Errorf("Connection count mismatch: expected %d, got %d", len(expected), len(actual)) 95 | return 96 | } 97 | 98 | for i, exp := range expected { 99 | act := actual[i] 100 | 101 | // Compare key fields (timestamps may vary slightly) 102 | if exp.PID != act.PID { 103 | t.Errorf("Connection %d PID mismatch: expected %d, got %d", i, exp.PID, act.PID) 104 | } 105 | if exp.Process != act.Process { 106 | t.Errorf("Connection %d Process mismatch: expected %s, got %s", i, exp.Process, act.Process) 107 | } 108 | if exp.Proto != act.Proto { 109 | t.Errorf("Connection %d Proto mismatch: expected %s, got %s", i, exp.Proto, act.Proto) 110 | } 111 | if exp.State != act.State { 112 | t.Errorf("Connection %d State mismatch: expected %s, got %s", i, exp.State, act.State) 113 | } 114 | if exp.Laddr != act.Laddr { 115 | t.Errorf("Connection %d Laddr mismatch: expected %s, got %s", i, exp.Laddr, act.Laddr) 116 | } 117 | if exp.Lport != act.Lport { 118 | t.Errorf("Connection %d Lport mismatch: expected %d, got %d", i, exp.Lport, act.Lport) 119 | } 120 | } 121 | } 122 | 123 | // GetTestConfig returns a test configuration with safe defaults 124 | func GetTestConfig() map[string]interface{} { 125 | return map[string]interface{}{ 126 | "defaults": map[string]interface{}{ 127 | "interval": "1s", 128 | "numeric": true, // Disable resolution in tests 129 | "fields": []string{"pid", "process", "proto", "state", "laddr", "lport"}, 130 | "theme": "mono", // Use monochrome theme in tests 131 | "units": "auto", 132 | "color": "never", 133 | "resolve": false, 134 | "ipv4": false, 135 | "ipv6": false, 136 | "no_headers": false, 137 | "output_format": "table", 138 | "sort_by": "", 139 | }, 140 | } 141 | } 142 | 143 | // CaptureOutput captures stdout/stderr during test execution 144 | type OutputCapture struct { 145 | stdout *os.File 146 | stderr *os.File 147 | oldStdout *os.File 148 | oldStderr *os.File 149 | stdoutFile string 150 | stderrFile string 151 | } 152 | 153 | // NewOutputCapture creates a new output capture 154 | func NewOutputCapture(t *testing.T) *OutputCapture { 155 | tempDir, err := os.MkdirTemp("", "snitch-output-*") 156 | if err != nil { 157 | t.Fatalf("Failed to create temp dir for output capture: %v", err) 158 | } 159 | 160 | stdoutFile := filepath.Join(tempDir, "stdout") 161 | stderrFile := filepath.Join(tempDir, "stderr") 162 | 163 | stdout, err := os.Create(stdoutFile) 164 | if err != nil { 165 | t.Fatalf("Failed to create stdout file: %v", err) 166 | } 167 | 168 | stderr, err := os.Create(stderrFile) 169 | if err != nil { 170 | t.Fatalf("Failed to create stderr file: %v", err) 171 | } 172 | 173 | return &OutputCapture{ 174 | stdout: stdout, 175 | stderr: stderr, 176 | oldStdout: os.Stdout, 177 | oldStderr: os.Stderr, 178 | stdoutFile: stdoutFile, 179 | stderrFile: stderrFile, 180 | } 181 | } 182 | 183 | // Start begins capturing output 184 | func (oc *OutputCapture) Start() { 185 | os.Stdout = oc.stdout 186 | os.Stderr = oc.stderr 187 | } 188 | 189 | // Stop stops capturing and returns the captured output 190 | func (oc *OutputCapture) Stop() (string, string, error) { 191 | // Restore original stdout/stderr 192 | os.Stdout = oc.oldStdout 193 | os.Stderr = oc.oldStderr 194 | 195 | // Close files 196 | errutil.Close(oc.stdout) 197 | errutil.Close(oc.stderr) 198 | 199 | // Read captured content 200 | stdoutContent, err := os.ReadFile(oc.stdoutFile) 201 | if err != nil { 202 | return "", "", err 203 | } 204 | 205 | stderrContent, err := os.ReadFile(oc.stderrFile) 206 | if err != nil { 207 | return "", "", err 208 | } 209 | 210 | // Cleanup 211 | errutil.Remove(oc.stdoutFile) 212 | errutil.Remove(oc.stderrFile) 213 | errutil.Remove(filepath.Dir(oc.stdoutFile)) 214 | 215 | return string(stdoutContent), string(stderrContent), nil 216 | } -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | 10 | "github.com/karol-broda/snitch/internal/theme" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | // Config represents the application configuration 15 | type Config struct { 16 | Defaults DefaultConfig `mapstructure:"defaults"` 17 | } 18 | 19 | // DefaultConfig contains default values for CLI options 20 | type DefaultConfig struct { 21 | Interval string `mapstructure:"interval"` 22 | Numeric bool `mapstructure:"numeric"` 23 | Fields []string `mapstructure:"fields"` 24 | Theme string `mapstructure:"theme"` 25 | Units string `mapstructure:"units"` 26 | Color string `mapstructure:"color"` 27 | Resolve bool `mapstructure:"resolve"` 28 | DNSCache bool `mapstructure:"dns_cache"` 29 | IPv4 bool `mapstructure:"ipv4"` 30 | IPv6 bool `mapstructure:"ipv6"` 31 | NoHeaders bool `mapstructure:"no_headers"` 32 | OutputFormat string `mapstructure:"output_format"` 33 | SortBy string `mapstructure:"sort_by"` 34 | } 35 | 36 | var globalConfig *Config 37 | 38 | // Load loads configuration from file and environment variables 39 | func Load() (*Config, error) { 40 | if globalConfig != nil { 41 | return globalConfig, nil 42 | } 43 | 44 | v := viper.New() 45 | 46 | // set config name and file type (auto-detect based on extension) 47 | v.SetConfigName("snitch") 48 | // don't set config type - let viper auto-detect based on file extension 49 | // this allows both .toml and .yaml files to work 50 | v.AddConfigPath("$HOME/.config/snitch") 51 | v.AddConfigPath("$HOME/.snitch") 52 | v.AddConfigPath("/etc/snitch") 53 | 54 | // Environment variables 55 | v.SetEnvPrefix("SNITCH") 56 | v.AutomaticEnv() 57 | 58 | // environment variable bindings for readme-documented variables 59 | _ = v.BindEnv("config", "SNITCH_CONFIG") 60 | _ = v.BindEnv("defaults.resolve", "SNITCH_RESOLVE") 61 | _ = v.BindEnv("defaults.dns_cache", "SNITCH_DNS_CACHE") 62 | _ = v.BindEnv("defaults.theme", "SNITCH_THEME") 63 | _ = v.BindEnv("defaults.color", "SNITCH_NO_COLOR") 64 | 65 | // Set defaults 66 | setDefaults(v) 67 | 68 | // Handle SNITCH_CONFIG environment variable for custom config path 69 | if configPath := os.Getenv("SNITCH_CONFIG"); configPath != "" { 70 | v.SetConfigFile(configPath) 71 | } 72 | 73 | // Try to read config file 74 | if err := v.ReadInConfig(); err != nil { 75 | // It's OK if config file doesn't exist 76 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 77 | return nil, fmt.Errorf("error reading config file: %w", err) 78 | } 79 | } 80 | 81 | // Handle special environment variables 82 | handleSpecialEnvVars(v) 83 | 84 | // Unmarshal into config struct 85 | config := &Config{} 86 | if err := v.Unmarshal(config); err != nil { 87 | return nil, fmt.Errorf("error unmarshaling config: %w", err) 88 | } 89 | 90 | globalConfig = config 91 | return config, nil 92 | } 93 | 94 | func setDefaults(v *viper.Viper) { 95 | v.SetDefault("defaults.interval", "1s") 96 | v.SetDefault("defaults.numeric", false) 97 | v.SetDefault("defaults.fields", []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"}) 98 | v.SetDefault("defaults.theme", "ansi") 99 | v.SetDefault("defaults.units", "auto") 100 | v.SetDefault("defaults.color", "auto") 101 | v.SetDefault("defaults.resolve", true) 102 | v.SetDefault("defaults.dns_cache", true) 103 | v.SetDefault("defaults.ipv4", false) 104 | v.SetDefault("defaults.ipv6", false) 105 | v.SetDefault("defaults.no_headers", false) 106 | v.SetDefault("defaults.output_format", "table") 107 | v.SetDefault("defaults.sort_by", "") 108 | } 109 | 110 | func handleSpecialEnvVars(v *viper.Viper) { 111 | // Handle SNITCH_NO_COLOR - if set to "1", disable color 112 | if os.Getenv("SNITCH_NO_COLOR") == "1" { 113 | v.Set("defaults.color", "never") 114 | } 115 | 116 | // Handle SNITCH_RESOLVE - if set to "0", disable resolution 117 | if os.Getenv("SNITCH_RESOLVE") == "0" { 118 | v.Set("defaults.resolve", false) 119 | v.Set("defaults.numeric", true) 120 | } 121 | 122 | // Handle SNITCH_DNS_CACHE - if set to "0", disable dns caching 123 | if os.Getenv("SNITCH_DNS_CACHE") == "0" { 124 | v.Set("defaults.dns_cache", false) 125 | } 126 | } 127 | 128 | // Get returns the global configuration, loading it if necessary 129 | func Get() *Config { 130 | if globalConfig == nil { 131 | config, err := Load() 132 | if err != nil { 133 | return &Config{ 134 | Defaults: DefaultConfig{ 135 | Interval: "1s", 136 | Numeric: false, 137 | Fields: []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"}, 138 | Theme: "ansi", 139 | Units: "auto", 140 | Color: "auto", 141 | Resolve: true, 142 | DNSCache: true, 143 | IPv4: false, 144 | IPv6: false, 145 | NoHeaders: false, 146 | OutputFormat: "table", 147 | SortBy: "", 148 | }, 149 | } 150 | } 151 | return config 152 | } 153 | return globalConfig 154 | } 155 | 156 | // GetInterval returns the configured interval as a duration 157 | func (c *Config) GetInterval() time.Duration { 158 | if duration, err := time.ParseDuration(c.Defaults.Interval); err == nil { 159 | return duration 160 | } 161 | return time.Second // default fallback 162 | } 163 | 164 | // CreateExampleConfig creates an example configuration file 165 | func CreateExampleConfig(path string) error { 166 | themeList := strings.Join(theme.ListThemes(), ", ") 167 | 168 | exampleConfig := fmt.Sprintf(`# snitch configuration file 169 | # See https://github.com/you/snitch for full documentation 170 | 171 | [defaults] 172 | # Default refresh interval for watch/stats/trace commands 173 | interval = "1s" 174 | 175 | # Disable name/service resolution by default 176 | numeric = false 177 | 178 | # Default fields to display (comma-separated list) 179 | fields = ["pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"] 180 | 181 | # Default theme for TUI (ansi inherits terminal colors) 182 | # Available: %s 183 | theme = "%s" 184 | 185 | # Default units for byte display (auto, si, iec) 186 | units = "auto" 187 | 188 | # Default color mode (auto, always, never) 189 | color = "auto" 190 | 191 | # Enable name resolution by default 192 | resolve = true 193 | 194 | # Filter options 195 | ipv4 = false 196 | ipv6 = false 197 | 198 | # Output options 199 | no_headers = false 200 | output_format = "table" 201 | sort_by = "" 202 | `, themeList, theme.DefaultTheme) 203 | 204 | // Ensure directory exists 205 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 206 | return fmt.Errorf("failed to create config directory: %w", err) 207 | } 208 | 209 | // Write config file 210 | if err := os.WriteFile(path, []byte(exampleConfig), 0644); err != nil { 211 | return fmt.Errorf("failed to write config file: %w", err) 212 | } 213 | 214 | return nil 215 | } -------------------------------------------------------------------------------- /internal/tui/keys.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "github.com/karol-broda/snitch/internal/collector" 6 | "time" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 12 | // search mode captures all input 13 | if m.searchActive { 14 | return m.handleSearchKey(msg) 15 | } 16 | 17 | // kill confirmation dialog 18 | if m.showKillConfirm { 19 | return m.handleKillConfirmKey(msg) 20 | } 21 | 22 | // detail view only allows closing 23 | if m.showDetail { 24 | return m.handleDetailKey(msg) 25 | } 26 | 27 | // help view only allows closing 28 | if m.showHelp { 29 | return m.handleHelpKey(msg) 30 | } 31 | 32 | return m.handleNormalKey(msg) 33 | } 34 | 35 | func (m model) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 36 | switch msg.String() { 37 | case "esc": 38 | m.searchActive = false 39 | m.searchQuery = "" 40 | case "enter": 41 | m.searchActive = false 42 | m.cursor = 0 43 | case "backspace": 44 | if len(m.searchQuery) > 0 { 45 | m.searchQuery = m.searchQuery[:len(m.searchQuery)-1] 46 | } 47 | default: 48 | if len(msg.String()) == 1 { 49 | m.searchQuery += msg.String() 50 | } 51 | } 52 | return m, nil 53 | } 54 | 55 | func (m model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 56 | switch msg.String() { 57 | case "esc", "enter", "q": 58 | m.showDetail = false 59 | m.selected = nil 60 | } 61 | return m, nil 62 | } 63 | 64 | func (m model) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 65 | switch msg.String() { 66 | case "esc", "enter", "q", "?": 67 | m.showHelp = false 68 | } 69 | return m, nil 70 | } 71 | 72 | func (m model) handleKillConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 73 | switch msg.String() { 74 | case "y", "Y": 75 | if m.killTarget != nil && m.killTarget.PID > 0 { 76 | pid := m.killTarget.PID 77 | process := m.killTarget.Process 78 | m.showKillConfirm = false 79 | m.killTarget = nil 80 | return m, killProcess(pid, process) 81 | } 82 | m.showKillConfirm = false 83 | m.killTarget = nil 84 | case "n", "N", "esc", "q": 85 | m.showKillConfirm = false 86 | m.killTarget = nil 87 | } 88 | return m, nil 89 | } 90 | 91 | func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 92 | switch msg.String() { 93 | case "q", "ctrl+c": 94 | return m, tea.Sequence(tea.ShowCursor, tea.Quit) 95 | 96 | // navigation 97 | case "j", "down": 98 | m.moveCursor(1) 99 | case "k", "up": 100 | m.moveCursor(-1) 101 | case "g": 102 | m.cursor = 0 103 | case "G": 104 | visible := m.visibleConnections() 105 | if len(visible) > 0 { 106 | m.cursor = len(visible) - 1 107 | } 108 | case "ctrl+d": 109 | m.moveCursor(m.pageSize() / 2) 110 | case "ctrl+u": 111 | m.moveCursor(-m.pageSize() / 2) 112 | case "ctrl+f", "pgdown": 113 | m.moveCursor(m.pageSize()) 114 | case "ctrl+b", "pgup": 115 | m.moveCursor(-m.pageSize()) 116 | 117 | // filter toggles 118 | case "t": 119 | m.showTCP = !m.showTCP 120 | m.clampCursor() 121 | case "u": 122 | m.showUDP = !m.showUDP 123 | m.clampCursor() 124 | case "l": 125 | m.showListening = !m.showListening 126 | m.clampCursor() 127 | case "e": 128 | m.showEstablished = !m.showEstablished 129 | m.clampCursor() 130 | case "o": 131 | m.showOther = !m.showOther 132 | m.clampCursor() 133 | case "a": 134 | m.showTCP = true 135 | m.showUDP = true 136 | m.showListening = true 137 | m.showEstablished = true 138 | m.showOther = true 139 | 140 | // sorting 141 | case "s": 142 | m.cycleSort() 143 | case "S": 144 | m.sortReverse = !m.sortReverse 145 | m.applySorting() 146 | 147 | // search 148 | case "/": 149 | m.searchActive = true 150 | m.searchQuery = "" 151 | 152 | // actions 153 | case "enter", " ": 154 | visible := m.visibleConnections() 155 | if m.cursor < len(visible) { 156 | conn := visible[m.cursor] 157 | m.selected = &conn 158 | m.showDetail = true 159 | } 160 | case "r": 161 | return m, m.fetchData() 162 | case "?": 163 | m.showHelp = true 164 | 165 | // watch/monitor process 166 | case "w": 167 | visible := m.visibleConnections() 168 | if m.cursor < len(visible) { 169 | conn := visible[m.cursor] 170 | if conn.PID > 0 { 171 | wasWatched := m.isWatched(conn.PID) 172 | m.toggleWatch(conn.PID) 173 | 174 | // count connections for this pid 175 | connCount := 0 176 | for _, c := range m.connections { 177 | if c.PID == conn.PID { 178 | connCount++ 179 | } 180 | } 181 | 182 | if wasWatched { 183 | m.statusMessage = fmt.Sprintf("unwatched %s (pid %d)", conn.Process, conn.PID) 184 | } else if connCount > 1 { 185 | m.statusMessage = fmt.Sprintf("watching %s (pid %d) - %d connections", conn.Process, conn.PID, connCount) 186 | } else { 187 | m.statusMessage = fmt.Sprintf("watching %s (pid %d)", conn.Process, conn.PID) 188 | } 189 | m.statusExpiry = time.Now().Add(2 * time.Second) 190 | return m, clearStatusAfter(2 * time.Second) 191 | } 192 | } 193 | case "W": 194 | // clear all watched 195 | count := len(m.watchedPIDs) 196 | m.watchedPIDs = make(map[int]bool) 197 | if count > 0 { 198 | m.statusMessage = fmt.Sprintf("cleared %d watched processes", count) 199 | m.statusExpiry = time.Now().Add(2 * time.Second) 200 | return m, clearStatusAfter(2 * time.Second) 201 | } 202 | 203 | // kill process 204 | case "K": 205 | visible := m.visibleConnections() 206 | if m.cursor < len(visible) { 207 | conn := visible[m.cursor] 208 | if conn.PID > 0 { 209 | m.killTarget = &conn 210 | m.showKillConfirm = true 211 | } 212 | } 213 | 214 | // toggle address resolution 215 | case "n": 216 | m.resolveAddrs = !m.resolveAddrs 217 | if m.resolveAddrs { 218 | m.statusMessage = "address resolution: on" 219 | } else { 220 | m.statusMessage = "address resolution: off" 221 | } 222 | m.statusExpiry = time.Now().Add(2 * time.Second) 223 | return m, clearStatusAfter(2 * time.Second) 224 | 225 | // toggle port resolution 226 | case "N": 227 | m.resolvePorts = !m.resolvePorts 228 | if m.resolvePorts { 229 | m.statusMessage = "port resolution: on" 230 | } else { 231 | m.statusMessage = "port resolution: off" 232 | } 233 | m.statusExpiry = time.Now().Add(2 * time.Second) 234 | return m, clearStatusAfter(2 * time.Second) 235 | } 236 | 237 | return m, nil 238 | } 239 | 240 | func (m *model) moveCursor(delta int) { 241 | visible := m.visibleConnections() 242 | m.cursor += delta 243 | if m.cursor < 0 { 244 | m.cursor = 0 245 | } 246 | if m.cursor >= len(visible) { 247 | m.cursor = len(visible) - 1 248 | } 249 | if m.cursor < 0 { 250 | m.cursor = 0 251 | } 252 | } 253 | 254 | func (m model) pageSize() int { 255 | size := m.height - 6 256 | if size < 1 { 257 | return 10 258 | } 259 | return size 260 | } 261 | 262 | func (m *model) cycleSort() { 263 | fields := []collector.SortField{ 264 | collector.SortByLport, 265 | collector.SortByProcess, 266 | collector.SortByPID, 267 | collector.SortByState, 268 | collector.SortByProto, 269 | } 270 | 271 | for i, f := range fields { 272 | if f == m.sortField { 273 | m.sortField = fields[(i+1)%len(fields)] 274 | m.applySorting() 275 | return 276 | } 277 | } 278 | 279 | m.sortField = collector.SortByLport 280 | m.applySorting() 281 | } 282 | 283 | -------------------------------------------------------------------------------- /cmd/trace.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "strings" 11 | "syscall" 12 | "time" 13 | 14 | "github.com/karol-broda/snitch/internal/collector" 15 | "github.com/karol-broda/snitch/internal/config" 16 | "github.com/karol-broda/snitch/internal/resolver" 17 | 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | type TraceEvent struct { 22 | Timestamp time.Time `json:"ts"` 23 | Event string `json:"event"` // "opened" or "closed" 24 | Connection collector.Connection `json:"connection"` 25 | } 26 | 27 | var ( 28 | traceInterval time.Duration 29 | traceCount int 30 | traceOutputFormat string 31 | traceTimestamp bool 32 | ) 33 | 34 | var traceCmd = &cobra.Command{ 35 | Use: "trace [filters...]", 36 | Short: "Print new/closed connections as they happen", 37 | Long: `Print new/closed connections as they happen. 38 | 39 | Filters are specified in key=value format. For example: 40 | snitch trace proto=tcp state=established 41 | 42 | Available filters: 43 | proto, state, pid, proc, lport, rport, user, laddr, raddr, contains 44 | `, 45 | Run: func(cmd *cobra.Command, args []string) { 46 | runTraceCommand(args) 47 | }, 48 | } 49 | 50 | func runTraceCommand(args []string) { 51 | cfg := config.Get() 52 | 53 | // configure resolver with cache setting 54 | effectiveNoCache := noCache || !cfg.Defaults.DNSCache 55 | resolver.SetNoCache(effectiveNoCache) 56 | 57 | filters, err := BuildFilters(args) 58 | if err != nil { 59 | log.Fatalf("Error parsing filters: %v", err) 60 | } 61 | 62 | ctx, cancel := context.WithCancel(context.Background()) 63 | defer cancel() 64 | 65 | // Handle interrupts gracefully 66 | sigChan := make(chan os.Signal, 1) 67 | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 68 | go func() { 69 | <-sigChan 70 | cancel() 71 | }() 72 | 73 | // Track connections using a key-based approach 74 | currentConnections := make(map[string]collector.Connection) 75 | 76 | // Get initial snapshot 77 | initialConnections, err := collector.GetConnections() 78 | if err != nil { 79 | log.Printf("Error getting initial connections: %v", err) 80 | } else { 81 | filteredInitial := collector.FilterConnections(initialConnections, filters) 82 | for _, conn := range filteredInitial { 83 | key := getConnectionKey(conn) 84 | currentConnections[key] = conn 85 | } 86 | } 87 | 88 | ticker := time.NewTicker(traceInterval) 89 | defer ticker.Stop() 90 | 91 | eventCount := 0 92 | for { 93 | select { 94 | case <-ctx.Done(): 95 | return 96 | case <-ticker.C: 97 | newConnections, err := collector.GetConnections() 98 | if err != nil { 99 | log.Printf("Error getting connections: %v", err) 100 | continue 101 | } 102 | 103 | filteredNew := collector.FilterConnections(newConnections, filters) 104 | newConnectionsMap := make(map[string]collector.Connection) 105 | 106 | // Build map of new connections 107 | for _, conn := range filteredNew { 108 | key := getConnectionKey(conn) 109 | newConnectionsMap[key] = conn 110 | } 111 | 112 | // Find newly opened connections 113 | for key, conn := range newConnectionsMap { 114 | if _, exists := currentConnections[key]; !exists { 115 | event := TraceEvent{ 116 | Timestamp: time.Now(), 117 | Event: "opened", 118 | Connection: conn, 119 | } 120 | printTraceEvent(event) 121 | eventCount++ 122 | } 123 | } 124 | 125 | // Find closed connections 126 | for key, conn := range currentConnections { 127 | if _, exists := newConnectionsMap[key]; !exists { 128 | event := TraceEvent{ 129 | Timestamp: time.Now(), 130 | Event: "closed", 131 | Connection: conn, 132 | } 133 | printTraceEvent(event) 134 | eventCount++ 135 | } 136 | } 137 | 138 | // Update current state 139 | currentConnections = newConnectionsMap 140 | 141 | if traceCount > 0 && eventCount >= traceCount { 142 | return 143 | } 144 | } 145 | } 146 | } 147 | 148 | func getConnectionKey(conn collector.Connection) string { 149 | // Create a unique key for a connection based on protocol, addresses, ports, and PID 150 | // This helps identify the same logical connection across snapshots 151 | return fmt.Sprintf("%s|%s:%d|%s:%d|%d", conn.Proto, conn.Laddr, conn.Lport, conn.Raddr, conn.Rport, conn.PID) 152 | } 153 | 154 | func printTraceEvent(event TraceEvent) { 155 | switch traceOutputFormat { 156 | case "json": 157 | printTraceEventJSON(event) 158 | default: 159 | printTraceEventHuman(event) 160 | } 161 | } 162 | 163 | func printTraceEventJSON(event TraceEvent) { 164 | jsonOutput, err := json.Marshal(event) 165 | if err != nil { 166 | log.Printf("Error marshaling JSON: %v", err) 167 | return 168 | } 169 | fmt.Println(string(jsonOutput)) 170 | } 171 | 172 | func printTraceEventHuman(event TraceEvent) { 173 | conn := event.Connection 174 | 175 | timestamp := "" 176 | if traceTimestamp { 177 | timestamp = event.Timestamp.Format("15:04:05.000") + " " 178 | } 179 | 180 | eventIcon := "+" 181 | if event.Event == "closed" { 182 | eventIcon = "-" 183 | } 184 | 185 | laddr := conn.Laddr 186 | raddr := conn.Raddr 187 | lportStr := fmt.Sprintf("%d", conn.Lport) 188 | rportStr := fmt.Sprintf("%d", conn.Rport) 189 | 190 | // apply name resolution 191 | if resolveAddrs { 192 | if resolvedLaddr := resolver.ResolveAddr(conn.Laddr); resolvedLaddr != conn.Laddr { 193 | laddr = resolvedLaddr 194 | } 195 | if resolvedRaddr := resolver.ResolveAddr(conn.Raddr); resolvedRaddr != conn.Raddr && conn.Raddr != "*" && conn.Raddr != "" { 196 | raddr = resolvedRaddr 197 | } 198 | } 199 | if resolvePorts { 200 | if resolvedLport := resolver.ResolvePort(conn.Lport, conn.Proto); resolvedLport != fmt.Sprintf("%d", conn.Lport) { 201 | lportStr = resolvedLport 202 | } 203 | if resolvedRport := resolver.ResolvePort(conn.Rport, conn.Proto); resolvedRport != fmt.Sprintf("%d", conn.Rport) && conn.Rport != 0 { 204 | rportStr = resolvedRport 205 | } 206 | } 207 | 208 | // Format the connection string 209 | var connStr string 210 | if conn.Raddr != "" && conn.Raddr != "*" { 211 | connStr = fmt.Sprintf("%s:%s->%s:%s", laddr, lportStr, raddr, rportStr) 212 | } else { 213 | connStr = fmt.Sprintf("%s:%s", laddr, lportStr) 214 | } 215 | 216 | process := "" 217 | if conn.Process != "" { 218 | process = fmt.Sprintf(" (%s[%d])", conn.Process, conn.PID) 219 | } 220 | 221 | protocol := strings.ToUpper(conn.Proto) 222 | state := conn.State 223 | if state == "" { 224 | state = "UNKNOWN" 225 | } 226 | 227 | fmt.Printf("%s%s %s %s %s%s\n", timestamp, eventIcon, protocol, state, connStr, process) 228 | } 229 | 230 | func init() { 231 | rootCmd.AddCommand(traceCmd) 232 | 233 | // trace-specific flags 234 | traceCmd.Flags().DurationVarP(&traceInterval, "interval", "i", time.Second, "Polling interval (e.g., 500ms, 2s)") 235 | traceCmd.Flags().IntVarP(&traceCount, "count", "c", 0, "Number of events to capture (0 = unlimited)") 236 | traceCmd.Flags().StringVarP(&traceOutputFormat, "output", "o", "human", "Output format (human, json)") 237 | traceCmd.Flags().BoolVar(&traceTimestamp, "ts", false, "Include timestamp in output") 238 | 239 | // shared flags 240 | addFilterFlags(traceCmd) 241 | addResolutionFlags(traceCmd) 242 | } 243 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # snitch 2 | 3 | a friendlier `ss` / `netstat` for humans. inspect network connections with a clean tui or styled tables. 4 | 5 | ![snitch demo](demo/demo.gif) 6 | 7 | ## install 8 | 9 | ### homebrew 10 | 11 | ```bash 12 | brew install snitch 13 | ``` 14 | 15 | > thanks to [@bevanjkay](https://github.com/bevanjkay) for adding snitch to homebrew-core 16 | 17 | ### go 18 | 19 | ```bash 20 | go install github.com/karol-broda/snitch@latest 21 | ``` 22 | 23 | ### nixpkgs 24 | 25 | ```bash 26 | nix-env -iA nixpkgs.snitch 27 | ``` 28 | 29 | > thanks to [@DieracDelta](https://github.com/DieracDelta) for adding snitch to nixpkgs 30 | 31 | ### nixos / nix (flake) 32 | 33 | ```bash 34 | # try it 35 | nix run github:karol-broda/snitch 36 | 37 | # install to profile 38 | nix profile install github:karol-broda/snitch 39 | 40 | # or add to flake inputs 41 | { 42 | inputs.snitch.url = "github:karol-broda/snitch"; 43 | } 44 | # then use: inputs.snitch.packages.${system}.default 45 | ``` 46 | 47 | ### arch linux (aur) 48 | 49 | ```bash 50 | # with yay 51 | yay -S snitch-bin 52 | 53 | # with paru 54 | paru -S snitch-bin 55 | ``` 56 | 57 | ### shell script 58 | 59 | ```bash 60 | curl -sSL https://raw.githubusercontent.com/karol-broda/snitch/master/install.sh | sh 61 | ``` 62 | 63 | installs to `~/.local/bin` if available, otherwise `/usr/local/bin`. override with: 64 | 65 | ```bash 66 | curl -sSL https://raw.githubusercontent.com/karol-broda/snitch/master/install.sh | INSTALL_DIR=~/bin sh 67 | ``` 68 | 69 | > **macos:** the install script automatically removes the quarantine attribute (`com.apple.quarantine`) from the binary to allow it to run without gatekeeper warnings. to disable this, set `KEEP_QUARANTINE=1`. 70 | 71 | ### binary 72 | 73 | download from [releases](https://github.com/karol-broda/snitch/releases): 74 | 75 | - **linux:** `snitch__linux_.tar.gz` or `.deb`/`.rpm`/`.apk` 76 | - **macos:** `snitch__darwin_.tar.gz` 77 | 78 | ```bash 79 | tar xzf snitch_*.tar.gz 80 | sudo mv snitch /usr/local/bin/ 81 | ``` 82 | 83 | > **macos:** if blocked with "cannot be opened because the developer cannot be verified", run: 84 | > 85 | > ```bash 86 | > xattr -d com.apple.quarantine /usr/local/bin/snitch 87 | > ``` 88 | 89 | ## quick start 90 | 91 | ```bash 92 | snitch # launch interactive tui 93 | snitch -l # tui showing only listening sockets 94 | snitch ls # print styled table and exit 95 | snitch ls -l # listening sockets only 96 | snitch ls -t -e # tcp established connections 97 | snitch ls -p # plain output (parsable) 98 | ``` 99 | 100 | ## commands 101 | 102 | ### `snitch` / `snitch top` 103 | 104 | interactive tui with live-updating connection list. 105 | 106 | ```bash 107 | snitch # all connections 108 | snitch -l # listening only 109 | snitch -t # tcp only 110 | snitch -e # established only 111 | snitch -i 2s # 2 second refresh interval 112 | ``` 113 | 114 | **keybindings:** 115 | 116 | ``` 117 | j/k, ↑/↓ navigate 118 | g/G top/bottom 119 | t/u toggle tcp/udp 120 | l/e/o toggle listen/established/other 121 | s/S cycle sort / reverse 122 | w watch/monitor process (highlight) 123 | W clear all watched 124 | K kill process (with confirmation) 125 | / search 126 | enter connection details 127 | ? help 128 | q quit 129 | ``` 130 | 131 | ### `snitch ls` 132 | 133 | one-shot table output. uses a pager automatically if output exceeds terminal height. 134 | 135 | ```bash 136 | snitch ls # styled table (default) 137 | snitch ls -l # listening only 138 | snitch ls -t -l # tcp listeners 139 | snitch ls -e # established only 140 | snitch ls -p # plain/parsable output 141 | snitch ls -o json # json output 142 | snitch ls -o csv # csv output 143 | snitch ls -n # numeric (no dns resolution) 144 | snitch ls --no-headers # omit headers 145 | ``` 146 | 147 | ### `snitch json` 148 | 149 | json output for scripting. 150 | 151 | ```bash 152 | snitch json 153 | snitch json -l 154 | ``` 155 | 156 | ### `snitch watch` 157 | 158 | stream json frames at an interval. 159 | 160 | ```bash 161 | snitch watch -i 1s | jq '.count' 162 | snitch watch -l -i 500ms 163 | ``` 164 | 165 | ### `snitch upgrade` 166 | 167 | check for updates and upgrade in-place. 168 | 169 | ```bash 170 | snitch upgrade # check for updates 171 | snitch upgrade --yes # upgrade automatically 172 | snitch upgrade -v 0.1.7 # install specific version 173 | ``` 174 | 175 | ## filters 176 | 177 | shortcut flags work on all commands: 178 | 179 | ``` 180 | -t, --tcp tcp only 181 | -u, --udp udp only 182 | -l, --listen listening sockets 183 | -e, --established established connections 184 | -4, --ipv4 ipv4 only 185 | -6, --ipv6 ipv6 only 186 | ``` 187 | 188 | ## resolution 189 | 190 | dns and service name resolution options: 191 | 192 | ``` 193 | --resolve-addrs resolve ip addresses to hostnames (default: true) 194 | --resolve-ports resolve port numbers to service names 195 | --no-cache disable dns caching (force fresh lookups) 196 | ``` 197 | 198 | dns lookups are performed in parallel and cached for performance. use `--no-cache` to bypass the cache for debugging or when addresses change frequently. 199 | 200 | for more specific filtering, use `key=value` syntax with `ls`: 201 | 202 | ```bash 203 | snitch ls proto=tcp state=listen 204 | snitch ls pid=1234 205 | snitch ls proc=nginx 206 | snitch ls lport=443 207 | snitch ls contains=google 208 | ``` 209 | 210 | ## output 211 | 212 | styled table (default): 213 | 214 | ``` 215 | ╭─────────────────┬───────┬───────┬─────────────┬─────────────────┬────────╮ 216 | │ PROCESS │ PID │ PROTO │ STATE │ LADDR │ LPORT │ 217 | ├─────────────────┼───────┼───────┼─────────────┼─────────────────┼────────┤ 218 | │ nginx │ 1234 │ tcp │ LISTEN │ * │ 80 │ 219 | │ postgres │ 5678 │ tcp │ LISTEN │ 127.0.0.1 │ 5432 │ 220 | ╰─────────────────┴───────┴───────┴─────────────┴─────────────────┴────────╯ 221 | 2 connections 222 | ``` 223 | 224 | plain output (`-p`): 225 | 226 | ``` 227 | PROCESS PID PROTO STATE LADDR LPORT 228 | nginx 1234 tcp LISTEN * 80 229 | postgres 5678 tcp LISTEN 127.0.0.1 5432 230 | ``` 231 | 232 | ## configuration 233 | 234 | optional config file at `~/.config/snitch/snitch.toml`: 235 | 236 | ```toml 237 | [defaults] 238 | numeric = false # disable name resolution 239 | dns_cache = true # cache dns lookups (set to false to disable) 240 | theme = "auto" # color theme: auto, dark, light, mono 241 | ``` 242 | 243 | ### environment variables 244 | 245 | ```bash 246 | SNITCH_THEME=dark # set default theme 247 | SNITCH_RESOLVE=0 # disable dns resolution 248 | SNITCH_DNS_CACHE=0 # disable dns caching 249 | SNITCH_NO_COLOR=1 # disable color output 250 | SNITCH_CONFIG=/path/to # custom config file path 251 | ``` 252 | 253 | ## requirements 254 | 255 | - linux or macos 256 | - linux: reads from `/proc/net/*`, root or `CAP_NET_ADMIN` for full process info 257 | - macos: uses system APIs, may require sudo for full process info 258 | -------------------------------------------------------------------------------- /internal/tui/model.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "github.com/karol-broda/snitch/internal/collector" 6 | "github.com/karol-broda/snitch/internal/theme" 7 | "time" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | type model struct { 13 | connections []collector.Connection 14 | cursor int 15 | width int 16 | height int 17 | 18 | // filtering 19 | showTCP bool 20 | showUDP bool 21 | showListening bool 22 | showEstablished bool 23 | showOther bool 24 | searchQuery string 25 | searchActive bool 26 | 27 | // sorting 28 | sortField collector.SortField 29 | sortReverse bool 30 | 31 | // display options 32 | resolveAddrs bool // when true, resolve IP addresses to hostnames 33 | resolvePorts bool // when true, resolve port numbers to service names 34 | 35 | // ui state 36 | theme *theme.Theme 37 | showHelp bool 38 | showDetail bool 39 | selected *collector.Connection 40 | interval time.Duration 41 | lastRefresh time.Time 42 | err error 43 | 44 | // watched processes 45 | watchedPIDs map[int]bool 46 | 47 | // kill confirmation 48 | showKillConfirm bool 49 | killTarget *collector.Connection 50 | 51 | // status message (temporary feedback) 52 | statusMessage string 53 | statusExpiry time.Time 54 | } 55 | 56 | type Options struct { 57 | Theme string 58 | Interval time.Duration 59 | TCP bool 60 | UDP bool 61 | Listening bool 62 | Established bool 63 | Other bool 64 | FilterSet bool // true if user specified any filter flags 65 | ResolveAddrs bool // when true, resolve IP addresses to hostnames 66 | ResolvePorts bool // when true, resolve port numbers to service names 67 | NoCache bool // when true, disable DNS caching 68 | } 69 | 70 | func New(opts Options) model { 71 | interval := opts.Interval 72 | if interval == 0 { 73 | interval = time.Second 74 | } 75 | 76 | // default: show everything 77 | showTCP := true 78 | showUDP := true 79 | showListening := true 80 | showEstablished := true 81 | showOther := true 82 | 83 | // if user specified filters, use those instead 84 | if opts.FilterSet { 85 | showTCP = opts.TCP 86 | showUDP = opts.UDP 87 | showListening = opts.Listening 88 | showEstablished = opts.Established 89 | showOther = opts.Other 90 | 91 | // if only proto filters set, show all states 92 | if !opts.Listening && !opts.Established && !opts.Other { 93 | showListening = true 94 | showEstablished = true 95 | showOther = true 96 | } 97 | // if only state filters set, show all protos 98 | if !opts.TCP && !opts.UDP { 99 | showTCP = true 100 | showUDP = true 101 | } 102 | } 103 | 104 | return model{ 105 | connections: []collector.Connection{}, 106 | showTCP: showTCP, 107 | showUDP: showUDP, 108 | showListening: showListening, 109 | showEstablished: showEstablished, 110 | showOther: showOther, 111 | sortField: collector.SortByLport, 112 | resolveAddrs: opts.ResolveAddrs, 113 | resolvePorts: opts.ResolvePorts, 114 | theme: theme.GetTheme(opts.Theme), 115 | interval: interval, 116 | lastRefresh: time.Now(), 117 | watchedPIDs: make(map[int]bool), 118 | } 119 | } 120 | 121 | func (m model) Init() tea.Cmd { 122 | return tea.Batch( 123 | tea.HideCursor, 124 | m.fetchData(), 125 | m.tick(), 126 | ) 127 | } 128 | 129 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 130 | switch msg := msg.(type) { 131 | case tea.WindowSizeMsg: 132 | m.width = msg.Width 133 | m.height = msg.Height 134 | return m, nil 135 | 136 | case tea.KeyMsg: 137 | return m.handleKey(msg) 138 | 139 | case tickMsg: 140 | return m, tea.Batch(m.fetchData(), m.tick()) 141 | 142 | case dataMsg: 143 | m.connections = msg.connections 144 | m.lastRefresh = time.Now() 145 | m.applySorting() 146 | m.clampCursor() 147 | return m, nil 148 | 149 | case errMsg: 150 | m.err = msg.err 151 | return m, nil 152 | 153 | case killResultMsg: 154 | if msg.success { 155 | m.statusMessage = fmt.Sprintf("killed %s (pid %d)", msg.process, msg.pid) 156 | } else { 157 | m.statusMessage = fmt.Sprintf("failed to kill pid %d: %v", msg.pid, msg.err) 158 | } 159 | m.statusExpiry = time.Now().Add(3 * time.Second) 160 | return m, tea.Batch(m.fetchData(), clearStatusAfter(3*time.Second)) 161 | 162 | case clearStatusMsg: 163 | if time.Now().After(m.statusExpiry) { 164 | m.statusMessage = "" 165 | } 166 | return m, nil 167 | } 168 | 169 | return m, nil 170 | } 171 | 172 | func (m model) View() string { 173 | if m.err != nil { 174 | return m.renderError() 175 | } 176 | if m.showHelp { 177 | return m.renderHelp() 178 | } 179 | if m.showDetail && m.selected != nil { 180 | return m.renderDetail() 181 | } 182 | 183 | main := m.renderMain() 184 | 185 | // overlay kill confirmation modal on top of main view 186 | if m.showKillConfirm && m.killTarget != nil { 187 | return m.overlayModal(main, m.renderKillModal()) 188 | } 189 | 190 | return main 191 | } 192 | 193 | func (m *model) applySorting() { 194 | direction := collector.SortAsc 195 | if m.sortReverse { 196 | direction = collector.SortDesc 197 | } 198 | collector.SortConnections(m.connections, collector.SortOptions{ 199 | Field: m.sortField, 200 | Direction: direction, 201 | }) 202 | } 203 | 204 | func (m *model) clampCursor() { 205 | visible := m.visibleConnections() 206 | if m.cursor >= len(visible) { 207 | m.cursor = len(visible) - 1 208 | } 209 | if m.cursor < 0 { 210 | m.cursor = 0 211 | } 212 | } 213 | 214 | func (m model) visibleConnections() []collector.Connection { 215 | var watched []collector.Connection 216 | var unwatched []collector.Connection 217 | 218 | for _, c := range m.connections { 219 | if !m.matchesFilters(c) { 220 | continue 221 | } 222 | if m.searchQuery != "" && !m.matchesSearch(c) { 223 | continue 224 | } 225 | if m.isWatched(c.PID) { 226 | watched = append(watched, c) 227 | } else { 228 | unwatched = append(unwatched, c) 229 | } 230 | } 231 | 232 | // watched connections appear first 233 | return append(watched, unwatched...) 234 | } 235 | 236 | func (m model) matchesFilters(c collector.Connection) bool { 237 | isTCP := c.Proto == "tcp" || c.Proto == "tcp6" 238 | isUDP := c.Proto == "udp" || c.Proto == "udp6" 239 | 240 | if isTCP && !m.showTCP { 241 | return false 242 | } 243 | if isUDP && !m.showUDP { 244 | return false 245 | } 246 | 247 | isListening := c.State == "LISTEN" 248 | isEstablished := c.State == "ESTABLISHED" 249 | isOther := !isListening && !isEstablished 250 | 251 | if isListening && !m.showListening { 252 | return false 253 | } 254 | if isEstablished && !m.showEstablished { 255 | return false 256 | } 257 | if isOther && !m.showOther { 258 | return false 259 | } 260 | 261 | return true 262 | } 263 | 264 | func (m model) matchesSearch(c collector.Connection) bool { 265 | return containsIgnoreCase(c.Process, m.searchQuery) || 266 | containsIgnoreCase(c.Laddr, m.searchQuery) || 267 | containsIgnoreCase(c.Raddr, m.searchQuery) || 268 | containsIgnoreCase(c.User, m.searchQuery) || 269 | containsIgnoreCase(c.Proto, m.searchQuery) || 270 | containsIgnoreCase(c.State, m.searchQuery) 271 | } 272 | 273 | func (m model) isWatched(pid int) bool { 274 | if pid <= 0 { 275 | return false 276 | } 277 | return m.watchedPIDs[pid] 278 | } 279 | 280 | func (m *model) toggleWatch(pid int) { 281 | if pid <= 0 { 282 | return 283 | } 284 | if m.watchedPIDs[pid] { 285 | delete(m.watchedPIDs, pid) 286 | } else { 287 | m.watchedPIDs[pid] = true 288 | } 289 | } 290 | 291 | func (m model) watchedCount() int { 292 | return len(m.watchedPIDs) 293 | } 294 | -------------------------------------------------------------------------------- /cmd/runtime.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/karol-broda/snitch/internal/collector" 6 | "github.com/karol-broda/snitch/internal/color" 7 | "github.com/karol-broda/snitch/internal/config" 8 | "github.com/karol-broda/snitch/internal/resolver" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // Runtime holds the shared state for all commands. 16 | // it handles common filter logic, fetching, filtering, and resolution. 17 | type Runtime struct { 18 | // filter options built from flags and args 19 | Filters collector.FilterOptions 20 | 21 | // filtered connections ready for rendering 22 | Connections []collector.Connection 23 | 24 | // common settings 25 | ColorMode string 26 | ResolveAddrs bool 27 | ResolvePorts bool 28 | NoCache bool 29 | } 30 | 31 | // shared filter flags - used by all commands 32 | var ( 33 | filterTCP bool 34 | filterUDP bool 35 | filterListen bool 36 | filterEstab bool 37 | filterIPv4 bool 38 | filterIPv6 bool 39 | ) 40 | 41 | // shared resolution flags - used by all commands 42 | var ( 43 | resolveAddrs bool 44 | resolvePorts bool 45 | noCache bool 46 | ) 47 | 48 | // BuildFilters constructs FilterOptions from command args and shortcut flags. 49 | func BuildFilters(args []string) (collector.FilterOptions, error) { 50 | filters, err := ParseFilterArgs(args) 51 | if err != nil { 52 | return filters, err 53 | } 54 | 55 | // apply ipv4/ipv6 flags 56 | filters.IPv4 = filterIPv4 57 | filters.IPv6 = filterIPv6 58 | 59 | // apply protocol shortcut flags 60 | if filterTCP && !filterUDP { 61 | filters.Proto = "tcp" 62 | } else if filterUDP && !filterTCP { 63 | filters.Proto = "udp" 64 | } 65 | 66 | // apply state shortcut flags 67 | if filterListen && !filterEstab { 68 | filters.State = "LISTEN" 69 | } else if filterEstab && !filterListen { 70 | filters.State = "ESTABLISHED" 71 | } 72 | 73 | return filters, nil 74 | } 75 | 76 | // FetchConnections gets connections from the collector and applies filters. 77 | func FetchConnections(filters collector.FilterOptions) ([]collector.Connection, error) { 78 | connections, err := collector.GetConnections() 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | return collector.FilterConnections(connections, filters), nil 84 | } 85 | 86 | // NewRuntime creates a runtime with fetched and filtered connections. 87 | func NewRuntime(args []string, colorMode string) (*Runtime, error) { 88 | color.Init(colorMode) 89 | 90 | cfg := config.Get() 91 | 92 | // configure resolver with cache setting (flag overrides config) 93 | effectiveNoCache := noCache || !cfg.Defaults.DNSCache 94 | resolver.SetNoCache(effectiveNoCache) 95 | 96 | filters, err := BuildFilters(args) 97 | if err != nil { 98 | return nil, fmt.Errorf("failed to parse filters: %w", err) 99 | } 100 | 101 | connections, err := FetchConnections(filters) 102 | if err != nil { 103 | return nil, fmt.Errorf("failed to fetch connections: %w", err) 104 | } 105 | 106 | rt := &Runtime{ 107 | Filters: filters, 108 | Connections: connections, 109 | ColorMode: colorMode, 110 | ResolveAddrs: resolveAddrs, 111 | ResolvePorts: resolvePorts, 112 | NoCache: effectiveNoCache, 113 | } 114 | 115 | // pre-warm dns cache by resolving all addresses in parallel 116 | if resolveAddrs { 117 | rt.PreWarmDNS() 118 | } 119 | 120 | return rt, nil 121 | } 122 | 123 | // PreWarmDNS resolves all connection addresses in parallel to warm the cache. 124 | func (r *Runtime) PreWarmDNS() { 125 | addrs := make([]string, 0, len(r.Connections)*2) 126 | for _, c := range r.Connections { 127 | addrs = append(addrs, c.Laddr, c.Raddr) 128 | } 129 | resolver.ResolveAddrsParallel(addrs) 130 | } 131 | 132 | // SortConnections sorts the runtime's connections in place. 133 | func (r *Runtime) SortConnections(opts collector.SortOptions) { 134 | collector.SortConnections(r.Connections, opts) 135 | } 136 | 137 | // ParseFilterArgs parses key=value filter arguments. 138 | // exported for testing. 139 | func ParseFilterArgs(args []string) (collector.FilterOptions, error) { 140 | filters := collector.FilterOptions{} 141 | for _, arg := range args { 142 | parts := strings.SplitN(arg, "=", 2) 143 | if len(parts) != 2 { 144 | return filters, fmt.Errorf("invalid filter format: %s (expected key=value)", arg) 145 | } 146 | key, value := parts[0], parts[1] 147 | if err := applyFilter(&filters, key, value); err != nil { 148 | return filters, err 149 | } 150 | } 151 | return filters, nil 152 | } 153 | 154 | // applyFilter applies a single key=value filter to FilterOptions. 155 | func applyFilter(filters *collector.FilterOptions, key, value string) error { 156 | switch strings.ToLower(key) { 157 | case "proto": 158 | filters.Proto = value 159 | case "state": 160 | filters.State = value 161 | case "pid": 162 | pid, err := strconv.Atoi(value) 163 | if err != nil { 164 | return fmt.Errorf("invalid pid value: %s", value) 165 | } 166 | filters.Pid = pid 167 | case "proc": 168 | filters.Proc = value 169 | case "lport": 170 | port, err := strconv.Atoi(value) 171 | if err != nil { 172 | return fmt.Errorf("invalid lport value: %s", value) 173 | } 174 | filters.Lport = port 175 | case "rport": 176 | port, err := strconv.Atoi(value) 177 | if err != nil { 178 | return fmt.Errorf("invalid rport value: %s", value) 179 | } 180 | filters.Rport = port 181 | case "user": 182 | uid, err := strconv.Atoi(value) 183 | if err == nil { 184 | filters.UID = uid 185 | } else { 186 | filters.User = value 187 | } 188 | case "laddr": 189 | filters.Laddr = value 190 | case "raddr": 191 | filters.Raddr = value 192 | case "contains": 193 | filters.Contains = value 194 | case "if", "interface": 195 | filters.Interface = value 196 | case "mark": 197 | filters.Mark = value 198 | case "namespace": 199 | filters.Namespace = value 200 | case "inode": 201 | inode, err := strconv.ParseInt(value, 10, 64) 202 | if err != nil { 203 | return fmt.Errorf("invalid inode value: %s", value) 204 | } 205 | filters.Inode = inode 206 | case "since": 207 | since, sinceRel, err := collector.ParseTimeFilter(value) 208 | if err != nil { 209 | return fmt.Errorf("invalid since value: %s", value) 210 | } 211 | filters.Since = since 212 | filters.SinceRel = sinceRel 213 | default: 214 | return fmt.Errorf("unknown filter key: %s", key) 215 | } 216 | return nil 217 | } 218 | 219 | // FilterFlagsHelp returns the help text for common filter flags. 220 | const FilterFlagsHelp = ` 221 | Filters are specified in key=value format. For example: 222 | snitch ls proto=tcp state=established 223 | 224 | Available filters: 225 | proto, state, pid, proc, lport, rport, user, laddr, raddr, contains, if, mark, namespace, inode, since` 226 | 227 | // addFilterFlags adds the common filter flags to a command. 228 | func addFilterFlags(cmd *cobra.Command) { 229 | cmd.Flags().BoolVarP(&filterTCP, "tcp", "t", false, "Show only TCP connections") 230 | cmd.Flags().BoolVarP(&filterUDP, "udp", "u", false, "Show only UDP connections") 231 | cmd.Flags().BoolVarP(&filterListen, "listen", "l", false, "Show only listening sockets") 232 | cmd.Flags().BoolVarP(&filterEstab, "established", "e", false, "Show only established connections") 233 | cmd.Flags().BoolVarP(&filterIPv4, "ipv4", "4", false, "Only show IPv4 connections") 234 | cmd.Flags().BoolVarP(&filterIPv6, "ipv6", "6", false, "Only show IPv6 connections") 235 | } 236 | 237 | // addResolutionFlags adds the common resolution flags to a command. 238 | func addResolutionFlags(cmd *cobra.Command) { 239 | cfg := config.Get() 240 | cmd.Flags().BoolVar(&resolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames") 241 | cmd.Flags().BoolVar(&resolvePorts, "resolve-ports", false, "Resolve port numbers to service names") 242 | cmd.Flags().BoolVar(&noCache, "no-cache", !cfg.Defaults.DNSCache, "Disable DNS caching (force fresh lookups)") 243 | } 244 | 245 | -------------------------------------------------------------------------------- /internal/resolver/resolver.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "os" 8 | "strconv" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | var debugTiming = os.Getenv("SNITCH_DEBUG_TIMING") != "" 14 | 15 | // Resolver handles DNS and service name resolution with caching and timeouts 16 | type Resolver struct { 17 | timeout time.Duration 18 | cache map[string]string 19 | mutex sync.RWMutex 20 | noCache bool 21 | } 22 | 23 | // New creates a new resolver with the specified timeout 24 | func New(timeout time.Duration) *Resolver { 25 | return &Resolver{ 26 | timeout: timeout, 27 | cache: make(map[string]string), 28 | noCache: false, 29 | } 30 | } 31 | 32 | // SetNoCache disables caching - each lookup will hit DNS directly 33 | func (r *Resolver) SetNoCache(noCache bool) { 34 | r.noCache = noCache 35 | } 36 | 37 | // ResolveAddr resolves an IP address to a hostname, with caching 38 | func (r *Resolver) ResolveAddr(addr string) string { 39 | // check cache first (unless caching is disabled) 40 | if !r.noCache { 41 | r.mutex.RLock() 42 | if cached, exists := r.cache[addr]; exists { 43 | r.mutex.RUnlock() 44 | return cached 45 | } 46 | r.mutex.RUnlock() 47 | } 48 | 49 | // parse ip to validate it 50 | ip := net.ParseIP(addr) 51 | if ip == nil { 52 | return addr 53 | } 54 | 55 | // perform resolution with timeout 56 | start := time.Now() 57 | ctx, cancel := context.WithTimeout(context.Background(), r.timeout) 58 | defer cancel() 59 | 60 | names, err := net.DefaultResolver.LookupAddr(ctx, addr) 61 | 62 | resolved := addr 63 | if err == nil && len(names) > 0 { 64 | resolved = names[0] 65 | // remove trailing dot if present 66 | if len(resolved) > 0 && resolved[len(resolved)-1] == '.' { 67 | resolved = resolved[:len(resolved)-1] 68 | } 69 | } 70 | 71 | elapsed := time.Since(start) 72 | if debugTiming && elapsed > 50*time.Millisecond { 73 | fmt.Fprintf(os.Stderr, "[timing] slow DNS lookup: %s -> %s (%v)\n", addr, resolved, elapsed) 74 | } 75 | 76 | // cache the result (unless caching is disabled) 77 | if !r.noCache { 78 | r.mutex.Lock() 79 | r.cache[addr] = resolved 80 | r.mutex.Unlock() 81 | } 82 | 83 | return resolved 84 | } 85 | 86 | // ResolvePort resolves a port number to a service name 87 | func (r *Resolver) ResolvePort(port int, proto string) string { 88 | if port == 0 { 89 | return "0" 90 | } 91 | 92 | cacheKey := strconv.Itoa(port) + "/" + proto 93 | 94 | // check cache first (unless caching is disabled) 95 | if !r.noCache { 96 | r.mutex.RLock() 97 | if cached, exists := r.cache[cacheKey]; exists { 98 | r.mutex.RUnlock() 99 | return cached 100 | } 101 | r.mutex.RUnlock() 102 | } 103 | 104 | // perform resolution with timeout 105 | ctx, cancel := context.WithTimeout(context.Background(), r.timeout) 106 | defer cancel() 107 | 108 | service, err := net.DefaultResolver.LookupPort(ctx, proto, strconv.Itoa(port)) 109 | 110 | resolved := strconv.Itoa(port) // fallback to port number 111 | if err == nil && service != 0 { 112 | // try to get service name 113 | if serviceName := getServiceName(port, proto); serviceName != "" { 114 | resolved = serviceName 115 | } 116 | } 117 | 118 | // cache the result (unless caching is disabled) 119 | if !r.noCache { 120 | r.mutex.Lock() 121 | r.cache[cacheKey] = resolved 122 | r.mutex.Unlock() 123 | } 124 | 125 | return resolved 126 | } 127 | 128 | // ResolveAddrPort resolves both address and port 129 | func (r *Resolver) ResolveAddrPort(addr string, port int, proto string) (string, string) { 130 | resolvedAddr := r.ResolveAddr(addr) 131 | resolvedPort := r.ResolvePort(port, proto) 132 | return resolvedAddr, resolvedPort 133 | } 134 | 135 | // ClearCache clears the resolution cache 136 | func (r *Resolver) ClearCache() { 137 | r.mutex.Lock() 138 | defer r.mutex.Unlock() 139 | r.cache = make(map[string]string) 140 | } 141 | 142 | // GetCacheSize returns the number of cached entries 143 | func (r *Resolver) GetCacheSize() int { 144 | r.mutex.RLock() 145 | defer r.mutex.RUnlock() 146 | return len(r.cache) 147 | } 148 | 149 | // getServiceName returns well-known service names for common ports 150 | func getServiceName(port int, proto string) string { 151 | // Common services - this could be expanded or loaded from /etc/services 152 | services := map[string]string{ 153 | "80/tcp": "http", 154 | "443/tcp": "https", 155 | "22/tcp": "ssh", 156 | "21/tcp": "ftp", 157 | "25/tcp": "smtp", 158 | "53/tcp": "domain", 159 | "53/udp": "domain", 160 | "110/tcp": "pop3", 161 | "143/tcp": "imap", 162 | "993/tcp": "imaps", 163 | "995/tcp": "pop3s", 164 | "3306/tcp": "mysql", 165 | "5432/tcp": "postgresql", 166 | "6379/tcp": "redis", 167 | "3389/tcp": "rdp", 168 | "5900/tcp": "vnc", 169 | "23/tcp": "telnet", 170 | "69/udp": "tftp", 171 | "123/udp": "ntp", 172 | "161/udp": "snmp", 173 | "514/udp": "syslog", 174 | "67/udp": "bootps", 175 | "68/udp": "bootpc", 176 | } 177 | 178 | key := strconv.Itoa(port) + "/" + proto 179 | if service, exists := services[key]; exists { 180 | return service 181 | } 182 | 183 | return "" 184 | } 185 | 186 | // global resolver instance 187 | var globalResolver *Resolver 188 | 189 | // ResolverOptions configures the global resolver 190 | type ResolverOptions struct { 191 | Timeout time.Duration 192 | NoCache bool 193 | } 194 | 195 | // SetGlobalResolver sets the global resolver instance with options 196 | func SetGlobalResolver(opts ResolverOptions) { 197 | timeout := opts.Timeout 198 | if timeout == 0 { 199 | timeout = 200 * time.Millisecond 200 | } 201 | globalResolver = New(timeout) 202 | globalResolver.SetNoCache(opts.NoCache) 203 | } 204 | 205 | // GetGlobalResolver returns the global resolver instance 206 | func GetGlobalResolver() *Resolver { 207 | if globalResolver == nil { 208 | globalResolver = New(200 * time.Millisecond) 209 | } 210 | return globalResolver 211 | } 212 | 213 | // SetNoCache configures whether the global resolver bypasses cache 214 | func SetNoCache(noCache bool) { 215 | GetGlobalResolver().SetNoCache(noCache) 216 | } 217 | 218 | // ResolveAddr is a convenience function using the global resolver 219 | func ResolveAddr(addr string) string { 220 | return GetGlobalResolver().ResolveAddr(addr) 221 | } 222 | 223 | // ResolvePort is a convenience function using the global resolver 224 | func ResolvePort(port int, proto string) string { 225 | return GetGlobalResolver().ResolvePort(port, proto) 226 | } 227 | 228 | // ResolveAddrPort is a convenience function using the global resolver 229 | func ResolveAddrPort(addr string, port int, proto string) (string, string) { 230 | return GetGlobalResolver().ResolveAddrPort(addr, port, proto) 231 | } 232 | 233 | // ResolveAddrsParallel resolves multiple addresses concurrently and caches results. 234 | // This should be called before rendering to pre-warm the cache. 235 | func (r *Resolver) ResolveAddrsParallel(addrs []string) { 236 | // dedupe and filter addresses that need resolution 237 | unique := make(map[string]struct{}) 238 | for _, addr := range addrs { 239 | if addr == "" || addr == "*" { 240 | continue 241 | } 242 | // skip if already cached 243 | r.mutex.RLock() 244 | _, exists := r.cache[addr] 245 | r.mutex.RUnlock() 246 | if exists { 247 | continue 248 | } 249 | unique[addr] = struct{}{} 250 | } 251 | 252 | if len(unique) == 0 { 253 | return 254 | } 255 | 256 | var wg sync.WaitGroup 257 | // limit concurrency to avoid overwhelming dns 258 | sem := make(chan struct{}, 32) 259 | 260 | for addr := range unique { 261 | wg.Add(1) 262 | go func(a string) { 263 | defer wg.Done() 264 | sem <- struct{}{} 265 | defer func() { <-sem }() 266 | r.ResolveAddr(a) 267 | }(addr) 268 | } 269 | 270 | wg.Wait() 271 | } 272 | 273 | // ResolveAddrsParallel is a convenience function using the global resolver 274 | func ResolveAddrsParallel(addrs []string) { 275 | GetGlobalResolver().ResolveAddrsParallel(addrs) 276 | } 277 | -------------------------------------------------------------------------------- /cmd/ls_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/karol-broda/snitch/internal/collector" 8 | "github.com/karol-broda/snitch/internal/testutil" 9 | ) 10 | 11 | func TestLsCommand_EmptyResults(t *testing.T) { 12 | tempDir, cleanup := testutil.SetupTestEnvironment(t) 13 | defer cleanup() 14 | 15 | // Create empty fixture 16 | fixture := testutil.CreateFixtureFile(t, tempDir, "empty", []collector.Connection{}) 17 | 18 | // Override collector with mock 19 | originalCollector := collector.GetCollector() 20 | defer func() { 21 | collector.SetCollector(originalCollector) 22 | }() 23 | 24 | mock, err := collector.NewMockCollectorFromFile(fixture) 25 | if err != nil { 26 | t.Fatalf("Failed to create mock collector: %v", err) 27 | } 28 | 29 | collector.SetCollector(mock) 30 | 31 | // Capture output 32 | capture := testutil.NewOutputCapture(t) 33 | capture.Start() 34 | 35 | // Run command 36 | runListCommand("table", []string{}) 37 | 38 | stdout, stderr, err := capture.Stop() 39 | if err != nil { 40 | t.Fatalf("Failed to capture output: %v", err) 41 | } 42 | 43 | // Verify no error output 44 | if stderr != "" { 45 | t.Errorf("Expected no stderr, got: %s", stderr) 46 | } 47 | 48 | // Verify table headers are present even with no data 49 | if !strings.Contains(stdout, "PID") { 50 | t.Errorf("Expected table headers in output, got: %s", stdout) 51 | } 52 | } 53 | 54 | func TestLsCommand_SingleTCPConnection(t *testing.T) { 55 | _, cleanup := testutil.SetupTestEnvironment(t) 56 | defer cleanup() 57 | 58 | // Use predefined fixture 59 | testCollector := testutil.NewTestCollectorWithFixture("single-tcp") 60 | 61 | // Override collector 62 | originalCollector := collector.GetCollector() 63 | defer func() { 64 | collector.SetCollector(originalCollector) 65 | }() 66 | 67 | collector.SetCollector(testCollector.MockCollector) 68 | 69 | // Capture output 70 | capture := testutil.NewOutputCapture(t) 71 | capture.Start() 72 | 73 | // Run command 74 | runListCommand("table", []string{}) 75 | 76 | stdout, stderr, err := capture.Stop() 77 | if err != nil { 78 | t.Fatalf("Failed to capture output: %v", err) 79 | } 80 | 81 | // Verify no error output 82 | if stderr != "" { 83 | t.Errorf("Expected no stderr, got: %s", stderr) 84 | } 85 | 86 | // Verify connection appears in output 87 | if !strings.Contains(stdout, "test-app") { 88 | t.Errorf("Expected process name 'test-app' in output, got: %s", stdout) 89 | } 90 | if !strings.Contains(stdout, "1234") { 91 | t.Errorf("Expected PID '1234' in output, got: %s", stdout) 92 | } 93 | if !strings.Contains(stdout, "tcp") { 94 | t.Errorf("Expected protocol 'tcp' in output, got: %s", stdout) 95 | } 96 | } 97 | 98 | func TestLsCommand_JSONOutput(t *testing.T) { 99 | _, cleanup := testutil.SetupTestEnvironment(t) 100 | defer cleanup() 101 | 102 | // Use predefined fixture 103 | testCollector := testutil.NewTestCollectorWithFixture("single-tcp") 104 | 105 | // Override collector 106 | originalCollector := collector.GetCollector() 107 | defer func() { 108 | collector.SetCollector(originalCollector) 109 | }() 110 | 111 | collector.SetCollector(testCollector.MockCollector) 112 | 113 | // Capture output 114 | capture := testutil.NewOutputCapture(t) 115 | capture.Start() 116 | 117 | // Run command with JSON output 118 | runListCommand("json", []string{}) 119 | 120 | stdout, stderr, err := capture.Stop() 121 | if err != nil { 122 | t.Fatalf("Failed to capture output: %v", err) 123 | } 124 | 125 | // Verify no error output 126 | if stderr != "" { 127 | t.Errorf("Expected no stderr, got: %s", stderr) 128 | } 129 | 130 | // Verify JSON structure 131 | if !strings.Contains(stdout, `"pid"`) { 132 | t.Errorf("Expected JSON with 'pid' field, got: %s", stdout) 133 | } 134 | if !strings.Contains(stdout, `"process"`) { 135 | t.Errorf("Expected JSON with 'process' field, got: %s", stdout) 136 | } 137 | if !strings.Contains(stdout, `[`) || !strings.Contains(stdout, `]`) { 138 | t.Errorf("Expected JSON array format, got: %s", stdout) 139 | } 140 | } 141 | 142 | func TestLsCommand_Filtering(t *testing.T) { 143 | _, cleanup := testutil.SetupTestEnvironment(t) 144 | defer cleanup() 145 | 146 | // Use mixed protocols fixture 147 | testCollector := testutil.NewTestCollectorWithFixture("mixed-protocols") 148 | 149 | // Override collector 150 | originalCollector := collector.GetCollector() 151 | defer func() { 152 | collector.SetCollector(originalCollector) 153 | }() 154 | 155 | collector.SetCollector(testCollector.MockCollector) 156 | 157 | // Capture output 158 | capture := testutil.NewOutputCapture(t) 159 | capture.Start() 160 | 161 | // Run command with TCP filter 162 | runListCommand("table", []string{"proto=tcp"}) 163 | 164 | stdout, stderr, err := capture.Stop() 165 | if err != nil { 166 | t.Fatalf("Failed to capture output: %v", err) 167 | } 168 | 169 | // Verify no error output 170 | if stderr != "" { 171 | t.Errorf("Expected no stderr, got: %s", stderr) 172 | } 173 | 174 | // Should contain TCP connections 175 | if !strings.Contains(stdout, "tcp") { 176 | t.Errorf("Expected TCP connections in filtered output, got: %s", stdout) 177 | } 178 | 179 | // Should not contain UDP connections 180 | if strings.Contains(stdout, "udp") { 181 | t.Errorf("Expected no UDP connections in TCP-filtered output, got: %s", stdout) 182 | } 183 | 184 | // Should not contain Unix sockets 185 | if strings.Contains(stdout, "unix") { 186 | t.Errorf("Expected no Unix sockets in TCP-filtered output, got: %s", stdout) 187 | } 188 | } 189 | 190 | func TestLsCommand_InvalidFilter(t *testing.T) { 191 | // Skip this test as it's designed to fail 192 | t.Skip("Skipping TestLsCommand_InvalidFilter as it's designed to fail") 193 | } 194 | 195 | func TestParseFilters(t *testing.T) { 196 | tests := []struct { 197 | name string 198 | args []string 199 | expectError bool 200 | checkField func(collector.FilterOptions) bool 201 | }{ 202 | { 203 | name: "empty args", 204 | args: []string{}, 205 | expectError: false, 206 | checkField: func(f collector.FilterOptions) bool { return f.IsEmpty() }, 207 | }, 208 | { 209 | name: "proto filter", 210 | args: []string{"proto=tcp"}, 211 | expectError: false, 212 | checkField: func(f collector.FilterOptions) bool { return f.Proto == "tcp" }, 213 | }, 214 | { 215 | name: "state filter", 216 | args: []string{"state=established"}, 217 | expectError: false, 218 | checkField: func(f collector.FilterOptions) bool { return f.State == "established" }, 219 | }, 220 | { 221 | name: "pid filter", 222 | args: []string{"pid=1234"}, 223 | expectError: false, 224 | checkField: func(f collector.FilterOptions) bool { return f.Pid == 1234 }, 225 | }, 226 | { 227 | name: "invalid pid", 228 | args: []string{"pid=notanumber"}, 229 | expectError: true, 230 | checkField: nil, 231 | }, 232 | { 233 | name: "multiple filters", 234 | args: []string{"proto=tcp", "state=listen"}, 235 | expectError: false, 236 | checkField: func(f collector.FilterOptions) bool { return f.Proto == "tcp" && f.State == "listen" }, 237 | }, 238 | { 239 | name: "invalid format", 240 | args: []string{"invalid"}, 241 | expectError: true, 242 | checkField: nil, 243 | }, 244 | { 245 | name: "unknown filter", 246 | args: []string{"unknown=value"}, 247 | expectError: true, 248 | checkField: nil, 249 | }, 250 | } 251 | 252 | for _, tt := range tests { 253 | t.Run(tt.name, func(t *testing.T) { 254 | filters, err := ParseFilterArgs(tt.args) 255 | 256 | if tt.expectError { 257 | if err == nil { 258 | t.Errorf("Expected error for args %v, but got none", tt.args) 259 | } 260 | return 261 | } 262 | 263 | if err != nil { 264 | t.Errorf("Unexpected error for args %v: %v", tt.args, err) 265 | return 266 | } 267 | 268 | if tt.checkField != nil && !tt.checkField(filters) { 269 | t.Errorf("Filter validation failed for args %v, filters: %+v", tt.args, filters) 270 | } 271 | }) 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /cmd/stats.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/csv" 6 | "encoding/json" 7 | "fmt" 8 | "log" 9 | "os" 10 | "os/signal" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "syscall" 15 | "text/tabwriter" 16 | "time" 17 | 18 | "github.com/spf13/cobra" 19 | 20 | "github.com/karol-broda/snitch/internal/collector" 21 | "github.com/karol-broda/snitch/internal/errutil" 22 | ) 23 | 24 | type StatsData struct { 25 | Timestamp time.Time `json:"ts"` 26 | Total int `json:"total"` 27 | ByProto map[string]int `json:"by_proto"` 28 | ByState map[string]int `json:"by_state"` 29 | ByProc []ProcessStats `json:"by_proc"` 30 | ByIf []InterfaceStats `json:"by_if"` 31 | } 32 | 33 | type ProcessStats struct { 34 | PID int `json:"pid"` 35 | Process string `json:"process"` 36 | Count int `json:"count"` 37 | } 38 | 39 | type InterfaceStats struct { 40 | Interface string `json:"if"` 41 | Count int `json:"count"` 42 | } 43 | 44 | // stats-specific flags 45 | var ( 46 | statsOutputFormat string 47 | statsInterval time.Duration 48 | statsCount int 49 | statsNoHeaders bool 50 | ) 51 | 52 | var statsCmd = &cobra.Command{ 53 | Use: "stats [filters...]", 54 | Short: "Aggregated connection counters", 55 | Long: `Aggregated connection counters. 56 | 57 | Filters are specified in key=value format. For example: 58 | snitch stats proto=tcp state=listening 59 | 60 | Available filters: 61 | proto, state, pid, proc, lport, rport, user, laddr, raddr, contains 62 | `, 63 | Run: func(cmd *cobra.Command, args []string) { 64 | runStatsCommand(args) 65 | }, 66 | } 67 | 68 | func runStatsCommand(args []string) { 69 | filters, err := BuildFilters(args) 70 | if err != nil { 71 | log.Fatalf("Error parsing filters: %v", err) 72 | } 73 | 74 | ctx, cancel := context.WithCancel(context.Background()) 75 | defer cancel() 76 | 77 | // Handle interrupts gracefully 78 | sigChan := make(chan os.Signal, 1) 79 | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 80 | go func() { 81 | <-sigChan 82 | cancel() 83 | }() 84 | 85 | count := 0 86 | for { 87 | stats, err := generateStats(filters) 88 | if err != nil { 89 | log.Printf("Error generating stats: %v", err) 90 | if statsCount > 0 || statsInterval == 0 { 91 | return 92 | } 93 | time.Sleep(statsInterval) 94 | continue 95 | } 96 | 97 | switch statsOutputFormat { 98 | case "json": 99 | printStatsJSON(stats) 100 | case "csv": 101 | printStatsCSV(stats, !statsNoHeaders && count == 0) 102 | default: 103 | printStatsTable(stats, !statsNoHeaders && count == 0) 104 | } 105 | 106 | count++ 107 | if statsCount > 0 && count >= statsCount { 108 | return 109 | } 110 | 111 | if statsInterval == 0 { 112 | return // One-shot mode 113 | } 114 | 115 | select { 116 | case <-ctx.Done(): 117 | return 118 | case <-time.After(statsInterval): 119 | continue 120 | } 121 | } 122 | } 123 | 124 | func generateStats(filters collector.FilterOptions) (*StatsData, error) { 125 | filteredConnections, err := FetchConnections(filters) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | stats := &StatsData{ 131 | Timestamp: time.Now(), 132 | Total: len(filteredConnections), 133 | ByProto: make(map[string]int), 134 | ByState: make(map[string]int), 135 | ByProc: make([]ProcessStats, 0), 136 | ByIf: make([]InterfaceStats, 0), 137 | } 138 | 139 | procCounts := make(map[string]ProcessStats) 140 | ifCounts := make(map[string]int) 141 | 142 | for _, conn := range filteredConnections { 143 | // Count by protocol 144 | stats.ByProto[conn.Proto]++ 145 | 146 | // Count by state 147 | stats.ByState[conn.State]++ 148 | 149 | // Count by process 150 | if conn.Process != "" { 151 | key := fmt.Sprintf("%d-%s", conn.PID, conn.Process) 152 | if existing, ok := procCounts[key]; ok { 153 | existing.Count++ 154 | procCounts[key] = existing 155 | } else { 156 | procCounts[key] = ProcessStats{ 157 | PID: conn.PID, 158 | Process: conn.Process, 159 | Count: 1, 160 | } 161 | } 162 | } 163 | 164 | // Count by interface (placeholder since we don't have interface data yet) 165 | if conn.Interface != "" { 166 | ifCounts[conn.Interface]++ 167 | } 168 | } 169 | 170 | // Convert process map to sorted slice 171 | for _, procStats := range procCounts { 172 | stats.ByProc = append(stats.ByProc, procStats) 173 | } 174 | sort.Slice(stats.ByProc, func(i, j int) bool { 175 | return stats.ByProc[i].Count > stats.ByProc[j].Count 176 | }) 177 | 178 | // Convert interface map to sorted slice 179 | for iface, count := range ifCounts { 180 | stats.ByIf = append(stats.ByIf, InterfaceStats{ 181 | Interface: iface, 182 | Count: count, 183 | }) 184 | } 185 | sort.Slice(stats.ByIf, func(i, j int) bool { 186 | return stats.ByIf[i].Count > stats.ByIf[j].Count 187 | }) 188 | 189 | return stats, nil 190 | } 191 | 192 | func printStatsJSON(stats *StatsData) { 193 | jsonOutput, err := json.MarshalIndent(stats, "", " ") 194 | if err != nil { 195 | log.Printf("Error marshaling JSON: %v", err) 196 | return 197 | } 198 | fmt.Println(string(jsonOutput)) 199 | } 200 | 201 | func printStatsCSV(stats *StatsData, headers bool) { 202 | writer := csv.NewWriter(os.Stdout) 203 | defer writer.Flush() 204 | 205 | if headers { 206 | _ = writer.Write([]string{"timestamp", "metric", "key", "value"}) 207 | } 208 | 209 | ts := stats.Timestamp.Format(time.RFC3339) 210 | 211 | _ = writer.Write([]string{ts, "total", "", strconv.Itoa(stats.Total)}) 212 | 213 | for proto, count := range stats.ByProto { 214 | _ = writer.Write([]string{ts, "proto", proto, strconv.Itoa(count)}) 215 | } 216 | 217 | for state, count := range stats.ByState { 218 | _ = writer.Write([]string{ts, "state", state, strconv.Itoa(count)}) 219 | } 220 | 221 | for _, proc := range stats.ByProc { 222 | _ = writer.Write([]string{ts, "process", proc.Process, strconv.Itoa(proc.Count)}) 223 | } 224 | 225 | for _, iface := range stats.ByIf { 226 | _ = writer.Write([]string{ts, "interface", iface.Interface, strconv.Itoa(iface.Count)}) 227 | } 228 | } 229 | 230 | func printStatsTable(stats *StatsData, headers bool) { 231 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) 232 | defer errutil.Flush(w) 233 | 234 | if headers { 235 | errutil.Ignore(fmt.Fprintf(w, "TIMESTAMP\t%s\n", stats.Timestamp.Format(time.RFC3339))) 236 | errutil.Ignore(fmt.Fprintf(w, "TOTAL CONNECTIONS\t%d\n", stats.Total)) 237 | errutil.Ignore(fmt.Fprintln(w)) 238 | } 239 | 240 | // Protocol breakdown 241 | if len(stats.ByProto) > 0 { 242 | if headers { 243 | errutil.Ignore(fmt.Fprintln(w, "BY PROTOCOL:")) 244 | errutil.Ignore(fmt.Fprintln(w, "PROTO\tCOUNT")) 245 | } 246 | protocols := make([]string, 0, len(stats.ByProto)) 247 | for proto := range stats.ByProto { 248 | protocols = append(protocols, proto) 249 | } 250 | sort.Strings(protocols) 251 | for _, proto := range protocols { 252 | errutil.Ignore(fmt.Fprintf(w, "%s\t%d\n", strings.ToUpper(proto), stats.ByProto[proto])) 253 | } 254 | errutil.Ignore(fmt.Fprintln(w)) 255 | } 256 | 257 | // State breakdown 258 | if len(stats.ByState) > 0 { 259 | if headers { 260 | errutil.Ignore(fmt.Fprintln(w, "BY STATE:")) 261 | errutil.Ignore(fmt.Fprintln(w, "STATE\tCOUNT")) 262 | } 263 | states := make([]string, 0, len(stats.ByState)) 264 | for state := range stats.ByState { 265 | states = append(states, state) 266 | } 267 | sort.Strings(states) 268 | for _, state := range states { 269 | errutil.Ignore(fmt.Fprintf(w, "%s\t%d\n", state, stats.ByState[state])) 270 | } 271 | errutil.Ignore(fmt.Fprintln(w)) 272 | } 273 | 274 | // Process breakdown (top 10) 275 | if len(stats.ByProc) > 0 { 276 | if headers { 277 | errutil.Ignore(fmt.Fprintln(w, "BY PROCESS (TOP 10):")) 278 | errutil.Ignore(fmt.Fprintln(w, "PID\tPROCESS\tCOUNT")) 279 | } 280 | limit := 10 281 | if len(stats.ByProc) < limit { 282 | limit = len(stats.ByProc) 283 | } 284 | for i := 0; i < limit; i++ { 285 | proc := stats.ByProc[i] 286 | errutil.Ignore(fmt.Fprintf(w, "%d\t%s\t%d\n", proc.PID, proc.Process, proc.Count)) 287 | } 288 | } 289 | } 290 | 291 | func init() { 292 | rootCmd.AddCommand(statsCmd) 293 | 294 | // stats-specific flags 295 | statsCmd.Flags().StringVarP(&statsOutputFormat, "output", "o", "table", "Output format (table, json, csv)") 296 | statsCmd.Flags().DurationVarP(&statsInterval, "interval", "i", 0, "Refresh interval (0 = one-shot)") 297 | statsCmd.Flags().IntVarP(&statsCount, "count", "c", 0, "Number of iterations (0 = unlimited)") 298 | statsCmd.Flags().BoolVar(&statsNoHeaders, "no-headers", false, "Omit headers for table/csv output") 299 | 300 | // shared filter flags 301 | addFilterFlags(statsCmd) 302 | } 303 | -------------------------------------------------------------------------------- /internal/collector/collector_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package collector 4 | 5 | /* 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | 16 | // get process name by pid 17 | static int get_proc_name(int pid, char *name, int namelen) { 18 | return proc_name(pid, name, namelen); 19 | } 20 | 21 | // get uid for a process 22 | static int get_proc_uid(int pid) { 23 | struct proc_bsdinfo info; 24 | int ret = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, sizeof(info)); 25 | if (ret <= 0) { 26 | return -1; 27 | } 28 | return info.pbi_uid; 29 | } 30 | 31 | // get username from uid 32 | static const char* get_username(int uid) { 33 | struct passwd *pw = getpwuid(uid); 34 | if (pw == NULL) { 35 | return NULL; 36 | } 37 | return pw->pw_name; 38 | } 39 | 40 | // socket info extraction - handles the union properly in C 41 | typedef struct { 42 | int family; 43 | int sock_type; 44 | int protocol; 45 | int state; 46 | uint32_t laddr4; 47 | uint32_t raddr4; 48 | uint8_t laddr6[16]; 49 | uint8_t raddr6[16]; 50 | int lport; 51 | int rport; 52 | } socket_info_t; 53 | 54 | static int get_socket_info(int pid, int fd, socket_info_t *info) { 55 | struct socket_fdinfo si; 56 | int ret = proc_pidfdinfo(pid, fd, PROC_PIDFDSOCKETINFO, &si, sizeof(si)); 57 | if (ret <= 0) { 58 | return -1; 59 | } 60 | 61 | info->family = si.psi.soi_family; 62 | info->sock_type = si.psi.soi_type; 63 | info->protocol = si.psi.soi_protocol; 64 | 65 | if (info->family == AF_INET) { 66 | if (info->sock_type == SOCK_STREAM) { 67 | // TCP 68 | info->state = si.psi.soi_proto.pri_tcp.tcpsi_state; 69 | info->laddr4 = si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_laddr.ina_46.i46a_addr4.s_addr; 70 | info->raddr4 = si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_faddr.ina_46.i46a_addr4.s_addr; 71 | info->lport = ntohs(si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_lport); 72 | info->rport = ntohs(si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_fport); 73 | } else if (info->sock_type == SOCK_DGRAM) { 74 | // UDP 75 | info->state = 0; 76 | info->laddr4 = si.psi.soi_proto.pri_in.insi_laddr.ina_46.i46a_addr4.s_addr; 77 | info->raddr4 = si.psi.soi_proto.pri_in.insi_faddr.ina_46.i46a_addr4.s_addr; 78 | info->lport = ntohs(si.psi.soi_proto.pri_in.insi_lport); 79 | info->rport = ntohs(si.psi.soi_proto.pri_in.insi_fport); 80 | } 81 | } else if (info->family == AF_INET6) { 82 | if (info->sock_type == SOCK_STREAM) { 83 | // TCP6 84 | info->state = si.psi.soi_proto.pri_tcp.tcpsi_state; 85 | memcpy(info->laddr6, &si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_laddr.ina_6, 16); 86 | memcpy(info->raddr6, &si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_faddr.ina_6, 16); 87 | info->lport = ntohs(si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_lport); 88 | info->rport = ntohs(si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_fport); 89 | } else if (info->sock_type == SOCK_DGRAM) { 90 | // UDP6 91 | info->state = 0; 92 | memcpy(info->laddr6, &si.psi.soi_proto.pri_in.insi_laddr.ina_6, 16); 93 | memcpy(info->raddr6, &si.psi.soi_proto.pri_in.insi_faddr.ina_6, 16); 94 | info->lport = ntohs(si.psi.soi_proto.pri_in.insi_lport); 95 | info->rport = ntohs(si.psi.soi_proto.pri_in.insi_fport); 96 | } 97 | } 98 | 99 | return 0; 100 | } 101 | */ 102 | import "C" 103 | 104 | import ( 105 | "fmt" 106 | "net" 107 | "strconv" 108 | "time" 109 | "unsafe" 110 | ) 111 | 112 | // DefaultCollector implements the Collector interface using libproc on macOS 113 | type DefaultCollector struct{} 114 | 115 | // GetConnections fetches all network connections using libproc 116 | func (dc *DefaultCollector) GetConnections() ([]Connection, error) { 117 | pids, err := listAllPids() 118 | if err != nil { 119 | return nil, fmt.Errorf("failed to list pids: %w", err) 120 | } 121 | 122 | var connections []Connection 123 | 124 | for _, pid := range pids { 125 | procConns, err := getConnectionsForPid(pid) 126 | if err != nil { 127 | continue 128 | } 129 | connections = append(connections, procConns...) 130 | } 131 | 132 | return connections, nil 133 | } 134 | 135 | // GetAllConnections returns network connections 136 | func GetAllConnections() ([]Connection, error) { 137 | return GetConnections() 138 | } 139 | 140 | func listAllPids() ([]int, error) { 141 | numPids := C.proc_listpids(C.PROC_ALL_PIDS, 0, nil, 0) 142 | if numPids <= 0 { 143 | return nil, fmt.Errorf("proc_listpids failed") 144 | } 145 | 146 | bufSize := C.int(numPids) * C.int(unsafe.Sizeof(C.int(0))) 147 | buf := make([]C.int, numPids) 148 | 149 | numPids = C.proc_listpids(C.PROC_ALL_PIDS, 0, unsafe.Pointer(&buf[0]), bufSize) 150 | if numPids <= 0 { 151 | return nil, fmt.Errorf("proc_listpids failed") 152 | } 153 | 154 | count := int(numPids) / int(unsafe.Sizeof(C.int(0))) 155 | pids := make([]int, 0, count) 156 | for i := 0; i < count; i++ { 157 | if buf[i] > 0 { 158 | pids = append(pids, int(buf[i])) 159 | } 160 | } 161 | 162 | return pids, nil 163 | } 164 | 165 | func getConnectionsForPid(pid int) ([]Connection, error) { 166 | procName := getProcessName(pid) 167 | uid := int(C.get_proc_uid(C.int(pid))) 168 | user := "" 169 | if uid >= 0 { 170 | cUser := C.get_username(C.int(uid)) 171 | if cUser != nil { 172 | user = C.GoString(cUser) 173 | } else { 174 | user = strconv.Itoa(uid) 175 | } 176 | } 177 | 178 | bufSize := C.proc_pidinfo(C.int(pid), C.PROC_PIDLISTFDS, 0, nil, 0) 179 | if bufSize <= 0 { 180 | return nil, fmt.Errorf("failed to get fd list size") 181 | } 182 | 183 | buf := make([]byte, bufSize) 184 | ret := C.proc_pidinfo(C.int(pid), C.PROC_PIDLISTFDS, 0, unsafe.Pointer(&buf[0]), bufSize) 185 | if ret <= 0 { 186 | return nil, fmt.Errorf("failed to get fd list") 187 | } 188 | 189 | fdInfoSize := int(unsafe.Sizeof(C.struct_proc_fdinfo{})) 190 | numFds := int(ret) / fdInfoSize 191 | 192 | var connections []Connection 193 | 194 | for i := 0; i < numFds; i++ { 195 | fdInfo := (*C.struct_proc_fdinfo)(unsafe.Pointer(&buf[i*fdInfoSize])) 196 | 197 | if fdInfo.proc_fdtype != C.PROX_FDTYPE_SOCKET { 198 | continue 199 | } 200 | 201 | conn, ok := getSocketInfo(pid, int(fdInfo.proc_fd), procName, uid, user) 202 | if ok { 203 | connections = append(connections, conn) 204 | } 205 | } 206 | 207 | return connections, nil 208 | } 209 | 210 | func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connection, bool) { 211 | var info C.socket_info_t 212 | 213 | ret := C.get_socket_info(C.int(pid), C.int(fd), &info) 214 | if ret != 0 { 215 | return Connection{}, false 216 | } 217 | 218 | // only interested in IPv4 and IPv6 219 | if info.family != C.AF_INET && info.family != C.AF_INET6 { 220 | return Connection{}, false 221 | } 222 | 223 | // only TCP and UDP 224 | if info.sock_type != C.SOCK_STREAM && info.sock_type != C.SOCK_DGRAM { 225 | return Connection{}, false 226 | } 227 | 228 | proto := "tcp" 229 | if info.sock_type == C.SOCK_DGRAM { 230 | proto = "udp" 231 | } 232 | 233 | ipVersion := "IPv4" 234 | if info.family == C.AF_INET6 { 235 | ipVersion = "IPv6" 236 | proto = proto + "6" 237 | } 238 | 239 | var laddr, raddr string 240 | 241 | if info.family == C.AF_INET { 242 | laddr = ipv4ToString(uint32(info.laddr4)) 243 | raddr = ipv4ToString(uint32(info.raddr4)) 244 | } else { 245 | laddr = ipv6ToString(info.laddr6) 246 | raddr = ipv6ToString(info.raddr6) 247 | } 248 | 249 | if laddr == "0.0.0.0" || laddr == "::" { 250 | laddr = "*" 251 | } 252 | if raddr == "0.0.0.0" || raddr == "::" { 253 | raddr = "*" 254 | } 255 | 256 | state := "" 257 | if info.sock_type == C.SOCK_STREAM { 258 | state = tcpStateToString(int(info.state)) 259 | } else if info.sock_type == C.SOCK_DGRAM { 260 | // udp is connectionless - infer state from remote address 261 | if raddr == "*" && int(info.rport) == 0 { 262 | state = "LISTEN" 263 | } else { 264 | state = "ESTABLISHED" 265 | } 266 | } 267 | 268 | conn := Connection{ 269 | TS: time.Now(), 270 | Proto: proto, 271 | IPVersion: ipVersion, 272 | State: state, 273 | Laddr: laddr, 274 | Lport: int(info.lport), 275 | Raddr: raddr, 276 | Rport: int(info.rport), 277 | PID: pid, 278 | Process: procName, 279 | UID: uid, 280 | User: user, 281 | Interface: guessNetworkInterface(laddr), 282 | } 283 | 284 | return conn, true 285 | } 286 | 287 | func getProcessName(pid int) string { 288 | var name [256]C.char 289 | ret := C.get_proc_name(C.int(pid), &name[0], 256) 290 | if ret <= 0 { 291 | return "" 292 | } 293 | return C.GoString(&name[0]) 294 | } 295 | 296 | func ipv4ToString(addr uint32) string { 297 | ip := make(net.IP, 4) 298 | ip[0] = byte(addr) 299 | ip[1] = byte(addr >> 8) 300 | ip[2] = byte(addr >> 16) 301 | ip[3] = byte(addr >> 24) 302 | return ip.String() 303 | } 304 | 305 | func ipv6ToString(addr [16]C.uint8_t) string { 306 | ip := make(net.IP, 16) 307 | for i := 0; i < 16; i++ { 308 | ip[i] = byte(addr[i]) 309 | } 310 | 311 | if ip.To4() != nil { 312 | return ip.To4().String() 313 | } 314 | 315 | return ip.String() 316 | } 317 | 318 | func tcpStateToString(state int) string { 319 | // macOS TCP states from netinet/tcp_fsm.h 320 | states := map[int]string{ 321 | 0: "CLOSED", 322 | 1: "LISTEN", 323 | 2: "SYN_SENT", 324 | 3: "SYN_RECV", 325 | 4: "ESTABLISHED", 326 | 5: "CLOSE_WAIT", 327 | 6: "FIN_WAIT1", 328 | 7: "CLOSING", 329 | 8: "LAST_ACK", 330 | 9: "FIN_WAIT2", 331 | 10: "TIME_WAIT", 332 | } 333 | 334 | if s, exists := states[state]; exists { 335 | return s 336 | } 337 | return "" 338 | } 339 | -------------------------------------------------------------------------------- /internal/resolver/resolver_test.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestNew(t *testing.T) { 10 | r := New(100 * time.Millisecond) 11 | if r == nil { 12 | t.Fatal("expected non-nil resolver") 13 | } 14 | if r.timeout != 100*time.Millisecond { 15 | t.Errorf("expected timeout 100ms, got %v", r.timeout) 16 | } 17 | if r.cache == nil { 18 | t.Error("expected cache to be initialized") 19 | } 20 | if r.noCache { 21 | t.Error("expected noCache to be false by default") 22 | } 23 | } 24 | 25 | func TestSetNoCache(t *testing.T) { 26 | r := New(100 * time.Millisecond) 27 | 28 | r.SetNoCache(true) 29 | if !r.noCache { 30 | t.Error("expected noCache to be true") 31 | } 32 | 33 | r.SetNoCache(false) 34 | if r.noCache { 35 | t.Error("expected noCache to be false") 36 | } 37 | } 38 | 39 | func TestResolveAddr_InvalidIP(t *testing.T) { 40 | r := New(100 * time.Millisecond) 41 | 42 | // invalid ip should return as-is 43 | result := r.ResolveAddr("not-an-ip") 44 | if result != "not-an-ip" { 45 | t.Errorf("expected 'not-an-ip', got %q", result) 46 | } 47 | 48 | // empty string should return as-is 49 | result = r.ResolveAddr("") 50 | if result != "" { 51 | t.Errorf("expected empty string, got %q", result) 52 | } 53 | } 54 | 55 | func TestResolveAddr_Caching(t *testing.T) { 56 | r := New(100 * time.Millisecond) 57 | 58 | // first call should cache 59 | addr := "127.0.0.1" 60 | result1 := r.ResolveAddr(addr) 61 | 62 | // verify cache is populated 63 | if r.GetCacheSize() != 1 { 64 | t.Errorf("expected cache size 1, got %d", r.GetCacheSize()) 65 | } 66 | 67 | // second call should use cache 68 | result2 := r.ResolveAddr(addr) 69 | if result1 != result2 { 70 | t.Errorf("expected same result from cache, got %q and %q", result1, result2) 71 | } 72 | } 73 | 74 | func TestResolveAddr_NoCacheMode(t *testing.T) { 75 | r := New(100 * time.Millisecond) 76 | r.SetNoCache(true) 77 | 78 | addr := "127.0.0.1" 79 | r.ResolveAddr(addr) 80 | 81 | // cache should remain empty when noCache is enabled 82 | if r.GetCacheSize() != 0 { 83 | t.Errorf("expected cache size 0 with noCache, got %d", r.GetCacheSize()) 84 | } 85 | } 86 | 87 | func TestResolvePort_Zero(t *testing.T) { 88 | r := New(100 * time.Millisecond) 89 | 90 | result := r.ResolvePort(0, "tcp") 91 | if result != "0" { 92 | t.Errorf("expected '0' for port 0, got %q", result) 93 | } 94 | } 95 | 96 | func TestResolvePort_WellKnown(t *testing.T) { 97 | r := New(100 * time.Millisecond) 98 | 99 | tests := []struct { 100 | port int 101 | proto string 102 | expected string 103 | }{ 104 | {80, "tcp", "http"}, 105 | {443, "tcp", "https"}, 106 | {22, "tcp", "ssh"}, 107 | {53, "udp", "domain"}, 108 | {5432, "tcp", "postgresql"}, 109 | } 110 | 111 | for _, tt := range tests { 112 | result := r.ResolvePort(tt.port, tt.proto) 113 | if result != tt.expected { 114 | t.Errorf("ResolvePort(%d, %q) = %q, want %q", tt.port, tt.proto, result, tt.expected) 115 | } 116 | } 117 | } 118 | 119 | func TestResolvePort_Caching(t *testing.T) { 120 | r := New(100 * time.Millisecond) 121 | 122 | r.ResolvePort(80, "tcp") 123 | r.ResolvePort(443, "tcp") 124 | 125 | if r.GetCacheSize() != 2 { 126 | t.Errorf("expected cache size 2, got %d", r.GetCacheSize()) 127 | } 128 | 129 | // same port/proto should not add new entry 130 | r.ResolvePort(80, "tcp") 131 | if r.GetCacheSize() != 2 { 132 | t.Errorf("expected cache size still 2, got %d", r.GetCacheSize()) 133 | } 134 | } 135 | 136 | func TestResolveAddrPort(t *testing.T) { 137 | r := New(100 * time.Millisecond) 138 | 139 | addr, port := r.ResolveAddrPort("127.0.0.1", 80, "tcp") 140 | 141 | if addr == "" { 142 | t.Error("expected non-empty address") 143 | } 144 | if port != "http" { 145 | t.Errorf("expected port 'http', got %q", port) 146 | } 147 | } 148 | 149 | func TestClearCache(t *testing.T) { 150 | r := New(100 * time.Millisecond) 151 | 152 | r.ResolveAddr("127.0.0.1") 153 | r.ResolvePort(80, "tcp") 154 | 155 | if r.GetCacheSize() == 0 { 156 | t.Error("expected non-empty cache before clear") 157 | } 158 | 159 | r.ClearCache() 160 | 161 | if r.GetCacheSize() != 0 { 162 | t.Errorf("expected empty cache after clear, got %d", r.GetCacheSize()) 163 | } 164 | } 165 | 166 | func TestGetCacheSize(t *testing.T) { 167 | r := New(100 * time.Millisecond) 168 | 169 | if r.GetCacheSize() != 0 { 170 | t.Errorf("expected initial cache size 0, got %d", r.GetCacheSize()) 171 | } 172 | 173 | r.ResolveAddr("127.0.0.1") 174 | if r.GetCacheSize() != 1 { 175 | t.Errorf("expected cache size 1, got %d", r.GetCacheSize()) 176 | } 177 | } 178 | 179 | func TestGetServiceName(t *testing.T) { 180 | tests := []struct { 181 | port int 182 | proto string 183 | expected string 184 | }{ 185 | {80, "tcp", "http"}, 186 | {443, "tcp", "https"}, 187 | {22, "tcp", "ssh"}, 188 | {53, "tcp", "domain"}, 189 | {53, "udp", "domain"}, 190 | {12345, "tcp", ""}, 191 | {0, "tcp", ""}, 192 | } 193 | 194 | for _, tt := range tests { 195 | result := getServiceName(tt.port, tt.proto) 196 | if result != tt.expected { 197 | t.Errorf("getServiceName(%d, %q) = %q, want %q", tt.port, tt.proto, result, tt.expected) 198 | } 199 | } 200 | } 201 | 202 | func TestResolveAddrsParallel(t *testing.T) { 203 | r := New(100 * time.Millisecond) 204 | 205 | addrs := []string{ 206 | "127.0.0.1", 207 | "127.0.0.2", 208 | "127.0.0.3", 209 | "", // should be skipped 210 | "*", // should be skipped 211 | } 212 | 213 | r.ResolveAddrsParallel(addrs) 214 | 215 | // should have cached 3 addresses (excluding empty and *) 216 | if r.GetCacheSize() != 3 { 217 | t.Errorf("expected cache size 3, got %d", r.GetCacheSize()) 218 | } 219 | } 220 | 221 | func TestResolveAddrsParallel_Dedupe(t *testing.T) { 222 | r := New(100 * time.Millisecond) 223 | 224 | addrs := []string{ 225 | "127.0.0.1", 226 | "127.0.0.1", 227 | "127.0.0.1", 228 | "127.0.0.2", 229 | } 230 | 231 | r.ResolveAddrsParallel(addrs) 232 | 233 | // should have cached 2 unique addresses 234 | if r.GetCacheSize() != 2 { 235 | t.Errorf("expected cache size 2, got %d", r.GetCacheSize()) 236 | } 237 | } 238 | 239 | func TestResolveAddrsParallel_SkipsCached(t *testing.T) { 240 | r := New(100 * time.Millisecond) 241 | 242 | // pre-cache one address 243 | r.ResolveAddr("127.0.0.1") 244 | 245 | addrs := []string{ 246 | "127.0.0.1", // already cached 247 | "127.0.0.2", // not cached 248 | } 249 | 250 | initialSize := r.GetCacheSize() 251 | r.ResolveAddrsParallel(addrs) 252 | 253 | // should have added 1 more 254 | if r.GetCacheSize() != initialSize+1 { 255 | t.Errorf("expected cache size %d, got %d", initialSize+1, r.GetCacheSize()) 256 | } 257 | } 258 | 259 | func TestResolveAddrsParallel_Empty(t *testing.T) { 260 | r := New(100 * time.Millisecond) 261 | 262 | // should not panic with empty input 263 | r.ResolveAddrsParallel([]string{}) 264 | r.ResolveAddrsParallel(nil) 265 | 266 | if r.GetCacheSize() != 0 { 267 | t.Errorf("expected cache size 0, got %d", r.GetCacheSize()) 268 | } 269 | } 270 | 271 | func TestGlobalResolver(t *testing.T) { 272 | // reset global resolver 273 | globalResolver = nil 274 | 275 | r := GetGlobalResolver() 276 | if r == nil { 277 | t.Fatal("expected non-nil global resolver") 278 | } 279 | 280 | // should return same instance 281 | r2 := GetGlobalResolver() 282 | if r != r2 { 283 | t.Error("expected same global resolver instance") 284 | } 285 | } 286 | 287 | func TestSetGlobalResolver(t *testing.T) { 288 | SetGlobalResolver(ResolverOptions{ 289 | Timeout: 500 * time.Millisecond, 290 | NoCache: true, 291 | }) 292 | 293 | r := GetGlobalResolver() 294 | if r.timeout != 500*time.Millisecond { 295 | t.Errorf("expected timeout 500ms, got %v", r.timeout) 296 | } 297 | if !r.noCache { 298 | t.Error("expected noCache to be true") 299 | } 300 | 301 | // reset for other tests 302 | globalResolver = nil 303 | } 304 | 305 | func TestSetGlobalResolver_DefaultTimeout(t *testing.T) { 306 | SetGlobalResolver(ResolverOptions{ 307 | Timeout: 0, // should use default 308 | }) 309 | 310 | r := GetGlobalResolver() 311 | if r.timeout != 200*time.Millisecond { 312 | t.Errorf("expected default timeout 200ms, got %v", r.timeout) 313 | } 314 | 315 | // reset for other tests 316 | globalResolver = nil 317 | } 318 | 319 | func TestGlobalConvenienceFunctions(t *testing.T) { 320 | globalResolver = nil 321 | 322 | // test global ResolveAddr 323 | result := ResolveAddr("127.0.0.1") 324 | if result == "" { 325 | t.Error("expected non-empty result from global ResolveAddr") 326 | } 327 | 328 | // test global ResolvePort 329 | port := ResolvePort(80, "tcp") 330 | if port != "http" { 331 | t.Errorf("expected 'http', got %q", port) 332 | } 333 | 334 | // test global ResolveAddrPort 335 | addr, portStr := ResolveAddrPort("127.0.0.1", 443, "tcp") 336 | if addr == "" { 337 | t.Error("expected non-empty address") 338 | } 339 | if portStr != "https" { 340 | t.Errorf("expected 'https', got %q", portStr) 341 | } 342 | 343 | // test global SetNoCache 344 | SetNoCache(true) 345 | if !GetGlobalResolver().noCache { 346 | t.Error("expected global noCache to be true") 347 | } 348 | 349 | // reset 350 | globalResolver = nil 351 | } 352 | 353 | func TestConcurrentAccess(t *testing.T) { 354 | r := New(100 * time.Millisecond) 355 | 356 | var wg sync.WaitGroup 357 | for i := 0; i < 100; i++ { 358 | wg.Add(1) 359 | go func(n int) { 360 | defer wg.Done() 361 | addr := "127.0.0.1" 362 | r.ResolveAddr(addr) 363 | r.ResolvePort(80+n%10, "tcp") 364 | r.GetCacheSize() 365 | }(i) 366 | } 367 | 368 | wg.Wait() 369 | 370 | // should not panic and cache should have entries 371 | if r.GetCacheSize() == 0 { 372 | t.Error("expected non-empty cache after concurrent access") 373 | } 374 | } 375 | 376 | func TestResolveAddr_TrailingDot(t *testing.T) { 377 | // this test verifies the trailing dot removal logic 378 | // by checking the internal logic works correctly 379 | r := New(100 * time.Millisecond) 380 | 381 | // localhost should resolve and have trailing dot removed 382 | result := r.ResolveAddr("127.0.0.1") 383 | if len(result) > 0 && result[len(result)-1] == '.' { 384 | t.Error("expected trailing dot to be removed") 385 | } 386 | } 387 | 388 | -------------------------------------------------------------------------------- /internal/collector/mock.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "time" 7 | ) 8 | 9 | // MockCollector provides deterministic test data for testing 10 | // It implements the Collector interface 11 | type MockCollector struct { 12 | connections []Connection 13 | } 14 | 15 | // NewMockCollector creates a new mock collector with default test data 16 | func NewMockCollector() *MockCollector { 17 | return &MockCollector{ 18 | connections: getDefaultTestConnections(), 19 | } 20 | } 21 | 22 | // NewMockCollectorFromFile creates a mock collector from a JSON fixture file 23 | func NewMockCollectorFromFile(filename string) (*MockCollector, error) { 24 | data, err := os.ReadFile(filename) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | var connections []Connection 30 | if err := json.Unmarshal(data, &connections); err != nil { 31 | return nil, err 32 | } 33 | 34 | return &MockCollector{ 35 | connections: connections, 36 | }, nil 37 | } 38 | 39 | // GetConnections returns the mock connections 40 | func (m *MockCollector) GetConnections() ([]Connection, error) { 41 | // Return a copy to avoid mutation 42 | result := make([]Connection, len(m.connections)) 43 | copy(result, m.connections) 44 | return result, nil 45 | } 46 | 47 | // AddConnection adds a connection to the mock data 48 | func (m *MockCollector) AddConnection(conn Connection) { 49 | m.connections = append(m.connections, conn) 50 | } 51 | 52 | // SetConnections replaces all connections with the provided slice 53 | func (m *MockCollector) SetConnections(connections []Connection) { 54 | m.connections = make([]Connection, len(connections)) 55 | copy(m.connections, connections) 56 | } 57 | 58 | // SaveToFile saves the current connections to a JSON file 59 | func (m *MockCollector) SaveToFile(filename string) error { 60 | data, err := json.MarshalIndent(m.connections, "", " ") 61 | if err != nil { 62 | return err 63 | } 64 | 65 | return os.WriteFile(filename, data, 0644) 66 | } 67 | 68 | // getDefaultTestConnections returns a set of deterministic test connections 69 | func getDefaultTestConnections() []Connection { 70 | baseTime := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) 71 | 72 | return []Connection{ 73 | { 74 | TS: baseTime, 75 | PID: 1234, 76 | Process: "nginx", 77 | User: "www-data", 78 | UID: 33, 79 | Proto: "tcp", 80 | IPVersion: "IPv4", 81 | State: "LISTEN", 82 | Laddr: "0.0.0.0", 83 | Lport: 80, 84 | Raddr: "*", 85 | Rport: 0, 86 | Interface: "eth0", 87 | RxBytes: 0, 88 | TxBytes: 0, 89 | RttMs: 0, 90 | Mark: "0x0", 91 | Namespace: "init", 92 | Inode: 12345, 93 | }, 94 | { 95 | TS: baseTime.Add(time.Second), 96 | PID: 1234, 97 | Process: "nginx", 98 | User: "www-data", 99 | UID: 33, 100 | Proto: "tcp", 101 | IPVersion: "IPv4", 102 | State: "ESTABLISHED", 103 | Laddr: "10.0.0.1", 104 | Lport: 80, 105 | Raddr: "203.0.113.10", 106 | Rport: 52344, 107 | Interface: "eth0", 108 | RxBytes: 10240, 109 | TxBytes: 2048, 110 | RttMs: 1.7, 111 | Mark: "0x0", 112 | Namespace: "init", 113 | Inode: 12346, 114 | }, 115 | { 116 | TS: baseTime.Add(2 * time.Second), 117 | PID: 5678, 118 | Process: "postgres", 119 | User: "postgres", 120 | UID: 26, 121 | Proto: "tcp", 122 | IPVersion: "IPv4", 123 | State: "LISTEN", 124 | Laddr: "127.0.0.1", 125 | Lport: 5432, 126 | Raddr: "*", 127 | Rport: 0, 128 | Interface: "lo", 129 | RxBytes: 0, 130 | TxBytes: 0, 131 | RttMs: 0, 132 | Mark: "0x0", 133 | Namespace: "init", 134 | Inode: 12347, 135 | }, 136 | { 137 | TS: baseTime.Add(3 * time.Second), 138 | PID: 5678, 139 | Process: "postgres", 140 | User: "postgres", 141 | UID: 26, 142 | Proto: "tcp", 143 | IPVersion: "IPv4", 144 | State: "ESTABLISHED", 145 | Laddr: "127.0.0.1", 146 | Lport: 5432, 147 | Raddr: "127.0.0.1", 148 | Rport: 45678, 149 | Interface: "lo", 150 | RxBytes: 8192, 151 | TxBytes: 4096, 152 | RttMs: 0.1, 153 | Mark: "0x0", 154 | Namespace: "init", 155 | Inode: 12348, 156 | }, 157 | { 158 | TS: baseTime.Add(4 * time.Second), 159 | PID: 9999, 160 | Process: "dns-server", 161 | User: "named", 162 | UID: 25, 163 | Proto: "udp", 164 | IPVersion: "IPv4", 165 | State: "LISTEN", 166 | Laddr: "0.0.0.0", 167 | Lport: 53, 168 | Raddr: "*", 169 | Rport: 0, 170 | Interface: "eth0", 171 | RxBytes: 1024, 172 | TxBytes: 512, 173 | RttMs: 0, 174 | Mark: "0x0", 175 | Namespace: "init", 176 | Inode: 12349, 177 | }, 178 | { 179 | TS: baseTime.Add(5 * time.Second), 180 | PID: 1111, 181 | Process: "ssh", 182 | User: "root", 183 | UID: 0, 184 | Proto: "tcp", 185 | IPVersion: "IPv4", 186 | State: "ESTABLISHED", 187 | Laddr: "192.168.1.100", 188 | Lport: 22, 189 | Raddr: "192.168.1.200", 190 | Rport: 54321, 191 | Interface: "eth0", 192 | RxBytes: 2048, 193 | TxBytes: 1024, 194 | RttMs: 2.3, 195 | Mark: "0x0", 196 | Namespace: "init", 197 | Inode: 12350, 198 | }, 199 | { 200 | TS: baseTime.Add(6 * time.Second), 201 | PID: 2222, 202 | Process: "app-server", 203 | User: "app", 204 | UID: 1000, 205 | Proto: "unix", 206 | IPVersion: "", 207 | State: "CONNECTED", 208 | Laddr: "/tmp/app.sock", 209 | Lport: 0, 210 | Raddr: "", 211 | Rport: 0, 212 | Interface: "unix", 213 | RxBytes: 512, 214 | TxBytes: 256, 215 | RttMs: 0, 216 | Mark: "", 217 | Namespace: "init", 218 | Inode: 12351, 219 | }, 220 | } 221 | } 222 | 223 | // ConnectionBuilder provides a fluent interface for building test connections 224 | type ConnectionBuilder struct { 225 | conn Connection 226 | } 227 | 228 | // NewConnectionBuilder creates a new connection builder with sensible defaults 229 | func NewConnectionBuilder() *ConnectionBuilder { 230 | return &ConnectionBuilder{ 231 | conn: Connection{ 232 | TS: time.Now(), 233 | PID: 1000, 234 | Process: "test-process", 235 | User: "test-user", 236 | UID: 1000, 237 | Proto: "tcp", 238 | IPVersion: "IPv4", 239 | State: "ESTABLISHED", 240 | Laddr: "127.0.0.1", 241 | Lport: 8080, 242 | Raddr: "127.0.0.1", 243 | Rport: 9090, 244 | Interface: "lo", 245 | RxBytes: 1024, 246 | TxBytes: 512, 247 | RttMs: 1.0, 248 | Mark: "0x0", 249 | Namespace: "init", 250 | Inode: 99999, 251 | }, 252 | } 253 | } 254 | 255 | // WithPID sets the PID 256 | func (b *ConnectionBuilder) WithPID(pid int) *ConnectionBuilder { 257 | b.conn.PID = pid 258 | return b 259 | } 260 | 261 | // WithProcess sets the process name 262 | func (b *ConnectionBuilder) WithProcess(process string) *ConnectionBuilder { 263 | b.conn.Process = process 264 | return b 265 | } 266 | 267 | // WithProto sets the protocol 268 | func (b *ConnectionBuilder) WithProto(proto string) *ConnectionBuilder { 269 | b.conn.Proto = proto 270 | return b 271 | } 272 | 273 | // WithState sets the connection state 274 | func (b *ConnectionBuilder) WithState(state string) *ConnectionBuilder { 275 | b.conn.State = state 276 | return b 277 | } 278 | 279 | // WithLocalAddr sets the local address and port 280 | func (b *ConnectionBuilder) WithLocalAddr(addr string, port int) *ConnectionBuilder { 281 | b.conn.Laddr = addr 282 | b.conn.Lport = port 283 | return b 284 | } 285 | 286 | // WithRemoteAddr sets the remote address and port 287 | func (b *ConnectionBuilder) WithRemoteAddr(addr string, port int) *ConnectionBuilder { 288 | b.conn.Raddr = addr 289 | b.conn.Rport = port 290 | return b 291 | } 292 | 293 | // WithInterface sets the network interface 294 | func (b *ConnectionBuilder) WithInterface(iface string) *ConnectionBuilder { 295 | b.conn.Interface = iface 296 | return b 297 | } 298 | 299 | // WithBytes sets the rx and tx byte counts 300 | func (b *ConnectionBuilder) WithBytes(rx, tx int64) *ConnectionBuilder { 301 | b.conn.RxBytes = rx 302 | b.conn.TxBytes = tx 303 | return b 304 | } 305 | 306 | // Build returns the constructed connection 307 | func (b *ConnectionBuilder) Build() Connection { 308 | return b.conn 309 | } 310 | 311 | // TestFixture provides test scenarios for different use cases 312 | type TestFixture struct { 313 | Name string 314 | Description string 315 | Connections []Connection 316 | } 317 | 318 | // GetTestFixtures returns predefined test fixtures for different scenarios 319 | func GetTestFixtures() []TestFixture { 320 | baseTime := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) 321 | 322 | return []TestFixture{ 323 | { 324 | Name: "empty", 325 | Description: "No connections", 326 | Connections: []Connection{}, 327 | }, 328 | { 329 | Name: "single-tcp", 330 | Description: "Single TCP connection", 331 | Connections: []Connection{ 332 | NewConnectionBuilder(). 333 | WithPID(1234). 334 | WithProcess("test-app"). 335 | WithProto("tcp"). 336 | WithState("ESTABLISHED"). 337 | WithLocalAddr("127.0.0.1", 8080). 338 | WithRemoteAddr("127.0.0.1", 9090). 339 | Build(), 340 | }, 341 | }, 342 | { 343 | Name: "mixed-protocols", 344 | Description: "Mix of TCP, UDP, and Unix sockets", 345 | Connections: []Connection{ 346 | { 347 | TS: baseTime, 348 | PID: 1, 349 | Process: "tcp-server", 350 | Proto: "tcp", 351 | State: "LISTEN", 352 | Laddr: "0.0.0.0", 353 | Lport: 80, 354 | Interface: "eth0", 355 | }, 356 | { 357 | TS: baseTime.Add(time.Second), 358 | PID: 2, 359 | Process: "udp-server", 360 | Proto: "udp", 361 | State: "LISTEN", 362 | Laddr: "0.0.0.0", 363 | Lport: 53, 364 | Interface: "eth0", 365 | }, 366 | { 367 | TS: baseTime.Add(2 * time.Second), 368 | PID: 3, 369 | Process: "unix-app", 370 | Proto: "unix", 371 | State: "CONNECTED", 372 | Laddr: "/tmp/test.sock", 373 | Interface: "unix", 374 | }, 375 | }, 376 | }, 377 | { 378 | Name: "high-volume", 379 | Description: "Large number of connections for performance testing", 380 | Connections: generateHighVolumeConnections(1000), 381 | }, 382 | } 383 | } 384 | 385 | // generateHighVolumeConnections creates a large number of test connections 386 | func generateHighVolumeConnections(count int) []Connection { 387 | connections := make([]Connection, count) 388 | baseTime := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) 389 | 390 | for i := 0; i < count; i++ { 391 | connections[i] = NewConnectionBuilder(). 392 | WithPID(1000 + i). 393 | WithProcess("worker-" + string(rune('a'+i%26))). 394 | WithProto([]string{"tcp", "udp"}[i%2]). 395 | WithState([]string{"ESTABLISHED", "LISTEN", "TIME_WAIT"}[i%3]). 396 | WithLocalAddr("127.0.0.1", 8000+i%1000). 397 | WithRemoteAddr("10.0.0."+string(rune('1'+i%10)), 9000+i%1000). 398 | Build() 399 | connections[i].TS = baseTime.Add(time.Duration(i) * time.Millisecond) 400 | } 401 | 402 | return connections 403 | } --------------------------------------------------------------------------------