Files
mfd-monitor-agent/internal/collector/collector_linux.go
codex de3fd88948 ✨ feat(agent): 添加 token 鉴权和流程文档
* 默认监听调整为 0.0.0.0:8080,并要求查询参数 token
* 支持首次 run 自动生成含随机 token 的配置文件
* 补充 HTTP 鉴权、配置校验和相关测试
* 完善代码注释,覆盖缓存、上报、服务和平台采集路径
* 新增架构与开发流程文档,补充一键构建脚本
2026-05-22 06:46:21 +00:00

594 lines
16 KiB
Go
Raw Permalink 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"
)
// socketInodesFunc 是测试可替换的 Linux socket inode 到进程映射函数。
var socketInodesFunc = socketInodes
// readTCPListenersFunc 是测试可替换的 Linux TCP 表解析函数。
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 {
// 进程可能在遍历期间退出,或因权限限制无法读取;保留其他进程并降级为 partial。
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 {
// inode 映射失败通常意味着端口仍可见但进程归属不完整,后续继续解析 TCP 表。
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 和 stat 都可能因权限或进程退出失败;这些字段按空值/零值降级。
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 {
// 无法读取 fd 目录会导致部分端口缺少 PID 映射,因此计数并向上报告 partial。
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 {
// /proc/net/tcp6 以 32 位小端字组保存 IPv6 地址,需逐字反转为网络序。
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
}