Files
mfd-monitor-agent/internal/collector/collector_linux.go
codex 89d5e27874 💥 feat(agent)!: 添加主机监控代理首版
* 实现周期采集、快照缓存、本地 HTTP API 和主动上报

* 补齐 Linux 和 Windows 主机、网络、资源、进程和 TCP 采集

* 添加服务生命周期管理、配置加载、构建脚本和测试覆盖

BREAKING CHANGE: network.ips 拆分为 network.ipv4s 和 network.ipv6s。
2026-05-18 15:25:51 +00:00

584 lines
16 KiB
Go

//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/<pid>.
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/<pid>/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/<pid>/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/<pid>/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
}