feat: Go agent source (stdlib-only rewrite of agent.js) + parity/install docs; binaries ship as release assets
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user