16 Commits

Author SHA1 Message Date
Karol Broda
0252087bd0 feat: enhance install script and upgrade command 2025-12-21 21:19:07 +01:00
Karol Broda
eadd1b3452 feat: add upgrade command 2025-12-21 17:02:50 +01:00
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
41 changed files with 2168 additions and 465 deletions

View File

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

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

@@ -24,20 +24,6 @@ builds:
- -X snitch/cmd.Commit={{.ShortCommit}} - -X snitch/cmd.Commit={{.ShortCommit}}
- -X snitch/cmd.Date={{.Date}} - -X snitch/cmd.Date={{.Date}}
- id: darwin
env:
- CGO_ENABLED=1
goos:
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X snitch/cmd.Version={{.Version}}
- -X snitch/cmd.Commit={{.ShortCommit}}
- -X snitch/cmd.Date={{.Date}}
archives: archives:
- formats: - formats:
- tar.gz - tar.gz
@@ -47,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"
@@ -74,8 +63,27 @@ nfpms:
- deb - deb
- rpm - rpm
- apk - apk
builds:
- linux 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:

23
Makefile Normal file
View File

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

View File

@@ -2,6 +2,8 @@
a friendlier `ss` / `netstat` for humans. inspect network connections with a clean tui or styled tables. a friendlier `ss` / `netstat` for humans. inspect network connections with a clean tui or styled tables.
![snitch demo](demo/demo.gif)
## install ## install
### go ### go
@@ -26,18 +28,48 @@ nix profile install github:karol-broda/snitch
# then use: inputs.snitch.packages.${system}.default # 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
```
> **macos:** the install script automatically removes the quarantine attribute (`com.apple.quarantine`) from the binary to allow it to run without gatekeeper warnings. to disable this, set `KEEP_QUARANTINE=1`.
### binary ### binary
download from [releases](https://github.com/karol-broda/snitch/releases): download from [releases](https://github.com/karol-broda/snitch/releases):
```bash - **linux:** `snitch_<version>_linux_<arch>.tar.gz` or `.deb`/`.rpm`/`.apk`
# amd64 - **macos:** `snitch_<version>_darwin_<arch>.tar.gz`
curl -L https://github.com/karol-broda/snitch/releases/latest/download/snitch_linux_amd64.tar.gz | tar xz
sudo mv snitch /usr/local/bin/
# or install .deb/.rpm/.apk from releases ```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
@@ -71,6 +103,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
@@ -111,6 +146,16 @@ snitch watch -i 1s | jq '.count'
snitch watch -l -i 500ms snitch watch -l -i 500ms
``` ```
### `snitch upgrade`
check for updates and upgrade in-place.
```bash
snitch upgrade # check for updates
snitch upgrade --yes # upgrade automatically
snitch upgrade -v 0.1.7 # install specific version
```
## filters ## filters
shortcut flags work on all commands: shortcut flags work on all commands:
@@ -169,5 +214,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)
} }

412
cmd/upgrade.go Normal file
View File

