7 Commits

Author SHA1 Message Date
Karol Broda
d7cf490ff5 refactor(tui): unicode symbols from a single definition 2025-12-20 19:29:16 +01:00
Karol Broda
99f1d95295 feat(tui): add process features watch and kill 2025-12-20 19:11:58 +01:00
Karol Broda
2c9ce1445f chore(demo): update demo 2025-12-18 08:14:10 +01:00
Karol Broda
3c3656966e fix: correct ldflags capitalization for version info 2025-12-17 17:40:13 +01:00
Karol Broda
3ce1ce8aed refactor: extract common filter logic into shared runtime 2025-12-17 17:33:01 +01:00
Karol Broda
c543a8a4e9 fix: udp handling and ipv4 and ipv6 filtering 2025-12-17 17:15:52 +01:00
Karol Broda
7f2bd068ad chore: separate darwin build configuration into .goreleaser-darwin.yaml and update action 2025-12-17 00:11:13 +01:00
37 changed files with 1391 additions and 282 deletions

View File

@@ -9,7 +9,7 @@ permissions:
contents: write contents: write
jobs: jobs:
build-linux: release-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -20,21 +20,16 @@ jobs:
with: with:
go-version: "1.25.0" go-version: "1.25.0"
- name: build linux binaries - name: release linux
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v6
with: with:
version: "~> v2" version: "~> v2"
args: build --clean --id linux args: release --clean
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: upload linux artifacts release-darwin:
uses: actions/upload-artifact@v4 needs: release-linux
with:
name: linux-dist
path: dist/
build-darwin:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -45,49 +40,10 @@ jobs:
with: with:
go-version: "1.25.0" go-version: "1.25.0"
- name: build darwin binaries - name: release darwin
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v6
with: with:
version: "~> v2" version: "~> v2"
args: build --clean --id darwin args: release --clean --config .goreleaser-darwin.yaml --skip=validate
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: upload darwin artifacts
uses: actions/upload-artifact@v4
with:
name: darwin-dist
path: dist/
release:
needs: [build-linux, build-darwin]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: "1.25.0"
- name: download linux artifacts
uses: actions/download-artifact@v4
with:
name: linux-dist
path: dist/
- name: download darwin artifacts
uses: actions/download-artifact@v4
with:
name: darwin-dist
path: dist/
merge-multiple: true
- name: release
uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean --skip=build
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

36
.goreleaser-darwin.yaml Normal file
View File

@@ -0,0 +1,36 @@
version: 2
project_name: snitch
builds:
- id: darwin
env:
- CGO_ENABLED=1
goos:
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X snitch/cmd.Version={{.Version}}
- -X snitch/cmd.Commit={{.ShortCommit}}
- -X snitch/cmd.Date={{.Date}}
archives:
- formats:
- tar.gz
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- .Os }}_
{{- .Arch }}
release:
github:
owner: karol-broda
name: snitch
draft: false
prerelease: auto
mode: append

View File

@@ -24,20 +24,6 @@ builds:
- -X snitch/cmd.Commit={{.ShortCommit}} - -X snitch/cmd.Commit={{.ShortCommit}}
- -X snitch/cmd.Date={{.Date}} - -X snitch/cmd.Date={{.Date}}
- id: darwin
env:
- CGO_ENABLED=1
goos:
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X snitch/cmd.Version={{.Version}}
- -X snitch/cmd.Commit={{.ShortCommit}}
- -X snitch/cmd.Date={{.Date}}
archives: archives:
- formats: - formats:
- tar.gz - tar.gz
@@ -74,8 +60,6 @@ nfpms:
- deb - deb
- rpm - rpm
- apk - apk
ids:
- linux
release: release:
github: github:

23
Makefile Normal file
View File

@@ -0,0 +1,23 @@
.PHONY: build test lint demo demo-build demo-run clean
build:
go build -o snitch .
test:
go test ./...
lint:
golangci-lint run
demo: demo-build demo-run
demo-build:
docker build -f demo/Dockerfile -t snitch-demo .
demo-run:
docker run --rm -v $(PWD)/demo:/output snitch-demo
clean:
rm -f snitch
rm -f demo/demo.gif

View File

@@ -2,6 +2,8 @@
a friendlier `ss` / `netstat` for humans. inspect network connections with a clean tui or styled tables. a friendlier `ss` / `netstat` for humans. inspect network connections with a clean tui or styled tables.
![snitch demo](demo/demo.gif)
## install ## install
### go ### go
@@ -71,6 +73,9 @@ g/G top/bottom
t/u toggle tcp/udp t/u toggle tcp/udp
l/e/o toggle listen/established/other l/e/o toggle listen/established/other
s/S cycle sort / reverse s/S cycle sort / reverse
w watch/monitor process (highlight)
W clear all watched
K kill process (with confirmation)
/ search / search
enter connection details enter connection details
? help ? help

View File

@@ -361,8 +361,8 @@ func resetGlobalFlags() {
showTimestamp = false showTimestamp = false
sortBy = "" sortBy = ""
fields = "" fields = ""
ipv4 = false filterIPv4 = false
ipv6 = false filterIPv6 = false
colorMode = "auto" colorMode = "auto"
numeric = false numeric = false
} }

137
cmd/ls.go
View File

