* 实现周期采集、快照缓存、本地 HTTP API 和主动上报 * 补齐 Linux 和 Windows 主机、网络、资源、进程和 TCP 采集 * 添加服务生命周期管理、配置加载、构建脚本和测试覆盖 BREAKING CHANGE: network.ips 拆分为 network.ipv4s 和 network.ipv6s。
584 lines
16 KiB
Go
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
|
|
}
|