26 Commits

Author SHA1 Message Date
Karol Broda
6d6d057675 Merge pull request #6 from karol-broda/feat/reverse-dns-lookup 2025-12-23 16:31:03 +01:00
Karol Broda
c58f2a233d feat: add address and port resolution options 2025-12-23 16:24:29 +01:00
Karol Broda
fd4c5500ea Merge pull request #4 from karol-broda/fix/darwin-support 2025-12-23 11:21:52 +01:00
Karol Broda
df6fd318fc ci: pin determinate systems action versions 2025-12-23 11:15:43 +01:00
Karol Broda
dc7e5d435f fix: use nixos-25.11 with apple-sdk_15 for darwin support (fixes #1)
- Switch to nixos-25.11 for modern apple-sdk packages
- Use apple-sdk_15 which includes SecTrustCopyCertificateChain (macOS 12+)
- Required for Go 1.25 crypto/x509 compatibility on darwin
2025-12-23 11:09:44 +01:00
Karol Broda
c95a5ebd23 fix: add darwin/macOS platform support for nix flake (fixes #1)
- Enable CGO on darwin (required for libproc)
- Set MACOSX_DEPLOYMENT_TARGET=12.0 for Go 1.25 crypto/x509 compatibility
- Add nix build CI on macOS (macos-14 = Apple Silicon)
2025-12-23 11:01:15 +01:00
Karol Broda
755605de26 refactor: remove explicit macOS SDK framework buildInputs in flake.nix 2025-12-23 10:51:23 +01:00
Karol Broda
5b6e098e68 fix: add darwin SDK frameworks for macOS 12+ compatibility 2025-12-23 10:45:23 +01:00
Karol Broda
f20fc96c96 Merge pull request #3 from karol-broda/fix/go-and-nix-build-not-working 2025-12-23 10:02:55 +01:00
Karol Broda
7fdb1ed477 fix(build): go and nix builds not working properly 2025-12-23 10:01:01 +01:00
Karol Broda
b2be0df2f9 feat: enhance versioning and upgrade feedback for nix installations 2025-12-21 22:04:51 +01:00
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
46 changed files with 2747 additions and 484 deletions

View File

@@ -36,3 +36,21 @@ jobs:
with:
version: latest
nix-build:
strategy:
matrix:
os: [ubuntu-latest, macos-14]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@v17
- uses: DeterminateSystems/magic-nix-cache-action@v9
- name: nix flake check
run: nix flake check
- name: nix build
run: nix build

View File

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

39
.goreleaser-darwin.yaml Normal file
View File

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

View File

@@ -24,20 +24,6 @@ builds:
- -X snitch/cmd.Commit={{.ShortCommit}}
- -X snitch/cmd.Date={{.Date}}
- id: darwin
env:
- CGO_ENABLED=1
goos:
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X snitch/cmd.Version={{.Version}}
- -X snitch/cmd.Commit={{.ShortCommit}}
- -X snitch/cmd.Date={{.Date}}
archives:
- formats:
- tar.gz
@@ -47,6 +33,9 @@ archives:
{{- .Os }}_
{{- .Arch }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
files:
- LICENSE
- README.md
checksum:
name_template: "checksums.txt"
@@ -74,8 +63,27 @@ nfpms:
- deb
- rpm
- apk
ids:
- linux
aurs:
- name: snitch-bin
homepage: https://github.com/karol-broda/snitch
description: a friendlier ss/netstat for humans
maintainers:
- "Karol Broda <me@karolbroda.com>"
license: MIT
private_key: "{{ .Env.AUR_KEY }}"
git_url: "ssh://aur@aur.archlinux.org/snitch-bin.git"
depends:
- glibc
provides:
- snitch
conflicts:
- snitch
package: |-
install -Dm755 "./snitch" "${pkgdir}/usr/bin/snitch"
install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/snitch/LICENSE"
commit_msg_template: "Update to {{ .Tag }}"
skip_upload: auto
release:
github:

23
Makefile Normal file
View File

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

View File

@@ -2,6 +2,8 @@
a friendlier `ss` / `netstat` for humans. inspect network connections with a clean tui or styled tables.
![snitch demo](demo/demo.gif)
## install
### go
@@ -26,18 +28,48 @@ nix profile install 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):
```bash
# amd64
curl -L https://github.com/karol-broda/snitch/releases/latest/download/snitch_linux_amd64.tar.gz | tar xz
sudo mv snitch /usr/local/bin/
- **linux:** `snitch_<version>_linux_<arch>.tar.gz` or `.deb`/`.rpm`/`.apk`
- **macos:** `snitch_<version>_darwin_<arch>.tar.gz`
# or install .deb/.rpm/.apk from releases
```bash
tar xzf snitch_*.tar.gz
sudo mv snitch /usr/local/bin/
```
> **macos:** if blocked with "cannot be opened because the developer cannot be verified", run:
>
> ```bash
> xattr -d com.apple.quarantine /usr/local/bin/snitch
> ```
## quick start
```bash
@@ -71,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
@@ -111,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:
@@ -169,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

@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"snitch/internal/testutil"
"github.com/karol-broda/snitch/internal/testutil"
)
// TestCLIContract tests the CLI interface contracts as specified in the README
@@ -71,7 +71,7 @@ func TestCLIContract(t *testing.T) {
name: "version",
args: []string{"version"},
expectExitCode: 0,
expectStdout: []string{"snitch", "commit:", "built:"},
expectStdout: []string{"snitch", "commit", "built"},
expectStderr: nil,
description: "version command should show version information",
},
@@ -361,10 +361,11 @@ func resetGlobalFlags() {
showTimestamp = false
sortBy = ""
fields = ""
ipv4 = false
ipv6 = false
filterIPv4 = false
filterIPv6 = false
colorMode = "auto"
numeric = false
resolveAddrs = true
resolvePorts = false
}
// TestEnvironmentVariables tests that environment variables are properly handled

View File

@@ -9,8 +9,8 @@ import (
"strings"
"testing"
"snitch/internal/collector"
"snitch/internal/testutil"
"github.com/karol-broda/snitch/internal/collector"
"github.com/karol-broda/snitch/internal/testutil"
)
var updateGolden = flag.Bool("update-golden", false, "Update golden files")

View File

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

159
cmd/ls.go
View File

@@ -8,10 +8,10 @@ import (
"log"
"os"
"os/exec"
"snitch/internal/collector"
"snitch/internal/color"
"snitch/internal/config"
"snitch/internal/resolver"
"github.com/karol-broda/snitch/internal/collector"
"github.com/karol-broda/snitch/internal/color"
"github.com/karol-broda/snitch/internal/config"
"github.com/karol-broda/snitch/internal/resolver"
"strconv"
"strings"
"text/tabwriter"
@@ -22,20 +22,16 @@ 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
resolveAddrs bool
resolvePorts bool
plainOutput bool
)
@@ -56,39 +52,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)
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 +72,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
@@ -193,14 +99,18 @@ func getFieldMap(c collector.Connection) map[string]string {
lport := strconv.Itoa(c.Lport)
rport := strconv.Itoa(c.Rport)
// Apply name resolution if not in numeric mode
if !numeric {
// apply address resolution
if resolveAddrs {
if resolvedLaddr := resolver.ResolveAddr(c.Laddr); resolvedLaddr != c.Laddr {
laddr = resolvedLaddr
}
if resolvedRaddr := resolver.ResolveAddr(c.Raddr); resolvedRaddr != c.Raddr && c.Raddr != "*" && c.Raddr != "" {
raddr = resolvedRaddr
}
}
// apply port resolution
if resolvePorts {
if resolvedLport := resolver.ResolvePort(c.Lport, c.Proto); resolvedLport != strconv.Itoa(c.Lport) {
lport = resolvedLport
}
@@ -483,20 +393,17 @@ 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().BoolVar(&resolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
lsCmd.Flags().BoolVar(&resolvePorts, "resolve-ports", false, "Resolve port numbers to service names")
lsCmd.Flags().BoolVarP(&plainOutput, "plain", "p", false, "Plain output (parsable, no styling)")
// shared filter flags
addFilterFlags(lsCmd)
}

View File

@@ -4,8 +4,8 @@ import (
"strings"
"testing"
"snitch/internal/collector"
"snitch/internal/testutil"
"github.com/karol-broda/snitch/internal/collector"
"github.com/karol-broda/snitch/internal/testutil"
)
func TestLsCommand_EmptyResults(t *testing.T) {
@@ -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

@@ -3,7 +3,7 @@ package cmd
import (
"fmt"
"os"
"snitch/internal/config"
"github.com/karol-broda/snitch/internal/config"
"github.com/spf13/cobra"
)
@@ -40,12 +40,13 @@ 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")
rootCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
rootCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names")
// shared filter flags for root command
addFilterFlags(rootCmd)
}

203
cmd/runtime.go Normal file
View File

@@ -0,0 +1,203 @@
package cmd
import (
"fmt"
"github.com/karol-broda/snitch/internal/collector"
"github.com/karol-broda/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
ResolveAddrs bool
ResolvePorts 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) (*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,
ResolveAddrs: resolveAddrs,
ResolvePorts: resolvePorts,
}, 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

@@ -8,7 +8,7 @@ import (
"log"
"os"
"os/signal"
"snitch/internal/collector"
"github.com/karol-broda/snitch/internal/collector"
"sort"
"strconv"
"strings"
@@ -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

@@ -2,21 +2,20 @@ package cmd
import (
"log"
"snitch/internal/config"
"snitch/internal/tui"
"github.com/karol-broda/snitch/internal/config"
"github.com/karol-broda/snitch/internal/tui"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
// top-specific flags
var (
topTheme string
topInterval time.Duration
topTCP bool
topUDP bool
topListen bool
topEstab bool
topResolveAddrs bool
topResolvePorts bool
)
var topCmd = &cobra.Command{
@@ -33,14 +32,16 @@ var topCmd = &cobra.Command{
opts := tui.Options{
Theme: theme,
Interval: topInterval,
ResolveAddrs: topResolveAddrs,
ResolvePorts: topResolvePorts,
}
// 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 +58,13 @@ 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")
topCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
topCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names")
// shared filter flags
addFilterFlags(topCmd)
}

View File

@@ -7,8 +7,8 @@ import (
"log"
"os"
"os/signal"
"snitch/internal/collector"
"snitch/internal/resolver"
"github.com/karol-broda/snitch/internal/collector"
"github.com/karol-broda/snitch/internal/resolver"
"strings"
"syscall"
"time"
@@ -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)
}

688
cmd/upgrade.go Normal file
View File

@@ -0,0 +1,688 @@
package cmd
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/karol-broda/snitch/internal/tui"
)
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"`
}
type githubCommit struct {
SHA string `json:"sha"`
}
type githubCompare struct {
Status string `json:"status"`
AheadBy int `json:"ahead_by"`
BehindBy int `json:"behind_by"`
TotalCommits int `json:"total_commits"`
}
func runUpgrade(cmd *cobra.Command, args []string) error {
current := Version
nixInstall := isNixInstall()
nixVersion := isNixVersion(current)
if upgradeVersion != "" {
if nixInstall || nixVersion {
return handleNixSpecificVersion(current, upgradeVersion)
}
return handleSpecificVersion(current, upgradeVersion)
}
latest, err := fetchLatestVersion()
if err != nil {
return fmt.Errorf("failed to check for updates: %w", err)
}
if nixInstall || nixVersion {
return handleNixUpgrade(current, latest)
}
currentClean := strings.TrimPrefix(current, "v")
latestClean := strings.TrimPrefix(latest, "v")
printVersionComparison(current, latest)
if currentClean == latestClean {
green := color.New(color.FgGreen)
green.Println(tui.SymbolSuccess + " you are running the latest version")
return nil
}
if current == "dev" {
yellow := color.New(color.FgYellow)
yellow.Println(tui.SymbolWarning + " 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(tui.SymbolSuccess+" update available: %s "+tui.SymbolArrowRight+" %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(tui.SymbolWarning+" 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(tui.SymbolSuccess + " 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(tui.SymbolArrowDown+" this will downgrade from %s to %s\n", current, target)
} else {
green := color.New(color.FgGreen)
green.Printf(tui.SymbolArrowUp+" 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 handleNixUpgrade(current, latest string) error {
faint := color.New(color.Faint)
version := color.New(color.FgCyan)
currentCommit := extractCommitFromVersion(current)
dirty := isNixDirty(current)
faint.Print("current ")
version.Print(current)
if currentCommit != "" {
faint.Printf(" (commit %s)", currentCommit)
}
fmt.Println()
faint.Print("latest ")
version.Println(latest)
fmt.Println()
if dirty {
yellow := color.New(color.FgYellow)
yellow.Println(tui.SymbolWarning + " you are running a dirty nix build (uncommitted changes)")
fmt.Println()
printNixUpgradeInstructions()
return nil
}
if currentCommit == "" {
yellow := color.New(color.FgYellow)
yellow.Println(tui.SymbolWarning + " this is a nix installation")
faint.Println(" nix store is immutable; use nix commands to upgrade")
fmt.Println()
printNixUpgradeInstructions()
return nil
}
releaseCommit, err := fetchCommitForTag(latest)
if err != nil {
faint.Printf(" (could not fetch release commit: %v)\n", err)
fmt.Println()
yellow := color.New(color.FgYellow)
yellow.Println(tui.SymbolWarning + " this is a nix installation")
faint.Println(" nix store is immutable; use nix commands to upgrade")
fmt.Println()
printNixUpgradeInstructions()
return nil
}
releaseShort := releaseCommit
if len(releaseShort) > 7 {
releaseShort = releaseShort[:7]
}
if strings.HasPrefix(releaseCommit, currentCommit) || strings.HasPrefix(currentCommit, releaseShort) {
green := color.New(color.FgGreen)
green.Printf(tui.SymbolSuccess+" you are running %s (commit %s)\n", latest, releaseShort)
return nil
}
comparison, err := compareCommits(latest, currentCommit)
if err != nil {
green := color.New(color.FgGreen, color.Bold)
green.Printf(tui.SymbolSuccess+" update available: %s "+tui.SymbolArrowRight+" %s\n", currentCommit, latest)
faint.Printf(" your commit: %s\n", currentCommit)
faint.Printf(" release: %s (%s)\n", releaseShort, latest)
fmt.Println()
yellow := color.New(color.FgYellow)
yellow.Println(tui.SymbolWarning + " this is a nix installation")
faint.Println(" nix store is immutable; use nix commands to upgrade")
fmt.Println()
printNixUpgradeInstructions()
return nil
}
if comparison.AheadBy > 0 {
cyan := color.New(color.FgCyan)
cyan.Printf(tui.SymbolArrowUp+" you are %d commit(s) ahead of %s\n", comparison.AheadBy, latest)
faint.Printf(" your commit: %s\n", currentCommit)
faint.Printf(" release: %s (%s)\n", releaseShort, latest)
fmt.Println()
faint.Println("you are running a newer build than the latest release")
return nil
}
if comparison.BehindBy > 0 {
green := color.New(color.FgGreen, color.Bold)
green.Printf(tui.SymbolSuccess+" update available: %d commit(s) behind %s\n", comparison.BehindBy, latest)
faint.Printf(" your commit: %s\n", currentCommit)
faint.Printf(" release: %s (%s)\n", releaseShort, latest)
fmt.Println()
yellow := color.New(color.FgYellow)
yellow.Println(tui.SymbolWarning + " this is a nix installation")
faint.Println(" nix store is immutable; use nix commands to upgrade")
fmt.Println()
printNixUpgradeInstructions()
return nil
}
green := color.New(color.FgGreen)
green.Printf(tui.SymbolSuccess+" you are running %s (commit %s)\n", latest, releaseShort)
return nil
}
func handleNixSpecificVersion(current, target string) error {
if !strings.HasPrefix(target, "v") {
target = "v" + target
}
printVersionComparisonTarget(current, target)
yellow := color.New(color.FgYellow)
yellow.Println(tui.SymbolWarning + " this is a nix installation")
faint := color.New(color.Faint)
faint.Println(" nix store is immutable; in-place upgrades are not supported")
fmt.Println()
bold := color.New(color.Bold)
cmd := color.New(color.FgCyan)
bold.Println("to install a specific version with nix:")
fmt.Println()
faint.Print(" specific ref ")
cmd.Printf("nix profile install github:%s/%s/%s\n", repoOwner, repoName, target)
faint.Print(" latest ")
cmd.Printf("nix profile install github:%s/%s\n", repoOwner, repoName)
return nil
}
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)
}
if strings.HasPrefix(execPath, "/nix/store/") {
yellow := color.New(color.FgYellow)
yellow.Println(tui.SymbolWarning + " cannot perform in-place upgrade for nix installation")
fmt.Println()
printNixUpgradeInstructions()
return nil
}
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(tui.SymbolDownload + " 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)
}
if goos == "darwin" {
removeQuarantine(binaryPath)
}
// 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(tui.SymbolWarning+" 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, tui.SymbolWarning + " warning: failed to remove backup file %s: %v\n", backupPath, err)
}
green := color.New(color.FgGreen, color.Bold)
green.Printf(tui.SymbolSuccess + " 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()
}
func removeQuarantine(path string) {
cmd := exec.Command("xattr", "-d", "com.apple.quarantine", path)
if err := cmd.Run(); err == nil {
faint := color.New(color.Faint)
faint.Println(" removed macOS quarantine attribute")
}
}
func isNixInstall() bool {
execPath, err := os.Executable()
if err != nil {
return false
}
resolved, err := filepath.EvalSymlinks(execPath)
if err != nil {
return false
}
return strings.HasPrefix(resolved, "/nix/store/")
}
var nixVersionPattern = regexp.MustCompile(`^nix-([a-f0-9]+)(-dirty)?$`)
var commitHashPattern = regexp.MustCompile(`^[a-f0-9]{7,40}$`)
func isNixVersion(version string) bool {
if nixVersionPattern.MatchString(version) {
return true
}
if commitHashPattern.MatchString(version) {
return true
}
return false
}
func extractCommitFromVersion(version string) string {
matches := nixVersionPattern.FindStringSubmatch(version)
if len(matches) >= 2 {
return matches[1]
}
if commitHashPattern.MatchString(version) {
return version
}
return ""
}
func isNixDirty(version string) bool {
return strings.HasSuffix(version, "-dirty")
}
func fetchCommitForTag(tag string) (string, error) {
url := fmt.Sprintf("%s/repos/%s/%s/commits/%s", githubAPI, repoOwner, repoName, tag)
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 commit githubCommit
if err := json.NewDecoder(resp.Body).Decode(&commit); err != nil {
return "", err
}
return commit.SHA, nil
}
func compareCommits(base, head string) (*githubCompare, error) {
url := fmt.Sprintf("%s/repos/%s/%s/compare/%s...%s", githubAPI, repoOwner, repoName, base, head)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("github api returned status %d", resp.StatusCode)
}
var compare githubCompare
if err := json.NewDecoder(resp.Body).Decode(&compare); err != nil {
return nil, err
}
return &compare, nil
}
func printNixUpgradeInstructions() {
bold := color.New(color.Bold)
faint := color.New(color.Faint)
cmd := color.New(color.FgCyan)
bold.Println("nix upgrade options:")
fmt.Println()
faint.Print(" flake profile ")
cmd.Printf("nix profile install github:%s/%s\n", repoOwner, repoName)
faint.Print(" flake update ")
cmd.Println("nix flake update snitch (in your system/home-manager config)")
faint.Print(" rebuild ")
cmd.Println("nixos-rebuild switch or home-manager switch")
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"runtime"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
@@ -17,11 +18,25 @@ var versionCmd = &cobra.Command{
Use: "version",
Short: "Show version/build info",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("snitch %s\n", Version)
fmt.Printf(" commit: %s\n", Commit)
fmt.Printf(" built: %s\n", Date)
fmt.Printf(" go: %s\n", runtime.Version())
fmt.Printf(" os: %s/%s\n", runtime.GOOS, runtime.GOARCH)
bold := color.New(color.Bold)
cyan := color.New(color.FgCyan)
faint := color.New(color.Faint)
bold.Print("snitch ")
cyan.Println(Version)
fmt.Println()
faint.Print(" commit ")
fmt.Println(Commit)
faint.Print(" built ")
fmt.Println(Date)
faint.Print(" go ")
fmt.Println(runtime.Version())
faint.Print(" os ")
fmt.Printf("%s/%s\n", runtime.GOOS, runtime.GOARCH)
},
}

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

26
flake.lock generated
View File

@@ -2,39 +2,23 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1765687488,
"narHash": "sha256-7YAJ6xgBAQ/Nr+7MI13Tui1ULflgAdKh63m1tfYV7+M=",
"lastModified": 1766201043,
"narHash": "sha256-eplAP+rorKKd0gNjV3rA6+0WMzb1X1i16F5m5pASnjA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d02bcc33948ca19b0aaa0213fe987ceec1f4ebe1",
"rev": "b3aad468604d3e488d627c0b43984eb60e75e782",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"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"
}
}
},