@@ -0,0 +1,412 @@
package cmd
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
const (
repoOwner = "karol-broda"
repoName = "snitch"
githubAPI = "https://api.github.com"
firstUpgradeVersion = "0.1.8"
)
var (
upgradeYes bool
upgradeVersion string
)
var upgradeCmd = &cobra.Command{
Use: "upgrade",
Short: "Check for updates and optionally upgrade snitch",
Long: `Check for available updates and show upgrade instructions.
Use --yes to perform an in-place upgrade automatically.
Use --version to install a specific version.`,
RunE: runUpgrade,
}
func init() {
upgradeCmd.Flags().BoolVarP(&upgradeYes, "yes", "y", false, "Perform the upgrade automatically")
upgradeCmd.Flags().StringVarP(&upgradeVersion, "version", "v", "", "Install a specific version (e.g., v0.1.7)")
rootCmd.AddCommand(upgradeCmd)
}
type githubRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
}
func runUpgrade(cmd *cobra.Command, args []string) error {
current := Version
if upgradeVersion != "" {
return handleSpecificVersion(current, upgradeVersion)
}
latest, err := fetchLatestVersion()
if err != nil {
return fmt.Errorf("failed to check for updates: %w", err)
}
currentClean := strings.TrimPrefix(current, "v")
latestClean := strings.TrimPrefix(latest, "v")
printVersionComparison(current, latest)
if currentClean == latestClean {
green := color.New(color.FgGreen)
green.Println("✓ you are running the latest version")
return nil
}
if current == "dev" {
yellow := color.New(color.FgYellow)
yellow.Println("⚠ you are running a development build")
fmt.Println()
fmt.Println("use one of the methods below to install a release version:")
fmt.Println()
printUpgradeInstructions()
return nil
}
green := color.New(color.FgGreen, color.Bold)
green.Printf("✓ update available: %s → %s\n", current, latest)
fmt.Println()
if !upgradeYes {
printUpgradeInstructions()
fmt.Println()
faint := color.New(color.Faint)
cmdStyle := color.New(color.FgCyan)
faint.Print(" in-place ")
cmdStyle.Println("snitch upgrade --yes")
return nil
}
return performUpgrade(latest)
}
func handleSpecificVersion(current, target string) error {
if !strings.HasPrefix(target, "v") {
target = "v" + target
}
targetClean := strings.TrimPrefix(target, "v")
printVersionComparisonTarget(current, target)
if isVersionLower(targetClean, firstUpgradeVersion) {
yellow := color.New(color.FgYellow)
yellow.Printf("⚠ warning: the upgrade command was introduced in v%s\n", firstUpgradeVersion)
faint := color.New(color.Faint)
faint.Printf(" version %s does not include this command\n", target)
faint.Println(" you will need to use other methods to upgrade from that version")
fmt.Println()
}
currentClean := strings.TrimPrefix(current, "v")
if currentClean == targetClean {
green := color.New(color.FgGreen)
green.Println("✓ you are already running this version")
return nil
}
if !upgradeYes {
faint := color.New(color.Faint)
cmdStyle := color.New(color.FgCyan)
if isVersionLower(targetClean, currentClean) {
yellow := color.New(color.FgYellow)
yellow.Printf("↓ this will downgrade from %s to %s\n", current, target)
} else {
green := color.New(color.FgGreen)
green.Printf("↑ this will upgrade from %s to %s\n", current, target)
}
fmt.Println()
faint.Print("run ")
cmdStyle.Printf("snitch upgrade --version %s --yes", target)
faint.Println(" to proceed")
return nil
}
return performUpgrade(target)
}
func isVersionLower(v1, v2 string) bool {
parts1 := parseVersion(v1)
parts2 := parseVersion(v2)
for i := 0; i < 3; i++ {
if parts1[i] < parts2[i] {
return true
}
if parts1[i] > parts2[i] {
return false
}
}
return false
}
func parseVersion(v string) [3]int {
var parts [3]int
segments := strings.Split(v, ".")
for i := 0; i < len(segments) && i < 3; i++ {
n, err := strconv.Atoi(segments[i])
if err == nil {
parts[i] = n
}
}
return parts
}
func fetchLatestVersion() (string, error) {
url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", githubAPI, repoOwner, repoName)
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("github api returned status %d", resp.StatusCode)
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", err
}
if release.TagName == "" {
return "", fmt.Errorf("no releases found")
}
return release.TagName, nil
}
func printVersionComparison(current, latest string) {
faint := color.New(color.Faint)
version := color.New(color.FgCyan)
faint.Print("current ")
version.Println(current)
faint.Print("latest ")
version.Println(latest)
fmt.Println()
}
func printVersionComparisonTarget(current, target string) {
faint := color.New(color.Faint)
version := color.New(color.FgCyan)
faint.Print("current ")
version.Println(current)
faint.Print("target ")
version.Println(target)
fmt.Println()
}
func printUpgradeInstructions() {
bold := color.New(color.Bold)
faint := color.New(color.Faint)
cmd := color.New(color.FgCyan)
bold.Println("upgrade options:")
fmt.Println()
faint.Print(" go install ")
cmd.Printf("go install github.com/%s/%s@latest\n", repoOwner, repoName)
faint.Print(" shell script ")
cmd.Printf("curl -sSL https://raw.githubusercontent.com/%s/%s/master/install.sh | sh\n", repoOwner, repoName)
faint.Print(" arch (aur) ")
cmd.Println("yay -S snitch-bin")
faint.Print(" nix ")
cmd.Printf("nix profile upgrade --inputs-from github:%s/%s\n", repoOwner, repoName)
}
func performUpgrade(version string) error {
execPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
}
execPath, err = filepath.EvalSymlinks(execPath)
if err != nil {
return fmt.Errorf("failed to resolve executable path: %w", err)
}
goos := runtime.GOOS
goarch := runtime.GOARCH
versionClean := strings.TrimPrefix(version, "v")
archiveName := fmt.Sprintf("%s_%s_%s_%s.tar.gz", repoName, versionClean, goos, goarch)
downloadURL := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s",
repoOwner, repoName, version, archiveName)
faint := color.New(color.Faint)
cyan := color.New(color.FgCyan)
faint.Print("↓ downloading ")
cyan.Printf("%s", archiveName)
faint.Println("...")
resp, err := http.Get(downloadURL)
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
tmpDir, err := os.MkdirTemp("", "snitch-upgrade-*")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
binaryPath, err := extractBinaryFromTarGz(resp.Body, tmpDir)
if err != nil {
return fmt.Errorf("failed to extract binary: %w", err)
}
// check if we can write to the target location
targetDir := filepath.Dir(execPath)
if !isWritable(targetDir) {
yellow := color.New(color.FgYellow)
cmdStyle := color.New(color.FgCyan)
yellow.Printf("⚠ elevated permissions required to install to %s\n", targetDir)
fmt.Println()
faint.Println("run with sudo or install to a user-writable location:")
fmt.Println()
faint.Print(" sudo ")
cmdStyle.Println("sudo snitch upgrade --yes")
faint.Print(" custom dir ")
cmdStyle.Printf("curl -sSL https://raw.githubusercontent.com/%s/%s/master/install.sh | INSTALL_DIR=~/.local/bin sh\n",
repoOwner, repoName)
return nil
}
// replace the binary
backupPath := execPath + ".bak"
if err := os.Rename(execPath, backupPath); err != nil {
return fmt.Errorf("failed to backup current binary: %w", err)
}
if err := copyFile(binaryPath, execPath); err != nil {
// try to restore backup
if restoreErr := os.Rename(backupPath, execPath); restoreErr != nil {
return fmt.Errorf("failed to install new binary and restore backup: %w (restore error: %v)", err, restoreErr)
}
return fmt.Errorf("failed to install new binary: %w", err)
}
if err := os.Chmod(execPath, 0755); err != nil {
return fmt.Errorf("failed to set executable permissions: %w", err)
}
if err := os.Remove(backupPath); err != nil {
// non-fatal, just warn
yellow := color.New(color.FgYellow)
yellow.Fprintf(os.Stderr, "⚠ warning: failed to remove backup file %s: %v\n", backupPath, err)
}
green := color.New(color.FgGreen, color.Bold)
green.Printf("✓ successfully upgraded to %s\n", version)
return nil
}
func extractBinaryFromTarGz(r io.Reader, destDir string) (string, error) {
gzr, err := gzip.NewReader(r)
if err != nil {
return "", err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
if header.Typeflag != tar.TypeReg {
continue
}
// look for the snitch binary
name := filepath.Base(header.Name)
if name != repoName {
continue
}
destPath := filepath.Join(destDir, name)
outFile, err := os.Create(destPath)
if err != nil {
return "", err
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return "", err
}
outFile.Close()
return destPath, nil
}
return "", fmt.Errorf("binary not found in archive")
}
func isWritable(path string) bool {
testFile := filepath.Join(path, ".snitch-write-test")
f, err := os.Create(testFile)
if err != nil {
return false
}
f.Close()
os.Remove(testFile)
return true
}
func copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
return dstFile.Sync()
}

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!"

