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
+1
View File
@@ -0,0 +1 @@
nexusgate-agent-linux-*
+46
View File
@@ -0,0 +1,46 @@
# Go Agent 安装/更新流程(自 v0.6.8 起默认)
Agent 已从 Node.js(`agent/agent.js`)切换为 Go 静态二进制
(`agent-go/`,约 6MB,实测 RSS 约 12MB,节点机不再需要安装 Node.js)。
协议、环境变量、状态文件格式与 Node 版完全一致(见 `PARITY.md`),
可直接接管已有节点的状态。
## 新装(scripts/agent-install.sh)
- 默认从 GitHub Release 下载二进制:
`https://github.com/aaaamoooo/NexusGate/releases/download/agent-v0.6.8/nexusgate-agent-linux-<arch>`
(`<arch>` 由 `uname -m` 决定:x86_64→amd64,aarch64→arm64)
- 安装到 `/opt/nexusgate-agent/nexusgate-agent`(0755)。
- 服务名仍为 `nexusgate-agent`:
- systemd:`systemd/nexusgate-agent.service`,`EnvironmentFile=/etc/nexusgate/agent.env`,
`ExecStart=/opt/nexusgate-agent/nexusgate-agent`,`Restart=always`。
- OpenRC:`openrc/nexusgate-agent`,`command` 指向同一二进制,
脚本内手动 source `/etc/nexusgate/agent.env`(OpenRC 不读 EnvironmentFile)。
- 其余流程与旧版一致:写 `agent.env`、注册、安装 sing-box、logrotate、心跳校验。
- 环境变量:`NG_AGENT_RELEASE` 可覆盖 Release tag;`NG_LOCAL_SRC` 指向本地
仓库时从 `agent-go/` 取已构建的二进制(离线/调试用)。
## 更新(scripts/agent-update.sh / ng-agent update)
脚本按已安装程序自动判断实现:
- 存在 `/opt/nexusgate-agent/nexusgate-agent` → Go 路径:先用当前二进制
`flush-usage` 落盘流量计数,再下载同一 Release 资产替换二进制、
刷新服务文件并重启,最后校验心跳。
- 只有 `agent.js` → Node 路径:保持旧流程(下载 agent.js/run.sh 并
`node --check`);刷新服务文件时会把 ExecStart/command 改回经
`run.sh` 启动。
## 回退到 Node Agent
- 新装时回退:`NG_AGENT_IMPL=node bash agent-install.sh ...`(会安装
nodejs 包、部署 `agent/agent.js` + `agent/run.sh`,服务经 run.sh 启动)。
- 已装 Go 的节点回退:同样用 `NG_AGENT_IMPL=node` 重跑安装脚本,
它会删除 Go 二进制并部署 Node 版(注册信息会重新生成,需用新 token)。
## 发布清单(Release: agent-v0.6.8)
需上传到 tag `agent-v0.6.8` 的资产(文件名必须完全一致):
- `nexusgate-agent-linux-amd64`
- `nexusgate-agent-linux-arm64`
+207
View File
@@ -0,0 +1,207 @@
# NexusGate Agent — Go rewrite parity report
Source of truth: `agent/agent.js` (561 lines, v0.6.8) + the `/api/agent/*`
handlers in `server.js` / `lib/orchestrator.js`. The Go agent is a drop-in
replacement: same env vars, same state files/formats, same wire protocol, so
it can take over an existing node without wiping state.
## Build
- Module: `agent-go/` (single `main` package, **stdlib only, zero deps**).
- `CGO_ENABLED=0 go build -ldflags="-s -w"`, Go 1.24.6.
- `nexusgate-agent-linux-amd64`: 6,353,080 bytes (6.06 MiB)
- `nexusgate-agent-linux-arm64`: 6,029,496 bytes (5.75 MiB), static ELF
## Function-by-function parity
| agent.js | Go | Status |
|---|---|---|
| `request()` (Bearer auth, 20s timeout, `body.message` error) | `request()`/`requestJSON()` in main.go | ✅ |
| `safeError()` (Private Key / Bearer redaction) | `safeError()` | ✅ unit-tested |
| `ensureDirectories()` (resources 0700, /var/log/nexusgate 0750) | `ensureDirectories()` | ✅ |
| `loadKeys()`/`saveKeys()` (temp+rename, 0600) | keys.go | ✅ extra keys preserved as raw JSON |
| `realityKey()` | `realityKey()` in keys.go | ✅ see below |
| `materialize()` (`${REALITY_PRIVATE:id}` whole-string) | `materialize()` | ✅ artifacts shape `{realityPublicKey}` preserved |
| `readResources()` (sorted *.json) | `readResources()` | ✅ |
| `normalizeInboundListen()` (non-local IP → wildcard fallback) | resources.go | ✅ unit-tested |
| `combinedSingBoxConfig()` | `combinedSingBoxConfig()` | ✅ unit-tested + real `sing-box check` passes |
| `run()` (throw with redacted stderr, 1200 chars) | `runThrow()` | ✅ |
| `serviceCommand()` (systemctl vs rc-service via /run/systemd/system) | `serviceCommand()` | ✅ |
| `activateSingBox()` (candidate check, swap, rollback, no-op when unchanged+ready, stop+remove when empty) | `activateSingBoxLocked()` | ✅ exercised in binary smoke test |
| `safeResourcePath()` (`^[a-zA-Z0-9_-]{8,100}$`) | `safeResourcePath()` | ✅ |
| `flushUsageBeforeChange()` / `flushAllUsageBeforeRestart()` | usage.go | ✅ exercised via delete job |
| `applyResource()` (engine check, HY2 auth guard, TLS cert checks, backup/restore) | `applyResourceLocked()` | ✅ |
| `deleteResource()` (`{removed}` / `{alreadyAbsent}`) | `deleteResourceLocked()` | ✅ |
| `execute()` (complete / failure reporting) | `execute()` | ✅ incl. the JS quirk below |
| `systemInfo()` | health.go (sysinfo(2) like Node, MemAvailable override, statfs) | ✅ heartbeat body verified in smoke test |
| `engineHealth()` (+ HY2 UDP listener check) | `engineHealthLocked()` | ✅ |
| `installedCertificates()` (openssl chain, 5-min fingerprint cache) | health.go | ✅ same openssl invocations/timeouts |
| `serviceHealth()` (is-active / rc-service status, lastEngineError detail) | `serviceHealth()` | ✅ |
| `heartbeat()` (+ `/etc/nexusgate/last-heartbeat.json` stamp) | `heartbeat()` | ✅ path hardcoded exactly as JS |
| `parseUsageStats()` | usage.go + `jsStatValue()` | ✅ unit-tested incl. error strings (负数 / 超出安全整数范围 / 格式 …) |
| `serviceEpoch()` (`pid:startTick` from /proc) | `serviceEpoch()` | ✅ |
| `udpPortsForPid()` / `missingHysteriaListeners()` | singbox.go | ✅ |
| `assertHysteriaListeners()` | — | ⚠️ dead code in agent.js (defined, never called, not exported); not ported |
| `queryUsage()` | `queryUsageLocked()` | ✅ samples/epoch/error shape verified in smoke test |
| `readSingBoxObservations()` (AnyTLS `[ng:<id>]` log parsing, 2 MiB window, cursor) | `readSingBoxObservationsLocked()` | ✅ unit-tested (auth-only counting, tag filter, cursor EOF) |
| `usageLoop()` (60s; usage + observations; cursor only advances after successful upload) | `usageLoop()` | ✅ |
| `pollLoop()` (job → execute immediately; idle → POLL_MS; error → max(POLL_MS, 10s)) | `pollLoop()` | ✅ |
| `main()` (env check, initial activation tolerant of failure, initial heartbeat, 30s heartbeat interval) | `runAgent()` | ✅ |
| CLI `flush-usage` (used by agent-update.sh) | `main()` argv check | ✅ smoke-tested (exit 0 no-op; missing env → exit 1) |
## Wire contract (verified against server.js)
- All calls POST with `Authorization: Bearer <NG_AGENT_KEY>`,
`Content-Type: application/json`, 20s timeout.
- Endpoints: `/api/agent/poll` (`{}` → `{job|null}`),
`/api/agent/jobs/<id>/complete` (`{success,result}` / `{success,error}`),
`/api/agent/heartbeat` (`{version, system, engine}`),
`/api/agent/usage` (`{samples, error, engineErrors}`),
`/api/agent/observations` (`{observations:[{customerId, ip}]}`).
- **Agent version is reported as exactly `0.6.8`.** `lib/orchestrator.js`
gates AnyTLS deploys on `version.split('.').map(Number) >= 0.6.1`; a
`"0.6.8-go"` suffix would parse the third component as `NaN` and fail the
gate. Do not change without updating the panel check.
- Heartbeat `system.arch` uses Node vocabulary (`x64` for amd64) — mapped in
`nodeArch()`.
- Engine object sends `singBoxStatus` as the raw service status
(`ready`/`error`); the panel maps non-`ready` to `inactive`, same as with
the Node agent.
## Critical correctness points
1. **Reality keys**: generated with `crypto/ecdh` X25519; private/public are
`base64.RawURLEncoding` of the raw 32-byte keys — byte-identical to Node
JWK `d`/`x` and to `sing-box generate reality-keypair` output (verified
against the RFC 7748 test vector, the real sing-box binary's output
format, and a full `sing-box check` over a config containing a
Go-generated key). No padded/standard-base64 conversion anywhere.
2. **Usage flush ordering**: apply/delete flush counters (and await the
panel's durable write) *before* writing/removing the resource file and
restarting sing-box, exactly as JS. The flush aborts the job when the
stats helper reports an engine error.
3. **Observations**: only *authenticated* AnyTLS lines
(`[ng:<customerId>] inbound connection to`) count, joined with the source
IP from the same connection id; unauthenticated probes never count; the
cursor file advances only after a successful upload.
## Stats helper decision (stats-query.go)
The Node agent shells out to the separate `nexusgate-sing-box-stats` binary
(env `NG_SINGBOX_STATS_BIN`), which speaks gRPC to sing-box's v2ray API
using sing-box's own protobuf stubs. The Go agent keeps that exact design:
- Integrating stats-query.go would import `github.com/sagernet/sing-box` +
`google.golang.org/grpc` + protobuf — a large module graph and several
extra MB — for a component that is *already built, deployed, and
versioned together with the sing-box binary* by `agent-singbox.sh`.
- Keeping the subprocess preserves the failure semantics 1:1 (exit code,
stderr text in error messages, 15s timeout, epoch-change detection).
- Result: the agent stays stdlib-only at ~6 MB. Integrating the gRPC client
later is a drop-in change inside `queryUsageLocked()` if ever wanted.
## Env vars & paths preserved (drop-in checklist)
- `NG_CONTROLLER` (trailing slashes trimmed), `NG_AGENT_KEY`
- `NG_SINGBOX_BIN` (default `/usr/local/bin/nexusgate-sing-box`)
- `NG_SINGBOX_STATS_BIN` (default `/usr/local/bin/nexusgate-sing-box-stats`)
- `NG_CONFIG_DIR` (default `/etc/nexusgate/agent`; resources in `<dir>/resources`)
- `NG_SINGBOX_CONFIG` (default `/etc/nexusgate/sing-box/config.json`;
`.candidate.json` / `.previous` siblings as in JS)
- `NG_SINGBOX_LOG` (default `/var/log/nexusgate/sing-box-access.log`)
- `NG_SINGBOX_CURSOR` (default `/etc/nexusgate/sing-box-cursor.json`, `{"offset":N}`)
- `NG_KEY_FILE` (default `/etc/nexusgate/keys.json`, 0600, same JSON shape)
- `NG_TLS_DIR` (default `/etc/nexusgate/tls`) — note the TLS path prefix
check inside applyResource stays hardcoded to `/etc/nexusgate/tls/<domain>/`
exactly as in JS.
- `NG_SINGBOX_API_PORT` (default `10086`), `NG_POLL_SECONDS` (default 8, min 3)
- `/etc/nexusgate/last-heartbeat.json` stamp after each successful heartbeat.
- Resource files: materialized resource JSON, 2-space indent + `\n`, mode 0600.
**Inherited JS behavior**: the stored resource contains the *materialized*
Reality private key (not the placeholder). Kept for parity; flagged here
because it means private keys live in `<NG_CONFIG_DIR>/resources/*.json`
(dir mode 0700, files 0600) just as with the Node agent.
## Known divergences (all behavior-preserving)
1. **JSON key order inside resource files / combined config**: Go reserializes
nested objects with alphabetically sorted keys; JS preserves insertion
order. Semantically identical for sing-box and for the panel. Side effect:
on the first Go-agent activation over a Node-written `config.json`, the
byte-equality no-restart check misses once and sing-box restarts a single
time; afterwards the Go output is byte-stable (verified deterministic).
2. **HTTP request body key order** (heartbeat/usage bodies): Go maps sort
keys; the panel parses JSON, so this is invisible.
3. **`copyFilePreserve` mode**: `.previous` config backup is written 0600;
JS `copyFileSync` would create it at 0666&^umask. Stricter, intentional.
4. **`NG_POLL_SECONDS` non-numeric**: JS yields NaN (busy loop); Go clamps to
the 3-second floor. Only affects a misconfigured env.
5. **Error texts from OS layer**: spawn-failure details come from Go's exec
package (`fork/exec …: no such file or directory`) where JS embeds V8's
`spawnSync … ENOENT` wording; same position in the same message templates,
only the OS-error phrasing differs. Likewise V8 vs Go JSON parse error
texts inside the `sing-box: …` diagnostic prefix.
6. **`execute()` completion-report quirk (faithfully kept)**: if a job
succeeds but the success POST to the panel fails, JS falls into the same
catch block and reports `{success:false}`; the Go agent does the same.
Panel-side reconcile re-queues the apply, so state converges.
7. **`readSingBoxObservations` internal failure logging**: a malformed
resource file makes JS log `IP observation report failed` once per cycle;
Go silently returns no observations (cursor not advanced in both).
8. **Local-address detection** compares parsed IPs (Go `net.IP.Equal`)
instead of JS's string equality against interface addresses — slightly
more lenient for non-canonical IPv6 spellings; can only avoid a fallback,
never cause a wrong bind.
9. **Certificate fingerprint** uses integer milliseconds (JS float ms) and
sorted directory order (Node readdir order is filesystem-dependent);
the fingerprint is only an in-process cache key.
10. **Job id in the complete URL** is concatenated raw; JS uses
`encodeURIComponent`. Panel-generated job ids are URL-safe, so this is
equivalent in practice.
11. **Concurrency**: JS is single-threaded; Go runs poll/usage/heartbeat
goroutines serialized around on-disk state with a mutex. Panel-visible
behavior is unchanged (the panel already tolerates duplicate/out-of-order
usage samples per agent.js's own comment).
## Not replicated / out of scope
- Enrollment (`/api/agent/enroll`) is performed by `agent-install.sh`, not
the agent binary — unchanged.
- `ng-agent` helper scripts (cert/doctor/engine/update/uninstall) are shell
and unchanged; they invoke the agent only via the `flush-usage` action,
which is supported.
- Service unit files still exec Node (`systemd/nexusgate-agent.service`,
`agent/run.sh`). Pointing them at this binary (and dropping the Node
dependency from `agent-install.sh`) is a separate deployment step, not
done here.
## Tests run in this environment
- `go test ./...` — all pass:
- RFC 7748 X25519 vector + key format (43 chars, raw base64url, 32-byte
round-trip) + keys.json persistence (0600, JS field shape, stable pair).
- Combined config merge for fabricated panel-shaped resources (VLESS
Reality + SS2022 + SOCKS): listen fallback, fallback outbound first,
route final, stats inbounds/users (name *and* username forms),
deterministic bytes.
- Generated config accepted by the **real** sing-box v1.13.21 binary
(`sing-box check`), incl. a Go-generated Reality private key.
- `parseUsageStats`: `{"stat":null}`, overwrite semantics, missing value →
0, bare JSON number, and all error branches (负数 / 超出安全整数范围 /
格式 string / 格式 object / invalid envelope).
- Observations: authenticated-only counting, per-connection IP join,
entry-tag filtering, cursor EOF behavior.
- `safeError` redaction.
- Binary smoke test against a stub panel (real binary, stub
systemctl/sing-box in PATH): startup heartbeat (version `0.6.8`, full
system/engine bodies), poll → `apply_resource` → resource + config files
written → `complete {success:true, result:{artifacts:{}}}`; then
`delete_resource` → usage flush POST first
(`{"samples":[],"error":"sing-box 入口尚无统计记录","engineErrors":[]}`),
`complete {removed:true}`, config removed, engine stopped. 30s heartbeat
cadence observed. `flush-usage` CLI exits 0; missing env exits 1 with the
JS error message.
- **Not tested here**: real-node deployment (systemd/OpenRC service restart
of an actual sing-box, live traffic counters via the real stats binary,
real panel). That is the user's scheduled real-machine step; nothing in
this report claims e2e success on a live node.
+3
View File
@@ -0,0 +1,3 @@
module nexusgate-agent-go
go 1.24
+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
}
+193
View File
@@ -0,0 +1,193 @@
package main
import (
"encoding/json"
"fmt"
"os"
"regexp"
"strings"
"time"
)
type agentJob struct {
ID string `json:"id"`
Action string `json:"action"`
Payload struct {
Resource json.RawMessage `json:"resource"`
ResourceID string `json:"resourceId"`
} `json:"payload"`
}
var domainRe = regexp.MustCompile(`^(?:[a-z0-9-]+\.)+[a-z]{2,63}$`)
func tlsCertMissingError(domain string) error {
return fmt.Errorf("TLS certificate for %s is missing; run ng-agent cert issue %s EMAIL or ng-agent cert import", domain, domain)
}
// applyResourceLocked validates and persists one materialized resource, then
// re-activates sing-box, restoring the previous resource on failure —
// agent.js applyResource(). Callers must hold stateMu.
func applyResourceLocked(rawResource json.RawMessage) (map[string]interface{}, error) {
if len(rawResource) == 0 {
return nil, fmt.Errorf("Missing resource payload")
}
decoded, err := decodeJSON(rawResource)
if err != nil {
return nil, err
}
resMap, ok := asMap(decoded)
if !ok || asString(resMap["id"]) == "" {
return nil, fmt.Errorf("Missing resource payload")
}
art := &artifacts{}
materialized, err := materialize(resMap, art)
if err != nil {
return nil, err
}
resource, _ := asMap(materialized)
if engine, present := resource["engine"]; present && jsTruthy(engine) && asString(engine) != "sing-box" {
return nil, fmt.Errorf("Unsupported engine: %v(Xray 已弃用,只支持 sing-box)", engine)
}
for _, inboundRaw := range asSlice(resource["inbounds"]) {
inbound, ok := asMap(inboundRaw)
if !ok {
continue
}
// Hysteria 2 refuses to run an unauthenticated entry.
if asString(inbound["type"]) == "hysteria2" {
users := asSlice(inbound["users"])
unprotected := len(users) == 0
for _, user := range users {
um, ok := asMap(user)
if !ok || asString(um["password"]) == "" {
unprotected = true
}
}
if unprotected {
return nil, fmt.Errorf("Hysteria 2 入口没有认证账户;拒绝启动未受保护的入口")
}
}
tls, ok := asMap(inbound["tls"])
if !ok {
continue
}
if reality, ok := asMap(tls["reality"]); ok && jsTruthy(reality["enabled"]) {
continue // Reality has no certificate to check
}
domain := asString(tls["server_name"])
if !domainRe.MatchString(domain) {
return nil, fmt.Errorf("TLS 域名无效")
}
expected := "/etc/nexusgate/tls/" + domain + "/"
certFile := asString(tls["certificate_path"])
keyFile := asString(tls["key_path"])
if certFile == "" || keyFile == "" {
return nil, tlsCertMissingError(domain)
}
if !strings.HasPrefix(certFile, expected) || !strings.HasPrefix(keyFile, expected) ||
!fileExists(certFile) || !fileExists(keyFile) {
return nil, tlsCertMissingError(domain)
}
if _, err := runThrow(30*time.Second, "openssl", "x509", "-in", certFile, "-noout", "-checkend", "86400"); err != nil {
return nil, err
}
nameCheck, err := runThrow(30*time.Second, "openssl", "x509", "-in", certFile, "-noout", "-checkhost", domain)
if err != nil {
return nil, err
}
if !strings.Contains(nameCheck, "does match") {
return nil, fmt.Errorf("TLS certificate does not match %s", domain)
}
}
target, err := safeResourcePath(asString(resource["id"]))
if err != nil {
return nil, err
}
var backup []byte
if data, err := os.ReadFile(target); err == nil {
backup = data
}
if err := flushUsageBeforeChangeLocked(); err != nil {
return nil, err
}
body, err := marshalIndentJSON(resource)
if err != nil {
return nil, err
}
if err := os.WriteFile(target, []byte(body+"\n"), 0o600); err != nil {
return nil, err
}
if err := activateSingBoxLocked(); err != nil {
if backup != nil {
_ = os.WriteFile(target, backup, 0o600)
} else {
_ = os.Remove(target)
}
_ = activateSingBoxLocked() // preserve original error
return nil, err
}
setLastEngineError("")
return map[string]interface{}{"artifacts": art.toMap()}, nil
}
// deleteResourceLocked removes one resource and re-activates — agent.js
// deleteResource(). Callers must hold stateMu.
func deleteResourceLocked(resourceID string) (map[string]interface{}, error) {
target, err := safeResourcePath(resourceID)
if err != nil {
return nil, err
}
if !fileExists(target) {
return map[string]interface{}{"alreadyAbsent": true}, nil
}
backup, err := os.ReadFile(target)
if err != nil {
return nil, err
}
if err := flushUsageBeforeChangeLocked(); err != nil {
return nil, err
}
if err := os.Remove(target); err != nil {
return nil, err
}
if err := activateSingBoxLocked(); err != nil {
_ = os.WriteFile(target, backup, 0o600)
return nil, err
}
return map[string]interface{}{"removed": true}, nil
}
// execute runs one job and reports completion to the panel — agent.js execute().
func execute(job *agentJob) {
logf("Executing %s %s", job.ID, job.Action)
stateMu.Lock()
var result map[string]interface{}
var jobErr error
switch job.Action {
case "apply_resource":
result, jobErr = applyResourceLocked(job.Payload.Resource)
case "delete_resource":
result, jobErr = deleteResourceLocked(job.Payload.ResourceID)
default:
jobErr = fmt.Errorf("Unsupported job action: %s", job.Action)
}
stateMu.Unlock()
completeURL := "/api/agent/jobs/" + job.ID + "/complete"
// agent.js wraps the job AND the success report in one try/catch: if the
// completion POST fails after a successful apply, it still falls through
// to the failure report below. Faithfully replicated (see PARITY.md).
err := jobErr
if err == nil {
_, err = requestJSON(completeURL, map[string]interface{}{"success": true, "result": result})
if err == nil {
logf("Completed %s", job.ID)
return
}
}
logf("Failed %s %s", job.ID, safeError(err.Error()))
if _, reportErr := requestJSON(completeURL, map[string]interface{}{
"success": false, "error": safeError(err.Error()),
}); reportErr != nil {
logf("Unable to report failure %s", reportErr.Error())
}
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"bytes"
"encoding/json"
"math"
"regexp"
"strconv"
"strings"
)
// decodeJSON parses JSON the way JS JSON.parse + later property access behaves
// for this codebase: numbers keep their literal form (json.Number) so large
// counters never lose precision through float64.
func decodeJSON(data []byte) (interface{}, error) {
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
var v interface{}
if err := dec.Decode(&v); err != nil {
return nil, err
}
return v, nil
}
// marshalJSON renders compact JSON like JSON.stringify: UTF-8 output, no HTML
// escaping (Go's default would escape <, >, & which JS does not).
func marshalJSON(v interface{}) (string, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return "", err
}
return strings.TrimRight(buf.String(), "\n"), nil
}
// marshalIndentJSON renders like JSON.stringify(v, null, 2).
func marshalIndentJSON(v interface{}) (string, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
return "", err
}
return strings.TrimRight(buf.String(), "\n"), nil
}
func asMap(v interface{}) (map[string]interface{}, bool) {
m, ok := v.(map[string]interface{})
return m, ok
}
func asSlice(v interface{}) []interface{} {
if s, ok := v.([]interface{}); ok {
return s
}
return nil
}
func asString(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
// jsTruthy mirrors JS truthiness for JSON-decoded values.
func jsTruthy(v interface{}) bool {
switch t := v.(type) {
case nil:
return false
case bool:
return t
case string:
return t != ""
case json.Number:
f, err := t.Float64()
return err == nil && f != 0
case float64:
return t != 0
default:
return true // objects and arrays are always truthy in JS
}
}
// jsonNumberToInt converts a decoded JSON number to int (0 for anything else).
func jsonNumberToInt(v interface{}) int {
switch n := v.(type) {
case json.Number:
if i, err := n.Int64(); err == nil {
return int(i)
}
if f, err := n.Float64(); err == nil {
return int(f)
}
case float64:
return int(n)
}
return 0
}
const maxSafeInteger = 9007199254740991 // 2^53 - 1
var intStringRe = regexp.MustCompile(`^-?\d+$`)
// jsNumberString evaluates JS Number(string). Returns (value, ok) where ok is
// false when JS would produce NaN. It supports the forms Number() supports:
// decimal/exponent, hex 0x, binary 0b, octal 0o, surrounding whitespace.
func jsNumberString(s string) (float64, bool) {
t := strings.TrimSpace(s)
if t == "" {
return 0, true // Number("") and Number(" ") are 0 in JS
}
if t == "Infinity" || t == "+Infinity" {
return math.Inf(1), true
}
if t == "-Infinity" {
return math.Inf(-1), true
}
// Hex/binary/octal literals (no sign allowed before the prefix in JS).
if len(t) > 2 && t[0] == '0' {
var base int
switch t[1] {
case 'x', 'X':
base = 16
case 'b', 'B':
base = 2
case 'o', 'O':
base = 8
}
if base != 0 {
n, err := strconv.ParseUint(t[2:], base, 64)
if err != nil {
return 0, false
}
return float64(n), true
}
}
f, err := strconv.ParseFloat(t, 64)
if err != nil {
return 0, false
}
return f, true
}
// jsStatValue validates one stats counter value with the exact semantics of
// parseUsageStats in agent.js. raw is the JSON literal of the `value` field
// (nil RawMessage when the field is absent). Returns (value, reason, valid);
// reason is the Chinese diagnostic fragment JS embeds in its error message.
func jsStatValue(raw json.RawMessage) (int64, string, bool) {
if len(raw) == 0 {
return 0, "", true // missing field: protobuf default zero, JS uses 0
}
text := strings.TrimSpace(string(raw))
if text == "null" {
return 0, "格式 object", false
}
if text == "true" {
return 1, "", true // Number(true) === 1
}
if text == "false" {
return 0, "", true
}
if strings.HasPrefix(text, `"`) {
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return 0, "格式 string", false
}
if s == "" {
return 0, "格式 string", false // JS rejects value === '' explicitly
}
if intStringRe.MatchString(s) {
if strings.HasPrefix(s, "-") {
// "-0" is valid in JS (Number("-0") is -0, not < 0).
n, err := strconv.ParseInt(s, 10, 64)
if err == nil && n == 0 {
return 0, "", true
}
return 0, "负数", false
}
n, err := strconv.ParseUint(s, 10, 64)
if err != nil || n > maxSafeInteger {
return 0, "超出安全整数范围", false
}
return int64(n), "", true
}
f, ok := jsNumberString(s)
if !ok || math.IsNaN(f) || math.IsInf(f, 0) || f != math.Trunc(f) || f < 0 || f > maxSafeInteger {
return 0, "格式 string", false
}
return int64(f), "", true
}
if strings.HasPrefix(text, "{") || strings.HasPrefix(text, "[") {
return 0, "格式 object", false
}
// Bare JSON number.
f, err := strconv.ParseFloat(text, 64)
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) || f != math.Trunc(f) || f < 0 || f > maxSafeInteger {
return 0, "格式 number", false
}
return int64(f), "", true
}
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"regexp"
)
// keys.json stores Reality keypairs by key id:
// { "<keyId>": { "privateKey": "...", "publicKey": "...", "createdAt": "..." } }
// Entries are kept as raw JSON so keys written by the Node agent (or any
// future fields) survive a save by this agent untouched.
type realityKeyPair struct {
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
CreatedAt string `json:"createdAt"`
}
func loadKeys() (map[string]json.RawMessage, error) {
data, err := os.ReadFile(cfg.KeyFile)
if err != nil {
if os.IsNotExist(err) {
return map[string]json.RawMessage{}, nil
}
return nil, err
}
keys := map[string]json.RawMessage{}
if err := json.Unmarshal(data, &keys); err != nil {
return nil, err
}
if keys == nil {
keys = map[string]json.RawMessage{}
}
return keys, nil
}
func saveKeys(keys map[string]json.RawMessage) error {
body, err := marshalIndentJSON(keys)
if err != nil {
return err
}
tmp := cfg.KeyFile + ".tmp"
if err := os.WriteFile(tmp, []byte(body+"\n"), 0o600); err != nil {
return err
}
if err := os.Rename(tmp, cfg.KeyFile); err != nil {
return err
}
return os.Chmod(cfg.KeyFile, 0o600)
}
// realityKey returns the stored keypair for keyId or generates and persists a
// new X25519 pair. Encoding is base64url WITHOUT padding (base64.RawURLEncoding
// of the raw 32-byte keys) — byte-identical to the JWK `d`/`x` values Node's
// crypto.generateKeyPairSync('x25519') produces, and to the output of
// `sing-box generate reality-keypair`. Converting to padded standard base64
// here was a production bug; do not reintroduce it.
func realityKey(keyID string) (realityKeyPair, error) {
keys, err := loadKeys()
if err != nil {
return realityKeyPair{}, err
}
if raw, ok := keys[keyID]; ok {
var pair realityKeyPair
if err := json.Unmarshal(raw, &pair); err == nil && pair.PrivateKey != "" {
return pair, nil
}
}
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return realityKeyPair{}, fmt.Errorf("X25519 密钥生成失败: %w", err)
}
pair := realityKeyPair{
PrivateKey: base64.RawURLEncoding.EncodeToString(priv.Bytes()),
PublicKey: base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()),
CreatedAt: isoNow(),
}
encoded, err := json.Marshal(pair)
if err != nil {
return realityKeyPair{}, err
}
keys[keyID] = encoded
if err := saveKeys(keys); err != nil {
return realityKeyPair{}, err
}
return pair, nil
}
type artifacts struct {
RealityPublicKey *string `json:"realityPublicKey,omitempty"`
}
func (a *artifacts) toMap() map[string]interface{} {
out := map[string]interface{}{}
if a.RealityPublicKey != nil {
out["realityPublicKey"] = *a.RealityPublicKey
}
return out
}
var realityPlaceholderRe = regexp.MustCompile(`^\$\{REALITY_PRIVATE:(.+)}$`)
// materialize deep-walks a job resource replacing whole-string
// "${REALITY_PRIVATE:<keyId>}" placeholders with the stored private key,
// recording the matching public key in artifacts — agent.js materialize().
func materialize(value interface{}, art *artifacts) (interface{}, error) {
switch v := value.(type) {
case []interface{}:
out := make([]interface{}, len(v))
for i, item := range v {
replaced, err := materialize(item, art)
if err != nil {
return nil, err
}
out[i] = replaced
}
return out, nil
case map[string]interface{}:
out := make(map[string]interface{}, len(v))
for key, item := range v {
replaced, err := materialize(item, art)
if err != nil {
return nil, err
}
out[key] = replaced
}
return out, nil
case string:
match := realityPlaceholderRe.FindStringSubmatch(v)
if match == nil {
return v, nil
}
pair, err := realityKey(match[1])
if err != nil {
return nil, err
}
pub := pair.PublicKey
art.RealityPublicKey = &pub
return pair.PrivateKey, nil
default:
return value, nil
}
}
+281
View File
@@ -0,0 +1,281 @@
// NexusGate node agent — Go rewrite of agent/agent.js (Node.js) with 1:1
// protocol parity against the panel's /api/agent/* endpoints.
//
// Configuration comes from the process environment, exactly like the Node
// version: systemd EnvironmentFile or agent/run.sh sources
// /etc/nexusgate/agent.env before exec. This binary never reads the env
// file itself, same as agent.js.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// version is reported to the panel in every heartbeat. It MUST stay a plain
// semver: lib/orchestrator.js parses it with split('.').map(Number) and
// requires >= 0.6.1 before allowing AnyTLS deploys, so a suffix such as
// "0.6.8-go" would parse to NaN and break that gate.
const version = "0.6.8"
type config struct {
Controller string
AgentKey string
SingBoxBin string
SingBoxStatsBin string
Root string // NG_CONFIG_DIR
ResourceDir string // Root + "/resources"
SingBoxConfig string
SingBoxLog string
SingBoxCursor string
KeyFile string
TLSDir string
APIPort string // NG_SINGBOX_API_PORT (string, as in JS)
PollMS int64
}
func envOr(name, def string) string {
if v, ok := os.LookupEnv(name); ok {
return v
}
return def
}
func loadConfig() config {
root := envOr("NG_CONFIG_DIR", "/etc/nexusgate/agent")
// POLL_MS = Math.max(3, Number(NG_POLL_SECONDS || 8)) * 1000.
// JS yields NaN for a non-numeric value; we clamp to the 3s floor instead
// (documented divergence, only affects a misconfigured env).
pollSec := 8.0
if raw, ok := os.LookupEnv("NG_POLL_SECONDS"); ok && raw != "" {
if n, err := strconv.ParseFloat(raw, 64); err == nil {
pollSec = n
} else {
pollSec = 0
}
}
if pollSec < 3 {
pollSec = 3
}
return config{
Controller: strings.TrimRight(os.Getenv("NG_CONTROLLER"), "/"),
AgentKey: os.Getenv("NG_AGENT_KEY"),
SingBoxBin: envOr("NG_SINGBOX_BIN", "/usr/local/bin/nexusgate-sing-box"),
SingBoxStatsBin: envOr("NG_SINGBOX_STATS_BIN", "/usr/local/bin/nexusgate-sing-box-stats"),
Root: root,
ResourceDir: root + "/resources",
SingBoxConfig: envOr("NG_SINGBOX_CONFIG", "/etc/nexusgate/sing-box/config.json"),
SingBoxLog: envOr("NG_SINGBOX_LOG", "/var/log/nexusgate/sing-box-access.log"),
SingBoxCursor: envOr("NG_SINGBOX_CURSOR", "/etc/nexusgate/sing-box-cursor.json"),
KeyFile: envOr("NG_KEY_FILE", "/etc/nexusgate/keys.json"),
TLSDir: envOr("NG_TLS_DIR", "/etc/nexusgate/tls"),
APIPort: envOr("NG_SINGBOX_API_PORT", "10086"),
PollMS: int64(pollSec * 1000),
}
}
var cfg = loadConfig()
// stateMu serializes access to on-disk agent state (resource files, sing-box
// config, keys, cursor). agent.js gets this for free from Node's event loop
// plus synchronous fs calls; the Go goroutines need it explicitly.
var stateMu sync.Mutex
// lastEngineError holds the most recent engine failure text; serviceHealth
// surfaces it as the detail string while sing-box is down (agent.js keeps a
// module-level `lastEngineError` variable for the same purpose).
var lastEngineError struct {
mu sync.Mutex
s string
}
func setLastEngineError(v string) {
lastEngineError.mu.Lock()
lastEngineError.s = v
lastEngineError.mu.Unlock()
}
func getLastEngineError() string {
lastEngineError.mu.Lock()
defer lastEngineError.mu.Unlock()
return lastEngineError.s
}
var httpClient = &http.Client{Timeout: 20 * time.Second}
// isoNow mirrors new Date().toISOString(): UTC, millisecond precision, Z suffix.
func isoNow() string {
return time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00")
}
func logf(format string, args ...interface{}) {
fmt.Printf("%s %s\n", isoNow(), fmt.Sprintf(format, args...))
}
var (
rePrivateKey = regexp.MustCompile(`(?i)\bPrivate\s*Key\s*:\s*[^\s,;]+`)
reBearer = regexp.MustCompile(`(?i)\bBearer\s+[^\s,;]+`)
)
// safeError mirrors agent.js safeError(): redacts private keys and bearer
// tokens from text that may be logged or reported to the panel.
func safeError(value string) string {
value = rePrivateKey.ReplaceAllString(value, "PrivateKey: [REDACTED]")
value = reBearer.ReplaceAllString(value, "Bearer [REDACTED]")
return value
}
// truncateRunes ~ JS String.slice(0, n) (JS counts UTF-16 units; close enough
// for diagnostics, and safer than cutting a UTF-8 sequence mid-rune).
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n])
}
return s
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
// request mirrors agent.js request(): POST with bearer auth, 20s timeout,
// JSON body best-effort parsed ({} on failure), error message taken from
// body.message or synthesized from the HTTP status.
func request(endpoint string, body []byte) (map[string]interface{}, error) {
req, err := http.NewRequest(http.MethodPost, cfg.Controller+endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+cfg.AgentKey)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
parsed := map[string]interface{}{}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &parsed) // .catch(() => ({})) in JS
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
if msg, ok := parsed["message"].(string); ok && msg != "" {
return nil, fmt.Errorf("%s", msg)
}
return nil, fmt.Errorf("Controller returned %d", resp.StatusCode)
}
return parsed, nil
}
func requestJSON(endpoint string, payload interface{}) (map[string]interface{}, error) {
body, err := marshalJSON(payload)
if err != nil {
return nil, err
}
return request(endpoint, []byte(body))
}
func ensureDirectories() error {
if err := os.MkdirAll(cfg.ResourceDir, 0o700); err != nil {
return err
}
return os.MkdirAll("/var/log/nexusgate", 0o750)
}
func sleepMS(ms int64) {
time.Sleep(time.Duration(ms) * time.Millisecond)
}
func pollLoop() {
for {
result, err := request("/api/agent/poll", []byte("{}"))
if err != nil {
logf("Poll failed %s", err.Error())
ms := cfg.PollMS
if ms < 10000 {
ms = 10000
}
sleepMS(ms)
continue
}
jobRaw, ok := result["job"]
if !ok || jobRaw == nil {
sleepMS(cfg.PollMS)
continue
}
encoded, _ := json.Marshal(jobRaw)
var job agentJob
if err := json.Unmarshal(encoded, &job); err != nil {
logf("Poll failed %s", err.Error())
sleepMS(cfg.PollMS)
continue
}
execute(&job)
}
}
func heartbeatLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
if err := heartbeat(); err != nil {
logf("Heartbeat failed %s", err.Error())
}
}
}
func runAgent() error {
if cfg.Controller == "" || cfg.AgentKey == "" {
return fmt.Errorf("NG_CONTROLLER and NG_AGENT_KEY are required")
}
if err := ensureDirectories(); err != nil {
return err
}
logf("NexusGate Agent v%s starting", version)
// Keep the control channel alive even when a stale node configuration
// cannot start (same rationale as agent.js main()).
stateMu.Lock()
if err := activateSingBoxLocked(); err != nil {
setLastEngineError(safeError(err.Error()))
logf("Initial sing-box activation failed %s", getLastEngineError())
}
stateMu.Unlock()
if err := heartbeat(); err != nil {
logf("Initial heartbeat failed %s", err.Error())
}
go heartbeatLoop()
go usageLoop()
pollLoop()
return nil
}
func main() {
// `nexusgate-agent flush-usage` mirrors `node agent.js flush-usage`,
// invoked by agent-update.sh before the agent binary is replaced so the
// running sing-box counters are persisted first.
if len(os.Args) > 1 && os.Args[1] == "flush-usage" {
if err := flushAllUsageBeforeRestart(); err != nil {
fmt.Fprintln(os.Stderr, safeError(err.Error()))
os.Exit(1)
}
return
}
if err := runAgent(); err != nil {
fmt.Fprintln(os.Stderr, safeError(err.Error()))
os.Exit(1)
}
}
+369
View File
@@ -0,0 +1,369 @@
package main
import (
"crypto/ecdh"
"encoding/base64"
"encoding/hex"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// ---- Reality keys -----------------------------------------------------------
func TestX25519RFC7748Vector(t *testing.T) {
// RFC 7748 §5.2 Alice keypair: proves Go's ecdh byte conventions
// (raw little-endian scalar / u-coordinate) match the JWK d/x values
// Node produces, which is what sing-box expects.
privBytes, _ := hex.DecodeString("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a")
wantPub, _ := hex.DecodeString("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a")
priv, err := ecdh.X25519().NewPrivateKey(privBytes)
if err != nil {
t.Fatal(err)
}
got := priv.PublicKey().Bytes()
if len(got) != 32 {
t.Fatalf("public key length %d, want 32", len(got))
}
for i := range got {
if got[i] != wantPub[i] {
t.Fatalf("public key mismatch at %d: got %x want %x", i, got, wantPub)
}
}
// JWK d/x are base64url of exactly these byte strings: the encoding
// must round-trip to the RFC bytes with the raw (unpadded) alphabet.
enc := base64.RawURLEncoding.EncodeToString(privBytes)
back, err := base64.RawURLEncoding.DecodeString(enc)
if err != nil || len(back) != 32 {
t.Fatalf("base64url round-trip failed: %q %v", enc, err)
}
for i := range back {
if back[i] != privBytes[i] {
t.Fatalf("base64url round-trip mismatch at %d", i)
}
}
}
func TestRealityKeyFormatAndPersistence(t *testing.T) {
dir := t.TempDir()
old := cfg.KeyFile
cfg.KeyFile = filepath.Join(dir, "keys.json")
defer func() { cfg.KeyFile = old }()
pair, err := realityKey("reality-node-test")
if err != nil {
t.Fatal(err)
}
for _, k := range []string{pair.PrivateKey, pair.PublicKey} {
if len(k) != 43 {
t.Fatalf("key %q length %d, want 43 (32 bytes, base64url no padding)", k, len(k))
}
if strings.Contains(k, "=") || strings.Contains(k, "+") || strings.Contains(k, "/") {
t.Fatalf("key %q is not raw base64url", k)
}
raw, err := base64.RawURLEncoding.DecodeString(k)
if err != nil || len(raw) != 32 {
t.Fatalf("key %q does not decode to 32 bytes: %v", k, err)
}
}
// Public key must derive from the private key.
priv, err := ecdh.X25519().NewPrivateKey(mustDecode(t, pair.PrivateKey))
if err != nil {
t.Fatal(err)
}
if base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()) != pair.PublicKey {
t.Fatal("stored public key does not match private key")
}
// Second call returns the persisted pair; file is 0600 with the JS shape.
again, err := realityKey("reality-node-test")
if err != nil {
t.Fatal(err)
}
if again != pair {
t.Fatalf("key not persisted: %+v vs %+v", again, pair)
}
info, err := os.Stat(cfg.KeyFile)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("keys.json mode %o, want 600", info.Mode().Perm())
}
data, _ := os.ReadFile(cfg.KeyFile)
for _, field := range []string{`"privateKey"`, `"publicKey"`, `"createdAt"`, `"reality-node-test"`} {
if !strings.Contains(string(data), field) {
t.Fatalf("keys.json missing %s: %s", field, data)
}
}
}
func mustDecode(t *testing.T, s string) []byte {
t.Helper()
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
t.Fatal(err)
}
return b
}
// ---- Combined config ----------------------------------------------------------
const testSSKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" // 32 bytes 0x00..0x1f
func sampleResources(t *testing.T) []map[string]interface{} {
t.Helper()
old := cfg.KeyFile
cfg.KeyFile = filepath.Join(t.TempDir(), "keys.json")
defer func() { cfg.KeyFile = old }()
art := &artifacts{}
v1, err := decodeJSON([]byte(`{
"id": "res-node-0001", "engine": "sing-box",
"meta": {"kind": "node", "nodeId": "node-abc", "metricsTag": "ng-node-abc-in", "protocol": "vless-reality"},
"inbounds": [{
"type": "vless", "tag": "ng-node-abc-in", "listen": "203.0.113.9", "listen_port": 22038,
"users": [{"name": "ng:cust-1", "uuid": "11111111-1111-1111-1111-111111111111", "flow": "xtls-rprx-vision"}],
"tls": {"enabled": true, "server_name": "www.microsoft.com",
"reality": {"enabled": true, "handshake": {"server": "www.microsoft.com", "server_port": 443},
"private_key": "${REALITY_PRIVATE:reality-node-abc}", "short_id": ["abcd1234"]}}
}],
"outbounds": [{"type": "direct", "tag": "ng-node-abc-direct"}],
"routingRules": [{"inbound": ["ng-node-abc-in"], "action": "route", "outbound": "ng-node-abc-direct"}]
}`))
if err != nil {
t.Fatal(err)
}
m1, err := materialize(v1, art)
if err != nil {
t.Fatal(err)
}
if art.RealityPublicKey == nil || *art.RealityPublicKey == "" {
t.Fatal("materialize did not record the reality public key artifact")
}
v2, err := decodeJSON([]byte(`{
"id": "res-node-0002", "engine": "sing-box",
"meta": {"kind": "node", "nodeId": "node-def", "metricsTag": "ng-node-def-in", "protocol": "ss2022"},
"inbounds": [
{"type": "shadowsocks", "tag": "ng-node-def-in", "listen": "0.0.0.0", "listen_port": 8388,
"method": "2022-blake3-aes-256-gcm", "password": "` + testSSKey + `",
"users": [{"name": "ng:cust-2", "password": "` + testSSKey + `"}]},
{"type": "socks", "tag": "ng-node-def-socks", "listen": "127.0.0.1", "listen_port": 1080,
"users": [{"username": "ng:cust-3", "password": "pw"}]}
],
"outbounds": [{"type": "direct", "tag": "ng-node-def-direct"}],
"routingRules": []
}`))
if err != nil {
t.Fatal(err)
}
r1, _ := asMap(m1)
r2, _ := asMap(v2)
return []map[string]interface{}{r1, r2}
}
func TestCombinedSingBoxConfig(t *testing.T) {
resources := sampleResources(t)
combined := combinedSingBoxConfig(resources)
if len(combined.Inbounds) != 3 {
t.Fatalf("inbounds %d, want 3", len(combined.Inbounds))
}
first, _ := asMap(combined.Inbounds[0])
if first["listen"] != "0.0.0.0" {
t.Fatalf("non-local listen 203.0.113.9 should fall back to 0.0.0.0, got %v", first["listen"])
}
third, _ := asMap(combined.Inbounds[2])
if third["listen"] != "127.0.0.1" {
t.Fatalf("local listen 127.0.0.1 must be kept, got %v", third["listen"])
}
if len(combined.Outbounds) != 3 {
t.Fatalf("outbounds %d, want 3 (fallback + 2)", len(combined.Outbounds))
}
ob0, _ := asMap(combined.Outbounds[0])
if ob0["tag"] != "ng-fallback-direct" {
t.Fatalf("first outbound = %v, want ng-fallback-direct", ob0["tag"])
}
if combined.Route.Final != "ng-fallback-direct" || len(combined.Route.Rules) != 1 {
t.Fatalf("route = %+v", combined.Route)
}
stats := combined.Experimental.V2RayAPI.Stats
wantTags := []string{"ng-node-abc-in", "ng-node-def-in", "ng-node-def-socks"}
if len(stats.Inbounds) != len(wantTags) {
t.Fatalf("stats inbounds %v", stats.Inbounds)
}
for i, tag := range wantTags {
if stats.Inbounds[i] != tag {
t.Fatalf("stats inbounds[%d] = %v, want %s", i, stats.Inbounds[i], tag)
}
}
wantUsers := []string{"ng:cust-1", "ng:cust-2", "ng:cust-3"}
if len(stats.Users) != len(wantUsers) {
t.Fatalf("stats users %v", stats.Users)
}
for i, u := range wantUsers {
if stats.Users[i] != u {
t.Fatalf("stats users[%d] = %v, want %s", i, stats.Users[i], u)
}
}
// Deterministic output (the activate path byte-compares against the
// existing config to decide whether a restart is needed).
a, _ := marshalIndentJSON(combined)
b, _ := marshalIndentJSON(combinedSingBoxConfig(resources))
if a != b {
t.Fatal("combined config marshaling is not deterministic")
}
// The materialized reality private key must be a raw-base64url 32-byte key.
in0, _ := asMap(combined.Inbounds[0])
tls, _ := asMap(in0["tls"])
reality, _ := asMap(tls["reality"])
pk := asString(reality["private_key"])
if len(pk) != 43 {
t.Fatalf("materialized private_key %q is not a 43-char raw base64url key", pk)
}
}
// TestCombinedConfigAcceptedBySingBox runs the real sing-box binary's
// `check` over a generated config when the release binary is present.
func TestCombinedConfigAcceptedBySingBox(t *testing.T) {
bin := "/home/hatch/workspace/nexusgate-release/bin/nexusgate-sing-box-linux-amd64-v1.13.21"
if _, err := os.Stat(bin); err != nil {
t.Skip("sing-box release binary not present")
}
oldLog := cfg.SingBoxLog
cfg.SingBoxLog = filepath.Join(t.TempDir(), "access.log")
defer func() { cfg.SingBoxLog = oldLog }()
resources := sampleResources(t)
body, err := marshalIndentJSON(combinedSingBoxConfig(resources))
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(body+"\n"), 0o600); err != nil {
t.Fatal(err)
}
out, err := exec.Command(bin, "check", "-c", path).CombinedOutput()
if err != nil {
t.Fatalf("sing-box check rejected generated config: %v\n%s", err, out)
}
}
// ---- parseUsageStats -----------------------------------------------------------
func TestParseUsageStats(t *testing.T) {
parsed, err := parseUsageStats(`{"stat":null}`)
if err != nil {
t.Fatal(err)
}
if len(parsed.inbounds.keys) != 0 || len(parsed.users.keys) != 0 {
t.Fatal("null stat should yield no counters")
}
parsed, err = parseUsageStats(`{"stat":[
{"name":"inbound>>>ng-a-in>>>traffic>>>uplink","value":"100"},
{"name":"inbound>>>ng-a-in>>>traffic>>>downlink","value":"250"},
{"name":"inbound>>>ng-a-in>>>traffic>>>uplink","value":"130"},
{"name":"user>>>ng:cust-1>>>traffic>>>uplink","value":"40"},
{"name":"user>>>ng:cust-1>>>traffic>>>downlink"},
{"name":"outbound>>>direct>>>traffic>>>uplink","value":"999"},
{"name":"user>>>other>>>traffic>>>uplink","value":42}
]}`)
if err != nil {
t.Fatal(err)
}
in := parsed.inbounds.m["ng-a-in"]
if in == nil || in.Uplink != 130 || in.Downlink != 250 {
t.Fatalf("inbound counters = %+v (later value must overwrite)", in)
}
u := parsed.users.m["ng:cust-1"]
if u == nil || u.Uplink != 40 || u.Downlink != 0 {
t.Fatalf("user counters = %+v (missing value must default to 0)", u)
}
if parsed.users.m["other"].Uplink != 42 {
t.Fatal("bare JSON number value 42 not accepted")
}
cases := []struct {
name string
input string
wantErr string
}{
{"negative", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":"-5"}]}`, "负数"},
{"overflow", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":"99999999999999999999999"}]}`, "超出安全整数范围"},
{"alpha", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":"abc"}]}`, "格式 string"},
{"empty-string", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":""}]}`, "格式 string"},
{"null-value", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":null}]}`, "格式 object"},
{"top-array", `[]`, "统计接口返回的 JSON stat 格式无效"},
{"stat-object", `{"stat":{}}`, "统计接口返回的 JSON stat 格式无效"},
{"garbage", `not json`, "统计接口返回的 JSON stat 格式无效"},
}
for _, tc := range cases {
_, err := parseUsageStats(tc.input)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("%s: err = %v, want substring %q", tc.name, err, tc.wantErr)
}
}
}
// ---- observations ------------------------------------------------------------
func TestReadSingBoxObservations(t *testing.T) {
dir := t.TempDir()
oldRes, oldLog, oldCursor := cfg.ResourceDir, cfg.SingBoxLog, cfg.SingBoxCursor
cfg.ResourceDir = filepath.Join(dir, "resources")
cfg.SingBoxLog = filepath.Join(dir, "access.log")
cfg.SingBoxCursor = filepath.Join(dir, "cursor.json")
defer func() { cfg.ResourceDir, cfg.SingBoxLog, cfg.SingBoxCursor = oldRes, oldLog, oldCursor }()
if err := os.MkdirAll(cfg.ResourceDir, 0o700); err != nil {
t.Fatal(err)
}
res := `{"id":"res-node-0001","engine":"sing-box","meta":{"kind":"node","nodeId":"node-abc","metricsTag":"ng-node-abc-in"},"inbounds":[],"outbounds":[],"routingRules":[]}`
if err := os.WriteFile(filepath.Join(cfg.ResourceDir, "res-node-0001.json"), []byte(res), 0o600); err != nil {
t.Fatal(err)
}
logBody := strings.Join([]string{
`2026-10-20T05:00:01+08:00 INFO [111 5ms] inbound/anytls[ng-node-abc-in]: inbound connection from 203.0.113.5:40001`,
`2026-10-20T05:00:01+08:00 INFO [111 9ms] inbound/anytls[ng-node-abc-in]: [ng:cust-9] inbound connection to example.com:443`,
`2026-10-20T05:00:02+08:00 INFO [222 3ms] inbound/anytls[ng-node-abc-in]: inbound connection from 203.0.113.6:40002`,
`2026-10-20T05:00:03+08:00 INFO [333 3ms] inbound/anytls[other-tag]: inbound connection from 203.0.113.7:40003`,
`2026-10-20T05:00:03+08:00 INFO [333 4ms] inbound/anytls[other-tag]: [ng:cust-9] inbound connection to example.org:443`,
"",
}, "\n")
if err := os.WriteFile(cfg.SingBoxLog, []byte(logBody), 0o600); err != nil {
t.Fatal(err)
}
result := readSingBoxObservationsLocked()
if len(result.observations) != 1 {
t.Fatalf("observations = %+v, want exactly the one authenticated session", result.observations)
}
obs := result.observations[0]
if obs.CustomerID != "cust-9" || obs.IP != "203.0.113.5" {
t.Fatalf("observation = %+v", obs)
}
if result.offset == nil || *result.offset != int64(len(logBody)) {
t.Fatalf("offset = %v, want %d (end of file)", result.offset, len(logBody))
}
// With the cursor persisted at EOF, a second read finds nothing new.
body, _ := marshalJSON(map[string]interface{}{"offset": *result.offset})
if err := os.WriteFile(cfg.SingBoxCursor, []byte(body+"\n"), 0o600); err != nil {
t.Fatal(err)
}
second := readSingBoxObservationsLocked()
if len(second.observations) != 0 || second.offset != nil {
t.Fatalf("second read = %+v, want empty with nil offset", second)
}
}
// ---- misc -----------------------------------------------------------------------
func TestSafeError(t *testing.T) {
in := "dial failed Private Key: abcDEF123, more; Authorization: Bearer tok123 end"
out := safeError(in)
if strings.Contains(out, "abcDEF123") || strings.Contains(out, "tok123") {
t.Fatalf("safeError did not redact: %s", out)
}
if !strings.Contains(out, "PrivateKey: [REDACTED]") || !strings.Contains(out, "Bearer [REDACTED]") {
t.Fatalf("safeError replacements missing: %s", out)
}
}
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// readResources loads every *.json in the resource dir, filename-sorted,
// exactly like agent.js readResources(). Files hold the *materialized*
// resource objects written by applyResource.
func readResources() ([]map[string]interface{}, error) {
entries, err := os.ReadDir(cfg.ResourceDir)
if err != nil {
return nil, err
}
names := []string{}
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".json") {
names = append(names, entry.Name())
}
}
sort.Strings(names)
resources := []map[string]interface{}{}
for _, name := range names {
data, err := os.ReadFile(filepath.Join(cfg.ResourceDir, name))
if err != nil {
return nil, err
}
decoded, err := decodeJSON(data)
if err != nil {
return nil, err
}
if m, ok := asMap(decoded); ok {
resources = append(resources, m)
continue
}
if decoded == nil {
// JS would throw a TypeError on item.inbounds for a null resource.
return nil, fmt.Errorf("resource %s is null", name)
}
// Non-object JSON: JS property access yields undefined everywhere,
// which behaves like an empty resource. Mirror with an empty map.
resources = append(resources, map[string]interface{}{})
}
return resources, nil
}
var resourceIDRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{8,100}$`)
func safeResourcePath(resourceID string) (string, error) {
if !resourceIDRe.MatchString(resourceID) {
return "", fmt.Errorf("Invalid resource id")
}
return filepath.Join(cfg.ResourceDir, resourceID+".json"), nil
}
func resMeta(res map[string]interface{}) map[string]interface{} {
meta, _ := asMap(res["meta"])
return meta
}
func isLocalAddress(text string) bool {
target := net.ParseIP(text)
if target == nil {
return false
}
ifaces, err := net.Interfaces()
if err != nil {
return false
}
for _, iface := range ifaces {
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch a := addr.(type) {
case *net.IPNet:
ip = a.IP
case *net.IPAddr:
ip = a.IP
}
if ip != nil && ip.Equal(target) {
return true
}
}
}
return false
}
// normalizeInboundListen mirrors agent.js: a public IP that is not assigned
// to a local interface (typical NAT cloud VM) cannot be bound, so fall back
// to the wildcard address of the same family.
func normalizeInboundListen(inbound interface{}) interface{} {
m, ok := asMap(inbound)
if !ok {
return inbound
}
raw, isStr := m["listen"].(string)
if !isStr {
return inbound
}
text := strings.TrimSpace(raw)
if text == "" || net.ParseIP(text) == nil || text == "0.0.0.0" || text == "::" {
return inbound
}
if isLocalAddress(text) {
return inbound
}
fallback := "0.0.0.0"
if strings.Contains(text, ":") {
fallback = "::"
}
logf("Listen %s 不是本机地址,回退为 %s", text, fallback)
out := make(map[string]interface{}, len(m))
for k, v := range m {
out[k] = v
}
out["listen"] = fallback
return out
}
// Ordered structs keep the generated sing-box config's top-level field order
// identical to the JS template literal in combinedSingBoxConfig (nested
// resource objects keep their own keys, reserialized alphabetically by Go —
// semantically irrelevant to sing-box).
type combinedLog struct {
Level string `json:"level"`
Output string `json:"output"`
Timestamp bool `json:"timestamp"`
}
type combinedRoute struct {
Rules []interface{} `json:"rules"`
Final string `json:"final"`
}
type v2rayAPIStats struct {
Enabled bool `json:"enabled"`
Inbounds []interface{} `json:"inbounds"`
Users []interface{} `json:"users"`
}
type v2rayAPI struct {
Listen string `json:"listen"`
Stats v2rayAPIStats `json:"stats"`
}
type combinedExperimental struct {
V2RayAPI v2rayAPI `json:"v2ray_api"`
}
type combinedConfig struct {
Log combinedLog `json:"log"`
Inbounds []interface{} `json:"inbounds"`
Outbounds []interface{} `json:"outbounds"`
Route combinedRoute `json:"route"`
Experimental combinedExperimental `json:"experimental"`
}
// combinedSingBoxConfig merges all resources into the single sing-box config
// — agent.js combinedSingBoxConfig().
func combinedSingBoxConfig(resources []map[string]interface{}) combinedConfig {
inbounds := []interface{}{}
statInbounds := []interface{}{}
statUsers := []interface{}{}
outbounds := []interface{}{map[string]interface{}{"type": "direct", "tag": "ng-fallback-direct"}}
rules := []interface{}{}
for _, res := range resources {
resInbounds := asSlice(res["inbounds"])
for _, inbound := range resInbounds {
inbounds = append(inbounds, normalizeInboundListen(inbound))
if im, ok := asMap(inbound); ok {
statInbounds = append(statInbounds, im["tag"])
for _, user := range asSlice(im["users"]) {
um, ok := asMap(user)
if !ok {
continue
}
name := um["name"]
if !jsTruthy(name) {
name = um["username"] // AnyTLS/SS use name, SOCKS5 username
}
if jsTruthy(name) {
statUsers = append(statUsers, name)
}
}
}
}
outbounds = append(outbounds, asSlice(res["outbounds"])...)
rules = append(rules, asSlice(res["routingRules"])...)
}
return combinedConfig{
Log: combinedLog{Level: "info", Output: cfg.SingBoxLog, Timestamp: true},
Inbounds: inbounds,
Outbounds: outbounds,
Route: combinedRoute{Rules: rules, Final: "ng-fallback-direct"},
Experimental: combinedExperimental{V2RayAPI: v2rayAPI{
Listen: "127.0.0.1:" + cfg.APIPort,
Stats: v2rayAPIStats{Enabled: true, Inbounds: statInbounds, Users: statUsers},
}},
}
}
+281
View File
@@ -0,0 +1,281 @@
package main
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// runCapture executes a command with a timeout and reports its exit code
// (-1 when the process could not be started or was killed by timeout,
// mirroring spawnSync's null status in agent.js).
func runCapture(timeout time.Duration, name string, args ...string) (stdout, stderr string, exitCode int) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err := cmd.Run()
stdout, stderr = outBuf.String(), errBuf.String()
if err == nil {
return stdout, stderr, 0
}
if exitErr, ok := err.(*exec.ExitError); ok {
return stdout, stderr, exitErr.ExitCode()
}
return stdout, stderr, -1
}
// runThrow mirrors agent.js run(): returns stdout, or throws
// "<cmd> <args> failed: <stderr||stdout, redacted, 1200 chars>".
func runThrow(timeout time.Duration, name string, args ...string) (string, error) {
stdout, stderr, code := runCapture(timeout, name, args...)
if code != 0 {
detail := stderr
if detail == "" {
detail = stdout
}
return "", fmt.Errorf("%s %s failed: %s", name, strings.Join(args, " "),
truncateRunes(safeError(detail), 1200))
}
return stdout, nil
}
func systemdPresent() bool {
return fileExists("/run/systemd/system")
}
// serviceCommand mirrors agent.js: systemctl under systemd, rc-service under
// OpenRC, selected by the presence of /run/systemd/system.
func serviceCommand(action, service string) error {
if systemdPresent() {
_, err := runThrow(30*time.Second, "systemctl", action, service+".service")
return err
}
_, err := runThrow(30*time.Second, "rc-service", service, action)
return err
}
type serviceStatus struct {
Status string
Detail string
}
func serviceHealth(service string) serviceStatus {
var stdout, stderr string
var code int
if systemdPresent() {
stdout, stderr, code = runCapture(3*time.Second, "systemctl", "is-active", service+".service")
} else {
stdout, stderr, code = runCapture(3*time.Second, "rc-service", service, "status")
}
status := "error"
detail := stdout
if code == 0 {
status = "ready"
} else {
detail = getLastEngineError()
if detail == "" {
detail = stderr
}
if detail == "" {
detail = stdout
}
}
return serviceStatus{Status: status, Detail: truncateRunes(strings.TrimSpace(detail), 400)}
}
func copyFilePreserve(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
// JS copyFileSync would create dst at 0666&^umask; the config contains
// Reality private keys, so the Go agent deliberately keeps 0600
// (functional parity, stricter permissions — see PARITY.md).
return os.WriteFile(dst, data, 0o600)
}
// activateSingBoxLocked regenerates the combined sing-box config from all
// resources, validates it with `sing-box check`, swaps it in and restarts
// the service, rolling back on failure — agent.js activateSingBox().
// Callers must hold stateMu.
func activateSingBoxLocked() error {
resources, err := readResources()
if err != nil {
return err
}
if len(resources) == 0 {
// No resources: the engine must be stopped (and any stale config
// removed), otherwise a fresh install loops restarts.
_ = serviceCommand("stop", "nexusgate-sing-box")
if fileExists(cfg.SingBoxConfig) {
_ = os.Remove(cfg.SingBoxConfig)
}
return nil
}
if !fileExists(cfg.SingBoxBin) || !fileExists(cfg.SingBoxStatsBin) {
return fmt.Errorf("sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install")
}
candidate := cfg.SingBoxConfig + ".candidate.json"
previous := cfg.SingBoxConfig + ".previous"
next, err := marshalIndentJSON(combinedSingBoxConfig(resources))
if err != nil {
return err
}
next += "\n"
if current, err := os.ReadFile(cfg.SingBoxConfig); err == nil && string(current) == next &&
serviceHealth("nexusgate-sing-box").Status == "ready" {
return nil // config unchanged and engine healthy: no restart
}
if err := os.MkdirAll(filepath.Dir(cfg.SingBoxConfig), 0o700); err != nil {
return err
}
if err := os.WriteFile(candidate, []byte(next), 0o600); err != nil {
return err
}
if _, err := runThrow(30*time.Second, cfg.SingBoxBin, "check", "-c", candidate); err != nil {
_ = os.Remove(candidate)
return err
}
if fileExists(cfg.SingBoxConfig) {
if err := copyFilePreserve(cfg.SingBoxConfig, previous); err != nil {
return err
}
}
if err := os.Rename(candidate, cfg.SingBoxConfig); err != nil {
return err
}
if err := serviceCommand("restart", "nexusgate-sing-box"); err != nil {
if fileExists(previous) {
_ = copyFilePreserve(previous, cfg.SingBoxConfig)
} else {
_ = os.Remove(cfg.SingBoxConfig)
}
if fileExists(cfg.SingBoxConfig) {
_ = serviceCommand("restart", "nexusgate-sing-box") // preserve original error
}
return err
}
return nil
}
// serviceEpoch identifies one sing-box process instance as "<pid>:<startTick>"
// (PID alone can be reused) — agent.js serviceEpoch(). "" when unknown.
func serviceEpoch() string {
const service = "nexusgate-sing-box"
pid := 0
if systemdPresent() {
stdout, _, code := runCapture(3*time.Second, "systemctl", "show", "-p", "MainPID", "--value", service+".service")
if code == 0 {
pid, _ = strconv.Atoi(strings.TrimSpace(stdout))
}
} else {
if data, err := os.ReadFile("/run/" + service + ".pid"); err == nil {
pid, _ = strconv.Atoi(strings.TrimSpace(string(data)))
}
}
if pid <= 0 {
return ""
}
stat, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
if err != nil {
return ""
}
text := string(stat)
idx := strings.LastIndex(text, ") ")
if idx < 0 {
return ""
}
fields := strings.Fields(strings.TrimSpace(text[idx+2:]))
if len(fields) <= 19 || fields[19] == "" {
return ""
}
return fmt.Sprintf("%d:%s", pid, fields[19])
}
// udpPortsForPid returns the set of UDP ports a process listens on, resolved
// through /proc/<pid>/fd socket inodes and /proc/net/udp{,6} — agent.js
// udpPortsForPid().
func udpPortsForPid(pid int) (map[int]bool, error) {
sockets := map[string]bool{}
fds, err := os.ReadDir(fmt.Sprintf("/proc/%d/fd", pid))
if err != nil {
return nil, err
}
for _, fd := range fds {
target, err := os.Readlink(filepath.Join(fmt.Sprintf("/proc/%d/fd", pid), fd.Name()))
if err != nil {
continue // descriptor closed during inspection
}
if strings.HasPrefix(target, "socket:[") && strings.HasSuffix(target, "]") {
sockets[target[len("socket:["):len(target)-1]] = true
}
}
ports := map[int]bool{}
for _, table := range []string{"/proc/net/udp", "/proc/net/udp6"} {
data, err := os.ReadFile(table)
if err != nil {
return nil, err
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
for _, row := range lines[1:] {
fields := strings.Fields(row)
if len(fields) > 9 && fields[3] == "07" && sockets[fields[9]] {
local := fields[1]
if idx := strings.LastIndex(local, ":"); idx >= 0 {
if port, err := strconv.ParseInt(local[idx+1:], 16, 32); err == nil {
ports[int(port)] = true
}
}
}
}
}
return ports, nil
}
// missingHysteriaListeners lists hysteria2 listen ports the running sing-box
// has no UDP socket for — agent.js missingHysteriaListeners().
func missingHysteriaListeners(resources []map[string]interface{}) ([]int, error) {
expected := []int{}
seen := map[int]bool{}
for _, res := range resources {
for _, inbound := range asSlice(res["inbounds"]) {
im, ok := asMap(inbound)
if !ok || asString(im["type"]) != "hysteria2" {
continue
}
port := jsonNumberToInt(im["listen_port"])
if !seen[port] {
seen[port] = true
expected = append(expected, port)
}
}
}
if len(expected) == 0 {
return []int{}, nil
}
epoch := serviceEpoch()
if epoch == "" {
return nil, fmt.Errorf("无法确认 sing-box 进程,无法验证 Hysteria 2 UDP 监听")
}
pid, _ := strconv.Atoi(strings.SplitN(epoch, ":", 2)[0])
actual, err := udpPortsForPid(pid)
if err != nil {
return nil, err
}
missing := []int{}
for _, port := range expected {
if !actual[port] {
missing = append(missing, port)
}
}
return missing, nil
}
+422
View File
@@ -0,0 +1,422 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
)
// ---- parseUsageStats -------------------------------------------------------
type counterPair struct {
Uplink int64
Downlink int64
}
// orderedCounters keeps first-insertion key order like a JS Map.
type orderedCounters struct {
keys []string
m map[string]*counterPair
}
func newOrderedCounters() *orderedCounters {
return &orderedCounters{m: map[string]*counterPair{}}
}
func (o *orderedCounters) set(key, direction string, value int64) {
entry, ok := o.m[key]
if !ok {
entry = &counterPair{}
o.m[key] = entry
o.keys = append(o.keys, key)
}
if direction == "uplink" {
entry.Uplink = value
} else {
entry.Downlink = value
}
}
var (
inboundStatRe = regexp.MustCompile(`^inbound>>>(.+)>>>traffic>>>(uplink|downlink)$`)
userStatRe = regexp.MustCompile(`^user>>>(.+)>>>traffic>>>(uplink|downlink)$`)
)
type parsedStats struct {
inbounds *orderedCounters
users *orderedCounters
}
// parseUsageStats parses the stats helper's JSON output — agent.js
// parseUsageStats(). Zero traffic makes the helper print {"stat":null},
// which is "no data", not a format error.
func parseUsageStats(output string) (*parsedStats, error) {
invalid := fmt.Errorf("统计接口返回的 JSON stat 格式无效")
var top map[string]json.RawMessage
if err := json.Unmarshal([]byte(output), &top); err != nil || top == nil {
return nil, invalid
}
statRaw, present := top["stat"]
if !present || strings.TrimSpace(string(statRaw)) == "null" {
return &parsedStats{inbounds: newOrderedCounters(), users: newOrderedCounters()}, nil
}
var entries []struct {
Name string `json:"name"`
Value json.RawMessage `json:"value"`
}
if err := json.Unmarshal(statRaw, &entries); err != nil {
return nil, invalid
}
parsed := &parsedStats{inbounds: newOrderedCounters(), users: newOrderedCounters()}
for _, stat := range entries {
var bucket *orderedCounters
var key, direction string
if m := inboundStatRe.FindStringSubmatch(stat.Name); m != nil {
bucket, key, direction = parsed.inbounds, m[1], m[2]
} else if m := userStatRe.FindStringSubmatch(stat.Name); m != nil {
bucket, key, direction = parsed.users, m[1], m[2]
} else {
continue
}
value, reason, valid := jsStatValue(stat.Value)
if !valid {
return nil, fmt.Errorf("入口计数异常(%s):%s;请运行 ng-agent doctor 查看原始统计", reason, stat.Name)
}
bucket.set(key, direction, value)
}
return parsed, nil
}
// ---- queryUsage ------------------------------------------------------------
type usageSample struct {
Kind string `json:"kind"`
ID string `json:"id"`
Uplink int64 `json:"uplink"`
Downlink int64 `json:"downlink"`
Epoch string `json:"epoch"`
}
type usageReport struct {
Samples []usageSample `json:"samples"`
Error *string `json:"error"`
EngineErrors []string `json:"engineErrors"`
}
func (r *usageReport) toMap() map[string]interface{} {
return map[string]interface{}{
"samples": r.Samples,
"error": r.Error,
"engineErrors": r.EngineErrors,
}
}
// queryUsage reads sing-box counters through the stats helper binary and
// shapes them into per-node / per-user samples — agent.js queryUsage().
// Callers must hold stateMu.
func queryUsageLocked() (*usageReport, error) {
all, err := readResources()
if err != nil {
return nil, err
}
resources := []map[string]interface{}{}
for _, res := range all {
meta := resMeta(res)
if meta != nil && jsTruthy(meta["metricsTag"]) && asString(meta["kind"]) == "node" {
resources = append(resources, res)
}
}
samples := []usageSample{}
errorsList := []string{}
engineErrors := []string{}
if len(resources) == 0 {
return &usageReport{Samples: samples, Error: nil, EngineErrors: engineErrors}, nil
}
before := serviceEpoch()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
cmd := exec.CommandContext(ctx, cfg.SingBoxStatsBin, "--server=127.0.0.1:"+cfg.APIPort)
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
runErr := cmd.Run()
cancel()
after := serviceEpoch()
failed := false
parsed := &parsedStats{inbounds: newOrderedCounters(), users: newOrderedCounters()}
if before != "" && after != "" && before != after {
errorsList = append(errorsList, "sing-box restarted during statistics query")
engineErrors = append(engineErrors, "sing-box")
failed = true
} else if runErr != nil {
detail := errBuf.String()
if detail == "" && runErr != nil {
detail = runErr.Error()
}
if detail == "" {
detail = outBuf.String()
}
errorsList = append(errorsList, fmt.Sprintf("sing-box statsquery failed: %s",
truncateRunes(safeError(detail), 160)))
engineErrors = append(engineErrors, "sing-box")
failed = true
}
if !failed {
part, err := parseUsageStats(outBuf.String())
if err != nil {
errorsList = append(errorsList, "sing-box: "+safeError(err.Error()))
engineErrors = append(engineErrors, "sing-box")
failed = true
} else {
parsed = part
}
}
if !failed {
epochValue := before
if epochValue == "" {
epochValue = serviceEpoch()
}
if epochValue == "" {
epochValue = "unknown"
}
epoch := "sing-box:" + epochValue
tagNodes := map[string]string{}
for _, res := range resources {
meta := resMeta(res)
tagNodes[asString(meta["metricsTag"])] = asString(meta["nodeId"])
}
for _, tag := range parsed.inbounds.keys {
counters := parsed.inbounds.m[tag]
nodeID, ok := tagNodes[tag]
if !ok || nodeID == "" {
continue
}
samples = append(samples, usageSample{Kind: "inbound", ID: nodeID,
Uplink: counters.Uplink, Downlink: counters.Downlink, Epoch: epoch})
}
userRe := regexp.MustCompile(`^ng:(.+)$`)
for _, name := range parsed.users.keys {
counters := parsed.users.m[name]
match := userRe.FindStringSubmatch(name)
if match == nil {
continue
}
samples = append(samples, usageSample{Kind: "user", ID: match[1],
Uplink: counters.Uplink, Downlink: counters.Downlink, Epoch: epoch})
}
if len(parsed.inbounds.keys) == 0 && len(parsed.users.keys) == 0 {
errorsList = append(errorsList, "sing-box 入口尚无统计记录")
}
}
var errPtr *string
if len(errorsList) > 0 {
joined := strings.Join(errorsList, "; ")
errPtr = &joined
}
return &usageReport{Samples: samples, Error: errPtr, EngineErrors: engineErrors}, nil
}
func queryUsage() (*usageReport, error) {
stateMu.Lock()
defer stateMu.Unlock()
return queryUsageLocked()
}
// ---- flush before config changes ------------------------------------------
// flushUsageBeforeChange persists the running engine's counters before an
// apply/delete restarts sing-box and destroys them — agent.js
// flushUsageBeforeChange(). Callers must hold stateMu.
func flushUsageBeforeChangeLocked() error {
resources, err := readResources()
if err != nil {
return err
}
live := false
for _, res := range resources {
meta := resMeta(res)
if meta != nil && jsTruthy(meta["metricsTag"]) && asString(meta["kind"]) == "node" {
live = true
break
}
}
if !live || serviceHealth("nexusgate-sing-box").Status != "ready" {
return nil
}
report, err := queryUsageLocked()
if err != nil {
return err
}
for _, e := range report.EngineErrors {
if e == "sing-box" {
return fmt.Errorf("sing-box 重载前无法读取入口计数;请先运行 ng-agent doctor")
}
}
// Await the controller's durable write before the restart.
_, err = requestJSON("/api/agent/usage", report.toMap())
return err
}
// flushAllUsageBeforeRestart backs the `flush-usage` CLI action used by
// agent-update.sh.
func flushAllUsageBeforeRestart() error {
stateMu.Lock()
defer stateMu.Unlock()
return flushUsageBeforeChangeLocked()
}
// ---- AnyTLS IP observations ------------------------------------------------
var (
anytlsCtxRe = regexp.MustCompile(`\[(\d+)\s+\d+ms\]\s+inbound/anytls\[([^\]]+)\]:\s+(.*)$`)
anytlsSrcRe = regexp.MustCompile(`inbound connection from\s+(\[[^\]]+\]|[\d.]+):\d+`)
anytlsAuthRe = regexp.MustCompile(`\[ng:([^\]]+)\] inbound connection to `)
)
type observation struct {
CustomerID string `json:"customerId"`
IP string `json:"ip"`
}
type observationsResult struct {
observations []observation
offset *int64
}
// readSingBoxObservations tails the sing-box access log from the saved cursor
// and extracts authenticated AnyTLS (customerId, source IP) pairs — agent.js
// readSingBoxObservations(). Callers must hold stateMu.
func readSingBoxObservationsLocked() observationsResult {
empty := observationsResult{observations: []observation{}, offset: nil}
info, err := os.Stat(cfg.SingBoxLog)
if err != nil {
return empty
}
var cursorOffset int64
if data, err := os.ReadFile(cfg.SingBoxCursor); err == nil {
var cursor struct {
Offset int64 `json:"offset"`
}
if err := json.Unmarshal(data, &cursor); err == nil {
cursorOffset = cursor.Offset
}
}
size := info.Size()
if size < cursorOffset {
cursorOffset = 0
}
start := cursorOffset
if floor := size - 2*1024*1024; floor > start {
start = floor
}
if start >= size {
return empty
}
f, err := os.Open(cfg.SingBoxLog)
if err != nil {
return empty
}
defer f.Close()
buffer := make([]byte, size-start)
if _, err := f.ReadAt(buffer, start); err != nil {
return empty
}
end := bytes.LastIndex(buffer, []byte("\n"))
if end < 0 {
return empty
}
resources, err := readResources()
if err != nil {
return empty
}
entryTags := map[string]bool{}
for _, res := range resources {
if meta := resMeta(res); meta != nil && asString(meta["kind"]) == "node" {
if tag := asString(meta["metricsTag"]); tag != "" {
entryTags[tag] = true
}
}
}
sourceByID := map[string]string{}
uniqueKeys := []string{}
unique := map[string]observation{}
for _, line := range strings.Split(string(buffer[:end]), "\n") {
ctx := anytlsCtxRe.FindStringSubmatch(line)
if ctx == nil || !entryTags[ctx[2]] {
continue
}
key := ctx[2] + ":" + ctx[1]
if src := anytlsSrcRe.FindStringSubmatch(ctx[3]); src != nil {
ip := src[1]
ip = strings.TrimPrefix(ip, "[")
ip = strings.TrimSuffix(ip, "]")
sourceByID[key] = ip
}
// Only count authenticated AnyTLS sessions, never unauthenticated probes.
if authed := anytlsAuthRe.FindStringSubmatch(ctx[3]); authed != nil {
if ip, ok := sourceByID[key]; ok {
customerID := authed[1]
uniqKey := customerID + "|" + ip
if _, exists := unique[uniqKey]; !exists {
uniqueKeys = append(uniqueKeys, uniqKey)
}
unique[uniqKey] = observation{CustomerID: customerID, IP: ip}
}
}
}
result := observationsResult{observations: []observation{}}
for _, k := range uniqueKeys {
result.observations = append(result.observations, unique[k])
}
offset := start + int64(end) + 1
result.offset = &offset
return result
}
// ---- periodic loops ----------------------------------------------------------
func usageLoop() {
for {
sleepMS(60000)
func() {
report, err := queryUsage()
var body map[string]interface{}
if err != nil {
// JS catch fallback: { samples: [], error } (no engineErrors key).
msg := safeError(err.Error())
body = map[string]interface{}{"samples": []usageSample{}, "error": msg}
} else {
body = report.toMap()
}
if _, err := requestJSON("/api/agent/usage", body); err != nil {
logf("Usage report failed %s", safeError(err.Error()))
}
}()
func() {
stateMu.Lock()
defer stateMu.Unlock()
sing := readSingBoxObservationsLocked()
if len(sing.observations) > 0 {
if _, err := requestJSON("/api/agent/observations",
map[string]interface{}{"observations": sing.observations}); err != nil {
logf("IP observation report failed %s", safeError(err.Error()))
return
}
}
// A failed upload must not advance the cursor; the same log
// lines are retried next cycle (the early return above matches
// the JS try/catch scope).
if sing.offset != nil {
body, _ := marshalJSON(map[string]interface{}{"offset": *sing.offset})
if err := os.WriteFile(cfg.SingBoxCursor, []byte(body+"\n"), 0o600); err != nil {
logf("IP observation report failed %s", safeError(err.Error()))
}
}
}()
}
}