feat: add darwin support

This commit is contained in:
Karol Broda
2025-12-16 23:59:43 +01:00
parent 9fcc6d47c2
commit dc235a5807
5 changed files with 816 additions and 454 deletions

View File

@@ -9,7 +9,7 @@ permissions:
contents: write
jobs:
goreleaser:
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -20,11 +20,74 @@ jobs:
with:
go-version: "1.25.0"
- name: run goreleaser
- name: build linux binaries
uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
args: build --clean --id linux
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: upload linux artifacts
uses: actions/upload-artifact@v4
with:
name: linux-dist
path: dist/
build-darwin:
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: build darwin binaries
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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -7,7 +7,8 @@ before:
- go mod tidy
builds:
- env:
- id: linux
env:
- CGO_ENABLED=0
goos:
- linux
@@ -23,6 +24,20 @@ 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
@@ -59,6 +74,8 @@ nfpms:
- deb
- rpm
- apk
builds:
- linux
release:
github:
@@ -66,4 +83,3 @@ release:
name: snitch
draft: false
prerelease: auto

View File

@@ -1,17 +1,8 @@
package collector
import (
"bufio"
"bytes"
"fmt"
"net"
"os"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// Collector interface defines methods for collecting connection data
@@ -19,9 +10,6 @@ type Collector interface {
GetConnections() ([]Connection, error)
}
// DefaultCollector implements the Collector interface using /proc
type DefaultCollector struct{}
// Global collector instance (can be overridden for testing)
var globalCollector Collector = &DefaultCollector{}
@@ -40,64 +28,6 @@ func GetConnections() ([]Connection, error) {
return globalCollector.GetConnections()
}
// GetConnections fetches all network connections by parsing /proc files.
func (dc *DefaultCollector) GetConnections() ([]Connection, error) {
if runtime.GOOS != "linux" {
return nil, fmt.Errorf("proc-based collector only supports Linux")
}
// Build map of inode -> process info by scanning /proc
inodeMap, err := buildInodeToProcessMap()
if err != nil {
return nil, fmt.Errorf("failed to build inode map: %w", err)
}
var connections []Connection
// Parse TCP connections
tcpConns, err := parseProcNet("/proc/net/tcp", "tcp", 4, inodeMap)
if err == nil {
connections = append(connections, tcpConns...)
}
tcpConns6, err := parseProcNet("/proc/net/tcp6", "tcp6", 6, inodeMap)
if err == nil {
connections = append(connections, tcpConns6...)
}
// Parse UDP connections
udpConns, err := parseProcNet("/proc/net/udp", "udp", 4, inodeMap)
if err == nil {
connections = append(connections, udpConns...)
}
udpConns6, err := parseProcNet("/proc/net/udp6", "udp6", 6, inodeMap)
if err == nil {
connections = append(connections, udpConns6...)
}
return connections, nil
}
// GetAllConnections returns both network and Unix domain socket connections
func GetAllConnections() ([]Connection, error) {
// Get network connections
networkConns, err := GetConnections()
if err != nil {
return nil, err
}
// Get Unix sockets (only on Linux)
if runtime.GOOS == "linux" {
unixConns, err := GetUnixSockets()
if err == nil {
networkConns = append(networkConns, unixConns...)
}
}
return networkConns, nil
}
func FilterConnections(conns []Connection, filters FilterOptions) []Connection {
if filters.IsEmpty() {
return conns
@@ -112,395 +42,60 @@ func FilterConnections(conns []Connection, filters FilterOptions) []Connection {
return filtered
}
// processInfo holds information about a process
type processInfo struct {
pid int
command string
uid int
user string
}
// buildInodeToProcessMap scans /proc to map socket inodes to processes
func buildInodeToProcessMap() (map[int64]*processInfo, error) {
inodeMap := make(map[int64]*processInfo)
procDir, err := os.Open("/proc")
if err != nil {
return nil, err
}
defer procDir.Close()
entries, err := procDir.Readdir(-1)
if err != nil {
return nil, err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// check if directory name is a number (pid)
pidStr := entry.Name()
pid, err := strconv.Atoi(pidStr)
if err != nil {
continue
}
// get process info
procInfo, err := getProcessInfo(pid)
if err != nil {
continue
}
// scan /proc/[pid]/fd/ for socket file descriptors
fdDir := filepath.Join("/proc", pidStr, "fd")
fdEntries, err := os.ReadDir(fdDir)
if err != nil {
continue
}
for _, fdEntry := range fdEntries {
fdPath := filepath.Join(fdDir, fdEntry.Name())
link, err := os.Readlink(fdPath)
if err != nil {
continue
}
// socket inodes look like: socket:[12345]
if strings.HasPrefix(link, "socket:[") && strings.HasSuffix(link, "]") {
inodeStr := link[8 : len(link)-1]
inode, err := strconv.ParseInt(inodeStr, 10, 64)
if err != nil {
continue
}
inodeMap[inode] = procInfo
}
}
}
return inodeMap, nil
}
// getProcessInfo reads process information from /proc/[pid]/
func getProcessInfo(pid int) (*processInfo, error) {
info := &processInfo{pid: pid}
// prefer /proc/[pid]/comm as it's always just the command name
commPath := filepath.Join("/proc", strconv.Itoa(pid), "comm")
commData, err := os.ReadFile(commPath)
if err == nil && len(commData) > 0 {
info.command = strings.TrimSpace(string(commData))
}
// if comm is not available, try cmdline
if info.command == "" {
cmdlinePath := filepath.Join("/proc", strconv.Itoa(pid), "cmdline")
cmdlineData, err := os.ReadFile(cmdlinePath)
if err != nil {
return nil, err
}
// cmdline is null-separated, take first part
if len(cmdlineData) > 0 {
parts := bytes.Split(cmdlineData, []byte{0})
if len(parts) > 0 && len(parts[0]) > 0 {
fullPath := string(parts[0])
// extract basename from full path
baseName := filepath.Base(fullPath)
// if basename contains spaces (single-string cmdline), take first word
if strings.Contains(baseName, " ") {
baseName = strings.Fields(baseName)[0]
}
info.command = baseName
}
}
}
// read UID from /proc/[pid]/status
statusPath := filepath.Join("/proc", strconv.Itoa(pid), "status")
statusFile, err := os.Open(statusPath)
if err != nil {
return info, nil
}
defer statusFile.Close()
scanner := bufio.NewScanner(statusFile)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "Uid:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
uid, err := strconv.Atoi(fields[1])
if err == nil {
info.uid = uid
// get username from uid
u, err := user.LookupId(strconv.Itoa(uid))
if err == nil {
info.user = u.Username
} else {
info.user = strconv.Itoa(uid)
}
}
}
break
}
}
return info, nil
}
// parseProcNet parses a /proc/net/tcp or /proc/net/udp file
func parseProcNet(path, proto string, ipVersion int, inodeMap map[int64]*processInfo) ([]Connection, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
var connections []Connection
scanner := bufio.NewScanner(file)
// skip header
scanner.Scan()
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
// parse local address and port
localAddr, localPort, err := parseHexAddr(fields[1])
if err != nil {
continue
}
// parse remote address and port
remoteAddr, remotePort, err := parseHexAddr(fields[2])
if err != nil {
continue
}
// parse state (field 3)
stateHex := fields[3]
state := parseState(stateHex, proto)
// parse inode (field 9)
inode, _ := strconv.ParseInt(fields[9], 10, 64)
conn := Connection{
TS: time.Now(),
Proto: proto,
IPVersion: fmt.Sprintf("IPv%d", ipVersion),
State: state,
Laddr: localAddr,
Lport: localPort,
Raddr: remoteAddr,
Rport: remotePort,
Inode: inode,
}
// add process info if available
if procInfo, exists := inodeMap[inode]; exists {
conn.PID = procInfo.pid
conn.Process = procInfo.command
conn.UID = procInfo.uid
conn.User = procInfo.user
}
// determine interface
conn.Interface = guessNetworkInterface(localAddr, nil)
connections = append(connections, conn)
}
return connections, scanner.Err()
}
// parseState converts hex state value to string
func parseState(hexState, proto string) string {
state, err := strconv.ParseInt(hexState, 16, 32)
if err != nil {
return ""
}
// TCP states
tcpStates := map[int64]string{
0x01: "ESTABLISHED",
0x02: "SYN_SENT",
0x03: "SYN_RECV",
0x04: "FIN_WAIT1",
0x05: "FIN_WAIT2",
0x06: "TIME_WAIT",
0x07: "CLOSE",
0x08: "CLOSE_WAIT",
0x09: "LAST_ACK",
0x0A: "LISTEN",
0x0B: "CLOSING",
}
if strings.HasPrefix(proto, "tcp") {
if s, exists := tcpStates[state]; exists {
return s
}
} else {
// UDP doesn't have states in the same way
if state == 0x07 {
return "CLOSE"
}
return ""
}
return ""
}
// parseHexAddr parses hex-encoded address:port from /proc/net files
func parseHexAddr(hexAddr string) (string, int, error) {
parts := strings.Split(hexAddr, ":")
if len(parts) != 2 {
return "", 0, fmt.Errorf("invalid address format")
}
hexIP := parts[0]
// parse hex port
port, err := strconv.ParseInt(parts[1], 16, 32)
if err != nil {
return "", 0, err
}
if len(hexIP) == 8 {
// IPv4 (stored in little-endian)
ip1, _ := strconv.ParseInt(hexIP[6:8], 16, 32)
ip2, _ := strconv.ParseInt(hexIP[4:6], 16, 32)
ip3, _ := strconv.ParseInt(hexIP[2:4], 16, 32)
ip4, _ := strconv.ParseInt(hexIP[0:2], 16, 32)
addr := fmt.Sprintf("%d.%d.%d.%d", ip1, ip2, ip3, ip4)
// handle wildcard address
if addr == "0.0.0.0" {
addr = "*"
}
return addr, int(port), nil
} else if len(hexIP) == 32 {
// IPv6 (stored in little-endian per 32-bit word)
var ipv6Parts []string
for i := 0; i < 32; i += 8 {
word := hexIP[i : i+8]
// reverse byte order within each 32-bit word
p1 := word[6:8] + word[4:6] + word[2:4] + word[0:2]
ipv6Parts = append(ipv6Parts, p1)
}
// convert to standard IPv6 notation
fullAddr := strings.Join(ipv6Parts, "")
var formatted []string
for i := 0; i < len(fullAddr); i += 4 {
formatted = append(formatted, fullAddr[i:i+4])
}
addr := strings.Join(formatted, ":")
// simplify IPv6 address
addr = simplifyIPv6(addr)
// handle wildcard address
if addr == "::" || addr == "0:0:0:0:0:0:0:0" {
addr = "*"
}
return addr, int(port), nil
}
return "", 0, fmt.Errorf("unsupported address format")
}
// simplifyIPv6 simplifies IPv6 address notation
func simplifyIPv6(addr string) string {
// remove leading zeros from each group
parts := strings.Split(addr, ":")
for i, part := range parts {
// convert to int and back to remove leading zeros
val, err := strconv.ParseInt(part, 16, 64)
if err == nil {
parts[i] = strconv.FormatInt(val, 16)
}
}
return strings.Join(parts, ":")
}
func guessNetworkInterface(addr string, interfaces map[string]string) string {
// Simple heuristic - try to match common interface patterns
func guessNetworkInterface(addr string) string {
if addr == "127.0.0.1" || addr == "::1" {
return "lo"
}
// Check if it's a private network address
ip := net.ParseIP(addr)
if ip != nil {
if ip.IsLoopback() {
return "lo"
}
// More sophisticated interface detection would require routing table analysis
// For now, return a placeholder
if ip.To4() != nil {
return "eth0" // Common default for IPv4
} else {
return "eth0" // Common default for IPv6
}
if ip == nil {
return ""
}
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 ""
}
// Add Unix socket support
func GetUnixSockets() ([]Connection, error) {
connections := []Connection{}
// Parse /proc/net/unix for Unix domain sockets
file, err := os.Open("/proc/net/unix")
if err != nil {
return connections, nil // silently fail on non-Linux systems
func simplifyIPv6(addr string) string {
parts := strings.Split(addr, ":")
for i, part := range parts {
// parse as hex then format back to remove leading zeros
var val int64
for _, c := range part {
val = val*16 + int64(hexCharToInt(c))
}
parts[i] = formatHex(val)
}
defer file.Close()
scanner := bufio.NewScanner(file)
// Skip header
scanner.Scan()
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
fields := strings.Fields(line)
if len(fields) < 7 {
continue
}
// 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
return strings.Join(parts, ":")
}
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,306 @@
//go:build darwin
package collector
/*
#include <libproc.h>
#include <sys/proc_info.h>
#include <sys/socket.h>
#include <netinet/in.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 process path by pid
static int get_proc_path(int pid, char *path, int pathlen) {
return proc_pidpath(pid, path, pathlen);
}
// get uid for a process
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 char* get_username(int uid) {
struct passwd *pw = getpwuid(uid);
if (pw == NULL) {
return NULL;
}
return pw->pw_name;
}
*/
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 (Unix sockets not easily available via libproc)
func GetAllConnections() ([]Connection, error) {
return GetConnections()
}
func listAllPids() ([]int, error) {
// first call to get buffer size needed
numPids := C.proc_listpids(C.PROC_ALL_PIDS, 0, nil, 0)
if numPids <= 0 {
return nil, fmt.Errorf("proc_listpids failed")
}
// allocate buffer
bufSize := C.int(numPids) * C.int(unsafe.Sizeof(C.int(0)))
buf := make([]C.int, numPids)
// get actual pids
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) {
// get process info first
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)
}
}
// get file descriptors for this process
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]))
// only interested in sockets
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 socketInfo C.struct_socket_fdinfo
ret := C.proc_pidfdinfo(
C.int(pid),
C.int(fd),
C.PROC_PIDFDSOCKETINFO,
unsafe.Pointer(&socketInfo),
C.int(unsafe.Sizeof(socketInfo)),
)
if ret <= 0 {
return Connection{}, false
}
// check socket family - only interested in IPv4 and IPv6
family := socketInfo.psi.soi_family
if family != C.AF_INET && family != C.AF_INET6 {
return Connection{}, false
}
// check socket type - only TCP and UDP
sockType := socketInfo.psi.soi_type
if sockType != C.SOCK_STREAM && sockType != C.SOCK_DGRAM {
return Connection{}, false
}
proto := "tcp"
if sockType == C.SOCK_DGRAM {
proto = "udp"
}
ipVersion := "IPv4"
if family == C.AF_INET6 {
ipVersion = "IPv6"
proto = proto + "6"
}
var laddr, raddr string
var lport, rport int
var state string
if family == C.AF_INET {
// IPv4
insi := socketInfo.psi.soi_proto.pri_tcp.tcpsi_ini
laddr = ipv4ToString(insi.insi_laddr.ina_46.i46a_addr4.s_addr)
raddr = ipv4ToString(insi.insi_faddr.ina_46.i46a_addr4.s_addr)
lport = int(ntohs(insi.insi_lport))
rport = int(ntohs(insi.insi_fport))
if sockType == C.SOCK_STREAM {
state = tcpStateToString(int(socketInfo.psi.soi_proto.pri_tcp.tcpsi_state))
}
} else {
// IPv6
insi := socketInfo.psi.soi_proto.pri_tcp.tcpsi_ini
laddr = ipv6ToString(insi.insi_laddr.ina_6)
raddr = ipv6ToString(insi.insi_faddr.ina_6)
lport = int(ntohs(insi.insi_lport))
rport = int(ntohs(insi.insi_fport))
if sockType == C.SOCK_STREAM {
state = tcpStateToString(int(socketInfo.psi.soi_proto.pri_tcp.tcpsi_state))
}
}
// normalize wildcard addresses
if laddr == "0.0.0.0" || laddr == "::" {
laddr = "*"
}
if raddr == "0.0.0.0" || raddr == "::" {
raddr = "*"
}
conn := Connection{
TS: time.Now(),
Proto: proto,
IPVersion: ipVersion,
State: state,
Laddr: laddr,
Lport: lport,
Raddr: raddr,
Rport: 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 C.in_addr_t) 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 C.struct_in6_addr) string {
ip := make(net.IP, 16)
for i := 0; i < 16; i++ {
ip[i] = byte(addr.__u6_addr.__u6_addr8[i])
}
// check for IPv4-mapped IPv6 addresses
if ip.To4() != nil {
return ip.To4().String()
}
return ip.String()
}
func ntohs(port C.int) uint16 {
return uint16((port&0xff)<<8 | (port>>8)&0xff)
}
func tcpStateToString(state int) string {
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
}