feat: Go agent source (stdlib-only rewrite of agent.js) + parity/install docs; binaries ship as release assets

This commit is contained in:
aaaamoooo
2026-10-20 06:55:29 +00:00
parent c6615f0ab5
commit ddfc27efba
13 changed files with 2626 additions and 0 deletions
+263
View File
@@ -0,0 +1,263 @@
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
}