17 Commits

Author SHA1 Message Date
Karol Broda
2615fe5871 fix: json command didnt filter 2025-12-21 12:22:01 +01:00
Karol Broda
29891c0bb8 feat: improve empty state 2025-12-21 12:13:50 +01:00
Karol Broda
a93e682aa2 refactor: simplify flake.nix structure and improve go binary derivation 2025-12-21 01:57:32 +01:00
Karol Broda
04aa42a9c9 feat: add install script for automated binary installation 2025-12-20 20:46:41 +01:00
Karol Broda
6e4f6b3d61 build: add aur target 2025-12-20 19:55:14 +01:00
Karol Broda
e99e6c8df7 chore: update readme 2025-12-20 19:41:49 +01:00
Karol Broda
d7cf490ff5 refactor(tui): unicode symbols from a single definition 2025-12-20 19:29:16 +01:00
Karol Broda
99f1d95295 feat(tui): add process features watch and kill 2025-12-20 19:11:58 +01:00
Karol Broda
2c9ce1445f chore(demo): update demo 2025-12-18 08:14:10 +01:00
Karol Broda
3c3656966e fix: correct ldflags capitalization for version info 2025-12-17 17:40:13 +01:00
Karol Broda
3ce1ce8aed refactor: extract common filter logic into shared runtime 2025-12-17 17:33:01 +01:00
Karol Broda
c543a8a4e9 fix: udp handling and ipv4 and ipv6 filtering 2025-12-17 17:15:52 +01:00
Karol Broda
7f2bd068ad chore: separate darwin build configuration into .goreleaser-darwin.yaml and update action 2025-12-17 00:11:13 +01:00
Karol Broda
eee7cfd64d refactor: update socket information handling in darwin collector 2025-12-17 00:02:56 +01:00
Karol Broda
dc235a5807 feat: add darwin support 2025-12-16 23:59:43 +01:00
Karol Broda
9fcc6d47c2 chore: remove main binary 2025-12-16 23:18:05 +01:00
Karol Broda
5f76d5cd76 add nix package and installation docs 2025-12-16 23:12:59 +01:00
43 changed files with 2458 additions and 780 deletions

View File

@@ -9,7 +9,7 @@ permissions:
contents: write contents: write
jobs: jobs:
goreleaser: release-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -20,11 +20,31 @@ jobs:
with: with:
go-version: "1.25.0" go-version: "1.25.0"
- name: run goreleaser - name: release linux
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v6
with: with:
version: "~> v2" version: "~> v2"
args: release --clean args: release --clean
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AUR_KEY: ${{ secrets.AUR_KEY }}
release-darwin:
needs: release-linux
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: "1.25.0"
- name: release darwin
uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean --config .goreleaser-darwin.yaml --skip=validate
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -27,6 +27,9 @@ Thumbs.db
# go # go
vendor/ vendor/
# nix
result
# misc # misc
*.log *.log
*.tmp *.tmp

39
.goreleaser-darwin.yaml Normal file
View File

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

View File

@@ -7,7 +7,8 @@ before:
- go mod tidy - go mod tidy
builds: builds:
- env: - id: linux
env:
- CGO_ENABLED=0 - CGO_ENABLED=0
goos: goos:
- linux - linux
@@ -32,6 +33,9 @@ archives:
{{- .Os }}_ {{- .Os }}_
{{- .Arch }} {{- .Arch }}
{{- if .Arm }}v{{ .Arm }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }}
files:
- LICENSE
- README.md
checksum: checksum:
name_template: "checksums.txt" name_template: "checksums.txt"
@@ -60,10 +64,30 @@ nfpms:
- rpm - rpm
- apk - apk
aurs:
- name: snitch-bin
homepage: https://github.com/karol-broda/snitch
description: a friendlier ss/netstat for humans
maintainers:
- "Karol Broda <me@karolbroda.com>"
license: MIT
private_key: "{{ .Env.AUR_KEY }}"
git_url: "ssh://aur@aur.archlinux.org/snitch-bin.git"
depends:
- glibc
provides:
- snitch
conflicts:
- snitch
package: |-
install -Dm755 "./snitch" "${pkgdir}/usr/bin/snitch"
install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/snitch/LICENSE"
commit_msg_template: "Update to {{ .Tag }}"
skip_upload: auto
release: release:
github: github:
owner: karol-broda owner: karol-broda
name: snitch name: snitch
draft: false draft: false
prerelease: auto prerelease: auto

23
Makefile Normal file
View File

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

View File

