💥 feat(agent)!: 添加主机监控代理首版

* 实现周期采集、快照缓存、本地 HTTP API 和主动上报

* 补齐 Linux 和 Windows 主机、网络、资源、进程和 TCP 采集

* 添加服务生命周期管理、配置加载、构建脚本和测试覆盖

BREAKING CHANGE: network.ips 拆分为 network.ipv4s 和 network.ipv6s。
This commit is contained in:
codex
2026-05-18 15:25:51 +00:00
commit 89d5e27874
33 changed files with 4205 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
package collector
import (
"context"
"mfd-monitor-agent/internal/snapshot"
"mfd-monitor-agent/internal/status"
)
// namedCollector binds a stable collector name to a collection function.
type namedCollector struct {
name string
fn func(context.Context, *status.HostStatus) error
}
// Name returns the collector name used in snapshot error records.
func (c namedCollector) Name() string {
return c.name
}
// Collect runs the wrapped collection function.
func (c namedCollector) Collect(ctx context.Context, out *status.HostStatus) error {
return c.fn(ctx, out)
}
// New returns the default collector set for the current platform.
func New(hostID string) []snapshot.Collector {
return []snapshot.Collector{
namedCollector{name: "system", fn: func(ctx context.Context, out *status.HostStatus) error {
return collectSystem(ctx, hostID, out)
}},
namedCollector{name: "network", fn: collectNetwork},
namedCollector{name: "resource", fn: collectResource},
namedCollector{name: "process", fn: collectProcesses},
namedCollector{name: "socket", fn: collectSockets},
}
}

View 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
}

View File

@@ -0,0 +1,169 @@
//go:build linux
package collector
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"mfd-monitor-agent/internal/status"
)
// TestParseAddressIPv4 verifies Linux IPv4 socket addresses are decoded.
func TestParseAddressIPv4(t *testing.T) {
ip, port, err := parseAddress("0100007F:1F90", "ipv4")
if err != nil {
t.Fatal(err)
}
if ip != "127.0.0.1" || port != 8080 {
t.Fatalf("address = %s:%d", ip, port)
}
}
// TestParseAddressIPv6 verifies Linux IPv6 socket addresses are decoded.
func TestParseAddressIPv6(t *testing.T) {
ip, port, err := parseAddress("00000000000000000000000001000000:1F90", "ipv6")
if err != nil {
t.Fatal(err)
}
if ip != "::1" || port != 8080 {
t.Fatalf("address = %s:%d", ip, port)
}
}
// TestParseProcessStatStartTime verifies field 22 parsing from /proc/<pid>/stat.
func TestParseProcessStatStartTime(t *testing.T) {
raw := "1234 (agent worker) S 1 2 3 4 5 0 0 0 0 0 11 22 0 0 20 0 1 0 987654 0 0"
startTime, err := parseProcessStatStartTime(raw)
if err != nil {
t.Fatal(err)
}
if startTime != 987654 {
t.Fatalf("start time = %d", startTime)
}
}
// TestCPUUsageFromProcStatSamples verifies CPU usage from two /proc/stat samples.
func TestCPUUsageFromProcStatSamples(t *testing.T) {
prevIdle, prevTotal, err := parseCPUTimes("cpu 100 0 100 800 0 0 0 0 0 0\n")
if err != nil {
t.Fatal(err)
}
nextIdle, nextTotal, err := parseCPUTimes("cpu 150 0 150 900 0 0 0 0 0 0\n")
if err != nil {
t.Fatal(err)
}
got := cpuUsagePercent(prevIdle, prevTotal, nextIdle, nextTotal)
if got != 50 {
t.Fatalf("usage percent = %f", got)
}
}
// TestReadProcessStatReturnsStartTime verifies process start time is exposed.
func TestReadProcessStatReturnsStartTime(t *testing.T) {
path := filepath.Join(t.TempDir(), "stat")
raw := "1234 (agent worker) S 1 2 3 4 5 0 0 0 0 0 11 22 0 0 20 0 1 0 987654 0 0"
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
got, err := readProcessStat(path)
if err != nil {
t.Fatal(err)
}
if got.StartTime != 987654 {
t.Fatalf("start time = %d", got.StartTime)
}
}
// TestReadMemoryReturnsBytesWithUnit verifies memory capacity fields are exposed in bytes.
func TestReadMemoryReturnsBytesWithUnit(t *testing.T) {
meminfo, err := os.ReadFile("/proc/meminfo")
if err != nil {
t.Fatal(err)
}
var totalKiB uint64
for _, line := range strings.Split(string(meminfo), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && fields[0] == "MemTotal:" {
totalKiB, err = strconv.ParseUint(fields[1], 10, 64)
if err != nil {
t.Fatal(err)
}
break
}
}
if totalKiB == 0 {
t.Fatal("MemTotal not found")
}
got := readMemory()
if got.Total != totalKiB*1024 {
t.Fatalf("total memory = %d bytes, want %d bytes", got.Total, totalKiB*1024)
}
if got.Unit != "byte" {
t.Fatalf("unit = %q, want byte", got.Unit)
}
}
// TestShouldIncludeDiskMount keeps physical and network disks while dropping pseudo mounts.
func TestShouldIncludeDiskMount(t *testing.T) {
tests := []struct {
name string
device string
fsType string
total uint64
want bool
}{
{name: "physical block device", device: "/dev/mapper/pve-vm--103--disk--1", fsType: "ext4", total: 1024, want: true},
{name: "nfs mount", device: "server:/export/data", fsType: "nfs4", total: 1024, want: true},
{name: "lxcfs pseudo mount", device: "lxcfs", fsType: "fuse.lxcfs", total: 0, want: false},
{name: "proc pseudo mount", device: "proc", fsType: "proc", total: 0, want: false},
{name: "tmpfs memory mount", device: "tmpfs", fsType: "tmpfs", total: 1024, want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := shouldIncludeDiskMount(test.device, test.fsType, test.total)
if got != test.want {
t.Fatalf("include = %t, want %t", got, test.want)
}
})
}
}
// TestCollectSocketsMarksPartialWhenProcessFDsUnreadable verifies socket mapping degradation.
func TestCollectSocketsMarksPartialWhenProcessFDsUnreadable(t *testing.T) {
oldSocketInodes := socketInodesFunc
oldReadTCPListeners := readTCPListenersFunc
t.Cleanup(func() {
socketInodesFunc = oldSocketInodes
readTCPListenersFunc = oldReadTCPListeners
})
socketInodesFunc = func() (map[string]status.ProcessInfo, error) {
return map[string]status.ProcessInfo{}, fmt.Errorf("1 process fd directory could not be read")
}
readTCPListenersFunc = func(path string, family string, inodes map[string]status.ProcessInfo) ([]status.TCPListenerInfo, error) {
return []status.TCPListenerInfo{{IP: "127.0.0.1", Port: 8080, Family: family}}, nil
}
var got status.HostStatus
err := collectSockets(context.Background(), &got)
if err == nil {
t.Fatal("expected partial mapping error")
}
if got.TCPListeners.Status != "partial" {
t.Fatalf("status = %q", got.TCPListeners.Status)
}
if len(got.TCPListeners.Items) != 2 {
t.Fatalf("listener count = %d", len(got.TCPListeners.Items))
}
}

View File

@@ -0,0 +1,75 @@
//go:build !linux && !windows
package collector
import (
"context"
"fmt"
"net"
"os"
"runtime"
"strings"
"mfd-monitor-agent/internal/status"
)
// collectSystem fills portable host identity fields for non-Linux platforms.
func collectSystem(_ context.Context, hostID string, out *status.HostStatus) error {
hostname, err := os.Hostname()
if err != nil {
return err
}
out.Host.HostID = hostID
if out.Host.HostID == "" {
out.Host.HostID = hostname
}
out.Host.Hostname = hostname
out.Host.Platform = runtime.GOOS
out.Host.Arch = runtime.GOARCH
return nil
}
// collectNetwork fills interface, address, and MAC data using the portable net package.
func collectNetwork(_ context.Context, out *status.HostStatus) error {
ifaces, err := net.Interfaces()
if err != nil {
return err
}
seenIPs := map[string]struct{}{}
seenMACs := map[string]struct{}{}
for _, iface := range ifaces {
item := status.NetworkInterface{Name: iface.Name, MAC: iface.HardwareAddr.String(), MTU: iface.MTU, Flags: strings.Fields(iface.Flags.String())}
addrs, _ := iface.Addrs()
for _, addr := range addrs {
item.Addresses = append(item.Addresses, addr.String())
seenIPs[addr.String()] = struct{}{}
}
if item.MAC != "" {
seenMACs[item.MAC] = struct{}{}
}
out.Network.Interfaces = append(out.Network.Interfaces, item)
}
out.Network.IPv4s, out.Network.IPv6s = networkAddressSets(seenIPs)
for mac := range seenMACs {
out.Network.MACs = append(out.Network.MACs, mac)
}
return nil
}
// collectResource fills portable resource fields available without platform-specific APIs.
func collectResource(_ context.Context, out *status.HostStatus) error {
out.CPU.LogicalCores = runtime.NumCPU()
return nil
}
// collectProcesses reports partial process collection until platform-specific support is added.
func collectProcesses(_ context.Context, out *status.HostStatus) error {
out.Processes.Status = "partial"
return fmt.Errorf("process collection is not implemented for %s without platform-specific support", runtime.GOOS)
}
// collectSockets reports partial TCP listener collection until platform-specific support is added.
func collectSockets(_ context.Context, out *status.HostStatus) error {
out.TCPListeners.Status = "partial"
return fmt.Errorf("tcp listener process mapping is not implemented for %s without platform-specific support", runtime.GOOS)
}

View File

