feat(go): port socket detectors

This commit is contained in:
Luna 2026-07-10 17:29:07 -07:00
parent fc9b5f0448
commit c6f7219de8
23 changed files with 693 additions and 28 deletions

View file

@ -4,11 +4,15 @@
package system
import (
"context"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
@ -17,6 +21,34 @@ import (
type Paths struct {
ProcRoot string
LDPreloadPath string
SocketSource func() []model.Socket
}
var (
inodePattern = regexp.MustCompile(`\bino:(\d+)`)
userPattern = regexp.MustCompile(`users:\(\("([^"]+)",pid=(\d+),fd=\d+`)
)
var socketSpecs = []struct {
args []string
kind string
}{
{[]string{"-tanep"}, "tcp"},
{[]string{"-uanep"}, "udp"},
{[]string{"-wanep"}, "raw"},
{[]string{"-Sanep"}, "sctp"},
{[]string{"-danep"}, "dccp"},
{[]string{"-0anep"}, "packet"},
{[]string{"-Manep"}, "mptcp"},
{[]string{"--tipc", "-anep"}, "tipc"},
{[]string{"--xdp", "-anep"}, "xdp"},
{[]string{"--vsock", "-anep"}, "vsock"},
}
var socketNetIDs = map[string]bool{
"tcp": true, "udp": true, "raw": true, "sctp": true, "dccp": true,
"mptcp": true, "p_raw": true, "p_dgr": true, "packet": true,
"tipc": true, "xdp": true, "vsock": true,
}
// Capture reads process state once from procRoot. Transient per-process read
@ -55,12 +87,79 @@ func CaptureWithPaths(paths Paths) (model.State, error) {
})
}
sort.Slice(processes, func(i, j int) bool { return processes[i].PID < processes[j].PID })
var sockets []model.Socket
if paths.SocketSource != nil {
sockets = paths.SocketSource()
} else {
sockets = captureSockets()
}
return model.State{
Processes: processes,
Sockets: sockets,
LDPreload: strings.TrimSpace(readText(paths.LDPreloadPath)),
}, nil
}
func captureSockets() []model.Socket {
result := make([]model.Socket, 0)
for _, spec := range socketSpecs {
// Python asks ss once per protocol family because several families use
// incompatible flags. Preserve that behavior instead of hiding gaps
// behind a single TCP/UDP-only invocation.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
command := exec.CommandContext(ctx, "ss", append([]string{"-H"}, spec.args...)...)
output, err := command.Output()
cancel()
if err != nil {
// Collection is fail-open by design: a missing/unsupported ss flag
// drops only that socket family and never stops process detection.
continue
}
result = append(result, parseSS(string(output), spec.kind)...)
}
return result
}
func parseSS(text, kind string) []model.Socket {
result := make([]model.Socket, 0)
for _, line := range strings.Split(text, "\n") {
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
var state, local, peer string
// Some ss versions prefix each row with the protocol netid and some
// begin directly with state. Python accepts both layouts; Go must too.
if socketNetIDs[strings.ToLower(fields[0])] && len(fields) >= 6 {
state, local, peer = fields[1], fields[4], fields[5]
} else {
state, local, peer = fields[0], fields[3], fields[4]
}
var inode, pid *int
// Ownership is best-effort. Nil values are meaningful because Python
// serializes an unknown PID differently in keys (None) and prose (?).
if match := inodePattern.FindStringSubmatch(line); len(match) == 2 {
value, err := strconv.Atoi(match[1])
if err == nil {
inode = &value
}
}
comm := ""
if match := userPattern.FindStringSubmatch(line); len(match) == 3 {
comm = match[1]
value, err := strconv.Atoi(match[2])
if err == nil {
pid = &value
}
}
result = append(result, model.Socket{
State: state, Local: local, Peer: peer, Inode: inode,
Comm: comm, PID: pid, Kind: kind,
})
}
return result
}
func parseEnviron(raw string) map[string]string {
result := make(map[string]string)
for _, item := range strings.Split(raw, "\x00") {

View file

@ -6,6 +6,8 @@ import (
"os"
"path/filepath"
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestCaptureUsesInjectableProcRoot(t *testing.T) {
@ -37,7 +39,10 @@ func TestCaptureUsesInjectableProcRoot(t *testing.T) {
if err := os.WriteFile(preload, []byte("/tmp/global.so\n"), 0o600); err != nil {
t.Fatal(err)
}
state, err := CaptureWithPaths(Paths{ProcRoot: root, LDPreloadPath: preload})
state, err := CaptureWithPaths(Paths{
ProcRoot: root, LDPreloadPath: preload,
SocketSource: func() []model.Socket { return []model.Socket{} },
})
if err != nil {
t.Fatal(err)
}
@ -55,3 +60,18 @@ func TestCaptureUsesInjectableProcRoot(t *testing.T) {
t.Fatalf("unexpected global preload: %q", state.LDPreload)
}
}
func TestParseSSMatchesPythonShapes(t *testing.T) {
text := "tcp ESTAB 0 0 10.0.0.2:5555 8.8.8.8:443 users:((\"bash\",pid=42,fd=3)) ino:999\n"
sockets := parseSS(text, "tcp")
if len(sockets) != 1 {
t.Fatalf("unexpected sockets: %#v", sockets)
}
got := sockets[0]
if got.State != "ESTAB" || got.Local != "10.0.0.2:5555" || got.Peer != "8.8.8.8:443" {
t.Fatalf("unexpected endpoints: %#v", got)
}
if got.Inode == nil || *got.Inode != 999 || got.PID == nil || *got.PID != 42 || got.Comm != "bash" {
t.Fatalf("unexpected ownership: %#v", got)
}
}