Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc235a5807 | ||
|
|
9fcc6d47c2 | ||
|
|
5f76d5cd76 |
69
.github/workflows/release.yaml
vendored
69
.github/workflows/release.yaml
vendored
@@ -9,7 +9,7 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
goreleaser:
|
build-linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -20,11 +20,74 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: "1.25.0"
|
go-version: "1.25.0"
|
||||||
|
|
||||||
- name: run goreleaser
|
- name: build linux binaries
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
version: "~> v2"
|
version: "~> v2"
|
||||||
args: release --clean
|
args: build --clean --id linux
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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 }}
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,6 +27,9 @@ Thumbs.db
|
|||||||
# go
|
# go
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
|
# nix
|
||||||
|
result
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ before:
|
|||||||
- go mod tidy
|
- go mod tidy
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- env:
|
- id: linux
|
||||||
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
@@ -23,6 +24,20 @@ builds:
|
|||||||
- -X snitch/cmd.Commit={{.ShortCommit}}
|
- -X snitch/cmd.Commit={{.ShortCommit}}
|
||||||
- -X snitch/cmd.Date={{.Date}}
|
- -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:
|
archives:
|
||||||
- formats:
|
- formats:
|
||||||
- tar.gz
|
- tar.gz
|
||||||
@@ -59,6 +74,8 @@ nfpms:
|
|||||||
- deb
|
- deb
|
||||||
- rpm
|
- rpm
|
||||||
- apk
|
- apk
|
||||||
|
builds:
|
||||||
|
- linux
|
||||||
|
|
||||||
release:
|
release:
|
||||||
github:
|
github:
|
||||||
@@ -66,4 +83,3 @@ release:
|
|||||||
name: snitch
|
name: snitch
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: auto
|
prerelease: auto
|
||||||
|
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -4,10 +4,40 @@ a friendlier `ss` / `netstat` for humans. inspect network connections with a cle
|
|||||||
|
|
||||||
## install
|
## install
|
||||||
|
|
||||||
|
### go
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install github.com/karol-broda/snitch@latest
|
go install github.com/karol-broda/snitch@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### nixos / nix
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# try it
|
||||||
|
nix run github:karol-broda/snitch
|
||||||
|
|
||||||
|
# install to profile
|
||||||
|
nix profile install github:karol-broda/snitch
|
||||||
|
|
||||||
|
# or add to flake inputs
|
||||||
|
{
|
||||||
|
inputs.snitch.url = "github:karol-broda/snitch";
|
||||||
|
}
|
||||||
|
# then use: inputs.snitch.packages.${system}.default
|
||||||
|
```
|
||||||
|
|
||||||
|
### binary
|
||||||
|
|
||||||
|
download from [releases](https://github.com/karol-broda/snitch/releases):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# amd64
|
||||||
|
curl -L https://github.com/karol-broda/snitch/releases/latest/download/snitch_linux_amd64.tar.gz | tar xz
|
||||||
|
sudo mv snitch /usr/local/bin/
|
||||||
|
|
||||||
|
# or install .deb/.rpm/.apk from releases
|
||||||
|
```
|
||||||
|
|
||||||
## quick start
|
## quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756217674,
|
"lastModified": 1765687488,
|
||||||
"narHash": "sha256-TH1SfSP523QI7kcPiNtMAEuwZR3Jdz0MCDXPs7TS8uo=",
|
"narHash": "sha256-7YAJ6xgBAQ/Nr+7MI13Tui1ULflgAdKh63m1tfYV7+M=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "4e7667a90c167f7a81d906e5a75cba4ad8bee620",
|
"rev": "d02bcc33948ca19b0aaa0213fe987ceec1f4ebe1",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
83
flake.nix
83
flake.nix
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
description = "go 1.25.0 dev flake";
|
description = "snitch - a friendlier ss/netstat for humans";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
||||||
@@ -10,44 +10,29 @@
|
|||||||
let
|
let
|
||||||
supportedSystems = import systems;
|
supportedSystems = import systems;
|
||||||
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
|
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
|
||||||
in
|
|
||||||
{
|
# go 1.25 overlay (required until nixpkgs has it)
|
||||||
overlays.default = final: prev:
|
goOverlay = final: prev:
|
||||||
let
|
let
|
||||||
version = "1.25.0";
|
version = "1.25.0";
|
||||||
|
|
||||||
platformInfo = {
|
platformInfo = {
|
||||||
"x86_64-linux" = { suffix = "linux-amd64"; sri = "sha256-KFKvDLIKExObNEiZLmm4aOUO0Pih5ZQO4d6eGaEjthM="; };
|
"x86_64-linux" = { suffix = "linux-amd64"; sri = "sha256-KFKvDLIKExObNEiZLmm4aOUO0Pih5ZQO4d6eGaEjthM="; };
|
||||||
"aarch64-linux" = { suffix = "linux-arm64"; sri = "sha256-Bd511plKJ4NpmBXuVTvVqTJ9i3mZHeNuOLZoYngvVK4="; };
|
"aarch64-linux" = { suffix = "linux-arm64"; sri = "sha256-Bd511plKJ4NpmBXuVTvVqTJ9i3mZHeNuOLZoYngvVK4="; };
|
||||||
"i686-linux" = { suffix = "linux-386"; sri = "sha256-jGAt2dmbyUU7OZXSDOS684LMUIVZAKDs5d6ZKd9KmTo="; };
|
|
||||||
"armv6l-linux" = { suffix = "linux-armv6l"; sri = "sha256-paj4GY/PAOHkhbjs757gIHeL8ypAik6Iczcb/ORYzQk="; };
|
|
||||||
|
|
||||||
"x86_64-darwin" = { suffix = "darwin-amd64"; sri = "sha256-W9YOgjA3BiwjB8cegRGAmGURZxTW9rQQWXz1B139gO8="; };
|
"x86_64-darwin" = { suffix = "darwin-amd64"; sri = "sha256-W9YOgjA3BiwjB8cegRGAmGURZxTW9rQQWXz1B139gO8="; };
|
||||||
"aarch64-darwin" = { suffix = "darwin-arm64"; sri = "sha256-VEkyhEFW2Bcveij3fyrJwVojBGaYtiQ/YzsKCwDAdJw="; };
|
"aarch64-darwin" = { suffix = "darwin-arm64"; sri = "sha256-VEkyhEFW2Bcveij3fyrJwVojBGaYtiQ/YzsKCwDAdJw="; };
|
||||||
};
|
};
|
||||||
|
|
||||||
hostSystem = prev.stdenv.hostPlatform.system;
|
hostSystem = prev.stdenv.hostPlatform.system;
|
||||||
|
chosen = platformInfo.${hostSystem} or (throw "unsupported system: ${hostSystem}");
|
||||||
chosen =
|
|
||||||
if prev.lib.hasAttr hostSystem platformInfo then platformInfo.${hostSystem}
|
|
||||||
else
|
|
||||||
throw ''
|
|
||||||
unsupported system: ${hostSystem}
|
|
||||||
add a mapping for your platform using the upstream tarball + sri sha256
|
|
||||||
'';
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
go_1_25_bin = prev.stdenvNoCC.mkDerivation {
|
go_1_25 = prev.stdenvNoCC.mkDerivation {
|
||||||
pname = "go";
|
pname = "go";
|
||||||
version = version;
|
inherit version;
|
||||||
|
|
||||||
src = prev.fetchurl {
|
src = prev.fetchurl {
|
||||||
url = "https://go.dev/dl/go${version}.${chosen.suffix}.tar.gz";
|
url = "https://go.dev/dl/go${version}.${chosen.suffix}.tar.gz";
|
||||||
hash = chosen.sri;
|
hash = chosen.sri;
|
||||||
};
|
};
|
||||||
|
|
||||||
dontBuild = true;
|
dontBuild = true;
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
mkdir -p "$out"/{bin,share}
|
mkdir -p "$out"/{bin,share}
|
||||||
@@ -57,35 +42,57 @@
|
|||||||
ln -s "$out/share/go/bin/gofmt" "$out/bin/gofmt"
|
ln -s "$out/share/go/bin/gofmt" "$out/bin/gofmt"
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
dontPatchELF = true;
|
dontPatchELF = true;
|
||||||
dontStrip = true;
|
dontStrip = true;
|
||||||
|
|
||||||
meta = with prev.lib; {
|
|
||||||
description = "go compiler and tools v${version}";
|
|
||||||
homepage = "https://go.dev/dl/";
|
|
||||||
license = licenses.bsd3;
|
|
||||||
platforms = [ hostSystem ];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
overlays.default = final: prev: {
|
||||||
|
snitch = final.callPackage ./nix/package.nix { };
|
||||||
};
|
};
|
||||||
|
|
||||||
packages = forAllSystems (system:
|
packages = forAllSystems (system:
|
||||||
let pkgs = import nixpkgs { inherit system; overlays = [ self.overlays.default ]; };
|
let
|
||||||
in {
|
pkgs = import nixpkgs {
|
||||||
default = pkgs.go_1_25_bin;
|
inherit system;
|
||||||
go_1_25_bin = pkgs.go_1_25_bin;
|
overlays = [ goOverlay ];
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkgs.buildGoModule {
|
||||||
|
pname = "snitch";
|
||||||
|
version = self.shortRev or self.dirtyShortRev or "dev";
|
||||||
|
src = self;
|
||||||
|
vendorHash = "sha256-BNNbA72puV0QSLkAlgn/buJJt7mIlVkbTEBhTXOg8pY=";
|
||||||
|
env.CGO_ENABLED = 0;
|
||||||
|
ldflags = [
|
||||||
|
"-s" "-w"
|
||||||
|
"-X snitch/cmd.version=${self.shortRev or "dev"}"
|
||||||
|
"-X snitch/cmd.commit=${self.shortRev or "unknown"}"
|
||||||
|
];
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "a friendlier ss/netstat for humans";
|
||||||
|
homepage = "https://github.com/karol-broda/snitch";
|
||||||
|
license = licenses.mit;
|
||||||
|
platforms = platforms.linux;
|
||||||
|
mainProgram = "snitch";
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
devShells = forAllSystems (system:
|
devShells = forAllSystems (system:
|
||||||
let pkgs = import nixpkgs { inherit system; overlays = [ self.overlays.default ]; };
|
let
|
||||||
in {
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [ goOverlay ];
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
packages = [ pkgs.go_1_25_bin pkgs.git ];
|
packages = [ pkgs.go_1_25 pkgs.git ];
|
||||||
|
|
||||||
GOTOOLCHAIN = "local";
|
GOTOOLCHAIN = "local";
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
echo "go toolchain: $(go version)"
|
echo "go toolchain: $(go version)"
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
package collector
|
package collector
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
"net"
|
||||||
"os"
|
|
||||||
"os/user"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Collector interface defines methods for collecting connection data
|
// Collector interface defines methods for collecting connection data
|
||||||
@@ -19,9 +10,6 @@ type Collector interface {
|
|||||||
GetConnections() ([]Connection, error)
|
GetConnections() ([]Connection, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultCollector implements the Collector interface using /proc
|
|
||||||
type DefaultCollector struct{}
|
|
||||||
|
|
||||||
// Global collector instance (can be overridden for testing)
|
// Global collector instance (can be overridden for testing)
|
||||||
var globalCollector Collector = &DefaultCollector{}
|
var globalCollector Collector = &DefaultCollector{}
|
||||||
|
|
||||||
@@ -40,64 +28,6 @@ func GetConnections() ([]Connection, error) {
|
|||||||
return globalCollector.GetConnections()
|
return globalCollector.GetConnections()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConnections fetches all network connections by parsing /proc files.
|
|
||||||
func (dc *DefaultCollector) GetConnections() ([]Connection, error) {
|
|
||||||
if runtime.GOOS != "linux" {
|
|
||||||
return nil, fmt.Errorf("proc-based collector only supports Linux")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build map of inode -> process info by scanning /proc
|
|
||||||
inodeMap, err := buildInodeToProcessMap()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to build inode map: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var connections []Connection
|
|
||||||
|
|
||||||
// Parse TCP connections
|
|
||||||
tcpConns, err := parseProcNet("/proc/net/tcp", "tcp", 4, inodeMap)
|
|
||||||
if err == nil {
|
|
||||||
connections = append(connections, tcpConns...)
|
|
||||||
}
|
|
||||||
|
|
||||||
tcpConns6, err := parseProcNet("/proc/net/tcp6", "tcp6", 6, inodeMap)
|
|
||||||
if err == nil {
|
|
||||||
connections = append(connections, tcpConns6...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse UDP connections
|
|
||||||
udpConns, err := parseProcNet("/proc/net/udp", "udp", 4, inodeMap)
|
|
||||||
if err == nil {
|
|
||||||
connections = append(connections, udpConns...)
|
|
||||||
}
|
|
||||||
|
|
||||||
udpConns6, err := parseProcNet("/proc/net/udp6", "udp6", 6, inodeMap)
|
|
||||||
if err == nil {
|
|
||||||
connections = append(connections, udpConns6...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return connections, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllConnections returns both network and Unix domain socket connections
|
|
||||||
func GetAllConnections() ([]Connection, error) {
|
|
||||||
// Get network connections
|
|
||||||
networkConns, err := GetConnections()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Unix sockets (only on Linux)
|
|
||||||
if runtime.GOOS == "linux" {
|
|
||||||
unixConns, err := GetUnixSockets()
|
|
||||||
if err == nil {
|
|
||||||
networkConns = append(networkConns, unixConns...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return networkConns, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func FilterConnections(conns []Connection, filters FilterOptions) []Connection {
|
func FilterConnections(conns []Connection, filters FilterOptions) []Connection {
|
||||||
if filters.IsEmpty() {
|
if filters.IsEmpty() {
|
||||||
return conns
|
return conns
|
||||||
@@ -112,395 +42,60 @@ func FilterConnections(conns []Connection, filters FilterOptions) []Connection {
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
// processInfo holds information about a process
|
func guessNetworkInterface(addr string) string {
|
||||||
type processInfo struct {
|
|
||||||
pid int
|
|
||||||
command string
|
|
||||||
uid int
|
|
||||||
user string
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildInodeToProcessMap scans /proc to map socket inodes to processes
|
|
||||||
func buildInodeToProcessMap() (map[int64]*processInfo, error) {
|
|
||||||
inodeMap := make(map[int64]*processInfo)
|
|
||||||
|
|
||||||
procDir, err := os.Open("/proc")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer procDir.Close()
|
|
||||||
|
|
||||||
entries, err := procDir.Readdir(-1)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if directory name is a number (pid)
|
|
||||||
pidStr := entry.Name()
|
|
||||||
pid, err := strconv.Atoi(pidStr)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// get process info
|
|
||||||
procInfo, err := getProcessInfo(pid)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// scan /proc/[pid]/fd/ for socket file descriptors
|
|
||||||
fdDir := filepath.Join("/proc", pidStr, "fd")
|
|
||||||
fdEntries, err := os.ReadDir(fdDir)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, fdEntry := range fdEntries {
|
|
||||||
fdPath := filepath.Join(fdDir, fdEntry.Name())
|
|
||||||
link, err := os.Readlink(fdPath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// socket inodes look like: socket:[12345]
|
|
||||||
if strings.HasPrefix(link, "socket:[") && strings.HasSuffix(link, "]") {
|
|
||||||
inodeStr := link[8 : len(link)-1]
|
|
||||||
inode, err := strconv.ParseInt(inodeStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
inodeMap[inode] = procInfo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return inodeMap, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getProcessInfo reads process information from /proc/[pid]/
|
|
||||||
func getProcessInfo(pid int) (*processInfo, error) {
|
|
||||||
info := &processInfo{pid: pid}
|
|
||||||
|
|
||||||
// prefer /proc/[pid]/comm as it's always just the command name
|
|
||||||
commPath := filepath.Join("/proc", strconv.Itoa(pid), "comm")
|
|
||||||
commData, err := os.ReadFile(commPath)
|
|
||||||
if err == nil && len(commData) > 0 {
|
|
||||||
info.command = strings.TrimSpace(string(commData))
|
|
||||||
}
|
|
||||||
|
|
||||||
// if comm is not available, try cmdline
|
|
||||||
if info.command == "" {
|
|
||||||
cmdlinePath := filepath.Join("/proc", strconv.Itoa(pid), "cmdline")
|
|
||||||
cmdlineData, err := os.ReadFile(cmdlinePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// cmdline is null-separated, take first part
|
|
||||||
if len(cmdlineData) > 0 {
|
|
||||||
parts := bytes.Split(cmdlineData, []byte{0})
|
|
||||||
if len(parts) > 0 && len(parts[0]) > 0 {
|
|
||||||
fullPath := string(parts[0])
|
|
||||||
// extract basename from full path
|
|
||||||
baseName := filepath.Base(fullPath)
|
|
||||||
// if basename contains spaces (single-string cmdline), take first word
|
|
||||||
if strings.Contains(baseName, " ") {
|
|
||||||
baseName = strings.Fields(baseName)[0]
|
|
||||||
}
|
|
||||||
info.command = baseName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// read UID from /proc/[pid]/status
|
|
||||||
statusPath := filepath.Join("/proc", strconv.Itoa(pid), "status")
|
|
||||||
statusFile, err := os.Open(statusPath)
|
|
||||||
if err != nil {
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
defer statusFile.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(statusFile)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if strings.HasPrefix(line, "Uid:") {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) >= 2 {
|
|
||||||
uid, err := strconv.Atoi(fields[1])
|
|
||||||
if err == nil {
|
|
||||||
info.uid = uid
|
|
||||||
// get username from uid
|
|
||||||
u, err := user.LookupId(strconv.Itoa(uid))
|
|
||||||
if err == nil {
|
|
||||||
info.user = u.Username
|
|
||||||
} else {
|
|
||||||
info.user = strconv.Itoa(uid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseProcNet parses a /proc/net/tcp or /proc/net/udp file
|
|
||||||
func parseProcNet(path, proto string, ipVersion int, inodeMap map[int64]*processInfo) ([]Connection, error) {
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
var connections []Connection
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
|
|
||||||
// skip header
|
|
||||||
scanner.Scan()
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 10 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse local address and port
|
|
||||||
localAddr, localPort, err := parseHexAddr(fields[1])
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse remote address and port
|
|
||||||
remoteAddr, remotePort, err := parseHexAddr(fields[2])
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse state (field 3)
|
|
||||||
stateHex := fields[3]
|
|
||||||
state := parseState(stateHex, proto)
|
|
||||||
|
|
||||||
// parse inode (field 9)
|
|
||||||
inode, _ := strconv.ParseInt(fields[9], 10, 64)
|
|
||||||
|
|
||||||
conn := Connection{
|
|
||||||
TS: time.Now(),
|
|
||||||
Proto: proto,
|
|
||||||
IPVersion: fmt.Sprintf("IPv%d", ipVersion),
|
|
||||||
State: state,
|
|
||||||
Laddr: localAddr,
|
|
||||||
Lport: localPort,
|
|
||||||
Raddr: remoteAddr,
|
|
||||||
Rport: remotePort,
|
|
||||||
Inode: inode,
|
|
||||||
}
|
|
||||||
|
|
||||||
// add process info if available
|
|
||||||
if procInfo, exists := inodeMap[inode]; exists {
|
|
||||||
conn.PID = procInfo.pid
|
|
||||||
conn.Process = procInfo.command
|
|
||||||
conn.UID = procInfo.uid
|
|
||||||
conn.User = procInfo.user
|
|
||||||
}
|
|
||||||
|
|
||||||
// determine interface
|
|
||||||
conn.Interface = guessNetworkInterface(localAddr, nil)
|
|
||||||
|
|
||||||
connections = append(connections, conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
return connections, scanner.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseState converts hex state value to string
|
|
||||||
func parseState(hexState, proto string) string {
|
|
||||||
state, err := strconv.ParseInt(hexState, 16, 32)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// TCP states
|
|
||||||
tcpStates := map[int64]string{
|
|
||||||
0x01: "ESTABLISHED",
|
|
||||||
0x02: "SYN_SENT",
|
|
||||||
0x03: "SYN_RECV",
|
|
||||||
0x04: "FIN_WAIT1",
|
|
||||||
0x05: "FIN_WAIT2",
|
|
||||||
0x06: "TIME_WAIT",
|
|
||||||
0x07: "CLOSE",
|
|
||||||
0x08: "CLOSE_WAIT",
|
|
||||||
0x09: "LAST_ACK",
|
|
||||||
0x0A: "LISTEN",
|
|
||||||
0x0B: "CLOSING",
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(proto, "tcp") {
|
|
||||||
if s, exists := tcpStates[state]; exists {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// UDP doesn't have states in the same way
|
|
||||||
if state == 0x07 {
|
|
||||||
return "CLOSE"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseHexAddr parses hex-encoded address:port from /proc/net files
|
|
||||||
func parseHexAddr(hexAddr string) (string, int, error) {
|
|
||||||
parts := strings.Split(hexAddr, ":")
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return "", 0, fmt.Errorf("invalid address format")
|
|
||||||
}
|
|
||||||
|
|
||||||
hexIP := parts[0]
|
|
||||||
|
|
||||||
// parse hex port
|
|
||||||
port, err := strconv.ParseInt(parts[1], 16, 32)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(hexIP) == 8 {
|
|
||||||
// IPv4 (stored in little-endian)
|
|
||||||
ip1, _ := strconv.ParseInt(hexIP[6:8], 16, 32)
|
|
||||||
ip2, _ := strconv.ParseInt(hexIP[4:6], 16, 32)
|
|
||||||
ip3, _ := strconv.ParseInt(hexIP[2:4], 16, 32)
|
|
||||||
ip4, _ := strconv.ParseInt(hexIP[0:2], 16, 32)
|
|
||||||
addr := fmt.Sprintf("%d.%d.%d.%d", ip1, ip2, ip3, ip4)
|
|
||||||
|
|
||||||
// handle wildcard address
|
|
||||||
if addr == "0.0.0.0" {
|
|
||||||
addr = "*"
|
|
||||||
}
|
|
||||||
|
|
||||||
return addr, int(port), nil
|
|
||||||
} else if len(hexIP) == 32 {
|
|
||||||
// IPv6 (stored in little-endian per 32-bit word)
|
|
||||||
var ipv6Parts []string
|
|
||||||
for i := 0; i < 32; i += 8 {
|
|
||||||
word := hexIP[i : i+8]
|
|
||||||
// reverse byte order within each 32-bit word
|
|
||||||
p1 := word[6:8] + word[4:6] + word[2:4] + word[0:2]
|
|
||||||
ipv6Parts = append(ipv6Parts, p1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// convert to standard IPv6 notation
|
|
||||||
fullAddr := strings.Join(ipv6Parts, "")
|
|
||||||
var formatted []string
|
|
||||||
for i := 0; i < len(fullAddr); i += 4 {
|
|
||||||
formatted = append(formatted, fullAddr[i:i+4])
|
|
||||||
}
|
|
||||||
addr := strings.Join(formatted, ":")
|
|
||||||
|
|
||||||
// simplify IPv6 address
|
|
||||||
addr = simplifyIPv6(addr)
|
|
||||||
|
|
||||||
// handle wildcard address
|
|
||||||
if addr == "::" || addr == "0:0:0:0:0:0:0:0" {
|
|
||||||
addr = "*"
|
|
||||||
}
|
|
||||||
|
|
||||||
return addr, int(port), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", 0, fmt.Errorf("unsupported address format")
|
|
||||||
}
|
|
||||||
|
|
||||||
// simplifyIPv6 simplifies IPv6 address notation
|
|
||||||
func simplifyIPv6(addr string) string {
|
|
||||||
// remove leading zeros from each group
|
|
||||||
parts := strings.Split(addr, ":")
|
|
||||||
for i, part := range parts {
|
|
||||||
// convert to int and back to remove leading zeros
|
|
||||||
val, err := strconv.ParseInt(part, 16, 64)
|
|
||||||
if err == nil {
|
|
||||||
parts[i] = strconv.FormatInt(val, 16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ":")
|
|
||||||
}
|
|
||||||
|
|
||||||
func guessNetworkInterface(addr string, interfaces map[string]string) string {
|
|
||||||
// Simple heuristic - try to match common interface patterns
|
|
||||||
if addr == "127.0.0.1" || addr == "::1" {
|
if addr == "127.0.0.1" || addr == "::1" {
|
||||||
return "lo"
|
return "lo"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a private network address
|
|
||||||
ip := net.ParseIP(addr)
|
ip := net.ParseIP(addr)
|
||||||
if ip != nil {
|
if ip == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
if ip.IsLoopback() {
|
if ip.IsLoopback() {
|
||||||
return "lo"
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// default interface name varies by OS but we return a generic value
|
||||||
|
// actual interface detection would require routing table analysis
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Unix socket support
|
func simplifyIPv6(addr string) string {
|
||||||
func GetUnixSockets() ([]Connection, error) {
|
parts := strings.Split(addr, ":")
|
||||||
connections := []Connection{}
|
for i, part := range parts {
|
||||||
|
// parse as hex then format back to remove leading zeros
|
||||||
// Parse /proc/net/unix for Unix domain sockets
|
var val int64
|
||||||
file, err := os.Open("/proc/net/unix")
|
for _, c := range part {
|
||||||
if err != nil {
|
val = val*16 + int64(hexCharToInt(c))
|
||||||
return connections, nil // silently fail on non-Linux systems
|
|
||||||
}
|
}
|
||||||
defer file.Close()
|
parts[i] = formatHex(val)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
return strings.Join(parts, ":")
|
||||||
// Parse Unix socket information
|
|
||||||
inode, _ := strconv.ParseInt(fields[6], 10, 64)
|
|
||||||
path := ""
|
|
||||||
if len(fields) > 7 {
|
|
||||||
path = fields[7]
|
|
||||||
}
|
|
||||||
|
|
||||||
conn := Connection{
|
|
||||||
TS: time.Now(),
|
|
||||||
Proto: "unix",
|
|
||||||
Laddr: path,
|
|
||||||
Raddr: "",
|
|
||||||
State: "CONNECTED", // Simplified
|
|
||||||
Inode: inode,
|
|
||||||
Interface: "unix",
|
|
||||||
}
|
|
||||||
|
|
||||||
connections = append(connections, conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
return connections, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hexCharToInt(c rune) int {
|
||||||
|
switch {
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
return int(c - '0')
|
||||||
|
case c >= 'a' && c <= 'f':
|
||||||
|
return int(c - 'a' + 10)
|
||||||
|
case c >= 'A' && c <= 'F':
|
||||||
|
return int(c - 'A' + 10)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatHex(val int64) string {
|
||||||
|
if val == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
const hexDigits = "0123456789abcdef"
|
||||||
|
var result []byte
|
||||||
|
for val > 0 {
|
||||||
|
result = append([]byte{hexDigits[val%16]}, result...)
|
||||||
|
val /= 16
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|||||||
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