Files
NexusGate/agent-go/health.go
T
aaaamoooo b49c134513 feat(agent+panel): sing-box core version probe and display (round-2 #4)
agent-go: engineReport gains singBoxVersion. The probe runs
`sing-box version` once, parses the version token from its first line
('unknown' from unstamped builds reports empty), and caches it keyed by
the binary's mtime+size — re-probed only when an engine install/update
replaces the file. Steady-state heartbeats pay one stat, no subprocess.
Agent version stays 0.6.8.

panel-go: EngineState.singBoxVersion persisted from heartbeats (old
agents without the field keep showing '-'), servers table core-version
column now renders it. Golden gains heartbeatWithEngine +
serversAfterEngineHeartbeat sections locking the full chain, plus a
listen-address (<hostip>) normalization for the sandbox bind address.
singboxversion_test.go covers parsing, cache hits, and re-probe on
binary replacement.
2026-10-21 11:18:00 +08:00

325 lines
10 KiB
Go

package main
import (
"math"
"os"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
// ---- systemInfo --------------------------------------------------------------
type systemReport struct {
Hostname string `json:"hostname"`
Platform string `json:"platform"`
Release string `json:"release"`
Arch string `json:"arch"`
UptimeSeconds int64 `json:"uptimeSeconds"`
Load1 float64 `json:"load1"`
CPUCount int `json:"cpuCount"`
MemoryTotal uint64 `json:"memoryTotal"`
MemoryFree uint64 `json:"memoryFree"`
MemoryAvailable uint64 `json:"memoryAvailable"`
DiskTotal *uint64 `json:"diskTotal"`
DiskAvailable *uint64 `json:"diskAvailable"`
}
// nodeArch maps Go's GOARCH to Node's os.arch() vocabulary (amd64 → x64 etc).
func nodeArch() string {
switch runtime.GOARCH {
case "amd64":
return "x64"
case "386":
return "ia32"
case "arm64", "arm", "ppc64le", "s390x", "riscv64", "loong64", "mips64le":
return runtime.GOARCH
default:
return runtime.GOARCH
}
}
var memAvailableRe = regexp.MustCompile(`(?m)^MemAvailable:\s+(\d+) kB`)
func systemInfo() systemReport {
// Node's os.totalmem()/freemem() use sysinfo(2) on Linux; do the same.
var si syscall.Sysinfo_t
var memTotal, memFree uint64
if err := syscall.Sysinfo(&si); err == nil {
memTotal = si.Totalram * uint64(si.Unit)
memFree = si.Freeram * uint64(si.Unit)
}
memAvailable := memFree
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
if m := memAvailableRe.FindSubmatch(data); m != nil {
if kb, err := strconv.ParseUint(string(m[1]), 10, 64); err == nil {
memAvailable = kb * 1024
}
}
}
var uptime int64
if data, err := os.ReadFile("/proc/uptime"); err == nil {
fields := strings.Fields(string(data))
if len(fields) > 0 {
if seconds, err := strconv.ParseFloat(fields[0], 64); err == nil {
uptime = int64(math.Round(seconds))
}
}
}
var load1 float64
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
fields := strings.Fields(string(data))
if len(fields) > 0 {
if v, err := strconv.ParseFloat(fields[0], 64); err == nil {
load1 = math.Round(v*100) / 100 // Number(loadavg[0].toFixed(2))
}
}
}
release := ""
if data, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil {
release = strings.TrimSpace(string(data))
}
hostname, _ := os.Hostname()
report := systemReport{
Hostname: hostname,
Platform: runtime.GOOS,
Release: release,
Arch: nodeArch(),
UptimeSeconds: uptime,
Load1: load1,
CPUCount: runtime.NumCPU(),
MemoryTotal: memTotal,
MemoryFree: memFree,
MemoryAvailable: memAvailable,
}
var fs syscall.Statfs_t
if err := syscall.Statfs("/", &fs); err == nil {
total := fs.Blocks * uint64(fs.Bsize)
avail := fs.Bavail * uint64(fs.Bsize)
report.DiskTotal = &total
report.DiskAvailable = &avail
}
return report
}
// ---- installedCertificates ---------------------------------------------------
type certCache struct {
fingerprint string
checkedAt time.Time
domains []string
}
var (
certCacheMu sync.Mutex
certCacheV certCache
)
func opensslOK(timeout time.Duration, args ...string) (string, bool) {
stdout, _, code := runCapture(timeout, "openssl", args...)
return stdout, code == 0
}
// installedCertificates lists TLS domains under NG_TLS_DIR whose cert/key
// pairs verify — agent.js installedCertificates(), including its 5-minute
// fingerprint cache.
func installedCertificates() []string {
root := cfg.TLSDir
entries, err := os.ReadDir(root)
if err != nil {
return []string{}
}
domains := []string{}
for _, entry := range entries {
domains = append(domains, entry.Name())
}
if len(domains) > 50 {
domains = domains[:50]
}
// Fingerprint over (domain, cert mtime/size, key mtime/size) tuples.
fpParts := []interface{}{}
for _, domain := range domains {
certStat, certErr := os.Stat(filepath.Join(root, domain, "fullchain.pem"))
keyStat, keyErr := os.Stat(filepath.Join(root, domain, "privkey.pem"))
if certErr == nil && keyErr == nil {
fpParts = append(fpParts, []interface{}{domain,
float64(certStat.ModTime().UnixMilli()), certStat.Size(),
float64(keyStat.ModTime().UnixMilli()), keyStat.Size()})
} else {
fpParts = append(fpParts, []interface{}{domain})
}
}
fingerprint, _ := marshalJSON(fpParts)
certCacheMu.Lock()
defer certCacheMu.Unlock()
if certCacheV.fingerprint == fingerprint && time.Since(certCacheV.checkedAt) < 300*time.Second {
return append([]string{}, certCacheV.domains...)
}
valid := []string{}
for _, domain := range domains {
if !domainRe.MatchString(domain) {
continue
}
cert := filepath.Join(root, domain, "fullchain.pem")
key := filepath.Join(root, domain, "privkey.pem")
if !fileExists(cert) || !fileExists(key) {
continue
}
if _, ok := opensslOK(3*time.Second, "x509", "-in", cert, "-noout", "-checkend", "86400"); !ok {
continue
}
hostOut, ok := opensslOK(3*time.Second, "x509", "-in", cert, "-noout", "-checkhost", domain)
if !ok || !strings.Contains(hostOut, "does match") {
continue
}
if _, ok := opensslOK(3*time.Second, "verify", "-verify_hostname", domain, "-untrusted", cert, cert); !ok {
continue
}
certKey, ok1 := opensslOK(3*time.Second, "x509", "-in", cert, "-pubkey", "-noout")
privateKey, ok2 := opensslOK(3*time.Second, "pkey", "-in", key, "-pubout")
if !ok1 || !ok2 || strings.TrimSpace(certKey) != strings.TrimSpace(privateKey) {
continue
}
valid = append(valid, domain)
}
certCacheV = certCache{fingerprint: fingerprint, checkedAt: time.Now(), domains: valid}
return append([]string{}, valid...)
}
// ---- engineHealth / heartbeat -------------------------------------------------
type engineReport struct {
Status string `json:"status"`
Detail string `json:"detail"`
SingBoxInstalled bool `json:"singBoxInstalled"`
Certificates []string `json:"certificates"`
SingBoxStatus string `json:"singBoxStatus"`
SingBoxVersion string `json:"singBoxVersion,omitempty"`
}
// ---- sing-box version probe -------------------------------------------------
// The core version is probed by running `sing-box version` exactly once and
// cached against the binary's mtime+size. It is re-probed only when the file
// changes (an engine install/update replaces the binary) — the first probe
// happens on the first heartbeat after agent start. Steady-state heartbeats
// pay one stat, never a subprocess.
var singBoxVersionMu sync.Mutex
var singBoxVersionCache struct {
path string
mtimeNs int64
size int64
version string
probed bool
}
// parseSingBoxVersion extracts the version token from the first line of
// `sing-box version` output ("sing-box version 1.13.21"). Unstamped local
// builds print "unknown"; those report no version.
func parseSingBoxVersion(out string) string {
line := out
if i := strings.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
fields := strings.Fields(line)
if len(fields) >= 3 && fields[0] == "sing-box" && fields[1] == "version" && fields[2] != "unknown" {
return fields[2]
}
return ""
}
func singBoxVersion() string {
info, err := os.Stat(cfg.SingBoxBin)
if err != nil {
return ""
}
singBoxVersionMu.Lock()
defer singBoxVersionMu.Unlock()
c := &singBoxVersionCache
if c.probed && c.path == cfg.SingBoxBin && c.mtimeNs == info.ModTime().UnixNano() && c.size == info.Size() {
return c.version
}
version := ""
if out, _, code := runCapture(5*time.Second, cfg.SingBoxBin, "version"); code == 0 {
version = parseSingBoxVersion(out)
}
c.path, c.mtimeNs, c.size, c.version, c.probed = cfg.SingBoxBin, info.ModTime().UnixNano(), info.Size(), version, true
return version
}
// engineHealth mirrors agent.js engineHealth(). Callers must hold stateMu
// and pass the resources they read under the same lock.
func engineHealthLocked(resources []map[string]interface{}) engineReport {
installed := fileExists(cfg.SingBoxBin) && fileExists(cfg.SingBoxStatsBin)
service := serviceHealth("nexusgate-sing-box")
result := engineReport{
SingBoxInstalled: installed,
Certificates: installedCertificates(),
SingBoxStatus: service.Status,
}
if installed {
result.Status = service.Status
result.Detail = service.Detail
result.SingBoxVersion = singBoxVersion()
} else {
result.Status = "error"
result.Detail = "sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install"
}
if result.Status == "ready" && len(resources) > 0 {
missing, err := missingHysteriaListeners(resources)
if err != nil {
result.Status = "error"
result.Detail = safeError(err.Error())
} else if len(missing) > 0 {
parts := make([]string, len(missing))
for i, port := range missing {
parts[i] = strconv.Itoa(port)
}
result.Status = "error"
result.Detail = "Hysteria 2 UDP 未监听:" + strings.Join(parts, ", ")
}
}
return result
}
func heartbeat() error {
stateMu.Lock()
// A readResources error skips the heartbeat entirely (JS throws).
resources, err := readResources()
if err != nil {
stateMu.Unlock()
return err
}
engine := engineHealthLocked(resources)
stateMu.Unlock()
// The panel reconciles against this list: any expected resource the node
// no longer holds (e.g. local state was wiped) is re-dispatched
// automatically. An empty list is meaningful, so keep it non-nil.
resourceIDs := []string{}
for _, res := range resources {
if s, ok := res["id"].(string); ok && s != "" {
resourceIDs = append(resourceIDs, s)
}
}
body := map[string]interface{}{
"version": version,
"system": systemInfo(),
"engine": engine,
"resources": resourceIDs,
}
if _, err := requestJSON("/api/agent/heartbeat", body); err != nil {
return err
}
stamp, _ := marshalJSON(map[string]interface{}{"at": isoNow()})
if err := os.WriteFile("/etc/nexusgate/last-heartbeat.json", []byte(stamp+"\n"), 0o600); err != nil {
// JS logs this but does not fail the heartbeat.
logf("Heartbeat reached controller but readiness file failed %s", err.Error())
}
return nil
}