Files
NexusGate/agent-go/PARITY.md
T

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/ (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.