diff --git a/net/net_aix.go b/net/net_aix.go index 4531dd4449..ac4721eb5e 100644 --- a/net/net_aix.go +++ b/net/net_aix.go @@ -31,8 +31,113 @@ func ConntrackStatsWithContext(_ context.Context, _ bool) ([]ConntrackStat, erro return nil, common.ErrNotImplementedError } -func ProtoCountersWithContext(_ context.Context, _ []string) ([]ProtoCountersStat, error) { - return nil, common.ErrNotImplementedError +func ProtoCountersWithContext(ctx context.Context, protocols []string) ([]ProtoCountersStat, error) { + out, err := invoke.CommandWithContext(ctx, "netstat", "-s") + if err != nil { + return nil, err + } + return parseNetstatS(string(out), protocols) +} + +// parseNetstatS parses AIX netstat -s output into per-protocol statistics. +// If protocols is nil or empty, all recognized protocols are returned. +func parseNetstatS(output string, protocols []string) ([]ProtoCountersStat, error) { + wantAll := len(protocols) == 0 + want := make(map[string]bool) + for _, p := range protocols { + want[strings.ToLower(p)] = true + } + + // Split output into protocol sections. Section headers are ":" at column 0. + sections := make(map[string][]string) + var currentProto string + for _, line := range strings.Split(output, "\n") { + if line != "" && line[0] != '\t' && line[0] != ' ' && strings.HasSuffix(strings.TrimSpace(line), ":") { + currentProto = strings.TrimSuffix(strings.TrimSpace(line), ":") + continue + } + if currentProto != "" { + sections[currentProto] = append(sections[currentProto], line) + } + } + + var ret []ProtoCountersStat + for proto, lines := range sections { + if !wantAll && !want[proto] { + continue + } + + stats := make(map[string]int64) + for _, line := range lines { + // Only parse top-level stats (single-tab prefix). + // Skip sub-items (double-tab or deeper) to avoid double-counting. + if !strings.HasPrefix(line, "\t") || strings.HasPrefix(line, "\t\t") { + continue + } + + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + // Extract leading number. Format: " " + // Some lines have parenthetical data: "3958645 data packets (3187866062 bytes)" + idx := strings.IndexByte(trimmed, ' ') + if idx < 0 { + continue + } + val, err := strconv.ParseInt(trimmed[:idx], 10, 64) + if err != nil { + continue + } + desc := strings.TrimSpace(trimmed[idx+1:]) + key := descToCamelCase(desc) + if key != "" { + stats[key] = val + } + } + + if len(stats) > 0 { + ret = append(ret, ProtoCountersStat{ + Protocol: proto, + Stats: stats, + }) + } + } + return ret, nil +} + +// descToCamelCase converts a netstat -s description to a camelCase key. +// e.g. "packets sent" -> "packetsSent", "total packets received" -> "totalPacketsReceived" +// Parenthetical suffixes are stripped: "data packets (3187866062 bytes)" -> "dataPackets" +// Hyphens and underscores are treated as word boundaries: "ack-only" -> "ackOnly", "icmp_error" -> "icmpError" +func descToCamelCase(desc string) string { + // Strip parenthetical suffix + if idx := strings.IndexByte(desc, '('); idx > 0 { + desc = strings.TrimSpace(desc[:idx]) + } + + // Replace hyphens and underscores with spaces so they act as word boundaries + desc = strings.NewReplacer("-", " ", "_", " ").Replace(desc) + + words := strings.Fields(desc) + if len(words) == 0 { + return "" + } + + var b strings.Builder + for i, w := range words { + w = strings.ToLower(w) + if w == "" { + continue + } + if i == 0 { + b.WriteString(w) + } else { + b.WriteString(strings.ToUpper(w[:1]) + w[1:]) + } + } + return b.String() } func parseNetstatNetLine(line string) (ConnectionStat, error) { @@ -295,6 +400,188 @@ func ConnectionsPidMaxWithoutUidsWithContext(ctx context.Context, kind string, p return connectionsPidMaxWithoutUidsWithContext(ctx, kind, pid, maxConn, true) } -func connectionsPidMaxWithoutUidsWithContext(_ context.Context, _ string, _ int32, _ int, _ bool) ([]ConnectionStat, error) { - return []ConnectionStat{}, common.ErrNotImplementedError +// netstatAanEntry represents a parsed line from `netstat -Aan` output, +// pairing a socket control block address with the parsed connection info. +type netstatAanEntry struct { + sockAddr string // hex socket address from netstat (e.g. "f1000f00002d8bc0") + conn ConnectionStat // parsed connection details + proto string // raw protocol string (tcp, tcp4, tcp6, udp, etc.) +} + +// parseNetstatAan parses `netstat -Aan` output which prepends a socket address +// to each connection line. It reuses the existing parseNetstatNetLine and +// parseNetstatUnixLine functions by stripping the socket address field. +// +// Inet line format: [] +// Unix line format: [] +// Unix sockets span two lines; the second line (single hex field) is skipped. +func parseNetstatAan(output, kind string) ([]netstatAanEntry, error) { + var ret []netstatAanEntry + lines := strings.Split(output, "\n") + + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + + switch { + case strings.HasPrefix(fields[1], "tcp") || strings.HasPrefix(fields[1], "udp"): + // Inet line + if !hasCorrectInetProto(kind, fields[1]) { + continue + } + + if len(fields) < 6 { + continue + } + + // Skip connections with "*.*" as local address (no port bound) + if fields[4] == "*.*" { + continue + } + + // Strip the socket address and parse the remaining fields + connLine := strings.Join(fields[1:], " ") + conn, err := parseNetstatNetLine(connLine) + if err != nil { + return nil, fmt.Errorf("failed to parse Inet Address (%s): %w", line, err) + } + + ret = append(ret, netstatAanEntry{ + sockAddr: fields[0], + conn: conn, + proto: fields[1], + }) + + case fields[1] == "dgram" || fields[1] == "stream": + // Unix socket line + if kind != "all" && kind != "unix" { + continue + } + + conn, err := parseNetstatUnixLine(fields) + if err != nil { + return nil, fmt.Errorf("failed to parse Unix Address (%s): %w", line, err) + } + + ret = append(ret, netstatAanEntry{ + sockAddr: fields[0], + conn: conn, + proto: "unix", + }) + + default: + // Header lines, section separators, unix continuation lines + continue + } + } + + return ret, nil +} + +func connectionsPidMaxWithoutUidsWithContext(ctx context.Context, kind string, pid int32, maxConn int, _ bool) ([]ConnectionStat, error) { + // Normalize kind + kind = strings.ToLower(kind) + switch kind { + case "all", "inet", "inet4", "inet6", "tcp", "tcp4", "tcp6", "udp", "udp4", "udp6", "unix": + // valid + default: + kind = "all" + } + + // Build netstat args with -A to include socket addresses for PID resolution + args := []string{"-Aan"} + switch kind { + case "inet", "inet4", "inet6", "tcp", "tcp4", "tcp6", "udp", "udp4", "udp6": + args = append(args, "-finet") + case "unix": + args = append(args, "-funix") + } + + out, err := invoke.CommandWithContext(ctx, "netstat", args...) + if err != nil { + return nil, err + } + + entries, err := parseNetstatAan(string(out), kind) + if err != nil { + return nil, err + } + + var conns []ConnectionStat + for i := range entries { + if maxConn > 0 && len(conns) >= maxConn { + break + } + + // Resolve PID via rmsock for inet connections + var connPid int32 + if entries[i].proto != "unix" { + var protocol string + if strings.HasPrefix(entries[i].proto, "tcp") { + protocol = "tcp" + } else { + protocol = "udp" + } + connPid = resolveAIXSockToPid(ctx, entries[i].sockAddr, protocol) + } + + // If filtering by PID, skip non-matching connections + if pid > 0 && connPid != pid { + continue + } + + entries[i].conn.Pid = connPid + conns = append(conns, entries[i].conn) + } + + return conns, nil +} + +// rmsockPidRe matches PID in rmsock output. AIX rmsock has a known typo +// spelling "process" with a double 'c', so we match both spellings +// with proc+ess (one or more 'c'). +// Expected output: "The socket 0x... is being held by proc+ess ()." +var rmsockPidRe = regexp.MustCompile(`proc+ess\s+(\d+)\s+\(`) + +// parseAIXRmsockPid extracts PID from rmsock output. +func parseAIXRmsockPid(output string) int32 { + matches := rmsockPidRe.FindStringSubmatch(output) + if len(matches) > 1 { + if pid, err := strconv.ParseInt(matches[1], 10, 32); err == nil { + return int32(pid) + } + } + return 0 +} + +// resolveAIXSockToPid uses rmsock to get the PID holding a socket, returns 0 if unable to resolve. +func resolveAIXSockToPid(ctx context.Context, sockAddr, protocol string) int32 { + if protocol != "tcp" && protocol != "udp" { + return 0 + } + + var cbType string + if protocol == "tcp" { + cbType = "tcpcb" + } else { + cbType = "inpcb" + } + + output, err := invoke.CommandWithContext(ctx, "rmsock", sockAddr, cbType) + // rmsock exits with status 1 even on successful resolution, + // so we parse the output regardless of error status. + + outputStr := string(output) + pid := parseAIXRmsockPid(outputStr) + + if pid == 0 && err != nil { + // "Wait for exiting processes" is a transient cleanup situation - skip silently + if strings.Contains(outputStr, "Wait for exiting processes") { + return 0 + } + } + + return pid } diff --git a/net/net_aix_test.go b/net/net_aix_test.go new file mode 100644 index 0000000000..c594fcc04d --- /dev/null +++ b/net/net_aix_test.go @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: BSD-3-Clause +//go:build aix + +package net + +import ( + "context" + "os" + "strings" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Sample netstat -Aan output captured from AIX 7.3 for unit testing. +const testNetstatAanInet = `Active Internet connections (including servers) +PCB/ADDR Proto Recv-Q Send-Q Local Address Foreign Address (state) +f1000f00055bcbc0 tcp 0 0 *.* *.* CLOSED +f1000f00055963c0 tcp4 0 0 *.* *.* CLOSED +f1000f000017e3c0 tcp6 0 0 *.22 *.* LISTEN +f1000f0000287bc0 tcp4 0 0 *.22 *.* LISTEN +f1000f000017f3c0 tcp4 0 0 *.25 *.* LISTEN +f1000f00002d8bc0 tcp4 0 0 192.168.242.122.22 24.236.207.124.33236 ESTABLISHED +f1000f00055eabc0 tcp6 0 0 ::1.6010 *.* LISTEN +f1000f000561c3c0 tcp4 0 0 192.168.242.122.34898 34.120.255.184.443 ESTABLISHED +f1000f0000140600 udp 0 0 *.111 *.* +f1000f000017be00 udp 0 0 *.161 *.* +f1000f0005582e00 udp 0 0 *.514 *.* +` + +const testNetstatAanUnix = `Active UNIX domain sockets +SADR/PCB Type Recv-Q Send-Q Inode Conn Refs Nextref Addr +f1000f0000144808 dgram 0 0 0 f1000f00055a1b00 0 0 +f1000f0000145580 +f1000f000010a408 dgram 0 0 f1000b02a0354c20 0 0 0 /dev/SRC +f1000f000557b280 +f1000f0000169808 stream 0 0 f1000b02a03a9420 0 0 0 /var/ct/IW/soc/mc/RMIBM.DRM.0 +f1000f0000177780 +f1000f000015c808 stream 0 0 0 f1000f0000177580 0 0 +f1000f0000177880 +` + +const testNetstatAanFull = testNetstatAanInet + "\n" + testNetstatAanUnix + +func TestParseNetstatAanInet(t *testing.T) { + entries, err := parseNetstatAan(testNetstatAanInet, "inet") + require.NoError(t, err) + + // Should skip *.* local addresses (2 CLOSED entries) and include the rest + // Expected: *.22 tcp6, *.22 tcp4, *.25 tcp4, ESTABLISHED ssh, ::1.6010, ESTABLISHED https, + // *.111 udp, *.161 udp, *.514 udp = 9 entries + assert.Len(t, entries, 9) + + // Verify the ESTABLISHED TCP connection + var sshConn *netstatAanEntry + for i := range entries { + if entries[i].conn.Status == "ESTABLISHED" && entries[i].conn.Laddr.Port == 22 { + sshConn = &entries[i] + break + } + } + require.NotNil(t, sshConn, "should find the SSH ESTABLISHED connection") + assert.Equal(t, "f1000f00002d8bc0", sshConn.sockAddr) + assert.Equal(t, "tcp4", sshConn.proto) + assert.Equal(t, uint32(syscall.AF_INET), sshConn.conn.Family) + assert.Equal(t, uint32(syscall.SOCK_STREAM), sshConn.conn.Type) + assert.Equal(t, "192.168.242.122", sshConn.conn.Laddr.IP) + assert.Equal(t, uint32(22), sshConn.conn.Laddr.Port) + assert.Equal(t, "24.236.207.124", sshConn.conn.Raddr.IP) + assert.Equal(t, uint32(33236), sshConn.conn.Raddr.Port) + + // Verify a LISTEN entry + var listenConn *netstatAanEntry + for i := range entries { + if entries[i].conn.Status == "LISTEN" && entries[i].conn.Laddr.Port == 25 { + listenConn = &entries[i] + break + } + } + require.NotNil(t, listenConn, "should find the SMTP LISTEN connection") + assert.Equal(t, "0.0.0.0", listenConn.conn.Laddr.IP) + assert.Equal(t, uint32(25), listenConn.conn.Laddr.Port) + + // Verify IPv6 LISTEN entry + var ipv6Conn *netstatAanEntry + for i := range entries { + if entries[i].proto == "tcp6" && entries[i].conn.Laddr.Port == 22 { + ipv6Conn = &entries[i] + break + } + } + require.NotNil(t, ipv6Conn, "should find the IPv6 SSH LISTEN connection") + assert.Equal(t, uint32(syscall.AF_INET6), ipv6Conn.conn.Family) + assert.Equal(t, "::", ipv6Conn.conn.Laddr.IP) + + // Verify UDP entry (no state field) + var udpConn *netstatAanEntry + for i := range entries { + if entries[i].conn.Laddr.Port == 161 { + udpConn = &entries[i] + break + } + } + require.NotNil(t, udpConn, "should find the SNMP UDP connection") + assert.Equal(t, uint32(syscall.SOCK_DGRAM), udpConn.conn.Type) + assert.Empty(t, udpConn.conn.Status) +} + +func TestParseNetstatAanTCPFilter(t *testing.T) { + entries, err := parseNetstatAan(testNetstatAanInet, "tcp") + require.NoError(t, err) + + for _, entry := range entries { + assert.True(t, strings.HasPrefix(entry.proto, "tcp"), + "tcp filter should only return TCP entries, got proto=%s", entry.proto) + } +} + +func TestParseNetstatAanUDPFilter(t *testing.T) { + entries, err := parseNetstatAan(testNetstatAanInet, "udp") + require.NoError(t, err) + + assert.Len(t, entries, 3) + for _, entry := range entries { + assert.True(t, strings.HasPrefix(entry.proto, "udp"), + "udp filter should only return UDP entries, got proto=%s", entry.proto) + } +} + +func TestParseNetstatAanUnix(t *testing.T) { + entries, err := parseNetstatAan(testNetstatAanUnix, "unix") + require.NoError(t, err) + + // 4 unix socket entries (each spans 2 lines; second line is skipped) + assert.Len(t, entries, 4) + + // Verify dgram entry with address + var srcConn *netstatAanEntry + for i := range entries { + if entries[i].conn.Laddr.IP == "/dev/SRC" { + srcConn = &entries[i] + break + } + } + require.NotNil(t, srcConn, "should find the /dev/SRC unix socket") + assert.Equal(t, uint32(syscall.AF_UNIX), srcConn.conn.Family) + assert.Equal(t, uint32(syscall.SOCK_DGRAM), srcConn.conn.Type) + + // Verify stream entry + var streamConn *netstatAanEntry + for i := range entries { + if entries[i].conn.Type == uint32(syscall.SOCK_STREAM) { + streamConn = &entries[i] + break + } + } + require.NotNil(t, streamConn, "should find a stream unix socket") + assert.Equal(t, uint32(syscall.AF_UNIX), streamConn.conn.Family) +} + +func TestParseNetstatAanAll(t *testing.T) { + entries, err := parseNetstatAan(testNetstatAanFull, "all") + require.NoError(t, err) + + // Should include both inet and unix entries + var hasInet, hasUnix bool + for _, entry := range entries { + if entry.proto == "unix" { + hasUnix = true + } else { + hasInet = true + } + } + assert.True(t, hasInet, "should have inet entries") + assert.True(t, hasUnix, "should have unix entries") +} + +func TestParseNetstatAanUnixFilterExcludesInet(t *testing.T) { + entries, err := parseNetstatAan(testNetstatAanFull, "unix") + require.NoError(t, err) + + for _, entry := range entries { + assert.Equal(t, "unix", entry.proto, + "unix filter should exclude inet entries") + } +} + +func TestParseAIXRmsockPid(t *testing.T) { + tests := []struct { + name string + output string + want int32 + }{ + { + // AIX has a known typo: double 'c' in "process" + name: "AIX typo double c", + output: "The socket 0xf1000f00002d8808 is being held by proc" + "cess 21496092 (sshd).", + want: 21496092, + }, + { + // Handle correct spelling too in case a future AIX version fixes it + name: "correct spelling process", + output: "The socket 0xf1000f00002d8808 is being held by process 14287304 (sshd).", + want: 14287304, + }, + { + name: "wait for exiting processes", + output: "Wait for exiting processes to be cleaned up before removing the socket", + want: 0, + }, + { + name: "not a socket", + output: "It is not a socket", + want: 0, + }, + { + name: "kernel address error", + output: "rmsock : Unable to read kernel address f1000f0000140600, errno = 22", + want: 0, + }, + { + name: "empty output", + output: "", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseAIXRmsockPid(tt.output) + assert.Equal(t, tt.want, got) + }) + } +} + +// Integration tests below require running on AIX with real netstat/rmsock. + +func TestConnectionsPidWithContext(t *testing.T) { + ctx := context.Background() + pid := int32(os.Getpid()) + + conns, err := ConnectionsPidWithContext(ctx, "inet", pid) + if err != nil { + t.Logf("ConnectionsPidWithContext error: %v", err) + return + } + if conns != nil { + assert.IsType(t, []ConnectionStat{}, conns) + for _, conn := range conns { + assert.NotEmpty(t, conn.Family) + assert.NotEmpty(t, conn.Type) + } + } +} + +func TestConnectionsPidWithContextAll(t *testing.T) { + ctx := context.Background() + pid := int32(os.Getpid()) + + conns, err := ConnectionsPidWithContext(ctx, "all", pid) + if err != nil { + t.Logf("ConnectionsPidWithContext error: %v", err) + return + } + if conns != nil { + assert.IsType(t, []ConnectionStat{}, conns) + } +} + +func TestConnectionsPidWithContextUDP(t *testing.T) { + ctx := context.Background() + pid := int32(os.Getpid()) + + conns, err := ConnectionsPidWithContext(ctx, "udp", pid) + if err != nil { + t.Logf("ConnectionsPidWithContext error: %v", err) + return + } + if conns != nil { + assert.IsType(t, []ConnectionStat{}, conns) + } +} + +func TestConnectionsWithContext(t *testing.T) { + ctx := context.Background() + + conns, err := ConnectionsWithContext(ctx, "inet") + require.NoError(t, err) + assert.NotNil(t, conns) + assert.IsType(t, []ConnectionStat{}, conns) + assert.NotEmpty(t, conns) + + for _, conn := range conns { + assert.NotEmpty(t, conn.Family) + assert.NotEmpty(t, conn.Type) + } +} + +// Abbreviated netstat -s fixture from AIX 7.3 for unit testing. +const testNetstatS = `icmp: + 3 calls to icmp_error + 0 errors not generated because old message was icmp + Output histogram: + destination unreachable: 3 + 12 messages with bad code fields + 0 messages < minimum length + 0 bad checksums + 0 messages with bad length + Input histogram: + destination unreachable: 24 + time exceeded: 34 + 0 message responses generated +tcp: + 8864927 packets sent + 4194757 data packets (3255534866 bytes) + 26849 data packets (23271573 bytes) retransmitted + 3174391 ack-only packets (1213424 delayed) + 13907119 packets received + 4921506 acks (for 3255334387 bytes) + 1443623 duplicate acks + 2177 connection requests + 465222 connection accepts + 467064 connections established (including accepts) + 467502 connections closed (including 1913 drops) +udp: + 1886908 datagrams received + 0 incomplete headers + 0 bad checksums + 222742 dropped due to no socket + 1481568 delivered + 2224137 datagrams output +ip: + 16177190 total packets received + 0 bad header checksums + 15571192 packets for this host + 58 packets for unknown/unsupported protocol + 0 packets forwarded + 11912013 packets sent from this host +` + +func TestParseNetstatS(t *testing.T) { + results, err := parseNetstatS(testNetstatS, nil) + require.NoError(t, err) + + // Should find all 4 protocol sections + protos := make(map[string]map[string]int64) + for _, r := range results { + protos[r.Protocol] = r.Stats + } + assert.Contains(t, protos, "tcp") + assert.Contains(t, protos, "udp") + assert.Contains(t, protos, "ip") + assert.Contains(t, protos, "icmp") + + // Verify specific TCP values + assert.Equal(t, int64(8864927), protos["tcp"]["packetsSent"]) + assert.Equal(t, int64(13907119), protos["tcp"]["packetsReceived"]) + assert.Equal(t, int64(2177), protos["tcp"]["connectionRequests"]) + assert.Equal(t, int64(465222), protos["tcp"]["connectionAccepts"]) + + // Verify sub-items (double-tab) are NOT included + _, hasDataPackets := protos["tcp"]["dataPackets"] + assert.False(t, hasDataPackets, "sub-items should be skipped") + _, hasAckOnlyPackets := protos["tcp"]["ackOnlyPackets"] + assert.False(t, hasAckOnlyPackets, "sub-items should be skipped") + + // Verify UDP values + assert.Equal(t, int64(1886908), protos["udp"]["datagramsReceived"]) + assert.Equal(t, int64(2224137), protos["udp"]["datagramsOutput"]) + + // Verify IP values + assert.Equal(t, int64(16177190), protos["ip"]["totalPacketsReceived"]) + assert.Equal(t, int64(11912013), protos["ip"]["packetsSentFromThisHost"]) + + // Verify ICMP values + assert.Equal(t, int64(3), protos["icmp"]["callsToIcmpError"]) + assert.Equal(t, int64(12), protos["icmp"]["messagesWithBadCodeFields"]) +} + +func TestParseNetstatSFiltered(t *testing.T) { + results, err := parseNetstatS(testNetstatS, []string{"tcp", "udp"}) + require.NoError(t, err) + + protos := make(map[string]bool) + for _, r := range results { + protos[r.Protocol] = true + } + assert.True(t, protos["tcp"]) + assert.True(t, protos["udp"]) + assert.False(t, protos["ip"]) + assert.False(t, protos["icmp"]) +} + +func TestParseNetstatSEmpty(t *testing.T) { + results, err := parseNetstatS("", nil) + require.NoError(t, err) + assert.Empty(t, results) +} + +func TestDescToCamelCase(t *testing.T) { + tests := []struct { + desc string + want string + }{ + {"packets sent", "packetsSent"}, + {"total packets received", "totalPacketsReceived"}, + {"data packets (3187866062 bytes)", "dataPackets"}, + {"ack-only packets (1213424 delayed)", "ackOnlyPackets"}, + {"calls to icmp_error", "callsToIcmpError"}, + {"connections established (including accepts)", "connectionsEstablished"}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + assert.Equal(t, tt.want, descToCamelCase(tt.desc)) + }) + } +} + +func TestProtoCountersWithContext(t *testing.T) { + ctx := context.Background() + results, err := ProtoCountersWithContext(ctx, nil) + require.NoError(t, err) + assert.NotEmpty(t, results) + + for _, r := range results { + assert.NotEmpty(t, r.Protocol) + assert.NotEmpty(t, r.Stats) + } +}