Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions cmd/ls.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
// ls-specific flags
var (
outputFormat string
outputFile string
noHeaders bool
showTimestamp bool
sortBy string
Expand Down Expand Up @@ -72,9 +73,77 @@ func runListCommand(outputFormat string, args []string) {
selectedFields = strings.Split(fields, ",")
}

// handle file output
if outputFile != "" {
writeToFile(rt.Connections, outputFile, selectedFields)
return
}

renderList(rt.Connections, outputFormat, selectedFields)
}

func writeToFile(connections []collector.Connection, filename string, selectedFields []string) {
file, err := os.Create(filename)
if err != nil {
log.Fatalf("failed to create file: %v", err)
}
defer errutil.Close(file)

// determine format from extension
format := "csv"
lowerFilename := strings.ToLower(filename)
if strings.HasSuffix(lowerFilename, ".json") {
format = "json"
} else if strings.HasSuffix(lowerFilename, ".tsv") {
format = "tsv"
}

if len(selectedFields) == 0 {
selectedFields = []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"}
if showTimestamp {
selectedFields = append([]string{"ts"}, selectedFields...)
}
}

switch format {
case "json":
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(connections); err != nil {
log.Fatalf("failed to write JSON: %v", err)
}
case "tsv":
writeDelimited(file, connections, "\t", !noHeaders, selectedFields)
default:
writeDelimited(file, connections, ",", !noHeaders, selectedFields)
}

fmt.Fprintf(os.Stderr, "exported %d connections to %s\n", len(connections), filename)
}

func writeDelimited(w io.Writer, connections []collector.Connection, delimiter string, headers bool, selectedFields []string) {
if headers {
headerRow := make([]string, len(selectedFields))
for i, field := range selectedFields {
headerRow[i] = strings.ToUpper(field)
}
_, _ = fmt.Fprintln(w, strings.Join(headerRow, delimiter))
}

for _, conn := range connections {
fieldMap := getFieldMap(conn)
row := make([]string, len(selectedFields))
for i, field := range selectedFields {
val := fieldMap[field]
if delimiter == "," && (strings.Contains(val, ",") || strings.Contains(val, "\"") || strings.Contains(val, "\n")) {
val = "\"" + strings.ReplaceAll(val, "\"", "\"\"") + "\""
}
row[i] = val
}
_, _ = fmt.Fprintln(w, strings.Join(row, delimiter))
}
}

func renderList(connections []collector.Connection, format string, selectedFields []string) {
switch format {
case "json":
Expand Down Expand Up @@ -122,6 +191,8 @@ func getFieldMap(c collector.Connection) map[string]string {
return map[string]string{
"pid": strconv.Itoa(c.PID),
"process": c.Process,
"cmdline": c.Cmdline,
"cwd": c.Cwd,
"user": c.User,
"uid": strconv.Itoa(c.UID),
"proto": c.Proto,
Expand Down Expand Up @@ -395,6 +466,7 @@ func init() {

// ls-specific flags
lsCmd.Flags().StringVarP(&outputFormat, "output", "o", cfg.Defaults.OutputFormat, "Output format (table, wide, json, csv)")
lsCmd.Flags().StringVarP(&outputFile, "output-file", "O", "", "Write output to file (format detected from extension: .csv, .tsv, .json)")
lsCmd.Flags().BoolVar(&noHeaders, "no-headers", cfg.Defaults.NoHeaders, "Omit headers for table/csv output")
lsCmd.Flags().BoolVar(&showTimestamp, "ts", false, "Include timestamp in output")
lsCmd.Flags().StringVarP(&sortBy, "sort", "s", cfg.Defaults.SortBy, "Sort by column (e.g., pid:desc)")
Expand Down
28 changes: 26 additions & 2 deletions internal/collector/collector_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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),
Expand All @@ -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)
Expand Down
47 changes: 31 additions & 16 deletions internal/collector/collector_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ func GetAllConnections() ([]Connection, error) {
type processInfo struct {
pid int
command string
cmdline string
cwd string
uid int
user string
}
Expand Down Expand Up @@ -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
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 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
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
}

cwdPath := filepath.Join("/proc", pidStr, "cwd")
cwdLink, err := os.Readlink(cwdPath)
if err == nil {
info.cwd = cwdLink
}

statusPath := filepath.Join("/proc", strconv.Itoa(pid), "status")
statusPath := filepath.Join("/proc", pidStr, "status")
statusFile, err := os.Open(statusPath)
if err != nil {
return info, nil
Expand Down Expand Up @@ -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
}
Expand Down
56 changes: 56 additions & 0 deletions internal/collector/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
72 changes: 72 additions & 0 deletions internal/collector/sort_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}

2 changes: 2 additions & 0 deletions internal/collector/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading
Loading