feat: add darwin support
This commit is contained in:
69
.github/workflows/release.yaml
vendored
69
.github/workflows/release.yaml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
306
internal/collector/collector_darwin.go
Normal file
306
internal/collector/collector_darwin.go
Normal 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 ""
|
||||
}
|
||||
382
internal/collector/collector_linux.go
Normal file
382
internal/collector/collector_linux.go
Normal 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user