feat: add port search, remote sort, export, and process info

This commit is contained in:
Karol Broda
2025-12-29 19:36:05 +01:00
parent 7c757f2769
commit d7aae1de84
12 changed files with 906 additions and 25 deletions

View File

@@ -37,6 +37,19 @@ static const char* get_username(int uid) {
return pw->pw_name;
}
// get current working directory for a process
static int get_proc_cwd(int pid, char *path, int pathlen) {
struct proc_vnodepathinfo vpi;
int ret = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &vpi, sizeof(vpi));
if (ret <= 0) {
path[0] = '\0';
return -1;
}
strncpy(path, vpi.pvi_cdir.vip_path, pathlen - 1);
path[pathlen - 1] = '\0';
return 0;
}
// socket info extraction - handles the union properly in C
typedef struct {
int family;
@@ -164,6 +177,7 @@ func listAllPids() ([]int, error) {
func getConnectionsForPid(pid int) ([]Connection, error) {
procName := getProcessName(pid)
cwd := getProcessCwd(pid)
uid := int(C.get_proc_uid(C.int(pid)))
user := ""
if uid >= 0 {
@@ -198,7 +212,7 @@ func getConnectionsForPid(pid int) ([]Connection, error) {
continue
}
conn, ok := getSocketInfo(pid, int(fdInfo.proc_fd), procName, uid, user)
conn, ok := getSocketInfo(pid, int(fdInfo.proc_fd), procName, cwd, uid, user)
if ok {
connections = append(connections, conn)
}
@@ -207,7 +221,7 @@ func getConnectionsForPid(pid int) ([]Connection, error) {
return connections, nil
}
func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connection, bool) {
func getSocketInfo(pid, fd int, procName, cwd string, uid int, user string) (Connection, bool) {
var info C.socket_info_t
ret := C.get_socket_info(C.int(pid), C.int(fd), &info)
@@ -276,6 +290,7 @@ func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connecti
Rport: int(info.rport),
PID: pid,
Process: procName,
Cwd: cwd,
UID: uid,
User: user,
Interface: guessNetworkInterface(laddr),
@@ -293,6 +308,15 @@ func getProcessName(pid int) string {
return C.GoString(&name[0])
}
func getProcessCwd(pid int) string {
var path [1024]C.char
ret := C.get_proc_cwd(C.int(pid), &path[0], 1024)
if ret != 0 {
return ""
}
return C.GoString(&path[0])
}
func ipv4ToString(addr uint32) string {
ip := make(net.IP, 4)
ip[0] = byte(addr)

View File

@@ -125,6 +125,8 @@ func GetAllConnections() ([]Connection, error) {
type processInfo struct {
pid int
command string
cmdline string
cwd string
uid int
user string
}
@@ -248,34 +250,45 @@ func scanProcessSockets(pid int) []inodeEntry {
func getProcessInfo(pid int) (*processInfo, error) {
info := &processInfo{pid: pid}
pidStr := strconv.Itoa(pid)
commPath := filepath.Join("/proc", strconv.Itoa(pid), "comm")
commPath := filepath.Join("/proc", pidStr, "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
cmdlinePath := filepath.Join("/proc", pidStr, "cmdline")
cmdlineData, err := os.ReadFile(cmdlinePath)
if err == nil && len(cmdlineData) > 0 {
parts := bytes.Split(cmdlineData, []byte{0})
var args []string
for _, p := range parts {
if len(p) > 0 {
args = append(args, string(p))
}
}
info.cmdline = strings.Join(args, " ")
if info.command == "" && 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
}
} else if info.command == "" {
return nil, err
}
statusPath := filepath.Join("/proc", strconv.Itoa(pid), "status")
cwdPath := filepath.Join("/proc", pidStr, "cwd")
cwdLink, err := os.Readlink(cwdPath)
if err == nil {
info.cwd = cwdLink
}
statusPath := filepath.Join("/proc", pidStr, "status")
statusFile, err := os.Open(statusPath)
if err != nil {
return info, nil
@@ -361,6 +374,8 @@ func parseProcNet(path, proto string, ipVersion int, inodeMap map[int64]*process
if procInfo, exists := inodeMap[inode]; exists {
conn.PID = procInfo.pid
conn.Process = procInfo.command
conn.Cmdline = procInfo.cmdline
conn.Cwd = procInfo.cwd
conn.UID = procInfo.uid
conn.User = procInfo.user
}

View File

@@ -114,4 +114,60 @@ func BenchmarkBuildInodeMap(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = buildInodeToProcessMap()
}
}
func TestConnectionHasCmdlineAndCwd(t *testing.T) {
conns, err := GetConnections()
if err != nil {
t.Fatalf("GetConnections() returned an error: %v", err)
}
if len(conns) == 0 {
t.Skip("no connections to test")
}
// find a connection with a PID (owned by some process)
var connWithProcess *Connection
for i := range conns {
if conns[i].PID > 0 {
connWithProcess = &conns[i]
break
}
}
if connWithProcess == nil {
t.Skip("no connections with associated process found")
}
t.Logf("testing connection: pid=%d process=%s", connWithProcess.PID, connWithProcess.Process)
// cmdline and cwd should be populated for connections with PIDs
// note: they might be empty if we don't have permission to read them
if connWithProcess.Cmdline != "" {
t.Logf("cmdline: %s", connWithProcess.Cmdline)
} else {
t.Logf("cmdline is empty (might be permission issue)")
}
if connWithProcess.Cwd != "" {
t.Logf("cwd: %s", connWithProcess.Cwd)
} else {
t.Logf("cwd is empty (might be permission issue)")
}
}
func TestGetProcessInfoPopulatesCmdlineAndCwd(t *testing.T) {
// test that getProcessInfo correctly populates cmdline and cwd for our own process
info, err := getProcessInfo(1) // init process (usually has cwd of /)
if err != nil {
t.Logf("could not get process info for pid 1: %v", err)
t.Skip("skipping - may not have permission")
}
t.Logf("pid 1 info: command=%s cmdline=%s cwd=%s", info.command, info.cmdline, info.cwd)
// at minimum, we should have a command name
if info.command == "" && info.cmdline == "" {
t.Error("expected either command or cmdline to be populated")
}
}

View File

@@ -128,3 +128,75 @@ func TestSortByTimestamp(t *testing.T) {
}
}
func TestSortByRemoteAddr(t *testing.T) {
conns := []Connection{
{Raddr: "192.168.1.100", Rport: 443},
{Raddr: "10.0.0.1", Rport: 80},
{Raddr: "172.16.0.50", Rport: 8080},
}
t.Run("sort by raddr ascending", func(t *testing.T) {
c := make([]Connection, len(conns))
copy(c, conns)
SortConnections(c, SortOptions{Field: SortByRaddr, Direction: SortAsc})
if c[0].Raddr != "10.0.0.1" {
t.Errorf("expected '10.0.0.1' first, got '%s'", c[0].Raddr)
}
if c[1].Raddr != "172.16.0.50" {
t.Errorf("expected '172.16.0.50' second, got '%s'", c[1].Raddr)
}
if c[2].Raddr != "192.168.1.100" {
t.Errorf("expected '192.168.1.100' last, got '%s'", c[2].Raddr)
}
})
t.Run("sort by raddr descending", func(t *testing.T) {
c := make([]Connection, len(conns))
copy(c, conns)
SortConnections(c, SortOptions{Field: SortByRaddr, Direction: SortDesc})
if c[0].Raddr != "192.168.1.100" {
t.Errorf("expected '192.168.1.100' first, got '%s'", c[0].Raddr)
}
})
}
func TestSortByRemotePort(t *testing.T) {
conns := []Connection{
{Raddr: "192.168.1.1", Rport: 443},
{Raddr: "192.168.1.2", Rport: 80},
{Raddr: "192.168.1.3", Rport: 8080},
}
t.Run("sort by rport ascending", func(t *testing.T) {
c := make([]Connection, len(conns))
copy(c, conns)
SortConnections(c, SortOptions{Field: SortByRport, Direction: SortAsc})
if c[0].Rport != 80 {
t.Errorf("expected port 80 first, got %d", c[0].Rport)
}
if c[1].Rport != 443 {
t.Errorf("expected port 443 second, got %d", c[1].Rport)
}
if c[2].Rport != 8080 {
t.Errorf("expected port 8080 last, got %d", c[2].Rport)
}
})
t.Run("sort by rport descending", func(t *testing.T) {
c := make([]Connection, len(conns))
copy(c, conns)
SortConnections(c, SortOptions{Field: SortByRport, Direction: SortDesc})
if c[0].Rport != 8080 {
t.Errorf("expected port 8080 first, got %d", c[0].Rport)
}
})
}

View File

@@ -6,6 +6,8 @@ type Connection struct {
TS time.Time `json:"ts"`
PID int `json:"pid"`
Process string `json:"process"`
Cmdline string `json:"cmdline,omitempty"`
Cwd string `json:"cwd,omitempty"`
User string `json:"user"`
UID int `json:"uid"`
Proto string `json:"proto"`