# 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:]` 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 `, `Content-Type: application/json`, 20s timeout. - Endpoints: `/api/agent/poll` (`{}` → `{job|null}`), `/api/agent/jobs//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 = { "": { 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:] 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 `/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//` 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 `/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.