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 }