✨ feat(agent): 添加 token 鉴权和流程文档
* 默认监听调整为 0.0.0.0:8080,并要求查询参数 token * 支持首次 run 自动生成含随机 token 的配置文件 * 补充 HTTP 鉴权、配置校验和相关测试 * 完善代码注释,覆盖缓存、上报、服务和平台采集路径 * 新增架构与开发流程文档,补充一键构建脚本
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user