💥 feat(agent)!: 添加主机监控代理首版
* 实现周期采集、快照缓存、本地 HTTP API 和主动上报 * 补齐 Linux 和 Windows 主机、网络、资源、进程和 TCP 采集 * 添加服务生命周期管理、配置加载、构建脚本和测试覆盖 BREAKING CHANGE: network.ips 拆分为 network.ipv4s 和 network.ipv6s。
This commit is contained in:
583
internal/collector/collector_linux.go
Normal file
583
internal/collector/collector_linux.go
Normal file
@@ -0,0 +1,583 @@
|
||||
//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
|
||||
}
|
||||
Reference in New Issue
Block a user