125
flake.nix
View File

@@ -1,103 +1,110 @@
{
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.11";
outputs = { self, nixpkgs, systems }:
outputs = { self, nixpkgs }:
let
supportedSystems = import systems;
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
eachSystem = nixpkgs.lib.genAttrs systems;
# go 1.25 overlay (required until nixpkgs has it)
goOverlay = final: prev:
# 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="; };
"x86_64-darwin" = { suffix = "darwin-amd64"; sri = "sha256-W9YOgjA3BiwjB8cegRGAmGURZxTW9rQQWXz1B139gO8="; };
"aarch64-darwin" = { suffix = "darwin-arm64"; sri = "sha256-VEkyhEFW2Bcveij3fyrJwVojBGaYtiQ/YzsKCwDAdJw="; };
};
hostSystem = prev.stdenv.hostPlatform.system;
chosen = platformInfo.${hostSystem} or (throw "unsupported system: ${hostSystem}");
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 = prev.stdenvNoCC.mkDerivation {
pkgs.stdenv.mkDerivation {
pname = "go";
inherit version;
src = prev.fetchurl {
url = "https://go.dev/dl/go${version}.${chosen.suffix}.tar.gz";
hash = chosen.sri;
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;
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;
};
};
in
{
overlays.default = final: prev: {
snitch = final.callPackage ./nix/package.nix { };
};
packages = forAllSystems (system:
pkgsFor = system: import nixpkgs { inherit system; };
mkSnitch = pkgs:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ goOverlay ];
};
rev = self.shortRev or self.dirtyShortRev or "unknown";
version = "nix-${rev}";
isDarwin = pkgs.stdenv.isDarwin;
go = mkGo125 pkgs;
buildGoModule = pkgs.buildGoModule.override { inherit go; };
in
{
default = pkgs.buildGoModule {
buildGoModule {
pname = "snitch";
version = self.shortRev or self.dirtyShortRev or "dev";
inherit version;
src = self;
vendorHash = "sha256-BNNbA72puV0QSLkAlgn/buJJt7mIlVkbTEBhTXOg8pY=";
env.CGO_ENABLED = 0;
vendorHash = "sha256-fX3wOqeOgjH7AuWGxPQxJ+wbhp240CW8tiF4rVUUDzk=";
# darwin requires cgo for libproc, linux uses pure go with /proc
env.CGO_ENABLED = if isDarwin then "1" else "0";
env.GOTOOLCHAIN = "local";
# darwin: use macOS 15 SDK for SecTrustCopyCertificateChain (Go 1.25 crypto/x509)
buildInputs = pkgs.lib.optionals isDarwin [ pkgs.apple-sdk_15 ];
ldflags = [
"-s" "-w"
"-X snitch/cmd.version=${self.shortRev or "dev"}"
"-X snitch/cmd.commit=${self.shortRev or "unknown"}"
"-s"
"-w"
"-X snitch/cmd.Version=${version}"
"-X snitch/cmd.Commit=${rev}"
"-X snitch/cmd.Date=${self.lastModifiedDate or "unknown"}"
];
meta = with pkgs.lib; {
meta = {
description = "a friendlier ss/netstat for humans";
homepage = "https://github.com/karol-broda/snitch";
license = licenses.mit;
platforms = platforms.linux;
license = pkgs.lib.licenses.mit;
platforms = pkgs.lib.platforms.linux ++ pkgs.lib.platforms.darwin;
mainProgram = "snitch";
};
};
in
{
packages = eachSystem (system:
let pkgs = pkgsFor system; in
{
default = mkSnitch pkgs;
snitch = mkSnitch pkgs;
}
);
devShells = forAllSystems (system:
devShells = eachSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ goOverlay ];
};
pkgs = pkgsFor system;
go = mkGo125 pkgs;
in
{
default = pkgs.mkShell {
packages = [ pkgs.go_1_25 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;
};
};
}

15
go.mod
View File

@@ -1,4 +1,4 @@
module snitch
module github.com/karol-broda/snitch
go 1.24.0
@@ -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

@@ -246,11 +246,6 @@ func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connecti
raddr = ipv6ToString(info.raddr6)
}
state := ""
if info.sock_type == C.SOCK_STREAM {
state = tcpStateToString(int(info.state))
}
if laddr == "0.0.0.0" || laddr == "::" {
laddr = "*"
}
@@ -258,6 +253,18 @@ func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connecti
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,

View File

@@ -226,6 +226,13 @@ func parseProcNet(path, proto string, ipVersion int, inodeMap map[int64]*process
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,
@@ -277,13 +284,22 @@ func parseState(hexState, proto string) string {
if s, exists := tcpStates[state]; exists {
return s
}
} else {
if state == 0x07 {
return "CLOSE"
}
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 ""
}

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

@@ -5,7 +5,7 @@ import (
"path/filepath"
"testing"
"snitch/internal/collector"
"github.com/karol-broda/snitch/internal/collector"
)
// TestCollector wraps MockCollector for use in tests

View File

@@ -1,9 +1,8 @@
package tui
import (
"fmt"
"regexp"
"snitch/internal/collector"
"github.com/karol-broda/snitch/internal/collector"
"strings"
)
@@ -14,7 +13,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`)
@@ -44,10 +43,3 @@ func sortFieldLabel(f collector.SortField) string {
}
}
func formatRemote(addr string, port int) string {
if addr == "" || addr == "*" || port == 0 {
return "-"
}
return fmt.Sprintf("%s:%d", addr, port)
}

View File

@@ -1,7 +1,9 @@
package tui
import (
"snitch/internal/collector"
"fmt"
"github.com/karol-broda/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,77 @@ 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
}
}
// toggle address resolution
case "n":
m.resolveAddrs = !m.resolveAddrs
if m.resolveAddrs {
m.statusMessage = "address resolution: on"
} else {
m.statusMessage = "address resolution: off"
}
m.statusExpiry = time.Now().Add(2 * time.Second)
return m, clearStatusAfter(2 * time.Second)
// toggle port resolution
case "N":
m.resolvePorts = !m.resolvePorts
if m.resolvePorts {
m.statusMessage = "port resolution: on"
} else {
m.statusMessage = "port resolution: off"
}
m.statusExpiry = time.Now().Add(2 * time.Second)
return m, clearStatusAfter(2 * time.Second)
}
return m, nil

View File

@@ -1,7 +1,9 @@
package tui
import (
"snitch/internal/collector"
"fmt"
"github.com/karol-broda/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,8 +1,9 @@
package tui
import (
"snitch/internal/collector"
"snitch/internal/theme"
"fmt"
"github.com/karol-broda/snitch/internal/collector"
"github.com/karol-broda/snitch/internal/theme"
"time"
tea "github.com/charmbracelet/bubbletea"
@@ -27,6 +28,10 @@ type model struct {
sortField collector.SortField
sortReverse bool
// display options
resolveAddrs bool // when true, resolve IP addresses to hostnames
resolvePorts bool // when true, resolve port numbers to service names
// ui state
theme *theme.Theme
showHelp bool
@@ -35,6 +40,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 {
@@ -46,6 +62,8 @@ type Options struct {
Established bool
Other bool
FilterSet bool // true if user specified any filter flags
ResolveAddrs bool // when true, resolve IP addresses to hostnames
ResolvePorts bool // when true, resolve port numbers to service names
}
func New(opts Options) model {
@@ -90,9 +108,12 @@ func New(opts Options) model {
showEstablished: showEstablished,
showOther: showOther,
sortField: collector.SortByLport,
resolveAddrs: opts.ResolveAddrs,
resolvePorts: opts.ResolvePorts,
theme: theme.GetTheme(opts.Theme),
interval: interval,
lastRefresh: time.Now(),
watchedPIDs: make(map[int]bool),
}
}
@@ -127,6 +148,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 +178,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 +211,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 +221,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 +268,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)
}

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

@@ -0,0 +1,432 @@
package tui
import (
"github.com/karol-broda/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")
}
}
func TestTUI_ResolutionOptions(t *testing.T) {
// test default resolution settings
m := New(Options{Theme: "dark", Interval: time.Hour})
if m.resolveAddrs != false {
t.Error("expected resolveAddrs to be false by default (must be explicitly set)")
}
if m.resolvePorts != false {
t.Error("expected resolvePorts to be false by default")
}
// test with explicit options
m2 := New(Options{
Theme: "dark",
Interval: time.Hour,
ResolveAddrs: true,
ResolvePorts: true,
})
if m2.resolveAddrs != true {
t.Error("expected resolveAddrs to be true when set")
}
if m2.resolvePorts != true {
t.Error("expected resolvePorts to be true when set")
}
}
func TestTUI_ToggleResolution(t *testing.T) {
m := New(Options{Theme: "dark", Interval: time.Hour, ResolveAddrs: true})
if m.resolveAddrs != true {
t.Fatal("expected resolveAddrs to be true initially")
}
// toggle address resolution with 'n'
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
m = newModel.(model)
if m.resolveAddrs != false {
t.Error("expected resolveAddrs to be false after toggle")
}
// toggle back
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
m = newModel.(model)
if m.resolveAddrs != true {
t.Error("expected resolveAddrs to be true after second toggle")
}
// toggle port resolution with 'N'
if m.resolvePorts != false {
t.Fatal("expected resolvePorts to be false initially")
}
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}})
m = newModel.(model)
if m.resolvePorts != true {
t.Error("expected resolvePorts to be true after toggle")
}
// toggle back
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}})
m = newModel.(model)
if m.resolvePorts != false {
t.Error("expected resolvePorts to be false after second toggle")
}
}
func TestTUI_ResolveAddrHelper(t *testing.T) {
m := New(Options{Theme: "dark", Interval: time.Hour})
m.resolveAddrs = false
// when resolution is off, should return original address
addr := m.resolveAddr("192.168.1.1")
if addr != "192.168.1.1" {
t.Errorf("expected original address when resolution off, got %s", addr)
}
// empty and wildcard addresses should pass through unchanged
if m.resolveAddr("") != "" {
t.Error("expected empty string to pass through")
}
if m.resolveAddr("*") != "*" {
t.Error("expected wildcard to pass through")
}
}
func TestTUI_ResolvePortHelper(t *testing.T) {
m := New(Options{Theme: "dark", Interval: time.Hour})
m.resolvePorts = false
// when resolution is off, should return port number as string
port := m.resolvePort(80, "tcp")
if port != "80" {
t.Errorf("expected '80' when resolution off, got %s", port)
}
port = m.resolvePort(443, "tcp")
if port != "443" {
t.Errorf("expected '443' when resolution off, got %s", port)
}
}
func TestTUI_FormatRemoteHelper(t *testing.T) {
m := New(Options{Theme: "dark", Interval: time.Hour})
m.resolveAddrs = false
m.resolvePorts = false
// empty/wildcard addresses should return dash
if m.formatRemote("", 80, "tcp") != "-" {
t.Error("expected dash for empty address")
}
if m.formatRemote("*", 80, "tcp") != "-" {
t.Error("expected dash for wildcard address")
}
if m.formatRemote("192.168.1.1", 0, "tcp") != "-" {
t.Error("expected dash for zero port")
}
// valid address:port should format correctly
result := m.formatRemote("192.168.1.1", 443, "tcp")
if result != "192.168.1.1:443" {
t.Errorf("expected '192.168.1.1:443', got %s", result)
}
}

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

@@ -0,0 +1,38 @@
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
SymbolDownload = string('\u21E9') // downwards white arrow
// 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

@@ -2,9 +2,13 @@ package tui
import (
"fmt"
"snitch/internal/collector"
"github.com/karol-broda/snitch/internal/collector"
"github.com/karol-broda/snitch/internal/resolver"
"strconv"
"strings"
"time"
"github.com/mattn/go-runewidth"
)
func (m model) renderMain() string {
@@ -31,7 +35,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 +49,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 +98,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 +125,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,27 +153,29 @@ 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)
port := truncate(m.resolvePort(c.Lport, c.Proto), cols.port)
proto := c.Proto
state := c.State
if state == "" {
state = ""
state = SymbolDash
}
local := c.Laddr
local := truncate(m.resolveAddr(c.Laddr), cols.local)
if local == "*" || local == "" {
local = "*"
}
remote := formatRemote(c.Raddr, c.Rport)
remote := truncate(m.formatRemote(c.Raddr, c.Rport, c.Proto), cols.remote)
// apply styling
protoStyled := m.theme.Styles.GetProtoStyle(proto).Render(fmt.Sprintf("%-*s", cols.proto, proto))
@@ -189,8 +187,8 @@ func (m model) renderRow(c collector.Connection, selected bool) string {
cols.port, port,
protoStyled,
stateStyled,
cols.local, truncate(local, cols.local),
truncate(remote, cols.remote))
cols.local, local,
remote)
if selected {
return m.theme.Styles.Selected.Render(row) + "\n"
@@ -200,7 +198,33 @@ 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 n/N dns 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)
}
// show dns resolution status
var resolveStatus string
if m.resolveAddrs && m.resolvePorts {
resolveStatus = "all"
} else if m.resolveAddrs {
resolveStatus = "addrs"
} else if m.resolvePorts {
resolveStatus = "ports"
} else {
resolveStatus = "off"
}
if resolveStatus != "addrs" { // addrs is the default, don't show
left += m.theme.Styles.Normal.Render(fmt.Sprintf(" dns: %s", resolveStatus))
}
return left
}
@@ -233,6 +257,17 @@ func (m model) renderHelp() string {
s cycle sort field
S reverse sort order
display
───────
n toggle address resolution (dns)
N toggle port resolution (service names)
process management
──────────────────
w watch/unwatch process (highlight & track)
W clear all watched processes
K kill process (with confirmation)
other
─────
/ search
@@ -254,7 +289,12 @@ 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")
localAddr := m.resolveAddr(c.Laddr)
localPort := m.resolvePort(c.Lport, c.Proto)
remoteAddr := m.resolveAddr(c.Raddr)
remotePort := m.resolvePort(c.Rport, c.Proto)
fields := []struct {
label string
@@ -265,8 +305,8 @@ func (m model) renderDetail() string {
{"user", c.User},
{"protocol", c.Proto},
{"state", c.State},
{"local", fmt.Sprintf("%s:%d", c.Laddr, c.Lport)},
{"remote", fmt.Sprintf("%s:%d", c.Raddr, c.Rport)},
{"local", fmt.Sprintf("%s:%s", localAddr, localPort)},
{"remote", fmt.Sprintf("%s:%s", remoteAddr, remotePort)},
{"interface", c.Interface},
{"inode", fmt.Sprintf("%d", c.Inode)},
}
@@ -274,7 +314,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 +326,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
@@ -312,23 +525,72 @@ type columns struct {
}
func (m model) columnWidths() columns {
available := m.safeWidth() - 16
// minimum widths (header lengths + padding)
c := columns{
process: 16,
port: 6,
proto: 5,
state: 11,
local: 15,
remote: 20,
process: 7, // "PROCESS"
port: 4, // "PORT"
proto: 5, // "PROTO"
state: 5, // "STATE"
local: 5, // "LOCAL"
remote: 6, // "REMOTE"
}
used := c.process + c.port + c.proto + c.state + c.local + c.remote
extra := available - used
// scan visible connections to find max content width for each column
visible := m.visibleConnections()
for _, conn := range visible {
if len(conn.Process) > c.process {
c.process = len(conn.Process)
}
if extra > 0 {
c.process += extra / 3
c.remote += extra - extra/3
port := m.resolvePort(conn.Lport, conn.Proto)
if len(port) > c.port {
c.port = len(port)
}
if len(conn.Proto) > c.proto {
c.proto = len(conn.Proto)
}
if len(conn.State) > c.state {
c.state = len(conn.State)
}
local := m.resolveAddr(conn.Laddr)
if len(local) > c.local {
c.local = len(local)
}
remote := m.formatRemote(conn.Raddr, conn.Rport, conn.Proto)
if len(remote) > c.remote {
c.remote = len(remote)
}
}
// calculate total and available width
spacing := 12 // 2 spaces between each of 6 columns
indicator := 2
margin := 2
available := m.safeWidth() - spacing - indicator - margin
total := c.process + c.port + c.proto + c.state + c.local + c.remote
// if content fits, we're done
if total <= available {
return c
}
// content exceeds available space - need to shrink columns proportionally
// fixed columns that shouldn't shrink much: port, proto, state
fixedWidth := c.port + c.proto + c.state
flexibleAvailable := available - fixedWidth
// distribute flexible space between process, local, remote
flexibleTotal := c.process + c.local + c.remote
if flexibleTotal > 0 && flexibleAvailable > 0 {
ratio := float64(flexibleAvailable) / float64(flexibleTotal)
c.process = max(7, int(float64(c.process)*ratio))
c.local = max(5, int(float64(c.local)*ratio))
c.remote = max(6, int(float64(c.remote)*ratio))
}
return c
@@ -350,3 +612,29 @@ func formatDuration(d time.Duration) string {
}
return fmt.Sprintf("%.0fm", d.Minutes())
}
func (m model) resolveAddr(addr string) string {
if !m.resolveAddrs {
return addr
}
if addr == "" || addr == "*" {
return addr
}
return resolver.ResolveAddr(addr)
}
func (m model) resolvePort(port int, proto string) string {
if !m.resolvePorts {
return strconv.Itoa(port)
}
return resolver.ResolvePort(port, proto)
}
func (m model) formatRemote(addr string, port int, proto string) string {
if addr == "" || addr == "*" || port == 0 {
return "-"
}
resolvedAddr := m.resolveAddr(addr)
resolvedPort := m.resolvePort(port, proto)
return fmt.Sprintf("%s:%s", resolvedAddr, resolvedPort)
}

View File

@@ -1,7 +1,7 @@
package main
import (
"snitch/cmd"
"github.com/karol-broda/snitch/cmd"
)
func main() {