@@ -0,0 +1,781 @@
//go:build windows
package collector
import (
"bytes"
"context"
"encoding/csv"
"errors"
"fmt"
"math/bits"
"net"
"os"
"os/exec"
"runtime"
"sort"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
"mfd-monitor-agent/internal/status"
)
const (
windowsAFInet = 2
windowsAFInet6 = 23
windowsNoError = 0
windowsErrorInsufficientBuffer = 122
windowsTCPTableOwnerPIDListen = 3
windowsTCPStateListen = 2
windowsDriveFixed = 3
windowsDriveRemote = 4
windowsProcessQueryInformation = 0x0400
windowsProcessQueryLimitedInfo = 0x1000
windowsProcessVMRead = 0x0010
windowsTokenQuery = 0x0008
windowsMaxPath = 260
windowsRegSZ = 1
windowsRelationProcessorCore = 0
)
var (
windowsKernel32 = syscall.NewLazyDLL("kernel32.dll")
windowsGlobalMemoryStatusExProc = windowsKernel32.NewProc("GlobalMemoryStatusEx")
windowsGetTickCount64Proc = windowsKernel32.NewProc("GetTickCount64")
windowsGetLogicalDrivesProc = windowsKernel32.NewProc("GetLogicalDrives")
windowsGetDriveTypeWProc = windowsKernel32.NewProc("GetDriveTypeW")
windowsGetDiskFreeSpaceExWProc = windowsKernel32.NewProc("GetDiskFreeSpaceExW")
windowsGetSystemTimesProc = windowsKernel32.NewProc("GetSystemTimes")
windowsGetNativeSystemInfoProc = windowsKernel32.NewProc("GetNativeSystemInfo")
windowsGetLogicalProcessorInfoProc = windowsKernel32.NewProc("GetLogicalProcessorInformation")
windowsQueryFullProcessImageNameWProc = windowsKernel32.NewProc("QueryFullProcessImageNameW")
windowsIphlpapi = syscall.NewLazyDLL("iphlpapi.dll")
windowsGetExtendedTCPTableProc = windowsIphlpapi.NewProc("GetExtendedTcpTable")
windowsPsapi = syscall.NewLazyDLL("psapi.dll")
windowsGetProcessMemoryInfoProc = windowsPsapi.NewProc("GetProcessMemoryInfo")
windowsAdvapi32 = syscall.NewLazyDLL("advapi32.dll")
windowsOpenProcessTokenProc = windowsAdvapi32.NewProc("OpenProcessToken")
windowsGetTokenInformationProc = windowsAdvapi32.NewProc("GetTokenInformation")
windowsLookupAccountSidWProc = windowsAdvapi32.NewProc("LookupAccountSidW")
)
// windowsMemoryStatus mirrors the Windows MEMORYSTATUSEX structure used by GlobalMemoryStatusEx.
type windowsMemoryStatus struct {
Length uint32
MemoryLoad uint32
TotalPhys uint64
AvailPhys uint64
TotalPageFile uint64
AvailPageFile uint64
TotalVirtual uint64
AvailVirtual uint64
AvailExtendedVirtual uint64
}
// windowsMIBTCPRowOwnerPID mirrors a MIB_TCPROW_OWNER_PID row returned by GetExtendedTcpTable.
type windowsMIBTCPRowOwnerPID struct {
State uint32
LocalAddr uint32
LocalPort uint32
RemoteAddr uint32
RemotePort uint32
OwningPID uint32
}
// windowsMIBTCP6RowOwnerPID mirrors a MIB_TCP6ROW_OWNER_PID row returned by GetExtendedTcpTable.
type windowsMIBTCP6RowOwnerPID struct {
State uint32
LocalAddr [16]byte
LocalScopeID uint32
LocalPort uint32
RemoteAddr [16]byte
RemoteScopeID uint32
RemotePort uint32
OwningPID uint32
}
// windowsSystemInfo mirrors the subset of SYSTEM_INFO needed for CPU package sizing.
type windowsSystemInfo struct {
ProcessorArchitecture uint16
Reserved uint16
PageSize uint32
MinimumApplicationAddress uintptr
MaximumApplicationAddress uintptr
ActiveProcessorMask uintptr
NumberOfProcessors uint32
ProcessorType uint32
AllocationGranularity uint32
ProcessorLevel uint16
ProcessorRevision uint16
}
// windowsSystemTimes stores system CPU time counters as 100ns units.
type windowsSystemTimes struct {
Idle uint64
Kernel uint64
User uint64
}
// windowsLogicalProcessorInformation mirrors SYSTEM_LOGICAL_PROCESSOR_INFORMATION for core counting.
type windowsLogicalProcessorInformation struct {
ProcessorMask uintptr
Relationship uint32
Data [16]byte
}
// windowsProcessMemoryCounters mirrors PROCESS_MEMORY_COUNTERS.
type windowsProcessMemoryCounters struct {
CB uint32
PageFaultCount uint32
PeakWorkingSetSize uintptr
WorkingSetSize uintptr
QuotaPeakPagedPoolUsage uintptr
QuotaPagedPoolUsage uintptr
QuotaPeakNonPagedPoolUsage uintptr
QuotaNonPagedPoolUsage uintptr
PagefileUsage uintptr
PeakPagefileUsage uintptr
}
// windowsProcessDetails contains best-effort per-process fields requiring extra handles or commands.
type windowsProcessDetails struct {
Exe string
Cmdline string
Username string
Status string
StartTime int64
CPUPercent float64
MemoryRSS uint64
MemoryVMS uint64
}
// collectSystem fills Windows host identity, OS, and architecture fields.
func collectSystem(_ context.Context, hostID string, out *status.HostStatus) error {
hostname, err := os.Hostname()
if err != nil {
return err
}
out.Host.HostID = hostID
if out.Host.HostID == "" {
out.Host.HostID = hostname
}
out.Host.Hostname = hostname
out.Host.Platform = runtime.GOOS
out.Host.PlatformVersion = windowsVersion()
out.Host.Arch = runtime.GOARCH
out.Host.UptimeSeconds = windowsUptimeSeconds()
return nil
}
// collectNetwork fills Windows interface, address, and MAC data using the portable net package.
func collectNetwork(_ context.Context, out *status.HostStatus) error {
ifaces, err := net.Interfaces()
if err != nil {
return err
}
seenIPs := map[string]struct{}{}
seenMACs := map[string]struct{}{}
for _, iface := range ifaces {
item := status.NetworkInterface{Name: iface.Name, MAC: iface.HardwareAddr.String(), MTU: iface.MTU, Flags: strings.Fields(iface.Flags.String())}
addrs, _ := iface.Addrs()
for _, addr := range addrs {
item.Addresses = append(item.Addresses, addr.String())
seenIPs[addr.String()] = struct{}{}
}
if item.MAC != "" {
seenMACs[item.MAC] = struct{}{}
}
out.Network.Interfaces = append(out.Network.Interfaces, item)
}
out.Network.IPv4s, out.Network.IPv6s = networkAddressSets(seenIPs)
out.Network.MACs = windowsSortedKeys(seenMACs)
return nil
}
// collectResource fills Windows CPU, memory, and disk resource data.
func collectResource(ctx context.Context, out *status.HostStatus) error {
var errs []error
out.CPU.LogicalCores = runtime.NumCPU()
out.CPU.PhysicalCores = windowsPhysicalCores()
out.CPU.ModelName = windowsCPUModelName()
out.CPU.UsagePercent = windowsReadCPUUsagePercent(ctx)
memory, err := windowsReadMemory()
if err != nil {
errs = append(errs, err)
} else {
out.Memory = memory
}
disks, err := windowsReadDisks()
if err != nil {
errs = append(errs, err)
} else {
out.Disks = disks
}
return errors.Join(errs...)
}
// collectProcesses enumerates Windows processes with Toolhelp and returns best-effort names.
func collectProcesses(_ context.Context, out *status.HostStatus) error {
processes, err := windowsProcessList()
out.Processes.Items = processes
if err != nil {
out.Processes.Status = "partial"
return err
}
out.Processes.Status = "ok"
return nil
}
// collectSockets reads Windows TCP listener tables and maps listener PIDs to process names.
func collectSockets(_ context.Context, out *status.HostStatus) error {
processes, processErr := windowsProcessMap()
var errs []error
if processErr != nil {
errs = append(errs, processErr)
}
listeners4, err := windowsTCPListeners(windowsAFInet, "ipv4", processes)
if err != nil {
errs = append(errs, err)
}
listeners6, err := windowsTCPListeners(windowsAFInet6, "ipv6", processes)
if err != nil {
errs = append(errs, err)
}
out.TCPListeners.Items = append(listeners4, listeners6...)
sort.Slice(out.TCPListeners.Items, func(i, j int) bool {
if out.TCPListeners.Items[i].IP == out.TCPListeners.Items[j].IP {
return out.TCPListeners.Items[i].Port < out.TCPListeners.Items[j].Port
}
return out.TCPListeners.Items[i].IP < out.TCPListeners.Items[j].IP
})
if len(errs) > 0 {
out.TCPListeners.Status = "partial"
return errors.Join(errs...)
}
out.TCPListeners.Status = "ok"
return nil
}
// windowsReadMemory calls GlobalMemoryStatusEx and converts physical memory counters to API fields.
func windowsReadMemory() (status.MemoryInfo, error) {
memory := windowsMemoryStatus{Length: uint32(unsafe.Sizeof(windowsMemoryStatus{}))}
ok, _, err := windowsGlobalMemoryStatusExProc.Call(uintptr(unsafe.Pointer(&memory)))
if ok == 0 {
return status.MemoryInfo{}, err
}
return windowsMemoryInfoFromStatus(memory), nil
}
// windowsVersion returns the legacy Windows major.minor.build tuple exposed by GetVersion.
func windowsVersion() string {
version, err := syscall.GetVersion()
if err != nil {
return ""
}
major := byte(version)
minor := uint8(version >> 8)
build := uint16(version >> 16)
return fmt.Sprintf("%d.%d.%d", major, minor, build)
}
// windowsUptimeSeconds returns host uptime in seconds from GetTickCount64.
func windowsUptimeSeconds() uint64 {
millis, _, _ := windowsGetTickCount64Proc.Call()
return uint64(millis) / 1000
}
// windowsPhysicalCores returns physical CPU core count from Windows processor topology.
func windowsPhysicalCores() int {
var size uint32
result, _, _ := windowsGetLogicalProcessorInfoProc.Call(0, uintptr(unsafe.Pointer(&size)))
if result == 0 && size > 0 {
buf := make([]byte, size)
result, _, _ = windowsGetLogicalProcessorInfoProc.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(&size)))
if result != 0 {
entrySize := int(unsafe.Sizeof(windowsLogicalProcessorInformation{}))
cores := 0
for offset := 0; offset+entrySize <= int(size); offset += entrySize {
entry := (*windowsLogicalProcessorInformation)(unsafe.Pointer(&buf[offset]))
if entry.Relationship == windowsRelationProcessorCore {
cores++
}
}
if cores > 0 {
return cores
}
}
}
var info windowsSystemInfo
windowsGetNativeSystemInfoProc.Call(uintptr(unsafe.Pointer(&info)))
if info.NumberOfProcessors > 0 {
return int(info.NumberOfProcessors)
}
if info.ActiveProcessorMask != 0 {
return bits.OnesCount64(uint64(info.ActiveProcessorMask))
}
return runtime.NumCPU()
}
// windowsCPUModelName reads the first processor name from the registry.
func windowsCPUModelName() string {
keyPath, _ := syscall.UTF16PtrFromString(`HARDWARE\DESCRIPTION\System\CentralProcessor\0`)
var key syscall.Handle
if err := syscall.RegOpenKeyEx(syscall.HKEY_LOCAL_MACHINE, keyPath, 0, syscall.KEY_READ, &key); err != nil {
return ""
}
defer syscall.RegCloseKey(key)
valueName, _ := syscall.UTF16PtrFromString("ProcessorNameString")
var valueType uint32
var size uint32
if err := syscall.RegQueryValueEx(key, valueName, nil, &valueType, nil, &size); err != nil || valueType != windowsRegSZ || size == 0 {
return ""
}
buf := make([]byte, size)
if err := syscall.RegQueryValueEx(key, valueName, nil, &valueType, &buf[0], &size); err != nil {
return ""
}
utf16s := make([]uint16, 0, len(buf)/2)
for i := 0; i+1 < int(size); i += 2 {
utf16s = append(utf16s, uint16(buf[i])|uint16(buf[i+1])<<8)
}
return strings.TrimSpace(syscall.UTF16ToString(utf16s))
}
// windowsReadCPUUsagePercent samples GetSystemTimes and returns best-effort CPU usage.
func windowsReadCPUUsagePercent(ctx context.Context) float64 {
prev, err := windowsReadSystemTimes()
if err != nil {
return 0
}
select {
case <-ctx.Done():
return 0
case <-time.After(100 * time.Millisecond):
}
next, err := windowsReadSystemTimes()
if err != nil {
return 0
}
return windowsCPUUsagePercent(prev, next)
}
// windowsReadSystemTimes returns idle, kernel, and user system counters from GetSystemTimes.
func windowsReadSystemTimes() (windowsSystemTimes, error) {
var idle, kernel, user syscall.Filetime
ok, _, err := windowsGetSystemTimesProc.Call(
uintptr(unsafe.Pointer(&idle)),
uintptr(unsafe.Pointer(&kernel)),
uintptr(unsafe.Pointer(&user)),
)
if ok == 0 {
return windowsSystemTimes{}, err
}
return windowsSystemTimes{Idle: windowsFiletimeUint64(idle), Kernel: windowsFiletimeUint64(kernel), User: windowsFiletimeUint64(user)}, nil
}
// windowsCPUUsagePercent calculates non-idle CPU percentage between two Windows CPU samples.
func windowsCPUUsagePercent(prev windowsSystemTimes, next windowsSystemTimes) float64 {
prevTotal := prev.Kernel + prev.User
nextTotal := next.Kernel + next.User
if nextTotal <= prevTotal || next.Idle < prev.Idle {
return 0
}
totalDelta := nextTotal - prevTotal
idleDelta := next.Idle - prev.Idle
if totalDelta == 0 || idleDelta >= totalDelta {
return 0
}
return float64(totalDelta-idleDelta) / float64(totalDelta) * 100
}
// windowsFiletimeUint64 converts a FILETIME to a monotonically comparable counter.
func windowsFiletimeUint64(value syscall.Filetime) uint64 {
return uint64(value.HighDateTime)<<32 | uint64(value.LowDateTime)
}
// windowsReadDisks enumerates fixed and remote Windows drives and returns capacity data.
func windowsReadDisks() ([]status.DiskInfo, error) {
mask, _, _ := windowsGetLogicalDrivesProc.Call()
if mask == 0 {
return nil, syscall.GetLastError()
}
var disks []status.DiskInfo
var errs []error
for i := 0; i < 26; i++ {
if mask&(1<<i) == 0 {
continue
}
root := fmt.Sprintf("%c:\\", 'A'+i)
rootPtr, _ := syscall.UTF16PtrFromString(root)
driveType, _, _ := windowsGetDriveTypeWProc.Call(uintptr(unsafe.Pointer(rootPtr)))
if driveType != windowsDriveFixed && driveType != windowsDriveRemote {
continue
}
var freeAvailable, total, free uint64
ok, _, err := windowsGetDiskFreeSpaceExWProc.Call(
uintptr(unsafe.Pointer(rootPtr)),
uintptr(unsafe.Pointer(&freeAvailable)),
uintptr(unsafe.Pointer(&total)),
uintptr(unsafe.Pointer(&free)),
)
if ok == 0 {
errs = append(errs, fmt.Errorf("%s: %w", root, err))
continue
}
disks = append(disks, windowsDiskInfoFromSpace(root, uint32(driveType), total, freeAvailable))
}
return disks, errors.Join(errs...)
}
// windowsDiskInfoFromSpace converts Windows disk capacity counters into API disk fields.
func windowsDiskInfoFromSpace(root string, driveType uint32, total uint64, free uint64) status.DiskInfo {
used := uint64(0)
if total >= free {
used = total - free
}
percent := 0.0
if total > 0 {
percent = float64(used) / float64(total) * 100
}
return status.DiskInfo{
Device: root, MountPoint: root, FSType: windowsDriveTypeName(driveType),
Unit: "byte", Total: total, Used: used, Free: free, UsedPercent: percent,
}
}
// windowsDriveTypeName maps Windows drive type constants to stable API labels.
func windowsDriveTypeName(driveType uint32) string {
switch driveType {
case windowsDriveFixed:
return "fixed"
case windowsDriveRemote:
return "remote"
default:
return "unknown"
}
}
// windowsMemoryInfoFromStatus converts Windows physical memory counters into HostStatus memory data.
func windowsMemoryInfoFromStatus(memory windowsMemoryStatus) status.MemoryInfo {
used := uint64(0)
if memory.TotalPhys >= memory.AvailPhys {
used = memory.TotalPhys - memory.AvailPhys
}
percent := 0.0
if memory.TotalPhys > 0 {
percent = float64(used) / float64(memory.TotalPhys) * 100
}
return status.MemoryInfo{
Unit: "byte", Total: memory.TotalPhys, Used: used,
Free: memory.AvailPhys, Available: memory.AvailPhys, UsedPercent: percent,
}
}
// windowsTCPListeners returns IPv4 or IPv6 TCP listeners from GetExtendedTcpTable.
func windowsTCPListeners(family int, familyName string, processes map[int]status.ProcessInfo) ([]status.TCPListenerInfo, error) {
table, err := windowsExtendedTCPTable(family)
if err != nil {
return nil, err
}
if len(table) < 4 {
return nil, fmt.Errorf("windows tcp table for %s is too small", familyName)
}
count := *(*uint32)(unsafe.Pointer(&table[0]))
if family == windowsAFInet {
return windowsParseTCP4Rows(table[4:], count, processes), nil
}
return windowsParseTCP6Rows(table[4:], count, processes), nil
}
// windowsExtendedTCPTable calls GetExtendedTcpTable and returns the raw table bytes.
func windowsExtendedTCPTable(family int) ([]byte, error) {
var size uint32
result, _, _ := windowsGetExtendedTCPTableProc.Call(
0,
uintptr(unsafe.Pointer(&size)),
1,
uintptr(family),
uintptr(windowsTCPTableOwnerPIDListen),
0,
)
if result != windowsErrorInsufficientBuffer && result != windowsNoError {
return nil, fmt.Errorf("GetExtendedTcpTable size query failed: %d", result)
}
if size == 0 {
return nil, nil
}
table := make([]byte, size)
result, _, _ = windowsGetExtendedTCPTableProc.Call(
uintptr(unsafe.Pointer(&table[0])),
uintptr(unsafe.Pointer(&size)),
1,
uintptr(family),
uintptr(windowsTCPTableOwnerPIDListen),
0,
)
if result != windowsNoError {
return nil, fmt.Errorf("GetExtendedTcpTable failed: %d", result)
}
return table, nil
}
// windowsParseTCP4Rows converts raw MIB_TCPROW_OWNER_PID rows into listener records.
func windowsParseTCP4Rows(rows []byte, count uint32, processes map[int]status.ProcessInfo) []status.TCPListenerInfo {
rowSize := int(unsafe.Sizeof(windowsMIBTCPRowOwnerPID{}))
items := make([]status.TCPListenerInfo, 0, count)
for i := 0; i < int(count); i++ {
offset := i * rowSize
if offset+rowSize > len(rows) {
break
}
row := *(*windowsMIBTCPRowOwnerPID)(unsafe.Pointer(&rows[offset]))
if row.State != windowsTCPStateListen {
continue
}
process := processes[int(row.OwningPID)]
addr := row.LocalAddr
items = append(items, status.TCPListenerInfo{
IP: net.IPv4(byte(addr), byte(addr>>8), byte(addr>>16), byte(addr>>24)).String(),
Port: windowsTCPPortFromNetworkOrder(row.LocalPort), PID: int(row.OwningPID),
ProcessName: process.Name, ProcessExe: process.Exe, Family: "ipv4",
})
}
return items
}
// windowsParseTCP6Rows converts raw MIB_TCP6ROW_OWNER_PID rows into listener records.
func windowsParseTCP6Rows(rows []byte, count uint32, processes map[int]status.ProcessInfo) []status.TCPListenerInfo {
rowSize := int(unsafe.Sizeof(windowsMIBTCP6RowOwnerPID{}))
items := make([]status.TCPListenerInfo, 0, count)
for i := 0; i < int(count); i++ {
offset := i * rowSize
if offset+rowSize > len(rows) {
break
}
row := *(*windowsMIBTCP6RowOwnerPID)(unsafe.Pointer(&rows[offset]))
if row.State != windowsTCPStateListen {
continue
}
process := processes[int(row.OwningPID)]
items = append(items, status.TCPListenerInfo{
IP: net.IP(row.LocalAddr[:]).String(), Port: windowsTCPPortFromNetworkOrder(row.LocalPort),
PID: int(row.OwningPID), ProcessName: process.Name, ProcessExe: process.Exe, Family: "ipv6",
})
}
return items
}
// windowsTCPPortFromNetworkOrder decodes the low 16 bits Windows stores in network byte order.
func windowsTCPPortFromNetworkOrder(raw uint32) int {
port := uint16(raw)
return int(port>>8) | int(port&0xff)<<8
}
// windowsProcessList enumerates processes with the Windows Toolhelp snapshot API.
func windowsProcessList() ([]status.ProcessInfo, error) {
cmdlines := windowsProcessCommandLines()
snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPPROCESS, 0)
if err != nil {
return nil, err
}
defer syscall.CloseHandle(snapshot)
var entry syscall.ProcessEntry32
entry.Size = uint32(unsafe.Sizeof(entry))
if err := syscall.Process32First(snapshot, &entry); err != nil {
return nil, err
}
var processes []status.ProcessInfo
for {
details := windowsReadProcessDetails(entry.ProcessID)
if cmdline := cmdlines[int(entry.ProcessID)]; cmdline != "" {
details.Cmdline = cmdline
}
processes = append(processes, windowsProcessInfoFromEntry(entry, details))
err = syscall.Process32Next(snapshot, &entry)
if err != nil {
if errors.Is(err, syscall.ERROR_NO_MORE_FILES) {
return processes, nil
}
return processes, err
}
}
}
// windowsProcessInfoFromEntry combines Toolhelp identity data with optional process details.
func windowsProcessInfoFromEntry(entry syscall.ProcessEntry32, details windowsProcessDetails) status.ProcessInfo {
name := syscall.UTF16ToString(entry.ExeFile[:])
exe := details.Exe
if exe == "" {
exe = name
}
return status.ProcessInfo{
PID: int(entry.ProcessID), PPID: int(entry.ParentProcessID), Name: name,
Exe: exe, Cmdline: details.Cmdline, Username: details.Username, Status: details.Status,
StartTime: details.StartTime, CPUPercent: details.CPUPercent,
MemoryRSS: details.MemoryRSS, MemoryVMS: details.MemoryVMS,
}
}
// windowsReadProcessDetails opens a process and reads fields that may require elevated privileges.
func windowsReadProcessDetails(pid uint32) windowsProcessDetails {
handle, err := syscall.OpenProcess(windowsProcessQueryLimitedInfo|windowsProcessQueryInformation|windowsProcessVMRead, false, pid)
if err != nil {
return windowsProcessDetails{Status: "unknown"}
}
defer syscall.CloseHandle(handle)
details := windowsProcessDetails{Status: "running"}
details.Exe = windowsQueryProcessImageName(handle)
details.Username = windowsQueryProcessUsername(handle)
details.StartTime, details.CPUPercent = windowsQueryProcessTimes(handle)
details.MemoryRSS, details.MemoryVMS = windowsQueryProcessMemory(handle)
return details
}
// windowsQueryProcessImageName returns the full process image path when access allows it.
func windowsQueryProcessImageName(handle syscall.Handle) string {
buf := make([]uint16, windowsMaxPath)
size := uint32(len(buf))
ok, _, _ := windowsQueryFullProcessImageNameWProc.Call(
uintptr(handle),
0,
uintptr(unsafe.Pointer(&buf[0])),
uintptr(unsafe.Pointer(&size)),
)
if ok == 0 || size == 0 {
return ""
}
return syscall.UTF16ToString(buf[:size])
}
// windowsQueryProcessTimes returns start time and lifetime-average CPU percentage.
func windowsQueryProcessTimes(handle syscall.Handle) (int64, float64) {
var creation, exit, kernel, user syscall.Filetime
if err := syscall.GetProcessTimes(handle, &creation, &exit, &kernel, &user); err != nil {
return 0, 0
}
startNanos := creation.Nanoseconds()
elapsedNanos := time.Now().UnixNano() - startNanos
if elapsedNanos <= 0 {
return startNanos / int64(time.Second), 0
}
cpuNanos := int64(windowsFiletimeUint64(kernel)+windowsFiletimeUint64(user)) * 100
percent := float64(cpuNanos) / float64(elapsedNanos) * 100 / float64(runtime.NumCPU())
return startNanos / int64(time.Second), percent
}
// windowsQueryProcessMemory returns working set and pagefile usage when access allows it.
func windowsQueryProcessMemory(handle syscall.Handle) (uint64, uint64) {
counters := windowsProcessMemoryCounters{CB: uint32(unsafe.Sizeof(windowsProcessMemoryCounters{}))}
ok, _, _ := windowsGetProcessMemoryInfoProc.Call(
uintptr(handle),
uintptr(unsafe.Pointer(&counters)),
uintptr(counters.CB),
)
if ok == 0 {
return 0, 0
}
return uint64(counters.WorkingSetSize), uint64(counters.PagefileUsage)
}
// windowsQueryProcessUsername returns DOMAIN\user for a process token when access allows it.
func windowsQueryProcessUsername(handle syscall.Handle) string {
var token syscall.Handle
ok, _, _ := windowsOpenProcessTokenProc.Call(uintptr(handle), uintptr(windowsTokenQuery), uintptr(unsafe.Pointer(&token)))
if ok == 0 {
return ""
}
defer syscall.CloseHandle(token)
var size uint32
windowsGetTokenInformationProc.Call(uintptr(token), 1, 0, 0, uintptr(unsafe.Pointer(&size)))
if size == 0 {
return ""
}
buf := make([]byte, size)
ok, _, _ = windowsGetTokenInformationProc.Call(uintptr(token), 1, uintptr(unsafe.Pointer(&buf[0])), uintptr(size), uintptr(unsafe.Pointer(&size)))
if ok == 0 || len(buf) < int(unsafe.Sizeof(uintptr(0)))+4 {
return ""
}
sid := *(*uintptr)(unsafe.Pointer(&buf[0]))
return windowsLookupAccountName(sid)
}
// windowsLookupAccountName resolves a SID pointer to DOMAIN\name.
func windowsLookupAccountName(sid uintptr) string {
var nameSize, domainSize uint32
var sidType uint32
windowsLookupAccountSidWProc.Call(0, sid, 0, uintptr(unsafe.Pointer(&nameSize)), 0, uintptr(unsafe.Pointer(&domainSize)), uintptr(unsafe.Pointer(&sidType)))
if nameSize == 0 {
return ""
}
name := make([]uint16, nameSize)
domain := make([]uint16, domainSize)
ok, _, _ := windowsLookupAccountSidWProc.Call(
0,
sid,
uintptr(unsafe.Pointer(&name[0])),
uintptr(unsafe.Pointer(&nameSize)),
uintptr(unsafe.Pointer(&domain[0])),
uintptr(unsafe.Pointer(&domainSize)),
uintptr(unsafe.Pointer(&sidType)),
)
if ok == 0 {
return ""
}
account := syscall.UTF16ToString(name[:nameSize])
domainName := syscall.UTF16ToString(domain[:domainSize])
if domainName == "" {
return account
}
return domainName + `\` + account
}
// windowsProcessCommandLines reads command lines in one best-effort WMIC call to avoid per-process shelling.
func windowsProcessCommandLines() map[int]string {
out := map[int]string{}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
output, err := exec.CommandContext(ctx, "wmic", "process", "get", "ProcessId,CommandLine", "/format:csv").Output()
if err != nil {
return out
}
reader := csv.NewReader(bytes.NewReader(output))
reader.FieldsPerRecord = -1
records, err := reader.ReadAll()
if err != nil {
return out
}
for _, record := range records {
if len(record) < 3 || strings.EqualFold(record[1], "CommandLine") {
continue
}
pid, err := strconv.Atoi(strings.TrimSpace(record[2]))
if err != nil {
continue
}
out[pid] = strings.TrimSpace(record[1])
}
return out
}
// windowsProcessMap returns process metadata keyed by PID for TCP owner mapping.
func windowsProcessMap() (map[int]status.ProcessInfo, error) {
processes, err := windowsProcessList()
out := make(map[int]status.ProcessInfo, len(processes))
for _, process := range processes {
out[process.PID] = process
}
return out, err
}
// windowsSortedKeys returns deterministic sorted keys from a string set.
func windowsSortedKeys(values map[string]struct{}) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}

View File

@@ -0,0 +1,87 @@
//go:build windows
package collector
import (
"syscall"
"testing"
)
// TestWindowsMemoryInfoFromStatus verifies Windows memory counters are exposed in bytes.
func TestWindowsMemoryInfoFromStatus(t *testing.T) {
got := windowsMemoryInfoFromStatus(windowsMemoryStatus{
TotalPhys: 10,
AvailPhys: 4,
})
if got.Unit != "byte" {
t.Fatalf("unit = %q, want byte", got.Unit)
}
if got.Total != 10 || got.Available != 4 || got.Free != 4 || got.Used != 6 {
t.Fatalf("memory = %+v", got)
}
if got.UsedPercent != 60 {
t.Fatalf("used percent = %f, want 60", got.UsedPercent)
}
}
// TestWindowsTCPPortFromNetworkOrder verifies Windows TCP port values are decoded.
func TestWindowsTCPPortFromNetworkOrder(t *testing.T) {
if got := windowsTCPPortFromNetworkOrder(0x0000901f); got != 8080 {
t.Fatalf("port = %d, want 8080", got)
}
}
// TestWindowsDiskInfoFromSpace verifies Windows disk counters are mapped to API disk fields.
func TestWindowsDiskInfoFromSpace(t *testing.T) {
got := windowsDiskInfoFromSpace(`C:\`, windowsDriveFixed, 100, 40)
if got.Device != `C:\` || got.MountPoint != `C:\` {
t.Fatalf("disk paths = %q/%q", got.Device, got.MountPoint)
}
if got.FSType != "fixed" || got.Unit != "byte" {
t.Fatalf("disk type/unit = %q/%q", got.FSType, got.Unit)
}
if got.Total != 100 || got.Free != 40 || got.Used != 60 {
t.Fatalf("disk capacity = %+v", got)
}
if got.UsedPercent != 60 {
t.Fatalf("used percent = %f, want 60", got.UsedPercent)
}
}
// TestWindowsCPUUsagePercent verifies idle and kernel counters are converted to busy percentage.
func TestWindowsCPUUsagePercent(t *testing.T) {
prev := windowsSystemTimes{Idle: 100, Kernel: 500, User: 500}
next := windowsSystemTimes{Idle: 150, Kernel: 650, User: 700}
got := windowsCPUUsagePercent(prev, next)
if got != 75 {
t.Fatalf("cpu usage = %f, want 75", got)
}
}
// TestWindowsProcessInfoFromEntry verifies Toolhelp process data is enriched with memory and command line data.
func TestWindowsProcessInfoFromEntry(t *testing.T) {
entry := syscall.ProcessEntry32{ProcessID: 123, ParentProcessID: 12}
copy(entry.ExeFile[:], syscall.StringToUTF16("agent.exe"))
got := windowsProcessInfoFromEntry(entry, windowsProcessDetails{
Exe: `C:\agent\agent.exe`,
Cmdline: `"C:\agent\agent.exe" run`,
Username: `NT AUTHORITY\SYSTEM`,
MemoryRSS: 2048,
MemoryVMS: 4096,
})
if got.PID != 123 || got.PPID != 12 || got.Name != "agent.exe" {
t.Fatalf("process identity = %+v", got)
}
if got.Exe != `C:\agent\agent.exe` || got.Cmdline != `"C:\agent\agent.exe" run` {
t.Fatalf("process command fields = %+v", got)
}
if got.Username != `NT AUTHORITY\SYSTEM` || got.MemoryRSS != 2048 || got.MemoryVMS != 4096 {
t.Fatalf("process details = %+v", got)
}
}

View File

@@ -0,0 +1,27 @@
package collector
import (
"net"
"sort"
)
// networkAddressSets separates interface addresses into deterministic IPv4 and IPv6 lists.
func networkAddressSets(values map[string]struct{}) (ipv4s []string, ipv6s []string) {
for value := range values {
host, _, err := net.ParseCIDR(value)
if err != nil {
host = net.ParseIP(value)
}
if host == nil {
continue
}
if host.To4() != nil {
ipv4s = append(ipv4s, value)
continue
}
ipv6s = append(ipv6s, value)
}
sort.Strings(ipv4s)
sort.Strings(ipv6s)
return ipv4s, ipv6s
}

View File

@@ -0,0 +1,23 @@
package collector
import (
"reflect"
"testing"
)
// TestNetworkAddressSetsSeparatesIPv4AndIPv6 verifies flattened IP lists are split by address family.
func TestNetworkAddressSetsSeparatesIPv4AndIPv6(t *testing.T) {
ipv4s, ipv6s := networkAddressSets(map[string]struct{}{
"fe80::1/64": {},
"192.168.1.10/24": {},
"10.0.0.2/8": {},
"not-an-ip": {},
})
if want := []string{"10.0.0.2/8", "192.168.1.10/24"}; !reflect.DeepEqual(ipv4s, want) {
t.Fatalf("ipv4s = %#v, want %#v", ipv4s, want)
}
if want := []string{"fe80::1/64"}; !reflect.DeepEqual(ipv6s, want) {
t.Fatalf("ipv6s = %#v, want %#v", ipv6s, want)
}
}

149
internal/config/config.go Normal file
View File

@@ -0,0 +1,149 @@
package config
import (
"encoding/json"
"errors"
"os"
"strconv"
"time"
)
// Config contains all runtime, reporting, collection, and service settings.
type Config struct {
HostID string `json:"host_id"`
HTTP HTTPConfig `json:"http"`
Collect CollectConfig `json:"collect"`
Report ReportConfig `json:"report"`
Service ServiceConfig `json:"service"`
}
// HTTPConfig controls the local HTTP API listener.
type HTTPConfig struct {
ListenAddress string `json:"listen_address"`
}
// CollectConfig controls periodic host status collection.
type CollectConfig struct {
Interval Duration `json:"interval"`
Timeout Duration `json:"timeout"`
}
// ReportConfig controls optional periodic status POST reporting.
type ReportConfig struct {
Enabled bool `json:"enabled"`
Endpoint string `json:"endpoint"`
Interval Duration `json:"interval"`
Timeout Duration `json:"timeout"`
}
// ServiceConfig controls operating system service registration metadata.
type ServiceConfig struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
}
// Duration wraps time.Duration so JSON config can use Go duration strings.
type Duration time.Duration
// Duration returns the standard library duration value.
func (d Duration) Duration() time.Duration {
return time.Duration(d)
}
// MarshalJSON writes the duration as a Go duration string such as "30s".
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}
// UnmarshalJSON parses a Go duration string from JSON.
func (d *Duration) UnmarshalJSON(data []byte) error {
var raw string
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
parsed, err := time.ParseDuration(raw)
if err != nil {
return err
}
*d = Duration(parsed)
return nil
}
// Default returns safe local defaults for running the agent.
func Default() Config {
return Config{
HTTP: HTTPConfig{ListenAddress: "127.0.0.1:8080"},
Collect: CollectConfig{
Interval: Duration(30 * time.Second),
Timeout: Duration(10 * time.Second),
},
Report: ReportConfig{
Enabled: false,
Interval: Duration(60 * time.Second),
Timeout: Duration(5 * time.Second),
},
Service: ServiceConfig{
Name: "mfd-monitor-agent",
DisplayName: "MFD Monitor Agent",
Description: "Collects host status and exposes it over HTTP.",
},
}
}
// Load reads optional JSON config, applies environment overrides, and validates required settings.
func Load(path string) (Config, error) {
cfg := Default()
if path != "" {
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, err
}
}
applyEnv(&cfg)
if cfg.Report.Enabled && cfg.Report.Endpoint == "" {
return cfg, errors.New("report endpoint is required when reporting is enabled")
}
return cfg, nil
}
// applyEnv overlays supported MFD_AGENT_* environment variables onto cfg.
func applyEnv(cfg *Config) {
if value := os.Getenv("MFD_AGENT_HOST_ID"); value != "" {
cfg.HostID = value
}
if value := os.Getenv("MFD_AGENT_LISTEN"); value != "" {
cfg.HTTP.ListenAddress = value
}
if value := os.Getenv("MFD_AGENT_REPORT_ENABLED"); value != "" {
if parsed, err := strconv.ParseBool(value); err == nil {
cfg.Report.Enabled = parsed
}
}
if value := os.Getenv("MFD_AGENT_REPORT_ENDPOINT"); value != "" {
cfg.Report.Endpoint = value
}
if value := os.Getenv("MFD_AGENT_REPORT_INTERVAL"); value != "" {
if parsed, err := time.ParseDuration(value); err == nil {
cfg.Report.Interval = Duration(parsed)
}
}
if value := os.Getenv("MFD_AGENT_REPORT_TIMEOUT"); value != "" {
if parsed, err := time.ParseDuration(value); err == nil {
cfg.Report.Timeout = Duration(parsed)
}
}
if value := os.Getenv("MFD_AGENT_COLLECT_INTERVAL"); value != "" {
if parsed, err := time.ParseDuration(value); err == nil {
cfg.Collect.Interval = Duration(parsed)
}
}
if value := os.Getenv("MFD_AGENT_COLLECT_TIMEOUT"); value != "" {
if parsed, err := time.ParseDuration(value); err == nil {
cfg.Collect.Timeout = Duration(parsed)
}
}
}

View File

@@ -0,0 +1,71 @@
package config
import (
"os"
"testing"
"time"
)
// TestDefaultConfigUsesLocalhostAndSaneIntervals verifies safe local defaults.
func TestDefaultConfigUsesLocalhostAndSaneIntervals(t *testing.T) {
cfg := Default()
if cfg.HTTP.ListenAddress != "127.0.0.1:8080" {
t.Fatalf("listen address = %q", cfg.HTTP.ListenAddress)
}
if cfg.Collect.Interval <= 0 || cfg.Collect.Timeout <= 0 {
t.Fatalf("collect intervals must be positive: %+v", cfg.Collect)
}
if cfg.Report.Enabled {
t.Fatal("reporting should be disabled by default")
}
}
// TestLoadAppliesEnvironmentOverrides verifies environment variables override defaults.
func TestLoadAppliesEnvironmentOverrides(t *testing.T) {
t.Setenv("MFD_AGENT_LISTEN", "127.0.0.1:9090")
t.Setenv("MFD_AGENT_REPORT_ENABLED", "true")
t.Setenv("MFD_AGENT_REPORT_ENDPOINT", "http://center/status")
t.Setenv("MFD_AGENT_REPORT_INTERVAL", "2s")
t.Setenv("MFD_AGENT_HOST_ID", "host-a")
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
if cfg.HTTP.ListenAddress != "127.0.0.1:9090" {
t.Fatalf("listen address = %q", cfg.HTTP.ListenAddress)
}
if !cfg.Report.Enabled || cfg.Report.Endpoint != "http://center/status" {
t.Fatalf("report config not applied: %+v", cfg.Report)
}
if cfg.Report.Interval.Duration() != 2*time.Second {
t.Fatalf("report interval = %s", cfg.Report.Interval.Duration())
}
if cfg.HostID != "host-a" {
t.Fatalf("host id = %q", cfg.HostID)
}
}
// TestLoadJSONConfigFile verifies JSON config files are parsed into Config.
func TestLoadJSONConfigFile(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "config-*.json")
if err != nil {
t.Fatal(err)
}
_, _ = file.WriteString(`{"http":{"listen_address":"127.0.0.1:7070"},"collect":{"interval":"3s","timeout":"1s"}}`)
_ = file.Close()
cfg, err := Load(file.Name())
if err != nil {
t.Fatal(err)
}
if cfg.HTTP.ListenAddress != "127.0.0.1:7070" {
t.Fatalf("listen address = %q", cfg.HTTP.ListenAddress)
}
if cfg.Collect.Interval.Duration() != 3*time.Second || cfg.Collect.Timeout.Duration() != time.Second {
t.Fatalf("collect config = %+v", cfg.Collect)
}
}

View File

@@ -0,0 +1,69 @@
package httpapi
import (
"encoding/json"
"net/http"
"mfd-monitor-agent/internal/status"
)
// Provider supplies the latest cached HostStatus to HTTP handlers.
type Provider interface {
// Get returns the latest snapshot and whether a snapshot is ready.
Get() (status.HostStatus, bool)
}
// StaticProvider is a test provider with a fixed snapshot response.
type StaticProvider struct {
Snapshot status.HostStatus
OK bool
}
// Get returns the configured static snapshot and readiness flag.
func (p StaticProvider) Get() (status.HostStatus, bool) {
return p.Snapshot, p.OK
}
// New creates the HTTP API handler for health and status endpoints.
func New(provider Provider) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
snapshot, ok := provider.Get()
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"status": "starting",
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"version": snapshot.Agent.Version,
"snapshot_at": snapshot.Agent.SnapshotAt,
"snapshot_status": snapshot.Agent.SnapshotStatus,
})
})
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
snapshot, ok := provider.Get()
if !ok {
http.Error(w, "snapshot not ready", http.StatusServiceUnavailable)
return
}
writeJSON(w, http.StatusOK, snapshot)
})
return mux
}
// writeJSON serializes value as JSON with a response status code.
func writeJSON(w http.ResponseWriter, code int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(value)
}

View File

@@ -0,0 +1,117 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"mfd-monitor-agent/internal/status"
)
// TestStatusReturnsCachedSnapshot verifies /status reads the provider cache.
func TestStatusReturnsCachedSnapshot(t *testing.T) {
provider := StaticProvider{Snapshot: status.HostStatus{
Agent: status.AgentInfo{Version: "test", SnapshotAt: time.Now(), SnapshotStatus: "ok"},
Host: status.HostInfo{Hostname: "host-a"},
}, OK: true}
server := New(provider)
req := httptest.NewRequest(http.MethodGet, "/status", nil)
rec := httptest.NewRecorder()
server.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status code = %d", rec.Code)
}
var got status.HostStatus
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.Host.Hostname != "host-a" {
t.Fatalf("hostname = %q", got.Host.Hostname)
}
}
// TestHealthReportsSnapshotMetadata verifies /health returns snapshot metadata.
func TestHealthReportsSnapshotMetadata(t *testing.T) {
provider := StaticProvider{Snapshot: status.HostStatus{
Agent: status.AgentInfo{Version: "test", SnapshotAt: time.Unix(10, 0), SnapshotStatus: "partial"},
}, OK: true}
server := New(provider)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
server.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status code = %d", rec.Code)
}
var got map[string]any
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got["snapshot_status"] != "partial" {
t.Fatalf("health = %+v", got)
}
}
// TestStatusReturnsServiceUnavailableBeforeSnapshot verifies /status does not live collect.
func TestStatusReturnsServiceUnavailableBeforeSnapshot(t *testing.T) {
server := New(StaticProvider{OK: false})
req := httptest.NewRequest(http.MethodGet, "/status", nil)
rec := httptest.NewRecorder()
server.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status code = %d", rec.Code)
}
}
// TestHealthReturnsStartingBeforeSnapshot verifies /health exposes startup state.
func TestHealthReturnsStartingBeforeSnapshot(t *testing.T) {
server := New(StaticProvider{OK: false})
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
server.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status code = %d", rec.Code)
}
var got map[string]string
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got["status"] != "starting" {
t.Fatalf("status = %q", got["status"])
}
}
// TestStatusRejectsNonGET verifies /status only accepts GET requests.
func TestStatusRejectsNonGET(t *testing.T) {
server := New(StaticProvider{OK: true})
req := httptest.NewRequest(http.MethodPost, "/status", nil)
rec := httptest.NewRecorder()
server.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status code = %d", rec.Code)
}
}
// TestHealthRejectsNonGET verifies /health only accepts GET requests.
func TestHealthRejectsNonGET(t *testing.T) {
server := New(StaticProvider{OK: true})
req := httptest.NewRequest(http.MethodPost, "/health", nil)
rec := httptest.NewRecorder()
server.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status code = %d", rec.Code)
}
}

View File

@@ -0,0 +1,99 @@
package reporter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"mfd-monitor-agent/internal/status"
)
const defaultRetryDelay = 2 * time.Second
// Provider supplies the cached HostStatus used for outbound reports.
type Provider interface {
// Get returns the latest snapshot and whether a snapshot is ready.
Get() (status.HostStatus, bool)
}
// Client posts HostStatus snapshots to a configured center endpoint.
type Client struct {
endpoint string
client *http.Client
}
// NewClient creates a reporting client with the provided endpoint and request timeout.
func NewClient(endpoint string, timeout time.Duration) *Client {
return &Client{
endpoint: endpoint,
client: &http.Client{Timeout: timeout},
}
}
// Report posts one HostStatus snapshot as JSON and returns non-2xx responses as errors.
func (c *Client) Report(snapshot status.HostStatus) error {
body, err := json.Marshal(snapshot)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, c.endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("report returned HTTP %d", resp.StatusCode)
}
return nil
}
// Run periodically posts the latest cached snapshot until ctx is canceled.
func Run(ctx context.Context, provider Provider, client *Client, interval time.Duration, logger *slog.Logger) {
runWithRetryDelay(ctx, provider, client, interval, defaultRetryDelay, logger)
}
// runWithRetryDelay runs the reporting loop and retries one failed report after retryDelay.
func runWithRetryDelay(ctx context.Context, provider Provider, client *Client, interval time.Duration, retryDelay time.Duration, logger *slog.Logger) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
snapshot, ok := provider.Get()
if !ok {
continue
}
if err := reportWithRetry(ctx, client, snapshot, retryDelay); err != nil {
logger.Warn("status report failed", "error", err)
}
}
}
}
// reportWithRetry posts a snapshot and performs one cancellation-aware retry on failure.
func reportWithRetry(ctx context.Context, client *Client, snapshot status.HostStatus, retryDelay time.Duration) error {
err := client.Report(snapshot)
if err == nil {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(retryDelay):
}
if retryErr := client.Report(snapshot); retryErr != nil {
return retryErr
}
return nil
}

View File

@@ -0,0 +1,138 @@
package reporter
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"mfd-monitor-agent/internal/status"
)
// StaticProvider is a test provider with a fixed cached snapshot.
type StaticProvider struct {
Snapshot status.HostStatus
OK bool
}
// Get returns the configured static snapshot and readiness flag.
func (p StaticProvider) Get() (status.HostStatus, bool) {
return p.Snapshot, p.OK
}
// TestReportPostsHostStatusJSON verifies reports reuse the HostStatus JSON payload.
func TestReportPostsHostStatusJSON(t *testing.T) {
var got status.HostStatus
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Fatalf("content type = %q", r.Header.Get("Content-Type"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
w.WriteHeader(http.StatusAccepted)
}))
defer server.Close()
client := NewClient(server.URL, time.Second)
err := client.Report(status.HostStatus{Host: status.HostInfo{Hostname: "host-a"}})
if err != nil {
t.Fatal(err)
}
if got.Host.Hostname != "host-a" {
t.Fatalf("posted hostname = %q", got.Host.Hostname)
}
}
// TestReportReturnsErrorForHTTPFailure verifies non-2xx responses fail one report.
func TestReportReturnsErrorForHTTPFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
client := NewClient(server.URL, time.Second)
if err := client.Report(status.HostStatus{}); err == nil {
t.Fatal("expected error for HTTP 500")
}
}
// TestRunRetriesFailedReportOnce verifies the loop retries one failed report.
func TestRunRetriesFailedReportOnce(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if requests.Add(1) == 1 {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusAccepted)
}))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
provider := StaticProvider{Snapshot: status.HostStatus{Host: status.HostInfo{Hostname: "host-a"}}, OK: true}
client := NewClient(server.URL, time.Second)
done := make(chan struct{})
go func() {
defer close(done)
runWithRetryDelay(ctx, provider, client, 10*time.Millisecond, time.Millisecond, slog.New(slog.NewTextHandler(io.Discard, nil)))
}()
deadline := time.After(time.Second)
for requests.Load() < 2 {
select {
case <-deadline:
t.Fatalf("requests = %d", requests.Load())
case <-time.After(time.Millisecond):
}
}
cancel()
<-done
}
// TestRunStopsDuringRetryDelayWhenContextCanceled verifies retry waits honor cancellation.
func TestRunStopsDuringRetryDelayWhenContextCanceled(t *testing.T) {
var requests atomic.Int32
firstRequest := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if requests.Add(1) == 1 {
close(firstRequest)
}
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
provider := StaticProvider{Snapshot: status.HostStatus{}, OK: true}
client := NewClient(server.URL, time.Second)
done := make(chan struct{})
go func() {
defer close(done)
runWithRetryDelay(ctx, provider, client, time.Millisecond, time.Hour, slog.New(slog.NewTextHandler(io.Discard, nil)))
}()
select {
case <-firstRequest:
case <-time.After(time.Second):
t.Fatal("initial report did not run")
}
cancel()
select {
case <-done:
case <-time.After(100 * time.Millisecond):
t.Fatal("run did not stop during retry delay")
}
if requests.Load() != 1 {
t.Fatalf("requests = %d", requests.Load())
}
}

View File

@@ -0,0 +1,26 @@
package service
import "mfd-monitor-agent/internal/config"
// InstallOptions carries platform-specific service installation inputs.
type InstallOptions struct {
// ConfigPath is preserved in the service command line when a config file was supplied.
ConfigPath string
}
// Manager controls operating system service installation and lifecycle commands.
type Manager interface {
// Install registers the agent executable as an operating system service.
Install(executable string, cfg config.Config, opts InstallOptions) error
// Uninstall removes the operating system service registration.
Uninstall(cfg config.Config) error
// Start asks the operating system service manager to start the agent service.
Start(cfg config.Config) error
// Stop asks the operating system service manager to stop the agent service.
Stop(cfg config.Config) error
}
// NewManager returns the platform-specific service manager.
func NewManager() Manager {
return newManager()
}

View File

@@ -0,0 +1,82 @@
//go:build linux
package service
import (
"fmt"
"os"
"os/exec"
"strings"
"mfd-monitor-agent/internal/config"
)
var currentUID = os.Geteuid
// linuxManager manages a systemd service for the agent.
type linuxManager struct{}
// newManager returns the Linux systemd-backed service manager.
func newManager() Manager {
return linuxManager{}
}
// Install writes a systemd unit, reloads systemd, and enables the configured service.
func (linuxManager) Install(executable string, cfg config.Config, opts InstallOptions) error {
if currentUID() != 0 {
return fmt.Errorf("install requires root")
}
unit := renderSystemdUnit(executable, opts.ConfigPath, cfg)
path := "/etc/systemd/system/" + cfg.Service.Name + ".service"
if err := os.WriteFile(path, []byte(unit), 0644); err != nil {
return fmt.Errorf("write systemd unit: %w", err)
}
if err := exec.Command("systemctl", "daemon-reload").Run(); err != nil {
return fmt.Errorf("systemctl daemon-reload: %w", err)
}
if err := exec.Command("systemctl", "enable", cfg.Service.Name).Run(); err != nil {
return fmt.Errorf("systemctl enable: %w", err)
}
return nil
}
// renderSystemdUnit builds the Linux systemd unit used by Install.
func renderSystemdUnit(executable string, configPath string, cfg config.Config) string {
args := []string{executable, "run"}
if configPath != "" {
args = append(args, "-config", configPath)
}
return fmt.Sprintf(`[Unit]
Description=%s
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=%s
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
`, cfg.Service.Description, strings.Join(args, " "))
}
// Uninstall disables the service, removes its unit file, and reloads systemd.
func (linuxManager) Uninstall(cfg config.Config) error {
_ = exec.Command("systemctl", "disable", "--now", cfg.Service.Name).Run()
if err := os.Remove("/etc/systemd/system/" + cfg.Service.Name + ".service"); err != nil && !os.IsNotExist(err) {
return err
}
return exec.Command("systemctl", "daemon-reload").Run()
}
// Start asks systemd to start the configured service.
func (linuxManager) Start(cfg config.Config) error {
return exec.Command("systemctl", "start", cfg.Service.Name).Run()
}
// Stop asks systemd to stop the configured service.
func (linuxManager) Stop(cfg config.Config) error {
return exec.Command("systemctl", "stop", cfg.Service.Name).Run()
}

View File

@@ -0,0 +1,37 @@
//go:build linux
package service
import (
"strings"
"testing"
"mfd-monitor-agent/internal/config"
)
// TestRenderSystemdUnitIncludesConfigPath verifies install preserves the config flag.
func TestRenderSystemdUnitIncludesConfigPath(t *testing.T) {
cfg := config.Default()
cfg.Service.Description = "MFD Agent"
unit := renderSystemdUnit("/opt/agent", "/etc/mfd-agent.json", cfg)
if !strings.Contains(unit, "ExecStart=/opt/agent run -config /etc/mfd-agent.json") {
t.Fatalf("unit missing config path:\n%s", unit)
}
}
// TestLinuxInstallRequiresRoot verifies Linux install rejects non-root callers.
func TestLinuxInstallRequiresRoot(t *testing.T) {
oldUID := currentUID
t.Cleanup(func() { currentUID = oldUID })
currentUID = func() int { return 1000 }
err := linuxManager{}.Install("/opt/agent", config.Default(), InstallOptions{})
if err == nil {
t.Fatal("expected root requirement error")
}
if !strings.Contains(err.Error(), "root") {
t.Fatalf("error = %v", err)
}
}

View File

@@ -0,0 +1,38 @@
//go:build !linux && !windows
package service
import (
"fmt"
"runtime"
"mfd-monitor-agent/internal/config"
)
// unsupportedManager reports service lifecycle operations as unsupported on this platform.
type unsupportedManager struct{}
// newManager returns a manager that fails all service operations on unsupported platforms.
func newManager() Manager {
return unsupportedManager{}
}
// Install reports that service installation is unsupported on this platform.
func (unsupportedManager) Install(string, config.Config, InstallOptions) error {
return fmt.Errorf("service install is not implemented for %s", runtime.GOOS)
}
// Uninstall reports that service removal is unsupported on this platform.
func (unsupportedManager) Uninstall(config.Config) error {
return fmt.Errorf("service uninstall is not implemented for %s", runtime.GOOS)
}
// Start reports that service start is unsupported on this platform.
func (unsupportedManager) Start(config.Config) error {
return fmt.Errorf("service start is not implemented for %s", runtime.GOOS)
}
// Stop reports that service stop is unsupported on this platform.
func (unsupportedManager) Stop(config.Config) error {
return fmt.Errorf("service stop is not implemented for %s", runtime.GOOS)
}

View File

@@ -0,0 +1,43 @@
//go:build windows
package service
import (
"os/exec"
"strings"
"mfd-monitor-agent/internal/config"
)
// windowsManager manages the agent through the Windows Service Control Manager.
type windowsManager struct{}
// newManager returns the Windows service manager.
func newManager() Manager {
return windowsManager{}
}
// Install registers the agent executable as an auto-start Windows Service.
func (windowsManager) Install(executable string, cfg config.Config, opts InstallOptions) error {
args := []string{`"` + executable + `"`, "run"}
if opts.ConfigPath != "" {
args = append(args, "-config", `"`+opts.ConfigPath+`"`)
}
return exec.Command("sc.exe", "create", cfg.Service.Name, "binPath=", strings.Join(args, " "), "DisplayName=", cfg.Service.DisplayName, "start=", "auto").Run()
}
// Uninstall stops and removes the configured Windows Service.
func (windowsManager) Uninstall(cfg config.Config) error {
_ = exec.Command("sc.exe", "stop", cfg.Service.Name).Run()
return exec.Command("sc.exe", "delete", cfg.Service.Name).Run()
}
// Start asks the Windows Service Control Manager to start the service.
func (windowsManager) Start(cfg config.Config) error {
return exec.Command("sc.exe", "start", cfg.Service.Name).Run()
}
// Stop asks the Windows Service Control Manager to stop the service.
func (windowsManager) Stop(cfg config.Config) error {
return exec.Command("sc.exe", "stop", cfg.Service.Name).Run()
}

View File

@@ -0,0 +1,104 @@
package snapshot
import (
"context"
"sync"
"time"
"mfd-monitor-agent/internal/status"
)
// Collector gathers one subsection of HostStatus into the provided snapshot.
type Collector interface {
// Name returns the stable collector name used in partial snapshot errors.
Name() string
// Collect fills its subsection of out and returns an error when that subsection is incomplete.
Collect(context.Context, *status.HostStatus) error
}
// CollectorFunc adapts a function into a Collector for tests and small collectors.
type CollectorFunc func(context.Context, *status.HostStatus) error
// Name returns a generic collector name for function-backed collectors.
func (fn CollectorFunc) Name() string {
return "collector"
}
// Collect calls the wrapped function.
func (fn CollectorFunc) Collect(ctx context.Context, out *status.HostStatus) error {
return fn(ctx, out)
}
// Cache stores the most recent HostStatus snapshot behind a concurrency-safe interface.
type Cache struct {
collectors []Collector
version string
startedAt time.Time
mu sync.RWMutex
last status.HostStatus
hasLast bool
}
// NewCache creates an empty snapshot cache using the provided collectors and agent metadata.
func NewCache(collectors []Collector, version string, startedAt time.Time) *Cache {
return &Cache{collectors: collectors, version: version, startedAt: startedAt}
}
// Collect runs all collectors once and stores the resulting snapshot even when some collectors fail.
func (c *Cache) Collect(ctx context.Context) status.HostStatus {
out := status.HostStatus{
Agent: status.AgentInfo{
Version: c.version,
StartedAt: c.startedAt,
SnapshotAt: time.Now().UTC(),
SnapshotStatus: "ok",
},
Processes: status.ProcessResult{Status: "ok"},
TCPListeners: status.TCPResult{Status: "ok"},
}
for _, collector := range c.collectors {
if err := collector.Collect(ctx, &out); err != nil {
out.Agent.SnapshotStatus = "partial"
out.Errors = append(out.Errors, status.StatusError{
Collector: collector.Name(),
Message: err.Error(),
})
}
}
c.mu.Lock()
c.last = out
c.hasLast = true
c.mu.Unlock()
return out
}
// Get returns the latest cached snapshot and whether collection has completed at least once.
func (c *Cache) Get() (status.HostStatus, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.last, c.hasLast
}
// Run collects immediately and then refreshes the cache until ctx is canceled.
func (c *Cache) Run(ctx context.Context, interval time.Duration, timeout time.Duration) {
c.collectWithTimeout(ctx, timeout)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.collectWithTimeout(ctx, timeout)
}
}
}
// collectWithTimeout runs one collection cycle with an overall deadline.
func (c *Cache) collectWithTimeout(ctx context.Context, timeout time.Duration) {
collectCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
c.Collect(collectCtx)
}

View File

@@ -0,0 +1,103 @@
package snapshot
import (
"context"
"errors"
"testing"
"time"
"mfd-monitor-agent/internal/status"
)
// TestCollectorFailuresReturnPartialSnapshot verifies failed collectors do not fail snapshots.
func TestCollectorFailuresReturnPartialSnapshot(t *testing.T) {
cache := NewCache([]Collector{
CollectorFunc(func(context.Context, *status.HostStatus) error {
return nil
}),
CollectorFunc(func(context.Context, *status.HostStatus) error {
return errors.New("permission denied")
}),
}, "test", time.Now())
got := cache.Collect(context.Background())
if got.Agent.SnapshotStatus != "partial" {
t.Fatalf("snapshot status = %q", got.Agent.SnapshotStatus)
}
if len(got.Errors) != 1 || got.Errors[0].Message != "permission denied" {
t.Fatalf("errors = %+v", got.Errors)
}
}
// TestCacheStoresLastSnapshot verifies successful collection is cached.
func TestCacheStoresLastSnapshot(t *testing.T) {
cache := NewCache([]Collector{
CollectorFunc(func(_ context.Context, out *status.HostStatus) error {
out.Host.Hostname = "host-a"
return nil
}),
}, "test", time.Now())
cache.Collect(context.Background())
got, ok := cache.Get()
if !ok {
t.Fatal("expected cached snapshot")
}
if got.Host.Hostname != "host-a" {
t.Fatalf("hostname = %q", got.Host.Hostname)
}
}
// TestRunCollectsImmediately verifies Run collects before the first interval tick.
func TestRunCollectsImmediately(t *testing.T) {
collected := make(chan struct{})
cache := NewCache([]Collector{
CollectorFunc(func(_ context.Context, out *status.HostStatus) error {
out.Host.Hostname = "host-a"
close(collected)
return nil
}),
}, "test", time.Now())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go cache.Run(ctx, time.Hour, time.Second)
select {
case <-collected:
case <-time.After(time.Second):
t.Fatal("initial collection did not run")
}
got, ok := cache.Get()
if !ok {
t.Fatal("expected cached snapshot")
}
if got.Host.Hostname != "host-a" {
t.Fatalf("hostname = %q", got.Host.Hostname)
}
}
// TestCollectWithTimeoutMarksCanceledCollectorPartial verifies timeout errors are partial.
func TestCollectWithTimeoutMarksCanceledCollectorPartial(t *testing.T) {
cache := NewCache([]Collector{
CollectorFunc(func(ctx context.Context, _ *status.HostStatus) error {
<-ctx.Done()
return ctx.Err()
}),
}, "test", time.Now())
cache.collectWithTimeout(context.Background(), time.Millisecond)
got, ok := cache.Get()
if !ok {
t.Fatal("expected cached snapshot")
}
if got.Agent.SnapshotStatus != "partial" {
t.Fatalf("snapshot status = %q", got.Agent.SnapshotStatus)
}
if len(got.Errors) != 1 || got.Errors[0].Message != context.DeadlineExceeded.Error() {
t.Fatalf("errors = %+v", got.Errors)
}
}

148
internal/status/types.go Normal file
View File

@@ -0,0 +1,148 @@
package status
import "time"
// HostStatus is the full cached host snapshot returned by /status and posted by the reporter.
type HostStatus struct {
// Agent describes the agent process and most recent snapshot metadata.
Agent AgentInfo `json:"agent"`
Host HostInfo `json:"host"`
Network NetworkInfo `json:"network"`
CPU CPUInfo `json:"cpu"`
Memory MemoryInfo `json:"memory"`
Disks []DiskInfo `json:"disks"`
Processes ProcessResult `json:"processes"`
TCPListeners TCPResult `json:"tcp_listeners"`
// Errors records collector-level failures that caused a partial snapshot.
Errors []StatusError `json:"errors"`
}
// AgentInfo contains agent process metadata and the latest collection result.
type AgentInfo struct {
Version string `json:"version"`
StartedAt time.Time `json:"started_at"`
SnapshotAt time.Time `json:"snapshot_at"`
// SnapshotStatus is ok when all collectors succeed and partial when one or more fail.
SnapshotStatus string `json:"snapshot_status"`
}
// HostInfo describes the monitored host identity and operating system.
type HostInfo struct {
HostID string `json:"host_id"`
Hostname string `json:"hostname"`
Platform string `json:"platform"`
PlatformVersion string `json:"platform_version"`
Arch string `json:"arch"`
// UptimeSeconds is the host uptime in seconds when available.
UptimeSeconds uint64 `json:"uptime_seconds"`
}
// NetworkInfo contains all network interfaces plus flattened IPv4, IPv6, and MAC lists.
type NetworkInfo struct {
Interfaces []NetworkInterface `json:"interfaces"`
IPv4s []string `json:"ipv4s"`
IPv6s []string `json:"ipv6s"`
MACs []string `json:"macs"`
}
// NetworkInterface describes one host network interface and its configured addresses.
type NetworkInterface struct {
Name string `json:"name"`
MAC string `json:"mac"`
// MTU is the interface maximum transmission unit in bytes.
MTU int `json:"mtu"`
Flags []string `json:"flags"`
Addresses []string `json:"addresses"`
}
// CPUInfo describes host CPU capacity and current utilization.
type CPUInfo struct {
LogicalCores int `json:"logical_cores"`
PhysicalCores int `json:"physical_cores"`
// UsagePercent is the best-effort host CPU usage percentage for the latest collection.
UsagePercent float64 `json:"usage_percent"`
LoadAvg []float64 `json:"load_avg"`
ModelName string `json:"model_name"`
}
// MemoryInfo describes host memory usage capacity fields in the declared unit.
type MemoryInfo struct {
// Unit declares the unit used by total, used, free, and available.
Unit string `json:"unit"`
// Total is total physical memory in the declared unit.
Total uint64 `json:"total"`
// Used is memory currently used in the declared unit.
Used uint64 `json:"used"`
// Free is memory currently free in the declared unit.
Free uint64 `json:"free"`
// Available is memory available to new workloads in the declared unit.
Available uint64 `json:"available"`
// UsedPercent is the percentage of total memory currently used.
UsedPercent float64 `json:"used_percent"`
}
// DiskInfo describes one physical or network mounted filesystem and its capacity.
type DiskInfo struct {
Device string `json:"device"`
MountPoint string `json:"mount_point"`
FSType string `json:"fs_type"`
// Unit declares the unit used by total, used, and free.
Unit string `json:"unit"`
// Total is filesystem capacity in the declared unit.
Total uint64 `json:"total"`
// Used is consumed filesystem capacity in the declared unit.
Used uint64 `json:"used"`
// Free is available filesystem capacity in the declared unit.
Free uint64 `json:"free"`
// UsedPercent is the percentage of total filesystem capacity currently used.
UsedPercent float64 `json:"used_percent"`
}
// ProcessResult contains process collection status and the collected process list.
type ProcessResult struct {
// Status is ok, partial, or error depending on process collection completeness.
Status string `json:"status"`
Items []ProcessInfo `json:"items"`
}
// ProcessInfo describes one operating system process.
type ProcessInfo struct {
PID int `json:"pid"`
PPID int `json:"ppid"`
Name string `json:"name"`
Exe string `json:"exe"`
Cmdline string `json:"cmdline"`
Username string `json:"username"`
Status string `json:"status"`
// StartTime is the platform-specific process start timestamp when available.
StartTime int64 `json:"start_time"`
// CPUPercent is the best-effort process CPU usage percentage.
CPUPercent float64 `json:"cpu_percent"`
// MemoryRSS is resident memory size in bytes.
MemoryRSS uint64 `json:"memory_rss"`
// MemoryVMS is virtual memory size in bytes.
MemoryVMS uint64 `json:"memory_vms"`
}
// TCPResult contains TCP listener collection status and listener entries.
type TCPResult struct {
// Status is ok, partial, or error depending on listener mapping completeness.
Status string `json:"status"`
Items []TCPListenerInfo `json:"items"`
}
// TCPListenerInfo describes one listening TCP socket and its owning process when known.
type TCPListenerInfo struct {
IP string `json:"ip"`
Port int `json:"port"`
PID int `json:"pid"`
ProcessName string `json:"process_name"`
ProcessExe string `json:"process_exe"`
Family string `json:"family"`
}
// StatusError records a collector failure without failing the whole snapshot.
type StatusError struct {
Collector string `json:"collector"`
Message string `json:"message"`
}