Files
NexusGate/agent-go/PARITY.md
T

14 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.
  • Panel-hosted Reality keys: apply payloads from a hosting panel carry payload.keyMaterial = { "<keyId>": { privateKey, publicKey } }. The agent persists it into keys.json before materializing (panel store wins over a divergent local entry), and reports any used keypair the panel did not provide back as artifacts.keyMaterial for the panel to adopt. Panels without hosting send no keyMaterial and old panels ignore the new artifact field — both directions degrade to the pre-hosting flow. Protocol details and precedence rules: KEYHOSTING.md in the repo root.
  • 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.