11 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
24 changed files with 692 additions and 107 deletions

View File

@@ -36,3 +36,21 @@ jobs:
with: with:
version: latest 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

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

View File

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

View File

@@ -8,10 +8,10 @@ import (
"log" "log"
"os" "os"
"os/exec" "os/exec"
"snitch/internal/collector" "github.com/karol-broda/snitch/internal/collector"
"snitch/internal/color" "github.com/karol-broda/snitch/internal/color"
"snitch/internal/config" "github.com/karol-broda/snitch/internal/config"
"snitch/internal/resolver" "github.com/karol-broda/snitch/internal/resolver"
"strconv" "strconv"
"strings" "strings"
"text/tabwriter" "text/tabwriter"
@@ -30,7 +30,8 @@ var (
sortBy string sortBy string
fields string fields string
colorMode string colorMode string
numeric bool resolveAddrs bool
resolvePorts bool
plainOutput bool plainOutput bool
) )
@@ -51,7 +52,7 @@ Available filters:
} }
func runListCommand(outputFormat string, args []string) { func runListCommand(outputFormat string, args []string) {
rt, err := NewRuntime(args, colorMode, numeric) rt, err := NewRuntime(args, colorMode)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -98,14 +99,18 @@ func getFieldMap(c collector.Connection) map[string]string {
lport := strconv.Itoa(c.Lport) lport := strconv.Itoa(c.Lport)
rport := strconv.Itoa(c.Rport) rport := strconv.Itoa(c.Rport)
// Apply name resolution if not in numeric mode // apply address resolution
if !numeric { if resolveAddrs {
if resolvedLaddr := resolver.ResolveAddr(c.Laddr); resolvedLaddr != c.Laddr { if resolvedLaddr := resolver.ResolveAddr(c.Laddr); resolvedLaddr != c.Laddr {
laddr = resolvedLaddr laddr = resolvedLaddr
} }
if resolvedRaddr := resolver.ResolveAddr(c.Raddr); resolvedRaddr != c.Raddr && c.Raddr != "*" && c.Raddr != "" { if resolvedRaddr := resolver.ResolveAddr(c.Raddr); resolvedRaddr != c.Raddr && c.Raddr != "*" && c.Raddr != "" {
raddr = resolvedRaddr raddr = resolvedRaddr
} }
}
// apply port resolution
if resolvePorts {
if resolvedLport := resolver.ResolvePort(c.Lport, c.Proto); resolvedLport != strconv.Itoa(c.Lport) { if resolvedLport := resolver.ResolvePort(c.Lport, c.Proto); resolvedLport != strconv.Itoa(c.Lport) {
lport = resolvedLport lport = resolvedLport
} }
@@ -395,7 +400,8 @@ func init() {
lsCmd.Flags().StringVarP(&sortBy, "sort", "s", cfg.Defaults.SortBy, "Sort by column (e.g., pid:desc)") lsCmd.Flags().StringVarP(&sortBy, "sort", "s", cfg.Defaults.SortBy, "Sort by column (e.g., pid:desc)")
lsCmd.Flags().StringVarP(&fields, "fields", "f", strings.Join(cfg.Defaults.Fields, ","), "Comma-separated list of fields to show") lsCmd.Flags().StringVarP(&fields, "fields", "f", strings.Join(cfg.Defaults.Fields, ","), "Comma-separated list of fields to show")
lsCmd.Flags().StringVar(&colorMode, "color", cfg.Defaults.Color, "Color mode (auto, always, never)") lsCmd.Flags().StringVar(&colorMode, "color", cfg.Defaults.Color, "Color mode (auto, always, never)")
lsCmd.Flags().BoolVarP(&numeric, "numeric", "n", cfg.Defaults.Numeric, "Don't resolve hostnames") lsCmd.Flags().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)") lsCmd.Flags().BoolVarP(&plainOutput, "plain", "p", false, "Plain output (parsable, no styling)")
// shared filter flags // shared filter flags

View File

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

View File

@@ -3,7 +3,7 @@ package cmd
import ( import (
"fmt" "fmt"
"os" "os"
"snitch/internal/config" "github.com/karol-broda/snitch/internal/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -44,6 +44,8 @@ func init() {
cfg := config.Get() cfg := config.Get()
rootCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)") rootCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)")
rootCmd.Flags().DurationVarP(&topInterval, "interval", "i", 0, "Refresh interval (default 1s)") rootCmd.Flags().DurationVarP(&topInterval, "interval", "i", 0, "Refresh interval (default 1s)")
rootCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
rootCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names")
// shared filter flags for root command // shared filter flags for root command
addFilterFlags(rootCmd) addFilterFlags(rootCmd)

View File

@@ -2,8 +2,8 @@ package cmd
import ( import (
"fmt" "fmt"
"snitch/internal/collector" "github.com/karol-broda/snitch/internal/collector"
"snitch/internal/color" "github.com/karol-broda/snitch/internal/color"
"strconv" "strconv"
"strings" "strings"
@@ -20,8 +20,9 @@ type Runtime struct {
Connections []collector.Connection Connections []collector.Connection
// common settings // common settings
ColorMode string ColorMode string
Numeric bool ResolveAddrs bool
ResolvePorts bool
} }
// shared filter flags - used by all commands // shared filter flags - used by all commands
@@ -73,7 +74,7 @@ func FetchConnections(filters collector.FilterOptions) ([]collector.Connection,
} }
// NewRuntime creates a runtime with fetched and filtered connections. // NewRuntime creates a runtime with fetched and filtered connections.
func NewRuntime(args []string, colorMode string, numeric bool) (*Runtime, error) { func NewRuntime(args []string, colorMode string) (*Runtime, error) {
color.Init(colorMode) color.Init(colorMode)
filters, err := BuildFilters(args) filters, err := BuildFilters(args)
@@ -87,10 +88,11 @@ func NewRuntime(args []string, colorMode string, numeric bool) (*Runtime, error)
} }
return &Runtime{ return &Runtime{
Filters: filters, Filters: filters,
Connections: connections, Connections: connections,
ColorMode: colorMode, ColorMode: colorMode,
Numeric: numeric, ResolveAddrs: resolveAddrs,
ResolvePorts: resolvePorts,
}, nil }, nil
} }

View File

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

View File

@@ -2,8 +2,8 @@ package cmd
import ( import (
"log" "log"
"snitch/internal/config" "github.com/karol-broda/snitch/internal/config"
"snitch/internal/tui" "github.com/karol-broda/snitch/internal/tui"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -12,8 +12,10 @@ import (
// top-specific flags // top-specific flags
var ( var (
topTheme string topTheme string
topInterval time.Duration topInterval time.Duration
topResolveAddrs bool
topResolvePorts bool
) )
var topCmd = &cobra.Command{ var topCmd = &cobra.Command{
@@ -28,8 +30,10 @@ var topCmd = &cobra.Command{
} }
opts := tui.Options{ opts := tui.Options{
Theme: theme, Theme: theme,
Interval: topInterval, Interval: topInterval,
ResolveAddrs: topResolveAddrs,
ResolvePorts: topResolvePorts,
} }
// if any filter flag is set, use exclusive mode // if any filter flag is set, use exclusive mode
@@ -58,6 +62,8 @@ func init() {
// top-specific flags // top-specific flags
topCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)") topCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)")
topCmd.Flags().DurationVarP(&topInterval, "interval", "i", time.Second, "Refresh interval") topCmd.Flags().DurationVarP(&topInterval, "interval", "i", time.Second, "Refresh interval")
topCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
topCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names")
// shared filter flags // shared filter flags
addFilterFlags(topCmd) addFilterFlags(topCmd)

View File

@@ -7,8 +7,8 @@ import (
"log" "log"
"os" "os"
"os/signal" "os/signal"
"snitch/internal/collector" "github.com/karol-broda/snitch/internal/collector"
"snitch/internal/resolver" "github.com/karol-broda/snitch/internal/resolver"
"strings" "strings"
"syscall" "syscall"
"time" "time"

View File

@@ -8,13 +8,17 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/karol-broda/snitch/internal/tui"
) )
const ( const (
@@ -50,10 +54,26 @@ type githubRelease struct {
HTMLURL string `json:"html_url"` 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 { func runUpgrade(cmd *cobra.Command, args []string) error {
current := Version current := Version
nixInstall := isNixInstall()
nixVersion := isNixVersion(current)
if upgradeVersion != "" { if upgradeVersion != "" {
if nixInstall || nixVersion {
return handleNixSpecificVersion(current, upgradeVersion)
}
return handleSpecificVersion(current, upgradeVersion) return handleSpecificVersion(current, upgradeVersion)
} }
@@ -62,6 +82,10 @@ func runUpgrade(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to check for updates: %w", err) return fmt.Errorf("failed to check for updates: %w", err)
} }
if nixInstall || nixVersion {
return handleNixUpgrade(current, latest)
}
currentClean := strings.TrimPrefix(current, "v") currentClean := strings.TrimPrefix(current, "v")
latestClean := strings.TrimPrefix(latest, "v") latestClean := strings.TrimPrefix(latest, "v")
@@ -69,13 +93,13 @@ func runUpgrade(cmd *cobra.Command, args []string) error {
if currentClean == latestClean { if currentClean == latestClean {
green := color.New(color.FgGreen) green := color.New(color.FgGreen)
green.Println(" you are running the latest version") green.Println(tui.SymbolSuccess + " you are running the latest version")
return nil return nil
} }
if current == "dev" { if current == "dev" {
yellow := color.New(color.FgYellow) yellow := color.New(color.FgYellow)
yellow.Println(" you are running a development build") yellow.Println(tui.SymbolWarning + " you are running a development build")
fmt.Println() fmt.Println()
fmt.Println("use one of the methods below to install a release version:") fmt.Println("use one of the methods below to install a release version:")
fmt.Println() fmt.Println()
@@ -84,7 +108,7 @@ func runUpgrade(cmd *cobra.Command, args []string) error {
} }
green := color.New(color.FgGreen, color.Bold) green := color.New(color.FgGreen, color.Bold)
green.Printf(" update available: %s %s\n", current, latest) green.Printf(tui.SymbolSuccess+" update available: %s "+tui.SymbolArrowRight+" %s\n", current, latest)
fmt.Println() fmt.Println()
if !upgradeYes { if !upgradeYes {
@@ -110,7 +134,7 @@ func handleSpecificVersion(current, target string) error {
if isVersionLower(targetClean, firstUpgradeVersion) { if isVersionLower(targetClean, firstUpgradeVersion) {
yellow := color.New(color.FgYellow) yellow := color.New(color.FgYellow)
yellow.Printf(" warning: the upgrade command was introduced in v%s\n", firstUpgradeVersion) yellow.Printf(tui.SymbolWarning+" warning: the upgrade command was introduced in v%s\n", firstUpgradeVersion)
faint := color.New(color.Faint) faint := color.New(color.Faint)
faint.Printf(" version %s does not include this command\n", target) 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") faint.Println(" you will need to use other methods to upgrade from that version")
@@ -120,7 +144,7 @@ func handleSpecificVersion(current, target string) error {
currentClean := strings.TrimPrefix(current, "v") currentClean := strings.TrimPrefix(current, "v")
if currentClean == targetClean { if currentClean == targetClean {
green := color.New(color.FgGreen) green := color.New(color.FgGreen)
green.Println(" you are already running this version") green.Println(tui.SymbolSuccess + " you are already running this version")
return nil return nil
} }
@@ -129,10 +153,10 @@ func handleSpecificVersion(current, target string) error {
cmdStyle := color.New(color.FgCyan) cmdStyle := color.New(color.FgCyan)
if isVersionLower(targetClean, currentClean) { if isVersionLower(targetClean, currentClean) {
yellow := color.New(color.FgYellow) yellow := color.New(color.FgYellow)
yellow.Printf(" this will downgrade from %s to %s\n", current, target) yellow.Printf(tui.SymbolArrowDown+" this will downgrade from %s to %s\n", current, target)
} else { } else {
green := color.New(color.FgGreen) green := color.New(color.FgGreen)
green.Printf(" this will upgrade from %s to %s\n", current, target) green.Printf(tui.SymbolArrowUp+" this will upgrade from %s to %s\n", current, target)
} }
fmt.Println() fmt.Println()
faint.Print("run ") faint.Print("run ")
@@ -144,6 +168,136 @@ func handleSpecificVersion(current, target string) error {
return performUpgrade(target) 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 { func isVersionLower(v1, v2 string) bool {
parts1 := parseVersion(v1) parts1 := parseVersion(v1)
parts2 := parseVersion(v2) parts2 := parseVersion(v2)
@@ -251,6 +405,14 @@ func performUpgrade(version string) error {
return fmt.Errorf("failed to resolve executable path: %w", err) 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 goos := runtime.GOOS
goarch := runtime.GOARCH goarch := runtime.GOARCH
@@ -261,7 +423,7 @@ func performUpgrade(version string) error {
faint := color.New(color.Faint) faint := color.New(color.Faint)
cyan := color.New(color.FgCyan) cyan := color.New(color.FgCyan)
faint.Print(" downloading ") faint.Print(tui.SymbolDownload + " downloading ")
cyan.Printf("%s", archiveName) cyan.Printf("%s", archiveName)
faint.Println("...") faint.Println("...")
@@ -286,13 +448,17 @@ func performUpgrade(version string) error {
return fmt.Errorf("failed to extract binary: %w", err) return fmt.Errorf("failed to extract binary: %w", err)
} }
if goos == "darwin" {
removeQuarantine(binaryPath)
}
// check if we can write to the target location // check if we can write to the target location
targetDir := filepath.Dir(execPath) targetDir := filepath.Dir(execPath)
if !isWritable(targetDir) { if !isWritable(targetDir) {
yellow := color.New(color.FgYellow) yellow := color.New(color.FgYellow)
cmdStyle := color.New(color.FgCyan) cmdStyle := color.New(color.FgCyan)
yellow.Printf(" elevated permissions required to install to %s\n", targetDir) yellow.Printf(tui.SymbolWarning+" elevated permissions required to install to %s\n", targetDir)
fmt.Println() fmt.Println()
faint.Println("run with sudo or install to a user-writable location:") faint.Println("run with sudo or install to a user-writable location:")
fmt.Println() fmt.Println()
@@ -325,11 +491,11 @@ func performUpgrade(version string) error {
if err := os.Remove(backupPath); err != nil { if err := os.Remove(backupPath); err != nil {
// non-fatal, just warn // non-fatal, just warn
yellow := color.New(color.FgYellow) yellow := color.New(color.FgYellow)
yellow.Fprintf(os.Stderr, " warning: failed to remove backup file %s: %v\n", backupPath, err) 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 := color.New(color.FgGreen, color.Bold)
green.Printf(" successfully upgraded to %s\n", version) green.Printf(tui.SymbolSuccess + " successfully upgraded to %s\n", version)
return nil return nil
} }
@@ -410,3 +576,113 @@ func copyFile(src, dst string) error {
return dstFile.Sync() 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" "fmt"
"runtime" "runtime"
"github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -17,11 +18,25 @@ var versionCmd = &cobra.Command{
Use: "version", Use: "version",
Short: "Show version/build info", Short: "Show version/build info",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("snitch %s\n", Version) bold := color.New(color.Bold)
fmt.Printf(" commit: %s\n", Commit) cyan := color.New(color.FgCyan)
fmt.Printf(" built: %s\n", Date) faint := color.New(color.Faint)
fmt.Printf(" go: %s\n", runtime.Version())
fmt.Printf(" os: %s/%s\n", runtime.GOOS, runtime.GOARCH) 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)
}, },
} }

8
flake.lock generated
View File

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

View File

@@ -1,7 +1,7 @@
{ {
description = "snitch - a friendlier ss/netstat for humans"; description = "snitch - a friendlier ss/netstat for humans";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
outputs = { self, nixpkgs }: outputs = { self, nixpkgs }:
let let
@@ -46,7 +46,9 @@
mkSnitch = pkgs: mkSnitch = pkgs:
let let
version = self.shortRev or self.dirtyShortRev or "dev"; rev = self.shortRev or self.dirtyShortRev or "unknown";
version = "nix-${rev}";
isDarwin = pkgs.stdenv.isDarwin;
go = mkGo125 pkgs; go = mkGo125 pkgs;
buildGoModule = pkgs.buildGoModule.override { inherit go; }; buildGoModule = pkgs.buildGoModule.override { inherit go; };
in in
@@ -55,20 +57,23 @@
inherit version; inherit version;
src = self; src = self;
vendorHash = "sha256-fX3wOqeOgjH7AuWGxPQxJ+wbhp240CW8tiF4rVUUDzk="; vendorHash = "sha256-fX3wOqeOgjH7AuWGxPQxJ+wbhp240CW8tiF4rVUUDzk=";
env.CGO_ENABLED = "0"; # darwin requires cgo for libproc, linux uses pure go with /proc
env.CGO_ENABLED = if isDarwin then "1" else "0";
env.GOTOOLCHAIN = "local"; 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 = [ ldflags = [
"-s" "-s"
"-w" "-w"
"-X snitch/cmd.Version=${version}" "-X snitch/cmd.Version=${version}"
"-X snitch/cmd.Commit=${version}" "-X snitch/cmd.Commit=${rev}"
"-X snitch/cmd.Date=${self.lastModifiedDate or "unknown"}" "-X snitch/cmd.Date=${self.lastModifiedDate or "unknown"}"
]; ];
meta = { meta = {
description = "a friendlier ss/netstat for humans"; description = "a friendlier ss/netstat for humans";
homepage = "https://github.com/karol-broda/snitch"; homepage = "https://github.com/karol-broda/snitch";
license = pkgs.lib.licenses.mit; license = pkgs.lib.licenses.mit;
platforms = pkgs.lib.platforms.linux; platforms = pkgs.lib.platforms.linux ++ pkgs.lib.platforms.darwin;
mainProgram = "snitch"; mainProgram = "snitch";
}; };
}; };

2
go.mod
View File

@@ -1,4 +1,4 @@
module snitch module github.com/karol-broda/snitch
go 1.24.0 go 1.24.0

View File

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

View File

@@ -1,9 +1,8 @@
package tui package tui
import ( import (
"fmt"
"regexp" "regexp"
"snitch/internal/collector" "github.com/karol-broda/snitch/internal/collector"
"strings" "strings"
) )
@@ -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

@@ -2,7 +2,7 @@ package tui
import ( import (
"fmt" "fmt"
"snitch/internal/collector" "github.com/karol-broda/snitch/internal/collector"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -210,6 +210,28 @@ func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showKillConfirm = true 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 return m, nil

View File

@@ -2,7 +2,7 @@ package tui
import ( import (
"fmt" "fmt"
"snitch/internal/collector" "github.com/karol-broda/snitch/internal/collector"
"syscall" "syscall"
"time" "time"

View File

@@ -2,8 +2,8 @@ package tui
import ( import (
"fmt" "fmt"
"snitch/internal/collector" "github.com/karol-broda/snitch/internal/collector"
"snitch/internal/theme" "github.com/karol-broda/snitch/internal/theme"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -28,6 +28,10 @@ type model struct {
sortField collector.SortField sortField collector.SortField
sortReverse bool 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 // ui state
theme *theme.Theme theme *theme.Theme
showHelp bool showHelp bool
@@ -50,14 +54,16 @@ type model struct {
} }
type Options struct { type Options struct {
Theme string Theme string
Interval time.Duration Interval time.Duration
TCP bool TCP bool
UDP bool UDP bool
Listening bool Listening bool
Established bool Established bool
Other bool Other bool
FilterSet bool // true if user specified any filter flags 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 { func New(opts Options) model {
@@ -102,6 +108,8 @@ func New(opts Options) model {
showEstablished: showEstablished, showEstablished: showEstablished,
showOther: showOther, showOther: showOther,
sortField: collector.SortByLport, sortField: collector.SortByLport,
resolveAddrs: opts.ResolveAddrs,
resolvePorts: opts.ResolvePorts,
theme: theme.GetTheme(opts.Theme), theme: theme.GetTheme(opts.Theme),
interval: interval, interval: interval,
lastRefresh: time.Now(), lastRefresh: time.Now(),

View File

@@ -1,7 +1,7 @@
package tui package tui
import ( import (
"snitch/internal/collector" "github.com/karol-broda/snitch/internal/collector"
"testing" "testing"
"time" "time"
@@ -301,3 +301,132 @@ func TestTUI_ViewRenders(t *testing.T) {
} }
} }
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)
}
}

View File

@@ -15,6 +15,7 @@ const (
SymbolArrowDown = string('\u2193') // downwards arrow SymbolArrowDown = string('\u2193') // downwards arrow
SymbolRefresh = string('\u21BB') // clockwise open circle arrow SymbolRefresh = string('\u21BB') // clockwise open circle arrow
SymbolEllipsis = string('\u2026') // horizontal ellipsis SymbolEllipsis = string('\u2026') // horizontal ellipsis
SymbolDownload = string('\u21E9') // downwards white arrow
// box drawing rounded // box drawing rounded
BoxTopLeft = string('\u256D') // light arc down and right BoxTopLeft = string('\u256D') // light arc down and right

View File

@@ -2,7 +2,9 @@ package tui
import ( import (
"fmt" "fmt"
"snitch/internal/collector" "github.com/karol-broda/snitch/internal/collector"
"github.com/karol-broda/snitch/internal/resolver"
"strconv"
"strings" "strings"
"time" "time"
@@ -161,19 +163,19 @@ func (m model) renderRow(c collector.Connection, selected bool) string {
process = SymbolDash process = SymbolDash
} }
port := fmt.Sprintf("%d", c.Lport) port := truncate(m.resolvePort(c.Lport, c.Proto), cols.port)
proto := c.Proto proto := c.Proto
state := c.State state := c.State
if state == "" { if state == "" {
state = SymbolDash state = SymbolDash
} }
local := c.Laddr local := truncate(m.resolveAddr(c.Laddr), cols.local)
if local == "*" || local == "" { if local == "*" || local == "" {
local = "*" local = "*"
} }
remote := formatRemote(c.Raddr, c.Rport) remote := truncate(m.formatRemote(c.Raddr, c.Rport, c.Proto), cols.remote)
// apply styling // apply styling
protoStyled := m.theme.Styles.GetProtoStyle(proto).Render(fmt.Sprintf("%-*s", cols.proto, proto)) protoStyled := m.theme.Styles.GetProtoStyle(proto).Render(fmt.Sprintf("%-*s", cols.proto, proto))
@@ -185,8 +187,8 @@ func (m model) renderRow(c collector.Connection, selected bool) string {
cols.port, port, cols.port, port,
protoStyled, protoStyled,
stateStyled, stateStyled,
cols.local, truncate(local, cols.local), cols.local, local,
truncate(remote, cols.remote)) remote)
if selected { if selected {
return m.theme.Styles.Selected.Render(row) + "\n" return m.theme.Styles.Selected.Render(row) + "\n"
@@ -201,7 +203,7 @@ func (m model) renderStatusLine() string {
return " " + m.theme.Styles.Warning.Render(m.statusMessage) return " " + m.theme.Styles.Warning.Render(m.statusMessage)
} }
left := " " + m.theme.Styles.Normal.Render("t/u proto l/e/o state w watch K kill s sort / search ? help q quit") 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 // show watched count if any
if m.watchedCount() > 0 { if m.watchedCount() > 0 {
@@ -209,6 +211,21 @@ func (m model) renderStatusLine() string {
left += m.theme.Styles.Watched.Render(watchedInfo) 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 return left
} }
@@ -240,6 +257,11 @@ func (m model) renderHelp() string {
s cycle sort field s cycle sort field
S reverse sort order S reverse sort order
display
───────
n toggle address resolution (dns)
N toggle port resolution (service names)
process management process management
────────────────── ──────────────────
w watch/unwatch process (highlight & track) w watch/unwatch process (highlight & track)
@@ -269,6 +291,11 @@ func (m model) renderDetail() string {
b.WriteString(" " + m.theme.Styles.Header.Render("connection details") + "\n") b.WriteString(" " + m.theme.Styles.Header.Render("connection details") + "\n")
b.WriteString(" " + m.theme.Styles.Border.Render(strings.Repeat(BoxHorizontal, 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 { fields := []struct {
label string label string
value string value string
@@ -278,8 +305,8 @@ func (m model) renderDetail() string {
{"user", c.User}, {"user", c.User},
{"protocol", c.Proto}, {"protocol", c.Proto},
{"state", c.State}, {"state", c.State},
{"local", fmt.Sprintf("%s:%d", c.Laddr, c.Lport)}, {"local", fmt.Sprintf("%s:%s", localAddr, localPort)},
{"remote", fmt.Sprintf("%s:%d", c.Raddr, c.Rport)}, {"remote", fmt.Sprintf("%s:%s", remoteAddr, remotePort)},
{"interface", c.Interface}, {"interface", c.Interface},
{"inode", fmt.Sprintf("%d", c.Inode)}, {"inode", fmt.Sprintf("%d", c.Inode)},
} }
@@ -498,23 +525,72 @@ type columns struct {
} }
func (m model) columnWidths() columns { func (m model) columnWidths() columns {
available := m.safeWidth() - 16 // minimum widths (header lengths + padding)
c := columns{ c := columns{
process: 16, process: 7, // "PROCESS"
port: 6, port: 4, // "PORT"
proto: 5, proto: 5, // "PROTO"
state: 11, state: 5, // "STATE"
local: 15, local: 5, // "LOCAL"
remote: 20, remote: 6, // "REMOTE"
} }
used := c.process + c.port + c.proto + c.state + c.local + c.remote // scan visible connections to find max content width for each column
extra := available - used visible := m.visibleConnections()
for _, conn := range visible {
if len(conn.Process) > c.process {
c.process = len(conn.Process)
}
if extra > 0 { port := m.resolvePort(conn.Lport, conn.Proto)
c.process += extra / 3 if len(port) > c.port {
c.remote += extra - extra/3 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 return c
@@ -536,3 +612,29 @@ func formatDuration(d time.Duration) string {
} }
return fmt.Sprintf("%.0fm", d.Minutes()) 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 package main
import ( import (
"snitch/cmd" "github.com/karol-broda/snitch/cmd"
) )
func main() { func main() {