💥 feat(agent)!: 添加主机监控代理首版
* 实现周期采集、快照缓存、本地 HTTP API 和主动上报 * 补齐 Linux 和 Windows 主机、网络、资源、进程和 TCP 采集 * 添加服务生命周期管理、配置加载、构建脚本和测试覆盖 BREAKING CHANGE: network.ips 拆分为 network.ipv4s 和 network.ipv6s。
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.exe
|
||||
*.test
|
||||
|
||||
# Go temporary files
|
||||
*.out
|
||||
coverage.out
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
*.local
|
||||
|
||||
# Editor and OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
115
AGENTS.md
Normal file
115
AGENTS.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 仓库规则
|
||||
|
||||
## 项目要求
|
||||
|
||||
### 项目简介
|
||||
|
||||
- MFD Monitor Agent 是一个使用 Go 实现的单二进制主机状态监控 agent。
|
||||
- agent 需要支持 Windows 和 Linux,用于采集并暴露完整主机状态。
|
||||
- agent 默认通过本地 HTTP API 提供查询能力,默认监听 `127.0.0.1:8080`。
|
||||
- `GET /health` 用于返回进程存活状态、版本信息、最近快照时间和最近采集状态。
|
||||
- `GET /status` 用于返回缓存中的完整 `HostStatus` 主机状态快照。
|
||||
- agent 通过后台周期采集刷新内存缓存,HTTP 接口和主动上报都读取同一份缓存。
|
||||
- agent 支持可选的周期性 HTTP POST 主动上报,上报数据必须复用 `/status` 的 `HostStatus` JSON 结构。
|
||||
- agent 需要通过 CLI 管理服务生命周期,包括 `run`、`install`、`uninstall`、`start`、`stop` 和 `version`。
|
||||
- Linux 侧面向 systemd service;Windows 侧面向 Windows Service。
|
||||
|
||||
### 权限要求
|
||||
|
||||
- 安装阶段必须要求管理员/root 权限。
|
||||
- 首版运行阶段默认使用高权限服务账户,以稳定采集完整进程列表和 TCP 监听端口到进程的映射。
|
||||
- 接口层必须支持部分成功:某些字段或子采集器失败时,不应导致整个 `/status` 失败,应返回 `partial` 或具体错误原因。
|
||||
- 默认监听地址必须尽量收敛到本地地址 `127.0.0.1`,避免默认暴露敏感主机信息。
|
||||
|
||||
### 架构要求
|
||||
|
||||
- 使用单进程一体化架构。
|
||||
- 一个 agent 进程同时负责周期采集、快照缓存、HTTP 查询、主动上报,以及 Windows/Linux 服务生命周期管理。
|
||||
- 代码应按职责拆分模块:
|
||||
- `collector/system`:主机名、OS、架构、启动时间、采集时间。
|
||||
- `collector/network`:网卡、IPv4/IPv6、MAC。
|
||||
- `collector/resource`:CPU、内存、磁盘。
|
||||
- `collector/process`:完整进程列表。
|
||||
- `collector/socket`:TCP 监听端口及其进程映射。
|
||||
- `snapshot`:聚合各采集器输出,形成统一 `HostStatus`。
|
||||
- `httpapi`:提供 `GET /health` 和 `GET /status`。
|
||||
- `reporter`:定时 POST `HostStatus` 到中心端。
|
||||
- `service`:封装 Windows/Linux 服务生命周期和安装命令。
|
||||
- `config`:统一加载监听地址、采集周期、上报周期、上报目标、机器标识等配置。
|
||||
|
||||
### HostStatus 要求
|
||||
|
||||
- `HostStatus` 应使用统一 JSON 结构,至少覆盖以下信息:
|
||||
- `agent`:版本、启动时间、快照时间、快照状态。
|
||||
- `host`:机器标识、主机名、平台、平台版本、架构、运行时长。
|
||||
- `network`:网卡、IP、MAC、MTU、flags、地址列表。
|
||||
- `cpu`:逻辑核心数、物理核心数、使用率、负载、型号。
|
||||
- `memory`:总量、已用、空闲、可用、使用率。
|
||||
- `disks`:设备、挂载点、文件系统类型、总量、已用、空闲、使用率。
|
||||
- `processes`:状态和完整进程列表,包含 pid、ppid、名称、exe、cmdline、用户、状态、启动时间、CPU 和内存占用。
|
||||
- `tcp_listeners`:状态和 TCP 监听列表,包含 IP、端口、pid、进程名、进程路径、地址族。
|
||||
- `errors`:记录子采集器失败原因。
|
||||
- `processes.status` 和 `tcp_listeners.status` 必须支持 `ok`、`partial`、`error`。
|
||||
|
||||
### 采集要求
|
||||
|
||||
- 必须采用“周期采集 + 内存缓存”策略。
|
||||
- agent 启动后应立即采集一次,之后后台定时刷新缓存。
|
||||
- HTTP 接口不得在每次请求时实时扫描系统,应直接读取最近一次缓存。
|
||||
- 主动上报必须读取同一份缓存,避免重复采集。
|
||||
- 采集逻辑需要设置整体采集超时。
|
||||
- 单个子采集器失败不能拖垮整次快照。
|
||||
- 例如进程采集失败时,仍应返回 CPU、内存、磁盘等其他信息;TCP 监听采集不完整时,应将对应状态标记为 `partial`。
|
||||
|
||||
### 主动上报要求
|
||||
|
||||
- agent 除支持外部系统通过 HTTP 拉取状态外,还必须支持定时主动上报。
|
||||
- 上报目标使用配置中的固定中心服务 URL。
|
||||
- 上报方式为周期性 POST `HostStatus` JSON。
|
||||
- 配置项应包含 `report.enabled`、`report.endpoint`、`report.interval`、`report.timeout`。
|
||||
- 上报失败和超时应独立记录日志。
|
||||
- 上报失败可支持重试或简单退避,但不能影响本地 HTTP 服务,也不能导致进程退出。
|
||||
|
||||
### CLI 和服务要求
|
||||
|
||||
- CLI 至少提供以下命令:
|
||||
- `agent run`
|
||||
- `agent install`
|
||||
- `agent uninstall`
|
||||
- `agent start`
|
||||
- `agent stop`
|
||||
- `agent version`
|
||||
- Linux 下,agent 进程以前台模式运行,并额外提供 systemd unit 模板,由安装流程写入 unit 并启停。
|
||||
- Windows 下,使用服务包装库实现 install、uninstall、start、stop、run,不依赖外部 NSSM。
|
||||
|
||||
### 技术和测试要求
|
||||
|
||||
- 优先使用 Go 实现,保持单二进制部署。
|
||||
- 系统信息采集优先使用成熟跨平台库,例如 `gopsutil`。
|
||||
- 如果跨平台库在端口到进程映射能力上不足,可以针对 Windows/Linux 做补充实现。
|
||||
- 单元测试应覆盖采集器到 `HostStatus` 的映射、空结果、字段缺失、权限错误、`partial/error` 状态和 snapshot 聚合逻辑。
|
||||
- 集成测试应覆盖 `GET /health`、`GET /status`、主动 POST 上报载荷、超时和失败重试行为。
|
||||
- 平台验证应覆盖 Windows 服务安装/启动/停止/卸载,以及 Linux systemd 安装、enable、start、restart。
|
||||
- 性能与稳定性验证应覆盖大量进程场景、采集周期与 HTTP 并发访问并存、较大 `/status` 响应体的内存占用。
|
||||
|
||||
### 首版边界
|
||||
|
||||
- 首版默认包含 IP、MAC、主机名、CPU、内存、磁盘、完整进程列表、TCP 监听端口及进程映射、本地 HTTP 查询、主动 HTTP 上报、Windows/Linux 服务化。
|
||||
- 首版默认不包含历史存储、趋势分析、分页查询、UDP 监听信息、线程列表、进程句柄、连接状态详情、强制 Token/mTLS。
|
||||
|
||||
## 注释规则
|
||||
|
||||
- 所有导出和内部的方法、函数、类结构、结构体、接口都必须编写清晰注释。
|
||||
- Go 代码中,每个包级函数、方法、类型、结构体和接口都需要使用简短注释说明其用途和行为。
|
||||
- 结构体注释需要说明该结构体代表什么。字段不够直观、包含单位、涉及安全影响,或会影响外部 JSON/API 行为时,需要为字段补充注释。
|
||||
- 方法和函数注释需要说明其职责、重要输入输出、副作用,以及相关错误行为。
|
||||
- 关键实现步骤需要添加简洁的行内注释说明逻辑,尤其是操作系统相关采集、权限敏感操作、缓存、上报、服务生命周期处理和错误降级路径。
|
||||
- 注释应解释代码为什么存在,或说明不直观逻辑如何工作。避免只重复代码本身含义的注释。
|
||||
- 修改代码行为时必须同步更新注释。不要留下过期注释、缺少上下文的 TODO,或容易误导的说明。
|
||||
|
||||
## 项目风格
|
||||
|
||||
- 优先使用直接、清晰的 Go 代码,并保持与现有单二进制 agent 结构一致。
|
||||
- 代码变更应聚焦在请求的行为范围内。
|
||||
- 保留采集器的部分成功行为:某个子系统失败时,应报告该子系统自身错误,而不是导致整个状态响应失败。
|
||||
- Linux 和 Windows 行为不同的代码路径,需要使用清晰命名和注释说明差异。
|
||||
79
README.md
Normal file
79
README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# MFD Monitor Agent
|
||||
|
||||
Single-binary host status agent implemented in Go.
|
||||
|
||||
## Features
|
||||
|
||||
- `GET /health` returns process liveness and latest snapshot metadata.
|
||||
- `GET /status` returns the cached full host snapshot.
|
||||
- Periodic background collection keeps HTTP responses fast.
|
||||
- Optional periodic HTTP POST reporting reuses the same `HostStatus` JSON.
|
||||
- Linux collectors read host, network, CPU, memory, disk, process, and TCP listener data from the OS.
|
||||
- Windows and Linux service lifecycle commands are exposed through the CLI.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
agent run [-config config.json]
|
||||
agent install [-config config.json] [-exe /path/to/agent]
|
||||
agent uninstall [-config config.json]
|
||||
agent start [-config config.json]
|
||||
agent stop [-config config.json]
|
||||
agent version
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
./scripts/build-linux.sh
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
.\scripts\build-windows.ps1
|
||||
```
|
||||
|
||||
Cross-compile Windows from Linux:
|
||||
|
||||
```bash
|
||||
GOOS_TARGET=windows GOARCH_TARGET=amd64 OUTPUT_NAME=agent-windows-amd64.exe ./scripts/build-linux.sh
|
||||
```
|
||||
|
||||
Build outputs are written to `dist/`, which is intentionally ignored by Git.
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings have safe defaults. The HTTP API listens on `127.0.0.1:8080` by default.
|
||||
|
||||
```json
|
||||
{
|
||||
"host_id": "host-a",
|
||||
"http": {
|
||||
"listen_address": "127.0.0.1:8080"
|
||||
},
|
||||
"collect": {
|
||||
"interval": "30s",
|
||||
"timeout": "10s"
|
||||
},
|
||||
"report": {
|
||||
"enabled": true,
|
||||
"endpoint": "http://center.example.internal/agent/status",
|
||||
"interval": "60s",
|
||||
"timeout": "5s"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment overrides:
|
||||
|
||||
- `MFD_AGENT_HOST_ID`
|
||||
- `MFD_AGENT_LISTEN`
|
||||
- `MFD_AGENT_COLLECT_INTERVAL`
|
||||
- `MFD_AGENT_COLLECT_TIMEOUT`
|
||||
- `MFD_AGENT_REPORT_ENABLED`
|
||||
- `MFD_AGENT_REPORT_ENDPOINT`
|
||||
- `MFD_AGENT_REPORT_INTERVAL`
|
||||
- `MFD_AGENT_REPORT_TIMEOUT`
|
||||
137
cmd/agent/main.go
Normal file
137
cmd/agent/main.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"mfd-monitor-agent/internal/collector"
|
||||
"mfd-monitor-agent/internal/config"
|
||||
"mfd-monitor-agent/internal/httpapi"
|
||||
"mfd-monitor-agent/internal/reporter"
|
||||
"mfd-monitor-agent/internal/service"
|
||||
"mfd-monitor-agent/internal/snapshot"
|
||||
)
|
||||
|
||||
const version = "0.1.0"
|
||||
|
||||
// main runs the CLI and exits non-zero when a command fails.
|
||||
func main() {
|
||||
if err := runCLI(os.Args[1:]); err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// runCLI dispatches agent subcommands and loads config before invoking command handlers.
|
||||
func runCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
args = []string{"run"}
|
||||
}
|
||||
switch args[0] {
|
||||
case "run":
|
||||
fs := flag.NewFlagSet("run", flag.ExitOnError)
|
||||
configPath := fs.String("config", "", "path to JSON config file")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runAgent(cfg)
|
||||
case "install":
|
||||
cfg, executable, configPath, err := commandConfig(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return service.NewManager().Install(executable, cfg, service.InstallOptions{ConfigPath: configPath})
|
||||
case "uninstall":
|
||||
cfg, _, _, err := commandConfig(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return service.NewManager().Uninstall(cfg)
|
||||
case "start":
|
||||
cfg, _, _, err := commandConfig(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return service.NewManager().Start(cfg)
|
||||
case "stop":
|
||||
cfg, _, _, err := commandConfig(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return service.NewManager().Stop(cfg)
|
||||
case "version":
|
||||
fmt.Println(version)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q; expected run, install, uninstall, start, stop, version", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
// commandConfig parses service command flags and resolves the executable path.
|
||||
func commandConfig(args []string) (config.Config, string, string, error) {
|
||||
fs := flag.NewFlagSet("service", flag.ExitOnError)
|
||||
configPath := fs.String("config", "", "path to JSON config file")
|
||||
executableFlag := fs.String("exe", "", "path to agent executable")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return config.Config{}, "", "", err
|
||||
}
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
return cfg, "", "", err
|
||||
}
|
||||
executable := *executableFlag
|
||||
if executable == "" {
|
||||
executable, err = os.Executable()
|
||||
if err != nil {
|
||||
return cfg, "", "", err
|
||||
}
|
||||
}
|
||||
return cfg, executable, *configPath, nil
|
||||
}
|
||||
|
||||
// runAgent starts collection, optional reporting, and the local HTTP API until signaled.
|
||||
func runAgent(cfg config.Config) error {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
startedAt := time.Now().UTC()
|
||||
cache := snapshot.NewCache(collector.New(cfg.HostID), version, startedAt)
|
||||
go cache.Run(ctx, cfg.Collect.Interval.Duration(), cfg.Collect.Timeout.Duration())
|
||||
|
||||
if cfg.Report.Enabled {
|
||||
client := reporter.NewClient(cfg.Report.Endpoint, cfg.Report.Timeout.Duration())
|
||||
go reporter.Run(ctx, cache, client, cfg.Report.Interval.Duration(), logger)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTP.ListenAddress,
|
||||
Handler: httpapi.New(cache),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
logger.Info("agent listening", "address", cfg.HTTP.ListenAddress)
|
||||
err := server.ListenAndServe()
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
# MFD Monitor Agent Completion Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** Complete the host monitoring agent behavior required by `AGENTS.md` while preserving the current single-binary, cached-snapshot architecture.
|
||||
|
||||
**Architecture:** Keep the current packages and add test seams only where needed. Linux collectors are completed with parser-level tests, service installation becomes testable through unit rendering and command injection, reporter retry/backoff stays isolated from HTTP and collection, and Windows service behavior remains build-tagged.
|
||||
|
||||
**Tech Stack:** Go 1.22, standard library HTTP/config/process APIs, Linux `/proc`, systemd, Windows Service build tags, `go test`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `cmd/agent/main.go`: preserve config path for service installation and add comments.
|
||||
- Modify `internal/status/types.go`: add required comments for API-visible JSON model fields and types.
|
||||
- Modify `internal/config/config.go`: add comments and preserve existing defaults/env behavior.
|
||||
- Modify `internal/snapshot/snapshot.go`: add comments and tests for timeout/partial behavior.
|
||||
- Modify `internal/httpapi/server.go`: add comments and tests for not-ready/method behavior.
|
||||
- Modify `internal/reporter/reporter.go`: add retry/backoff and tests.
|
||||
- Modify `internal/collector/collector.go`: add comments.
|
||||
- Modify `internal/collector/collector_linux.go`: complete Linux CPU/process/TCP partial behavior and add parser seams.
|
||||
- Modify `internal/collector/collector_other.go`: add comments and explicit unsupported behavior.
|
||||
- Create `internal/collector/collector_linux_test.go`: parser and degradation tests.
|
||||
- Modify `internal/service/service.go`: add comments and extend install options if needed.
|
||||
- Modify `internal/service/service_linux.go`: root guard, systemd unit rendering, config path propagation, command seams.
|
||||
- Create `internal/service/service_linux_test.go`: root guard and unit rendering tests.
|
||||
- Modify `internal/service/service_windows.go`: document Windows service behavior and improve no-NSSM path where compile-testable.
|
||||
- Modify `internal/service/service_other.go`: comments.
|
||||
|
||||
## Task 1: Comment Coverage Baseline
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/status/types.go`
|
||||
- Modify: `internal/config/config.go`
|
||||
- Modify: `internal/snapshot/snapshot.go`
|
||||
- Modify: `internal/httpapi/server.go`
|
||||
- Modify: `internal/reporter/reporter.go`
|
||||
- Modify: `internal/collector/collector.go`
|
||||
- Modify: `internal/collector/collector_linux.go`
|
||||
- Modify: `internal/collector/collector_other.go`
|
||||
- Modify: `internal/service/service.go`
|
||||
- Modify: `internal/service/service_linux.go`
|
||||
- Modify: `internal/service/service_windows.go`
|
||||
- Modify: `internal/service/service_other.go`
|
||||
|
||||
- [x] **Step 1: Run comment lint proxy**
|
||||
|
||||
Run: `go test ./...`
|
||||
|
||||
Expected: PASS. This is the behavioral baseline before comments.
|
||||
|
||||
- [x] **Step 2: Add comments**
|
||||
|
||||
Add concise comments above every package-level type, struct, interface, function, and method. For API structs in `status`, include JSON/API meaning and units where relevant, for example:
|
||||
|
||||
```go
|
||||
// HostStatus is the full cached host snapshot returned by /status and posted by the reporter.
|
||||
type HostStatus struct {
|
||||
// Agent contains agent process metadata and the latest snapshot status.
|
||||
Agent AgentInfo `json:"agent"`
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 3: Verify comments did not change behavior**
|
||||
|
||||
Run: `go test ./...`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 2: HTTP Cache Edge Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/httpapi/server_test.go`
|
||||
- Modify: `internal/httpapi/server.go`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
Add tests named:
|
||||
|
||||
```go
|
||||
func TestStatusReturnsServiceUnavailableBeforeSnapshot(t *testing.T) {}
|
||||
func TestHealthReturnsStartingBeforeSnapshot(t *testing.T) {}
|
||||
func TestStatusRejectsNonGET(t *testing.T) {}
|
||||
func TestHealthRejectsNonGET(t *testing.T) {}
|
||||
```
|
||||
|
||||
Assert `/status` returns `503` when `StaticProvider{OK:false}`, `/health` returns JSON status `starting`, and POST requests return `405`.
|
||||
|
||||
- [x] **Step 2: Verify red or existing green**
|
||||
|
||||
Run: `go test ./internal/httpapi -run 'Test(StatusReturnsServiceUnavailableBeforeSnapshot|HealthReturnsStartingBeforeSnapshot|StatusRejectsNonGET|HealthRejectsNonGET)'`
|
||||
|
||||
Expected: PASS if behavior already exists, otherwise FAIL with the missing behavior.
|
||||
|
||||
- [x] **Step 3: Implement only missing behavior**
|
||||
|
||||
Keep handler behavior cache-only. Do not add live collection.
|
||||
|
||||
- [x] **Step 4: Verify**
|
||||
|
||||
Run: `go test ./internal/httpapi`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 3: Reporter Retry And Cancellation
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/reporter/reporter.go`
|
||||
- Modify: `internal/reporter/reporter_test.go`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
Add tests named:
|
||||
|
||||
```go
|
||||
func TestReportReturnsErrorForHTTPFailure(t *testing.T) {}
|
||||
func TestRunRetriesFailedReportOnce(t *testing.T) {}
|
||||
func TestRunStopsDuringRetryDelayWhenContextCanceled(t *testing.T) {}
|
||||
```
|
||||
|
||||
Use `httptest.Server` and a short interval/delay. Count requests with an atomic counter. First request returns `500`, second returns `202`.
|
||||
|
||||
- [x] **Step 2: Verify red**
|
||||
|
||||
Run: `go test ./internal/reporter -run 'Test(ReportReturnsErrorForHTTPFailure|RunRetriesFailedReportOnce|RunStopsDuringRetryDelayWhenContextCanceled)'`
|
||||
|
||||
Expected: retry tests FAIL because `Run` does not retry yet.
|
||||
|
||||
- [x] **Step 3: Implement minimal retry**
|
||||
|
||||
Add a retry delay parameter through an internal helper while preserving the public `Run` signature:
|
||||
|
||||
```go
|
||||
const defaultRetryDelay = 2 * time.Second
|
||||
|
||||
func Run(ctx context.Context, provider Provider, client *Client, interval time.Duration, logger *slog.Logger) {
|
||||
runWithRetryDelay(ctx, provider, client, interval, defaultRetryDelay, logger)
|
||||
}
|
||||
```
|
||||
|
||||
In `runWithRetryDelay`, after a failed report, wait for `retryDelay` or context cancellation, then retry once.
|
||||
|
||||
- [x] **Step 4: Verify**
|
||||
|
||||
Run: `go test ./internal/reporter`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 4: Linux Collector Parser Coverage
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/collector/collector_linux_test.go`
|
||||
- Modify: `internal/collector/collector_linux.go`
|
||||
|
||||
- [x] **Step 1: Write parser tests**
|
||||
|
||||
Add tests named:
|
||||
|
||||
```go
|
||||
func TestParseAddressIPv4(t *testing.T) {}
|
||||
func TestParseAddressIPv6(t *testing.T) {}
|
||||
func TestParseProcessStatStartTime(t *testing.T) {}
|
||||
func TestCPUUsageFromProcStatSamples(t *testing.T) {}
|
||||
```
|
||||
|
||||
Use sample strings from `/proc/net/tcp`, `/proc/net/tcp6`, `/proc/<pid>/stat`, and `/proc/stat`. Expected IPv4 sample `0100007F:1F90` maps to `127.0.0.1:8080`.
|
||||
|
||||
- [x] **Step 2: Verify red**
|
||||
|
||||
Run: `go test ./internal/collector -run 'Test(ParseAddress|ParseProcessStatStartTime|CPUUsageFromProcStatSamples)'`
|
||||
|
||||
Expected: new process stat and CPU usage parser tests FAIL until helpers exist.
|
||||
|
||||
- [x] **Step 3: Implement parser helpers**
|
||||
|
||||
Add helpers:
|
||||
|
||||
```go
|
||||
func parseProcessStatStartTime(raw string) (int64, error)
|
||||
func parseCPUTimes(raw string) (idle uint64, total uint64, err error)
|
||||
func cpuUsagePercent(prevIdle, prevTotal, nextIdle, nextTotal uint64) float64
|
||||
```
|
||||
|
||||
Keep helpers unexported and deterministic.
|
||||
|
||||
- [x] **Step 4: Verify**
|
||||
|
||||
Run: `go test ./internal/collector`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 5: Linux Collection Fields
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/collector/collector_linux.go`
|
||||
- Modify: `internal/collector/collector_linux_test.go`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
Add tests for reading process start time from a temporary stat file through a helper:
|
||||
|
||||
```go
|
||||
func TestReadProcessStatReturnsStartTime(t *testing.T) {}
|
||||
```
|
||||
|
||||
Add a CPU usage test around the helper rather than sleeping on real `/proc`.
|
||||
|
||||
- [x] **Step 2: Verify red**
|
||||
|
||||
Run: `go test ./internal/collector -run 'Test(ReadProcessStatReturnsStartTime|CPUUsage)'`
|
||||
|
||||
Expected: FAIL until `readProcess` and `collectResource` use the helpers.
|
||||
|
||||
- [x] **Step 3: Implement collection**
|
||||
|
||||
Update `readProcess` to set `StartTime`. Update `collectResource` to set `CPU.UsagePercent` through a bounded sample that respects context:
|
||||
|
||||
```go
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: Verify**
|
||||
|
||||
Run: `go test ./internal/collector`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 6: TCP Listener Partial Mapping
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/collector/collector_linux.go`
|
||||
- Modify: `internal/collector/collector_linux_test.go`
|
||||
|
||||
- [x] **Step 1: Write failing test**
|
||||
|
||||
Add:
|
||||
|
||||
```go
|
||||
func TestCollectSocketsMarksPartialWhenProcessFDsUnreadable(t *testing.T) {}
|
||||
```
|
||||
|
||||
Use a helper seam around socket inode walking so the test can simulate an unreadable fd directory.
|
||||
|
||||
- [x] **Step 2: Verify red**
|
||||
|
||||
Run: `go test ./internal/collector -run TestCollectSocketsMarksPartialWhenProcessFDsUnreadable`
|
||||
|
||||
Expected: FAIL because unreadable fd directories are currently ignored.
|
||||
|
||||
- [x] **Step 3: Implement partial error propagation**
|
||||
|
||||
Make inode collection return `(map[string]status.ProcessInfo, error)`. Keep best-effort mappings, but return an error count when pid fd directories cannot be read. `collectSockets` sets `tcp_listeners.status` to `partial` and returns the error while preserving listener items.
|
||||
|
||||
- [x] **Step 4: Verify**
|
||||
|
||||
Run: `go test ./internal/collector`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 7: Linux Service Install Guard And Unit Rendering
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/agent/main.go`
|
||||
- Modify: `internal/service/service.go`
|
||||
- Modify: `internal/service/service_linux.go`
|
||||
- Create: `internal/service/service_linux_test.go`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
Add:
|
||||
|
||||
```go
|
||||
func TestRenderSystemdUnitIncludesConfigPath(t *testing.T) {}
|
||||
func TestLinuxInstallRequiresRoot(t *testing.T) {}
|
||||
```
|
||||
|
||||
Assert the rendered unit includes `ExecStart=/opt/agent run -config /etc/mfd-agent.json` when a config path is supplied.
|
||||
|
||||
- [x] **Step 2: Verify red**
|
||||
|
||||
Run: `go test ./internal/service -run 'Test(RenderSystemdUnitIncludesConfigPath|LinuxInstallRequiresRoot)'`
|
||||
|
||||
Expected: FAIL because config path and root guard seams do not exist.
|
||||
|
||||
- [x] **Step 3: Implement install options**
|
||||
|
||||
Add a small options struct or extend `Install` input so `cmd/agent` can pass the config path. Add root guard through an injectable `currentUID` function and render the unit through a pure helper:
|
||||
|
||||
```go
|
||||
func renderSystemdUnit(executable string, configPath string, cfg config.Config) string
|
||||
```
|
||||
|
||||
- [x] **Step 4: Verify**
|
||||
|
||||
Run: `go test ./internal/service`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 8: Snapshot Timeout And Partial Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/snapshot/snapshot_test.go`
|
||||
- Modify: `internal/snapshot/snapshot.go`
|
||||
|
||||
- [x] **Step 1: Write tests**
|
||||
|
||||
Add:
|
||||
|
||||
```go
|
||||
func TestRunCollectsImmediately(t *testing.T) {}
|
||||
func TestCollectWithTimeoutMarksCanceledCollectorPartial(t *testing.T) {}
|
||||
```
|
||||
|
||||
Use a collector that waits on `ctx.Done()` and returns `ctx.Err()`.
|
||||
|
||||
- [x] **Step 2: Verify**
|
||||
|
||||
Run: `go test ./internal/snapshot`
|
||||
|
||||
Expected: PASS if current timeout behavior already satisfies this; otherwise FAIL and then implement the minimal fix.
|
||||
|
||||
## Task 9: Windows Build-Tagged Service Contracts
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/service/service_windows.go`
|
||||
- Optional create: `internal/service/service_windows_test.go`
|
||||
|
||||
- [x] **Step 1: Add comments and compile-safe structure**
|
||||
|
||||
Ensure Windows service code is build-tagged, documented, and does not reference Linux-only APIs.
|
||||
|
||||
- [x] **Step 2: Compile-check Windows**
|
||||
|
||||
Run: `GOOS=windows GOARCH=amd64 go test ./...`
|
||||
|
||||
Expected: PASS. If network/dependency constraints block a wrapper dependency, document the exact failure and keep Windows behavior explicit.
|
||||
|
||||
## Task 10: Final Verification And Audit
|
||||
|
||||
**Files:**
|
||||
- Inspect: `AGENTS.md`
|
||||
- Inspect: all modified Go files
|
||||
|
||||
- [x] **Step 1: Run full tests**
|
||||
|
||||
Run: `go test ./...`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 2: Run Windows compile-check**
|
||||
|
||||
Run: `GOOS=windows GOARCH=amd64 go test ./...`
|
||||
|
||||
Expected: PASS or documented blocker.
|
||||
|
||||
- [x] **Step 3: Audit comments**
|
||||
|
||||
Run: `grep -R "^func \\|^type " -n cmd internal`
|
||||
|
||||
Expected: every listed package-level function/type has a preceding useful comment.
|
||||
|
||||
- [x] **Step 4: Completion checklist**
|
||||
|
||||
Map each explicit AGENTS.md requirement to implementation evidence, test evidence, or a documented first-version limitation.
|
||||
|
||||
## Git Note
|
||||
|
||||
This directory is not currently a Git repository, so plan steps cannot include real commits. Use the test checkpoints above as completion gates.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# MFD Monitor Agent Completion Design
|
||||
|
||||
## Objective
|
||||
|
||||
Bring the current Go single-binary host monitoring agent into alignment with `AGENTS.md`: complete missing host status behavior, preserve cached snapshots, support Linux and Windows service lifecycle, add reporter retry/backoff, improve partial-success handling, and raise comments/tests to the repository standard.
|
||||
|
||||
## Current Gaps
|
||||
|
||||
- Package-level Go types, functions, methods, and interfaces are mostly undocumented.
|
||||
- Linux collection misses CPU usage percent, process start time, and process CPU percent.
|
||||
- TCP listener mapping does not clearly report permission-related partial mappings.
|
||||
- Windows collector and service runtime are incomplete; current service install only shells out to `sc.exe`.
|
||||
- Linux service install does not explicitly require root and does not preserve `-config` in the systemd unit.
|
||||
- Reporter logs failures but has no retry or bounded backoff.
|
||||
- Tests do not cover enough collector parsing, partial/error statuses, service guards, reporter retry, or HTTP edge cases.
|
||||
|
||||
## Architecture
|
||||
|
||||
Keep the existing single-process architecture and package boundaries:
|
||||
|
||||
- `cmd/agent`: CLI and process lifecycle.
|
||||
- `config`: defaults, JSON file loading, environment overrides.
|
||||
- `collector`: platform collection into `status.HostStatus`.
|
||||
- `snapshot`: collector aggregation and in-memory cache.
|
||||
- `httpapi`: `GET /health` and `GET /status`, cache reads only.
|
||||
- `reporter`: periodic POST of the cached `HostStatus`.
|
||||
- `service`: Windows/Linux service install, uninstall, start, and stop.
|
||||
|
||||
No historical storage, pagination, auth, UDP collection, thread lists, process handles, or trend analysis are included.
|
||||
|
||||
## Behavior Design
|
||||
|
||||
Collection remains immediate-on-start and periodic afterward. HTTP and reporter paths both read the same cached snapshot and never trigger a live scan.
|
||||
|
||||
Each collector writes only its own section. If one collector fails, `snapshot.Cache` records a `StatusError`, marks `agent.snapshot_status` as `partial`, and keeps successful sections.
|
||||
|
||||
Linux should be the first complete collection implementation:
|
||||
|
||||
- CPU usage percent from `/proc/stat` with a bounded sample and context cancellation.
|
||||
- Process start time from `/proc/<pid>/stat`.
|
||||
- Process CPU percent best-effort; if exact calculation is unavailable, keep the field stable and do not fail the process collector.
|
||||
- TCP listeners from `/proc/net/tcp`, `/proc/net/tcp6`, and `/proc/<pid>/fd`; unreadable process fd data should degrade listener status to `partial`.
|
||||
|
||||
Windows should expose a real service runtime path without NSSM. If full Windows metric collection would require a larger dependency decision, platform files should keep unsupported subsections explicit with `partial`/`error` statuses and tests around those contracts.
|
||||
|
||||
## Service Design
|
||||
|
||||
Linux install should require root before writing `/etc/systemd/system`, render a systemd unit for the configured service name, include `agent run`, preserve `-config <path>` when supplied, run `daemon-reload`, and enable the service. Errors should include the failed operation.
|
||||
|
||||
Windows install/start/stop/uninstall should remain behind Windows build tags and avoid NSSM. Administrator requirements should be checked where practical.
|
||||
|
||||
## Reporter Design
|
||||
|
||||
Reporter POST payload remains exactly the `HostStatus` JSON used by `/status`. Failed reports are logged and retried once after a short delay or handled with a simple bounded backoff. Retry/backoff must respect context cancellation and never terminate the process or block the local HTTP server.
|
||||
|
||||
## Comments And Tests
|
||||
|
||||
All Go package-level types, structs, interfaces, functions, and methods need concise comments. Comments should explain behavior, side effects, units, platform assumptions, and permission-sensitive degradation.
|
||||
|
||||
Tests should be written before behavior changes and cover:
|
||||
|
||||
- Linux parser helpers for CPU, memory, process stat/status, and TCP listeners.
|
||||
- Snapshot partial aggregation and timeout behavior.
|
||||
- HTTP not-ready and method-not-allowed behavior.
|
||||
- Reporter HTTP errors, retry/backoff, payload structure, and cancellation.
|
||||
- Linux service unit rendering, root guard, and config path propagation.
|
||||
- Windows build-tagged service contracts where compile-testable.
|
||||
|
||||
## Verification
|
||||
|
||||
Completion requires:
|
||||
|
||||
- `go test ./...` passes.
|
||||
- Current Linux build passes.
|
||||
- `GOOS=windows GOARCH=amd64 go test ./...` is attempted and either passes or any blocker is documented with the exact failure.
|
||||
- A final audit maps each `AGENTS.md` requirement touched by this pass to code, tests, or a documented remaining platform limitation.
|
||||
|
||||
37
internal/collector/collector.go
Normal file
37
internal/collector/collector.go
Normal 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},
|
||||
}
|
||||
}
|
||||
583
internal/collector/collector_linux.go
Normal file
583
internal/collector/collector_linux.go
Normal file
@@ -0,0 +1,583 @@
|
||||
//go:build linux
|
||||
|
||||
package collector
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"mfd-monitor-agent/internal/status"
|
||||
)
|
||||
|
||||
var socketInodesFunc = socketInodes
|
||||
var readTCPListenersFunc = readTCPListeners
|
||||
|
||||
// collectSystem fills Linux host identity, OS, architecture, and uptime fields.
|
||||
func collectSystem(_ context.Context, hostID string, out *status.HostStatus) error {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Host.HostID = hostID
|
||||
out.Host.Hostname = hostname
|
||||
out.Host.Platform = runtime.GOOS
|
||||
out.Host.PlatformVersion = readOSRelease()
|
||||
out.Host.Arch = runtime.GOARCH
|
||||
out.Host.UptimeSeconds = readUptime()
|
||||
if out.Host.HostID == "" {
|
||||
out.Host.HostID = hostname
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectNetwork fills Linux network interface, address, and MAC data.
|
||||
func collectNetwork(_ context.Context, out *status.HostStatus) error {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ipSet := map[string]struct{}{}
|
||||
macSet := map[string]struct{}{}
|
||||
for _, iface := range ifaces {
|
||||
addrs, _ := iface.Addrs()
|
||||
item := status.NetworkInterface{
|
||||
Name: iface.Name,
|
||||
MAC: iface.HardwareAddr.String(),
|
||||
MTU: iface.MTU,
|
||||
Flags: strings.Fields(iface.Flags.String()),
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
value := addr.String()
|
||||
item.Addresses = append(item.Addresses, value)
|
||||
ipSet[value] = struct{}{}
|
||||
}
|
||||
if item.MAC != "" {
|
||||
macSet[item.MAC] = struct{}{}
|
||||
}
|
||||
out.Network.Interfaces = append(out.Network.Interfaces, item)
|
||||
}
|
||||
out.Network.IPv4s, out.Network.IPv6s = networkAddressSets(ipSet)
|
||||
out.Network.MACs = sortedKeys(macSet)
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectResource fills CPU, memory, and disk resource data from Linux proc files.
|
||||
func collectResource(ctx context.Context, out *status.HostStatus) error {
|
||||
out.CPU.LogicalCores = runtime.NumCPU()
|
||||
out.CPU.PhysicalCores, out.CPU.ModelName = readCPUInfo()
|
||||
out.CPU.LoadAvg = readLoadAvg()
|
||||
out.CPU.UsagePercent = readCPUUsagePercent(ctx)
|
||||
out.Memory = readMemory()
|
||||
disks, err := readDisks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Disks = disks
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectProcesses reads /proc process directories and degrades to partial when some entries fail.
|
||||
func collectProcesses(_ context.Context, out *status.HostStatus) error {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
out.Processes.Status = "error"
|
||||
return err
|
||||
}
|
||||
var errs []error
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
process, err := readProcess(pid)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
out.Processes.Items = append(out.Processes.Items, process)
|
||||
}
|
||||
sort.Slice(out.Processes.Items, func(i, j int) bool {
|
||||
return out.Processes.Items[i].PID < out.Processes.Items[j].PID
|
||||
})
|
||||
if len(errs) > 0 {
|
||||
out.Processes.Status = "partial"
|
||||
return fmt.Errorf("%d process entries could not be read", len(errs))
|
||||
}
|
||||
out.Processes.Status = "ok"
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectSockets reads Linux TCP listener tables and maps socket inodes to owning processes.
|
||||
func collectSockets(_ context.Context, out *status.HostStatus) error {
|
||||
inodes, inodeErr := socketInodesFunc()
|
||||
var listeners []status.TCPListenerInfo
|
||||
var errs []error
|
||||
if inodeErr != nil {
|
||||
errs = append(errs, inodeErr)
|
||||
}
|
||||
for _, file := range []struct {
|
||||
path string
|
||||
family string
|
||||
}{
|
||||
{path: "/proc/net/tcp", family: "ipv4"},
|
||||
{path: "/proc/net/tcp6", family: "ipv6"},
|
||||
} {
|
||||
items, err := readTCPListenersFunc(file.path, file.family, inodes)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
listeners = append(listeners, items...)
|
||||
}
|
||||
sort.Slice(listeners, func(i, j int) bool {
|
||||
if listeners[i].IP == listeners[j].IP {
|
||||
return listeners[i].Port < listeners[j].Port
|
||||
}
|
||||
return listeners[i].IP < listeners[j].IP
|
||||
})
|
||||
out.TCPListeners.Items = listeners
|
||||
if len(errs) > 0 {
|
||||
out.TCPListeners.Status = "partial"
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
out.TCPListeners.Status = "ok"
|
||||
return nil
|
||||
}
|
||||
|
||||
// readOSRelease returns the Linux PRETTY_NAME value when /etc/os-release is readable.
|
||||
func readOSRelease() string {
|
||||
file, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "PRETTY_NAME=") {
|
||||
return strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), `"`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// readUptime returns host uptime in seconds from /proc/uptime.
|
||||
func readUptime() uint64 {
|
||||
data, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
value, _ := strconv.ParseFloat(fields[0], 64)
|
||||
return uint64(value)
|
||||
}
|
||||
|
||||
// readCPUInfo returns physical CPU package count and the first model name from /proc/cpuinfo.
|
||||
func readCPUInfo() (int, string) {
|
||||
file, err := os.Open("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return 0, ""
|
||||
}
|
||||
defer file.Close()
|
||||
physical := map[string]struct{}{}
|
||||
model := ""
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
switch key {
|
||||
case "physical id":
|
||||
physical[value] = struct{}{}
|
||||
case "model name":
|
||||
if model == "" {
|
||||
model = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(physical), model
|
||||
}
|
||||
|
||||
// readLoadAvg returns the 1, 5, and 15 minute Linux load averages when available.
|
||||
func readLoadAvg() []float64 {
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
var values []float64
|
||||
for i := 0; i < 3 && i < len(fields); i++ {
|
||||
value, err := strconv.ParseFloat(fields[i], 64)
|
||||
if err == nil {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// readMemory returns Linux memory counters in bytes from /proc/meminfo.
|
||||
func readMemory() status.MemoryInfo {
|
||||
values := map[string]uint64{}
|
||||
file, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return status.MemoryInfo{}
|
||||
}
|
||||
defer file.Close()
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
value, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
// /proc/meminfo reports memory in KiB; the API exposes bytes and
|
||||
// includes memory.unit so clients can render the values explicitly.
|
||||
values[strings.TrimSuffix(fields[0], ":")] = value * 1024
|
||||
}
|
||||
total := values["MemTotal"]
|
||||
free := values["MemFree"]
|
||||
available := values["MemAvailable"]
|
||||
used := total - available
|
||||
percent := 0.0
|
||||
if total > 0 {
|
||||
percent = float64(used) / float64(total) * 100
|
||||
}
|
||||
return status.MemoryInfo{Unit: "byte", Total: total, Used: used, Free: free, Available: available, UsedPercent: percent}
|
||||
}
|
||||
|
||||
// readDisks returns physical and network mounted filesystem usage from /proc/mounts and statfs.
|
||||
func readDisks() ([]status.DiskInfo, error) {
|
||||
file, err := os.Open("/proc/mounts")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
var disks []status.DiskInfo
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(fields[1], &stat); err != nil {
|
||||
continue
|
||||
}
|
||||
total := stat.Blocks * uint64(stat.Bsize)
|
||||
if !shouldIncludeDiskMount(fields[0], fields[2], total) {
|
||||
continue
|
||||
}
|
||||
free := stat.Bavail * uint64(stat.Bsize)
|
||||
used := total - free
|
||||
percent := 0.0
|
||||
if total > 0 {
|
||||
percent = float64(used) / float64(total) * 100
|
||||
}
|
||||
disks = append(disks, status.DiskInfo{
|
||||
Device: fields[0], MountPoint: fields[1], FSType: fields[2],
|
||||
Unit: "byte", Total: total, Used: used, Free: free, UsedPercent: percent,
|
||||
})
|
||||
}
|
||||
return disks, scanner.Err()
|
||||
}
|
||||
|
||||
// shouldIncludeDiskMount reports whether a mount represents a user-relevant disk.
|
||||
func shouldIncludeDiskMount(device string, fsType string, total uint64) bool {
|
||||
if total == 0 {
|
||||
return false
|
||||
}
|
||||
networkFSTypes := map[string]bool{
|
||||
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "sshfs": true,
|
||||
}
|
||||
if networkFSTypes[fsType] {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(device, "/dev/")
|
||||
}
|
||||
|
||||
// readProcess returns one Linux process entry from /proc/<pid>.
|
||||
func readProcess(pid int) (status.ProcessInfo, error) {
|
||||
base := filepath.Join("/proc", strconv.Itoa(pid))
|
||||
statusValues, err := readProcessStatus(filepath.Join(base, "status"))
|
||||
if err != nil {
|
||||
return status.ProcessInfo{}, err
|
||||
}
|
||||
ppid, _ := strconv.Atoi(statusValues["PPid"])
|
||||
rss := parseKB(statusValues["VmRSS"])
|
||||
vms := parseKB(statusValues["VmSize"])
|
||||
username := ""
|
||||
if uid := firstField(statusValues["Uid"]); uid != "" {
|
||||
if u, err := user.LookupId(uid); err == nil {
|
||||
username = u.Username
|
||||
}
|
||||
}
|
||||
cmdline := ""
|
||||
if data, err := os.ReadFile(filepath.Join(base, "cmdline")); err == nil {
|
||||
cmdline = strings.Trim(strings.ReplaceAll(string(data), "\x00", " "), " ")
|
||||
}
|
||||
exe, _ := os.Readlink(filepath.Join(base, "exe"))
|
||||
statValues, _ := readProcessStat(filepath.Join(base, "stat"))
|
||||
return status.ProcessInfo{
|
||||
PID: pid, PPID: ppid, Name: statusValues["Name"], Exe: exe, Cmdline: cmdline,
|
||||
Username: username, Status: statusValues["State"], StartTime: statValues.StartTime,
|
||||
MemoryRSS: rss, MemoryVMS: vms,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// processStat contains process counters parsed from /proc/<pid>/stat.
|
||||
type processStat struct {
|
||||
StartTime int64
|
||||
}
|
||||
|
||||
// readProcessStat parses Linux process stat counters from path.
|
||||
func readProcessStat(path string) (processStat, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return processStat{}, err
|
||||
}
|
||||
startTime, err := parseProcessStatStartTime(string(data))
|
||||
if err != nil {
|
||||
return processStat{}, err
|
||||
}
|
||||
return processStat{StartTime: startTime}, nil
|
||||
}
|
||||
|
||||
// readProcessStatus parses key-value fields from /proc/<pid>/status.
|
||||
func readProcessStatus(path string) (map[string]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
values := map[string]string{}
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
parts := strings.SplitN(scanner.Text(), ":", 2)
|
||||
if len(parts) == 2 {
|
||||
values[parts[0]] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
return values, scanner.Err()
|
||||
}
|
||||
|
||||
// socketInodes maps Linux socket inode numbers to best-effort owning process data.
|
||||
func socketInodes() (map[string]status.ProcessInfo, error) {
|
||||
out := map[string]status.ProcessInfo{}
|
||||
var readErrors int
|
||||
_ = filepath.WalkDir("/proc", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || !d.IsDir() || filepath.Dir(path) != "/proc" {
|
||||
return nil
|
||||
}
|
||||
pid, err := strconv.Atoi(filepath.Base(path))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
process, _ := readProcess(pid)
|
||||
fdDir := filepath.Join(path, "fd")
|
||||
entries, err := os.ReadDir(fdDir)
|
||||
if err != nil {
|
||||
readErrors++
|
||||
return nil
|
||||
}
|
||||
for _, entry := range entries {
|
||||
target, err := os.Readlink(filepath.Join(fdDir, entry.Name()))
|
||||
if err != nil || !strings.HasPrefix(target, "socket:[") {
|
||||
continue
|
||||
}
|
||||
inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]")
|
||||
out[inode] = process
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if readErrors > 0 {
|
||||
return out, fmt.Errorf("%d process fd directories could not be read", readErrors)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readTCPListeners parses a Linux TCP table and returns sockets in LISTEN state.
|
||||
func readTCPListeners(path string, family string, inodes map[string]status.ProcessInfo) ([]status.TCPListenerInfo, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
var items []status.TCPListenerInfo
|
||||
scanner := bufio.NewScanner(file)
|
||||
if scanner.Scan() {
|
||||
// Skip header.
|
||||
}
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 10 || fields[3] != "0A" {
|
||||
continue
|
||||
}
|
||||
ip, port, err := parseAddress(fields[1], family)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
process := inodes[fields[9]]
|
||||
items = append(items, status.TCPListenerInfo{
|
||||
IP: ip, Port: port, PID: process.PID, ProcessName: process.Name,
|
||||
ProcessExe: process.Exe, Family: family,
|
||||
})
|
||||
}
|
||||
return items, scanner.Err()
|
||||
}
|
||||
|
||||
// readCPUUsagePercent samples /proc/stat over a short interval and returns best-effort usage.
|
||||
func readCPUUsagePercent(ctx context.Context) float64 {
|
||||
prevData, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
prevIdle, prevTotal, err := parseCPUTimes(string(prevData))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
nextData, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
nextIdle, nextTotal, err := parseCPUTimes(string(nextData))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return cpuUsagePercent(prevIdle, prevTotal, nextIdle, nextTotal)
|
||||
}
|
||||
|
||||
// parseAddress decodes a Linux /proc/net/tcp address into IP and port values.
|
||||
func parseAddress(raw string, family string) (string, int, error) {
|
||||
parts := strings.Split(raw, ":")
|
||||
if len(parts) != 2 {
|
||||
return "", 0, fmt.Errorf("invalid address %q", raw)
|
||||
}
|
||||
port64, err := strconv.ParseInt(parts[1], 16, 32)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
bytes, err := hex.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if family == "ipv4" && len(bytes) == 4 {
|
||||
return net.IPv4(bytes[3], bytes[2], bytes[1], bytes[0]).String(), int(port64), nil
|
||||
}
|
||||
if family == "ipv6" && len(bytes) == 16 {
|
||||
reordered := make([]byte, 16)
|
||||
for i := 0; i < 16; i += 4 {
|
||||
reordered[i], reordered[i+1], reordered[i+2], reordered[i+3] = bytes[i+3], bytes[i+2], bytes[i+1], bytes[i]
|
||||
}
|
||||
return net.IP(reordered).String(), int(port64), nil
|
||||
}
|
||||
return "", 0, fmt.Errorf("invalid %s address", family)
|
||||
}
|
||||
|
||||
// parseProcessStatStartTime extracts field 22 from Linux /proc/<pid>/stat content.
|
||||
func parseProcessStatStartTime(raw string) (int64, error) {
|
||||
end := strings.LastIndex(raw, ")")
|
||||
if end < 0 || end+2 >= len(raw) {
|
||||
return 0, fmt.Errorf("invalid process stat")
|
||||
}
|
||||
fields := strings.Fields(raw[end+2:])
|
||||
if len(fields) < 20 {
|
||||
return 0, fmt.Errorf("process stat has %d fields after comm", len(fields))
|
||||
}
|
||||
return strconv.ParseInt(fields[19], 10, 64)
|
||||
}
|
||||
|
||||
// parseCPUTimes extracts idle and total jiffy counters from the first cpu line in /proc/stat.
|
||||
func parseCPUTimes(raw string) (idle uint64, total uint64, err error) {
|
||||
scanner := bufio.NewScanner(strings.NewReader(raw))
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) == 0 || fields[0] != "cpu" {
|
||||
continue
|
||||
}
|
||||
if len(fields) < 5 {
|
||||
return 0, 0, fmt.Errorf("cpu line has %d fields", len(fields))
|
||||
}
|
||||
for i, field := range fields[1:] {
|
||||
value, err := strconv.ParseUint(field, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
total += value
|
||||
if i == 3 || i == 4 {
|
||||
idle += value
|
||||
}
|
||||
}
|
||||
return idle, total, nil
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return 0, 0, fmt.Errorf("cpu line not found")
|
||||
}
|
||||
|
||||
// cpuUsagePercent calculates non-idle CPU percentage between two /proc/stat samples.
|
||||
func cpuUsagePercent(prevIdle, prevTotal, nextIdle, nextTotal uint64) float64 {
|
||||
totalDelta := nextTotal - prevTotal
|
||||
if totalDelta == 0 {
|
||||
return 0
|
||||
}
|
||||
idleDelta := nextIdle - prevIdle
|
||||
if idleDelta >= totalDelta {
|
||||
return 0
|
||||
}
|
||||
return float64(totalDelta-idleDelta) / float64(totalDelta) * 100
|
||||
}
|
||||
|
||||
// parseKB converts the first numeric kilobyte value in raw to bytes.
|
||||
func parseKB(raw string) uint64 {
|
||||
value, _ := strconv.ParseUint(firstField(raw), 10, 64)
|
||||
return value * 1024
|
||||
}
|
||||
|
||||
// firstField returns the first whitespace-separated token in raw.
|
||||
func firstField(raw string) string {
|
||||
fields := strings.Fields(raw)
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fields[0]
|
||||
}
|
||||
|
||||
// sortedKeys returns deterministic sorted keys from a string set.
|
||||
func sortedKeys(values map[string]struct{}) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
169
internal/collector/collector_linux_test.go
Normal file
169
internal/collector/collector_linux_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
75
internal/collector/collector_other.go
Normal file
75
internal/collector/collector_other.go
Normal 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)
|
||||
}
|
||||
781
internal/collector/collector_windows.go
Normal file
781
internal/collector/collector_windows.go
Normal 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
|
||||
}
|
||||
87
internal/collector/collector_windows_test.go
Normal file
87
internal/collector/collector_windows_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
27
internal/collector/network.go
Normal file
27
internal/collector/network.go
Normal 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
|
||||
}
|
||||
23
internal/collector/network_test.go
Normal file
23
internal/collector/network_test.go
Normal 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
149
internal/config/config.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
71
internal/config/config_test.go
Normal file
71
internal/config/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
69
internal/httpapi/server.go
Normal file
69
internal/httpapi/server.go
Normal 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)
|
||||
}
|
||||
117
internal/httpapi/server_test.go
Normal file
117
internal/httpapi/server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
99
internal/reporter/reporter.go
Normal file
99
internal/reporter/reporter.go
Normal 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
|
||||
}
|
||||
138
internal/reporter/reporter_test.go
Normal file
138
internal/reporter/reporter_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
26
internal/service/service.go
Normal file
26
internal/service/service.go
Normal 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()
|
||||
}
|
||||
82
internal/service/service_linux.go
Normal file
82
internal/service/service_linux.go
Normal 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()
|
||||
}
|
||||
37
internal/service/service_linux_test.go
Normal file
37
internal/service/service_linux_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
38
internal/service/service_other.go
Normal file
38
internal/service/service_other.go
Normal 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)
|
||||
}
|
||||
43
internal/service/service_windows.go
Normal file
43
internal/service/service_windows.go
Normal 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()
|
||||
}
|
||||
104
internal/snapshot/snapshot.go
Normal file
104
internal/snapshot/snapshot.go
Normal 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)
|
||||
}
|
||||
103
internal/snapshot/snapshot_test.go
Normal file
103
internal/snapshot/snapshot_test.go
Normal 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
148
internal/status/types.go
Normal 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"`
|
||||
}
|
||||
349
plan.md
Normal file
349
plan.md
Normal file
@@ -0,0 +1,349 @@
|
||||
高权限主机状态 Agent 设计方案
|
||||
|
||||
1. 项目简介
|
||||
|
||||
MFD Monitor Agent 是一个使用 Go 实现的单二进制主机状态监控 agent,用于在 Windows 和 Linux 主机上采集并暴露完整主机状态。
|
||||
|
||||
agent 提供本地 HTTP API,默认监听 127.0.0.1:8080。GET /health 用于返回进程存活状态和最近一次快照元数据;GET /status 用于返回缓存中的完整主机状态快照。
|
||||
|
||||
agent 通过后台周期采集刷新内存缓存,避免每次 HTTP 请求都实时扫描系统,从而提升响应速度并降低系统负载。
|
||||
|
||||
agent 支持可选的周期性 HTTP POST 主动上报,上报数据直接复用 HostStatus JSON 结构,避免维护两套数据模型。
|
||||
|
||||
agent 支持通过 CLI 管理服务生命周期,包括 run、install、uninstall、start、stop 和 version。Linux 侧面向 systemd service,Windows 侧面向 Windows Service。
|
||||
|
||||
2. 目标
|
||||
|
||||
开发一个独立的 Go 单二进制 agent,支持安装为 Windows Service 和 Linux systemd service。
|
||||
|
||||
安装阶段要求管理员/root;首版运行阶段也默认使用高权限账户,以稳定采集完整主机信息。
|
||||
|
||||
agent 对外提供 HTTP 接口,核心接口为 GET /status,单次返回完整主机状态快照,包含:
|
||||
- IP
|
||||
- MAC
|
||||
- 主机名
|
||||
- CPU
|
||||
- 内存
|
||||
- 磁盘
|
||||
- 完整进程列表
|
||||
- 监听中的 TCP 端口及其进程映射
|
||||
|
||||
同时 agent 支持按周期向中心端固定 HTTP 接口主动上报同一份状态 JSON。
|
||||
|
||||
|
||||
3. 服务权限说明
|
||||
|
||||
注册为服务这一步基本都需要管理员权限:
|
||||
|
||||
- Windows:创建、删除、启动、停止 Windows Service 需要管理员权限
|
||||
- Linux:写入 systemd unit、systemctl enable/start 通常需要 root
|
||||
|
||||
服务运行阶段:
|
||||
|
||||
- 如果只读取主机名、IP、MAC、内存、磁盘、CPU,通常不一定需要管理员权限
|
||||
- 但如果要稳定获取完整进程列表,以及监听 TCP 端口和进程映射,建议服务默认也以高权限运行
|
||||
|
||||
建议首版权限策略:
|
||||
|
||||
- 安装阶段:必须管理员/root
|
||||
- 运行阶段:默认高权限运行
|
||||
- 接口层:即使某些字段采集失败,也不要让整个 /status 失败,应返回 partial 或错误原因
|
||||
|
||||
|
||||
4. 架构方案
|
||||
|
||||
推荐使用“单进程一体化”架构。
|
||||
|
||||
一个 agent 进程中同时承担以下职责:
|
||||
|
||||
- 周期采集主机状态
|
||||
- 缓存最近一次状态
|
||||
- 对外提供 HTTP 查询接口
|
||||
- 定时向中心端主动上报状态
|
||||
- 负责 Windows/Linux 服务生命周期管理
|
||||
|
||||
虽然是单进程,但代码建议按模块拆分:
|
||||
|
||||
- collector/system:主机名、OS、架构、启动时间、采集时间
|
||||
- collector/network:网卡、IPv4/IPv6、MAC
|
||||
- collector/resource:CPU、内存、磁盘
|
||||
- collector/process:完整进程列表
|
||||
- collector/socket:TCP 监听端口及其进程映射
|
||||
- snapshot:聚合各采集器输出,形成统一 HostStatus
|
||||
- httpapi:提供 GET /health、GET /status
|
||||
- reporter:定时 POST HostStatus 到中心端
|
||||
- service:封装 Windows/Linux 服务生命周期和安装命令
|
||||
- config:统一加载监听地址、采集周期、上报周期、上报目标、机器标识等配置
|
||||
|
||||
|
||||
5. HTTP 接口设计
|
||||
|
||||
建议保留两个接口:
|
||||
|
||||
- GET /health
|
||||
用于返回进程存活、版本、最近快照时间、最近采集状态
|
||||
|
||||
- GET /status
|
||||
返回完整 HostStatus
|
||||
|
||||
根据当前确认的需求,/status 采用单接口全量返回,不拆分接口,不使用 include 参数。
|
||||
|
||||
默认监听地址建议为:
|
||||
|
||||
- 127.0.0.1
|
||||
|
||||
如果后续需要局域网访问,再通过配置改为指定内网地址。
|
||||
|
||||
原因:
|
||||
|
||||
- /status 会返回较多敏感主机信息
|
||||
- 服务以高权限运行时,应尽量缩小默认暴露面
|
||||
|
||||
|
||||
6. HostStatus 建议字段
|
||||
|
||||
建议返回统一 JSON 结构,主要包含以下字段:
|
||||
|
||||
- agent
|
||||
- version
|
||||
- started_at
|
||||
- snapshot_at
|
||||
- snapshot_status
|
||||
|
||||
- host
|
||||
- host_id
|
||||
- hostname
|
||||
- platform
|
||||
- platform_version
|
||||
- arch
|
||||
- uptime_seconds
|
||||
|
||||
- network
|
||||
- interfaces[]
|
||||
- name
|
||||
- mac
|
||||
- mtu
|
||||
- flags
|
||||
- addresses[]
|
||||
- ips[]
|
||||
- macs[]
|
||||
|
||||
- cpu
|
||||
- logical_cores
|
||||
- physical_cores
|
||||
- usage_percent
|
||||
- load_avg
|
||||
- model_name
|
||||
|
||||
- memory
|
||||
- total
|
||||
- used
|
||||
- free
|
||||
- available
|
||||
- used_percent
|
||||
|
||||
- disks[]
|
||||
- device
|
||||
- mount_point
|
||||
- fs_type
|
||||
- total
|
||||
- used
|
||||
- free
|
||||
- used_percent
|
||||
|
||||
- processes
|
||||
- status: ok | partial | error
|
||||
- items[]
|
||||
- pid
|
||||
- ppid
|
||||
- name
|
||||
- exe
|
||||
- cmdline
|
||||
- username
|
||||
- status
|
||||
- start_time
|
||||
- cpu_percent
|
||||
- memory_rss
|
||||
- memory_vms
|
||||
|
||||
- tcp_listeners
|
||||
- status: ok | partial | error
|
||||
- items[]
|
||||
- ip
|
||||
- port
|
||||
- pid
|
||||
- process_name
|
||||
- process_exe
|
||||
- family: ipv4 | ipv6
|
||||
|
||||
- errors[]
|
||||
用于记录子采集器失败原因,例如:
|
||||
- process.cmdline permission denied
|
||||
|
||||
|
||||
7. 采集策略
|
||||
|
||||
建议采用“周期采集 + 内存缓存”:
|
||||
|
||||
- 启动后立即采集一次
|
||||
- 后台定时刷新缓存
|
||||
- HTTP 接口直接读取缓存,不在每次请求时实时扫描系统
|
||||
- 主动上报也读取同一份缓存,避免重复采集
|
||||
|
||||
这样做的好处:
|
||||
|
||||
- 响应更快
|
||||
- 降低系统扫描频率
|
||||
- 避免进程列表和端口列表每次请求都重新采集
|
||||
|
||||
采集时建议设置:
|
||||
|
||||
- 整体采集超时
|
||||
- 单个子采集器失败不拖垮整次快照
|
||||
|
||||
例如:
|
||||
|
||||
- 进程采集失败,仍返回 CPU、内存、磁盘等其他信息
|
||||
- tcp_listeners 采集不完整时,将状态标记为 partial
|
||||
|
||||
|
||||
8. 主动上报设计
|
||||
|
||||
agent 除了支持外部系统通过 HTTP 拉取状态,还支持定时主动上报。
|
||||
|
||||
建议方式:
|
||||
|
||||
- 配置一个固定中心服务 URL
|
||||
- 周期性 POST HostStatus JSON
|
||||
- 上报载荷直接复用 /status 的 JSON 结构
|
||||
|
||||
这样可以避免维护两套数据模型。
|
||||
|
||||
建议配置项:
|
||||
|
||||
- report.enabled
|
||||
- report.endpoint
|
||||
- report.interval
|
||||
- report.timeout
|
||||
|
||||
上报失败策略:
|
||||
|
||||
- 超时和失败日志独立记录
|
||||
- 支持重试或简单退避
|
||||
- 上报失败不能影响本地 HTTP 服务
|
||||
- 上报失败不能导致进程退出
|
||||
|
||||
|
||||
9. CLI 设计
|
||||
|
||||
建议提供以下命令:
|
||||
|
||||
- agent run
|
||||
- agent install
|
||||
- agent uninstall
|
||||
- agent start
|
||||
- agent stop
|
||||
- agent version
|
||||
|
||||
Linux:
|
||||
|
||||
- agent 进程以前台模式运行
|
||||
- 额外提供 systemd unit 模板
|
||||
- 安装脚本负责写入 unit 并启停
|
||||
|
||||
Windows:
|
||||
|
||||
- 使用服务包装库实现 install/uninstall/start/stop/run
|
||||
- 不依赖外部 NSSM
|
||||
|
||||
|
||||
10. 技术建议
|
||||
|
||||
推荐使用 Go 实现,原因:
|
||||
|
||||
- 单二进制,部署简单
|
||||
- Windows 和 Linux 跨平台支持较好
|
||||
- 服务化实现相对成熟
|
||||
- 适合做常驻守护进程和 HTTP 服务
|
||||
|
||||
系统信息采集建议优先使用成熟跨平台库,例如:
|
||||
|
||||
- gopsutil
|
||||
|
||||
这样可以减少自己处理平台差异的工作量。
|
||||
|
||||
如库在“端口到进程映射”能力上不足,可以针对 Windows/Linux 做补充实现。
|
||||
|
||||
|
||||
11. 测试建议
|
||||
|
||||
单元测试:
|
||||
|
||||
- 各采集器输出能正确映射为 HostStatus
|
||||
- 进程列表和 TCP 监听项在空结果、部分字段缺失、权限错误时能输出 partial/error
|
||||
- snapshot 聚合逻辑在部分采集失败时仍返回可用快照
|
||||
|
||||
集成测试:
|
||||
|
||||
- 启动后 GET /health 返回正常
|
||||
- GET /status 返回完整 JSON,包含 cpu、processes、tcp_listeners
|
||||
- 模拟中心端,验证 POST 的载荷、超时、失败重试行为
|
||||
|
||||
平台验证:
|
||||
|
||||
- Windows:服务安装、启动、停止、卸载
|
||||
- Linux:systemd 安装、enable、start、restart
|
||||
|
||||
性能与稳定性:
|
||||
|
||||
- 大量进程场景下 /status 仍在可接受时间内返回
|
||||
- 采集周期与 HTTP 并发访问同时存在时无明显阻塞
|
||||
- /status 响应体较大时内存占用可控
|
||||
|
||||
|
||||
12. 首版边界
|
||||
|
||||
首版默认包括:
|
||||
|
||||
- IP
|
||||
- MAC
|
||||
- 主机名
|
||||
- CPU
|
||||
- 内存
|
||||
- 磁盘
|
||||
- 完整进程列表
|
||||
- TCP 监听端口及进程映射
|
||||
- 本地 HTTP 查询
|
||||
- 主动 HTTP 上报
|
||||
- Windows/Linux 服务化
|
||||
|
||||
首版默认不包括:
|
||||
|
||||
- 历史存储
|
||||
- 趋势分析
|
||||
- 分页查询
|
||||
- UDP 监听信息
|
||||
- 线程列表
|
||||
- 进程句柄
|
||||
- 连接状态详情
|
||||
- 强制 Token/mTLS
|
||||
|
||||
|
||||
13. 结论
|
||||
|
||||
最务实的首版实现方案是:
|
||||
|
||||
- 使用 Go
|
||||
- 单进程一体化架构
|
||||
- 安装时管理员/root 权限
|
||||
- 运行时默认高权限服务账户
|
||||
- GET /status 单接口全量返回
|
||||
- 默认监听 127.0.0.1
|
||||
- 同时支持被动查询和主动上报
|
||||
|
||||
如果进入实现阶段,下一步可以直接开始定义:
|
||||
|
||||
- 配置文件结构
|
||||
- HostStatus Go struct
|
||||
- collector 接口
|
||||
- Windows/Linux 服务安装与运行流程
|
||||
22
scripts/build-linux.sh
Executable file
22
scripts/build-linux.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT_DIR="${OUT_DIR:-${ROOT_DIR}/dist}"
|
||||
VERSION="${VERSION:-0.1.0}"
|
||||
GOOS_TARGET="${GOOS_TARGET:-linux}"
|
||||
GOARCH_TARGET="${GOARCH_TARGET:-amd64}"
|
||||
OUTPUT_NAME="${OUTPUT_NAME:-agent-${GOOS_TARGET}-${GOARCH_TARGET}}"
|
||||
|
||||
mkdir -p "${OUT_DIR}"
|
||||
|
||||
CGO_ENABLED="${CGO_ENABLED:-0}" \
|
||||
GOOS="${GOOS_TARGET}" \
|
||||
GOARCH="${GOARCH_TARGET}" \
|
||||
go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-o "${OUT_DIR}/${OUTPUT_NAME}" \
|
||||
./cmd/agent
|
||||
|
||||
printf 'built %s\n' "${OUT_DIR}/${OUTPUT_NAME}"
|
||||
22
scripts/build-windows.ps1
Normal file
22
scripts/build-windows.ps1
Normal file
@@ -0,0 +1,22 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RootDir = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
$OutDir = if ($env:OUT_DIR) { $env:OUT_DIR } else { Join-Path $RootDir "dist" }
|
||||
$Version = if ($env:VERSION) { $env:VERSION } else { "0.1.0" }
|
||||
$GoosTarget = if ($env:GOOS_TARGET) { $env:GOOS_TARGET } else { "windows" }
|
||||
$GoarchTarget = if ($env:GOARCH_TARGET) { $env:GOARCH_TARGET } else { "amd64" }
|
||||
$OutputName = if ($env:OUTPUT_NAME) { $env:OUTPUT_NAME } else { "agent-$GoosTarget-$GoarchTarget.exe" }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||
|
||||
$env:CGO_ENABLED = if ($env:CGO_ENABLED) { $env:CGO_ENABLED } else { "0" }
|
||||
$env:GOOS = $GoosTarget
|
||||
$env:GOARCH = $GoarchTarget
|
||||
|
||||
go build `
|
||||
-trimpath `
|
||||
-ldflags "-s -w -X main.version=$Version" `
|
||||
-o (Join-Path $OutDir $OutputName) `
|
||||
./cmd/agent
|
||||
|
||||
Write-Host "built $(Join-Path $OutDir $OutputName)"
|
||||
Reference in New Issue
Block a user