* 默认监听调整为 0.0.0.0:8080,并要求查询参数 token * 支持首次 run 自动生成含随机 token 的配置文件 * 补充 HTTP 鉴权、配置校验和相关测试 * 完善代码注释,覆盖缓存、上报、服务和平台采集路径 * 新增架构与开发流程文档,补充一键构建脚本
594 lines
16 KiB
Go
594 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"
|
||
)
|
||
|
||
// 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
|
||
}
|