Panel: after one scrypt-verified key auth, issue a random 24h session token; store only its SHA-256 hash + expiry on the agent record. authenticateAgent tries the token path first (constant-time hash compare, active + unexpired), then the original fingerprint / legacy VerifySecret path, and hands fresh tokens out via the X-Agent-Session-Token response headers. Revoke() now clears token material at all three revocation sites. Agent: persist the token in the config dir (0600, temp+rename like keys.json), present it as bearer, and on any 401 clear it and retry once with the key (which re-issues). Old panels never send the headers, so key-only operation is unchanged; old agents ignore the headers. Version stays 0.6.8. Tests: panel — issuance, hash-only storage, scrypt-free token path (sabotaged KeyHash still authenticates), expiry fallback + rotation, hash-mismatch fallback, revocation, per-record binding, key-only rotation. Agent — capture/persist/reuse against a fake panel, 401 fallback and re-issue, old-panel key-only, client-side expiry, double rejection surfacing without retry loops.
14 KiB
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/(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. - 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 asartifacts.keyMaterialfor the panel to adopt. Panels without hosting send nokeyMaterialand old panels ignore the new artifact field — both directions degrade to the pre-hosting flow. Protocol details and precedence rules:KEYHOSTING.mdin the repo root. - 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)
- Panel session tokens (round-4 item 25, protocol superset): against a
panel that issues them, the Go agent persists the short-lived session
token (
session-token.jsonin the config dir, 0600) and presents it as the bearer instead of its key, falling back to the key on any 401. The Node agent has no such concept; wire format (a Bearer value) and all endpoints are unchanged, and panels without token support are unaffected. Divergent by design — it exists to take scrypt off the panel's per-request path. - 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.