refactor: extract common filter logic into shared runtime
This commit is contained in:
23
Makefile
Normal file
23
Makefile
Normal 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
|
||||||
|
|
||||||
@@ -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
137
cmd/ls.go
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
201
cmd/runtime.go
Normal 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")
|
||||||
|
}
|
||||||
|
|
||||||
36
cmd/stats.go
36
cmd/stats.go
@@ -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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
24
cmd/top.go
24
cmd/top.go
@@ -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")
|
|
||||||
}
|
}
|
||||||
11
cmd/trace.go
11
cmd/trace.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
21
cmd/watch.go
21
cmd/watch.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
demo/demo.gif
BIN
demo/demo.gif
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.6 MiB |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user