Files
aaaamoooo 408b03ee68 feat(agent-auth): session tokens replace per-request scrypt (item 25)
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.
2026-10-21 13:12:19 +00:00

224 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)
0. **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.json` in 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.
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.