@@ -22,20 +22,15 @@ import (
"golang.org/x/term" "golang.org/x/term"
) )
// ls-specific flags
var ( var (
outputFormat string outputFormat string
noHeaders bool noHeaders bool
showTimestamp bool showTimestamp bool
sortBy string sortBy string
fields string fields string
ipv4 bool
ipv6 bool
colorMode string colorMode string
numeric bool numeric bool
lsTCP bool
lsUDP bool
lsListen bool
lsEstab bool
plainOutput bool plainOutput bool
) )
@@ -56,39 +51,16 @@ Available filters:
} }
func runListCommand(outputFormat string, args []string) { func runListCommand(outputFormat string, args []string) {
color.Init(colorMode) rt, err := NewRuntime(args, colorMode, numeric)
filters, err := parseFilters(args)
if err != nil {
log.Fatalf("Error parsing filters: %v", err)
}
filters.IPv4 = ipv4
filters.IPv6 = ipv6
// apply shortcut flags
if lsTCP && !lsUDP {
filters.Proto = "tcp"
} else if lsUDP && !lsTCP {
filters.Proto = "udp"
}
if lsListen && !lsEstab {
filters.State = "LISTEN"
} else if lsEstab && !lsListen {
filters.State = "ESTABLISHED"
}
connections, err := collector.GetConnections()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
filteredConnections := collector.FilterConnections(connections, filters) // apply sorting
if sortBy != "" { if sortBy != "" {
collector.SortConnections(filteredConnections, collector.ParseSortOptions(sortBy)) rt.SortConnections(collector.ParseSortOptions(sortBy))
} else { } else {
// default sort by local port rt.SortConnections(collector.SortOptions{
collector.SortConnections(filteredConnections, collector.SortOptions{
Field: collector.SortByLport, Field: collector.SortByLport,
Direction: collector.SortAsc, Direction: collector.SortAsc,
}) })
@@ -99,93 +71,26 @@ func runListCommand(outputFormat string, args []string) {
selectedFields = strings.Split(fields, ",") selectedFields = strings.Split(fields, ",")
} }
switch outputFormat { renderList(rt.Connections, outputFormat, selectedFields)
}
func renderList(connections []collector.Connection, format string, selectedFields []string) {
switch format {
case "json": case "json":
printJSON(filteredConnections) printJSON(connections)
case "csv": case "csv":
printCSV(filteredConnections, !noHeaders, showTimestamp, selectedFields) printCSV(connections, !noHeaders, showTimestamp, selectedFields)
case "table", "wide": case "table", "wide":
if plainOutput { if plainOutput {
printPlainTable(filteredConnections, !noHeaders, showTimestamp, selectedFields) printPlainTable(connections, !noHeaders, showTimestamp, selectedFields)
} else { } else {
printStyledTable(filteredConnections, !noHeaders, selectedFields) printStyledTable(connections, !noHeaders, selectedFields)
} }
default: default:
log.Fatalf("Invalid output format: %s. Valid formats are: table, wide, json, csv", outputFormat) log.Fatalf("Invalid output format: %s. Valid formats are: table, wide, json, csv", format)
} }
} }
func parseFilters(args []string) (collector.FilterOptions, error) {
filters := collector.FilterOptions{}
for _, arg := range args {
parts := strings.SplitN(arg, "=", 2)
if len(parts) != 2 {
return filters, fmt.Errorf("invalid filter format: %s", arg)
}
key, value := parts[0], parts[1]
switch strings.ToLower(key) {
case "proto":
filters.Proto = value
case "state":
filters.State = value
case "pid":
pid, err := strconv.Atoi(value)
if err != nil {
return filters, fmt.Errorf("invalid pid value: %s", value)
}
filters.Pid = pid
case "proc":
filters.Proc = value
case "lport":
port, err := strconv.Atoi(value)
if err != nil {
return filters, fmt.Errorf("invalid lport value: %s", value)
}
filters.Lport = port
case "rport":
port, err := strconv.Atoi(value)
if err != nil {
return filters, fmt.Errorf("invalid rport value: %s", value)
}
filters.Rport = port
case "user":
uid, err := strconv.Atoi(value)
if err == nil {
filters.UID = uid
} else {
filters.User = value
}
case "laddr":
filters.Laddr = value
case "raddr":
filters.Raddr = value
case "contains":
filters.Contains = value
case "if", "interface":
filters.Interface = value
case "mark":
filters.Mark = value
case "namespace":
filters.Namespace = value
case "inode":
inode, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return filters, fmt.Errorf("invalid inode value: %s", value)
}
filters.Inode = inode
case "since":
since, sinceRel, err := collector.ParseTimeFilter(value)
if err != nil {
return filters, fmt.Errorf("invalid since value: %s", value)
}
filters.Since = since
filters.SinceRel = sinceRel
default:
return filters, fmt.Errorf("unknown filter key: %s", key)
}
}
return filters, nil
}
func getFieldMap(c collector.Connection) map[string]string { func getFieldMap(c collector.Connection) map[string]string {
laddr := c.Laddr laddr := c.Laddr
@@ -483,20 +388,16 @@ func init() {
cfg := config.Get() cfg := config.Get()
// ls-specific flags
lsCmd.Flags().StringVarP(&outputFormat, "output", "o", cfg.Defaults.OutputFormat, "Output format (table, wide, json, csv)") lsCmd.Flags().StringVarP(&outputFormat, "output", "o", cfg.Defaults.OutputFormat, "Output format (table, wide, json, csv)")
lsCmd.Flags().BoolVar(&noHeaders, "no-headers", cfg.Defaults.NoHeaders, "Omit headers for table/csv output") lsCmd.Flags().BoolVar(&noHeaders, "no-headers", cfg.Defaults.NoHeaders, "Omit headers for table/csv output")
lsCmd.Flags().BoolVar(&showTimestamp, "ts", false, "Include timestamp in output") lsCmd.Flags().BoolVar(&showTimestamp, "ts", false, "Include timestamp in output")
lsCmd.Flags().StringVarP(&sortBy, "sort", "s", cfg.Defaults.SortBy, "Sort by column (e.g., pid:desc)") lsCmd.Flags().StringVarP(&sortBy, "sort", "s", cfg.Defaults.SortBy, "Sort by column (e.g., pid:desc)")
lsCmd.Flags().StringVarP(&fields, "fields", "f", strings.Join(cfg.Defaults.Fields, ","), "Comma-separated list of fields to show") lsCmd.Flags().StringVarP(&fields, "fields", "f", strings.Join(cfg.Defaults.Fields, ","), "Comma-separated list of fields to show")
lsCmd.Flags().BoolVarP(&ipv4, "ipv4", "4", cfg.Defaults.IPv4, "Only show IPv4 connections")
lsCmd.Flags().BoolVarP(&ipv6, "ipv6", "6", cfg.Defaults.IPv6, "Only show IPv6 connections")
lsCmd.Flags().StringVar(&colorMode, "color", cfg.Defaults.Color, "Color mode (auto, always, never)") lsCmd.Flags().StringVar(&colorMode, "color", cfg.Defaults.Color, "Color mode (auto, always, never)")
lsCmd.Flags().BoolVarP(&numeric, "numeric", "n", cfg.Defaults.Numeric, "Don't resolve hostnames") lsCmd.Flags().BoolVarP(&numeric, "numeric", "n", cfg.Defaults.Numeric, "Don't resolve hostnames")
// shortcut filters
lsCmd.Flags().BoolVarP(&lsTCP, "tcp", "t", false, "Show only TCP connections")
lsCmd.Flags().BoolVarP(&lsUDP, "udp", "u", false, "Show only UDP connections")
lsCmd.Flags().BoolVarP(&lsListen, "listen", "l", false, "Show only listening sockets")
lsCmd.Flags().BoolVarP(&lsEstab, "established", "e", false, "Show only established connections")
lsCmd.Flags().BoolVarP(&plainOutput, "plain", "p", false, "Plain output (parsable, no styling)") lsCmd.Flags().BoolVarP(&plainOutput, "plain", "p", false, "Plain output (parsable, no styling)")
// shared filter flags
addFilterFlags(lsCmd)
} }

View File

@@ -251,7 +251,7 @@ func TestParseFilters(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
filters, err := parseFilters(tt.args) filters, err := ParseFilterArgs(tt.args)
if tt.expectError { if tt.expectError {
if err == nil { if err == nil {

View File

@@ -40,12 +40,11 @@ func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/snitch/snitch.toml)") rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/snitch/snitch.toml)")
rootCmd.PersistentFlags().Bool("debug", false, "enable debug logs to stderr") rootCmd.PersistentFlags().Bool("debug", false, "enable debug logs to stderr")
// add top's filter flags to root so `snitch -l` works // add top's flags to root so `snitch -l` works (defaults to top command)
cfg := config.Get() cfg := config.Get()
rootCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)") rootCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)")
rootCmd.Flags().DurationVarP(&topInterval, "interval", "i", 0, "Refresh interval (default 1s)") rootCmd.Flags().DurationVarP(&topInterval, "interval", "i", 0, "Refresh interval (default 1s)")
rootCmd.Flags().BoolVarP(&topTCP, "tcp", "t", false, "Show only TCP connections")
rootCmd.Flags().BoolVarP(&topUDP, "udp", "u", false, "Show only UDP connections") // shared filter flags for root command
rootCmd.Flags().BoolVarP(&topListen, "listen", "l", false, "Show only listening sockets") addFilterFlags(rootCmd)
rootCmd.Flags().BoolVarP(&topEstab, "established", "e", false, "Show only established connections")
} }

201
cmd/runtime.go Normal file
View File

@@ -0,0 +1,201 @@
package cmd
import (
"fmt"
"snitch/internal/collector"
"snitch/internal/color"
"strconv"
"strings"
"github.com/spf13/cobra"
)
// Runtime holds the shared state for all commands.
// it handles common filter logic, fetching, and filtering connections.
type Runtime struct {
// filter options built from flags and args
Filters collector.FilterOptions
// filtered connections ready for rendering
Connections []collector.Connection
// common settings
ColorMode string
Numeric bool
}
// shared filter flags - used by all commands
var (
filterTCP bool
filterUDP bool
filterListen bool
filterEstab bool
filterIPv4 bool
filterIPv6 bool
)
// BuildFilters constructs FilterOptions from command args and shortcut flags.
func BuildFilters(args []string) (collector.FilterOptions, error) {
filters, err := ParseFilterArgs(args)
if err != nil {
return filters, err
}
// apply ipv4/ipv6 flags
filters.IPv4 = filterIPv4
filters.IPv6 = filterIPv6
// apply protocol shortcut flags
if filterTCP && !filterUDP {
filters.Proto = "tcp"
} else if filterUDP && !filterTCP {
filters.Proto = "udp"
}
// apply state shortcut flags
if filterListen && !filterEstab {
filters.State = "LISTEN"
} else if filterEstab && !filterListen {
filters.State = "ESTABLISHED"
}
return filters, nil
}
// FetchConnections gets connections from the collector and applies filters.
func FetchConnections(filters collector.FilterOptions) ([]collector.Connection, error) {
connections, err := collector.GetConnections()
if err != nil {
return nil, err
}
return collector.FilterConnections(connections, filters), nil
}
// NewRuntime creates a runtime with fetched and filtered connections.
func NewRuntime(args []string, colorMode string, numeric bool) (*Runtime, error) {
color.Init(colorMode)
filters, err := BuildFilters(args)
if err != nil {
return nil, fmt.Errorf("failed to parse filters: %w", err)
}
connections, err := FetchConnections(filters)
if err != nil {
return nil, fmt.Errorf("failed to fetch connections: %w", err)
}
return &Runtime{
Filters: filters,
Connections: connections,
ColorMode: colorMode,
Numeric: numeric,
}, nil
}
// SortConnections sorts the runtime's connections in place.
func (r *Runtime) SortConnections(opts collector.SortOptions) {
collector.SortConnections(r.Connections, opts)
}
// ParseFilterArgs parses key=value filter arguments.
// exported for testing.
func ParseFilterArgs(args []string) (collector.FilterOptions, error) {
filters := collector.FilterOptions{}
for _, arg := range args {
parts := strings.SplitN(arg, "=", 2)
if len(parts) != 2 {
return filters, fmt.Errorf("invalid filter format: %s (expected key=value)", arg)
}
key, value := parts[0], parts[1]
if err := applyFilter(&filters, key, value); err != nil {
return filters, err
}
}
return filters, nil
}
// applyFilter applies a single key=value filter to FilterOptions.
func applyFilter(filters *collector.FilterOptions, key, value string) error {
switch strings.ToLower(key) {
case "proto":
filters.Proto = value
case "state":
filters.State = value
case "pid":
pid, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid pid value: %s", value)
}
filters.Pid = pid
case "proc":
filters.Proc = value
case "lport":
port, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid lport value: %s", value)
}
filters.Lport = port
case "rport":
port, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid rport value: %s", value)
}
filters.Rport = port
case "user":
uid, err := strconv.Atoi(value)
if err == nil {
filters.UID = uid
} else {
filters.User = value
}
case "laddr":
filters.Laddr = value
case "raddr":
filters.Raddr = value
case "contains":
filters.Contains = value
case "if", "interface":
filters.Interface = value
case "mark":
filters.Mark = value
case "namespace":
filters.Namespace = value
case "inode":
inode, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return fmt.Errorf("invalid inode value: %s", value)
}
filters.Inode = inode
case "since":
since, sinceRel, err := collector.ParseTimeFilter(value)
if err != nil {
return fmt.Errorf("invalid since value: %s", value)
}
filters.Since = since
filters.SinceRel = sinceRel
default:
return fmt.Errorf("unknown filter key: %s", key)
}
return nil
}
// FilterFlagsHelp returns the help text for common filter flags.
const FilterFlagsHelp = `
Filters are specified in key=value format. For example:
snitch ls proto=tcp state=established
Available filters:
proto, state, pid, proc, lport, rport, user, laddr, raddr, contains, if, mark, namespace, inode, since`
// addFilterFlags adds the common filter flags to a command.
func addFilterFlags(cmd *cobra.Command) {
cmd.Flags().BoolVarP(&filterTCP, "tcp", "t", false, "Show only TCP connections")
cmd.Flags().BoolVarP(&filterUDP, "udp", "u", false, "Show only UDP connections")
cmd.Flags().BoolVarP(&filterListen, "listen", "l", false, "Show only listening sockets")
cmd.Flags().BoolVarP(&filterEstab, "established", "e", false, "Show only established connections")
cmd.Flags().BoolVarP(&filterIPv4, "ipv4", "4", false, "Only show IPv4 connections")
cmd.Flags().BoolVarP(&filterIPv6, "ipv6", "6", false, "Only show IPv6 connections")
}