@@ -2,12 +2,72 @@
a friendlier `ss` / `netstat` for humans. inspect network connections with a clean tui or styled tables. a friendlier `ss` / `netstat` for humans. inspect network connections with a clean tui or styled tables.
![snitch demo](demo/demo.gif)
## install ## install
### go
```bash ```bash
go install github.com/karol-broda/snitch@latest go install github.com/karol-broda/snitch@latest
``` ```
### nixos / nix
```bash
# try it
nix run github:karol-broda/snitch
# install to profile
nix profile install github:karol-broda/snitch
# or add to flake inputs
{
inputs.snitch.url = "github:karol-broda/snitch";
}
# then use: inputs.snitch.packages.${system}.default
```
### arch linux (aur)
```bash
# with yay
yay -S snitch-bin
# with paru
paru -S snitch-bin
```
### shell script
```bash
curl -sSL https://raw.githubusercontent.com/karol-broda/snitch/master/install.sh | sh
```
installs to `~/.local/bin` if available, otherwise `/usr/local/bin`. override with:
```bash
curl -sSL https://raw.githubusercontent.com/karol-broda/snitch/master/install.sh | INSTALL_DIR=~/bin sh
```
### binary
download from [releases](https://github.com/karol-broda/snitch/releases):
- **linux:** `snitch_<version>_linux_<arch>.tar.gz` or `.deb`/`.rpm`/`.apk`
- **macos:** `snitch_<version>_darwin_<arch>.tar.gz`
```bash
tar xzf snitch_*.tar.gz
sudo mv snitch /usr/local/bin/
```
> **macos:** if blocked with "cannot be opened because the developer cannot be verified", run:
>
> ```bash
> xattr -d com.apple.quarantine /usr/local/bin/snitch
> ```
## quick start ## quick start
```bash ```bash
@@ -41,6 +101,9 @@ g/G top/bottom
t/u toggle tcp/udp t/u toggle tcp/udp
l/e/o toggle listen/established/other l/e/o toggle listen/established/other
s/S cycle sort / reverse s/S cycle sort / reverse
w watch/monitor process (highlight)
W clear all watched
K kill process (with confirmation)
/ search / search
enter connection details enter connection details
? help ? help
@@ -139,5 +202,6 @@ theme = "auto"
## requirements ## requirements
- linux (reads from `/proc/net/*`) - linux or macos
- root or `CAP_NET_ADMIN` for full process info - linux: reads from `/proc/net/*`, root or `CAP_NET_ADMIN` for full process info
- macos: uses system APIs, may require sudo for full process info

View File

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

View File

@@ -15,4 +15,5 @@ var jsonCmd = &cobra.Command{
func init() { func init() {
rootCmd.AddCommand(jsonCmd) rootCmd.AddCommand(jsonCmd)
addFilterFlags(jsonCmd)
} }

137
cmd/ls.go
View File

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

View File

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

View File

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

201
cmd/runtime.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

36
demo/Dockerfile Normal file
View File

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

45
demo/README.md Normal file
View File

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

BIN
demo/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

94
demo/demo.tape Normal file
View File

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

41
demo/entrypoint.sh Normal file
View File

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

24
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1756217674, "lastModified": 1765687488,
"narHash": "sha256-TH1SfSP523QI7kcPiNtMAEuwZR3Jdz0MCDXPs7TS8uo=", "narHash": "sha256-7YAJ6xgBAQ/Nr+7MI13Tui1ULflgAdKh63m1tfYV7+M=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "4e7667a90c167f7a81d906e5a75cba4ad8bee620", "rev": "d02bcc33948ca19b0aaa0213fe987ceec1f4ebe1",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -18,23 +18,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs"
"systems": "systems"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
} }
} }
}, },

147
flake.nix
View File

@@ -1,96 +1,105 @@
{ {
description = "go 1.25.0 dev flake"; description = "snitch - a friendlier ss/netstat for humans";
inputs = { inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
systems.url = "github:nix-systems/default";
};
outputs = { self, nixpkgs, systems }: outputs = { self, nixpkgs }:
let let
supportedSystems = import systems; systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system); eachSystem = nixpkgs.lib.genAttrs systems;
in
{ # go 1.25 binary derivation (required until nixpkgs ships it)
overlays.default = final: prev: mkGo125 = pkgs:
let let
version = "1.25.0"; version = "1.25.0";
platform = {
platformInfo = { "x86_64-linux" = { suffix = "linux-amd64"; hash = "sha256-KFKvDLIKExObNEiZLmm4aOUO0Pih5ZQO4d6eGaEjthM="; GOOS = "linux"; GOARCH = "amd64"; };
"x86_64-linux" = { suffix = "linux-amd64"; sri = "sha256-KFKvDLIKExObNEiZLmm4aOUO0Pih5ZQO4d6eGaEjthM="; }; "aarch64-linux" = { suffix = "linux-arm64"; hash = "sha256-Bd511plKJ4NpmBXuVTvVqTJ9i3mZHeNuOLZoYngvVK4="; GOOS = "linux"; GOARCH = "arm64"; };
"aarch64-linux" = { suffix = "linux-arm64"; sri = "sha256-Bd511plKJ4NpmBXuVTvVqTJ9i3mZHeNuOLZoYngvVK4="; }; "x86_64-darwin" = { suffix = "darwin-amd64"; hash = "sha256-W9YOgjA3BiwjB8cegRGAmGURZxTW9rQQWXz1B139gO8="; GOOS = "darwin"; GOARCH = "amd64"; };
"i686-linux" = { suffix = "linux-386"; sri = "sha256-jGAt2dmbyUU7OZXSDOS684LMUIVZAKDs5d6ZKd9KmTo="; }; "aarch64-darwin" = { suffix = "darwin-arm64"; hash = "sha256-VEkyhEFW2Bcveij3fyrJwVojBGaYtiQ/YzsKCwDAdJw="; GOOS = "darwin"; GOARCH = "arm64"; };
"armv6l-linux" = { suffix = "linux-armv6l"; sri = "sha256-paj4GY/PAOHkhbjs757gIHeL8ypAik6Iczcb/ORYzQk="; }; }.${pkgs.stdenv.hostPlatform.system} or (throw "unsupported system: ${pkgs.stdenv.hostPlatform.system}");
"x86_64-darwin" = { suffix = "darwin-amd64"; sri = "sha256-W9YOgjA3BiwjB8cegRGAmGURZxTW9rQQWXz1B139gO8="; };
"aarch64-darwin" = { suffix = "darwin-arm64"; sri = "sha256-VEkyhEFW2Bcveij3fyrJwVojBGaYtiQ/YzsKCwDAdJw="; };
};
hostSystem = prev.stdenv.hostPlatform.system;
chosen =
if prev.lib.hasAttr hostSystem platformInfo then platformInfo.${hostSystem}
else
throw ''
unsupported system: ${hostSystem}
add a mapping for your platform using the upstream tarball + sri sha256
'';
in in
{ pkgs.stdenv.mkDerivation {
go_1_25_bin = prev.stdenvNoCC.mkDerivation {
pname = "go"; pname = "go";
version = version; inherit version;
src = pkgs.fetchurl {
src = prev.fetchurl { url = "https://go.dev/dl/go${version}.${platform.suffix}.tar.gz";
url = "https://go.dev/dl/go${version}.${chosen.suffix}.tar.gz"; inherit (platform) hash;
hash = chosen.sri;
}; };
dontBuild = true; dontBuild = true;
installPhase = ''
runHook preInstall
mkdir -p "$out"/{bin,share}
tar -C "$TMPDIR" -xzf "$src"
cp -a "$TMPDIR/go" "$out/share/go"
ln -s "$out/share/go/bin/go" "$out/bin/go"
ln -s "$out/share/go/bin/gofmt" "$out/bin/gofmt"
runHook postInstall
'';
dontPatchELF = true; dontPatchELF = true;
dontStrip = true; dontStrip = true;
installPhase = ''
meta = with prev.lib; { runHook preInstall
description = "go compiler and tools v${version}"; mkdir -p $out/{bin,share/go}
homepage = "https://go.dev/dl/"; tar -xzf $src --strip-components=1 -C $out/share/go
license = licenses.bsd3; ln -s $out/share/go/bin/go $out/bin/go
platforms = [ hostSystem ]; ln -s $out/share/go/bin/gofmt $out/bin/gofmt
}; runHook postInstall
'';
passthru = {
inherit (platform) GOOS GOARCH;
}; };
}; };
packages = forAllSystems (system: pkgsFor = system: import nixpkgs { inherit system; };
let pkgs = import nixpkgs { inherit system; overlays = [ self.overlays.default ]; };
in { mkSnitch = pkgs:
default = pkgs.go_1_25_bin; let
go_1_25_bin = pkgs.go_1_25_bin; version = self.shortRev or self.dirtyShortRev or "dev";
go = mkGo125 pkgs;
buildGoModule = pkgs.buildGoModule.override { inherit go; };
in
buildGoModule {
pname = "snitch";
inherit version;
src = self;
vendorHash = "sha256-fX3wOqeOgjH7AuWGxPQxJ+wbhp240CW8tiF4rVUUDzk=";
env.CGO_ENABLED = "0";
env.GOTOOLCHAIN = "local";
ldflags = [
"-s"
"-w"
"-X snitch/cmd.Version=${version}"
"-X snitch/cmd.Commit=${version}"
"-X snitch/cmd.Date=${self.lastModifiedDate or "unknown"}"
];
meta = {
description = "a friendlier ss/netstat for humans";
homepage = "https://github.com/karol-broda/snitch";
license = pkgs.lib.licenses.mit;
platforms = pkgs.lib.platforms.linux;
mainProgram = "snitch";
};
};
in
{
packages = eachSystem (system:
let pkgs = pkgsFor system; in
{
default = mkSnitch pkgs;
snitch = mkSnitch pkgs;
} }
); );
devShells = forAllSystems (system: devShells = eachSystem (system:
let pkgs = import nixpkgs { inherit system; overlays = [ self.overlays.default ]; }; let
in { pkgs = pkgsFor system;
go = mkGo125 pkgs;
in
{
default = pkgs.mkShell { default = pkgs.mkShell {
packages = [ pkgs.go_1_25_bin pkgs.git ]; packages = [ go pkgs.git pkgs.vhs ];
env.GOTOOLCHAIN = "local";
GOTOOLCHAIN = "local";
shellHook = '' shellHook = ''
echo "go toolchain: $(go version)" echo "go toolchain: $(go version)"
''; '';
}; };
} }
); );
overlays.default = final: _prev: {
snitch = mkSnitch final;
};
}; };
} }

