216 lines
14 KiB
Markdown
216 lines
14 KiB
Markdown
# 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.
|