13 KiB
13 KiB
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/(singlemainpackage, 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.jsgates AnyTLS deploys onversion.split('.').map(Number) >= 0.6.1; a"0.6.8-go"suffix would parse the third component asNaNand fail the gate. Do not change without updating the panel check. - Heartbeat
system.archuses Node vocabulary (x64for amd64) — mapped innodeArch(). - Engine object sends
singBoxStatusas the raw service status (ready/error); the panel maps non-readytoinactive, same as with the Node agent.
Critical correctness points
- Reality keys: generated with
crypto/ecdhX25519; private/public arebase64.RawURLEncodingof the raw 32-byte keys — byte-identical to Node JWKd/xand tosing-box generate reality-keypairoutput (verified against the RFC 7748 test vector, the real sing-box binary's output format, and a fullsing-box checkover a config containing a Go-generated key). No padded/standard-base64 conversion anywhere. - 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.
- 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 byagent-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_KEYNG_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/.previoussiblings 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(default10086),NG_POLL_SECONDS(default 8, min 3)/etc/nexusgate/last-heartbeat.jsonstamp 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)
- 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). - HTTP request body key order (heartbeat/usage bodies): Go maps sort keys; the panel parses JSON, so this is invisible.
copyFilePreservemode:.previousconfig backup is written 0600; JScopyFileSyncwould create it at 0666&^umask. Stricter, intentional.NG_POLL_SECONDSnon-numeric: JS yields NaN (busy loop); Go clamps to the 3-second floor. Only affects a misconfigured env.- 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'sspawnSync … ENOENTwording; same position in the same message templates, only the OS-error phrasing differs. Likewise V8 vs Go JSON parse error texts inside thesing-box: …diagnostic prefix. 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.readSingBoxObservationsinternal failure logging: a malformed resource file makes JS logIP observation report failedonce per cycle; Go silently returns no observations (cursor not advanced in both).- 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. - 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.
- 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. - 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 byagent-install.sh, not the agent binary — unchanged. ng-agenthelper scripts (cert/doctor/engine/update/uninstall) are shell and unchanged; they invoke the agent only via theflush-usageaction, 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 fromagent-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.
safeErrorredaction.
- 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:{}}}; thendelete_resource→ usage flush POST first ({"samples":[],"error":"sing-box 入口尚无统计记录","engineErrors":[]}),complete {removed:true}, config removed, engine stopped. 30s heartbeat cadence observed.flush-usageCLI 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.