From dc235a58075ffd731c26dc6f5b276cad1a1ab5f7 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Tue, 16 Dec 2025 23:59:43 +0100 Subject: [PATCH] feat: add darwin support --- .github/workflows/release.yaml | 69 +++- .goreleaser.yaml | 20 +- internal/collector/collector.go | 493 +++---------------------- internal/collector/collector_darwin.go | 306 +++++++++++++++ internal/collector/collector_linux.go | 382 +++++++++++++++++++ 5 files changed, 816 insertions(+), 454 deletions(-) create mode 100644 internal/collector/collector_darwin.go create mode 100644 internal/collector/collector_linux.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index be807da..63f9a46 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index bb00860..b616e9d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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 - diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 52541c6..fe7e40a 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -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) +} diff --git a/internal/collector/collector_darwin.go b/internal/collector/collector_darwin.go new file mode 100644 index 0000000..cc0e5e5 --- /dev/null +++ b/internal/collector/collector_darwin.go @@ -0,0 +1,306 @@ +//go:build darwin + +package collector + +/* +#include +#include +#include +#include +#include +#include +#include +#include + +// 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 "" +} diff --git a/internal/collector/collector_linux.go b/internal/collector/collector_linux.go new file mode 100644 index 0000000..7e84e58 --- /dev/null +++ b/internal/collector/collector_linux.go @@ -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 +} +