5 Commits

Author SHA1 Message Date
Karol Broda
7f2bd068ad chore: separate darwin build configuration into .goreleaser-darwin.yaml and update action 2025-12-17 00:11:13 +01:00
Karol Broda
eee7cfd64d refactor: update socket information handling in darwin collector 2025-12-17 00:02:56 +01:00
Karol Broda
dc235a5807 feat: add darwin support 2025-12-16 23:59:43 +01:00
Karol Broda
9fcc6d47c2 chore: remove main binary 2025-12-16 23:18:05 +01:00
Karol Broda
5f76d5cd76 add nix package and installation docs 2025-12-16 23:12:59 +01:00
11 changed files with 899 additions and 496 deletions

View File

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

3
.gitignore vendored
View File

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

36
.goreleaser-darwin.yaml Normal file
View File

@@ -0,0 +1,36 @@
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 }}
release:
github:
owner: karol-broda
name: snitch
draft: false
prerelease: auto
mode: append

View File

@@ -7,7 +7,8 @@ before:
- go mod tidy - go mod tidy
builds: builds:
- env: - id: linux
env:
- CGO_ENABLED=0 - CGO_ENABLED=0
goos: goos:
- linux - linux
@@ -66,4 +67,3 @@ release:
name: snitch name: snitch
draft: false draft: false
prerelease: auto prerelease: auto

View File

@@ -4,10 +4,40 @@ a friendlier `ss` / `netstat` for humans. inspect network connections with a cle
## install ## install
### go
```bash ```bash
go install github.com/karol-broda/snitch@latest go install github.com/karol-broda/snitch@latest
``` ```
### nixos / nix
```bash
# try it
nix run github:karol-broda/snitch
# install to profile
nix profile install github:karol-broda/snitch
# or add to flake inputs
{
inputs.snitch.url = "github:karol-broda/snitch";
}
# then use: inputs.snitch.packages.${system}.default
```
### 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/
# or install .deb/.rpm/.apk from releases
```
## quick start ## quick start
```bash ```bash

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1756217674, "lastModified": 1765687488,
"narHash": "sha256-TH1SfSP523QI7kcPiNtMAEuwZR3Jdz0MCDXPs7TS8uo=", "narHash": "sha256-7YAJ6xgBAQ/Nr+7MI13Tui1ULflgAdKh63m1tfYV7+M=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "4e7667a90c167f7a81d906e5a75cba4ad8bee620", "rev": "d02bcc33948ca19b0aaa0213fe987ceec1f4ebe1",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

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

View File

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

View File

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

View File

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

BIN
main

Binary file not shown.