refactor: extract common filter logic into shared runtime

This commit is contained in:
Karol Broda
2025-12-17 17:33:01 +01:00
parent c543a8a4e9
commit 3ce1ce8aed
13 changed files with 309 additions and 216 deletions

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

@@ -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,15 +39,12 @@ 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
statsCount int statsCount int
statsNoHeaders bool statsNoHeaders bool
statsTCP bool
statsUDP bool
statsListen bool
statsEstab bool
) )
var statsCmd = &cobra.Command{ var statsCmd = &cobra.Command{
@@ -67,24 +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
// apply shortcut flags
if statsTCP && !statsUDP {
filters.Proto = "tcp"
} else if statsUDP && !statsTCP {
filters.Proto = "udp"
}
if statsListen && !statsEstab {
filters.State = "LISTEN"
} else if statsEstab && !statsListen {
filters.State = "ESTABLISHED"
}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -137,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),
@@ -307,16 +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")
// shortcut filters // shared filter flags
statsCmd.Flags().BoolVarP(&statsTCP, "tcp", "t", false, "Show only TCP connections") addFilterFlags(statsCmd)
statsCmd.Flags().BoolVarP(&statsUDP, "udp", "u", false, "Show only UDP connections")
statsCmd.Flags().BoolVarP(&statsListen, "listen", "l", false, "Show only listening sockets")
statsCmd.Flags().BoolVarP(&statsEstab, "established", "e", false, "Show only established connections")
} }

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1,6 +1,3 @@
# VHS tape file for snitch demo
# run with: docker build -f demo/Dockerfile -t snitch-demo . && docker run -v $(pwd)/demo:/output snitch-demo
Output demo.gif Output demo.gif
Set Shell "bash" Set Shell "bash"
@@ -11,7 +8,8 @@ Set Height 700
Set Theme "Catppuccin Frappe" Set Theme "Catppuccin Frappe"
Set Padding 15 Set Padding 15
Set Framerate 24 Set Framerate 24
Set TypingSpeed 40ms Set TypingSpeed 30ms
Set PlaybackSpeed 1.5
# force color output # force color output
Env TERM "xterm-256color" Env TERM "xterm-256color"
@@ -23,77 +21,74 @@ Env FORCE_COLOR "1"
# launch snitch # launch snitch
Type "./snitch top" Type "./snitch top"
Enter Enter
Sleep 2s Sleep 1.5s
# navigate down through connections # navigate down through connections
Down Down
Sleep 400ms Sleep 200ms
Down Down
Sleep 400ms Sleep 200ms
Down Down
Sleep 400ms Sleep 200ms
Down Down
Sleep 400ms Sleep 200ms
Down Down
Sleep 1s Sleep 600ms
# open detail view for selected connection # open detail view for selected connection
Enter Enter
Sleep 2s Sleep 1.5s
# close detail view # close detail view
Escape Escape
Sleep 1s Sleep 500ms
# search for nginx # search for nginx
Type "/" Type "/"
Sleep 500ms Sleep 300ms
Type "nginx" Type "nginx"
Sleep 1s Sleep 600ms
Enter Enter
Sleep 2s Sleep 1.2s
# clear search # clear search
Type "/" Type "/"
Sleep 300ms Sleep 200ms
Escape Escape
Sleep 1s Sleep 500ms
# filter: hide udp, show only tcp # filter: hide udp, show only tcp
Type "u" Type "u"
Sleep 1.5s Sleep 800ms
# show only listening connections # show only listening connections
Type "e" Type "e"
Sleep 1.5s Sleep 800ms
Type "o" Type "o"
Sleep 1.5s Sleep 800ms
# reset to show all # reset to show all
Type "a" Type "a"
Sleep 1.5s Sleep 800ms
# cycle through sort options # cycle through sort options
Type "s" Type "s"
Sleep 1s Sleep 500ms
Type "s" Type "s"
Sleep 1s Sleep 500ms
Type "s"
Sleep 1s
# reverse sort order # reverse sort order
Type "S" Type "S"
Sleep 1.5s Sleep 800ms
# show help screen # show help screen
Type "?" Type "?"
Sleep 3s Sleep 2s
# close help # close help
Escape Escape
Sleep 1s Sleep 500ms
# quit # quit
Type "q" Type "q"
Sleep 300ms Sleep 200ms

View File

@@ -64,7 +64,7 @@
pname = "snitch"; pname = "snitch";
version = self.shortRev or self.dirtyShortRev or "dev"; version = self.shortRev or self.dirtyShortRev or "dev";
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"