//go:build linux package collector import ( "bufio" "context" "encoding/hex" "errors" "fmt" "io/fs" "net" "os" "os/user" "path/filepath" "runtime" "sort" "strconv" "strings" "syscall" "time" "mfd-monitor-agent/internal/status" ) var socketInodesFunc = socketInodes var readTCPListenersFunc = readTCPListeners // collectSystem fills Linux host identity, OS, architecture, and uptime fields. func collectSystem(_ context.Context, hostID string, out *status.HostStatus) error { hostname, err := os.Hostname() if err != nil { return err } out.Host.HostID = hostID out.Host.Hostname = hostname out.Host.Platform = runtime.GOOS out.Host.PlatformVersion = readOSRelease() out.Host.Arch = runtime.GOARCH out.Host.UptimeSeconds = readUptime() if out.Host.HostID == "" { out.Host.HostID = hostname } return nil } // collectNetwork fills Linux network interface, address, and MAC data. func collectNetwork(_ context.Context, out *status.HostStatus) error { ifaces, err := net.Interfaces() if err != nil { return err } ipSet := map[string]struct{}{} macSet := map[string]struct{}{} for _, iface := range ifaces { addrs, _ := iface.Addrs() item := status.NetworkInterface{ Name: iface.Name, MAC: iface.HardwareAddr.String(), MTU: iface.MTU, Flags: strings.Fields(iface.Flags.String()), } for _, addr := range addrs { value := addr.String() item.Addresses = append(item.Addresses, value) ipSet[value] = struct{}{} } if item.MAC != "" { macSet[item.MAC] = struct{}{} } out.Network.Interfaces = append(out.Network.Interfaces, item) } out.Network.IPv4s, out.Network.IPv6s = networkAddressSets(ipSet) out.Network.MACs = sortedKeys(macSet) return nil } // collectResource fills CPU, memory, and disk resource data from Linux proc files. func collectResource(ctx context.Context, out *status.HostStatus) error { out.CPU.LogicalCores = runtime.NumCPU() out.CPU.PhysicalCores, out.CPU.ModelName = readCPUInfo() out.CPU.LoadAvg = readLoadAvg() out.CPU.UsagePercent = readCPUUsagePercent(ctx) out.Memory = readMemory() disks, err := readDisks() if err != nil { return err } out.Disks = disks return nil } // collectProcesses reads /proc process directories and degrades to partial when some entries fail. func collectProcesses(_ context.Context, out *status.HostStatus) error { entries, err := os.ReadDir("/proc") if err != nil { out.Processes.Status = "error" return err } var errs []error for _, entry := range entries { if !entry.IsDir() { continue } pid, err := strconv.Atoi(entry.Name()) if err != nil { continue } process, err := readProcess(pid) if err != nil { errs = append(errs, err) continue } out.Processes.Items = append(out.Processes.Items, process) } sort.Slice(out.Processes.Items, func(i, j int) bool { return out.Processes.Items[i].PID < out.Processes.Items[j].PID }) if len(errs) > 0 { out.Processes.Status = "partial" return fmt.Errorf("%d process entries could not be read", len(errs)) } out.Processes.Status = "ok" return nil } // collectSockets reads Linux TCP listener tables and maps socket inodes to owning processes. func collectSockets(_ context.Context, out *status.HostStatus) error { inodes, inodeErr := socketInodesFunc() var listeners []status.TCPListenerInfo var errs []error if inodeErr != nil { errs = append(errs, inodeErr) } for _, file := range []struct { path string family string }{ {path: "/proc/net/tcp", family: "ipv4"}, {path: "/proc/net/tcp6", family: "ipv6"}, } { items, err := readTCPListenersFunc(file.path, file.family, inodes) if err != nil { errs = append(errs, err) continue } listeners = append(listeners, items...) } sort.Slice(listeners, func(i, j int) bool { if listeners[i].IP == listeners[j].IP { return listeners[i].Port < listeners[j].Port } return listeners[i].IP < listeners[j].IP }) out.TCPListeners.Items = listeners if len(errs) > 0 { out.TCPListeners.Status = "partial" return errors.Join(errs...) } out.TCPListeners.Status = "ok" return nil } // readOSRelease returns the Linux PRETTY_NAME value when /etc/os-release is readable. func readOSRelease() string { file, err := os.Open("/etc/os-release") if err != nil { return "" } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "PRETTY_NAME=") { return strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), `"`) } } return "" } // readUptime returns host uptime in seconds from /proc/uptime. func readUptime() uint64 { data, err := os.ReadFile("/proc/uptime") if err != nil { return 0 } fields := strings.Fields(string(data)) if len(fields) == 0 { return 0 } value, _ := strconv.ParseFloat(fields[0], 64) return uint64(value) } // readCPUInfo returns physical CPU package count and the first model name from /proc/cpuinfo. func readCPUInfo() (int, string) { file, err := os.Open("/proc/cpuinfo") if err != nil { return 0, "" } defer file.Close() physical := map[string]struct{}{} model := "" scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() parts := strings.SplitN(line, ":", 2) if len(parts) != 2 { continue } key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) switch key { case "physical id": physical[value] = struct{}{} case "model name": if model == "" { model = value } } } return len(physical), model } // readLoadAvg returns the 1, 5, and 15 minute Linux load averages when available. func readLoadAvg() []float64 { data, err := os.ReadFile("/proc/loadavg") if err != nil { return nil } fields := strings.Fields(string(data)) var values []float64 for i := 0; i < 3 && i < len(fields); i++ { value, err := strconv.ParseFloat(fields[i], 64) if err == nil { values = append(values, value) } } return values } // readMemory returns Linux memory counters in bytes from /proc/meminfo. func readMemory() status.MemoryInfo { values := map[string]uint64{} file, err := os.Open("/proc/meminfo") if err != nil { return status.MemoryInfo{} } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 2 { continue } value, _ := strconv.ParseUint(fields[1], 10, 64) // /proc/meminfo reports memory in KiB; the API exposes bytes and // includes memory.unit so clients can render the values explicitly. values[strings.TrimSuffix(fields[0], ":")] = value * 1024 } total := values["MemTotal"] free := values["MemFree"] available := values["MemAvailable"] used := total - available percent := 0.0 if total > 0 { percent = float64(used) / float64(total) * 100 } return status.MemoryInfo{Unit: "byte", Total: total, Used: used, Free: free, Available: available, UsedPercent: percent} } // readDisks returns physical and network mounted filesystem usage from /proc/mounts and statfs. func readDisks() ([]status.DiskInfo, error) { file, err := os.Open("/proc/mounts") if err != nil { return nil, err } defer file.Close() var disks []status.DiskInfo scanner := bufio.NewScanner(file) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 3 { continue } var stat syscall.Statfs_t if err := syscall.Statfs(fields[1], &stat); err != nil { continue } total := stat.Blocks * uint64(stat.Bsize) if !shouldIncludeDiskMount(fields[0], fields[2], total) { continue } free := stat.Bavail * uint64(stat.Bsize) used := total - free percent := 0.0 if total > 0 { percent = float64(used) / float64(total) * 100 } disks = append(disks, status.DiskInfo{ Device: fields[0], MountPoint: fields[1], FSType: fields[2], Unit: "byte", Total: total, Used: used, Free: free, UsedPercent: percent, }) } return disks, scanner.Err() } // shouldIncludeDiskMount reports whether a mount represents a user-relevant disk. func shouldIncludeDiskMount(device string, fsType string, total uint64) bool { if total == 0 { return false } networkFSTypes := map[string]bool{ "nfs": true, "nfs4": true, "cifs": true, "smb3": true, "sshfs": true, } if networkFSTypes[fsType] { return true } return strings.HasPrefix(device, "/dev/") } // readProcess returns one Linux process entry from /proc/. func readProcess(pid int) (status.ProcessInfo, error) { base := filepath.Join("/proc", strconv.Itoa(pid)) statusValues, err := readProcessStatus(filepath.Join(base, "status")) if err != nil { return status.ProcessInfo{}, err } ppid, _ := strconv.Atoi(statusValues["PPid"]) rss := parseKB(statusValues["VmRSS"]) vms := parseKB(statusValues["VmSize"]) username := "" if uid := firstField(statusValues["Uid"]); uid != "" { if u, err := user.LookupId(uid); err == nil { username = u.Username } } cmdline := "" if data, err := os.ReadFile(filepath.Join(base, "cmdline")); err == nil { cmdline = strings.Trim(strings.ReplaceAll(string(data), "\x00", " "), " ") } exe, _ := os.Readlink(filepath.Join(base, "exe")) statValues, _ := readProcessStat(filepath.Join(base, "stat")) return status.ProcessInfo{ PID: pid, PPID: ppid, Name: statusValues["Name"], Exe: exe, Cmdline: cmdline, Username: username, Status: statusValues["State"], StartTime: statValues.StartTime, MemoryRSS: rss, MemoryVMS: vms, }, nil } // processStat contains process counters parsed from /proc//stat. type processStat struct { StartTime int64 } // readProcessStat parses Linux process stat counters from path. func readProcessStat(path string) (processStat, error) { data, err := os.ReadFile(path) if err != nil { return processStat{}, err } startTime, err := parseProcessStatStartTime(string(data)) if err != nil { return processStat{}, err } return processStat{StartTime: startTime}, nil } // readProcessStatus parses key-value fields from /proc//status. func readProcessStatus(path string) (map[string]string, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() values := map[string]string{} scanner := bufio.NewScanner(file) for scanner.Scan() { parts := strings.SplitN(scanner.Text(), ":", 2) if len(parts) == 2 { values[parts[0]] = strings.TrimSpace(parts[1]) } } return values, scanner.Err() } // socketInodes maps Linux socket inode numbers to best-effort owning process data. func socketInodes() (map[string]status.ProcessInfo, error) { out := map[string]status.ProcessInfo{} var readErrors int _ = filepath.WalkDir("/proc", func(path string, d fs.DirEntry, err error) error { if err != nil || !d.IsDir() || filepath.Dir(path) != "/proc" { return nil } pid, err := strconv.Atoi(filepath.Base(path)) if err != nil { return nil } process, _ := readProcess(pid) fdDir := filepath.Join(path, "fd") entries, err := os.ReadDir(fdDir) if err != nil { readErrors++ return nil } for _, entry := range entries { target, err := os.Readlink(filepath.Join(fdDir, entry.Name())) if err != nil || !strings.HasPrefix(target, "socket:[") { continue } inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]") out[inode] = process } return nil }) if readErrors > 0 { return out, fmt.Errorf("%d process fd directories could not be read", readErrors) } return out, nil } // readTCPListeners parses a Linux TCP table and returns sockets in LISTEN state. func readTCPListeners(path string, family string, inodes map[string]status.ProcessInfo) ([]status.TCPListenerInfo, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() var items []status.TCPListenerInfo scanner := bufio.NewScanner(file) if scanner.Scan() { // Skip header. } for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 10 || fields[3] != "0A" { continue } ip, port, err := parseAddress(fields[1], family) if err != nil { continue } process := inodes[fields[9]] items = append(items, status.TCPListenerInfo{ IP: ip, Port: port, PID: process.PID, ProcessName: process.Name, ProcessExe: process.Exe, Family: family, }) } return items, scanner.Err() } // readCPUUsagePercent samples /proc/stat over a short interval and returns best-effort usage. func readCPUUsagePercent(ctx context.Context) float64 { prevData, err := os.ReadFile("/proc/stat") if err != nil { return 0 } prevIdle, prevTotal, err := parseCPUTimes(string(prevData)) if err != nil { return 0 } select { case <-ctx.Done(): return 0 case <-time.After(100 * time.Millisecond): } nextData, err := os.ReadFile("/proc/stat") if err != nil { return 0 } nextIdle, nextTotal, err := parseCPUTimes(string(nextData)) if err != nil { return 0 } return cpuUsagePercent(prevIdle, prevTotal, nextIdle, nextTotal) } // parseAddress decodes a Linux /proc/net/tcp address into IP and port values. func parseAddress(raw string, family string) (string, int, error) { parts := strings.Split(raw, ":") if len(parts) != 2 { return "", 0, fmt.Errorf("invalid address %q", raw) } port64, err := strconv.ParseInt(parts[1], 16, 32) if err != nil { return "", 0, err } bytes, err := hex.DecodeString(parts[0]) if err != nil { return "", 0, err } if family == "ipv4" && len(bytes) == 4 { return net.IPv4(bytes[3], bytes[2], bytes[1], bytes[0]).String(), int(port64), nil } if family == "ipv6" && len(bytes) == 16 { reordered := make([]byte, 16) for i := 0; i < 16; i += 4 { reordered[i], reordered[i+1], reordered[i+2], reordered[i+3] = bytes[i+3], bytes[i+2], bytes[i+1], bytes[i] } return net.IP(reordered).String(), int(port64), nil } return "", 0, fmt.Errorf("invalid %s address", family) } // parseProcessStatStartTime extracts field 22 from Linux /proc//stat content. func parseProcessStatStartTime(raw string) (int64, error) { end := strings.LastIndex(raw, ")") if end < 0 || end+2 >= len(raw) { return 0, fmt.Errorf("invalid process stat") } fields := strings.Fields(raw[end+2:]) if len(fields) < 20 { return 0, fmt.Errorf("process stat has %d fields after comm", len(fields)) } return strconv.ParseInt(fields[19], 10, 64) } // parseCPUTimes extracts idle and total jiffy counters from the first cpu line in /proc/stat. func parseCPUTimes(raw string) (idle uint64, total uint64, err error) { scanner := bufio.NewScanner(strings.NewReader(raw)) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) == 0 || fields[0] != "cpu" { continue } if len(fields) < 5 { return 0, 0, fmt.Errorf("cpu line has %d fields", len(fields)) } for i, field := range fields[1:] { value, err := strconv.ParseUint(field, 10, 64) if err != nil { return 0, 0, err } total += value if i == 3 || i == 4 { idle += value } } return idle, total, nil } if err := scanner.Err(); err != nil { return 0, 0, err } return 0, 0, fmt.Errorf("cpu line not found") } // cpuUsagePercent calculates non-idle CPU percentage between two /proc/stat samples. func cpuUsagePercent(prevIdle, prevTotal, nextIdle, nextTotal uint64) float64 { totalDelta := nextTotal - prevTotal if totalDelta == 0 { return 0 } idleDelta := nextIdle - prevIdle if idleDelta >= totalDelta { return 0 } return float64(totalDelta-idleDelta) / float64(totalDelta) * 100 } // parseKB converts the first numeric kilobyte value in raw to bytes. func parseKB(raw string) uint64 { value, _ := strconv.ParseUint(firstField(raw), 10, 64) return value * 1024 } // firstField returns the first whitespace-separated token in raw. func firstField(raw string) string { fields := strings.Fields(raw) if len(fields) == 0 { return "" } return fields[0] } // sortedKeys returns deterministic sorted keys from a string set. func sortedKeys(values map[string]struct{}) []string { keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } sort.Strings(keys) return keys }