18
flake.lock generated
View File

@@ -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"
} }
} }
}, },

150
flake.nix
View File

@@ -1,103 +1,105 @@
{ {
description = "snitch - a friendlier ss/netstat for humans"; 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;
# go 1.25 overlay (required until nixpkgs has it) # go 1.25 binary derivation (required until nixpkgs ships it)
goOverlay = final: prev: mkGo125 = pkgs:
let let
version = "1.25.0"; version = "1.25.0";
platformInfo = { platform = {
"x86_64-linux" = { suffix = "linux-amd64"; sri = "sha256-KFKvDLIKExObNEiZLmm4aOUO0Pih5ZQO4d6eGaEjthM="; }; "x86_64-linux" = { suffix = "linux-amd64"; hash = "sha256-KFKvDLIKExObNEiZLmm4aOUO0Pih5ZQO4d6eGaEjthM="; GOOS = "linux"; GOARCH = "amd64"; };
"aarch64-linux" = { suffix = "linux-arm64"; sri = "sha256-Bd511plKJ4NpmBXuVTvVqTJ9i3mZHeNuOLZoYngvVK4="; }; "aarch64-linux" = { suffix = "linux-arm64"; hash = "sha256-Bd511plKJ4NpmBXuVTvVqTJ9i3mZHeNuOLZoYngvVK4="; GOOS = "linux"; GOARCH = "arm64"; };
"x86_64-darwin" = { suffix = "darwin-amd64"; sri = "sha256-W9YOgjA3BiwjB8cegRGAmGURZxTW9rQQWXz1B139gO8="; }; "x86_64-darwin" = { suffix = "darwin-amd64"; hash = "sha256-W9YOgjA3BiwjB8cegRGAmGURZxTW9rQQWXz1B139gO8="; GOOS = "darwin"; GOARCH = "amd64"; };
"aarch64-darwin" = { suffix = "darwin-arm64"; sri = "sha256-VEkyhEFW2Bcveij3fyrJwVojBGaYtiQ/YzsKCwDAdJw="; }; "aarch64-darwin" = { suffix = "darwin-arm64"; hash = "sha256-VEkyhEFW2Bcveij3fyrJwVojBGaYtiQ/YzsKCwDAdJw="; GOOS = "darwin"; GOARCH = "arm64"; };
}; }.${pkgs.stdenv.hostPlatform.system} or (throw "unsupported system: ${pkgs.stdenv.hostPlatform.system}");
hostSystem = prev.stdenv.hostPlatform.system;
chosen = platformInfo.${hostSystem} or (throw "unsupported system: ${hostSystem}");
in in
{ pkgs.stdenv.mkDerivation {
go_1_25 = prev.stdenvNoCC.mkDerivation { pname = "go";
pname = "go"; inherit 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; dontPatchELF = true;
installPhase = '' dontStrip = true;
runHook preInstall installPhase = ''
mkdir -p "$out"/{bin,share} runHook preInstall
tar -C "$TMPDIR" -xzf "$src" mkdir -p $out/{bin,share/go}
cp -a "$TMPDIR/go" "$out/share/go" tar -xzf $src --strip-components=1 -C $out/share/go
ln -s "$out/share/go/bin/go" "$out/bin/go" ln -s $out/share/go/bin/go $out/bin/go
ln -s "$out/share/go/bin/gofmt" "$out/bin/gofmt" ln -s $out/share/go/bin/gofmt $out/bin/gofmt
runHook postInstall runHook postInstall
''; '';
dontPatchELF = true; passthru = {
dontStrip = true; inherit (platform) GOOS GOARCH;
};
};
pkgsFor = system: import nixpkgs { inherit system; };
mkSnitch = pkgs:
let
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 in
{ {
overlays.default = final: prev: { packages = eachSystem (system:
snitch = final.callPackage ./nix/package.nix { }; let pkgs = pkgsFor system; in
};
packages = forAllSystems (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ goOverlay ];
};
in
{ {
default = pkgs.buildGoModule { default = mkSnitch pkgs;
pname = "snitch"; snitch = mkSnitch pkgs;
version = self.shortRev or self.dirtyShortRev or "dev";
src = self;
vendorHash = "sha256-BNNbA72puV0QSLkAlgn/buJJt7mIlVkbTEBhTXOg8pY=";
env.CGO_ENABLED = 0;
ldflags = [
"-s" "-w"
"-X snitch/cmd.version=${self.shortRev or "dev"}"
"-X snitch/cmd.commit=${self.shortRev or "unknown"}"
];
meta = with pkgs.lib; {
description = "a friendlier ss/netstat for humans";
homepage = "https://github.com/karol-broda/snitch";
license = licenses.mit;
platforms = platforms.linux;
mainProgram = "snitch";
};
};
} }
); );
devShells = forAllSystems (system: devShells = eachSystem (system:
let let
pkgs = import nixpkgs { pkgs = pkgsFor system;
inherit system; go = mkGo125 pkgs;
overlays = [ goOverlay ];
};
in in
{ {
default = pkgs.mkShell { default = pkgs.mkShell {
packages = [ pkgs.go_1_25 pkgs.git ]; packages = [ go pkgs.git pkgs.vhs ];
GOTOOLCHAIN = "local"; env.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=

113
install.sh Executable file
View File

@@ -0,0 +1,113 @@
#!/bin/sh
set -e
REPO="karol-broda/snitch"
BINARY_NAME="snitch"
# allow override via environment
INSTALL_DIR="${INSTALL_DIR:-}"
KEEP_QUARANTINE="${KEEP_QUARANTINE:-}"
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 unless disabled
if [ "$os" = "darwin" ] && [ -z "$KEEP_QUARANTINE" ]; then
if xattr -d com.apple.quarantine "${tmp_dir}/${BINARY_NAME}" 2>/dev/null; then
echo "warning: removed macOS quarantine attribute from binary"
fi
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

@@ -7,6 +7,7 @@ package collector
#include <sys/proc_info.h> #include <sys/proc_info.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <netinet/in.h> #include <netinet/in.h>
#include <netinet/tcp_fsm.h>
#include <arpa/inet.h> #include <arpa/inet.h>
#include <pwd.h> #include <pwd.h>
#include <stdlib.h> #include <stdlib.h>
@@ -17,11 +18,6 @@ static int get_proc_name(int pid, char *name, int namelen) {
return proc_name(pid, name, namelen); return proc_name(pid, name, namelen);
} }
// get process path by pid
static int get_proc_path(int pid, char *path, int pathlen) {
return proc_pidpath(pid, path, pathlen);
}
// get uid for a process // get uid for a process
static int get_proc_uid(int pid) { static int get_proc_uid(int pid) {
struct proc_bsdinfo info; struct proc_bsdinfo info;
@@ -33,13 +29,75 @@ static int get_proc_uid(int pid) {
} }
// get username from uid // get username from uid
static char* get_username(int uid) { static const char* get_username(int uid) {
struct passwd *pw = getpwuid(uid); struct passwd *pw = getpwuid(uid);
if (pw == NULL) { if (pw == NULL) {
return NULL; return NULL;
} }
return pw->pw_name; 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 "C"
@@ -74,23 +132,20 @@ func (dc *DefaultCollector) GetConnections() ([]Connection, error) {
return connections, nil return connections, nil
} }
// GetAllConnections returns network connections (Unix sockets not easily available via libproc) // GetAllConnections returns network connections
func GetAllConnections() ([]Connection, error) { func GetAllConnections() ([]Connection, error) {
return GetConnections() return GetConnections()
} }
func listAllPids() ([]int, error) { func listAllPids() ([]int, error) {
// first call to get buffer size needed
numPids := C.proc_listpids(C.PROC_ALL_PIDS, 0, nil, 0) numPids := C.proc_listpids(C.PROC_ALL_PIDS, 0, nil, 0)
if numPids <= 0 { if numPids <= 0 {
return nil, fmt.Errorf("proc_listpids failed") return nil, fmt.Errorf("proc_listpids failed")
} }
// allocate buffer
bufSize := C.int(numPids) * C.int(unsafe.Sizeof(C.int(0))) bufSize := C.int(numPids) * C.int(unsafe.Sizeof(C.int(0)))
buf := make([]C.int, numPids) buf := make([]C.int, numPids)
// get actual pids
numPids = C.proc_listpids(C.PROC_ALL_PIDS, 0, unsafe.Pointer(&buf[0]), bufSize) numPids = C.proc_listpids(C.PROC_ALL_PIDS, 0, unsafe.Pointer(&buf[0]), bufSize)
if numPids <= 0 { if numPids <= 0 {
return nil, fmt.Errorf("proc_listpids failed") return nil, fmt.Errorf("proc_listpids failed")
@@ -108,7 +163,6 @@ func listAllPids() ([]int, error) {
} }
func getConnectionsForPid(pid int) ([]Connection, error) { func getConnectionsForPid(pid int) ([]Connection, error) {
// get process info first
procName := getProcessName(pid) procName := getProcessName(pid)
uid := int(C.get_proc_uid(C.int(pid))) uid := int(C.get_proc_uid(C.int(pid)))
user := "" user := ""
@@ -121,7 +175,6 @@ func getConnectionsForPid(pid int) ([]Connection, error) {
} }
} }
// get file descriptors for this process
bufSize := C.proc_pidinfo(C.int(pid), C.PROC_PIDLISTFDS, 0, nil, 0) bufSize := C.proc_pidinfo(C.int(pid), C.PROC_PIDLISTFDS, 0, nil, 0)
if bufSize <= 0 { if bufSize <= 0 {
return nil, fmt.Errorf("failed to get fd list size") return nil, fmt.Errorf("failed to get fd list size")
@@ -141,7 +194,6 @@ func getConnectionsForPid(pid int) ([]Connection, error) {
for i := 0; i < numFds; i++ { for i := 0; i < numFds; i++ {
fdInfo := (*C.struct_proc_fdinfo)(unsafe.Pointer(&buf[i*fdInfoSize])) fdInfo := (*C.struct_proc_fdinfo)(unsafe.Pointer(&buf[i*fdInfoSize]))
// only interested in sockets
if fdInfo.proc_fdtype != C.PROX_FDTYPE_SOCKET { if fdInfo.proc_fdtype != C.PROX_FDTYPE_SOCKET {
continue continue
} }
@@ -156,72 +208,44 @@ func getConnectionsForPid(pid int) ([]Connection, error) {
} }
func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connection, bool) { func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connection, bool) {
var socketInfo C.struct_socket_fdinfo var info C.socket_info_t
ret := C.proc_pidfdinfo( ret := C.get_socket_info(C.int(pid), C.int(fd), &info)
C.int(pid), if ret != 0 {
C.int(fd),
C.PROC_PIDFDSOCKETINFO,
unsafe.Pointer(&socketInfo),
C.int(unsafe.Sizeof(socketInfo)),
)
if ret <= 0 {
return Connection{}, false return Connection{}, false
} }
// check socket family - only interested in IPv4 and IPv6 // only interested in IPv4 and IPv6
family := socketInfo.psi.soi_family if info.family != C.AF_INET && info.family != C.AF_INET6 {
if family != C.AF_INET && family != C.AF_INET6 {
return Connection{}, false return Connection{}, false
} }
// check socket type - only TCP and UDP // only TCP and UDP
sockType := socketInfo.psi.soi_type if info.sock_type != C.SOCK_STREAM && info.sock_type != C.SOCK_DGRAM {
if sockType != C.SOCK_STREAM && sockType != C.SOCK_DGRAM {
return Connection{}, false return Connection{}, false
} }
proto := "tcp" proto := "tcp"
if sockType == C.SOCK_DGRAM { if info.sock_type == C.SOCK_DGRAM {
proto = "udp" proto = "udp"
} }
ipVersion := "IPv4" ipVersion := "IPv4"
if family == C.AF_INET6 { if info.family == C.AF_INET6 {
ipVersion = "IPv6" ipVersion = "IPv6"
proto = proto + "6" proto = proto + "6"
} }
var laddr, raddr string var laddr, raddr string
var lport, rport int
var state string
if family == C.AF_INET { if info.family == C.AF_INET {
// IPv4 laddr = ipv4ToString(uint32(info.laddr4))
insi := socketInfo.psi.soi_proto.pri_tcp.tcpsi_ini raddr = ipv4ToString(uint32(info.raddr4))
laddr = ipv4ToString(insi.insi_laddr.ina_46.i46a_addr4.s_addr)
raddr = ipv4ToString(insi.insi_faddr.ina_46.i46a_addr4.s_addr)
lport = int(ntohs(insi.insi_lport))
rport = int(ntohs(insi.insi_fport))
if sockType == C.SOCK_STREAM {
state = tcpStateToString(int(socketInfo.psi.soi_proto.pri_tcp.tcpsi_state))
}
} else { } else {
// IPv6 laddr = ipv6ToString(info.laddr6)
insi := socketInfo.psi.soi_proto.pri_tcp.tcpsi_ini raddr = ipv6ToString(info.raddr6)
laddr = ipv6ToString(insi.insi_laddr.ina_6)
raddr = ipv6ToString(insi.insi_faddr.ina_6)
lport = int(ntohs(insi.insi_lport))
rport = int(ntohs(insi.insi_fport))
if sockType == C.SOCK_STREAM {
state = tcpStateToString(int(socketInfo.psi.soi_proto.pri_tcp.tcpsi_state))
}
} }
// normalize wildcard addresses
if laddr == "0.0.0.0" || laddr == "::" { if laddr == "0.0.0.0" || laddr == "::" {
laddr = "*" laddr = "*"
} }
@@ -229,15 +253,27 @@ func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connecti
raddr = "*" raddr = "*"
} }
state := ""
if info.sock_type == C.SOCK_STREAM {
state = tcpStateToString(int(info.state))
} else if info.sock_type == C.SOCK_DGRAM {
// udp is connectionless - infer state from remote address
if raddr == "*" && int(info.rport) == 0 {
state = "LISTEN"
} else {
state = "ESTABLISHED"
}
}
conn := Connection{ conn := Connection{
TS: time.Now(), TS: time.Now(),
Proto: proto, Proto: proto,
IPVersion: ipVersion, IPVersion: ipVersion,
State: state, State: state,
Laddr: laddr, Laddr: laddr,
Lport: lport, Lport: int(info.lport),
Raddr: raddr, Raddr: raddr,
Rport: rport, Rport: int(info.rport),
PID: pid, PID: pid,
Process: procName, Process: procName,
UID: uid, UID: uid,
@@ -257,7 +293,7 @@ func getProcessName(pid int) string {
return C.GoString(&name[0]) return C.GoString(&name[0])
} }
func ipv4ToString(addr C.in_addr_t) string { func ipv4ToString(addr uint32) string {
ip := make(net.IP, 4) ip := make(net.IP, 4)
ip[0] = byte(addr) ip[0] = byte(addr)
ip[1] = byte(addr >> 8) ip[1] = byte(addr >> 8)
@@ -266,13 +302,12 @@ func ipv4ToString(addr C.in_addr_t) string {
return ip.String() return ip.String()
} }
func ipv6ToString(addr C.struct_in6_addr) string { func ipv6ToString(addr [16]C.uint8_t) string {
ip := make(net.IP, 16) ip := make(net.IP, 16)
for i := 0; i < 16; i++ { for i := 0; i < 16; i++ {
ip[i] = byte(addr.__u6_addr.__u6_addr8[i]) ip[i] = byte(addr[i])
} }
// check for IPv4-mapped IPv6 addresses
if ip.To4() != nil { if ip.To4() != nil {
return ip.To4().String() return ip.To4().String()
} }
@@ -280,11 +315,8 @@ func ipv6ToString(addr C.struct_in6_addr) string {
return ip.String() return ip.String()
} }
func ntohs(port C.int) uint16 {
return uint16((port&0xff)<<8 | (port>>8)&0xff)
}
func tcpStateToString(state int) string { func tcpStateToString(state int) string {
// macOS TCP states from netinet/tcp_fsm.h
states := map[int]string{ states := map[int]string{
0: "CLOSED", 0: "CLOSED",
1: "LISTEN", 1: "LISTEN",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -5,6 +5,8 @@ import (
"snitch/internal/collector" "snitch/internal/collector"
"strings" "strings"
"time" "time"
"github.com/mattn/go-runewidth"
) )
func (m model) renderMain() string { func (m model) renderMain() string {
@@ -31,7 +33,7 @@ func (m model) renderTitle() string {
left := m.theme.Styles.Header.Render("snitch") left := m.theme.Styles.Header.Render("snitch")
ago := time.Since(m.lastRefresh).Round(time.Millisecond * 100) ago := time.Since(m.lastRefresh).Round(time.Millisecond * 100)
right := m.theme.Styles.Normal.Render(fmt.Sprintf("%d/%d connections %s", len(visible), total, formatDuration(ago))) right := m.theme.Styles.Normal.Render(fmt.Sprintf("%d/%d connections %s %s", len(visible), total, SymbolRefresh, formatDuration(ago)))
w := m.safeWidth() w := m.safeWidth()
gap := w - len(stripAnsi(left)) - len(stripAnsi(right)) - 2 gap := w - len(stripAnsi(left)) - len(stripAnsi(right)) - 2
@@ -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