diff --git a/AGENTS.md b/AGENTS.md index 8c5247b..0510d62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,9 +6,10 @@ - MFD Monitor Agent 是一个使用 Go 实现的单二进制主机状态监控 agent。 - agent 需要支持 Windows 和 Linux,用于采集并暴露完整主机状态。 -- agent 默认通过本地 HTTP API 提供查询能力,默认监听 `127.0.0.1:8080`。 -- `GET /health` 用于返回进程存活状态、版本信息、最近快照时间和最近采集状态。 -- `GET /status` 用于返回缓存中的完整 `HostStatus` 主机状态快照。 +- agent 默认通过 HTTP API 提供查询能力,默认监听 `0.0.0.0:8080`。 +- `GET /health?token=` 用于返回进程存活状态、版本信息、最近快照时间和最近采集状态。 +- `GET /status?token=` 用于返回缓存中的完整 `HostStatus` 主机状态快照。 +- `http.token` 配置在 JSON 中;`agent run` 时若工作目录无 `config.json` 则自动生成含随机 token 的默认文件。 - agent 通过后台周期采集刷新内存缓存,HTTP 接口和主动上报都读取同一份缓存。 - agent 支持可选的周期性 HTTP POST 主动上报,上报数据必须复用 `/status` 的 `HostStatus` JSON 结构。 - agent 需要通过 CLI 管理服务生命周期,包括 `run`、`install`、`uninstall`、`start`、`stop` 和 `version`。 @@ -19,7 +20,7 @@ - 安装阶段必须要求管理员/root 权限。 - 首版运行阶段默认使用高权限服务账户,以稳定采集完整进程列表和 TCP 监听端口到进程的映射。 - 接口层必须支持部分成功:某些字段或子采集器失败时,不应导致整个 `/status` 失败,应返回 `partial` 或具体错误原因。 -- 默认监听地址必须尽量收敛到本地地址 `127.0.0.1`,避免默认暴露敏感主机信息。 +- 默认监听 `0.0.0.0:8080`,必须通过查询参数 `?token=` 鉴权;`http.token` 不能为空。 ### 架构要求 @@ -35,7 +36,7 @@ - `httpapi`:提供 `GET /health` 和 `GET /status`。 - `reporter`:定时 POST `HostStatus` 到中心端。 - `service`:封装 Windows/Linux 服务生命周期和安装命令。 - - `config`:统一加载监听地址、采集周期、上报周期、上报目标、机器标识等配置。 + - `config`:统一加载监听地址、HTTP token、采集周期、上报周期、上报目标、机器标识等配置。 ### HostStatus 要求 @@ -95,7 +96,8 @@ ### 首版边界 - 首版默认包含 IP、MAC、主机名、CPU、内存、磁盘、完整进程列表、TCP 监听端口及进程映射、本地 HTTP 查询、主动 HTTP 上报、Windows/Linux 服务化。 -- 首版默认不包含历史存储、趋势分析、分页查询、UDP 监听信息、线程列表、进程句柄、连接状态详情、强制 Token/mTLS。 +- 首版默认不包含历史存储、趋势分析、分页查询、UDP 监听信息、线程列表、进程句柄、连接状态详情、Bearer Token/mTLS。 +- 首版 HTTP API 使用查询参数 token 鉴权(非 Bearer/mTLS)。 ## 注释规则 diff --git a/README.md b/README.md index c20c20f..fa599ae 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ 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. +- `GET /health?token=...` returns process liveness and latest snapshot metadata. +- `GET /status?token=...` returns the cached full host snapshot. +- On first `agent run`, creates `config.json` in the working directory if missing (with a random token). - 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. @@ -13,6 +14,24 @@ Single-binary host status agent implemented in Go. **完整部署说明(中文):** [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) +**架构与流程说明:** [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) + +**开发与构建流程:** [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) + +## Quick Start + +```bash +go test ./... +go run ./cmd/agent run +``` + +首次运行会在当前目录生成 `config.json`,其中包含随机 `http.token`。使用该 token 访问: + +```text +http://127.0.0.1:8080/health?token= +http://127.0.0.1:8080/status?token= +``` + ## Commands ```bash @@ -26,6 +45,12 @@ agent version ## Build +All default targets: + +```bash +./scripts/build-all.sh +``` + Linux: ```bash @@ -54,7 +79,8 @@ Example config: [configs/example.json](configs/example.json). See [docs/DEPLOYME { "host_id": "host-a", "http": { - "listen_address": "127.0.0.1:8080" + "listen_address": "0.0.0.0:8080", + "token": "replace-with-a-strong-random-value" }, "collect": { "interval": "30s", @@ -69,13 +95,28 @@ Example config: [configs/example.json](configs/example.json). See [docs/DEPLOYME } ``` +API access (browser-friendly query parameter): + +```text +http://:8080/health?token= +http://:8080/status?token= +``` + Environment overrides: - `MFD_AGENT_HOST_ID` - `MFD_AGENT_LISTEN` +- `MFD_AGENT_TOKEN` - `MFD_AGENT_COLLECT_INTERVAL` - `MFD_AGENT_COLLECT_TIMEOUT` - `MFD_AGENT_REPORT_ENABLED` - `MFD_AGENT_REPORT_ENDPOINT` - `MFD_AGENT_REPORT_INTERVAL` - `MFD_AGENT_REPORT_TIMEOUT` + +## Documentation Map + +- [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md): production deployment, validation, upgrade, rollback, troubleshooting. +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): runtime workflow, cache behavior, partial-success model, platform differences. +- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md): tests, build scripts, cross-platform verification. +- [AGENTS.md](AGENTS.md): repository rules and first-version scope. diff --git a/cmd/agent/main.go b/cmd/agent/main.go index c24a0a6..e6b01b5 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -20,6 +20,7 @@ import ( "mfd-monitor-agent/internal/snapshot" ) +// version 是编译时可通过 -ldflags 覆盖的 agent 版本号。 var version = "0.1.0" // main 运行 CLI,命令失败时以非零状态退出。 @@ -42,7 +43,8 @@ func runCLI(args []string) error { if err := fs.Parse(args[1:]); err != nil { return err } - cfg, err := config.Load(*configPath) + path := config.ResolvePath(*configPath) + cfg, err := config.LoadOrCreate(path) if err != nil { return err } @@ -87,18 +89,20 @@ func commandConfig(args []string) (config.Config, string, string, error) { if err := fs.Parse(args); err != nil { return config.Config{}, "", "", err } - cfg, err := config.Load(*configPath) + path := config.ResolvePath(*configPath) + cfg, err := config.Load(path) 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 + return cfg, executable, path, nil } // runAgent 启动采集、可选上报与本地 HTTP API,直至收到退出信号。 @@ -109,20 +113,23 @@ func runAgent(cfg config.Config) error { startedAt := time.Now().UTC() cache := snapshot.NewCache(collector.New(cfg.HostID), version, startedAt) + // 采集在后台立即执行一次并周期刷新,HTTP 与上报都只读取这份缓存。 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()) + // 上报循环独立于 HTTP 服务运行;失败只记录日志并重试,不终止 agent。 go reporter.Run(ctx, cache, client, cfg.Report.Interval.Duration(), logger) } server := &http.Server{ Addr: cfg.HTTP.ListenAddress, - Handler: httpapi.New(cache), + Handler: httpapi.New(cache, cfg.HTTP.Token), ReadHeaderTimeout: 5 * time.Second, } go func() { <-ctx.Done() + // 收到系统信号后给 HTTP 请求一个短暂宽限期,避免直接中断正在返回的快照。 shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = server.Shutdown(shutdownCtx) diff --git a/configs/example.json b/configs/example.json index a57af12..713c4c7 100644 --- a/configs/example.json +++ b/configs/example.json @@ -1,7 +1,8 @@ { "host_id": "host-example", "http": { - "listen_address": "127.0.0.1:8080" + "listen_address": "0.0.0.0:8080", + "token": "请替换为强随机值" }, "collect": { "interval": "30s", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..cff79b5 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,172 @@ +# MFD Monitor Agent 架构与工作流程 + +本文档汇总当前代码中的实际运行流程,供开发、排障和评审时快速理解 agent 如何从配置加载到采集、缓存、查询、上报和服务化运行。 + +## 进程职责 + +MFD Monitor Agent 采用单进程一体化架构。一个 agent 进程同时负责: + +- 周期采集主机状态 +- 保存最近一次 `HostStatus` 内存缓存 +- 提供本地 HTTP API +- 可选地向中心端主动 POST 上报 +- 执行 Linux/Windows 服务安装、启动、停止和卸载命令 + +HTTP 查询与主动上报读取同一份缓存,不在请求路径上触发实时系统扫描。 + +## 包职责 + +| 包 | 职责 | +|----|------| +| `cmd/agent` | CLI 分发、配置加载、进程生命周期、HTTP server 启停 | +| `internal/config` | 默认配置、JSON 读取、首次配置生成、环境变量覆盖、配置校验 | +| `internal/status` | `/status` 与主动上报共用的 `HostStatus` JSON 数据模型 | +| `internal/collector` | 平台相关主机、网络、资源、进程、TCP 监听采集 | +| `internal/snapshot` | 子采集器聚合、partial 错误记录、并发安全缓存 | +| `internal/httpapi` | `GET /health` 与 `GET /status`,查询参数 token 鉴权 | +| `internal/reporter` | 周期性 POST 缓存快照,失败重试与日志记录 | +| `internal/service` | Linux systemd 与 Windows SCM 服务生命周期封装 | + +## 启动与生命周期 + +`agent` 无参数时等价于 `agent run`。 + +`agent run [-config config.json]` 流程: + +1. 解析 `-config`,未指定时使用当前工作目录 `config.json`。 +2. 调用 `config.LoadOrCreate`。配置不存在时创建默认 JSON 并生成随机 `http.token`。 +3. 创建 `snapshot.Cache`,启动后台采集循环。 +4. 若 `report.enabled=true`,启动主动上报循环。 +5. 启动 HTTP server,默认监听 `0.0.0.0:8080`。 +6. 收到 `SIGINT` 或 `SIGTERM` 后优雅关闭 HTTP server。 + +服务命令 `install`、`uninstall`、`start`、`stop` 使用 `config.Load` 读取已有配置,不会自动创建配置文件。 + +## 配置加载流程 + +配置来源按以下顺序生效: + +1. `config.Default()` 提供默认值。 +2. JSON 文件覆盖默认值。 +3. `MFD_AGENT_*` 环境变量覆盖 JSON。 +4. `validate` 校验安全和必填项。 + +关键规则: + +- `http.token` 必填;`agent run` 首次生成配置时会自动写入随机 token。 +- `report.enabled=true` 时 `report.endpoint` 必填。 +- 自动生成的配置文件权限为 `0600`,因为其中包含 API token。 +- 非法布尔或时长环境变量会被忽略,保留文件中的原值。 + +## 采集与缓存流程 + +`snapshot.Cache.Run` 启动后立即采集一次,随后按 `collect.interval` 周期刷新。 + +每轮采集流程: + +1. 用 `collect.timeout` 创建整体超时 context。 +2. 构造新的 `HostStatus`,默认 `agent.snapshot_status=ok`。 +3. 依次执行 `system`、`network`、`resource`、`process`、`socket` 采集器。 +4. 子采集器失败时记录到 `errors[]`,整份快照降级为 `partial`,其他字段继续保留。 +5. 用锁一次性发布最新快照,供 HTTP 与 reporter 并发读取。 + +`processes.status` 与 `tcp_listeners.status` 支持 `ok`、`partial`、`error`。例如 Linux 读取 `/proc//fd` 权限不足时,TCP 端口仍会返回,但进程映射可能不完整并标记为 `partial`。 + +## HTTP 查询流程 + +所有 API 都要求查询参数 token: + +```text +GET /health?token= +GET /status?token= +``` + +处理流程: + +1. `withQueryTokenAuth` 使用恒定时间比较校验 token。 +2. token 缺失、错误或配置 token 为空时返回 `401`。 +3. 非 `GET` 方法返回 `405`。 +4. `/health` 读取缓存元数据。首次快照未就绪时返回 `503` 和 `{"status":"starting"}`。 +5. `/status` 返回完整缓存 `HostStatus`。首次快照未就绪时返回 `503`。 + +HTTP 请求不会触发采集,避免高并发查询放大系统扫描成本。 + +## 主动上报流程 + +启用条件: + +```json +{ + "report": { + "enabled": true, + "endpoint": "http://center.example.internal/agent/status" + } +} +``` + +运行流程: + +1. `reporter.Run` 按 `report.interval` 定时执行。 +2. 从 `snapshot.Cache` 读取最新 `HostStatus`。 +3. 首次快照未就绪时跳过本轮。 +4. 以 `application/json` POST 到 `report.endpoint`。 +5. 非 2xx、网络错误或超时视为失败。 +6. 失败后等待短暂退避并重试一次。 +7. 最终失败只记录日志,不影响采集循环或本地 HTTP 服务。 + +上报载荷与 `/status` 响应结构完全一致。 + +## 服务生命周期流程 + +### Linux systemd + +`agent install -config /path/config.json -exe /path/agent`: + +1. 检查当前用户必须为 root。 +2. 生成 `/etc/systemd/system/.service`。 +3. `ExecStart` 写入 ` run -config `。 +4. 执行 `systemctl daemon-reload`。 +5. 执行 `systemctl enable `。 + +`start`、`stop` 调用 `systemctl start/stop`。`uninstall` 尽力 `disable --now`,删除 unit 文件后再次 `daemon-reload`。 + +### Windows SCM + +Windows 通过 `sc.exe` 管理服务,不依赖 NSSM。 + +`install` 使用 `sc.exe create` 注册自动启动服务,`binPath` 为: + +```text +"" run -config "" +``` + +`start`、`stop`、`uninstall` 分别调用 `sc.exe start`、`sc.exe stop`、`sc.exe delete`。安装、删除和启停通常需要管理员权限。 + +## HostStatus 数据模型 + +`HostStatus` 是唯一对外状态模型,同时用于: + +- `GET /status` 响应体 +- 主动上报 POST body +- 测试中的快照断言 + +顶层字段包括: + +- `agent`:版本、启动时间、快照时间、快照状态 +- `host`:机器标识、主机名、平台、架构、运行时长 +- `network`:网卡明细、IPv4/IPv6、MAC +- `cpu`:逻辑核心、物理核心、使用率、负载、型号 +- `memory`:内存容量与使用率 +- `disks`:文件系统容量与使用率 +- `processes`:采集状态与完整进程列表 +- `tcp_listeners`:采集状态与 TCP 监听端口到进程映射 +- `errors`:子采集器失败原因 + +## 平台差异 + +Linux 采集主要读取 `/proc`、`/etc/os-release`、`net.Interfaces` 和 `statfs`。进程与 socket 映射受 `/proc/` 权限和进程生命周期竞争影响。 + +Windows 采集使用 Win32 API、Toolhelp、注册表、PSAPI、iphlpapi 和一次尽力 WMIC 命令。部分字段需要高权限读取进程句柄或令牌,失败时降级为空字段或 `partial`。 + +非 Linux/Windows 平台保留基础主机和网络采集,进程与 TCP 监听采集明确返回不支持并标记 `partial`。 + diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8353ba7..65c5572 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -7,12 +7,28 @@ MFD Monitor Agent 是单二进制主机状态采集程序,具备以下能力: - 后台周期采集主机状态并写入内存缓存 -- 本地 HTTP API:`GET /health`、`GET /status`(默认仅监听 `127.0.0.1:8080`) +- 本地 HTTP API:`GET /health?token=...`、`GET /status?token=...`(默认监听 `0.0.0.0:8080`,需配置 `http.token`) - 可选周期性向中心端 POST 与 `/status` 相同结构的 `HostStatus` JSON - CLI 管理服务生命周期:`run`、`install`、`uninstall`、`start`、`stop`、`version` HTTP 接口与上报均读取同一份缓存,**不会在每次请求时实时扫描系统**。 +更多内部流程见 [架构与工作流程](ARCHITECTURE.md),构建测试流程见 [开发流程](DEVELOPMENT.md)。 + +## 运行流程 + +`agent run` 的运行链路如下: + +1. 解析 `-config`;未指定时使用当前目录 `config.json`。 +2. 配置不存在时自动生成默认配置与随机 `http.token`,文件权限为 `0600`。 +3. 加载 JSON,并应用 `MFD_AGENT_*` 环境变量覆盖。 +4. 启动 `snapshot.Cache.Run`,立即采集一次并按 `collect.interval` 周期刷新缓存。 +5. 如果 `report.enabled=true`,启动主动上报循环。 +6. 启动 HTTP API,默认监听 `0.0.0.0:8080`。 +7. 收到退出信号后优雅关闭 HTTP server。 + +单轮采集使用 `collect.timeout` 作为整体超时。子采集器失败不会导致整份 `/status` 失败,而是记录到 `errors[]` 并将 `agent.snapshot_status` 标记为 `partial`。 + ## 环境要求 | 项目 | 要求 | @@ -92,13 +108,16 @@ C:\ProgramData\MFD\config.json ### JSON 配置文件 -所有字段均有安全默认值;未提供配置文件时也可仅依赖环境变量启动。 +`agent run` 在未指定 `-config` 时使用当前工作目录下的 `config.json`;若文件不存在,会自动生成含随机 `http.token` 的默认配置(权限 `0600`)。 + +`install` / `start` 等命令不会自动创建配置文件,需事先准备好 JSON。 ```json { "host_id": "host-a", "http": { - "listen_address": "127.0.0.1:8080" + "listen_address": "0.0.0.0:8080", + "token": "请替换为强随机值" }, "collect": { "interval": "30s", @@ -121,7 +140,8 @@ C:\ProgramData\MFD\config.json | 字段 | 说明 | 默认值 | |------|------|--------| | `host_id` | 机器标识;为空时采集器可回退为主机名 | `""` | -| `http.listen_address` | 本地 HTTP 监听地址 | `127.0.0.1:8080` | +| `http.listen_address` | HTTP 监听地址 | `0.0.0.0:8080` | +| `http.token` | API 查询参数 `?token=` 鉴权密钥(必填) | 无(`run` 自动生成) | | `collect.interval` | 采集周期 | `30s` | | `collect.timeout` | 单次采集总超时 | `10s` | | `report.enabled` | 是否启用主动上报 | `false` | @@ -132,18 +152,22 @@ C:\ProgramData\MFD\config.json | `service.display_name` | Windows 显示名称 | `MFD Monitor Agent` | | `service.description` | systemd unit 描述 | 见默认配置 | -**校验规则:** `report.enabled=true` 时必须设置 `report.endpoint`,否则 `config.Load` 失败。 +**校验规则:** + +- `http.token` 不能为空,否则 `config.Load` 失败。 +- `report.enabled=true` 时必须设置 `report.endpoint`,否则 `config.Load` 失败。 时长字段使用 Go duration 字符串,例如 `"30s"`、`"1m"`。 ### 环境变量覆盖 -在 JSON 加载后,以下环境变量可覆盖对应配置(`Load("")` 时同样生效): +在 JSON 加载后,以下环境变量可覆盖对应配置: | 环境变量 | 配置字段 | |----------|----------| | `MFD_AGENT_HOST_ID` | `host_id` | | `MFD_AGENT_LISTEN` | `http.listen_address` | +| `MFD_AGENT_TOKEN` | `http.token` | | `MFD_AGENT_COLLECT_INTERVAL` | `collect.interval` | | `MFD_AGENT_COLLECT_TIMEOUT` | `collect.timeout` | | `MFD_AGENT_REPORT_ENABLED` | `report.enabled` | @@ -260,8 +284,10 @@ sc.exe query mfd-monitor-agent ### 健康检查 +将 `YOUR_TOKEN` 替换为配置文件中的 `http.token`: + ```bash -curl -s http://127.0.0.1:8080/health | jq . +curl -s 'http://127.0.0.1:8080/health?token=YOUR_TOKEN' | jq . ``` 首次采集完成前可能返回 `503` 且 `{"status":"starting"}`。就绪后示例: @@ -280,9 +306,11 @@ curl -s http://127.0.0.1:8080/health | jq . ### 完整状态 ```bash -curl -s http://127.0.0.1:8080/status | jq '.agent, .host, .processes.status, .tcp_listeners.status' +curl -s 'http://127.0.0.1:8080/status?token=YOUR_TOKEN' | jq '.agent, .host, .processes.status, .tcp_listeners.status' ``` +浏览器调试示例:`http://:8080/status?token=YOUR_TOKEN` + ### 主动上报验收 1. 在配置中设置 `report.enabled=true` 与可达的 `report.endpoint` @@ -311,8 +339,9 @@ sc.exe start mfd-monitor-agent ## 安全注意事项 -- **默认仅本机监听**(`127.0.0.1:8080`),避免将进程列表、监听端口等敏感信息默认暴露到网络。 -- 若需远程拉取,应通过反向代理、mTLS 或防火墙限制来源,而非直接绑定 `0.0.0.0`(除非明确接受风险)。 +- **默认监听所有网卡**(`0.0.0.0:8080`),`/health` 与 `/status` 均须通过查询参数 `?token=` 鉴权。 +- Token 会出现在 URL 中,代理与访问日志可能记录完整 URL;生产环境建议使用 HTTPS 反向代理、防火墙限制来源,并定期轮换 token。 +- 自动生成的 `config.json` 权限为 `0600`,请妥善保管其中的 `http.token`。 - 服务账户权限越高,采集越完整;生产环境应在「采集完整性」与「最小权限」之间权衡。 - `/status` 响应体可能较大(进程多、监听多时),注意中心端与本地内存占用。 @@ -320,7 +349,9 @@ sc.exe start mfd-monitor-agent | 现象 | 可能原因 | 处理建议 | |------|----------|----------| +| 启动报错 `http.token is required` | 配置中 token 为空 | 在 JSON 中设置 `http.token`,或使用 `agent run` 自动生成 | | 启动报错 `report endpoint is required` | 开启上报但未配置 endpoint | 设置 `report.endpoint` 或关闭 `report.enabled` | +| HTTP `401 unauthorized` | 缺少或错误的 `?token=` | 使用配置文件中的 `http.token` | | `install requires root` | Linux 非 root 执行 install | 使用 `sudo` | | `service install is not implemented` | 非 Linux/Windows | 仅使用 `run` 前台模式 | | `/health` 长期 `starting` | 首次采集未完成或采集卡住 | 检查 `collect.timeout`、系统负载与权限 | @@ -332,5 +363,7 @@ sc.exe start mfd-monitor-agent ## 相关文档 - 项目简介:[README.md](../README.md) +- 架构与流程:[docs/ARCHITECTURE.md](ARCHITECTURE.md) +- 开发与构建:[docs/DEVELOPMENT.md](DEVELOPMENT.md) - 架构与规范:[AGENTS.md](../AGENTS.md) - 示例配置:[configs/example.json](../configs/example.json) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..4d39540 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,127 @@ +# MFD Monitor Agent 开发流程 + +本文档记录当前仓库的构建、测试和跨平台验证流程。 + +## 本地环境 + +- Go 版本见 [go.mod](../go.mod) +- Linux 服务化验证需要 systemd +- Windows 服务化验证需要管理员 PowerShell +- 构建产物默认写入 `dist/`,该目录不提交到 Git + +## 测试命令 + +运行全部单元测试: + +```bash +go test ./... +``` + +按包运行: + +```bash +go test ./internal/collector +go test ./internal/httpapi +go test ./internal/reporter +go test ./internal/service +go test ./internal/snapshot +``` + +Windows 编译与测试验证: + +```bash +GOOS=windows GOARCH=amd64 go test ./... +``` + +## Linux 构建 + +```bash +./scripts/build-linux.sh +``` + +默认产物: + +```text +dist/agent-linux-amd64 +``` + +常用变量: + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `OUT_DIR` | 输出目录 | `dist` | +| `VERSION` | 注入到 `cmd/agent.version` 的版本号 | `0.1.0` | +| `GOOS_TARGET` | 目标系统 | `linux` | +| `GOARCH_TARGET` | 目标架构 | `amd64` | +| `OUTPUT_NAME` | 输出文件名 | `agent--` | +| `CGO_ENABLED` | CGO 开关 | `0` | + +版本号注入示例: + +```bash +VERSION=1.0.0 ./scripts/build-linux.sh +./dist/agent-linux-amd64 version +``` + +## Windows 构建 + +在 Windows PowerShell: + +```powershell +.\scripts\build-windows.ps1 +``` + +默认产物: + +```text +dist\agent-windows-amd64.exe +``` + +从 Linux 交叉编译 Windows: + +```bash +GOOS_TARGET=windows GOARCH_TARGET=amd64 OUTPUT_NAME=agent-windows-amd64.exe ./scripts/build-linux.sh +``` + +## 全平台构建 + +仓库包含 `scripts/build-all.sh`,用于一次构建 Linux amd64 与 Windows amd64 产物: + +```bash +./scripts/build-all.sh +``` + +建议发布前执行: + +```bash +go test ./... +GOOS=windows GOARCH=amd64 go test ./... +./scripts/build-all.sh +``` + +## 开发验证重点 + +变更采集逻辑时,重点验证: + +- 单个子采集器失败后 `agent.snapshot_status=partial` +- `errors[]` 包含失败采集器名称和错误摘要 +- `processes.status` 与 `tcp_listeners.status` 正确反映 `ok`、`partial`、`error` +- HTTP 请求只读取缓存,不触发实时采集 +- 上报载荷与 `/status` 的 `HostStatus` 结构一致 +- 上报失败不会影响本地 HTTP 服务 + +变更服务逻辑时,重点验证: + +- Linux `install` 非 root 返回错误 +- systemd unit 的 `ExecStart` 保留 `run -config ` +- Windows `sc.exe create` 的 `binPath` 包含 `run -config ` +- `uninstall` 对已停止或不存在的服务尽力清理 + +## 文档关系 + +- [README.md](../README.md):项目入口与快速命令 +- [DEPLOYMENT.md](DEPLOYMENT.md):部署、验收、安全与排障 +- [ARCHITECTURE.md](ARCHITECTURE.md):运行时架构和工作流程 +- [AGENTS.md](../AGENTS.md):仓库规则与功能边界 +- [plan.md](../plan.md):历史设计草案,当前行为以代码和 docs 文档为准 + diff --git a/internal/collector/collector_linux.go b/internal/collector/collector_linux.go index a84fbdd..2a179f8 100644 --- a/internal/collector/collector_linux.go +++ b/internal/collector/collector_linux.go @@ -23,7 +23,10 @@ import ( "mfd-monitor-agent/internal/status" ) +// socketInodesFunc 是测试可替换的 Linux socket inode 到进程映射函数。 var socketInodesFunc = socketInodes + +// readTCPListenersFunc 是测试可替换的 Linux TCP 表解析函数。 var readTCPListenersFunc = readTCPListeners // collectSystem 从 Linux 填充主机标识、操作系统、架构与运行时长字段。 @@ -108,6 +111,7 @@ func collectProcesses(_ context.Context, out *status.HostStatus) error { } process, err := readProcess(pid) if err != nil { + // 进程可能在遍历期间退出,或因权限限制无法读取;保留其他进程并降级为 partial。 errs = append(errs, err) continue } @@ -130,6 +134,7 @@ func collectSockets(_ context.Context, out *status.HostStatus) error { var listeners []status.TCPListenerInfo var errs []error if inodeErr != nil { + // inode 映射失败通常意味着端口仍可见但进程归属不完整,后续继续解析 TCP 表。 errs = append(errs, inodeErr) } for _, file := range []struct { @@ -285,6 +290,7 @@ func readDisks() ([]status.DiskInfo, error) { } var stat syscall.Statfs_t if err := syscall.Statfs(fields[1], &stat); err != nil { + // 个别挂载点可能瞬时不可达;磁盘采集保持尽力返回其他挂载。 continue } total := stat.Blocks * uint64(stat.Bsize) @@ -331,6 +337,7 @@ func readProcess(pid int) (status.ProcessInfo, error) { vms := parseKB(statusValues["VmSize"]) username := "" if uid := firstField(statusValues["Uid"]); uid != "" { + // 用户名解析失败不影响进程主体信息,保留空用户名表示未知。 if u, err := user.LookupId(uid); err == nil { username = u.Username } @@ -339,6 +346,7 @@ func readProcess(pid int) (status.ProcessInfo, error) { if data, err := os.ReadFile(filepath.Join(base, "cmdline")); err == nil { cmdline = strings.Trim(strings.ReplaceAll(string(data), "\x00", " "), " ") } + // exe 和 stat 都可能因权限或进程退出失败;这些字段按空值/零值降级。 exe, _ := os.Readlink(filepath.Join(base, "exe")) statValues, _ := readProcessStat(filepath.Join(base, "stat")) return status.ProcessInfo{ @@ -400,6 +408,7 @@ func socketInodes() (map[string]status.ProcessInfo, error) { fdDir := filepath.Join(path, "fd") entries, err := os.ReadDir(fdDir) if err != nil { + // 无法读取 fd 目录会导致部分端口缺少 PID 映射,因此计数并向上报告 partial。 readErrors++ return nil } @@ -495,6 +504,7 @@ func parseAddress(raw string, family string) (string, int, error) { if family == "ipv6" && len(bytes) == 16 { reordered := make([]byte, 16) for i := 0; i < 16; i += 4 { + // /proc/net/tcp6 以 32 位小端字组保存 IPv6 地址,需逐字反转为网络序。 reordered[i], reordered[i+1], reordered[i+2], reordered[i+3] = bytes[i+3], bytes[i+2], bytes[i+1], bytes[i] } return net.IP(reordered).String(), int(port64), nil diff --git a/internal/collector/collector_windows.go b/internal/collector/collector_windows.go index cd937da..0c392b1 100644 --- a/internal/collector/collector_windows.go +++ b/internal/collector/collector_windows.go @@ -23,6 +23,7 @@ import ( "mfd-monitor-agent/internal/status" ) +// Windows API 常量保持与 Win32 头文件一致,用于 syscall 调用和结果解析。 const ( windowsAFInet = 2 windowsAFInet6 = 23 @@ -42,6 +43,7 @@ const ( ) var ( + // 下列 LazyDLL/Proc 是 Windows 原生 API 入口,按需加载以保持单二进制无外部包装依赖。 windowsKernel32 = syscall.NewLazyDLL("kernel32.dll") windowsGlobalMemoryStatusExProc = windowsKernel32.NewProc("GlobalMemoryStatusEx") windowsGetTickCount64Proc = windowsKernel32.NewProc("GetTickCount64") @@ -63,9 +65,13 @@ var ( ) // windowsMemoryStatus 镜像 GlobalMemoryStatusEx 使用的 Windows MEMORYSTATUSEX 结构。 +// 字段顺序和宽度必须匹配 Win32 ABI,不应随 Go 风格重排。 type windowsMemoryStatus struct { - Length uint32 - MemoryLoad uint32 + // Length 必须在调用前设置为结构体大小。 + Length uint32 + // MemoryLoad 是 Windows 返回的内存使用率百分比。 + MemoryLoad uint32 + // TotalPhys 和 AvailPhys 是物理内存总量与可用量,单位为字节。 TotalPhys uint64 AvailPhys uint64 TotalPageFile uint64 @@ -76,8 +82,10 @@ type windowsMemoryStatus struct { } // windowsMIBTCPRowOwnerPID 镜像 GetExtendedTcpTable 返回的 MIB_TCPROW_OWNER_PID 行。 +// 字段布局必须与 iphlpapi 的 IPv4 TCP 表行一致。 type windowsMIBTCPRowOwnerPID struct { - State uint32 + State uint32 + // LocalAddr 和 LocalPort 使用 Windows API 的原始网络字节序表示。 LocalAddr uint32 LocalPort uint32 RemoteAddr uint32 @@ -86,8 +94,10 @@ type windowsMIBTCPRowOwnerPID struct { } // windowsMIBTCP6RowOwnerPID 镜像 GetExtendedTcpTable 返回的 MIB_TCP6ROW_OWNER_PID 行。 +// 字段布局必须与 iphlpapi 的 IPv6 TCP 表行一致。 type windowsMIBTCP6RowOwnerPID struct { - State uint32 + State uint32 + // LocalAddr 是网络序 IPv6 地址字节。 LocalAddr [16]byte LocalScopeID uint32 LocalPort uint32 @@ -98,6 +108,7 @@ type windowsMIBTCP6RowOwnerPID struct { } // windowsSystemInfo 镜像 CPU 封装规模计算所需的 SYSTEM_INFO 子集。 +// 字段顺序必须匹配 Win32 ABI,当前仅使用处理器数量与掩码字段。 type windowsSystemInfo struct { ProcessorArchitecture uint16 Reserved uint16 @@ -120,6 +131,7 @@ type windowsSystemTimes struct { } // windowsLogicalProcessorInformation 镜像用于统计核心的 SYSTEM_LOGICAL_PROCESSOR_INFORMATION。 +// Data 只作为占位保留 ABI 大小,当前仅读取 Relationship。 type windowsLogicalProcessorInformation struct { ProcessorMask uintptr Relationship uint32 @@ -127,6 +139,7 @@ type windowsLogicalProcessorInformation struct { } // windowsProcessMemoryCounters 镜像 PROCESS_MEMORY_COUNTERS。 +// 字段宽度依赖平台指针大小,必须保持与 psapi 返回结构一致。 type windowsProcessMemoryCounters struct { CB uint32 PageFaultCount uint32 @@ -142,14 +155,20 @@ type windowsProcessMemoryCounters struct { // windowsProcessDetails 包含需额外句柄或命令才能获取的尽力进程字段。 type windowsProcessDetails struct { - Exe string - Cmdline string - Username string - Status string - StartTime int64 + // Exe 是完整映像路径;权限不足时为空。 + Exe string + // Cmdline 是命令行文本;WMIC 不可用或超时时为空。 + Cmdline string + // Username 是 DOMAIN\user;令牌读取失败时为空。 + Username string + Status string + // StartTime 是 Unix 秒级时间戳。 + StartTime int64 + // CPUPercent 是生命周期平均 CPU 占用百分比。 CPUPercent float64 - MemoryRSS uint64 - MemoryVMS uint64 + // MemoryRSS 和 MemoryVMS 单位为字节。 + MemoryRSS uint64 + MemoryVMS uint64 } // collectSystem 填充 Windows 主机标识、操作系统与架构字段。 @@ -204,12 +223,14 @@ func collectResource(ctx context.Context, out *status.HostStatus) error { out.CPU.UsagePercent = windowsReadCPUUsagePercent(ctx) memory, err := windowsReadMemory() if err != nil { + // 内存失败不阻止 CPU/磁盘字段返回,交由 snapshot 标记 partial。 errs = append(errs, err) } else { out.Memory = memory } disks, err := windowsReadDisks() if err != nil { + // 单个资源子项失败通过 joined error 上报,保留其他成功资源。 errs = append(errs, err) } else { out.Disks = disks @@ -234,6 +255,7 @@ func collectSockets(_ context.Context, out *status.HostStatus) error { processes, processErr := windowsProcessMap() var errs []error if processErr != nil { + // 进程元数据失败时仍可返回 TCP 表,只是进程名/路径映射可能为空。 errs = append(errs, processErr) } listeners4, err := windowsTCPListeners(windowsAFInet, "ipv4", processes) @@ -290,6 +312,7 @@ func windowsUptimeSeconds() uint64 { // windowsPhysicalCores 从 Windows 处理器拓扑返回物理 CPU 核心数。 func windowsPhysicalCores() int { var size uint32 + // Windows 典型模式:先查询所需缓冲区大小,再用正确大小重新调用。 result, _, _ := windowsGetLogicalProcessorInfoProc.Call(0, uintptr(unsafe.Pointer(&size))) if result == 0 && size > 0 { buf := make([]byte, size) @@ -308,6 +331,7 @@ func windowsPhysicalCores() int { } } } + // 老系统或 API 失败时退回到 SYSTEM_INFO,再退回 runtime.NumCPU。 var info windowsSystemInfo windowsGetNativeSystemInfoProc.Call(uintptr(unsafe.Pointer(&info))) if info.NumberOfProcessors > 0 { @@ -412,6 +436,7 @@ func windowsReadDisks() ([]status.DiskInfo, error) { rootPtr, _ := syscall.UTF16PtrFromString(root) driveType, _, _ := windowsGetDriveTypeWProc.Call(uintptr(unsafe.Pointer(rootPtr))) if driveType != windowsDriveFixed && driveType != windowsDriveRemote { + // 首版只暴露固定盘和网络盘,跳过光驱、可移动盘等不稳定介质。 continue } var freeAvailable, total, free uint64 @@ -422,6 +447,7 @@ func windowsReadDisks() ([]status.DiskInfo, error) { uintptr(unsafe.Pointer(&free)), ) if ok == 0 { + // 个别盘符失败不影响其他磁盘,错误汇总后让快照降级为 partial。 errs = append(errs, fmt.Errorf("%s: %w", root, err)) continue } @@ -493,6 +519,7 @@ func windowsTCPListeners(family int, familyName string, processes map[int]status // windowsExtendedTCPTable 调用 GetExtendedTcpTable 并返回原始表字节。 func windowsExtendedTCPTable(family int) ([]byte, error) { var size uint32 + // 第一次调用用于获取所需缓冲区大小,ERROR_INSUFFICIENT_BUFFER 是预期结果。 result, _, _ := windowsGetExtendedTCPTableProc.Call( 0, uintptr(unsafe.Pointer(&size)), @@ -508,6 +535,7 @@ func windowsExtendedTCPTable(family int) ([]byte, error) { return nil, nil } table := make([]byte, size) + // 第二次调用用已分配缓冲区读取包含行数和行数据的原始表。 result, _, _ = windowsGetExtendedTCPTableProc.Call( uintptr(unsafe.Pointer(&table[0])), uintptr(unsafe.Pointer(&size)), @@ -529,6 +557,7 @@ func windowsParseTCP4Rows(rows []byte, count uint32, processes map[int]status.Pr for i := 0; i < int(count); i++ { offset := i * rowSize if offset+rowSize > len(rows) { + // 防御损坏或截断的 API 返回,避免 unsafe 读取越界。 break } row := *(*windowsMIBTCPRowOwnerPID)(unsafe.Pointer(&rows[offset])) @@ -536,6 +565,7 @@ func windowsParseTCP4Rows(rows []byte, count uint32, processes map[int]status.Pr continue } process := processes[int(row.OwningPID)] + // PID 映射缺失时 process 为零值,仍返回端口本身供调用方排障。 addr := row.LocalAddr items = append(items, status.TCPListenerInfo{ IP: net.IPv4(byte(addr), byte(addr>>8), byte(addr>>16), byte(addr>>24)).String(), @@ -553,6 +583,7 @@ func windowsParseTCP6Rows(rows []byte, count uint32, processes map[int]status.Pr for i := 0; i < int(count); i++ { offset := i * rowSize if offset+rowSize > len(rows) { + // 防御损坏或截断的 API 返回,避免 unsafe 读取越界。 break } row := *(*windowsMIBTCP6RowOwnerPID)(unsafe.Pointer(&rows[offset])) @@ -560,6 +591,7 @@ func windowsParseTCP6Rows(rows []byte, count uint32, processes map[int]status.Pr continue } process := processes[int(row.OwningPID)] + // PID 映射缺失时 process 为零值,仍返回端口本身供调用方排障。 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", @@ -623,6 +655,7 @@ func windowsProcessInfoFromEntry(entry syscall.ProcessEntry32, details windowsPr func windowsReadProcessDetails(pid uint32) windowsProcessDetails { handle, err := syscall.OpenProcess(windowsProcessQueryLimitedInfo|windowsProcessQueryInformation|windowsProcessVMRead, false, pid) if err != nil { + // 系统进程或权限不足时无法打开句柄,调用方仍保留 Toolhelp 的基础进程记录。 return windowsProcessDetails{Status: "unknown"} } defer syscall.CloseHandle(handle) @@ -739,6 +772,7 @@ func windowsProcessCommandLines() map[int]string { defer cancel() output, err := exec.CommandContext(ctx, "wmic", "process", "get", "ProcessId,CommandLine", "/format:csv").Output() if err != nil { + // WMIC 在新系统上可能不可用;命令行字段按空值降级。 return out } reader := csv.NewReader(bytes.NewReader(output)) diff --git a/internal/config/config.go b/internal/config/config.go index b6dad72..012cf9b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,45 +1,68 @@ package config import ( + "crypto/rand" + "encoding/hex" "encoding/json" "errors" + "fmt" "os" "strconv" "time" ) +// DefaultConfigFileName 是未指定 -config 时在工作目录下使用的默认配置文件名。 +const DefaultConfigFileName = "config.json" + // Config 包含运行时、上报、采集与服务注册相关的全部配置项。 type Config struct { - HostID string `json:"host_id"` - HTTP HTTPConfig `json:"http"` + // HostID 是对外上报和查询时使用的机器标识;为空时采集器会尽力回退为主机名。 + HostID string `json:"host_id"` + // HTTP 控制本地查询 API 的监听与 token 鉴权。 + HTTP HTTPConfig `json:"http"` + // Collect 控制后台快照采集周期与单轮超时。 Collect CollectConfig `json:"collect"` - Report ReportConfig `json:"report"` + // Report 控制可选的中心端主动上报流程。 + Report ReportConfig `json:"report"` + // Service 控制 Linux systemd unit 与 Windows 服务注册元数据。 Service ServiceConfig `json:"service"` } -// HTTPConfig 控制本地 HTTP API 的监听地址。 +// HTTPConfig 控制本地 HTTP API 的监听地址与查询参数鉴权 token。 type HTTPConfig struct { + // ListenAddress 是 HTTP API 监听地址,默认 0.0.0.0:8080 会暴露到所有网卡。 ListenAddress string `json:"listen_address"` + // Token 用于 GET 请求查询参数 ?token= 的鉴权,禁止为空。 + Token string `json:"token"` } // CollectConfig 控制周期性主机状态采集。 type CollectConfig struct { + // Interval 是后台刷新 HostStatus 缓存的周期。 Interval Duration `json:"interval"` - Timeout Duration `json:"timeout"` + // Timeout 是单轮聚合采集的整体截止时间。 + Timeout Duration `json:"timeout"` } // ReportConfig 控制可选的周期性状态 POST 上报。 type ReportConfig struct { - Enabled bool `json:"enabled"` - Endpoint string `json:"endpoint"` + // Enabled 控制是否启动主动上报循环。 + Enabled bool `json:"enabled"` + // Endpoint 是中心端接收 HostStatus JSON 的 HTTP URL,启用上报时必填。 + Endpoint string `json:"endpoint"` + // Interval 是两次上报尝试之间的周期。 Interval Duration `json:"interval"` - Timeout Duration `json:"timeout"` + // Timeout 是单次 POST 请求的 HTTP 客户端超时。 + Timeout Duration `json:"timeout"` } // ServiceConfig 控制操作系统服务注册时使用的元数据。 type ServiceConfig struct { - Name string `json:"name"` + // Name 是 systemd unit 名称和 Windows 服务名。 + Name string `json:"name"` + // DisplayName 是 Windows 服务管理器中显示的名称。 DisplayName string `json:"display_name"` + // Description 是 systemd unit 与 Windows 服务的说明文本。 Description string `json:"description"` } @@ -70,10 +93,10 @@ func (d *Duration) UnmarshalJSON(data []byte) error { return nil } -// Default 返回适合本地运行的安全默认配置。 +// Default 返回内存中的默认配置(不含 token,需由配置文件或 LoadOrCreate 提供)。 func Default() Config { return Config{ - HTTP: HTTPConfig{ListenAddress: "127.0.0.1:8080"}, + HTTP: HTTPConfig{ListenAddress: "0.0.0.0:8080"}, Collect: CollectConfig{ Interval: Duration(30 * time.Second), Timeout: Duration(10 * time.Second), @@ -91,25 +114,86 @@ func Default() Config { } } -// Load 读取可选的 JSON 配置文件,应用环境变量覆盖,并校验必填项。 +// ResolvePath 将 CLI 传入的配置路径解析为实际文件路径。 +func ResolvePath(explicit string) string { + if explicit != "" { + return explicit + } + return DefaultConfigFileName +} + +// LoadOrCreate 在配置文件不存在时写入含随机 token 的默认配置,再加载。 +// 仅供 agent run 使用;服务安装等场景应使用 Load。 +func LoadOrCreate(path string) (Config, error) { + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + cfg := Default() + // 自动生成配置时必须创建强随机 token,避免默认空 token 暴露主机状态 API。 + token, err := generateToken() + if err != nil { + return cfg, fmt.Errorf("generate http token: %w", err) + } + cfg.HTTP.Token = token + if err := writeConfigFile(path, cfg); err != nil { + return cfg, err + } + } + return Load(path) +} + +// Load 读取 JSON 配置文件,应用环境变量覆盖,并校验必填项。 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 - } + if path == "" { + return cfg, errors.New("config path is required") + } + 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") + if err := validate(&cfg); err != nil { + return cfg, err } return cfg, nil } +// validate 检查配置中的必填与安全约束。 +func validate(cfg *Config) error { + if cfg.HTTP.Token == "" { + return errors.New("http.token is required") + } + if cfg.Report.Enabled && cfg.Report.Endpoint == "" { + return errors.New("report endpoint is required when reporting is enabled") + } + return nil +} + +// writeConfigFile 将配置以缩进 JSON 写入 path,权限 0600。 +func writeConfigFile(path string, cfg Config) error { + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + data = append(data, '\n') + // 配置内包含 HTTP token,默认只允许当前用户读写。 + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("write config %s: %w", path, err) + } + return nil +} + +// generateToken 生成用于 HTTP 查询参数鉴权的随机十六进制字符串。 +func generateToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + // applyEnv 将支持的 MFD_AGENT_* 环境变量覆盖到 cfg 上。 func applyEnv(cfg *Config) { if value := os.Getenv("MFD_AGENT_HOST_ID"); value != "" { @@ -118,7 +202,11 @@ func applyEnv(cfg *Config) { if value := os.Getenv("MFD_AGENT_LISTEN"); value != "" { cfg.HTTP.ListenAddress = value } + if value := os.Getenv("MFD_AGENT_TOKEN"); value != "" { + cfg.HTTP.Token = value + } if value := os.Getenv("MFD_AGENT_REPORT_ENABLED"); value != "" { + // 非法环境变量值保持文件配置不变,避免拼写错误让 agent 无法启动。 if parsed, err := strconv.ParseBool(value); err == nil { cfg.Report.Enabled = parsed } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index af9018d..5148230 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,18 +1,23 @@ package config import ( + "encoding/json" "os" + "path/filepath" "testing" "time" ) -// TestDefaultConfigUsesLocalhostAndSaneIntervals 验证默认配置使用本地监听地址与合理的采集间隔。 -func TestDefaultConfigUsesLocalhostAndSaneIntervals(t *testing.T) { +// TestDefaultConfigUsesAllInterfacesAndSaneIntervals 验证默认配置监听所有网卡且采集间隔合理。 +func TestDefaultConfigUsesAllInterfacesAndSaneIntervals(t *testing.T) { cfg := Default() - if cfg.HTTP.ListenAddress != "127.0.0.1:8080" { + if cfg.HTTP.ListenAddress != "0.0.0.0:8080" { t.Fatalf("listen address = %q", cfg.HTTP.ListenAddress) } + if cfg.HTTP.Token != "" { + t.Fatalf("default config should not include token") + } if cfg.Collect.Interval <= 0 || cfg.Collect.Timeout <= 0 { t.Fatalf("collect intervals must be positive: %+v", cfg.Collect) } @@ -21,15 +26,85 @@ func TestDefaultConfigUsesLocalhostAndSaneIntervals(t *testing.T) { } } +// TestResolvePathUsesDefaultFileName 验证空路径解析为 config.json。 +func TestResolvePathUsesDefaultFileName(t *testing.T) { + if got := ResolvePath(""); got != DefaultConfigFileName { + t.Fatalf("path = %q", got) + } + if got := ResolvePath("/etc/agent.json"); got != "/etc/agent.json" { + t.Fatalf("path = %q", got) + } +} + +// TestLoadOrCreateWritesConfigWithToken 验证缺失配置文件时会生成含 token 的 JSON。 +func TestLoadOrCreateWritesConfigWithToken(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + cfg, err := LoadOrCreate(path) + if err != nil { + t.Fatal(err) + } + if cfg.HTTP.Token == "" { + t.Fatal("expected generated token") + } + if cfg.HTTP.ListenAddress != "0.0.0.0:8080" { + t.Fatalf("listen address = %q", cfg.HTTP.ListenAddress) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("file mode = %o", info.Mode().Perm()) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var onDisk Config + if err := json.Unmarshal(data, &onDisk); err != nil { + t.Fatal(err) + } + if onDisk.HTTP.Token != cfg.HTTP.Token { + t.Fatalf("token mismatch: disk=%q loaded=%q", onDisk.HTTP.Token, cfg.HTTP.Token) + } +} + +// TestLoadRejectsEmptyToken 验证未配置 token 时无法启动。 +func TestLoadRejectsEmptyToken(t *testing.T) { + file, err := os.CreateTemp(t.TempDir(), "config-*.json") + if err != nil { + t.Fatal(err) + } + _, _ = file.WriteString(`{"http":{"listen_address":"0.0.0.0:8080","token":""}}`) + _ = file.Close() + + _, err = Load(file.Name()) + if err == nil { + t.Fatal("expected error for empty token") + } +} + // TestLoadAppliesEnvironmentOverrides 验证环境变量可覆盖默认配置。 func TestLoadAppliesEnvironmentOverrides(t *testing.T) { + file, err := os.CreateTemp(t.TempDir(), "config-*.json") + if err != nil { + t.Fatal(err) + } + _, _ = file.WriteString(`{"http":{"listen_address":"0.0.0.0:8080","token":"base-token"}}`) + _ = file.Close() + t.Setenv("MFD_AGENT_LISTEN", "127.0.0.1:9090") + t.Setenv("MFD_AGENT_TOKEN", "env-token") 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("") + cfg, err := Load(file.Name()) if err != nil { t.Fatal(err) } @@ -37,6 +112,9 @@ func TestLoadAppliesEnvironmentOverrides(t *testing.T) { if cfg.HTTP.ListenAddress != "127.0.0.1:9090" { t.Fatalf("listen address = %q", cfg.HTTP.ListenAddress) } + if cfg.HTTP.Token != "env-token" { + t.Fatalf("token = %q", cfg.HTTP.Token) + } if !cfg.Report.Enabled || cfg.Report.Endpoint != "http://center/status" { t.Fatalf("report config not applied: %+v", cfg.Report) } @@ -54,7 +132,7 @@ func TestLoadJSONConfigFile(t *testing.T) { if err != nil { t.Fatal(err) } - _, _ = file.WriteString(`{"http":{"listen_address":"127.0.0.1:7070"},"collect":{"interval":"3s","timeout":"1s"}}`) + _, _ = file.WriteString(`{"http":{"listen_address":"127.0.0.1:7070","token":"secret"},"collect":{"interval":"3s","timeout":"1s"}}`) _ = file.Close() cfg, err := Load(file.Name()) @@ -65,6 +143,9 @@ func TestLoadJSONConfigFile(t *testing.T) { if cfg.HTTP.ListenAddress != "127.0.0.1:7070" { t.Fatalf("listen address = %q", cfg.HTTP.ListenAddress) } + if cfg.HTTP.Token != "secret" { + t.Fatalf("token = %q", cfg.HTTP.Token) + } if cfg.Collect.Interval.Duration() != 3*time.Second || cfg.Collect.Timeout.Duration() != time.Second { t.Fatalf("collect config = %+v", cfg.Collect) } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 894c489..787073d 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -1,6 +1,7 @@ package httpapi import ( + "crypto/subtle" "encoding/json" "net/http" @@ -24,8 +25,9 @@ func (p StaticProvider) Get() (status.HostStatus, bool) { return p.Snapshot, p.OK } -// New 创建提供 health 与 status 端点的 HTTP API 处理器。 -func New(provider Provider) http.Handler { +// New 创建提供 health 与 status 端点的 HTTP API 处理器;token 用于查询参数 ?token= 鉴权。 +// 两个端点都只读取 provider 缓存,不会在请求路径上触发实时系统扫描。 +func New(provider Provider, token string) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -58,10 +60,32 @@ func New(provider Provider) http.Handler { } writeJSON(w, http.StatusOK, snapshot) }) - return mux + return withQueryTokenAuth(token, mux) } -// writeJSON 以给定状态码将 value 序列化为 JSON 响应。 +// withQueryTokenAuth 校验 GET 请求中的 token 查询参数,失败时返回 401。 +func withQueryTokenAuth(expected string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 空 token 一律拒绝,配合配置校验避免无鉴权暴露主机状态。 + if !queryTokenValid(r.URL.Query().Get("token"), expected) { + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "error": "unauthorized", + }) + return + } + next.ServeHTTP(w, r) + }) +} + +// queryTokenValid 以恒定时间比较查询参数 token 与配置值,避免时序侧信道。 +func queryTokenValid(got, want string) bool { + if got == "" || want == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} + +// writeJSON 以给定状态码将 value 序列化为 JSON 响应;编码失败时连接会被动结束。 func writeJSON(w http.ResponseWriter, code int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 11c569e..5fd655d 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -10,15 +10,22 @@ import ( "mfd-monitor-agent/internal/status" ) +const testToken = "test-token" + +// withToken 为请求 URL 附加鉴权查询参数。 +func withToken(path string) string { + return path + "?token=" + testToken +} + // TestStatusReturnsCachedSnapshot 验证 /status 读取提供者缓存。 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) + server := New(provider, testToken) - req := httptest.NewRequest(http.MethodGet, "/status", nil) + req := httptest.NewRequest(http.MethodGet, withToken("/status"), nil) rec := httptest.NewRecorder() server.ServeHTTP(rec, req) @@ -39,9 +46,9 @@ 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) + server := New(provider, testToken) - req := httptest.NewRequest(http.MethodGet, "/health", nil) + req := httptest.NewRequest(http.MethodGet, withToken("/health"), nil) rec := httptest.NewRecorder() server.ServeHTTP(rec, req) @@ -59,9 +66,9 @@ func TestHealthReportsSnapshotMetadata(t *testing.T) { // TestStatusReturnsServiceUnavailableBeforeSnapshot 验证 /status 不会在请求时实时采集。 func TestStatusReturnsServiceUnavailableBeforeSnapshot(t *testing.T) { - server := New(StaticProvider{OK: false}) + server := New(StaticProvider{OK: false}, testToken) - req := httptest.NewRequest(http.MethodGet, "/status", nil) + req := httptest.NewRequest(http.MethodGet, withToken("/status"), nil) rec := httptest.NewRecorder() server.ServeHTTP(rec, req) @@ -72,9 +79,9 @@ func TestStatusReturnsServiceUnavailableBeforeSnapshot(t *testing.T) { // TestHealthReturnsStartingBeforeSnapshot 验证 /health 在启动阶段暴露 starting 状态。 func TestHealthReturnsStartingBeforeSnapshot(t *testing.T) { - server := New(StaticProvider{OK: false}) + server := New(StaticProvider{OK: false}, testToken) - req := httptest.NewRequest(http.MethodGet, "/health", nil) + req := httptest.NewRequest(http.MethodGet, withToken("/health"), nil) rec := httptest.NewRecorder() server.ServeHTTP(rec, req) @@ -92,9 +99,9 @@ func TestHealthReturnsStartingBeforeSnapshot(t *testing.T) { // TestStatusRejectsNonGET 验证 /status 仅接受 GET 请求。 func TestStatusRejectsNonGET(t *testing.T) { - server := New(StaticProvider{OK: true}) + server := New(StaticProvider{OK: true}, testToken) - req := httptest.NewRequest(http.MethodPost, "/status", nil) + req := httptest.NewRequest(http.MethodPost, withToken("/status"), nil) rec := httptest.NewRecorder() server.ServeHTTP(rec, req) @@ -105,9 +112,9 @@ func TestStatusRejectsNonGET(t *testing.T) { // TestHealthRejectsNonGET 验证 /health 仅接受 GET 请求。 func TestHealthRejectsNonGET(t *testing.T) { - server := New(StaticProvider{OK: true}) + server := New(StaticProvider{OK: true}, testToken) - req := httptest.NewRequest(http.MethodPost, "/health", nil) + req := httptest.NewRequest(http.MethodPost, withToken("/health"), nil) rec := httptest.NewRecorder() server.ServeHTTP(rec, req) @@ -115,3 +122,29 @@ func TestHealthRejectsNonGET(t *testing.T) { t.Fatalf("status code = %d", rec.Code) } } + +// TestMissingTokenReturnsUnauthorized 验证缺少 token 时返回 401。 +func TestMissingTokenReturnsUnauthorized(t *testing.T) { + server := New(StaticProvider{OK: true}, testToken) + + req := httptest.NewRequest(http.MethodGet, "/status", nil) + rec := httptest.NewRecorder() + server.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status code = %d", rec.Code) + } +} + +// TestWrongTokenReturnsUnauthorized 验证错误 token 时返回 401。 +func TestWrongTokenReturnsUnauthorized(t *testing.T) { + server := New(StaticProvider{OK: true}, testToken) + + req := httptest.NewRequest(http.MethodGet, "/status?token=wrong", nil) + rec := httptest.NewRecorder() + server.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status code = %d", rec.Code) + } +} diff --git a/internal/reporter/reporter.go b/internal/reporter/reporter.go index 634c4da..05bdb6b 100644 --- a/internal/reporter/reporter.go +++ b/internal/reporter/reporter.go @@ -12,6 +12,7 @@ import ( "mfd-monitor-agent/internal/status" ) +// defaultRetryDelay 是单次上报失败后重试前等待的默认时长。 const defaultRetryDelay = 2 * time.Second // Provider 提供用于外发上报的缓存 HostStatus。 @@ -35,6 +36,7 @@ func NewClient(endpoint string, timeout time.Duration) *Client { } // Report 将一份 HostStatus 以 JSON POST 上报;非 2xx 响应作为错误返回。 +// 请求超时由 Client 内部的 http.Client.Timeout 控制。 func (c *Client) Report(snapshot status.HostStatus) error { body, err := json.Marshal(snapshot) if err != nil { @@ -70,11 +72,14 @@ func runWithRetryDelay(ctx context.Context, provider Provider, client *Client, i case <-ctx.Done(): return case <-ticker.C: + // 上报只读取缓存,避免与 HTTP 查询之外再触发一轮昂贵采集。 snapshot, ok := provider.Get() if !ok { + // 首次快照未就绪时跳过本轮,上报循环继续等待下一周期。 continue } if err := reportWithRetry(ctx, client, snapshot, retryDelay); err != nil { + // 上报失败只写日志,不影响本地 HTTP 服务与后续采集。 logger.Warn("status report failed", "error", err) } } @@ -87,6 +92,7 @@ func reportWithRetry(ctx context.Context, client *Client, snapshot status.HostSt if err == nil { return nil } + // 重试等待受 ctx 控制,agent 退出时不会被固定退避时间拖住。 select { case <-ctx.Done(): return ctx.Err() diff --git a/internal/service/service_linux.go b/internal/service/service_linux.go index 9010c7e..d2f8d8a 100644 --- a/internal/service/service_linux.go +++ b/internal/service/service_linux.go @@ -11,6 +11,7 @@ import ( "mfd-monitor-agent/internal/config" ) +// currentUID 是测试可替换的当前用户 ID 查询函数。 var currentUID = os.Geteuid // linuxManager 通过 systemd 管理 agent 服务。 @@ -28,9 +29,11 @@ func (linuxManager) Install(executable string, cfg config.Config, opts InstallOp } unit := renderSystemdUnit(executable, opts.ConfigPath, cfg) path := "/etc/systemd/system/" + cfg.Service.Name + ".service" + // 安装阶段写入 systemd unit,需要 root 权限且会影响系统服务注册。 if err := os.WriteFile(path, []byte(unit), 0644); err != nil { return fmt.Errorf("write systemd unit: %w", err) } + // unit 文件变化后必须重载 systemd,再启用服务随系统启动。 if err := exec.Command("systemctl", "daemon-reload").Run(); err != nil { return fmt.Errorf("systemctl daemon-reload: %w", err) } @@ -44,6 +47,7 @@ func (linuxManager) Install(executable string, cfg config.Config, opts InstallOp func renderSystemdUnit(executable string, configPath string, cfg config.Config) string { args := []string{executable, "run"} if configPath != "" { + // install 接收的 -config 会固化到 ExecStart,保证服务模式读取同一配置。 args = append(args, "-config", configPath) } return fmt.Sprintf(`[Unit] @@ -64,6 +68,7 @@ WantedBy=multi-user.target // Uninstall 禁用服务、删除 unit 文件并重载 systemd。 func (linuxManager) Uninstall(cfg config.Config) error { + // 服务可能已经停止或未启用,忽略 disable 错误以继续清理 unit 文件。 _ = 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 diff --git a/internal/service/service_windows.go b/internal/service/service_windows.go index b633a78..4b4f5eb 100644 --- a/internal/service/service_windows.go +++ b/internal/service/service_windows.go @@ -21,13 +21,16 @@ func newManager() Manager { func (windowsManager) Install(executable string, cfg config.Config, opts InstallOptions) error { args := []string{`"` + executable + `"`, "run"} if opts.ConfigPath != "" { + // -config 被写入 binPath,确保 SCM 启动的 run 子命令读取指定配置。 args = append(args, "-config", `"`+opts.ConfigPath+`"`) } + // sc.exe 的 binPath= 参数要求等号后单独一个参数,因此这里保持分段传参。 return exec.Command("sc.exe", "create", cfg.Service.Name, "binPath=", strings.Join(args, " "), "DisplayName=", cfg.Service.DisplayName, "start=", "auto").Run() } // Uninstall 停止并删除已配置的 Windows 服务。 func (windowsManager) Uninstall(cfg config.Config) error { + // 服务可能未运行,忽略 stop 错误并继续删除服务注册。 _ = exec.Command("sc.exe", "stop", cfg.Service.Name).Run() return exec.Command("sc.exe", "delete", cfg.Service.Name).Run() } diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index 393bb25..830e32e 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -31,12 +31,17 @@ func (fn CollectorFunc) Collect(ctx context.Context, out *status.HostStatus) err // Cache 在并发安全接口后保存最近一次 HostStatus 快照。 type Cache struct { + // collectors 是按固定顺序执行的子采集器集合。 collectors []Collector - version string - startedAt time.Time - mu sync.RWMutex - last status.HostStatus - hasLast bool + // version 和 startedAt 会写入每次快照的 agent 元数据。 + version string + startedAt time.Time + // mu 保护 last 和 hasLast,允许 HTTP 与上报并发读取缓存。 + mu sync.RWMutex + // last 保存最近一次完整或 partial 的 HostStatus。 + last status.HostStatus + // hasLast 表示首次采集是否已经完成。 + hasLast bool } // NewCache 使用给定采集器与 agent 元数据创建空的快照缓存。 @@ -59,6 +64,7 @@ func (c *Cache) Collect(ctx context.Context) status.HostStatus { for _, collector := range c.collectors { if err := collector.Collect(ctx, &out); err != nil { + // 单个子采集器失败只让整份快照降级为 partial,其他已成功字段继续对外可用。 out.Agent.SnapshotStatus = "partial" out.Errors = append(out.Errors, status.StatusError{ Collector: collector.Name(), @@ -67,6 +73,7 @@ func (c *Cache) Collect(ctx context.Context) status.HostStatus { } } + // 发布快照时一次性替换缓存,避免读者看到半写入的结构。 c.mu.Lock() c.last = out c.hasLast = true @@ -83,6 +90,7 @@ func (c *Cache) Get() (status.HostStatus, bool) { // Run 立即采集一次,随后在 ctx 取消前按间隔刷新缓存。 func (c *Cache) Run(ctx context.Context, interval time.Duration, timeout time.Duration) { + // 启动后先同步完成一轮采集,让 /health 和 /status 尽快进入就绪状态。 c.collectWithTimeout(ctx, timeout) ticker := time.NewTicker(interval) defer ticker.Stop() @@ -98,6 +106,7 @@ func (c *Cache) Run(ctx context.Context, interval time.Duration, timeout time.Du // collectWithTimeout 在整体截止时间下执行一轮采集。 func (c *Cache) collectWithTimeout(ctx context.Context, timeout time.Duration) { + // 超时通过 context 传递给子采集器;无法被 context 打断的系统调用仍依赖平台返回。 collectCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() c.Collect(collectCtx) diff --git a/internal/status/types.go b/internal/status/types.go index d5276ef..b82c40e 100644 --- a/internal/status/types.go +++ b/internal/status/types.go @@ -5,22 +5,32 @@ import "time" // HostStatus 是 /status 返回的完整缓存主机快照,也是上报器 POST 的载荷结构。 type HostStatus struct { // Agent 描述 agent 进程及最近一次快照的元数据。 - 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"` + Agent AgentInfo `json:"agent"` + // Host 描述被监控主机的身份、平台与运行时长。 + Host HostInfo `json:"host"` + // Network 汇总网卡、IP 与 MAC 地址信息。 + Network NetworkInfo `json:"network"` + // CPU 描述主机 CPU 拓扑与最近一次采样利用率。 + CPU CPUInfo `json:"cpu"` + // Memory 描述主机内存容量与使用率。 + Memory MemoryInfo `json:"memory"` + // Disks 列出已挂载且被纳入监控的文件系统容量数据。 + Disks []DiskInfo `json:"disks"` + // Processes 包含进程采集状态与完整进程列表。 + Processes ProcessResult `json:"processes"` + // TCPListeners 包含 TCP 监听端口采集状态与端口到进程的映射。 + TCPListeners TCPResult `json:"tcp_listeners"` // Errors 记录导致快照变为 partial 的子采集器失败信息。 Errors []StatusError `json:"errors"` } // AgentInfo 包含 agent 进程元数据及最近一次采集结果。 type AgentInfo struct { - Version string `json:"version"` - StartedAt time.Time `json:"started_at"` + // Version 是当前 agent 二进制版本。 + Version string `json:"version"` + // StartedAt 是 agent 进程启动时间(UTC)。 + StartedAt time.Time `json:"started_at"` + // SnapshotAt 是最近一次快照开始构造的时间(UTC)。 SnapshotAt time.Time `json:"snapshot_at"` // SnapshotStatus 在所有采集器成功时为 ok,任一失败时为 partial。 SnapshotStatus string `json:"snapshot_status"` @@ -28,41 +38,58 @@ type AgentInfo struct { // HostInfo 描述被监控主机的标识与操作系统信息。 type HostInfo struct { - HostID string `json:"host_id"` - Hostname string `json:"hostname"` - Platform string `json:"platform"` + // HostID 是配置的机器标识;为空时平台采集器通常回退为主机名。 + HostID string `json:"host_id"` + // Hostname 是操作系统返回的当前主机名。 + Hostname string `json:"hostname"` + // Platform 是 Go 运行时平台名,例如 linux 或 windows。 + Platform string `json:"platform"` + // PlatformVersion 是尽力采集的平台版本说明。 PlatformVersion string `json:"platform_version"` - Arch string `json:"arch"` + // Arch 是 Go 运行时架构名,例如 amd64。 + Arch string `json:"arch"` // UptimeSeconds 为主机运行时长(秒),在可用时填充。 UptimeSeconds uint64 `json:"uptime_seconds"` } // NetworkInfo 包含全部网卡及展平后的 IPv4、IPv6、MAC 列表。 type NetworkInfo struct { + // Interfaces 列出所有可见网卡及其地址。 Interfaces []NetworkInterface `json:"interfaces"` - IPv4s []string `json:"ipv4s"` - IPv6s []string `json:"ipv6s"` - MACs []string `json:"macs"` + // IPv4s 是从网卡地址展平并排序后的 IPv4 CIDR/地址列表。 + IPv4s []string `json:"ipv4s"` + // IPv6s 是从网卡地址展平并排序后的 IPv6 CIDR/地址列表。 + IPv6s []string `json:"ipv6s"` + // MACs 是去重并排序后的非空 MAC 地址列表。 + MACs []string `json:"macs"` } // NetworkInterface 描述一块网卡及其配置的地址。 type NetworkInterface struct { + // Name 是操作系统中的网卡名称。 Name string `json:"name"` - MAC string `json:"mac"` + // MAC 是网卡硬件地址;虚拟或环回接口可能为空。 + MAC string `json:"mac"` // MTU 为接口最大传输单元(字节)。 - MTU int `json:"mtu"` - Flags []string `json:"flags"` + MTU int `json:"mtu"` + // Flags 是操作系统接口标志的稳定字符串列表。 + Flags []string `json:"flags"` + // Addresses 是接口配置的原始地址列表,通常包含 CIDR 前缀。 Addresses []string `json:"addresses"` } // CPUInfo 描述主机 CPU 容量与当前利用率。 type CPUInfo struct { - LogicalCores int `json:"logical_cores"` + // LogicalCores 是运行时可见的逻辑 CPU 数。 + LogicalCores int `json:"logical_cores"` + // PhysicalCores 是尽力采集的物理核心数;平台不可用时可能为 0。 PhysicalCores int `json:"physical_cores"` // UsagePercent 为最近一次采集得到的尽力主机 CPU 使用率(百分比)。 - UsagePercent float64 `json:"usage_percent"` - LoadAvg []float64 `json:"load_avg"` - ModelName string `json:"model_name"` + UsagePercent float64 `json:"usage_percent"` + // LoadAvg 是平台支持时的 1/5/15 分钟负载均值。 + LoadAvg []float64 `json:"load_avg"` + // ModelName 是尽力采集的 CPU 型号名称。 + ModelName string `json:"model_name"` } // MemoryInfo 描述主机内存容量与使用情况,数值单位由 Unit 字段声明。 @@ -83,9 +110,12 @@ type MemoryInfo struct { // DiskInfo 描述一块已挂载文件系统的容量与使用情况。 type DiskInfo struct { - Device string `json:"device"` + // Device 是文件系统来源设备或盘符。 + Device string `json:"device"` + // MountPoint 是文件系统挂载点或 Windows 盘符根路径。 MountPoint string `json:"mount_point"` - FSType string `json:"fs_type"` + // FSType 是文件系统类型或平台驱动器类型标签。 + FSType string `json:"fs_type"` // Unit 声明 total、used、free 使用的单位。 Unit string `json:"unit"` // Total 为文件系统总容量(与 Unit 一致)。 @@ -101,19 +131,27 @@ type DiskInfo struct { // ProcessResult 包含进程采集状态及进程列表。 type ProcessResult struct { // Status 根据进程采集完整程度为 ok、partial 或 error。 - Status string `json:"status"` - Items []ProcessInfo `json:"items"` + Status string `json:"status"` + // Items 是最近一次采集到的进程列表;partial 时仍包含成功读取的条目。 + Items []ProcessInfo `json:"items"` } // ProcessInfo 描述一个操作系统进程。 type ProcessInfo struct { - PID int `json:"pid"` - PPID int `json:"ppid"` - Name string `json:"name"` - Exe string `json:"exe"` - Cmdline string `json:"cmdline"` + // PID 是操作系统进程 ID。 + PID int `json:"pid"` + // PPID 是父进程 ID。 + PPID int `json:"ppid"` + // Name 是进程短名称。 + Name string `json:"name"` + // Exe 是可执行文件路径;权限不足时可能为空或退化为短名称。 + Exe string `json:"exe"` + // Cmdline 是完整命令行;权限或平台限制可能导致为空。 + Cmdline string `json:"cmdline"` + // Username 是进程所属用户;权限不足时可能为空。 Username string `json:"username"` - Status string `json:"status"` + // Status 是平台提供的进程状态标签。 + Status string `json:"status"` // StartTime 为平台相关的进程启动时间戳,在可用时填充。 StartTime int64 `json:"start_time"` // CPUPercent 为尽力估算的进程 CPU 使用率(百分比)。 @@ -127,22 +165,31 @@ type ProcessInfo struct { // TCPResult 包含 TCP 监听采集状态及监听项列表。 type TCPResult struct { // Status 根据监听端口到进程映射的完整程度为 ok、partial 或 error。 - Status string `json:"status"` - Items []TCPListenerInfo `json:"items"` + Status string `json:"status"` + // Items 是监听 TCP 套接字列表;partial 时可能缺少部分进程映射。 + Items []TCPListenerInfo `json:"items"` } // TCPListenerInfo 描述一个处于监听状态的 TCP 套接字及所属进程(若已知)。 type TCPListenerInfo struct { - IP string `json:"ip"` - Port int `json:"port"` - PID int `json:"pid"` + // IP 是本地监听地址。 + IP string `json:"ip"` + // Port 是本地监听端口。 + Port int `json:"port"` + // PID 是监听套接字所属进程 ID;未知时为 0。 + PID int `json:"pid"` + // ProcessName 是 PID 对应的进程名称;映射失败时为空。 ProcessName string `json:"process_name"` - ProcessExe string `json:"process_exe"` - Family string `json:"family"` + // ProcessExe 是 PID 对应的可执行路径;权限不足时可能为空。 + ProcessExe string `json:"process_exe"` + // Family 是地址族标签,通常为 ipv4 或 ipv6。 + Family string `json:"family"` } // StatusError 记录单个子采集器失败,不导致整份快照请求失败。 type StatusError struct { + // Collector 是失败或降级的子采集器稳定名称。 Collector string `json:"collector"` - Message string `json:"message"` + // Message 是面向排障的错误摘要,不保证适合终端用户展示。 + Message string `json:"message"` } diff --git a/scripts/build-all.sh b/scripts/build-all.sh new file mode 100755 index 0000000..5528a30 --- /dev/null +++ b/scripts/build-all.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# 一键构建 Linux + Windows amd64 单二进制产物。 +# +# 环境变量(会传递给子脚本 build-linux.sh): +# OUT_DIR 输出目录,默认 <仓库根>/dist +# VERSION 写入二进制的版本号,默认 0.1.0 +# CGO_ENABLED 是否启用 CGO,默认 0 +# +# 用法: +# ./scripts/build-all.sh +# VERSION=1.0.0 ./scripts/build-all.sh +# +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_LINUX="${ROOT_DIR}/scripts/build-linux.sh" + +export OUT_DIR="${OUT_DIR:-${ROOT_DIR}/dist}" +export VERSION="${VERSION:-0.1.0}" +export CGO_ENABLED="${CGO_ENABLED:-0}" + +"${BUILD_LINUX}" + +GOOS_TARGET=windows GOARCH_TARGET=amd64 OUTPUT_NAME=agent-windows-amd64.exe \ + "${BUILD_LINUX}" + +printf 'all platforms built under %s\n' "${OUT_DIR}"