View File

@@ -39,6 +39,7 @@ type InterfaceStats struct {
Count int `json:"count"` Count int `json:"count"`
} }
// stats-specific flags
var ( var (
statsOutputFormat string statsOutputFormat string
statsInterval time.Duration statsInterval time.Duration
@@ -63,12 +64,10 @@ Available filters:
} }
func runStatsCommand(args []string) { func runStatsCommand(args []string) {
filters, err := parseFilters(args) filters, err := BuildFilters(args)
if err != nil { if err != nil {
log.Fatalf("Error parsing filters: %v", err) log.Fatalf("Error parsing filters: %v", err)
} }
filters.IPv4 = ipv4
filters.IPv6 = ipv6
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -121,13 +120,11 @@ func runStatsCommand(args []string) {
} }
func generateStats(filters collector.FilterOptions) (*StatsData, error) { func generateStats(filters collector.FilterOptions) (*StatsData, error) {
connections, err := collector.GetConnections() filteredConnections, err := FetchConnections(filters)
if err != nil { if err != nil {
return nil, err return nil, err
} }
filteredConnections := collector.FilterConnections(connections, filters)
stats := &StatsData{ stats := &StatsData{
Timestamp: time.Now(), Timestamp: time.Now(),
Total: len(filteredConnections), Total: len(filteredConnections),
@@ -291,10 +288,13 @@ func printStatsTable(stats *StatsData, headers bool) {
func init() { func init() {
rootCmd.AddCommand(statsCmd) rootCmd.AddCommand(statsCmd)
// stats-specific flags
statsCmd.Flags().StringVarP(&statsOutputFormat, "output", "o", "table", "Output format (table, json, csv)") statsCmd.Flags().StringVarP(&statsOutputFormat, "output", "o", "table", "Output format (table, json, csv)")
statsCmd.Flags().DurationVarP(&statsInterval, "interval", "i", 0, "Refresh interval (0 = one-shot)") statsCmd.Flags().DurationVarP(&statsInterval, "interval", "i", 0, "Refresh interval (0 = one-shot)")
statsCmd.Flags().IntVarP(&statsCount, "count", "c", 0, "Number of iterations (0 = unlimited)") statsCmd.Flags().IntVarP(&statsCount, "count", "c", 0, "Number of iterations (0 = unlimited)")
statsCmd.Flags().BoolVar(&statsNoHeaders, "no-headers", false, "Omit headers for table/csv output") statsCmd.Flags().BoolVar(&statsNoHeaders, "no-headers", false, "Omit headers for table/csv output")
statsCmd.Flags().BoolVarP(&ipv4, "ipv4", "4", false, "Only show IPv4 connections")
statsCmd.Flags().BoolVarP(&ipv6, "ipv6", "6", false, "Only show IPv6 connections") // shared filter flags
addFilterFlags(statsCmd)
} }

View File

@@ -28,7 +28,7 @@
"uid": 0, "uid": 0,
"proto": "udp", "proto": "udp",
"ipversion": "", "ipversion": "",
"state": "CONNECTED", "state": "LISTEN",
"laddr": "0.0.0.0", "laddr": "0.0.0.0",
"lport": 53, "lport": 53,
"raddr": "", "raddr": "",

View File

@@ -1,4 +1,4 @@
PID PROCESS USER PROTO STATE LADDR LPORT RADDR RPORT PID PROCESS USER PROTO STATE LADDR LPORT RADDR RPORT
1 tcp-server tcp LISTEN 0.0.0.0 http 0 1 tcp-server tcp LISTEN 0.0.0.0 http 0
2 udp-server udp CONNECTED 0.0.0.0 domain 0 2 udp-server udp LISTEN 0.0.0.0 domain 0
3 unix-app unix CONNECTED /tmp/test.sock 0 0 3 unix-app unix CONNECTED /tmp/test.sock 0 0

View File

@@ -7,7 +7,7 @@
"uid": 0, "uid": 0,
"proto": "udp", "proto": "udp",
"ipversion": "", "ipversion": "",
"state": "CONNECTED", "state": "LISTEN",
"laddr": "0.0.0.0", "laddr": "0.0.0.0",
"lport": 53, "lport": 53,
"raddr": "", "raddr": "",

View File

@@ -10,13 +10,10 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// top-specific flags
var ( var (
topTheme string topTheme string
topInterval time.Duration topInterval time.Duration
topTCP bool
topUDP bool
topListen bool
topEstab bool
) )
var topCmd = &cobra.Command{ var topCmd = &cobra.Command{
@@ -36,11 +33,11 @@ var topCmd = &cobra.Command{
} }
// if any filter flag is set, use exclusive mode // if any filter flag is set, use exclusive mode
if topTCP || topUDP || topListen || topEstab { if filterTCP || filterUDP || filterListen || filterEstab {
opts.TCP = topTCP opts.TCP = filterTCP
opts.UDP = topUDP opts.UDP = filterUDP
opts.Listening = topListen opts.Listening = filterListen
opts.Established = topEstab opts.Established = filterEstab
opts.Other = false opts.Other = false
opts.FilterSet = true opts.FilterSet = true
} }
@@ -57,10 +54,11 @@ var topCmd = &cobra.Command{
func init() { func init() {
rootCmd.AddCommand(topCmd) rootCmd.AddCommand(topCmd)
cfg := config.Get() cfg := config.Get()
// top-specific flags
topCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)") topCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)")
topCmd.Flags().DurationVarP(&topInterval, "interval", "i", time.Second, "Refresh interval") topCmd.Flags().DurationVarP(&topInterval, "interval", "i", time.Second, "Refresh interval")
topCmd.Flags().BoolVarP(&topTCP, "tcp", "t", false, "Show only TCP connections")
topCmd.Flags().BoolVarP(&topUDP, "udp", "u", false, "Show only UDP connections") // shared filter flags
topCmd.Flags().BoolVarP(&topListen, "listen", "l", false, "Show only listening sockets") addFilterFlags(topCmd)
topCmd.Flags().BoolVarP(&topEstab, "established", "e", false, "Show only established connections")
} }

View File

@@ -47,12 +47,10 @@ Available filters:
} }
func runTraceCommand(args []string) { func runTraceCommand(args []string) {
filters, err := parseFilters(args) filters, err := BuildFilters(args)
if err != nil { if err != nil {
log.Fatalf("Error parsing filters: %v", err) log.Fatalf("Error parsing filters: %v", err)
} }
filters.IPv4 = ipv4
filters.IPv6 = ipv6
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -222,11 +220,14 @@ func printTraceEventHuman(event TraceEvent) {
func init() { func init() {
rootCmd.AddCommand(traceCmd) rootCmd.AddCommand(traceCmd)
// trace-specific flags
traceCmd.Flags().DurationVarP(&traceInterval, "interval", "i", time.Second, "Polling interval (e.g., 500ms, 2s)") traceCmd.Flags().DurationVarP(&traceInterval, "interval", "i", time.Second, "Polling interval (e.g., 500ms, 2s)")
traceCmd.Flags().IntVarP(&traceCount, "count", "c", 0, "Number of events to capture (0 = unlimited)") traceCmd.Flags().IntVarP(&traceCount, "count", "c", 0, "Number of events to capture (0 = unlimited)")
traceCmd.Flags().StringVarP(&traceOutputFormat, "output", "o", "human", "Output format (human, json)") traceCmd.Flags().StringVarP(&traceOutputFormat, "output", "o", "human", "Output format (human, json)")
traceCmd.Flags().BoolVarP(&traceNumeric, "numeric", "n", false, "Don't resolve hostnames") traceCmd.Flags().BoolVarP(&traceNumeric, "numeric", "n", false, "Don't resolve hostnames")
traceCmd.Flags().BoolVar(&traceTimestamp, "ts", false, "Include timestamp in output") traceCmd.Flags().BoolVar(&traceTimestamp, "ts", false, "Include timestamp in output")
traceCmd.Flags().BoolVarP(&ipv4, "ipv4", "4", false, "Only trace IPv4 connections")
traceCmd.Flags().BoolVarP(&ipv6, "ipv6", "6", false, "Only trace IPv6 connections") // shared filter flags
addFilterFlags(traceCmd)
} }

View File

@@ -7,7 +7,6 @@ import (
"log" "log"
"os" "os"
"os/signal" "os/signal"
"snitch/internal/collector"
"syscall" "syscall"
"time" "time"
@@ -36,17 +35,14 @@ Available filters:
} }
func runWatchCommand(args []string) { func runWatchCommand(args []string) {
filters, err := parseFilters(args) filters, err := BuildFilters(args)
if err != nil { if err != nil {
log.Fatalf("Error parsing filters: %v", err) log.Fatalf("Error parsing filters: %v", err)
} }
filters.IPv4 = ipv4
filters.IPv6 = ipv6
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
// Handle interrupts gracefully
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() { go func() {
@@ -63,18 +59,16 @@ func runWatchCommand(args []string) {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
connections, err := collector.GetConnections() connections, err := FetchConnections(filters)
if err != nil { if err != nil {
log.Printf("Error getting connections: %v", err) log.Printf("Error getting connections: %v", err)
continue continue
} }
filteredConnections := collector.FilterConnections(connections, filters)
frame := map[string]interface{}{ frame := map[string]interface{}{
"timestamp": time.Now().Format(time.RFC3339Nano), "timestamp": time.Now().Format(time.RFC3339Nano),
"connections": filteredConnections, "connections": connections,
"count": len(filteredConnections), "count": len(connections),
} }
jsonOutput, err := json.Marshal(frame) jsonOutput, err := json.Marshal(frame)
@@ -95,8 +89,11 @@ func runWatchCommand(args []string) {
func init() { func init() {
rootCmd.AddCommand(watchCmd) rootCmd.AddCommand(watchCmd)
// watch-specific flags
watchCmd.Flags().DurationVarP(&watchInterval, "interval", "i", time.Second, "Refresh interval (e.g., 500ms, 2s)") watchCmd.Flags().DurationVarP(&watchInterval, "interval", "i", time.Second, "Refresh interval (e.g., 500ms, 2s)")
watchCmd.Flags().IntVarP(&watchCount, "count", "c", 0, "Number of frames to emit (0 = unlimited)") watchCmd.Flags().IntVarP(&watchCount, "count", "c", 0, "Number of frames to emit (0 = unlimited)")
watchCmd.Flags().BoolVarP(&ipv4, "ipv4", "4", false, "Only show IPv4 connections")
watchCmd.Flags().BoolVarP(&ipv6, "ipv6", "6", false, "Only show IPv6 connections") // shared filter flags
addFilterFlags(watchCmd)
} }

36
demo/Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1
# build stage - compile snitch
FROM golang:1.25.0-bookworm AS builder
WORKDIR /src
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o snitch .
# runtime stage - official vhs image has ffmpeg, chromium, ttyd pre-installed
FROM ghcr.io/charmbracelet/vhs
# install only lightweight tools for fake services
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update --allow-releaseinfo-change && apt-get install -y --no-install-recommends \
netcat-openbsd \
procps \
socat \
nginx-light
WORKDIR /app
# copy built binary from builder
COPY --from=builder /src/snitch /app/snitch
# copy demo files
COPY demo/demo.tape /app/demo.tape
COPY demo/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
ENV TERM=xterm-256color
ENV COLORTERM=truecolor
ENTRYPOINT ["/app/entrypoint.sh"]

45
demo/README.md Normal file
View File

@@ -0,0 +1,45 @@
# Demo Recording
This directory contains files for recording the snitch demo GIF in a controlled Docker environment.
## Files
- `Dockerfile` - builds snitch and sets up fake network services
- `demo.tape` - VHS script that records the demo
- `entrypoint.sh` - starts fake services before recording
## Recording the Demo
From the project root:
```bash
# build the demo image
docker build -f demo/Dockerfile -t snitch-demo .
# run and output demo.gif to this directory
docker run --rm -v $(pwd)/demo:/output snitch-demo
```
The resulting `demo.gif` will be saved to this directory.
## Fake Services
The container runs several fake services to demonstrate snitch:
| Service | Port | Protocol |
|---------|------|----------|
| nginx | 80 | TCP |
| web app | 8080 | TCP |
| node | 3000 | TCP |
| postgres| 5432 | TCP |
| redis | 6379 | TCP |
| mongo | 27017| TCP |
| mdns | 5353 | UDP |
| ssdp | 1900 | UDP |
Plus some simulated established connections between services.
## Customizing
Edit `demo.tape` to change what's shown in the demo. See [VHS documentation](https://github.com/charmbracelet/vhs) for available commands.

BIN
demo/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

94
demo/demo.tape Normal file
View File

@@ -0,0 +1,94 @@
Output demo.gif
Set Shell "bash"
Set FontSize 14
Set FontFamily "DejaVu Sans Mono"
Set Width 1400
Set Height 700
Set Theme "Catppuccin Frappe"
Set Padding 15
Set Framerate 24
Set TypingSpeed 30ms
Set PlaybackSpeed 1.5
# force color output
Env TERM "xterm-256color"
Env COLORTERM "truecolor"
Env CLICOLOR "1"
Env CLICOLOR_FORCE "1"
Env FORCE_COLOR "1"
# launch snitch
Type "./snitch top"
Enter
Sleep 1s
# navigate down through connections
Down
Sleep 200ms
Down
Sleep 200ms
Down
Sleep 200ms
Down
Sleep 200ms
Down
Sleep 600ms
# open detail view for selected connection
Enter
Sleep 1.5s
# close detail view
Escape
Sleep 500ms
# search for nginx
Type "/"
Sleep 300ms
Type "nginx"
Sleep 600ms
Enter
Sleep 1.2s
# clear search
Type "/"
Sleep 200ms
Escape
Sleep 500ms
# filter: hide udp, show only tcp
Type "u"
Sleep 800ms
# show only listening connections
Type "e"
Sleep 800ms
Type "o"
Sleep 800ms
# reset to show all
Type "a"
Sleep 800ms
# cycle through sort options
Type "s"
Sleep 500ms
Type "s"
Sleep 500ms
# reverse sort order
Type "S"
Sleep 800ms
# show help screen
Type "?"
Sleep 2s
# close help
Escape
Sleep 500ms
# quit
Type "q"
Sleep 200ms

41
demo/entrypoint.sh Normal file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
# entrypoint script that creates fake network services for demo
set -e
echo "starting demo services..."
# start nginx on port 80
nginx &
sleep 0.5
# start some listening services with socat (stderr silenced)
socat TCP-LISTEN:8080,fork,reuseaddr SYSTEM:"echo HTTP/1.1 200 OK" 2>/dev/null &
socat TCP-LISTEN:3000,fork,reuseaddr SYSTEM:"echo hello" 2>/dev/null &
socat TCP-LISTEN:5432,fork,reuseaddr SYSTEM:"echo postgres" 2>/dev/null &
socat TCP-LISTEN:6379,fork,reuseaddr SYSTEM:"echo redis" 2>/dev/null &
socat TCP-LISTEN:27017,fork,reuseaddr SYSTEM:"echo mongo" 2>/dev/null &
# create some "established" connections by connecting to our own services
sleep 0.5
(while true; do echo "ping" | nc -q 1 localhost 8080 2>/dev/null; sleep 2; done) >/dev/null 2>&1 &
(while true; do echo "ping" | nc -q 1 localhost 3000 2>/dev/null; sleep 2; done) >/dev/null 2>&1 &
(while true; do curl -s http://localhost:80 >/dev/null 2>&1; sleep 3; done) &
# udp listeners
socat UDP-LISTEN:5353,fork,reuseaddr SYSTEM:"echo mdns" 2>/dev/null &
socat UDP-LISTEN:1900,fork,reuseaddr SYSTEM:"echo ssdp" 2>/dev/null &
sleep 1
echo "services started, recording demo..."
# run vhs to record the demo
cd /app
vhs demo.tape
echo "demo recorded, copying output..."
# output will be in /app/demo.gif
cp /app/demo.gif /output/demo.gif 2>/dev/null || echo "output copied"
echo "done!"

View File

@@ -59,17 +59,21 @@
overlays = [ goOverlay ]; overlays = [ goOverlay ];
}; };
in in
let
version = self.shortRev or self.dirtyShortRev or "dev";
in
{ {
default = pkgs.buildGoModule { default = pkgs.buildGoModule {
pname = "snitch"; pname = "snitch";
version = self.shortRev or self.dirtyShortRev or "dev"; inherit version;
src = self; src = self;
vendorHash = "sha256-BNNbA72puV0QSLkAlgn/buJJt7mIlVkbTEBhTXOg8pY="; vendorHash = "sha256-fX3wOqeOgjH7AuWGxPQxJ+wbhp240CW8tiF4rVUUDzk=";
env.CGO_ENABLED = 0; env.CGO_ENABLED = 0;
ldflags = [ ldflags = [
"-s" "-w" "-s" "-w"
"-X snitch/cmd.version=${self.shortRev or "dev"}" "-X snitch/cmd.Version=${version}"
"-X snitch/cmd.commit=${self.shortRev or "unknown"}" "-X snitch/cmd.Commit=${version}"
"-X snitch/cmd.Date=${self.lastModifiedDate or "unknown"}"
]; ];
meta = with pkgs.lib; { meta = with pkgs.lib; {
description = "a friendlier ss/netstat for humans"; description = "a friendlier ss/netstat for humans";
@@ -91,7 +95,7 @@
in in
{ {
default = pkgs.mkShell { default = pkgs.mkShell {
packages = [ pkgs.go_1_25 pkgs.git ]; packages = [ pkgs.go_1_25 pkgs.git pkgs.vhs ];
GOTOOLCHAIN = "local"; GOTOOLCHAIN = "local";
shellHook = '' shellHook = ''
echo "go toolchain: $(go version)" echo "go toolchain: $(go version)"

13
go.mod
View File

@@ -11,10 +11,13 @@ require (
require ( require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/aymanbagabas/go-udiff v0.3.1 // indirect
github.com/charmbracelet/colorprofile v0.3.2 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect
github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
@@ -43,11 +46,11 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sync v0.15.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.28.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

16
go.sum
View File

@@ -1,15 +1,25 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383 h1:nCaK/2JwS/z7GoS3cIQlNYIC6MMzWLC8zkT6JkGvkn0=
github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -90,8 +100,12 @@ go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -103,6 +117,8 @@ golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=

View File

@@ -246,11 +246,6 @@ func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connecti
raddr = ipv6ToString(info.raddr6) raddr = ipv6ToString(info.raddr6)
} }
state := ""
if info.sock_type == C.SOCK_STREAM {
state = tcpStateToString(int(info.state))
}
if laddr == "0.0.0.0" || laddr == "::" { if laddr == "0.0.0.0" || laddr == "::" {
laddr = "*" laddr = "*"
} }
@@ -258,6 +253,18 @@ func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connecti
raddr = "*" raddr = "*"
} }
state := ""
if info.sock_type == C.SOCK_STREAM {
state = tcpStateToString(int(info.state))
} else if info.sock_type == C.SOCK_DGRAM {
// udp is connectionless - infer state from remote address
if raddr == "*" && int(info.rport) == 0 {
state = "LISTEN"
} else {
state = "ESTABLISHED"
}
}
conn := Connection{ conn := Connection{
TS: time.Now(), TS: time.Now(),
Proto: proto, Proto: proto,

View File

@@ -226,6 +226,13 @@ func parseProcNet(path, proto string, ipVersion int, inodeMap map[int64]*process
inode, _ := strconv.ParseInt(fields[9], 10, 64) inode, _ := strconv.ParseInt(fields[9], 10, 64)
// refine udp state: if unconnected and remote is wildcard, it's listening
if strings.HasPrefix(proto, "udp") && state == "UNCONNECTED" {
if remoteAddr == "*" && remotePort == 0 {
state = "LISTEN"
}
}
conn := Connection{ conn := Connection{
TS: time.Now(), TS: time.Now(),
Proto: proto, Proto: proto,
@@ -277,13 +284,22 @@ func parseState(hexState, proto string) string {
if s, exists := tcpStates[state]; exists { if s, exists := tcpStates[state]; exists {
return s return s
} }
} else {
if state == 0x07 {
return "CLOSE"
}
return "" return ""
} }
// udp states - udp is connectionless so the kernel reuses tcp state values
// with different meanings:
// 0x07 (TCP_CLOSE) = unconnected socket, typically bound and listening
// 0x01 (TCP_ESTABLISHED) = "connected" socket (connect() was called)
udpStates := map[int64]string{
0x01: "ESTABLISHED",
0x07: "UNCONNECTED",
}
if s, exists := udpStates[state]; exists {
return s
}
return "" return ""
} }

View File

@@ -36,7 +36,7 @@ func (f *FilterOptions) IsEmpty() bool {
} }
func (f *FilterOptions) Matches(c Connection) bool { func (f *FilterOptions) Matches(c Connection) bool {
if f.Proto != "" && !strings.EqualFold(c.Proto, f.Proto) { if f.Proto != "" && !matchesProto(c.Proto, f.Proto) {
return false return false
} }
if f.State != "" && !strings.EqualFold(c.State, f.State) { if f.State != "" && !strings.EqualFold(c.State, f.State) {
@@ -104,6 +104,30 @@ func containsIgnoreCase(s, substr string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
} }
// checks if a connection's protocol matches the filter.
// treats "tcp" as matching "tcp" and "tcp6", same for "udp"/"udp6"
func matchesProto(connProto, filterProto string) bool {
connLower := strings.ToLower(connProto)
filterLower := strings.ToLower(filterProto)
// exact match
if connLower == filterLower {
return true
}
// "tcp" matches both "tcp" and "tcp6"
if filterLower == "tcp" && (connLower == "tcp" || connLower == "tcp6") {
return true
}
// "udp" matches both "udp" and "udp6"
if filterLower == "udp" && (connLower == "udp" || connLower == "udp6") {
return true
}
return false
}
func matchesContains(c Connection, query string) bool { func matchesContains(c Connection, query string) bool {
q := strings.ToLower(query) q := strings.ToLower(query)
return containsIgnoreCase(c.Process, q) || return containsIgnoreCase(c.Process, q) ||

View File

@@ -162,7 +162,7 @@ func getDefaultTestConnections() []Connection {
UID: 25, UID: 25,
Proto: "udp", Proto: "udp",
IPVersion: "IPv4", IPVersion: "IPv4",
State: "CONNECTED", State: "LISTEN",
Laddr: "0.0.0.0", Laddr: "0.0.0.0",
Lport: 53, Lport: 53,
Raddr: "*", Raddr: "*",
@@ -358,7 +358,7 @@ func GetTestFixtures() []TestFixture {
PID: 2, PID: 2,
Process: "udp-server", Process: "udp-server",
Proto: "udp", Proto: "udp",
State: "CONNECTED", State: "LISTEN",
Laddr: "0.0.0.0", Laddr: "0.0.0.0",
Lport: 53, Lport: 53,
Interface: "eth0", Interface: "eth0",

View File

@@ -111,16 +111,17 @@ func compareConnections(a, b Connection, field SortField) bool {
func stateOrder(state string) int { func stateOrder(state string) int {
order := map[string]int{ order := map[string]int{
"LISTEN": 0, "LISTEN": 0,
"ESTABLISHED": 1, "UNCONNECTED": 1, // udp sockets bound but not connected to a specific peer
"SYN_SENT": 2, "ESTABLISHED": 2,
"SYN_RECV": 3, "SYN_SENT": 3,
"FIN_WAIT1": 4, "SYN_RECV": 4,
"FIN_WAIT2": 5, "FIN_WAIT1": 5,
"TIME_WAIT": 6, "FIN_WAIT2": 6,
"CLOSE_WAIT": 7, "TIME_WAIT": 7,
"LAST_ACK": 8, "CLOSE_WAIT": 8,
"CLOSING": 9, "LAST_ACK": 9,
"CLOSED": 10, "CLOSING": 10,
"CLOSED": 11,
} }
if o, exists := order[strings.ToUpper(state)]; exists { if o, exists := order[strings.ToUpper(state)]; exists {

View File

@@ -14,7 +14,7 @@ func truncate(s string, max int) string {
if max <= 2 { if max <= 2 {
return s[:max] return s[:max]
} }
return s[:max-1] + "…" return s[:max-1] + SymbolEllipsis
} }
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)

View File

@@ -1,7 +1,9 @@
package tui package tui
import ( import (
"fmt"
"snitch/internal/collector" "snitch/internal/collector"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@@ -12,6 +14,11 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleSearchKey(msg) return m.handleSearchKey(msg)
} }
// kill confirmation dialog
if m.showKillConfirm {
return m.handleKillConfirmKey(msg)
}
// detail view only allows closing // detail view only allows closing
if m.showDetail { if m.showDetail {
return m.handleDetailKey(msg) return m.handleDetailKey(msg)
@@ -62,6 +69,25 @@ func (m model) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m model) handleKillConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "y", "Y":
if m.killTarget != nil && m.killTarget.PID > 0 {
pid := m.killTarget.PID
process := m.killTarget.Process
m.showKillConfirm = false
m.killTarget = nil
return m, killProcess(pid, process)
}
m.showKillConfirm = false
m.killTarget = nil
case "n", "N", "esc", "q":
m.showKillConfirm = false
m.killTarget = nil
}
return m, nil
}
func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "q", "ctrl+c": case "q", "ctrl+c":
@@ -135,6 +161,55 @@ func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, m.fetchData() return m, m.fetchData()
case "?": case "?":
m.showHelp = true m.showHelp = true
// watch/monitor process
case "w":
visible := m.visibleConnections()
if m.cursor < len(visible) {
conn := visible[m.cursor]
if conn.PID > 0 {
wasWatched := m.isWatched(conn.PID)
m.toggleWatch(conn.PID)
// count connections for this pid
connCount := 0
for _, c := range m.connections {
if c.PID == conn.PID {
connCount++
}
}
if wasWatched {
m.statusMessage = fmt.Sprintf("unwatched %s (pid %d)", conn.Process, conn.PID)
} else if connCount > 1 {
m.statusMessage = fmt.Sprintf("watching %s (pid %d) - %d connections", conn.Process, conn.PID, connCount)
} else {
m.statusMessage = fmt.Sprintf("watching %s (pid %d)", conn.Process, conn.PID)
}
m.statusExpiry = time.Now().Add(2 * time.Second)
return m, clearStatusAfter(2 * time.Second)
}
}
case "W":
// clear all watched
count := len(m.watchedPIDs)
m.watchedPIDs = make(map[int]bool)
if count > 0 {
m.statusMessage = fmt.Sprintf("cleared %d watched processes", count)
m.statusExpiry = time.Now().Add(2 * time.Second)
return m, clearStatusAfter(2 * time.Second)
}
// kill process
case "K":
visible := m.visibleConnections()
if m.cursor < len(visible) {
conn := visible[m.cursor]
if conn.PID > 0 {
m.killTarget = &conn
m.showKillConfirm = true
}
}
} }
return m, nil return m, nil

View File

@@ -1,7 +1,9 @@
package tui package tui
import ( import (
"fmt"
"snitch/internal/collector" "snitch/internal/collector"
"syscall"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -17,6 +19,15 @@ type errMsg struct {
err error err error
} }
type killResultMsg struct {
pid int
process string
success bool
err error
}
type clearStatusMsg struct{}
func (m model) tick() tea.Cmd { func (m model) tick() tea.Cmd {
return tea.Tick(m.interval, func(t time.Time) tea.Msg { return tea.Tick(m.interval, func(t time.Time) tea.Msg {
return tickMsg(t) return tickMsg(t)
@@ -33,3 +44,40 @@ func (m model) fetchData() tea.Cmd {
} }
} }
func killProcess(pid int, process string) tea.Cmd {
return func() tea.Msg {
if pid <= 0 {
return killResultMsg{
pid: pid,
process: process,
success: false,
err: fmt.Errorf("invalid pid"),
}
}
// send SIGTERM first (graceful shutdown)
err := syscall.Kill(pid, syscall.SIGTERM)
if err != nil {
return killResultMsg{
pid: pid,
process: process,
success: false,
err: err,
}
}
return killResultMsg{
pid: pid,
process: process,
success: true,
err: nil,
}
}
}
func clearStatusAfter(d time.Duration) tea.Cmd {
return tea.Tick(d, func(t time.Time) tea.Msg {
return clearStatusMsg{}
})
}

View File

@@ -1,6 +1,7 @@
package tui package tui
import ( import (
"fmt"
"snitch/internal/collector" "snitch/internal/collector"
"snitch/internal/theme" "snitch/internal/theme"
"time" "time"
@@ -35,6 +36,17 @@ type model struct {
interval time.Duration interval time.Duration
lastRefresh time.Time lastRefresh time.Time
err error err error
// watched processes
watchedPIDs map[int]bool
// kill confirmation
showKillConfirm bool
killTarget *collector.Connection
// status message (temporary feedback)
statusMessage string
statusExpiry time.Time
} }
type Options struct { type Options struct {
@@ -93,6 +105,7 @@ func New(opts Options) model {
theme: theme.GetTheme(opts.Theme), theme: theme.GetTheme(opts.Theme),
interval: interval, interval: interval,
lastRefresh: time.Now(), lastRefresh: time.Now(),
watchedPIDs: make(map[int]bool),
} }
} }
@@ -127,6 +140,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case errMsg: case errMsg:
m.err = msg.err m.err = msg.err
return m, nil return m, nil
case killResultMsg:
if msg.success {
m.statusMessage = fmt.Sprintf("killed %s (pid %d)", msg.process, msg.pid)
} else {
m.statusMessage = fmt.Sprintf("failed to kill pid %d: %v", msg.pid, msg.err)
}
m.statusExpiry = time.Now().Add(3 * time.Second)
return m, tea.Batch(m.fetchData(), clearStatusAfter(3*time.Second))
case clearStatusMsg:
if time.Now().After(m.statusExpiry) {
m.statusMessage = ""
}
return m, nil
} }
return m, nil return m, nil
@@ -142,7 +170,15 @@ func (m model) View() string {
if m.showDetail && m.selected != nil { if m.showDetail && m.selected != nil {
return m.renderDetail() return m.renderDetail()
} }
return m.renderMain()
main := m.renderMain()
// overlay kill confirmation modal on top of main view
if m.showKillConfirm && m.killTarget != nil {
return m.overlayModal(main, m.renderKillModal())
}
return main
} }
func (m *model) applySorting() { func (m *model) applySorting() {
@@ -167,7 +203,8 @@ func (m *model) clampCursor() {
} }
func (m model) visibleConnections() []collector.Connection { func (m model) visibleConnections() []collector.Connection {
var result []collector.Connection var watched []collector.Connection
var unwatched []collector.Connection
for _, c := range m.connections { for _, c := range m.connections {
if !m.matchesFilters(c) { if !m.matchesFilters(c) {
@@ -176,10 +213,15 @@ func (m model) visibleConnections() []collector.Connection {
if m.searchQuery != "" && !m.matchesSearch(c) { if m.searchQuery != "" && !m.matchesSearch(c) {
continue continue
} }
result = append(result, c) if m.isWatched(c.PID) {
watched = append(watched, c)
} else {
unwatched = append(unwatched, c)
}
} }
return result // watched connections appear first
return append(watched, unwatched...)
} }
func (m model) matchesFilters(c collector.Connection) bool { func (m model) matchesFilters(c collector.Connection) bool {
@@ -218,3 +260,25 @@ func (m model) matchesSearch(c collector.Connection) bool {
containsIgnoreCase(c.Proto, m.searchQuery) || containsIgnoreCase(c.Proto, m.searchQuery) ||
containsIgnoreCase(c.State, m.searchQuery) containsIgnoreCase(c.State, m.searchQuery)
} }
func (m model) isWatched(pid int) bool {
if pid <= 0 {
return false
}
return m.watchedPIDs[pid]
}
func (m *model) toggleWatch(pid int) {
if pid <= 0 {
return
}
if m.watchedPIDs[pid] {
delete(m.watchedPIDs, pid)
} else {
m.watchedPIDs[pid] = true
}
}
func (m model) watchedCount() int {
return len(m.watchedPIDs)
}

303
internal/tui/model_test.go Normal file
View File

@@ -0,0 +1,303 @@
package tui
import (
"snitch/internal/collector"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/teatest"
)
func TestTUI_InitialState(t *testing.T) {
m := New(Options{
Theme: "dark",
Interval: time.Second,
})
if m.showTCP != true {
t.Error("expected showTCP to be true by default")
}
if m.showUDP != true {
t.Error("expected showUDP to be true by default")
}
if m.showListening != true {
t.Error("expected showListening to be true by default")
}
if m.showEstablished != true {
t.Error("expected showEstablished to be true by default")
}
}
func TestTUI_FilterOptions(t *testing.T) {
m := New(Options{
Theme: "dark",
Interval: time.Second,
TCP: true,
UDP: false,
FilterSet: true,
})
if m.showTCP != true {
t.Error("expected showTCP to be true")
}
if m.showUDP != false {
t.Error("expected showUDP to be false")
}
}
func TestTUI_MatchesFilters(t *testing.T) {
m := New(Options{
Theme: "dark",
Interval: time.Second,
TCP: true,
UDP: false,
Listening: true,
Established: false,
FilterSet: true,
})
tests := []struct {
name string
conn collector.Connection
expected bool
}{
{
name: "tcp listen matches",
conn: collector.Connection{Proto: "tcp", State: "LISTEN"},
expected: true,
},
{
name: "tcp6 listen matches",
conn: collector.Connection{Proto: "tcp6", State: "LISTEN"},
expected: true,
},
{
name: "udp listen does not match",
conn: collector.Connection{Proto: "udp", State: "LISTEN"},
expected: false,
},
{
name: "tcp established does not match",
conn: collector.Connection{Proto: "tcp", State: "ESTABLISHED"},
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := m.matchesFilters(tc.conn)
if result != tc.expected {
t.Errorf("matchesFilters() = %v, want %v", result, tc.expected)
}
})
}
}
func TestTUI_MatchesSearch(t *testing.T) {
m := New(Options{Theme: "dark"})
m.searchQuery = "firefox"
tests := []struct {
name string
conn collector.Connection
expected bool
}{
{
name: "process name matches",
conn: collector.Connection{Process: "firefox"},
expected: true,
},
{
name: "process name case insensitive",
conn: collector.Connection{Process: "Firefox"},
expected: true,
},
{
name: "no match",
conn: collector.Connection{Process: "chrome"},
expected: false,
},
{
name: "matches in address",
conn: collector.Connection{Raddr: "firefox.com"},
expected: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := m.matchesSearch(tc.conn)
if result != tc.expected {
t.Errorf("matchesSearch() = %v, want %v", result, tc.expected)
}
})
}
}
func TestTUI_KeyBindings(t *testing.T) {
tm := teatest.NewTestModel(t, New(Options{Theme: "dark", Interval: time.Hour}))
// test quit with 'q'
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*3))
}
func TestTUI_ToggleFilters(t *testing.T) {
m := New(Options{Theme: "dark", Interval: time.Hour})
// initial state: all filters on
if m.showTCP != true || m.showUDP != true {
t.Fatal("expected all protocol filters on initially")
}
// toggle TCP with 't'
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}})
m = newModel.(model)
if m.showTCP != false {
t.Error("expected showTCP to be false after toggle")
}
// toggle UDP with 'u'
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}})
m = newModel.(model)
if m.showUDP != false {
t.Error("expected showUDP to be false after toggle")
}
// toggle listening with 'l'
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
m = newModel.(model)
if m.showListening != false {
t.Error("expected showListening to be false after toggle")
}
// toggle established with 'e'
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
m = newModel.(model)
if m.showEstablished != false {
t.Error("expected showEstablished to be false after toggle")
}
}
func TestTUI_HelpToggle(t *testing.T) {
m := New(Options{Theme: "dark", Interval: time.Hour})
if m.showHelp != false {
t.Fatal("expected showHelp to be false initially")
}
// toggle help with '?'
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
m = newModel.(model)
if m.showHelp != true {
t.Error("expected showHelp to be true after toggle")
}
// toggle help off
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
m = newModel.(model)
if m.showHelp != false {
t.Error("expected showHelp to be false after second toggle")
}
}
func TestTUI_CursorNavigation(t *testing.T) {
m := New(Options{Theme: "dark", Interval: time.Hour})
// add some test data
m.connections = []collector.Connection{
{PID: 1, Process: "proc1", Proto: "tcp", State: "LISTEN"},
{PID: 2, Process: "proc2", Proto: "tcp", State: "LISTEN"},
{PID: 3, Process: "proc3", Proto: "tcp", State: "LISTEN"},
}
if m.cursor != 0 {
t.Fatal("expected cursor at 0 initially")
}
// move down with 'j'
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
m = newModel.(model)
if m.cursor != 1 {
t.Errorf("expected cursor at 1 after down, got %d", m.cursor)
}
// move down again
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
m = newModel.(model)
if m.cursor != 2 {
t.Errorf("expected cursor at 2 after second down, got %d", m.cursor)
}
// move up with 'k'
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
m = newModel.(model)
if m.cursor != 1 {
t.Errorf("expected cursor at 1 after up, got %d", m.cursor)
}
// go to top with 'g'
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
m = newModel.(model)
if m.cursor != 0 {
t.Errorf("expected cursor at 0 after 'g', got %d", m.cursor)
}
// go to bottom with 'G'
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
m = newModel.(model)
if m.cursor != 2 {
t.Errorf("expected cursor at 2 after 'G', got %d", m.cursor)
}
}
func TestTUI_WindowResize(t *testing.T) {
m := New(Options{Theme: "dark", Interval: time.Hour})
newModel, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = newModel.(model)
if m.width != 120 {
t.Errorf("expected width 120, got %d", m.width)
}
if m.height != 40 {
t.Errorf("expected height 40, got %d", m.height)
}
}
func TestTUI_ViewRenders(t *testing.T) {
m := New(Options{Theme: "dark", Interval: time.Hour})
m.width = 120
m.height = 40
m.connections = []collector.Connection{
{PID: 1234, Process: "nginx", Proto: "tcp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 80},
}
// main view should render without panic
view := m.View()
if view == "" {
t.Error("expected non-empty view")
}
// help view
m.showHelp = true
helpView := m.View()
if helpView == "" {
t.Error("expected non-empty help view")
}
}

37
internal/tui/symbols.go Normal file
View File

@@ -0,0 +1,37 @@
package tui
// unicode symbols used throughout the TUI
const (
// indicators
SymbolSelected = string('\u25B8') // black right-pointing small triangle
SymbolWatched = string('\u2605') // black star
SymbolWarning = string('\u26A0') // warning sign
SymbolSuccess = string('\u2713') // check mark
SymbolError = string('\u2717') // ballot x
SymbolBullet = string('\u2022') // bullet
SymbolArrowRight = string('\u2192') // rightwards arrow
SymbolArrowLeft = string('\u2190') // leftwards arrow
SymbolArrowUp = string('\u2191') // upwards arrow
SymbolArrowDown = string('\u2193') // downwards arrow
SymbolRefresh = string('\u21BB') // clockwise open circle arrow
SymbolEllipsis = string('\u2026') // horizontal ellipsis
// box drawing rounded
BoxTopLeft = string('\u256D') // light arc down and right
BoxTopRight = string('\u256E') // light arc down and left
BoxBottomLeft = string('\u2570') // light arc up and right
BoxBottomRight = string('\u256F') // light arc up and left
BoxHorizontal = string('\u2500') // light horizontal
BoxVertical = string('\u2502') // light vertical
// box drawing connectors
BoxTeeDown = string('\u252C') // light down and horizontal
BoxTeeUp = string('\u2534') // light up and horizontal
BoxTeeRight = string('\u251C') // light vertical and right
BoxTeeLeft = string('\u2524') // light vertical and left
BoxCross = string('\u253C') // light vertical and horizontal
// misc
SymbolDash = string('\u2013') // en dash
)

View File

@@ -5,6 +5,8 @@ import (
"snitch/internal/collector" "snitch/internal/collector"
"strings" "strings"
"time" "time"
"github.com/mattn/go-runewidth"
) )
func (m model) renderMain() string { func (m model) renderMain() string {
@@ -31,7 +33,7 @@ func (m model) renderTitle() string {
left := m.theme.Styles.Header.Render("snitch") left := m.theme.Styles.Header.Render("snitch")
ago := time.Since(m.lastRefresh).Round(time.Millisecond * 100) ago := time.Since(m.lastRefresh).Round(time.Millisecond * 100)
right := m.theme.Styles.Normal.Render(fmt.Sprintf("%d/%d connections %s", len(visible), total, formatDuration(ago))) right := m.theme.Styles.Normal.Render(fmt.Sprintf("%d/%d connections %s %s", len(visible), total, SymbolRefresh, formatDuration(ago)))
w := m.safeWidth() w := m.safeWidth()
gap := w - len(stripAnsi(left)) - len(stripAnsi(right)) - 2 gap := w - len(stripAnsi(left)) - len(stripAnsi(right)) - 2
@@ -57,7 +59,7 @@ func (m model) renderFilters() string {
parts = append(parts, m.theme.Styles.Normal.Render("udp")) parts = append(parts, m.theme.Styles.Normal.Render("udp"))
} }
parts = append(parts, m.theme.Styles.Border.Render("│")) parts = append(parts, m.theme.Styles.Border.Render(BoxVertical))
if m.showListening { if m.showListening {
parts = append(parts, m.theme.Styles.Success.Render("listen")) parts = append(parts, m.theme.Styles.Success.Render("listen"))
@@ -80,9 +82,9 @@ func (m model) renderFilters() string {
left := " " + strings.Join(parts, " ") left := " " + strings.Join(parts, " ")
sortLabel := sortFieldLabel(m.sortField) sortLabel := sortFieldLabel(m.sortField)
sortDir := "↑" sortDir := SymbolArrowUp
if m.sortReverse { if m.sortReverse {
sortDir = "↓" sortDir = SymbolArrowDown
} }
var right string var right string
@@ -122,7 +124,7 @@ func (m model) renderSeparator() string {
if w < 1 { if w < 1 {
w = 76 w = 76
} }
line := " " + strings.Repeat("─", w) line := " " + strings.Repeat(BoxHorizontal, w)
return m.theme.Styles.Border.Render(line) + "\n" return m.theme.Styles.Border.Render(line) + "\n"
} }
@@ -157,19 +159,21 @@ func (m model) renderRow(c collector.Connection, selected bool) string {
indicator := " " indicator := " "
if selected { if selected {
indicator = m.theme.Styles.Success.Render(" ") indicator = m.theme.Styles.Success.Render(SymbolSelected + " ")
} else if m.isWatched(c.PID) {
indicator = m.theme.Styles.Watched.Render(SymbolWatched + " ")
} }
process := truncate(c.Process, cols.process) process := truncate(c.Process, cols.process)
if process == "" { if process == "" {
process = "" process = SymbolDash
} }
port := fmt.Sprintf("%d", c.Lport) port := fmt.Sprintf("%d", c.Lport)
proto := c.Proto proto := c.Proto
state := c.State state := c.State
if state == "" { if state == "" {
state = "" state = SymbolDash
} }
local := c.Laddr local := c.Laddr
@@ -200,7 +204,18 @@ func (m model) renderRow(c collector.Connection, selected bool) string {
} }
func (m model) renderStatusLine() string { func (m model) renderStatusLine() string {
left := " " + m.theme.Styles.Normal.Render("t/u proto l/e/o state s sort / search ? help q quit") // show status message if present
if m.statusMessage != "" {
return " " + m.theme.Styles.Warning.Render(m.statusMessage)
}
left := " " + m.theme.Styles.Normal.Render("t/u proto l/e/o state w watch K kill s sort / search ? help q quit")
// show watched count if any
if m.watchedCount() > 0 {
watchedInfo := fmt.Sprintf(" watching: %d", m.watchedCount())
left += m.theme.Styles.Watched.Render(watchedInfo)
}
return left return left
} }
@@ -233,6 +248,12 @@ func (m model) renderHelp() string {
s cycle sort field s cycle sort field
S reverse sort order S reverse sort order
process management
──────────────────
w watch/unwatch process (highlight & track)
W clear all watched processes
K kill process (with confirmation)
other other
───── ─────
/ search / search
@@ -254,7 +275,7 @@ func (m model) renderDetail() string {
b.WriteString("\n") b.WriteString("\n")
b.WriteString(" " + m.theme.Styles.Header.Render("connection details") + "\n") b.WriteString(" " + m.theme.Styles.Header.Render("connection details") + "\n")
b.WriteString(" " + m.theme.Styles.Border.Render(strings.Repeat("─", 40)) + "\n\n") b.WriteString(" " + m.theme.Styles.Border.Render(strings.Repeat(BoxHorizontal, 40)) + "\n\n")
fields := []struct { fields := []struct {
label string label string
@@ -274,7 +295,7 @@ func (m model) renderDetail() string {
for _, f := range fields { for _, f := range fields {
val := f.value val := f.value
if val == "" || val == "0" || val == ":0" { if val == "" || val == "0" || val == ":0" {
val = "" val = SymbolDash
} }
line := fmt.Sprintf(" %-12s %s\n", m.theme.Styles.Header.Render(f.label), val) line := fmt.Sprintf(" %-12s %s\n", m.theme.Styles.Header.Render(f.label), val)
b.WriteString(line) b.WriteString(line)
@@ -286,6 +307,179 @@ func (m model) renderDetail() string {
return b.String() return b.String()
} }
func (m model) renderKillModal() string {
if m.killTarget == nil {
return ""
}
c := m.killTarget
processName := c.Process
if processName == "" {
processName = "(unknown)"
}
// count how many connections this process has
connCount := 0
for _, conn := range m.connections {
if conn.PID == c.PID {
connCount++
}
}
// build modal content
var lines []string
lines = append(lines, "")
lines = append(lines, m.theme.Styles.Error.Render(" "+SymbolWarning+" KILL PROCESS? "))
lines = append(lines, "")
lines = append(lines, fmt.Sprintf(" process: %s", m.theme.Styles.Header.Render(processName)))
lines = append(lines, fmt.Sprintf(" pid: %s", m.theme.Styles.Header.Render(fmt.Sprintf("%d", c.PID))))
lines = append(lines, fmt.Sprintf(" user: %s", c.User))
lines = append(lines, fmt.Sprintf(" conns: %d", connCount))
lines = append(lines, "")
lines = append(lines, m.theme.Styles.Warning.Render(" sends SIGTERM to process"))
if connCount > 1 {
lines = append(lines, m.theme.Styles.Warning.Render(fmt.Sprintf(" will close all %d connections", connCount)))
}
lines = append(lines, "")
lines = append(lines, fmt.Sprintf(" %s confirm %s cancel",
m.theme.Styles.Success.Render("[y]"),
m.theme.Styles.Error.Render("[n]")))
lines = append(lines, "")
return strings.Join(lines, "\n")
}
func (m model) overlayModal(background, modal string) string {
bgLines := strings.Split(background, "\n")
modalLines := strings.Split(modal, "\n")
// find max modal line width using runewidth for proper unicode handling
modalWidth := 0
for _, line := range modalLines {
w := stringWidth(line)
if w > modalWidth {
modalWidth = w
}
}
modalWidth += 4 // padding for box
modalHeight := len(modalLines)
boxWidth := modalWidth + 2 // include border chars │ │
// calculate modal position (centered)
startRow := (m.height - modalHeight) / 2
if startRow < 2 {
startRow = 2
}
startCol := (m.width - boxWidth) / 2
if startCol < 0 {
startCol = 0
}
// build result
result := make([]string, len(bgLines))
copy(result, bgLines)
// ensure we have enough lines
for len(result) < startRow+modalHeight+2 {
result = append(result, strings.Repeat(" ", m.width))
}
// helper to build a line with modal overlay
buildLine := func(bgLine, modalContent string) string {
modalVisibleWidth := stringWidth(modalContent)
endCol := startCol + modalVisibleWidth
leftBg := visibleSubstring(bgLine, 0, startCol)
rightBg := visibleSubstring(bgLine, endCol, m.width)
// pad left side if needed
leftLen := stringWidth(leftBg)
if leftLen < startCol {
leftBg = leftBg + strings.Repeat(" ", startCol-leftLen)
}
return leftBg + modalContent + rightBg
}
// draw top border
borderRow := startRow - 1
if borderRow >= 0 && borderRow < len(result) {
border := m.theme.Styles.Border.Render(BoxTopLeft + strings.Repeat(BoxHorizontal, modalWidth) + BoxTopRight)
result[borderRow] = buildLine(result[borderRow], border)
}
// draw modal content with side borders
for i, line := range modalLines {
row := startRow + i
if row >= 0 && row < len(result) {
content := line
padding := modalWidth - stringWidth(line)
if padding > 0 {
content = line + strings.Repeat(" ", padding)
}
boxedLine := m.theme.Styles.Border.Render(BoxVertical) + content + m.theme.Styles.Border.Render(BoxVertical)
result[row] = buildLine(result[row], boxedLine)
}
}
// draw bottom border
bottomRow := startRow + modalHeight
if bottomRow >= 0 && bottomRow < len(result) {
border := m.theme.Styles.Border.Render(BoxBottomLeft + strings.Repeat(BoxHorizontal, modalWidth) + BoxBottomRight)
result[bottomRow] = buildLine(result[bottomRow], border)
}
return strings.Join(result, "\n")
}
// stringWidth returns the display width of a string excluding ANSI codes
func stringWidth(s string) int {
return runewidth.StringWidth(stripAnsi(s))
}
// visibleSubstring extracts a substring by visible column positions, preserving ANSI codes
func visibleSubstring(s string, start, end int) string {
if start >= end {
return ""
}
var result strings.Builder
visiblePos := 0
inEscape := false
for _, r := range s {
// detect start of ANSI escape sequence
if r == '\x1b' {
inEscape = true
result.WriteRune(r)
continue
}
if inEscape {
result.WriteRune(r)
// end of escape sequence is a letter
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
inEscape = false
}
continue
}
// regular visible character
w := runewidth.RuneWidth(r)
if visiblePos >= start && visiblePos+w <= end {
result.WriteRune(r)
}
visiblePos += w
if visiblePos >= end {
break
}
}
return result.String()
}
func (m model) scrollOffset(pageSize, total int) int { func (m model) scrollOffset(pageSize, total int) int {
if total <= pageSize { if total <= pageSize {
return 0 return 0