4 Commits

Author SHA1 Message Date
Karol Broda
dbc845bb21 fix: use proper go version in module file 2025-12-24 11:15:59 +01:00
Karol Broda
49948de0ed fix: dns resolution taking long and add caching options (#8) 2025-12-24 11:15:58 +01:00
Karol Broda
933da8bad2 feat: introduce theme management and performance improvements (#7) 2025-12-24 11:15:58 +01:00
Karol Broda
9b09ee7c6c ci: update Nix installation and caching actions 2025-12-24 10:39:00 +01:00
32 changed files with 2209 additions and 259 deletions

View File

@@ -44,9 +44,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@v17
- uses: cachix/install-nix-action@v30
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- uses: DeterminateSystems/magic-nix-cache-action@v9
- uses: nix-community/cache-nix-action@v6
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
- name: nix flake check
run: nix flake check

View File

@@ -167,9 +167,20 @@ shortcut flags work on all commands:
-e, --established established connections
-4, --ipv4 ipv4 only
-6, --ipv6 ipv6 only
-n, --numeric no dns resolution
```
## resolution
dns and service name resolution options:
```
--resolve-addrs resolve ip addresses to hostnames (default: true)
--resolve-ports resolve port numbers to service names
--no-cache disable dns caching (force fresh lookups)
```
dns lookups are performed in parallel and cached for performance. use `--no-cache` to bypass the cache for debugging or when addresses change frequently.
for more specific filtering, use `key=value` syntax with `ls`:
```bash
@@ -208,8 +219,19 @@ optional config file at `~/.config/snitch/snitch.toml`:
```toml
[defaults]
numeric = false
theme = "auto"
numeric = false # disable name resolution
dns_cache = true # cache dns lookups (set to false to disable)
theme = "auto" # color theme: auto, dark, light, mono
```
### environment variables
```bash
SNITCH_THEME=dark # set default theme
SNITCH_RESOLVE=0 # disable dns resolution
SNITCH_DNS_CACHE=0 # disable dns caching
SNITCH_NO_COLOR=1 # disable color output
SNITCH_CONFIG=/path/to # custom config file path
```
## requirements

View File

@@ -30,8 +30,6 @@ var (
sortBy string
fields string
colorMode string
resolveAddrs bool
resolvePorts bool
plainOutput bool
)
@@ -400,10 +398,9 @@ func init() {
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().StringVar(&colorMode, "color", cfg.Defaults.Color, "Color mode (auto, always, never)")
lsCmd.Flags().BoolVar(&resolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
lsCmd.Flags().BoolVar(&resolvePorts, "resolve-ports", false, "Resolve port numbers to service names")
lsCmd.Flags().BoolVarP(&plainOutput, "plain", "p", false, "Plain output (parsable, no styling)")
// shared filter flags
// shared flags
addFilterFlags(lsCmd)
addResolutionFlags(lsCmd)
}

View File

@@ -3,11 +3,12 @@ package cmd
import (
"fmt"
"os"
"github.com/karol-broda/snitch/internal/config"
"github.com/karol-broda/snitch/internal/config"
"github.com/spf13/cobra"
)
var (
cfgFile string
)
@@ -42,11 +43,10 @@ func init() {
// add top's flags to root so `snitch -l` works (defaults to top command)
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 (see 'snitch themes')")
rootCmd.Flags().DurationVarP(&topInterval, "interval", "i", 0, "Refresh interval (default 1s)")
rootCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
rootCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names")
// shared filter flags for root command
// shared flags for root command
addFilterFlags(rootCmd)
addResolutionFlags(rootCmd)
}

View File

@@ -4,6 +4,8 @@ import (
"fmt"
"github.com/karol-broda/snitch/internal/collector"
"github.com/karol-broda/snitch/internal/color"
"github.com/karol-broda/snitch/internal/config"
"github.com/karol-broda/snitch/internal/resolver"
"strconv"
"strings"
@@ -11,7 +13,7 @@ import (
)
// Runtime holds the shared state for all commands.
// it handles common filter logic, fetching, and filtering connections.
// it handles common filter logic, fetching, filtering, and resolution.
type Runtime struct {
// filter options built from flags and args
Filters collector.FilterOptions
@@ -23,6 +25,7 @@ type Runtime struct {
ColorMode string
ResolveAddrs bool
ResolvePorts bool
NoCache bool
}
// shared filter flags - used by all commands
@@ -35,6 +38,13 @@ var (
filterIPv6 bool
)
// shared resolution flags - used by all commands
var (
resolveAddrs bool
resolvePorts bool
noCache bool
)
// BuildFilters constructs FilterOptions from command args and shortcut flags.
func BuildFilters(args []string) (collector.FilterOptions, error) {
filters, err := ParseFilterArgs(args)
@@ -77,6 +87,12 @@ func FetchConnections(filters collector.FilterOptions) ([]collector.Connection,
func NewRuntime(args []string, colorMode string) (*Runtime, error) {
color.Init(colorMode)
cfg := config.Get()
// configure resolver with cache setting (flag overrides config)
effectiveNoCache := noCache || !cfg.Defaults.DNSCache
resolver.SetNoCache(effectiveNoCache)
filters, err := BuildFilters(args)
if err != nil {
return nil, fmt.Errorf("failed to parse filters: %w", err)
@@ -87,13 +103,30 @@ func NewRuntime(args []string, colorMode string) (*Runtime, error) {
return nil, fmt.Errorf("failed to fetch connections: %w", err)
}
return &Runtime{
rt := &Runtime{
Filters: filters,
Connections: connections,
ColorMode: colorMode,
ResolveAddrs: resolveAddrs,
ResolvePorts: resolvePorts,
}, nil
NoCache: effectiveNoCache,
}
// pre-warm dns cache by resolving all addresses in parallel
if resolveAddrs {
rt.PreWarmDNS()
}
return rt, nil
}
// PreWarmDNS resolves all connection addresses in parallel to warm the cache.
func (r *Runtime) PreWarmDNS() {
addrs := make([]string, 0, len(r.Connections)*2)
for _, c := range r.Connections {
addrs = append(addrs, c.Laddr, c.Raddr)
}
resolver.ResolveAddrsParallel(addrs)
}
// SortConnections sorts the runtime's connections in place.
@@ -201,3 +234,11 @@ func addFilterFlags(cmd *cobra.Command) {
cmd.Flags().BoolVarP(&filterIPv6, "ipv6", "6", false, "Only show IPv6 connections")
}
// addResolutionFlags adds the common resolution flags to a command.
func addResolutionFlags(cmd *cobra.Command) {
cfg := config.Get()
cmd.Flags().BoolVar(&resolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
cmd.Flags().BoolVar(&resolvePorts, "resolve-ports", false, "Resolve port numbers to service names")
cmd.Flags().BoolVar(&noCache, "no-cache", !cfg.Defaults.DNSCache, "Disable DNS caching (force fresh lookups)")
}

525
cmd/runtime_test.go Normal file
View File

@@ -0,0 +1,525 @@
package cmd
import (
"testing"
"github.com/karol-broda/snitch/internal/collector"
)
func TestParseFilterArgs_Empty(t *testing.T) {
filters, err := ParseFilterArgs([]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Proto != "" {
t.Errorf("expected empty proto, got %q", filters.Proto)
}
}
func TestParseFilterArgs_Proto(t *testing.T) {
filters, err := ParseFilterArgs([]string{"proto=tcp"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Proto != "tcp" {
t.Errorf("expected proto 'tcp', got %q", filters.Proto)
}
}
func TestParseFilterArgs_State(t *testing.T) {
filters, err := ParseFilterArgs([]string{"state=LISTEN"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.State != "LISTEN" {
t.Errorf("expected state 'LISTEN', got %q", filters.State)
}
}
func TestParseFilterArgs_PID(t *testing.T) {
filters, err := ParseFilterArgs([]string{"pid=1234"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Pid != 1234 {
t.Errorf("expected pid 1234, got %d", filters.Pid)
}
}
func TestParseFilterArgs_InvalidPID(t *testing.T) {
_, err := ParseFilterArgs([]string{"pid=notanumber"})
if err == nil {
t.Error("expected error for invalid pid")
}
}
func TestParseFilterArgs_Proc(t *testing.T) {
filters, err := ParseFilterArgs([]string{"proc=nginx"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Proc != "nginx" {
t.Errorf("expected proc 'nginx', got %q", filters.Proc)
}
}
func TestParseFilterArgs_Lport(t *testing.T) {
filters, err := ParseFilterArgs([]string{"lport=80"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Lport != 80 {
t.Errorf("expected lport 80, got %d", filters.Lport)
}
}
func TestParseFilterArgs_InvalidLport(t *testing.T) {
_, err := ParseFilterArgs([]string{"lport=notaport"})
if err == nil {
t.Error("expected error for invalid lport")
}
}
func TestParseFilterArgs_Rport(t *testing.T) {
filters, err := ParseFilterArgs([]string{"rport=443"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Rport != 443 {
t.Errorf("expected rport 443, got %d", filters.Rport)
}
}
func TestParseFilterArgs_InvalidRport(t *testing.T) {
_, err := ParseFilterArgs([]string{"rport=invalid"})
if err == nil {
t.Error("expected error for invalid rport")
}
}
func TestParseFilterArgs_UserByName(t *testing.T) {
filters, err := ParseFilterArgs([]string{"user=root"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.User != "root" {
t.Errorf("expected user 'root', got %q", filters.User)
}
}
func TestParseFilterArgs_UserByUID(t *testing.T) {
filters, err := ParseFilterArgs([]string{"user=1000"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.UID != 1000 {
t.Errorf("expected uid 1000, got %d", filters.UID)
}
}
func TestParseFilterArgs_Laddr(t *testing.T) {
filters, err := ParseFilterArgs([]string{"laddr=127.0.0.1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Laddr != "127.0.0.1" {
t.Errorf("expected laddr '127.0.0.1', got %q", filters.Laddr)
}
}
func TestParseFilterArgs_Raddr(t *testing.T) {
filters, err := ParseFilterArgs([]string{"raddr=8.8.8.8"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Raddr != "8.8.8.8" {
t.Errorf("expected raddr '8.8.8.8', got %q", filters.Raddr)
}
}
func TestParseFilterArgs_Contains(t *testing.T) {
filters, err := ParseFilterArgs([]string{"contains=google"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Contains != "google" {
t.Errorf("expected contains 'google', got %q", filters.Contains)
}
}
func TestParseFilterArgs_Interface(t *testing.T) {
filters, err := ParseFilterArgs([]string{"if=eth0"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Interface != "eth0" {
t.Errorf("expected interface 'eth0', got %q", filters.Interface)
}
// test alternative syntax
filters2, err := ParseFilterArgs([]string{"interface=lo"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters2.Interface != "lo" {
t.Errorf("expected interface 'lo', got %q", filters2.Interface)
}
}
func TestParseFilterArgs_Mark(t *testing.T) {
filters, err := ParseFilterArgs([]string{"mark=0x1234"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Mark != "0x1234" {
t.Errorf("expected mark '0x1234', got %q", filters.Mark)
}
}
func TestParseFilterArgs_Namespace(t *testing.T) {
filters, err := ParseFilterArgs([]string{"namespace=default"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Namespace != "default" {
t.Errorf("expected namespace 'default', got %q", filters.Namespace)
}
}
func TestParseFilterArgs_Inode(t *testing.T) {
filters, err := ParseFilterArgs([]string{"inode=123456"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Inode != 123456 {
t.Errorf("expected inode 123456, got %d", filters.Inode)
}
}
func TestParseFilterArgs_InvalidInode(t *testing.T) {
_, err := ParseFilterArgs([]string{"inode=notanumber"})
if err == nil {
t.Error("expected error for invalid inode")
}
}
func TestParseFilterArgs_Multiple(t *testing.T) {
filters, err := ParseFilterArgs([]string{"proto=tcp", "state=LISTEN", "lport=80"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Proto != "tcp" {
t.Errorf("expected proto 'tcp', got %q", filters.Proto)
}
if filters.State != "LISTEN" {
t.Errorf("expected state 'LISTEN', got %q", filters.State)
}
if filters.Lport != 80 {
t.Errorf("expected lport 80, got %d", filters.Lport)
}
}
func TestParseFilterArgs_InvalidFormat(t *testing.T) {
_, err := ParseFilterArgs([]string{"invalidformat"})
if err == nil {
t.Error("expected error for invalid format")
}
}
func TestParseFilterArgs_UnknownKey(t *testing.T) {
_, err := ParseFilterArgs([]string{"unknownkey=value"})
if err == nil {
t.Error("expected error for unknown key")
}
}
func TestParseFilterArgs_CaseInsensitiveKeys(t *testing.T) {
filters, err := ParseFilterArgs([]string{"PROTO=tcp", "State=LISTEN"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Proto != "tcp" {
t.Errorf("expected proto 'tcp', got %q", filters.Proto)
}
if filters.State != "LISTEN" {
t.Errorf("expected state 'LISTEN', got %q", filters.State)
}
}
func TestBuildFilters_TCPOnly(t *testing.T) {
// save and restore global flags
oldTCP, oldUDP := filterTCP, filterUDP
defer func() {
filterTCP, filterUDP = oldTCP, oldUDP
}()
filterTCP = true
filterUDP = false
filters, err := BuildFilters([]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Proto != "tcp" {
t.Errorf("expected proto 'tcp', got %q", filters.Proto)
}
}
func TestBuildFilters_UDPOnly(t *testing.T) {
oldTCP, oldUDP := filterTCP, filterUDP
defer func() {
filterTCP, filterUDP = oldTCP, oldUDP
}()
filterTCP = false
filterUDP = true
filters, err := BuildFilters([]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Proto != "udp" {
t.Errorf("expected proto 'udp', got %q", filters.Proto)
}
}
func TestBuildFilters_ListenOnly(t *testing.T) {
oldListen, oldEstab := filterListen, filterEstab
defer func() {
filterListen, filterEstab = oldListen, oldEstab
}()
filterListen = true
filterEstab = false
filters, err := BuildFilters([]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.State != "LISTEN" {
t.Errorf("expected state 'LISTEN', got %q", filters.State)
}
}
func TestBuildFilters_EstablishedOnly(t *testing.T) {
oldListen, oldEstab := filterListen, filterEstab
defer func() {
filterListen, filterEstab = oldListen, oldEstab
}()
filterListen = false
filterEstab = true
filters, err := BuildFilters([]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.State != "ESTABLISHED" {
t.Errorf("expected state 'ESTABLISHED', got %q", filters.State)
}
}
func TestBuildFilters_IPv4Flag(t *testing.T) {
oldIPv4 := filterIPv4
defer func() {
filterIPv4 = oldIPv4
}()
filterIPv4 = true
filters, err := BuildFilters([]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !filters.IPv4 {
t.Error("expected IPv4 to be true")
}
}
func TestBuildFilters_IPv6Flag(t *testing.T) {
oldIPv6 := filterIPv6
defer func() {
filterIPv6 = oldIPv6
}()
filterIPv6 = true
filters, err := BuildFilters([]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !filters.IPv6 {
t.Error("expected IPv6 to be true")
}
}
func TestBuildFilters_CombinedArgsAndFlags(t *testing.T) {
oldTCP := filterTCP
defer func() {
filterTCP = oldTCP
}()
filterTCP = true
filters, err := BuildFilters([]string{"lport=80"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if filters.Proto != "tcp" {
t.Errorf("expected proto 'tcp', got %q", filters.Proto)
}
if filters.Lport != 80 {
t.Errorf("expected lport 80, got %d", filters.Lport)
}
}
func TestRuntime_PreWarmDNS(t *testing.T) {
rt := &Runtime{
Connections: []collector.Connection{
{Laddr: "127.0.0.1", Raddr: "192.168.1.1"},
{Laddr: "127.0.0.1", Raddr: "10.0.0.1"},
},
}
// should not panic
rt.PreWarmDNS()
}
func TestRuntime_PreWarmDNS_Empty(t *testing.T) {
rt := &Runtime{
Connections: []collector.Connection{},
}
// should not panic with empty connections
rt.PreWarmDNS()
}
func TestRuntime_SortConnections(t *testing.T) {
rt := &Runtime{
Connections: []collector.Connection{
{Lport: 443},
{Lport: 80},
{Lport: 8080},
},
}
rt.SortConnections(collector.SortOptions{
Field: collector.SortByLport,
Direction: collector.SortAsc,
})
if rt.Connections[0].Lport != 80 {
t.Errorf("expected first connection to have lport 80, got %d", rt.Connections[0].Lport)
}
if rt.Connections[1].Lport != 443 {
t.Errorf("expected second connection to have lport 443, got %d", rt.Connections[1].Lport)
}
if rt.Connections[2].Lport != 8080 {
t.Errorf("expected third connection to have lport 8080, got %d", rt.Connections[2].Lport)
}
}
func TestRuntime_SortConnections_Desc(t *testing.T) {
rt := &Runtime{
Connections: []collector.Connection{
{Lport: 80},
{Lport: 443},
{Lport: 8080},
},
}
rt.SortConnections(collector.SortOptions{
Field: collector.SortByLport,
Direction: collector.SortDesc,
})
if rt.Connections[0].Lport != 8080 {
t.Errorf("expected first connection to have lport 8080, got %d", rt.Connections[0].Lport)
}
}
func TestApplyFilter_AllKeys(t *testing.T) {
tests := []struct {
key string
value string
validate func(t *testing.T, f *collector.FilterOptions)
}{
{"proto", "tcp", func(t *testing.T, f *collector.FilterOptions) {
if f.Proto != "tcp" {
t.Errorf("proto: expected 'tcp', got %q", f.Proto)
}
}},
{"state", "LISTEN", func(t *testing.T, f *collector.FilterOptions) {
if f.State != "LISTEN" {
t.Errorf("state: expected 'LISTEN', got %q", f.State)
}
}},
{"pid", "100", func(t *testing.T, f *collector.FilterOptions) {
if f.Pid != 100 {
t.Errorf("pid: expected 100, got %d", f.Pid)
}
}},
{"proc", "nginx", func(t *testing.T, f *collector.FilterOptions) {
if f.Proc != "nginx" {
t.Errorf("proc: expected 'nginx', got %q", f.Proc)
}
}},
{"lport", "80", func(t *testing.T, f *collector.FilterOptions) {
if f.Lport != 80 {
t.Errorf("lport: expected 80, got %d", f.Lport)
}
}},
{"rport", "443", func(t *testing.T, f *collector.FilterOptions) {
if f.Rport != 443 {
t.Errorf("rport: expected 443, got %d", f.Rport)
}
}},
{"laddr", "127.0.0.1", func(t *testing.T, f *collector.FilterOptions) {
if f.Laddr != "127.0.0.1" {
t.Errorf("laddr: expected '127.0.0.1', got %q", f.Laddr)
}
}},
{"raddr", "8.8.8.8", func(t *testing.T, f *collector.FilterOptions) {
if f.Raddr != "8.8.8.8" {
t.Errorf("raddr: expected '8.8.8.8', got %q", f.Raddr)
}
}},
{"contains", "test", func(t *testing.T, f *collector.FilterOptions) {
if f.Contains != "test" {
t.Errorf("contains: expected 'test', got %q", f.Contains)
}
}},
{"if", "eth0", func(t *testing.T, f *collector.FilterOptions) {
if f.Interface != "eth0" {
t.Errorf("interface: expected 'eth0', got %q", f.Interface)
}
}},
{"mark", "0xff", func(t *testing.T, f *collector.FilterOptions) {
if f.Mark != "0xff" {
t.Errorf("mark: expected '0xff', got %q", f.Mark)
}
}},
{"namespace", "ns1", func(t *testing.T, f *collector.FilterOptions) {
if f.Namespace != "ns1" {
t.Errorf("namespace: expected 'ns1', got %q", f.Namespace)
}
}},
{"inode", "12345", func(t *testing.T, f *collector.FilterOptions) {
if f.Inode != 12345 {
t.Errorf("inode: expected 12345, got %d", f.Inode)
}
}},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
filters := &collector.FilterOptions{}
err := applyFilter(filters, tt.key, tt.value)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tt.validate(t, filters)
})
}
}

24
cmd/themes.go Normal file
View File

@@ -0,0 +1,24 @@
package cmd
import (
"fmt"
"github.com/karol-broda/snitch/internal/theme"
"github.com/spf13/cobra"
)
var themesCmd = &cobra.Command{
Use: "themes",
Short: "List available themes",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Available themes (default: %s):\n\n", theme.DefaultTheme)
for _, name := range theme.ListThemes() {
fmt.Printf(" %s\n", name)
}
},
}
func init() {
rootCmd.AddCommand(themesCmd)
}

View File

@@ -2,20 +2,19 @@ package cmd
import (
"log"
"github.com/karol-broda/snitch/internal/config"
"github.com/karol-broda/snitch/internal/tui"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/karol-broda/snitch/internal/config"
"github.com/karol-broda/snitch/internal/resolver"
"github.com/karol-broda/snitch/internal/tui"
"github.com/spf13/cobra"
)
// top-specific flags
var (
topTheme string
topInterval time.Duration
topResolveAddrs bool
topResolvePorts bool
topTheme string
topInterval time.Duration
)
var topCmd = &cobra.Command{
@@ -29,11 +28,16 @@ var topCmd = &cobra.Command{
theme = cfg.Defaults.Theme
}
// configure resolver with cache setting
effectiveNoCache := noCache || !cfg.Defaults.DNSCache
resolver.SetNoCache(effectiveNoCache)
opts := tui.Options{
Theme: theme,
Interval: topInterval,
ResolveAddrs: topResolveAddrs,
ResolvePorts: topResolvePorts,
ResolveAddrs: resolveAddrs,
ResolvePorts: resolvePorts,
NoCache: effectiveNoCache,
}
// if any filter flag is set, use exclusive mode
@@ -60,11 +64,10 @@ func init() {
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 (see 'snitch themes')")
topCmd.Flags().DurationVarP(&topInterval, "interval", "i", time.Second, "Refresh interval")
topCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
topCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names")
// shared filter flags
// shared flags
addFilterFlags(topCmd)
addResolutionFlags(topCmd)
}

View File

@@ -7,12 +7,14 @@ import (
"log"
"os"
"os/signal"
"github.com/karol-broda/snitch/internal/collector"
"github.com/karol-broda/snitch/internal/resolver"
"strings"
"syscall"
"time"
"github.com/karol-broda/snitch/internal/collector"
"github.com/karol-broda/snitch/internal/config"
"github.com/karol-broda/snitch/internal/resolver"
"github.com/spf13/cobra"
)
@@ -23,11 +25,10 @@ type TraceEvent struct {
}
var (
traceInterval time.Duration
traceCount int
traceInterval time.Duration
traceCount int
traceOutputFormat string
traceNumeric bool
traceTimestamp bool
traceTimestamp bool
)
var traceCmd = &cobra.Command{
@@ -47,6 +48,12 @@ Available filters:
}
func runTraceCommand(args []string) {
cfg := config.Get()
// configure resolver with cache setting
effectiveNoCache := noCache || !cfg.Defaults.DNSCache
resolver.SetNoCache(effectiveNoCache)
filters, err := BuildFilters(args)
if err != nil {
log.Fatalf("Error parsing filters: %v", err)
@@ -180,14 +187,16 @@ func printTraceEventHuman(event TraceEvent) {
lportStr := fmt.Sprintf("%d", conn.Lport)
rportStr := fmt.Sprintf("%d", conn.Rport)
// Handle name resolution based on numeric flag
if !traceNumeric {
// apply name resolution
if resolveAddrs {
if resolvedLaddr := resolver.ResolveAddr(conn.Laddr); resolvedLaddr != conn.Laddr {
laddr = resolvedLaddr
}
if resolvedRaddr := resolver.ResolveAddr(conn.Raddr); resolvedRaddr != conn.Raddr && conn.Raddr != "*" && conn.Raddr != "" {
raddr = resolvedRaddr
}
}
if resolvePorts {
if resolvedLport := resolver.ResolvePort(conn.Lport, conn.Proto); resolvedLport != fmt.Sprintf("%d", conn.Lport) {
lportStr = resolvedLport
}
@@ -225,9 +234,9 @@ func init() {
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().StringVarP(&traceOutputFormat, "output", "o", "human", "Output format (human, json)")
traceCmd.Flags().BoolVarP(&traceNumeric, "numeric", "n", false, "Don't resolve hostnames")
traceCmd.Flags().BoolVar(&traceTimestamp, "ts", false, "Include timestamp in output")
// shared filter flags
// shared flags
addFilterFlags(traceCmd)
addResolutionFlags(traceCmd)
}

12
go.mod
View File

@@ -1,23 +1,26 @@
module github.com/karol-broda/snitch
go 1.24.0
go 1.25.0
require (
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383
github.com/fatih/color v1.18.0
github.com/mattn/go-runewidth v0.0.16
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.19.0
github.com/tidwall/pretty v1.2.1
golang.org/x/term v0.38.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymanbagabas/go-udiff v0.3.1 // indirect
github.com/charmbracelet/colorprofile v0.3.2 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect
github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
@@ -28,7 +31,6 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
@@ -41,7 +43,6 @@ require (
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/viper v1.19.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/atomic v1.9.0 // indirect
@@ -49,7 +50,6 @@ require (
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

29
go.sum
View File

@@ -4,14 +4,10 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
@@ -25,16 +21,26 @@ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNE
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
@@ -59,9 +65,13 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
@@ -87,6 +97,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
@@ -98,28 +109,22 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -11,21 +11,76 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// set SNITCH_DEBUG_TIMING=1 to enable timing diagnostics
var debugTiming = os.Getenv("SNITCH_DEBUG_TIMING") != ""
func logTiming(label string, start time.Time, extra ...string) {
if !debugTiming {
return
}
elapsed := time.Since(start)
if len(extra) > 0 {
fmt.Fprintf(os.Stderr, "[timing] %s: %v (%s)\n", label, elapsed, extra[0])
} else {
fmt.Fprintf(os.Stderr, "[timing] %s: %v\n", label, elapsed)
}
}
// userCache caches uid to username mappings to avoid repeated lookups
var userCache = struct {
sync.RWMutex
m map[int]string
}{m: make(map[int]string)}
func lookupUsername(uid int) string {
userCache.RLock()
if username, exists := userCache.m[uid]; exists {
userCache.RUnlock()
return username
}
userCache.RUnlock()
start := time.Now()
username := strconv.Itoa(uid)
u, err := user.LookupId(strconv.Itoa(uid))
if err == nil && u != nil {
username = u.Username
}
elapsed := time.Since(start)
if debugTiming && elapsed > 10*time.Millisecond {
fmt.Fprintf(os.Stderr, "[timing] user.LookupId(%d) slow: %v\n", uid, elapsed)
}
userCache.Lock()
userCache.m[uid] = username
userCache.Unlock()
return username
}
// DefaultCollector implements the Collector interface using /proc filesystem
type DefaultCollector struct{}
// GetConnections fetches all network connections by parsing /proc files
func (dc *DefaultCollector) GetConnections() ([]Connection, error) {
totalStart := time.Now()
defer func() { logTiming("GetConnections total", totalStart) }()
inodeStart := time.Now()
inodeMap, err := buildInodeToProcessMap()
logTiming("buildInodeToProcessMap", inodeStart, fmt.Sprintf("%d inodes", len(inodeMap)))
if err != nil {
return nil, fmt.Errorf("failed to build inode map: %w", err)
}
var connections []Connection
parseStart := time.Now()
tcpConns, err := parseProcNet("/proc/net/tcp", "tcp", 4, inodeMap)
if err == nil {
connections = append(connections, tcpConns...)
@@ -45,6 +100,7 @@ func (dc *DefaultCollector) GetConnections() ([]Connection, error) {
if err == nil {
connections = append(connections, udpConns6...)
}
logTiming("parseProcNet (all)", parseStart, fmt.Sprintf("%d connections", len(connections)))
return connections, nil
}
@@ -71,9 +127,13 @@ type processInfo struct {
user string
}
func buildInodeToProcessMap() (map[int64]*processInfo, error) {
inodeMap := make(map[int64]*processInfo)
type inodeEntry struct {
inode int64
info *processInfo
}
func buildInodeToProcessMap() (map[int64]*processInfo, error) {
readDirStart := time.Now()
procDir, err := os.Open("/proc")
if err != nil {
return nil, err
@@ -85,47 +145,103 @@ func buildInodeToProcessMap() (map[int64]*processInfo, error) {
return nil, err
}
// collect pids first
pids := make([]int, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pid, err := strconv.Atoi(entry.Name())
if err != nil {
continue
}
pids = append(pids, pid)
}
logTiming(" readdir /proc", readDirStart, fmt.Sprintf("%d pids", len(pids)))
pidStr := entry.Name()
pid, err := strconv.Atoi(pidStr)
// process pids in parallel with limited concurrency
scanStart := time.Now()
const numWorkers = 8
pidChan := make(chan int, len(pids))
resultChan := make(chan []inodeEntry, len(pids))
var totalFDs atomic.Int64
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for pid := range pidChan {
entries := scanProcessSockets(pid)
if len(entries) > 0 {
totalFDs.Add(int64(len(entries)))
resultChan <- entries
}
}
}()
}
for _, pid := range pids {
pidChan <- pid
}
close(pidChan)
go func() {
wg.Wait()
close(resultChan)
}()
inodeMap := make(map[int64]*processInfo)
for entries := range resultChan {
for _, e := range entries {
inodeMap[e.inode] = e.info
}
}
logTiming(" scan all processes", scanStart, fmt.Sprintf("%d socket fds scanned", totalFDs.Load()))
return inodeMap, nil
}
func scanProcessSockets(pid int) []inodeEntry {
start := time.Now()
procInfo, err := getProcessInfo(pid)
if err != nil {
return nil
}
pidStr := strconv.Itoa(pid)
fdDir := filepath.Join("/proc", pidStr, "fd")
fdEntries, err := os.ReadDir(fdDir)
if err != nil {
return nil
}
var results []inodeEntry
for _, fdEntry := range fdEntries {
fdPath := filepath.Join(fdDir, fdEntry.Name())
link, err := os.Readlink(fdPath)
if err != nil {
continue
}
procInfo, err := getProcessInfo(pid)
if err != nil {
continue
}
fdDir := filepath.Join("/proc", pidStr, "fd")
fdEntries, err := os.ReadDir(fdDir)
if err != nil {
continue
}
for _, fdEntry := range fdEntries {
fdPath := filepath.Join(fdDir, fdEntry.Name())
link, err := os.Readlink(fdPath)
if strings.HasPrefix(link, "socket:[") && strings.HasSuffix(link, "]") {
inodeStr := link[8 : len(link)-1]
inode, err := strconv.ParseInt(inodeStr, 10, 64)
if err != nil {
continue
}
if strings.HasPrefix(link, "socket:[") && strings.HasSuffix(link, "]") {
inodeStr := link[8 : len(link)-1]
inode, err := strconv.ParseInt(inodeStr, 10, 64)
if err != nil {
continue
}
inodeMap[inode] = procInfo
}
results = append(results, inodeEntry{inode: inode, info: procInfo})
}
}
return inodeMap, nil
elapsed := time.Since(start)
if debugTiming && elapsed > 20*time.Millisecond {
fmt.Fprintf(os.Stderr, "[timing] slow process scan: pid=%d (%s) fds=%d time=%v\n",
pid, procInfo.command, len(fdEntries), elapsed)
}
return results
}
func getProcessInfo(pid int) (*processInfo, error) {
@@ -173,12 +289,7 @@ func getProcessInfo(pid int) (*processInfo, error) {
uid, err := strconv.Atoi(fields[1])
if err == nil {
info.uid = uid
u, err := user.LookupId(strconv.Itoa(uid))
if err == nil {
info.user = u.Username
} else {
info.user = strconv.Itoa(uid)
}
info.user = lookupUsername(uid)
}
}
break

View File

@@ -1,7 +1,10 @@
//go:build linux
package collector
import (
"testing"
"time"
)
func TestGetConnections(t *testing.T) {
@@ -13,4 +16,102 @@ func TestGetConnections(t *testing.T) {
// connections are dynamic, so just verify function succeeded
t.Logf("Successfully got %d connections", len(conns))
}
func TestGetConnectionsPerformance(t *testing.T) {
// measures performance to catch regressions
// run with: go test -v -run TestGetConnectionsPerformance
const maxDuration = 500 * time.Millisecond
const iterations = 5
// warm up caches first
_, err := GetConnections()
if err != nil {
t.Fatalf("warmup failed: %v", err)
}
var total time.Duration
var maxSeen time.Duration
for i := 0; i < iterations; i++ {
start := time.Now()
conns, err := GetConnections()
elapsed := time.Since(start)
if err != nil {
t.Fatalf("iteration %d failed: %v", i, err)
}
total += elapsed
if elapsed > maxSeen {
maxSeen = elapsed
}
t.Logf("iteration %d: %v (%d connections)", i+1, elapsed, len(conns))
}
avg := total / time.Duration(iterations)
t.Logf("average: %v, max: %v", avg, maxSeen)
if maxSeen > maxDuration {
t.Errorf("slowest iteration took %v, expected < %v", maxSeen, maxDuration)
}
}
func TestGetConnectionsColdCache(t *testing.T) {
// tests performance with cold user cache
// this simulates first run or after cache invalidation
const maxDuration = 2 * time.Second
clearUserCache()
start := time.Now()
conns, err := GetConnections()
elapsed := time.Since(start)
if err != nil {
t.Fatalf("GetConnections() failed: %v", err)
}
t.Logf("cold cache: %v (%d connections, %d cached users after)",
elapsed, len(conns), userCacheSize())
if elapsed > maxDuration {
t.Errorf("cold cache took %v, expected < %v", elapsed, maxDuration)
}
}
func BenchmarkGetConnections(b *testing.B) {
// warm cache benchmark - measures typical runtime
// run with: go test -bench=BenchmarkGetConnections -benchtime=5s
// warm up
_, _ = GetConnections()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = GetConnections()
}
}
func BenchmarkGetConnectionsColdCache(b *testing.B) {
// cold cache benchmark - measures worst-case with cache cleared each iteration
// run with: go test -bench=BenchmarkGetConnectionsColdCache -benchtime=10s
b.ResetTimer()
for i := 0; i < b.N; i++ {
clearUserCache()
_, _ = GetConnections()
}
}
func BenchmarkBuildInodeMap(b *testing.B) {
// benchmarks just the inode map building (most expensive part)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = buildInodeToProcessMap()
}
}

View File

@@ -0,0 +1,18 @@
//go:build linux
package collector
// clearUserCache clears the user lookup cache for testing
func clearUserCache() {
userCache.Lock()
userCache.m = make(map[int]string)
userCache.Unlock()
}
// userCacheSize returns the number of cached user entries
func userCacheSize() int {
userCache.RLock()
defer userCache.RUnlock()
return len(userCache.m)
}

View File

@@ -4,8 +4,10 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/karol-broda/snitch/internal/theme"
"github.com/spf13/viper"
)
@@ -23,6 +25,7 @@ type DefaultConfig struct {
Units string `mapstructure:"units"`
Color string `mapstructure:"color"`
Resolve bool `mapstructure:"resolve"`
DNSCache bool `mapstructure:"dns_cache"`
IPv4 bool `mapstructure:"ipv4"`
IPv6 bool `mapstructure:"ipv6"`
NoHeaders bool `mapstructure:"no_headers"`
@@ -55,6 +58,7 @@ func Load() (*Config, error) {
// environment variable bindings for readme-documented variables
_ = v.BindEnv("config", "SNITCH_CONFIG")
_ = v.BindEnv("defaults.resolve", "SNITCH_RESOLVE")
_ = v.BindEnv("defaults.dns_cache", "SNITCH_DNS_CACHE")
_ = v.BindEnv("defaults.theme", "SNITCH_THEME")
_ = v.BindEnv("defaults.color", "SNITCH_NO_COLOR")
@@ -88,14 +92,14 @@ func Load() (*Config, error) {
}
func setDefaults(v *viper.Viper) {
// Set default values matching the README specification
v.SetDefault("defaults.interval", "1s")
v.SetDefault("defaults.numeric", false)
v.SetDefault("defaults.fields", []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"})
v.SetDefault("defaults.theme", "auto")
v.SetDefault("defaults.theme", "ansi")
v.SetDefault("defaults.units", "auto")
v.SetDefault("defaults.color", "auto")
v.SetDefault("defaults.resolve", true)
v.SetDefault("defaults.dns_cache", true)
v.SetDefault("defaults.ipv4", false)
v.SetDefault("defaults.ipv6", false)
v.SetDefault("defaults.no_headers", false)
@@ -114,6 +118,11 @@ func handleSpecialEnvVars(v *viper.Viper) {
v.Set("defaults.resolve", false)
v.Set("defaults.numeric", true)
}
// Handle SNITCH_DNS_CACHE - if set to "0", disable dns caching
if os.Getenv("SNITCH_DNS_CACHE") == "0" {
v.Set("defaults.dns_cache", false)
}
}
// Get returns the global configuration, loading it if necessary
@@ -121,16 +130,16 @@ func Get() *Config {
if globalConfig == nil {
config, err := Load()
if err != nil {
// Return default config on error
return &Config{
Defaults: DefaultConfig{
Interval: "1s",
Numeric: false,
Fields: []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"},
Theme: "auto",
Theme: "ansi",
Units: "auto",
Color: "auto",
Resolve: true,
DNSCache: true,
IPv4: false,
IPv6: false,
NoHeaders: false,
@@ -154,7 +163,9 @@ func (c *Config) GetInterval() time.Duration {
// CreateExampleConfig creates an example configuration file
func CreateExampleConfig(path string) error {
exampleConfig := `# snitch configuration file
themeList := strings.Join(theme.ListThemes(), ", ")
exampleConfig := fmt.Sprintf(`# snitch configuration file
# See https://github.com/you/snitch for full documentation
[defaults]
@@ -167,8 +178,9 @@ numeric = false
# Default fields to display (comma-separated list)
fields = ["pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"]
# Default theme for TUI (dark, light, mono, auto)
theme = "auto"
# Default theme for TUI (ansi inherits terminal colors)
# Available: %s
theme = "%s"
# Default units for byte display (auto, si, iec)
units = "auto"
@@ -187,17 +199,17 @@ ipv6 = false
no_headers = false
output_format = "table"
sort_by = ""
`
`, themeList, theme.DefaultTheme)
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
// Write config file
if err := os.WriteFile(path, []byte(exampleConfig), 0644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}

View File

@@ -2,17 +2,22 @@ package resolver
import (
"context"
"fmt"
"net"
"os"
"strconv"
"sync"
"time"
)
var debugTiming = os.Getenv("SNITCH_DEBUG_TIMING") != ""
// Resolver handles DNS and service name resolution with caching and timeouts
type Resolver struct {
timeout time.Duration
cache map[string]string
mutex sync.RWMutex
timeout time.Duration
cache map[string]string
mutex sync.RWMutex
noCache bool
}
// New creates a new resolver with the specified timeout
@@ -20,45 +25,60 @@ func New(timeout time.Duration) *Resolver {
return &Resolver{
timeout: timeout,
cache: make(map[string]string),
noCache: false,
}
}
// SetNoCache disables caching - each lookup will hit DNS directly
func (r *Resolver) SetNoCache(noCache bool) {
r.noCache = noCache
}
// ResolveAddr resolves an IP address to a hostname, with caching
func (r *Resolver) ResolveAddr(addr string) string {
// Check cache first
r.mutex.RLock()
if cached, exists := r.cache[addr]; exists {
// check cache first (unless caching is disabled)
if !r.noCache {
r.mutex.RLock()
if cached, exists := r.cache[addr]; exists {
r.mutex.RUnlock()
return cached
}
r.mutex.RUnlock()
return cached
}
r.mutex.RUnlock()
// Parse IP to validate it
// parse ip to validate it
ip := net.ParseIP(addr)
if ip == nil {
// Not a valid IP, return as-is
return addr
}
// Perform resolution with timeout
// perform resolution with timeout
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
defer cancel()
names, err := net.DefaultResolver.LookupAddr(ctx, addr)
resolved := addr // fallback to original address
resolved := addr
if err == nil && len(names) > 0 {
resolved = names[0]
// Remove trailing dot if present
// remove trailing dot if present
if len(resolved) > 0 && resolved[len(resolved)-1] == '.' {
resolved = resolved[:len(resolved)-1]
}
}
// Cache the result
r.mutex.Lock()
r.cache[addr] = resolved
r.mutex.Unlock()
elapsed := time.Since(start)
if debugTiming && elapsed > 50*time.Millisecond {
fmt.Fprintf(os.Stderr, "[timing] slow DNS lookup: %s -> %s (%v)\n", addr, resolved, elapsed)
}
// cache the result (unless caching is disabled)
if !r.noCache {
r.mutex.Lock()
r.cache[addr] = resolved
r.mutex.Unlock()
}
return resolved
}
@@ -71,15 +91,17 @@ func (r *Resolver) ResolvePort(port int, proto string) string {
cacheKey := strconv.Itoa(port) + "/" + proto
// Check cache first
r.mutex.RLock()
if cached, exists := r.cache[cacheKey]; exists {
// check cache first (unless caching is disabled)
if !r.noCache {
r.mutex.RLock()
if cached, exists := r.cache[cacheKey]; exists {
r.mutex.RUnlock()
return cached
}
r.mutex.RUnlock()
return cached
}
r.mutex.RUnlock()
// Perform resolution with timeout
// perform resolution with timeout
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
defer cancel()
@@ -87,16 +109,18 @@ func (r *Resolver) ResolvePort(port int, proto string) string {
resolved := strconv.Itoa(port) // fallback to port number
if err == nil && service != 0 {
// Try to get service name
// try to get service name
if serviceName := getServiceName(port, proto); serviceName != "" {
resolved = serviceName
}
}
// Cache the result
r.mutex.Lock()
r.cache[cacheKey] = resolved
r.mutex.Unlock()
// cache the result (unless caching is disabled)
if !r.noCache {
r.mutex.Lock()
r.cache[cacheKey] = resolved
r.mutex.Unlock()
}
return resolved
}
@@ -159,22 +183,38 @@ func getServiceName(port int, proto string) string {
return ""
}
// Global resolver instance
// global resolver instance
var globalResolver *Resolver
// SetGlobalResolver sets the global resolver instance
func SetGlobalResolver(timeout time.Duration) {
// ResolverOptions configures the global resolver
type ResolverOptions struct {
Timeout time.Duration
NoCache bool
}
// SetGlobalResolver sets the global resolver instance with options
func SetGlobalResolver(opts ResolverOptions) {
timeout := opts.Timeout
if timeout == 0 {
timeout = 200 * time.Millisecond
}
globalResolver = New(timeout)
globalResolver.SetNoCache(opts.NoCache)
}
// GetGlobalResolver returns the global resolver instance
func GetGlobalResolver() *Resolver {
if globalResolver == nil {
globalResolver = New(200 * time.Millisecond) // Default timeout
globalResolver = New(200 * time.Millisecond)
}
return globalResolver
}
// SetNoCache configures whether the global resolver bypasses cache
func SetNoCache(noCache bool) {
GetGlobalResolver().SetNoCache(noCache)
}
// ResolveAddr is a convenience function using the global resolver
func ResolveAddr(addr string) string {
return GetGlobalResolver().ResolveAddr(addr)
@@ -189,3 +229,48 @@ func ResolvePort(port int, proto string) string {
func ResolveAddrPort(addr string, port int, proto string) (string, string) {
return GetGlobalResolver().ResolveAddrPort(addr, port, proto)
}
// ResolveAddrsParallel resolves multiple addresses concurrently and caches results.
// This should be called before rendering to pre-warm the cache.
func (r *Resolver) ResolveAddrsParallel(addrs []string) {
// dedupe and filter addresses that need resolution
unique := make(map[string]struct{})
for _, addr := range addrs {
if addr == "" || addr == "*" {
continue
}
// skip if already cached
r.mutex.RLock()
_, exists := r.cache[addr]
r.mutex.RUnlock()
if exists {
continue
}
unique[addr] = struct{}{}
}
if len(unique) == 0 {
return
}
var wg sync.WaitGroup
// limit concurrency to avoid overwhelming dns
sem := make(chan struct{}, 32)
for addr := range unique {
wg.Add(1)
go func(a string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
r.ResolveAddr(a)
}(addr)
}
wg.Wait()
}
// ResolveAddrsParallel is a convenience function using the global resolver
func ResolveAddrsParallel(addrs []string) {
GetGlobalResolver().ResolveAddrsParallel(addrs)
}

View File

@@ -0,0 +1,159 @@
package resolver
import (
"fmt"
"testing"
"time"
)
func BenchmarkResolveAddr_CacheHit(b *testing.B) {
r := New(100 * time.Millisecond)
addr := "127.0.0.1"
// pre-populate cache
r.ResolveAddr(addr)
b.ResetTimer()
for i := 0; i < b.N; i++ {
r.ResolveAddr(addr)
}
}
func BenchmarkResolveAddr_CacheMiss(b *testing.B) {
r := New(10 * time.Millisecond) // short timeout for faster benchmarks
b.ResetTimer()
for i := 0; i < b.N; i++ {
// use different addresses to avoid cache hits
addr := fmt.Sprintf("127.0.0.%d", i%256)
r.ClearCache() // clear cache to force miss
r.ResolveAddr(addr)
}
}
func BenchmarkResolveAddr_NoCache(b *testing.B) {
r := New(10 * time.Millisecond)
r.SetNoCache(true)
addr := "127.0.0.1"
b.ResetTimer()
for i := 0; i < b.N; i++ {
r.ResolveAddr(addr)
}
}
func BenchmarkResolvePort_CacheHit(b *testing.B) {
r := New(100 * time.Millisecond)
// pre-populate cache
r.ResolvePort(80, "tcp")
b.ResetTimer()
for i := 0; i < b.N; i++ {
r.ResolvePort(80, "tcp")
}
}
func BenchmarkResolvePort_WellKnown(b *testing.B) {
r := New(100 * time.Millisecond)
b.ResetTimer()
for i := 0; i < b.N; i++ {
r.ClearCache()
r.ResolvePort(443, "tcp")
}
}
func BenchmarkGetServiceName(b *testing.B) {
for i := 0; i < b.N; i++ {
getServiceName(80, "tcp")
}
}
func BenchmarkGetServiceName_NotFound(b *testing.B) {
for i := 0; i < b.N; i++ {
getServiceName(12345, "tcp")
}
}
func BenchmarkResolveAddrsParallel_10(b *testing.B) {
benchmarkResolveAddrsParallel(b, 10)
}
func BenchmarkResolveAddrsParallel_100(b *testing.B) {
benchmarkResolveAddrsParallel(b, 100)
}
func BenchmarkResolveAddrsParallel_1000(b *testing.B) {
benchmarkResolveAddrsParallel(b, 1000)
}
func benchmarkResolveAddrsParallel(b *testing.B, count int) {
addrs := make([]string, count)
for i := 0; i < count; i++ {
addrs[i] = fmt.Sprintf("127.0.%d.%d", i/256, i%256)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
r := New(10 * time.Millisecond)
r.ResolveAddrsParallel(addrs)
}
}
func BenchmarkConcurrentResolveAddr(b *testing.B) {
r := New(100 * time.Millisecond)
addr := "127.0.0.1"
// pre-populate cache
r.ResolveAddr(addr)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
r.ResolveAddr(addr)
}
})
}
func BenchmarkConcurrentResolvePort(b *testing.B) {
r := New(100 * time.Millisecond)
// pre-populate cache
r.ResolvePort(80, "tcp")
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
r.ResolvePort(80, "tcp")
}
})
}
func BenchmarkGetCacheSize(b *testing.B) {
r := New(100 * time.Millisecond)
// populate with some entries
for i := 0; i < 100; i++ {
r.ResolvePort(i+1, "tcp")
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
r.GetCacheSize()
}
}
func BenchmarkClearCache(b *testing.B) {
r := New(100 * time.Millisecond)
b.ResetTimer()
for i := 0; i < b.N; i++ {
// populate and clear
for j := 0; j < 10; j++ {
r.ResolvePort(j+1, "tcp")
}
r.ClearCache()
}
}

View File

@@ -0,0 +1,387 @@
package resolver
import (
"sync"
"testing"
"time"
)
func TestNew(t *testing.T) {
r := New(100 * time.Millisecond)
if r == nil {
t.Fatal("expected non-nil resolver")
}
if r.timeout != 100*time.Millisecond {
t.Errorf("expected timeout 100ms, got %v", r.timeout)
}
if r.cache == nil {
t.Error("expected cache to be initialized")
}
if r.noCache {
t.Error("expected noCache to be false by default")
}
}
func TestSetNoCache(t *testing.T) {
r := New(100 * time.Millisecond)
r.SetNoCache(true)
if !r.noCache {
t.Error("expected noCache to be true")
}
r.SetNoCache(false)
if r.noCache {
t.Error("expected noCache to be false")
}
}
func TestResolveAddr_InvalidIP(t *testing.T) {
r := New(100 * time.Millisecond)
// invalid ip should return as-is
result := r.ResolveAddr("not-an-ip")
if result != "not-an-ip" {
t.Errorf("expected 'not-an-ip', got %q", result)
}
// empty string should return as-is
result = r.ResolveAddr("")
if result != "" {
t.Errorf("expected empty string, got %q", result)
}
}
func TestResolveAddr_Caching(t *testing.T) {
r := New(100 * time.Millisecond)
// first call should cache
addr := "127.0.0.1"
result1 := r.ResolveAddr(addr)
// verify cache is populated
if r.GetCacheSize() != 1 {
t.Errorf("expected cache size 1, got %d", r.GetCacheSize())
}
// second call should use cache
result2 := r.ResolveAddr(addr)
if result1 != result2 {
t.Errorf("expected same result from cache, got %q and %q", result1, result2)
}
}
func TestResolveAddr_NoCacheMode(t *testing.T) {
r := New(100 * time.Millisecond)
r.SetNoCache(true)
addr := "127.0.0.1"
r.ResolveAddr(addr)
// cache should remain empty when noCache is enabled
if r.GetCacheSize() != 0 {
t.Errorf("expected cache size 0 with noCache, got %d", r.GetCacheSize())
}
}
func TestResolvePort_Zero(t *testing.T) {
r := New(100 * time.Millisecond)
result := r.ResolvePort(0, "tcp")
if result != "0" {
t.Errorf("expected '0' for port 0, got %q", result)
}
}
func TestResolvePort_WellKnown(t *testing.T) {
r := New(100 * time.Millisecond)
tests := []struct {
port int
proto string
expected string
}{
{80, "tcp", "http"},
{443, "tcp", "https"},
{22, "tcp", "ssh"},
{53, "udp", "domain"},
{5432, "tcp", "postgresql"},
}
for _, tt := range tests {
result := r.ResolvePort(tt.port, tt.proto)
if result != tt.expected {
t.Errorf("ResolvePort(%d, %q) = %q, want %q", tt.port, tt.proto, result, tt.expected)
}
}
}
func TestResolvePort_Caching(t *testing.T) {
r := New(100 * time.Millisecond)
r.ResolvePort(80, "tcp")
r.ResolvePort(443, "tcp")
if r.GetCacheSize() != 2 {
t.Errorf("expected cache size 2, got %d", r.GetCacheSize())
}
// same port/proto should not add new entry
r.ResolvePort(80, "tcp")
if r.GetCacheSize() != 2 {
t.Errorf("expected cache size still 2, got %d", r.GetCacheSize())
}
}
func TestResolveAddrPort(t *testing.T) {
r := New(100 * time.Millisecond)
addr, port := r.ResolveAddrPort("127.0.0.1", 80, "tcp")
if addr == "" {
t.Error("expected non-empty address")
}
if port != "http" {
t.Errorf("expected port 'http', got %q", port)
}
}
func TestClearCache(t *testing.T) {
r := New(100 * time.Millisecond)
r.ResolveAddr("127.0.0.1")
r.ResolvePort(80, "tcp")
if r.GetCacheSize() == 0 {
t.Error("expected non-empty cache before clear")
}
r.ClearCache()
if r.GetCacheSize() != 0 {
t.Errorf("expected empty cache after clear, got %d", r.GetCacheSize())
}
}
func TestGetCacheSize(t *testing.T) {
r := New(100 * time.Millisecond)
if r.GetCacheSize() != 0 {
t.Errorf("expected initial cache size 0, got %d", r.GetCacheSize())
}
r.ResolveAddr("127.0.0.1")
if r.GetCacheSize() != 1 {
t.Errorf("expected cache size 1, got %d", r.GetCacheSize())
}
}
func TestGetServiceName(t *testing.T) {
tests := []struct {
port int
proto string
expected string
}{
{80, "tcp", "http"},
{443, "tcp", "https"},
{22, "tcp", "ssh"},
{53, "tcp", "domain"},
{53, "udp", "domain"},
{12345, "tcp", ""},
{0, "tcp", ""},
}
for _, tt := range tests {
result := getServiceName(tt.port, tt.proto)
if result != tt.expected {
t.Errorf("getServiceName(%d, %q) = %q, want %q", tt.port, tt.proto, result, tt.expected)
}
}
}
func TestResolveAddrsParallel(t *testing.T) {
r := New(100 * time.Millisecond)
addrs := []string{
"127.0.0.1",
"127.0.0.2",
"127.0.0.3",
"", // should be skipped
"*", // should be skipped
}
r.ResolveAddrsParallel(addrs)
// should have cached 3 addresses (excluding empty and *)
if r.GetCacheSize() != 3 {
t.Errorf("expected cache size 3, got %d", r.GetCacheSize())
}
}
func TestResolveAddrsParallel_Dedupe(t *testing.T) {
r := New(100 * time.Millisecond)
addrs := []string{
"127.0.0.1",
"127.0.0.1",
"127.0.0.1",
"127.0.0.2",
}
r.ResolveAddrsParallel(addrs)
// should have cached 2 unique addresses
if r.GetCacheSize() != 2 {
t.Errorf("expected cache size 2, got %d", r.GetCacheSize())
}
}
func TestResolveAddrsParallel_SkipsCached(t *testing.T) {
r := New(100 * time.Millisecond)
// pre-cache one address
r.ResolveAddr("127.0.0.1")
addrs := []string{
"127.0.0.1", // already cached
"127.0.0.2", // not cached
}
initialSize := r.GetCacheSize()
r.ResolveAddrsParallel(addrs)
// should have added 1 more
if r.GetCacheSize() != initialSize+1 {
t.Errorf("expected cache size %d, got %d", initialSize+1, r.GetCacheSize())
}
}
func TestResolveAddrsParallel_Empty(t *testing.T) {
r := New(100 * time.Millisecond)
// should not panic with empty input
r.ResolveAddrsParallel([]string{})
r.ResolveAddrsParallel(nil)
if r.GetCacheSize() != 0 {
t.Errorf("expected cache size 0, got %d", r.GetCacheSize())
}
}
func TestGlobalResolver(t *testing.T) {
// reset global resolver
globalResolver = nil
r := GetGlobalResolver()
if r == nil {
t.Fatal("expected non-nil global resolver")
}
// should return same instance
r2 := GetGlobalResolver()
if r != r2 {
t.Error("expected same global resolver instance")
}
}
func TestSetGlobalResolver(t *testing.T) {
SetGlobalResolver(ResolverOptions{
Timeout: 500 * time.Millisecond,
NoCache: true,
})
r := GetGlobalResolver()
if r.timeout != 500*time.Millisecond {
t.Errorf("expected timeout 500ms, got %v", r.timeout)
}
if !r.noCache {
t.Error("expected noCache to be true")
}
// reset for other tests
globalResolver = nil
}
func TestSetGlobalResolver_DefaultTimeout(t *testing.T) {
SetGlobalResolver(ResolverOptions{
Timeout: 0, // should use default
})
r := GetGlobalResolver()
if r.timeout != 200*time.Millisecond {
t.Errorf("expected default timeout 200ms, got %v", r.timeout)
}
// reset for other tests
globalResolver = nil
}
func TestGlobalConvenienceFunctions(t *testing.T) {
globalResolver = nil
// test global ResolveAddr
result := ResolveAddr("127.0.0.1")
if result == "" {
t.Error("expected non-empty result from global ResolveAddr")
}
// test global ResolvePort
port := ResolvePort(80, "tcp")
if port != "http" {
t.Errorf("expected 'http', got %q", port)
}
// test global ResolveAddrPort
addr, portStr := ResolveAddrPort("127.0.0.1", 443, "tcp")
if addr == "" {
t.Error("expected non-empty address")
}
if portStr != "https" {
t.Errorf("expected 'https', got %q", portStr)
}
// test global SetNoCache
SetNoCache(true)
if !GetGlobalResolver().noCache {
t.Error("expected global noCache to be true")
}
// reset
globalResolver = nil
}
func TestConcurrentAccess(t *testing.T) {
r := New(100 * time.Millisecond)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
addr := "127.0.0.1"
r.ResolveAddr(addr)
r.ResolvePort(80+n%10, "tcp")
r.GetCacheSize()
}(i)
}
wg.Wait()
// should not panic and cache should have entries
if r.GetCacheSize() == 0 {
t.Error("expected non-empty cache after concurrent access")
}
}
func TestResolveAddr_TrailingDot(t *testing.T) {
// this test verifies the trailing dot removal logic
// by checking the internal logic works correctly
r := New(100 * time.Millisecond)
// localhost should resolve and have trailing dot removed
result := r.ResolveAddr("127.0.0.1")
if len(result) > 0 && result[len(result)-1] == '.' {
t.Error("expected trailing dot to be removed")
}
}

24
internal/theme/ansi.go Normal file
View File

@@ -0,0 +1,24 @@
package theme
// ANSI palette uses standard terminal colors (0-15)
// this allows the theme to inherit from the user's terminal color scheme
var paletteANSI = Palette{
Name: "ansi",
Fg: "15", // bright white
FgMuted: "7", // white
FgSubtle: "8", // bright black (gray)
Bg: "0", // black
BgMuted: "0", // black
Border: "8", // bright black (gray)
Red: "1", // red
Green: "2", // green
Yellow: "3", // yellow
Blue: "4", // blue
Magenta: "5", // magenta
Cyan: "6", // cyan
Orange: "3", // yellow (ansi has no orange, fallback to yellow)
Gray: "8", // bright black
}

View File

@@ -0,0 +1,87 @@
package theme
// catppuccin mocha (dark)
// https://github.com/catppuccin/catppuccin
var paletteCatppuccinMocha = Palette{
Name: "catppuccin-mocha",
Fg: "#cdd6f4", // text
FgMuted: "#a6adc8", // subtext0
FgSubtle: "#6c7086", // overlay0
Bg: "#1e1e2e", // base
BgMuted: "#313244", // surface0
Border: "#45475a", // surface1
Red: "#f38ba8",
Green: "#a6e3a1",
Yellow: "#f9e2af",
Blue: "#89b4fa",
Magenta: "#cba6f7", // mauve
Cyan: "#94e2d5", // teal
Orange: "#fab387", // peach
Gray: "#585b70", // surface2
}
// catppuccin macchiato (medium-dark)
var paletteCatppuccinMacchiato = Palette{
Name: "catppuccin-macchiato",
Fg: "#cad3f5", // text
FgMuted: "#a5adcb", // subtext0
FgSubtle: "#6e738d", // overlay0
Bg: "#24273a", // base
BgMuted: "#363a4f", // surface0
Border: "#494d64", // surface1
Red: "#ed8796",
Green: "#a6da95",
Yellow: "#eed49f",
Blue: "#8aadf4",
Magenta: "#c6a0f6", // mauve
Cyan: "#8bd5ca", // teal
Orange: "#f5a97f", // peach
Gray: "#5b6078", // surface2
}
// catppuccin frappe (medium)
var paletteCatppuccinFrappe = Palette{
Name: "catppuccin-frappe",
Fg: "#c6d0f5", // text
FgMuted: "#a5adce", // subtext0
FgSubtle: "#737994", // overlay0
Bg: "#303446", // base
BgMuted: "#414559", // surface0
Border: "#51576d", // surface1
Red: "#e78284",
Green: "#a6d189",
Yellow: "#e5c890",
Blue: "#8caaee",
Magenta: "#ca9ee6", // mauve
Cyan: "#81c8be", // teal
Orange: "#ef9f76", // peach
Gray: "#626880", // surface2
}
// catppuccin latte (light)
var paletteCatppuccinLatte = Palette{
Name: "catppuccin-latte",
Fg: "#4c4f69", // text
FgMuted: "#6c6f85", // subtext0
FgSubtle: "#9ca0b0", // overlay0
Bg: "#eff1f5", // base
BgMuted: "#ccd0da", // surface0
Border: "#bcc0cc", // surface1
Red: "#d20f39",
Green: "#40a02b",
Yellow: "#df8e1d",
Blue: "#1e66f5",
Magenta: "#8839ef", // mauve
Cyan: "#179299", // teal
Orange: "#fe640b", // peach
Gray: "#acb0be", // surface2
}

24
internal/theme/dracula.go Normal file
View File

@@ -0,0 +1,24 @@
package theme
// dracula theme
// https://draculatheme.com/
var paletteDracula = Palette{
Name: "dracula",
Fg: "#f8f8f2", // foreground
FgMuted: "#f8f8f2", // foreground
FgSubtle: "#6272a4", // comment
Bg: "#282a36", // background
BgMuted: "#44475a", // selection
Border: "#44475a", // selection
Red: "#ff5555",
Green: "#50fa7b",
Yellow: "#f1fa8c",
Blue: "#6272a4", // dracula uses comment color for blue tones
Magenta: "#bd93f9", // purple
Cyan: "#8be9fd",
Orange: "#ffb86c",
Gray: "#6272a4", // comment
}

45
internal/theme/gruvbox.go Normal file
View File

@@ -0,0 +1,45 @@
package theme
// gruvbox dark
// https://github.com/morhetz/gruvbox
var paletteGruvboxDark = Palette{
Name: "gruvbox-dark",
Fg: "#ebdbb2", // fg
FgMuted: "#d5c4a1", // fg2
FgSubtle: "#a89984", // fg4
Bg: "#282828", // bg
BgMuted: "#3c3836", // bg1
Border: "#504945", // bg2
Red: "#fb4934",
Green: "#b8bb26",
Yellow: "#fabd2f",
Blue: "#83a598",
Magenta: "#d3869b", // purple
Cyan: "#8ec07c", // aqua
Orange: "#fe8019",
Gray: "#928374",
}
// gruvbox light
var paletteGruvboxLight = Palette{
Name: "gruvbox-light",
Fg: "#3c3836", // fg
FgMuted: "#504945", // fg2
FgSubtle: "#7c6f64", // fg4
Bg: "#fbf1c7", // bg
BgMuted: "#ebdbb2", // bg1
Border: "#d5c4a1", // bg2
Red: "#cc241d",
Green: "#98971a",
Yellow: "#d79921",
Blue: "#458588",
Magenta: "#b16286", // purple
Cyan: "#689d6a", // aqua
Orange: "#d65d0e",
Gray: "#928374",
}

49
internal/theme/mono.go Normal file
View File

@@ -0,0 +1,49 @@
package theme
import "github.com/charmbracelet/lipgloss"
// createMonoTheme creates a monochrome theme (no colors)
// useful for accessibility, piping output, or minimal terminals
func createMonoTheme() *Theme {
baseStyle := lipgloss.NewStyle()
boldStyle := lipgloss.NewStyle().Bold(true)
return &Theme{
Name: "mono",
Styles: Styles{
Header: boldStyle,
Border: baseStyle,
Selected: boldStyle,
Watched: boldStyle,
Normal: baseStyle,
Error: boldStyle,
Success: boldStyle,
Warning: boldStyle,
Footer: baseStyle,
Background: baseStyle,
Proto: ProtoStyles{
TCP: baseStyle,
UDP: baseStyle,
Unix: baseStyle,
TCP6: baseStyle,
UDP6: baseStyle,
},
State: StateStyles{
Listen: baseStyle,
Established: baseStyle,
TimeWait: baseStyle,
CloseWait: baseStyle,
SynSent: baseStyle,
SynRecv: baseStyle,
FinWait1: baseStyle,
FinWait2: baseStyle,
Closing: baseStyle,
LastAck: baseStyle,
Closed: baseStyle,
},
},
}
}

24
internal/theme/nord.go Normal file
View File

@@ -0,0 +1,24 @@
package theme
// nord theme
// https://www.nordtheme.com/
var paletteNord = Palette{
Name: "nord",
Fg: "#eceff4", // snow storm - nord6
FgMuted: "#d8dee9", // snow storm - nord4
FgSubtle: "#4c566a", // polar night - nord3
Bg: "#2e3440", // polar night - nord0
BgMuted: "#3b4252", // polar night - nord1
Border: "#434c5e", // polar night - nord2
Red: "#bf616a", // aurora - nord11
Green: "#a3be8c", // aurora - nord14
Yellow: "#ebcb8b", // aurora - nord13
Blue: "#81a1c1", // frost - nord9
Magenta: "#b48ead", // aurora - nord15
Cyan: "#88c0d0", // frost - nord8
Orange: "#d08770", // aurora - nord12
Gray: "#4c566a", // polar night - nord3
}

View File

@@ -0,0 +1,24 @@
package theme
// one dark theme (atom editor)
// https://github.com/atom/atom/tree/master/packages/one-dark-syntax
var paletteOneDark = Palette{
Name: "one-dark",
Fg: "#abb2bf", // foreground
FgMuted: "#9da5b4", // foreground muted
FgSubtle: "#5c6370", // comment
Bg: "#282c34", // background
BgMuted: "#21252b", // gutter background
Border: "#3e4451", // selection
Red: "#e06c75",
Green: "#98c379",
Yellow: "#e5c07b",
Blue: "#61afef",
Magenta: "#c678dd", // purple
Cyan: "#56b6c2",
Orange: "#d19a66",
Gray: "#5c6370", // comment
}

111
internal/theme/palette.go Normal file
View File

@@ -0,0 +1,111 @@
package theme
import (
"strconv"
"github.com/charmbracelet/lipgloss"
)
// Palette defines the semantic colors for a theme
type Palette struct {
Name string
// base colors
Fg string // primary foreground
FgMuted string // secondary/muted foreground
FgSubtle string // subtle/disabled foreground
Bg string // primary background
BgMuted string // secondary background (selections, highlights)
Border string // border color
// semantic colors
Red string
Green string
Yellow string
Blue string
Magenta string
Cyan string
Orange string
Gray string
}
// Color converts a palette color string to a lipgloss.TerminalColor.
// If the string is 1-2 characters, it's treated as an ANSI color code.
// Otherwise, it's treated as a hex color.
func (p *Palette) Color(c string) lipgloss.TerminalColor {
if c == "" {
return lipgloss.NoColor{}
}
if len(c) <= 2 {
n, err := strconv.Atoi(c)
if err == nil {
return lipgloss.ANSIColor(n)
}
}
return lipgloss.Color(c)
}
// ToTheme converts a Palette to a Theme with lipgloss styles
func (p *Palette) ToTheme() *Theme {
return &Theme{
Name: p.Name,
Styles: Styles{
Header: lipgloss.NewStyle().
Bold(true).
Foreground(p.Color(p.Fg)),
Border: lipgloss.NewStyle().
Foreground(p.Color(p.Border)),
Selected: lipgloss.NewStyle().
Bold(true).
Foreground(p.Color(p.Fg)),
Watched: lipgloss.NewStyle().
Bold(true).
Foreground(p.Color(p.Orange)),
Normal: lipgloss.NewStyle().
Foreground(p.Color(p.FgMuted)),
Error: lipgloss.NewStyle().
Foreground(p.Color(p.Red)),
Success: lipgloss.NewStyle().
Foreground(p.Color(p.Green)),
Warning: lipgloss.NewStyle().
Foreground(p.Color(p.Yellow)),
Footer: lipgloss.NewStyle().
Foreground(p.Color(p.FgSubtle)),
Background: lipgloss.NewStyle(),
Proto: ProtoStyles{
TCP: lipgloss.NewStyle().Foreground(p.Color(p.Green)),
UDP: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)),
Unix: lipgloss.NewStyle().Foreground(p.Color(p.Gray)),
TCP6: lipgloss.NewStyle().Foreground(p.Color(p.Cyan)),
UDP6: lipgloss.NewStyle().Foreground(p.Color(p.Blue)),
},
State: StateStyles{
Listen: lipgloss.NewStyle().Foreground(p.Color(p.Green)),
Established: lipgloss.NewStyle().Foreground(p.Color(p.Blue)),
TimeWait: lipgloss.NewStyle().Foreground(p.Color(p.Yellow)),
CloseWait: lipgloss.NewStyle().Foreground(p.Color(p.Orange)),
SynSent: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)),
SynRecv: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)),
FinWait1: lipgloss.NewStyle().Foreground(p.Color(p.Red)),
FinWait2: lipgloss.NewStyle().Foreground(p.Color(p.Red)),
Closing: lipgloss.NewStyle().Foreground(p.Color(p.Red)),
LastAck: lipgloss.NewStyle().Foreground(p.Color(p.Red)),
Closed: lipgloss.NewStyle().Foreground(p.Color(p.Gray)),
},
},
}
}

14
internal/theme/readme.md Normal file
View File

@@ -0,0 +1,14 @@
# theme Palettes
the color palettes in this directory were generated by an LLM agent (Claude Opus 4.5) using web search to fetch the official color specifications from each themes documentation
as it is with llm agents its possible the colors may be wrong
Sources:
- [Catppuccin](https://github.com/catppuccin/catppuccin)
- [Dracula](https://draculatheme.com/)
- [Gruvbox](https://github.com/morhetz/gruvbox)
- [Nord](https://www.nordtheme.com/)
- [One Dark](https://github.com/atom/one-dark-syntax)
- [Solarized](https://ethanschoonover.com/solarized/)
- [Tokyo Night](https://github.com/enkia/tokyo-night-vscode-theme)

View File

@@ -0,0 +1,45 @@
package theme
// solarized dark theme
// https://ethanschoonover.com/solarized/
var paletteSolarizedDark = Palette{
Name: "solarized-dark",
Fg: "#839496", // base0
FgMuted: "#93a1a1", // base1
FgSubtle: "#586e75", // base01
Bg: "#002b36", // base03
BgMuted: "#073642", // base02
Border: "#073642", // base02
Red: "#dc322f",
Green: "#859900",
Yellow: "#b58900",
Blue: "#268bd2",
Magenta: "#d33682",
Cyan: "#2aa198",
Orange: "#cb4b16",
Gray: "#657b83", // base00
}
// solarized light theme
var paletteSolarizedLight = Palette{
Name: "solarized-light",
Fg: "#657b83", // base00
FgMuted: "#586e75", // base01
FgSubtle: "#93a1a1", // base1
Bg: "#fdf6e3", // base3
BgMuted: "#eee8d5", // base2
Border: "#eee8d5", // base2
Red: "#dc322f",
Green: "#859900",
Yellow: "#b58900",
Blue: "#268bd2",
Magenta: "#d33682",
Cyan: "#2aa198",
Orange: "#cb4b16",
Gray: "#839496", // base0
}

View File

@@ -1,6 +1,7 @@
package theme
import (
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
@@ -52,152 +53,73 @@ type StateStyles struct {
Closed lipgloss.Style
}
var (
themes map[string]*Theme
)
var themes map[string]*Theme
func init() {
themes = map[string]*Theme{
"default": createAdaptiveTheme(),
"mono": createMonoTheme(),
}
themes = make(map[string]*Theme)
// ansi theme (default) - inherits from terminal colors
themes["ansi"] = paletteANSI.ToTheme()
// catppuccin variants
themes["catppuccin-mocha"] = paletteCatppuccinMocha.ToTheme()
themes["catppuccin-macchiato"] = paletteCatppuccinMacchiato.ToTheme()
themes["catppuccin-frappe"] = paletteCatppuccinFrappe.ToTheme()
themes["catppuccin-latte"] = paletteCatppuccinLatte.ToTheme()
// gruvbox variants
themes["gruvbox-dark"] = paletteGruvboxDark.ToTheme()
themes["gruvbox-light"] = paletteGruvboxLight.ToTheme()
// dracula
themes["dracula"] = paletteDracula.ToTheme()
// nord
themes["nord"] = paletteNord.ToTheme()
// tokyo night variants
themes["tokyo-night"] = paletteTokyoNight.ToTheme()
themes["tokyo-night-storm"] = paletteTokyoNightStorm.ToTheme()
themes["tokyo-night-light"] = paletteTokyoNightLight.ToTheme()
// solarized variants
themes["solarized-dark"] = paletteSolarizedDark.ToTheme()
themes["solarized-light"] = paletteSolarizedLight.ToTheme()
// one dark
themes["one-dark"] = paletteOneDark.ToTheme()
// monochrome (no colors)
themes["mono"] = createMonoTheme()
}
// GetTheme returns a theme by name, with auto-detection support
// DefaultTheme is the theme used when none is specified
const DefaultTheme = "ansi"
// GetTheme returns a theme by name
func GetTheme(name string) *Theme {
if name == "auto" {
// lipgloss handles adaptive colors, so we just return the default
return themes["default"]
if name == "" || name == "auto" || name == "default" {
return themes[DefaultTheme]
}
if theme, exists := themes[name]; exists {
return theme
}
// a specific theme was requested (e.g. "dark", "light"), but we now use adaptive
// so we can just return the default theme and lipgloss will handle it
if name == "dark" || name == "light" {
return themes["default"]
}
// fallback to default
return themes["default"]
return themes[DefaultTheme]
}
// ListThemes returns available theme names
// ListThemes returns available theme names sorted alphabetically
func ListThemes() []string {
var names []string
names := make([]string, 0, len(themes))
for name := range themes {
names = append(names, name)
}
sort.Strings(names)
return names
}
// createAdaptiveTheme creates a clean, minimal theme
func createAdaptiveTheme() *Theme {
return &Theme{
Name: "default",
Styles: Styles{
Header: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#F9FAFB"}),
Watched: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#F59E0B"}),
Border: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#D1D5DB", Dark: "#374151"}),
Selected: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#F9FAFB"}),
Normal: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#6B7280", Dark: "#9CA3AF"}),
Error: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
Success: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}),
Warning: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"}),
Footer: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#9CA3AF", Dark: "#6B7280"}),
Background: lipgloss.NewStyle(),
Proto: ProtoStyles{
TCP: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}),
UDP: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}),
Unix: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#6B7280", Dark: "#9CA3AF"}),
TCP6: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}),
UDP6: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}),
},
State: StateStyles{
Listen: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}),
Established: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#2563EB", Dark: "#60A5FA"}),
TimeWait: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"}),
CloseWait: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"}),
SynSent: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}),
SynRecv: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}),
FinWait1: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
FinWait2: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
Closing: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
LastAck: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
Closed: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9CA3AF", Dark: "#6B7280"}),
},
},
}
}
// createMonoTheme creates a monochrome theme (no colors)
func createMonoTheme() *Theme {
baseStyle := lipgloss.NewStyle()
boldStyle := lipgloss.NewStyle().Bold(true)
return &Theme{
Name: "mono",
Styles: Styles{
Header: boldStyle,
Border: baseStyle,
Selected: boldStyle,
Normal: baseStyle,
Error: boldStyle,
Success: boldStyle,
Warning: boldStyle,
Footer: baseStyle,
Background: baseStyle,
Proto: ProtoStyles{
TCP: baseStyle,
UDP: baseStyle,
Unix: baseStyle,
TCP6: baseStyle,
UDP6: baseStyle,
},
State: StateStyles{
Listen: baseStyle,
Established: baseStyle,
TimeWait: baseStyle,
CloseWait: baseStyle,
SynSent: baseStyle,
SynRecv: baseStyle,
FinWait1: baseStyle,
FinWait2: baseStyle,
Closing: baseStyle,
LastAck: baseStyle,
Closed: baseStyle,
},
},
}
}
// GetProtoStyle returns the appropriate style for a protocol
func (s *Styles) GetProtoStyle(proto string) lipgloss.Style {
switch strings.ToLower(proto) {

View File

@@ -0,0 +1,66 @@
package theme
// tokyo night theme
// https://github.com/enkia/tokyo-night-vscode-theme
var paletteTokyoNight = Palette{
Name: "tokyo-night",
Fg: "#c0caf5", // foreground
FgMuted: "#a9b1d6", // foreground dark
FgSubtle: "#565f89", // comment
Bg: "#1a1b26", // background
BgMuted: "#24283b", // background highlight
Border: "#414868", // border
Red: "#f7768e",
Green: "#9ece6a",
Yellow: "#e0af68",
Blue: "#7aa2f7",
Magenta: "#bb9af7", // purple
Cyan: "#7dcfff",
Orange: "#ff9e64",
Gray: "#565f89", // comment
}
// tokyo night storm variant
var paletteTokyoNightStorm = Palette{
Name: "tokyo-night-storm",
Fg: "#c0caf5", // foreground
FgMuted: "#a9b1d6", // foreground dark
FgSubtle: "#565f89", // comment
Bg: "#24283b", // background (storm is slightly lighter)
BgMuted: "#1f2335", // background dark
Border: "#414868", // border
Red: "#f7768e",
Green: "#9ece6a",
Yellow: "#e0af68",
Blue: "#7aa2f7",
Magenta: "#bb9af7", // purple
Cyan: "#7dcfff",
Orange: "#ff9e64",
Gray: "#565f89", // comment
}
// tokyo night light variant
var paletteTokyoNightLight = Palette{
Name: "tokyo-night-light",
Fg: "#343b58", // foreground
FgMuted: "#565a6e", // foreground dark
FgSubtle: "#9699a3", // comment
Bg: "#d5d6db", // background
BgMuted: "#cbccd1", // background highlight
Border: "#b4b5b9", // border
Red: "#8c4351",
Green: "#485e30",
Yellow: "#8f5e15",
Blue: "#34548a",
Magenta: "#5a4a78", // purple
Cyan: "#0f4b6e",
Orange: "#965027",
Gray: "#9699a3", // comment
}

View File

@@ -3,6 +3,7 @@ package tui
import (
"fmt"
"github.com/karol-broda/snitch/internal/collector"
"github.com/karol-broda/snitch/internal/resolver"
"syscall"
"time"
@@ -35,11 +36,20 @@ func (m model) tick() tea.Cmd {
}
func (m model) fetchData() tea.Cmd {
resolveAddrs := m.resolveAddrs
return func() tea.Msg {
conns, err := collector.GetConnections()
if err != nil {
return errMsg{err}
}
// pre-warm dns cache in parallel if resolution is enabled
if resolveAddrs {
addrs := make([]string, 0, len(conns)*2)
for _, c := range conns {
addrs = append(addrs, c.Laddr, c.Raddr)
}
resolver.ResolveAddrsParallel(addrs)
}
return dataMsg{connections: conns}
}
}

View File

@@ -64,6 +64,7 @@ type Options struct {
FilterSet bool // true if user specified any filter flags
ResolveAddrs bool // when true, resolve IP addresses to hostnames
ResolvePorts bool // when true, resolve port numbers to service names
NoCache bool // when true, disable DNS caching
}
func New(opts Options) model {