13
go.mod
View File

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

16
go.sum
View File

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

110
install.sh Executable file
View File

@@ -0,0 +1,110 @@
#!/bin/sh
set -e
REPO="karol-broda/snitch"
BINARY_NAME="snitch"
# allow override via environment
INSTALL_DIR="${INSTALL_DIR:-}"
detect_install_dir() {
if [ -n "$INSTALL_DIR" ]; then
echo "$INSTALL_DIR"
return
fi
# prefer user-local directory if it exists and is in PATH
if [ -d "$HOME/.local/bin" ] && echo "$PATH" | grep -q "$HOME/.local/bin"; then
echo "$HOME/.local/bin"
return
fi
# fallback to /usr/local/bin
echo "/usr/local/bin"
}
detect_os() {
os=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$os" in
darwin) echo "darwin" ;;
linux) echo "linux" ;;
*)
echo "error: unsupported operating system: $os" >&2
exit 1
;;
esac
}
detect_arch() {
arch=$(uname -m)
case "$arch" in
x86_64|amd64) echo "amd64" ;;
aarch64|arm64) echo "arm64" ;;
armv7l) echo "armv7" ;;
*)
echo "error: unsupported architecture: $arch" >&2
exit 1
;;
esac
}
fetch_latest_version() {
version=$(curl -sL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | head -1 | cut -d'"' -f4)
if [ -z "$version" ]; then
echo "error: failed to fetch latest version" >&2
exit 1
fi
echo "$version"
}
main() {
os=$(detect_os)
arch=$(detect_arch)
install_dir=$(detect_install_dir)
version=$(fetch_latest_version)
version_no_v="${version#v}"
archive_name="${BINARY_NAME}_${version_no_v}_${os}_${arch}.tar.gz"
download_url="https://github.com/${REPO}/releases/download/${version}/${archive_name}"
echo "installing ${BINARY_NAME} ${version} for ${os}/${arch}..."
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
echo "downloading ${download_url}..."
if ! curl -sL --fail "$download_url" -o "${tmp_dir}/${archive_name}"; then
echo "error: failed to download ${download_url}" >&2
exit 1
fi
echo "extracting..."
tar -xzf "${tmp_dir}/${archive_name}" -C "$tmp_dir"
if [ ! -f "${tmp_dir}/${BINARY_NAME}" ]; then
echo "error: binary not found in archive" >&2
exit 1
fi
# remove macos quarantine attribute
if [ "$os" = "darwin" ]; then
xattr -d com.apple.quarantine "${tmp_dir}/${BINARY_NAME}" 2>/dev/null || true
fi
# install binary
if [ -w "$install_dir" ]; then
mv "${tmp_dir}/${BINARY_NAME}" "${install_dir}/${BINARY_NAME}"
else
echo "elevated permissions required to install to ${install_dir}"
sudo mv "${tmp_dir}/${BINARY_NAME}" "${install_dir}/${BINARY_NAME}"
fi
chmod +x "${install_dir}/${BINARY_NAME}"
echo "installed ${BINARY_NAME} to ${install_dir}/${BINARY_NAME}"
echo ""
echo "run '${BINARY_NAME} --help' to get started"
}
main

