19 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
Karol Broda
dc235a5807 feat: add darwin support 2025-12-16 23:59:43 +01:00
Karol Broda
9fcc6d47c2 chore: remove main binary 2025-12-16 23:18:05 +01:00
Karol Broda
5f76d5cd76 add nix package and installation docs 2025-12-16 23:12:59 +01:00
44 changed files with 2885 additions and 780 deletions

View File

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

3
.gitignore vendored
View File

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

39
.goreleaser-darwin.yaml Normal file
View File

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

View File

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

23
Makefile Normal file
View File

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

View File

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

View File

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

137
cmd/ls.go
View File

@@ -22,20 +22,15 @@ import (
"golang.org/x/term"
)
// ls-specific flags
var (
outputFormat string
noHeaders bool
showTimestamp bool
sortBy string
fields string
ipv4 bool
ipv6 bool
colorMode string
numeric bool
lsTCP bool
lsUDP bool
lsListen bool
lsEstab bool
plainOutput bool
)
@@ -56,39 +51,16 @@ Available filters:
}
func runListCommand(outputFormat string, args []string) {
color.Init(colorMode)
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()
rt, err := NewRuntime(args, colorMode, numeric)
if err != nil {
log.Fatal(err)
}
filteredConnections := collector.FilterConnections(connections, filters)
// apply sorting
if sortBy != "" {
collector.SortConnections(filteredConnections, collector.ParseSortOptions(sortBy))
rt.SortConnections(collector.ParseSortOptions(sortBy))
} else {
// default sort by local port
collector.SortConnections(filteredConnections, collector.SortOptions{
rt.SortConnections(collector.SortOptions{
Field: collector.SortByLport,
Direction: collector.SortAsc,
})
@@ -99,93 +71,26 @@ func runListCommand(outputFormat string, args []string) {
selectedFields = strings.Split(fields, ",")
}
switch outputFormat {
renderList(rt.Connections, outputFormat, selectedFields)
}
func renderList(connections []collector.Connection, format string, selectedFields []string) {
switch format {
case "json":
printJSON(filteredConnections)
printJSON(connections)
case "csv":
printCSV(filteredConnections, !noHeaders, showTimestamp, selectedFields)
printCSV(connections, !noHeaders, showTimestamp, selectedFields)
case "table", "wide":
if plainOutput {
printPlainTable(filteredConnections, !noHeaders, showTimestamp, selectedFields)
printPlainTable(connections, !noHeaders, showTimestamp, selectedFields)
} else {
printStyledTable(filteredConnections, !noHeaders, selectedFields)
printStyledTable(connections, !noHeaders, selectedFields)
}
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 {
laddr := c.Laddr
@@ -483,20 +388,16 @@ func init() {
cfg := config.Get()
// ls-specific flags
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(&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(&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().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)")
// shared filter flags
addFilterFlags(lsCmd)
}

View File

@@ -251,7 +251,7 @@ func TestParseFilters(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filters, err := parseFilters(tt.args)
filters, err := ParseFilterArgs(tt.args)
if tt.expectError {
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().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()
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().BoolVarP(&topTCP, "tcp", "t", false, "Show only TCP connections")
rootCmd.Flags().BoolVarP(&topUDP, "udp", "u", false, "Show only UDP connections")
rootCmd.Flags().BoolVarP(&topListen, "listen", "l", false, "Show only listening sockets")
rootCmd.Flags().BoolVarP(&topEstab, "established", "e", false, "Show only established connections")
// shared filter flags for root command
addFilterFlags(rootCmd)
}

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"`
}
// stats-specific flags
var (
statsOutputFormat string
statsInterval time.Duration
@@ -63,12 +64,10 @@ Available filters:
}
func runStatsCommand(args []string) {
filters, err := parseFilters(args)
filters, err := BuildFilters(args)
if err != nil {
log.Fatalf("Error parsing filters: %v", err)
}
filters.IPv4 = ipv4
filters.IPv6 = ipv6
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -121,13 +120,11 @@ func runStatsCommand(args []string) {
}
func generateStats(filters collector.FilterOptions) (*StatsData, error) {
connections, err := collector.GetConnections()
filteredConnections, err := FetchConnections(filters)
if err != nil {
return nil, err
}
filteredConnections := collector.FilterConnections(connections, filters)
stats := &StatsData{
Timestamp: time.Now(),
Total: len(filteredConnections),
@@ -291,10 +288,13 @@ func printStatsTable(stats *StatsData, headers bool) {
func init() {
rootCmd.AddCommand(statsCmd)
// stats-specific flags
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().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().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,
"proto": "udp",
"ipversion": "",
"state": "CONNECTED",
"state": "LISTEN",
"laddr": "0.0.0.0",
"lport": 53,
"raddr": "",

View File

@@ -1,4 +1,4 @@
PID PROCESS USER PROTO STATE LADDR LPORT RADDR RPORT
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

View File

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

View File

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

View File

@@ -47,12 +47,10 @@ Available filters:
}
func runTraceCommand(args []string) {
filters, err := parseFilters(args)
filters, err := BuildFilters(args)
if err != nil {
log.Fatalf("Error parsing filters: %v", err)
}
filters.IPv4 = ipv4
filters.IPv6 = ipv6
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -222,11 +220,14 @@ func printTraceEventHuman(event TraceEvent) {
func init() {
rootCmd.AddCommand(traceCmd)
// trace-specific flags
traceCmd.Flags().DurationVarP(&traceInterval, "interval", "i", time.Second, "Polling interval (e.g., 500ms, 2s)")
traceCmd.Flags().IntVarP(&traceCount, "count", "c", 0, "Number of events to capture (0 = unlimited)")
traceCmd.Flags().StringVarP(&traceOutputFormat, "output", "o", "human", "Output format (human, json)")
traceCmd.Flags().BoolVarP(&traceNumeric, "numeric", "n", false, "Don't resolve hostnames")
traceCmd.Flags().BoolVar(&traceTimestamp, "ts", false, "Include timestamp in output")
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"
"os"
"os/signal"
"snitch/internal/collector"
"syscall"
"time"
@@ -36,17 +35,14 @@ Available filters:
}
func runWatchCommand(args []string) {
filters, err := parseFilters(args)
filters, err := BuildFilters(args)
if err != nil {
log.Fatalf("Error parsing filters: %v", err)
}
filters.IPv4 = ipv4
filters.IPv6 = ipv6
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle interrupts gracefully
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
@@ -63,18 +59,16 @@ func runWatchCommand(args []string) {
case <-ctx.Done():
return
case <-ticker.C:
connections, err := collector.GetConnections()
connections, err := FetchConnections(filters)
if err != nil {
log.Printf("Error getting connections: %v", err)
continue
}
filteredConnections := collector.FilterConnections(connections, filters)
frame := map[string]interface{}{
"timestamp": time.Now().Format(time.RFC3339Nano),
"connections": filteredConnections,
"count": len(filteredConnections),
"connections": connections,
"count": len(connections),
}
jsonOutput, err := json.Marshal(frame)
@@ -95,8 +89,11 @@ func runWatchCommand(args []string) {
func init() {
rootCmd.AddCommand(watchCmd)
// watch-specific flags
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().BoolVarP(&ipv4, "ipv4", "4", false, "Only show IPv4 connections")
watchCmd.Flags().BoolVarP(&ipv6, "ipv6", "6", false, "Only show IPv6 connections")
// shared filter flags
addFilterFlags(watchCmd)
}

36
demo/Dockerfile Normal file
View File

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

45
demo/README.md Normal file
View File

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

BIN
demo/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

94
demo/demo.tape Normal file
View File

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

41
demo/entrypoint.sh Normal file
View File

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

24
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1756217674,
"narHash": "sha256-TH1SfSP523QI7kcPiNtMAEuwZR3Jdz0MCDXPs7TS8uo=",
"lastModified": 1765687488,
"narHash": "sha256-7YAJ6xgBAQ/Nr+7MI13Tui1ULflgAdKh63m1tfYV7+M=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4e7667a90c167f7a81d906e5a75cba4ad8bee620",
"rev": "d02bcc33948ca19b0aaa0213fe987ceec1f4ebe1",
"type": "github"
},
"original": {
@@ -18,23 +18,7 @@
},
"root": {
"inputs": {
"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"
"nixpkgs": "nixpkgs"
}
}
},

147
flake.nix
View File

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

13
go.mod
View File

@@ -11,10 +11,13 @@ require (
require (
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/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/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect
github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
@@ -43,11 +46,11 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/atomic 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/sync v0.15.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

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/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/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
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/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
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=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -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/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
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

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

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ func (f *FilterOptions) IsEmpty() bool {
}
func (f *FilterOptions) Matches(c Connection) bool {
if f.Proto != "" && !strings.EqualFold(c.Proto, f.Proto) {
if f.Proto != "" && !matchesProto(c.Proto, f.Proto) {
return false
}
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))
}
// 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 {
q := strings.ToLower(query)
return containsIgnoreCase(c.Process, q) ||

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
package tui
import (
"fmt"
"snitch/internal/collector"
"time"
tea "github.com/charmbracelet/bubbletea"
)
@@ -12,6 +14,11 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleSearchKey(msg)
}
// kill confirmation dialog
if m.showKillConfirm {
return m.handleKillConfirmKey(msg)
}
// detail view only allows closing
if m.showDetail {
return m.handleDetailKey(msg)
@@ -62,6 +69,25 @@ func (m model) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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) {
switch msg.String() {
case "q", "ctrl+c":
@@ -135,6 +161,55 @@ func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, m.fetchData()
case "?":
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

View File

@@ -1,7 +1,9 @@
package tui
import (
"fmt"
"snitch/internal/collector"
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea"
@@ -17,6 +19,15 @@ type errMsg struct {
err error
}
type killResultMsg struct {
pid int
process string
success bool
err error
}
type clearStatusMsg struct{}
func (m model) tick() tea.Cmd {
return tea.Tick(m.interval, func(t time.Time) tea.Msg {
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
import (
"fmt"
"snitch/internal/collector"
"snitch/internal/theme"
"time"
@@ -35,6 +36,17 @@ type model struct {
interval time.Duration
lastRefresh time.Time
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 {
@@ -93,6 +105,7 @@ func New(opts Options) model {
theme: theme.GetTheme(opts.Theme),
interval: interval,
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:
m.err = msg.err
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
@@ -142,7 +170,15 @@ func (m model) View() string {
if m.showDetail && m.selected != nil {
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() {
@@ -167,7 +203,8 @@ func (m *model) clampCursor() {
}
func (m model) visibleConnections() []collector.Connection {
var result []collector.Connection
var watched []collector.Connection
var unwatched []collector.Connection
for _, c := range m.connections {
if !m.matchesFilters(c) {
@@ -176,10 +213,15 @@ func (m model) visibleConnections() []collector.Connection {
if m.searchQuery != "" && !m.matchesSearch(c) {
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 {
@@ -218,3 +260,25 @@ func (m model) matchesSearch(c collector.Connection) bool {
containsIgnoreCase(c.Proto, 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"
"strings"
"time"
"github.com/mattn/go-runewidth"
)
func (m model) renderMain() string {
@@ -31,7 +33,7 @@ func (m model) renderTitle() string {
left := m.theme.Styles.Header.Render("snitch")
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()
gap := w - len(stripAnsi(left)) - len(stripAnsi(right)) - 2
@@ -45,44 +47,21 @@ func (m model) renderTitle() string {
func (m model) renderFilters() string {
var parts []string
if m.showTCP {
parts = append(parts, m.theme.Styles.Success.Render("tcp"))
} else {
parts = append(parts, m.theme.Styles.Normal.Render("tcp"))
}
parts = append(parts, m.renderFilterLabel("t", "cp", m.showTCP))
parts = append(parts, m.renderFilterLabel("u", "dp", m.showUDP))
if m.showUDP {
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(BoxVertical))
parts = append(parts, m.theme.Styles.Border.Render("│"))
if m.showListening {
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"))
}
parts = append(parts, m.renderFilterLabel("l", "isten", m.showListening))
parts = append(parts, m.renderFilterLabel("e", "stab", m.showEstablished))
parts = append(parts, m.renderFilterLabel("o", "ther", m.showOther))
left := " " + strings.Join(parts, " ")
sortLabel := sortFieldLabel(m.sortField)
sortDir := "↑"
sortDir := SymbolArrowUp
if m.sortReverse {
sortDir = "↓"
sortDir = SymbolArrowDown
}
var right string
@@ -117,12 +96,24 @@ func (m model) renderTableHeader() string {
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 {
w := m.width - 4
if w < 1 {
w = 76
}
line := " " + strings.Repeat("─", w)
line := " " + strings.Repeat(BoxHorizontal, w)
return m.theme.Styles.Border.Render(line) + "\n"
}
@@ -132,8 +123,11 @@ func (m model) renderConnections() string {
pageSize := m.pageSize()
if len(visible) == 0 {
empty := "\n " + m.theme.Styles.Normal.Render("no connections match filters") + "\n"
return empty
b.WriteString(" " + m.theme.Styles.Normal.Render("no connections match filters") + "\n")
for i := 1; i < pageSize; i++ {
b.WriteString("\n")
}
return b.String()
}
start := m.scrollOffset(pageSize, len(visible))
@@ -157,19 +151,21 @@ func (m model) renderRow(c collector.Connection, selected bool) string {
indicator := " "
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)
if process == "" {
process = ""
process = SymbolDash
}
port := fmt.Sprintf("%d", c.Lport)
proto := c.Proto
state := c.State
if state == "" {
state = ""
state = SymbolDash
}
local := c.Laddr
@@ -200,7 +196,18 @@ func (m model) renderRow(c collector.Connection, selected bool) 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
}
@@ -233,6 +240,12 @@ func (m model) renderHelp() string {
s cycle sort field
S reverse sort order
process management
──────────────────
w watch/unwatch process (highlight & track)
W clear all watched processes
K kill process (with confirmation)
other
─────
/ search
@@ -254,7 +267,7 @@ func (m model) renderDetail() string {
b.WriteString("\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 {
label string
@@ -274,7 +287,7 @@ func (m model) renderDetail() string {
for _, f := range fields {
val := f.value
if val == "" || val == "0" || val == ":0" {
val = ""
val = SymbolDash
}
line := fmt.Sprintf(" %-12s %s\n", m.theme.Styles.Header.Render(f.label), val)
b.WriteString(line)
@@ -286,6 +299,179 @@ func (m model) renderDetail() 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 {
if total <= pageSize {
return 0

BIN
main

Binary file not shown.