264 lines
7.8 KiB
Go
264 lines
7.8 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"`
|
|
}
|
|
|
|
// engineHealth mirrors agent.js engineHealth(). Callers must hold stateMu.
|
|
// A readResources error propagates (JS throws, so the heartbeat is skipped).
|
|
func engineHealthLocked() (engineReport, error) {
|
|
resources, err := readResources()
|
|
if err != nil {
|
|
return engineReport{}, err
|
|
}
|
|
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
|
|
} 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, nil
|
|
}
|
|
|
|
func heartbeat() error {
|
|
stateMu.Lock()
|
|
engine, err := engineHealthLocked()
|
|
stateMu.Unlock()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body := map[string]interface{}{
|
|
"version": version,
|
|
"system": systemInfo(),
|
|
"engine": engine,
|
|
}
|
|
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
|
|
}
|