Files
mfd-monitor-agent/internal/collector/collector_linux.go
codex 877e4cf41d docs(agent): 统一中文注释并补充部署文档
将代码与测试注释改为中文以符合仓库规范;新增中文部署指南、
示例配置,并完善构建脚本对 VERSION 注入的说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 02:30:49 +00:00

584 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//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 从 Linux 填充主机标识、操作系统、架构与运行时长字段。
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 填充 Linux 网卡、地址与 MAC 数据。
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 从 Linux /proc 文件填充 CPU、内存与磁盘资源数据。
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 读取 /proc 进程目录;部分条目失败时降级为 partial。
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 读取 Linux TCP 监听表,并将 socket inode 映射到所属进程。
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 在可读时从 /etc/os-release 返回 PRETTY_NAME。
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 从 /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 从 /proc/cpuinfo 返回物理 CPU 封装数与首个型号名称。
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 在可用时返回 Linux 1/5/15 分钟负载均值。
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 从 /proc/meminfo 返回以字节为单位的 Linux 内存计数。
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 以 KiB 报告内存;API 对外暴露字节,
// 并通过 memory.unit 让客户端可明确展示单位。
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 从 /proc/mounts 与 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 判断挂载点是否代表用户关心的磁盘。
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 从 /proc/<pid> 返回一条 Linux 进程记录。
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 包含从 /proc/<pid>/stat 解析的进程计数。
type processStat struct {
StartTime int64
}
// readProcessStat 从路径解析 Linux 进程 stat 计数。
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 从 /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 将 Linux socket inode 映射到尽力获取的所属进程数据。
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 解析 Linux TCP 表并返回处于 LISTEN 状态的套接字。
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() {
// 跳过表头行。
}
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 在短间隔内采样 /proc/stat 并返回尽力估算的 CPU 使用率。
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 将 Linux /proc/net/tcp 地址解码为 IP 与端口。
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 从 Linux /proc/<pid>/stat 内容提取第 22 字段。
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 从 /proc/stat 首行 cpu 提取空闲与总 jiffies 计数。
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 根据两次 /proc/stat 采样计算非空闲 CPU 百分比。
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 将 raw 中首个以 KiB 表示的数值转换为字节。
func parseKB(raw string) uint64 {
value, _ := strconv.ParseUint(firstField(raw), 10, 64)
return value * 1024
}
// firstField 返回 raw 中首个以空白分隔的字段。
func firstField(raw string) string {
fields := strings.Fields(raw)
if len(fields) == 0 {
return ""
}
return fields[0]
}
// sortedKeys 从字符串集合返回确定顺序的排序键列表。
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
}