commit 0000356e5d92353e8ad2c23d0553733f8de26409 Author: zhi Date: Fri Mar 20 11:00:42 2026 +0000 feat: add Go-based HarborForge monitor client - collect CPU, memory, disk, swap, load, and uptime telemetry - detect nginx and list /etc/nginx/sites-enabled entries - send heartbeat-v2 payload with API key auth - provide install script, config example, and systemd unit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44e5817 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +dist/ +harborforge-monitor +*.exe +*.out +*.test diff --git a/README.md b/README.md new file mode 100644 index 0000000..61287b7 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# HarborForge.Monitor + +轻量级 Go 遥测客户端,用于把服务器硬件状态上报到 HarborForge Monitor。 + +它**不依赖 OpenClaw**,适合普通 Linux 主机、VPS、Nginx 机器等。 + +## 采集内容 + +- CPU 使用率 +- 内存使用率 +- 磁盘使用率 +- Swap 使用率 +- Load Average +- Uptime +- Nginx 是否安装 +- `/etc/nginx/sites-enabled` 列表 + +## 上报接口 + +客户端调用: + +- `POST /monitor/server/heartbeat-v2` +- Header: `X-API-Key` + +## 项目结构 + +```text +HarborForge.Monitor/ +├── cmd/harborforge-monitor/ # 程序入口 +├── internal/config/ # 配置加载 +├── internal/telemetry/ # 指标采集与上报 +├── scripts/install.sh # 安装脚本 +├── systemd/harborforge-monitor.service +├── config.example.json +└── README.md +``` + +## 配置 + +先在 HarborForge Monitor 中注册服务器并生成 API Key。 + +然后准备配置文件,例如 `/etc/harborforge-monitor/config.json`: + +```json +{ + "backendUrl": "https://monitor.hangman-lab.top", + "identifier": "vps-nginx-01", + "apiKey": "your-api-key", + "reportIntervalSec": 30, + "logLevel": "info" +} +``` + +也支持环境变量覆盖: + +- `HF_MONITOR_BACKEND_URL` +- `HF_MONITOR_IDENTIFIER` +- `HF_MONITOR_API_KEY` +- `HF_MONITOR_REPORT_INTERVAL` +- `HF_MONITOR_LOG_LEVEL` + +## 本地开发 + +```bash +go mod tidy +go build ./cmd/harborforge-monitor +./harborforge-monitor -config ./config.example.json -dry-run -once +``` + +## 安装 + +```bash +sudo ./scripts/install.sh +``` + +安装脚本会: + +- 构建二进制 `harborforge-monitor` +- 安装到 `/usr/local/bin/` +- 安装 systemd service +- 初始化 `/etc/harborforge-monitor/config.json` +- 自动启用并启动服务 + +## 注意 + +- 当前 Nginx site 列表读取的是 `/etc/nginx/sites-enabled` +- 如果机器没有安装 Nginx,会回报 `nginx_installed = false` +- 该客户端不会尝试读取 OpenClaw 信息,`agents` 默认为空,`openclaw_version` 不上报 diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..dbda9c7 --- /dev/null +++ b/config.example.json @@ -0,0 +1,7 @@ +{ + "backendUrl": "https://monitor.hangman-lab.top", + "identifier": "vps-01", + "apiKey": "replace-with-server-api-key", + "reportIntervalSec": 30, + "logLevel": "info" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ac69968 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module git.hangman-lab.top/zhi/HarborForge.Monitor + +go 1.22 + +require github.com/shirou/gopsutil/v4 v4.25.2 + +require ( + github.com/ebitengine/purego v0.8.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..da0f69e --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk= +github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b67f2b6 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,108 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" +) + +type Config struct { + BackendURL string `json:"backendUrl"` + Identifier string `json:"identifier"` + APIKey string `json:"apiKey"` + ReportIntervalSec int `json:"reportIntervalSec"` + LogLevel string `json:"logLevel"` +} + +func Load(path string) (Config, error) { + cfg := Config{ + BackendURL: getenv("HF_MONITOR_BACKEND_URL", "https://monitor.hangman-lab.top"), + Identifier: getenv("HF_MONITOR_IDENTIFIER", hostnameOr("unknown-host")), + APIKey: os.Getenv("HF_MONITOR_API_KEY"), + ReportIntervalSec: getenvInt("HF_MONITOR_REPORT_INTERVAL", 30), + LogLevel: getenv("HF_MONITOR_LOG_LEVEL", "info"), + } + + if path != "" { + if fileCfg, err := loadFile(path); err == nil { + merge(&cfg, fileCfg) + } else if !os.IsNotExist(err) { + return cfg, fmt.Errorf("load config file: %w", err) + } + } + + // env always wins over file + cfg.BackendURL = getenv("HF_MONITOR_BACKEND_URL", cfg.BackendURL) + cfg.Identifier = getenv("HF_MONITOR_IDENTIFIER", cfg.Identifier) + if v := os.Getenv("HF_MONITOR_API_KEY"); v != "" { + cfg.APIKey = v + } + cfg.ReportIntervalSec = getenvInt("HF_MONITOR_REPORT_INTERVAL", cfg.ReportIntervalSec) + cfg.LogLevel = getenv("HF_MONITOR_LOG_LEVEL", cfg.LogLevel) + + if cfg.BackendURL == "" { + return cfg, fmt.Errorf("backendUrl is required") + } + if cfg.Identifier == "" { + return cfg, fmt.Errorf("identifier is required") + } + if cfg.ReportIntervalSec <= 0 { + cfg.ReportIntervalSec = 30 + } + return cfg, nil +} + +func loadFile(path string) (Config, error) { + var cfg Config + buf, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + if err := json.Unmarshal(buf, &cfg); err != nil { + return cfg, err + } + return cfg, nil +} + +func merge(dst *Config, src Config) { + if src.BackendURL != "" { + dst.BackendURL = src.BackendURL + } + if src.Identifier != "" { + dst.Identifier = src.Identifier + } + if src.APIKey != "" { + dst.APIKey = src.APIKey + } + if src.ReportIntervalSec > 0 { + dst.ReportIntervalSec = src.ReportIntervalSec + } + if src.LogLevel != "" { + dst.LogLevel = src.LogLevel + } +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getenvInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + var out int + if _, err := fmt.Sscanf(v, "%d", &out); err == nil && out > 0 { + return out + } + } + return fallback +} + +func hostnameOr(fallback string) string { + name, err := os.Hostname() + if err != nil || name == "" { + return fallback + } + return name +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 0000000..8e6d81c --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,158 @@ +package telemetry + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/host" + gopsload "github.com/shirou/gopsutil/v4/load" + "github.com/shirou/gopsutil/v4/mem" + + "git.hangman-lab.top/zhi/HarborForge.Monitor/internal/config" +) + +const Version = "0.1.0" + +type Payload struct { + Identifier string `json:"identifier"` + PluginVersion string `json:"plugin_version,omitempty"` + Agents []any `json:"agents"` + NginxInstalled bool `json:"nginx_installed"` + NginxSites []string `json:"nginx_sites"` + CPUPct float64 `json:"cpu_pct,omitempty"` + MemPct float64 `json:"mem_pct,omitempty"` + DiskPct float64 `json:"disk_pct,omitempty"` + SwapPct float64 `json:"swap_pct,omitempty"` + LoadAvg []float64 `json:"load_avg,omitempty"` + UptimeSeconds uint64 `json:"uptime_seconds,omitempty"` +} + +func BuildPayload(ctx context.Context, cfg config.Config) (Payload, error) { + payload := Payload{ + Identifier: cfg.Identifier, + PluginVersion: Version, + Agents: []any{}, + NginxSites: []string{}, + } + + cpuPct, err := cpu.PercentWithContext(ctx, time.Second, false) + if err == nil && len(cpuPct) > 0 { + payload.CPUPct = round1(cpuPct[0]) + } + + vm, err := mem.VirtualMemoryWithContext(ctx) + if err == nil { + payload.MemPct = round1(vm.UsedPercent) + } + + diskUsage, err := disk.UsageWithContext(ctx, "/") + if err == nil { + payload.DiskPct = round1(diskUsage.UsedPercent) + } + + swapUsage, err := mem.SwapMemoryWithContext(ctx) + if err == nil { + payload.SwapPct = round1(swapUsage.UsedPercent) + } + + avg, err := gopsload.AvgWithContext(ctx) + if err == nil { + payload.LoadAvg = []float64{round2(avg.Load1), round2(avg.Load5), round2(avg.Load15)} + } + + hostInfo, err := host.InfoWithContext(ctx) + if err == nil { + payload.UptimeSeconds = hostInfo.Uptime + } + + nginxInstalled, nginxSites, err := detectNginx() + if err != nil { + return payload, err + } + payload.NginxInstalled = nginxInstalled + payload.NginxSites = nginxSites + + return payload, nil +} + +func Send(ctx context.Context, client *http.Client, cfg config.Config, payload Payload) error { + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(cfg.BackendURL, "/")+"/monitor/server/heartbeat-v2", strings.NewReader(string(body))) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", cfg.APIKey) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("heartbeat failed with status %s", resp.Status) + } + return nil +} + +func detectNginx() (bool, []string, error) { + installed := false + if _, err := exec.LookPath("nginx"); err == nil { + installed = true + } + for _, path := range []string{"/etc/nginx/nginx.conf", "/usr/local/etc/nginx/nginx.conf", "/opt/homebrew/etc/nginx/nginx.conf"} { + if _, err := os.Stat(path); err == nil { + installed = true + break + } + } + if !installed { + return false, []string{}, nil + } + + dir := "/etc/nginx/sites-enabled" + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return true, []string{}, nil + } + return true, nil, fmt.Errorf("read nginx sites-enabled: %w", err) + } + + sites := make([]string, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + fullPath := filepath.Join(dir, name) + if entry.Type()&os.ModeSymlink != 0 { + if target, err := os.Readlink(fullPath); err == nil { + name = fmt.Sprintf("%s -> %s", name, target) + } + } + sites = append(sites, name) + } + sort.Strings(sites) + return true, sites, nil +} + +func round1(v float64) float64 { + return float64(int(v*10+0.5)) / 10 +} + +func round2(v float64) float64 { + return float64(int(v*100+0.5)) / 100 +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..d57104b --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN_NAME="harborforge-monitor" +INSTALL_DIR="/usr/local/bin" +CONFIG_DIR="/etc/harborforge-monitor" +SERVICE_PATH="/etc/systemd/system/harborforge-monitor.service" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Please run as root (or via sudo)." >&2 + exit 1 +fi + +mkdir -p "${CONFIG_DIR}" + +pushd "${ROOT_DIR}" >/dev/null +go build -o "${BIN_NAME}" ./cmd/harborforge-monitor +install -m 0755 "${BIN_NAME}" "${INSTALL_DIR}/${BIN_NAME}" +install -m 0644 systemd/harborforge-monitor.service "${SERVICE_PATH}" +if [[ ! -f "${CONFIG_DIR}/config.json" ]]; then + install -m 0644 config.example.json "${CONFIG_DIR}/config.json" +fi +systemctl daemon-reload +systemctl enable --now harborforge-monitor +popd >/dev/null + +echo "Installed ${BIN_NAME}. Edit ${CONFIG_DIR}/config.json and restart the service if needed." diff --git a/systemd/harborforge-monitor.service b/systemd/harborforge-monitor.service new file mode 100644 index 0000000..8da7de1 --- /dev/null +++ b/systemd/harborforge-monitor.service @@ -0,0 +1,15 @@ +[Unit] +Description=HarborForge Monitor Telemetry Client +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/etc/harborforge-monitor +ExecStart=/usr/local/bin/harborforge-monitor -config /etc/harborforge-monitor/config.json +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target