View File

@@ -1,17 +1,8 @@
package collector package collector
import ( import (
"bufio"
"bytes"
"fmt"
"net" "net"
"os"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings" "strings"
"time"
) )
// Collector interface defines methods for collecting connection data // Collector interface defines methods for collecting connection data
@@ -19,9 +10,6 @@ type Collector interface {
GetConnections() ([]Connection, error) GetConnections() ([]Connection, error)
} }
// DefaultCollector implements the Collector interface using /proc
type DefaultCollector struct{}
// Global collector instance (can be overridden for testing) // Global collector instance (can be overridden for testing)
var globalCollector Collector = &DefaultCollector{} var globalCollector Collector = &DefaultCollector{}
@@ -40,64 +28,6 @@ func GetConnections() ([]Connection, error) {
return globalCollector.GetConnections() return globalCollector.GetConnections()
} }
// GetConnections fetches all network connections by parsing /proc files.
func (dc *DefaultCollector) GetConnections() ([]Connection, error) {
if runtime.GOOS != "linux" {
return nil, fmt.Errorf("proc-based collector only supports Linux")
}
// Build map of inode -> process info by scanning /proc
inodeMap, err := buildInodeToProcessMap()
if err != nil {
return nil, fmt.Errorf("failed to build inode map: %w", err)
}
var connections []Connection
// Parse TCP connections
tcpConns, err := parseProcNet("/proc/net/tcp", "tcp", 4, inodeMap)
if err == nil {
connections = append(connections, tcpConns...)
}
tcpConns6, err := parseProcNet("/proc/net/tcp6", "tcp6", 6, inodeMap)
if err == nil {
connections = append(connections, tcpConns6...)
}
// Parse UDP connections
udpConns, err := parseProcNet("/proc/net/udp", "udp", 4, inodeMap)
if err == nil {
connections = append(connections, udpConns...)
}
udpConns6, err := parseProcNet("/proc/net/udp6", "udp6", 6, inodeMap)
if err == nil {
connections = append(connections, udpConns6...)
}
return connections, nil
}
// GetAllConnections returns both network and Unix domain socket connections
func GetAllConnections() ([]Connection, error) {
// Get network connections
networkConns, err := GetConnections()
if err != nil {
return nil, err
}
// Get Unix sockets (only on Linux)
if runtime.GOOS == "linux" {
unixConns, err := GetUnixSockets()
if err == nil {
networkConns = append(networkConns, unixConns...)
}
}
return networkConns, nil
}
func FilterConnections(conns []Connection, filters FilterOptions) []Connection { func FilterConnections(conns []Connection, filters FilterOptions) []Connection {
if filters.IsEmpty() { if filters.IsEmpty() {
return conns return conns
@@ -112,395 +42,60 @@ func FilterConnections(conns []Connection, filters FilterOptions) []Connection {
return filtered return filtered
} }
// processInfo holds information about a process func guessNetworkInterface(addr string) string {
type processInfo struct {
pid int
command string
uid int
user string
}
// buildInodeToProcessMap scans /proc to map socket inodes to processes
func buildInodeToProcessMap() (map[int64]*processInfo, error) {
inodeMap := make(map[int64]*processInfo)
procDir, err := os.Open("/proc")
if err != nil {
return nil, err
}
defer procDir.Close()
entries, err := procDir.Readdir(-1)
if err != nil {
return nil, err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// check if directory name is a number (pid)
pidStr := entry.Name()
pid, err := strconv.Atoi(pidStr)
if err != nil {
continue
}
// get process info
procInfo, err := getProcessInfo(pid)
if err != nil {
continue
}
// scan /proc/[pid]/fd/ for socket file descriptors
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 err != nil {
continue
}
// socket inodes look like: socket:[12345]
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
}
}
}
return inodeMap, nil
}
// getProcessInfo reads process information from /proc/[pid]/
func getProcessInfo(pid int) (*processInfo, error) {
info := &processInfo{pid: pid}
// prefer /proc/[pid]/comm as it's always just the command name
commPath := filepath.Join("/proc", strconv.Itoa(pid), "comm")
commData, err := os.ReadFile(commPath)
if err == nil && len(commData) > 0 {
info.command = strings.TrimSpace(string(commData))
}
// if comm is not available, try cmdline
if info.command == "" {
cmdlinePath := filepath.Join("/proc", strconv.Itoa(pid), "cmdline")
cmdlineData, err := os.ReadFile(cmdlinePath)
if err != nil {
return nil, err
}
// cmdline is null-separated, take first part
if len(cmdlineData) > 0 {
parts := bytes.Split(cmdlineData, []byte{0})
if len(parts) > 0 && len(parts[0]) > 0 {
fullPath := string(parts[0])
// extract basename from full path
baseName := filepath.Base(fullPath)
// if basename contains spaces (single-string cmdline), take first word
if strings.Contains(baseName, " ") {
baseName = strings.Fields(baseName)[0]
}
info.command = baseName
}
}
}
// read UID from /proc/[pid]/status
statusPath := filepath.Join("/proc", strconv.Itoa(pid), "status")
statusFile, err := os.Open(statusPath)
if err != nil {
return info, nil
}
defer statusFile.Close()
scanner := bufio.NewScanner(statusFile)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "Uid:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
uid, err := strconv.Atoi(fields[1])
if err == nil {
info.uid = uid
// get username from uid
u, err := user.LookupId(strconv.Itoa(uid))
if err == nil {
info.user = u.Username
} else {
info.user = strconv.Itoa(uid)
}
}
}
break
}
}
return info, nil
}
// parseProcNet parses a /proc/net/tcp or /proc/net/udp file
func parseProcNet(path, proto string, ipVersion int, inodeMap map[int64]*processInfo) ([]Connection, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
var connections []Connection
scanner := bufio.NewScanner(file)
// skip header
scanner.Scan()
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
// parse local address and port
localAddr, localPort, err := parseHexAddr(fields[1])
if err != nil {
continue
}
// parse remote address and port
remoteAddr, remotePort, err := parseHexAddr(fields[2])
if err != nil {
continue
}
// parse state (field 3)
stateHex := fields[3]
state := parseState(stateHex, proto)
// parse inode (field 9)
inode, _ := strconv.ParseInt(fields[9], 10, 64)
conn := Connection{
TS: time.Now(),
Proto: proto,
IPVersion: fmt.Sprintf("IPv%d", ipVersion),
State: state,
Laddr: localAddr,
Lport: localPort,
Raddr: remoteAddr,
Rport: remotePort,
Inode: inode,
}
// add process info if available
if procInfo, exists := inodeMap[inode]; exists {
conn.PID = procInfo.pid
conn.Process = procInfo.command
conn.UID = procInfo.uid
conn.User = procInfo.user
}
// determine interface
conn.Interface = guessNetworkInterface(localAddr, nil)
connections = append(connections, conn)
}
return connections, scanner.Err()
}
// parseState converts hex state value to string
func parseState(hexState, proto string) string {
state, err := strconv.ParseInt(hexState, 16, 32)
if err != nil {
return ""
}
// TCP states
tcpStates := map[int64]string{
0x01: "ESTABLISHED",
0x02: "SYN_SENT",
0x03: "SYN_RECV",
0x04: "FIN_WAIT1",
0x05: "FIN_WAIT2",
0x06: "TIME_WAIT",
0x07: "CLOSE",
0x08: "CLOSE_WAIT",
0x09: "LAST_ACK",
0x0A: "LISTEN",
0x0B: "CLOSING",
}
if strings.HasPrefix(proto, "tcp") {
if s, exists := tcpStates[state]; exists {
return s
}
} else {
// UDP doesn't have states in the same way
if state == 0x07 {
return "CLOSE"
}
return ""
}
return ""
}
// parseHexAddr parses hex-encoded address:port from /proc/net files
func parseHexAddr(hexAddr string) (string, int, error) {
parts := strings.Split(hexAddr, ":")
if len(parts) != 2 {
return "", 0, fmt.Errorf("invalid address format")
}
hexIP := parts[0]
// parse hex port
port, err := strconv.ParseInt(parts[1], 16, 32)
if err != nil {
return "", 0, err
}
if len(hexIP) == 8 {
// IPv4 (stored in little-endian)
ip1, _ := strconv.ParseInt(hexIP[6:8], 16, 32)
ip2, _ := strconv.ParseInt(hexIP[4:6], 16, 32)
ip3, _ := strconv.ParseInt(hexIP[2:4], 16, 32)
ip4, _ := strconv.ParseInt(hexIP[0:2], 16, 32)
addr := fmt.Sprintf("%d.%d.%d.%d", ip1, ip2, ip3, ip4)
// handle wildcard address
if addr == "0.0.0.0" {
addr = "*"
}
return addr, int(port), nil
} else if len(hexIP) == 32 {
// IPv6 (stored in little-endian per 32-bit word)
var ipv6Parts []string
for i := 0; i < 32; i += 8 {
word := hexIP[i : i+8]
// reverse byte order within each 32-bit word
p1 := word[6:8] + word[4:6] + word[2:4] + word[0:2]
ipv6Parts = append(ipv6Parts, p1)
}
// convert to standard IPv6 notation
fullAddr := strings.Join(ipv6Parts, "")
var formatted []string
for i := 0; i < len(fullAddr); i += 4 {
formatted = append(formatted, fullAddr[i:i+4])
}
addr := strings.Join(formatted, ":")
// simplify IPv6 address
addr = simplifyIPv6(addr)
// handle wildcard address
if addr == "::" || addr == "0:0:0:0:0:0:0:0" {
addr = "*"
}
return addr, int(port), nil
}
return "", 0, fmt.Errorf("unsupported address format")
}
// simplifyIPv6 simplifies IPv6 address notation
func simplifyIPv6(addr string) string {
// remove leading zeros from each group
parts := strings.Split(addr, ":")
for i, part := range parts {
// convert to int and back to remove leading zeros
val, err := strconv.ParseInt(part, 16, 64)
if err == nil {
parts[i] = strconv.FormatInt(val, 16)
}
}
return strings.Join(parts, ":")
}
func guessNetworkInterface(addr string, interfaces map[string]string) string {
// Simple heuristic - try to match common interface patterns
if addr == "127.0.0.1" || addr == "::1" { if addr == "127.0.0.1" || addr == "::1" {
return "lo" return "lo"
} }
// Check if it's a private network address
ip := net.ParseIP(addr) ip := net.ParseIP(addr)
if ip != nil { if ip == nil {
return ""
}
if ip.IsLoopback() { if ip.IsLoopback() {
return "lo" return "lo"
} }
// More sophisticated interface detection would require routing table analysis
// For now, return a placeholder
if ip.To4() != nil {
return "eth0" // Common default for IPv4
} else {
return "eth0" // Common default for IPv6
}
}
// default interface name varies by OS but we return a generic value
// actual interface detection would require routing table analysis
return "" return ""
} }
// Add Unix socket support func simplifyIPv6(addr string) string {
func GetUnixSockets() ([]Connection, error) { parts := strings.Split(addr, ":")
connections := []Connection{} for i, part := range parts {
// parse as hex then format back to remove leading zeros
// Parse /proc/net/unix for Unix domain sockets var val int64
file, err := os.Open("/proc/net/unix") for _, c := range part {
if err != nil { val = val*16 + int64(hexCharToInt(c))
return connections, nil // silently fail on non-Linux systems
} }
defer file.Close() parts[i] = formatHex(val)
scanner := bufio.NewScanner(file)
// Skip header
scanner.Scan()
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
fields := strings.Fields(line)
if len(fields) < 7 {
continue
} }
return strings.Join(parts, ":")
// Parse Unix socket information
inode, _ := strconv.ParseInt(fields[6], 10, 64)
path := ""
if len(fields) > 7 {
path = fields[7]
}
conn := Connection{
TS: time.Now(),
Proto: "unix",
Laddr: path,
Raddr: "",
State: "CONNECTED", // Simplified
Inode: inode,
Interface: "unix",
}
connections = append(connections, conn)
}
return connections, nil
} }
func hexCharToInt(c rune) int {
switch {
case c >= '0' && c <= '9':
return int(c - '0')
case c >= 'a' && c <= 'f':
return int(c - 'a' + 10)
case c >= 'A' && c <= 'F':
return int(c - 'A' + 10)
default:
return 0
}
}
func formatHex(val int64) string {
if val == 0 {
return "0"
}
const hexDigits = "0123456789abcdef"
var result []byte
for val > 0 {
result = append([]byte{hexDigits[val%16]}, result...)
val /= 16
}
return string(result)
}

View File

@@ -0,0 +1,338 @@
//go:build darwin
package collector
/*
#include <libproc.h>
#include <sys/proc_info.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp_fsm.h>
#include <arpa/inet.h>
#include <pwd.h>
#include <stdlib.h>
#include <string.h>
// get process name by pid
static int get_proc_name(int pid, char *name, int namelen) {
return proc_name(pid, name, namelen);
}
// get uid for a process
static int get_proc_uid(int pid) {
struct proc_bsdinfo info;
int ret = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, sizeof(info));
if (ret <= 0) {
return -1;
}
return info.pbi_uid;
}
// get username from uid
static const char* get_username(int uid) {
struct passwd *pw = getpwuid(uid);
if (pw == NULL) {
return NULL;
}
return pw->pw_name;
}
// socket info extraction - handles the union properly in C
typedef struct {
int family;
int sock_type;
int protocol;
int state;
uint32_t laddr4;
uint32_t raddr4;
uint8_t laddr6[16];
uint8_t raddr6[16];
int lport;
int rport;
} socket_info_t;
static int get_socket_info(int pid, int fd, socket_info_t *info) {
struct socket_fdinfo si;
int ret = proc_pidfdinfo(pid, fd, PROC_PIDFDSOCKETINFO, &si, sizeof(si));
if (ret <= 0) {
return -1;
}
info->family = si.psi.soi_family;
info->sock_type = si.psi.soi_type;
info->protocol = si.psi.soi_protocol;
if (info->family == AF_INET) {
if (info->sock_type == SOCK_STREAM) {
// TCP
info->state = si.psi.soi_proto.pri_tcp.tcpsi_state;
info->laddr4 = si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_laddr.ina_46.i46a_addr4.s_addr;
info->raddr4 = si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_faddr.ina_46.i46a_addr4.s_addr;
info->lport = ntohs(si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_lport);
info->rport = ntohs(si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_fport);
} else if (info->sock_type == SOCK_DGRAM) {
// UDP
info->state = 0;
info->laddr4 = si.psi.soi_proto.pri_in.insi_laddr.ina_46.i46a_addr4.s_addr;
info->raddr4 = si.psi.soi_proto.pri_in.insi_faddr.ina_46.i46a_addr4.s_addr;
info->lport = ntohs(si.psi.soi_proto.pri_in.insi_lport);
info->rport = ntohs(si.psi.soi_proto.pri_in.insi_fport);
}
} else if (info->family == AF_INET6) {
if (info->sock_type == SOCK_STREAM) {
// TCP6
info->state = si.psi.soi_proto.pri_tcp.tcpsi_state;
memcpy(info->laddr6, &si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_laddr.ina_6, 16);
memcpy(info->raddr6, &si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_faddr.ina_6, 16);
info->lport = ntohs(si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_lport);
info->rport = ntohs(si.psi.soi_proto.pri_tcp.tcpsi_ini.insi_fport);
} else if (info->sock_type == SOCK_DGRAM) {
// UDP6
info->state = 0;
memcpy(info->laddr6, &si.psi.soi_proto.pri_in.insi_laddr.ina_6, 16);
memcpy(info->raddr6, &si.psi.soi_proto.pri_in.insi_faddr.ina_6, 16);
info->lport = ntohs(si.psi.soi_proto.pri_in.insi_lport);
info->rport = ntohs(si.psi.soi_proto.pri_in.insi_fport);
}
}
return 0;
}
*/
import "C"
import (
"fmt"
"net"
"strconv"
"time"
"unsafe"
)
// DefaultCollector implements the Collector interface using libproc on macOS
type DefaultCollector struct{}
// GetConnections fetches all network connections using libproc
func (dc *DefaultCollector) GetConnections() ([]Connection, error) {
pids, err := listAllPids()
if err != nil {
return nil, fmt.Errorf("failed to list pids: %w", err)
}
var connections []Connection
for _, pid := range pids {
procConns, err := getConnectionsForPid(pid)
if err != nil {
continue
}
connections = append(connections, procConns...)
}
return connections, nil
}
// GetAllConnections returns network connections
func GetAllConnections() ([]Connection, error) {
return GetConnections()
}
func listAllPids() ([]int, error) {
numPids := C.proc_listpids(C.PROC_ALL_PIDS, 0, nil, 0)
if numPids <= 0 {
return nil, fmt.Errorf("proc_listpids failed")
}
bufSize := C.int(numPids) * C.int(unsafe.Sizeof(C.int(0)))
buf := make([]C.int, numPids)
numPids = C.proc_listpids(C.PROC_ALL_PIDS, 0, unsafe.Pointer(&buf[0]), bufSize)
if numPids <= 0 {
return nil, fmt.Errorf("proc_listpids failed")
}
count := int(numPids) / int(unsafe.Sizeof(C.int(0)))
pids := make([]int, 0, count)
for i := 0; i < count; i++ {
if buf[i] > 0 {
pids = append(pids, int(buf[i]))
}
}
return pids, nil
}
func getConnectionsForPid(pid int) ([]Connection, error) {
procName := getProcessName(pid)
uid := int(C.get_proc_uid(C.int(pid)))
user := ""
if uid >= 0 {
cUser := C.get_username(C.int(uid))
if cUser != nil {
user = C.GoString(cUser)
} else {
user = strconv.Itoa(uid)
}
}
bufSize := C.proc_pidinfo(C.int(pid), C.PROC_PIDLISTFDS, 0, nil, 0)
if bufSize <= 0 {
return nil, fmt.Errorf("failed to get fd list size")
}
buf := make([]byte, bufSize)
ret := C.proc_pidinfo(C.int(pid), C.PROC_PIDLISTFDS, 0, unsafe.Pointer(&buf[0]), bufSize)
if ret <= 0 {
return nil, fmt.Errorf("failed to get fd list")
}
fdInfoSize := int(unsafe.Sizeof(C.struct_proc_fdinfo{}))
numFds := int(ret) / fdInfoSize
var connections []Connection
for i := 0; i < numFds; i++ {
fdInfo := (*C.struct_proc_fdinfo)(unsafe.Pointer(&buf[i*fdInfoSize]))
if fdInfo.proc_fdtype != C.PROX_FDTYPE_SOCKET {
continue
}
conn, ok := getSocketInfo(pid, int(fdInfo.proc_fd), procName, uid, user)
if ok {
connections = append(connections, conn)
}
}
return connections, nil
}
func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connection, bool) {
var info C.socket_info_t
ret := C.get_socket_info(C.int(pid), C.int(fd), &info)
if ret != 0 {
return Connection{}, false
}
// only interested in IPv4 and IPv6
if info.family != C.AF_INET && info.family != C.AF_INET6 {
return Connection{}, false
}
// only TCP and UDP
if info.sock_type != C.SOCK_STREAM && info.sock_type != C.SOCK_DGRAM {
return Connection{}, false
}
proto := "tcp"
if info.sock_type == C.SOCK_DGRAM {
proto = "udp"
}
ipVersion := "IPv4"
if info.family == C.AF_INET6 {
ipVersion = "IPv6"
proto = proto + "6"
}
var laddr, raddr string
if info.family == C.AF_INET {
laddr = ipv4ToString(uint32(info.laddr4))
raddr = ipv4ToString(uint32(info.raddr4))
} else {
laddr = ipv6ToString(info.laddr6)
raddr = ipv6ToString(info.raddr6)
}
if laddr == "0.0.0.0" || laddr == "::" {
laddr = "*"
}
if raddr == "0.0.0.0" || raddr == "::" {
raddr = "*"
}
state := ""
if info.sock_type == C.SOCK_STREAM {
state = tcpStateToString(int(info.state))
} else if info.sock_type == C.SOCK_DGRAM {
// udp is connectionless - infer state from remote address
if raddr == "*" && int(info.rport) == 0 {
state = "LISTEN"
} else {
state = "ESTABLISHED"
}
}
conn := Connection{
TS: time.Now(),
Proto: proto,
IPVersion: ipVersion,
State: state,
Laddr: laddr,
Lport: int(info.lport),
Raddr: raddr,
Rport: int(info.rport),
PID: pid,
Process: procName,
UID: uid,
User: user,
Interface: guessNetworkInterface(laddr),
}
return conn, true
}
func getProcessName(pid int) string {
var name [256]C.char
ret := C.get_proc_name(C.int(pid), &name[0], 256)
if ret <= 0 {
return ""
}
return C.GoString(&name[0])
}
func ipv4ToString(addr uint32) string {
ip := make(net.IP, 4)
ip[0] = byte(addr)
ip[1] = byte(addr >> 8)
ip[2] = byte(addr >> 16)
ip[3] = byte(addr >> 24)
return ip.String()
}
func ipv6ToString(addr [16]C.uint8_t) string {
ip := make(net.IP, 16)
for i := 0; i < 16; i++ {
ip[i] = byte(addr[i])
}
if ip.To4() != nil {
return ip.To4().String()
}
return ip.String()
}
func tcpStateToString(state int) string {
// macOS TCP states from netinet/tcp_fsm.h
states := map[int]string{
0: "CLOSED",
1: "LISTEN",
2: "SYN_SENT",
3: "SYN_RECV",
4: "ESTABLISHED",
5: "CLOSE_WAIT",
6: "FIN_WAIT1",
7: "CLOSING",
8: "LAST_ACK",
9: "FIN_WAIT2",
10: "TIME_WAIT",
}
if s, exists := states[state]; exists {
return s
}
return ""
}

View File

@@ -0,0 +1,398 @@
//go:build linux
package collector
import (
"bufio"
"bytes"
"fmt"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
"time"
)
// 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) {
inodeMap, err := buildInodeToProcessMap()
if err != nil {
return nil, fmt.Errorf("failed to build inode map: %w", err)
}
var connections []Connection
tcpConns, err := parseProcNet("/proc/net/tcp", "tcp", 4, inodeMap)
if err == nil {
connections = append(connections, tcpConns...)
}
tcpConns6, err := parseProcNet("/proc/net/tcp6", "tcp6", 6, inodeMap)
if err == nil {
connections = append(connections, tcpConns6...)
}
udpConns, err := parseProcNet("/proc/net/udp", "udp", 4, inodeMap)
if err == nil {
connections = append(connections, udpConns...)
}
udpConns6, err := parseProcNet("/proc/net/udp6", "udp6", 6, inodeMap)
if err == nil {
connections = append(connections, udpConns6...)
}
return connections, nil
}
// GetAllConnections returns both network and Unix domain socket connections
func GetAllConnections() ([]Connection, error) {
networkConns, err := GetConnections()
if err != nil {
return nil, err
}
unixConns, err := GetUnixSockets()
if err == nil {
networkConns = append(networkConns, unixConns...)
}
return networkConns, nil
}
type processInfo struct {
pid int
command string
uid int
user string
}
func buildInodeToProcessMap() (map[int64]*processInfo, error) {
inodeMap := make(map[int64]*processInfo)
procDir, err := os.Open("/proc")
if err != nil {
return nil, err
}
defer procDir.Close()
entries, err := procDir.Readdir(-1)
if err != nil {
return nil, err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pidStr := entry.Name()
pid, err := strconv.Atoi(pidStr)
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 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
}
}
}
return inodeMap, nil
}
func getProcessInfo(pid int) (*processInfo, error) {
info := &processInfo{pid: pid}
commPath := filepath.Join("/proc", strconv.Itoa(pid), "comm")
commData, err := os.ReadFile(commPath)
if err == nil && len(commData) > 0 {
info.command = strings.TrimSpace(string(commData))
}
if info.command == "" {
cmdlinePath := filepath.Join("/proc", strconv.Itoa(pid), "cmdline")
cmdlineData, err := os.ReadFile(cmdlinePath)
if err != nil {
return nil, err
}
if len(cmdlineData) > 0 {
parts := bytes.Split(cmdlineData, []byte{0})
if len(parts) > 0 && len(parts[0]) > 0 {
fullPath := string(parts[0])
baseName := filepath.Base(fullPath)
if strings.Contains(baseName, " ") {
baseName = strings.Fields(baseName)[0]
}
info.command = baseName
}
}
}
statusPath := filepath.Join("/proc", strconv.Itoa(pid), "status")
statusFile, err := os.Open(statusPath)
if err != nil {
return info, nil
}
defer statusFile.Close()
scanner := bufio.NewScanner(statusFile)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "Uid:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
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)
}
}
}
break
}
}
return info, nil
}
func parseProcNet(path, proto string, ipVersion int, inodeMap map[int64]*processInfo) ([]Connection, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
var connections []Connection
scanner := bufio.NewScanner(file)
scanner.Scan()
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
localAddr, localPort, err := parseHexAddr(fields[1])
if err != nil {
continue
}
remoteAddr, remotePort, err := parseHexAddr(fields[2])
if err != nil {
continue
}
stateHex := fields[3]
state := parseState(stateHex, proto)
inode, _ := strconv.ParseInt(fields[9], 10, 64)
// refine udp state: if unconnected and remote is wildcard, it's listening
if strings.HasPrefix(proto, "udp") && state == "UNCONNECTED" {
if remoteAddr == "*" && remotePort == 0 {
state = "LISTEN"
}
}
conn := Connection{
TS: time.Now(),
Proto: proto,
IPVersion: fmt.Sprintf("IPv%d", ipVersion),
State: state,
Laddr: localAddr,
Lport: localPort,
Raddr: remoteAddr,
Rport: remotePort,
Inode: inode,
}
if procInfo, exists := inodeMap[inode]; exists {
conn.PID = procInfo.pid
conn.Process = procInfo.command
conn.UID = procInfo.uid
conn.User = procInfo.user
}
conn.Interface = guessNetworkInterface(localAddr)
connections = append(connections, conn)
}
return connections, scanner.Err()
}
func parseState(hexState, proto string) string {
state, err := strconv.ParseInt(hexState, 16, 32)
if err != nil {
return ""
}
tcpStates := map[int64]string{
0x01: "ESTABLISHED",
0x02: "SYN_SENT",
0x03: "SYN_RECV",
0x04: "FIN_WAIT1",
0x05: "FIN_WAIT2",
0x06: "TIME_WAIT",
0x07: "CLOSE",
0x08: "CLOSE_WAIT",
0x09: "LAST_ACK",
0x0A: "LISTEN",
0x0B: "CLOSING",
}
if strings.HasPrefix(proto, "tcp") {
if s, exists := tcpStates[state]; exists {
return s
}
return ""
}
// udp states - udp is connectionless so the kernel reuses tcp state values
// with different meanings:
// 0x07 (TCP_CLOSE) = unconnected socket, typically bound and listening
// 0x01 (TCP_ESTABLISHED) = "connected" socket (connect() was called)
udpStates := map[int64]string{
0x01: "ESTABLISHED",
0x07: "UNCONNECTED",
}
if s, exists := udpStates[state]; exists {
return s
}
return ""
}
func parseHexAddr(hexAddr string) (string, int, error) {
parts := strings.Split(hexAddr, ":")
if len(parts) != 2 {
return "", 0, fmt.Errorf("invalid address format")
}
hexIP := parts[0]
port, err := strconv.ParseInt(parts[1], 16, 32)
if err != nil {
return "", 0, err
}
if len(hexIP) == 8 {
ip1, _ := strconv.ParseInt(hexIP[6:8], 16, 32)
ip2, _ := strconv.ParseInt(hexIP[4:6], 16, 32)
ip3, _ := strconv.ParseInt(hexIP[2:4], 16, 32)
ip4, _ := strconv.ParseInt(hexIP[0:2], 16, 32)
addr := fmt.Sprintf("%d.%d.%d.%d", ip1, ip2, ip3, ip4)
if addr == "0.0.0.0" {
addr = "*"
}
return addr, int(port), nil
} else if len(hexIP) == 32 {
var ipv6Parts []string
for i := 0; i < 32; i += 8 {
word := hexIP[i : i+8]
p1 := word[6:8] + word[4:6] + word[2:4] + word[0:2]
ipv6Parts = append(ipv6Parts, p1)
}
fullAddr := strings.Join(ipv6Parts, "")
var formatted []string
for i := 0; i < len(fullAddr); i += 4 {
formatted = append(formatted, fullAddr[i:i+4])
}
addr := strings.Join(formatted, ":")
addr = simplifyIPv6(addr)
if addr == "::" || addr == "0:0:0:0:0:0:0:0" {
addr = "*"
}
return addr, int(port), nil
}
return "", 0, fmt.Errorf("unsupported address format")
}
func GetUnixSockets() ([]Connection, error) {
connections := []Connection{}
file, err := os.Open("/proc/net/unix")
if err != nil {
return connections, nil
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Scan()
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
fields := strings.Fields(line)
if len(fields) < 7 {
continue
}
inode, _ := strconv.ParseInt(fields[6], 10, 64)
path := ""
if len(fields) > 7 {
path = fields[7]
}
conn := Connection{
TS: time.Now(),
Proto: "unix",
Laddr: path,
Raddr: "",
State: "CONNECTED",
Inode: inode,
Interface: "unix",
}
connections = append(connections, conn)
}
return connections, nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

BIN
main

Binary file not shown.