From ddfc27efbaf12837d49be2f4a3143dd9c1d3ac7b Mon Sep 17 00:00:00 2001 From: aaaamoooo Date: Tue, 20 Oct 2026 06:55:29 +0000 Subject: [PATCH] feat: Go agent source (stdlib-only rewrite of agent.js) + parity/install docs; binaries ship as release assets --- agent-go/.gitignore | 1 + agent-go/INSTALL-GO.md | 46 +++++ agent-go/PARITY.md | 207 ++++++++++++++++++++ agent-go/go.mod | 3 + agent-go/health.go | 263 +++++++++++++++++++++++++ agent-go/jobs.go | 193 +++++++++++++++++++ agent-go/jsonutil.go | 203 ++++++++++++++++++++ agent-go/keys.go | 148 +++++++++++++++ agent-go/main.go | 281 +++++++++++++++++++++++++++ agent-go/main_test.go | 369 +++++++++++++++++++++++++++++++++++ agent-go/resources.go | 209 ++++++++++++++++++++ agent-go/singbox.go | 281 +++++++++++++++++++++++++++ agent-go/usage.go | 422 +++++++++++++++++++++++++++++++++++++++++ 13 files changed, 2626 insertions(+) create mode 100644 agent-go/.gitignore create mode 100644 agent-go/INSTALL-GO.md create mode 100644 agent-go/PARITY.md create mode 100644 agent-go/go.mod create mode 100644 agent-go/health.go create mode 100644 agent-go/jobs.go create mode 100644 agent-go/jsonutil.go create mode 100644 agent-go/keys.go create mode 100644 agent-go/main.go create mode 100644 agent-go/main_test.go create mode 100644 agent-go/resources.go create mode 100644 agent-go/singbox.go create mode 100644 agent-go/usage.go diff --git a/agent-go/.gitignore b/agent-go/.gitignore new file mode 100644 index 0000000..85bd2d5 --- /dev/null +++ b/agent-go/.gitignore @@ -0,0 +1 @@ +nexusgate-agent-linux-* diff --git a/agent-go/INSTALL-GO.md b/agent-go/INSTALL-GO.md new file mode 100644 index 0000000..5f5529b --- /dev/null +++ b/agent-go/INSTALL-GO.md @@ -0,0 +1,46 @@ +# Go Agent 安装/更新流程(自 v0.6.8 起默认) + +Agent 已从 Node.js(`agent/agent.js`)切换为 Go 静态二进制 +(`agent-go/`,约 6MB,实测 RSS 约 12MB,节点机不再需要安装 Node.js)。 +协议、环境变量、状态文件格式与 Node 版完全一致(见 `PARITY.md`), +可直接接管已有节点的状态。 + +## 新装(scripts/agent-install.sh) + +- 默认从 GitHub Release 下载二进制: + `https://github.com/aaaamoooo/NexusGate/releases/download/agent-v0.6.8/nexusgate-agent-linux-` + (`` 由 `uname -m` 决定:x86_64→amd64,aarch64→arm64) +- 安装到 `/opt/nexusgate-agent/nexusgate-agent`(0755)。 +- 服务名仍为 `nexusgate-agent`: + - systemd:`systemd/nexusgate-agent.service`,`EnvironmentFile=/etc/nexusgate/agent.env`, + `ExecStart=/opt/nexusgate-agent/nexusgate-agent`,`Restart=always`。 + - OpenRC:`openrc/nexusgate-agent`,`command` 指向同一二进制, + 脚本内手动 source `/etc/nexusgate/agent.env`(OpenRC 不读 EnvironmentFile)。 +- 其余流程与旧版一致:写 `agent.env`、注册、安装 sing-box、logrotate、心跳校验。 +- 环境变量:`NG_AGENT_RELEASE` 可覆盖 Release tag;`NG_LOCAL_SRC` 指向本地 + 仓库时从 `agent-go/` 取已构建的二进制(离线/调试用)。 + +## 更新(scripts/agent-update.sh / ng-agent update) + +脚本按已安装程序自动判断实现: + +- 存在 `/opt/nexusgate-agent/nexusgate-agent` → Go 路径:先用当前二进制 + `flush-usage` 落盘流量计数,再下载同一 Release 资产替换二进制、 + 刷新服务文件并重启,最后校验心跳。 +- 只有 `agent.js` → Node 路径:保持旧流程(下载 agent.js/run.sh 并 + `node --check`);刷新服务文件时会把 ExecStart/command 改回经 + `run.sh` 启动。 + +## 回退到 Node Agent + +- 新装时回退:`NG_AGENT_IMPL=node bash agent-install.sh ...`(会安装 + nodejs 包、部署 `agent/agent.js` + `agent/run.sh`,服务经 run.sh 启动)。 +- 已装 Go 的节点回退:同样用 `NG_AGENT_IMPL=node` 重跑安装脚本, + 它会删除 Go 二进制并部署 Node 版(注册信息会重新生成,需用新 token)。 + +## 发布清单(Release: agent-v0.6.8) + +需上传到 tag `agent-v0.6.8` 的资产(文件名必须完全一致): + +- `nexusgate-agent-linux-amd64` +- `nexusgate-agent-linux-arm64` diff --git a/agent-go/PARITY.md b/agent-go/PARITY.md new file mode 100644 index 0000000..4dc6992 --- /dev/null +++ b/agent-go/PARITY.md @@ -0,0 +1,207 @@ +# 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. +- 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. diff --git a/agent-go/go.mod b/agent-go/go.mod new file mode 100644 index 0000000..20ab5d8 --- /dev/null +++ b/agent-go/go.mod @@ -0,0 +1,3 @@ +module nexusgate-agent-go + +go 1.24 diff --git a/agent-go/health.go b/agent-go/health.go new file mode 100644 index 0000000..e37f772 --- /dev/null +++ b/agent-go/health.go @@ -0,0 +1,263 @@ +package main + +import ( + "math" + "os" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +// ---- systemInfo -------------------------------------------------------------- + +type systemReport struct { + Hostname string `json:"hostname"` + Platform string `json:"platform"` + Release string `json:"release"` + Arch string `json:"arch"` + UptimeSeconds int64 `json:"uptimeSeconds"` + Load1 float64 `json:"load1"` + CPUCount int `json:"cpuCount"` + MemoryTotal uint64 `json:"memoryTotal"` + MemoryFree uint64 `json:"memoryFree"` + MemoryAvailable uint64 `json:"memoryAvailable"` + DiskTotal *uint64 `json:"diskTotal"` + DiskAvailable *uint64 `json:"diskAvailable"` +} + +// nodeArch maps Go's GOARCH to Node's os.arch() vocabulary (amd64 → x64 etc). +func nodeArch() string { + switch runtime.GOARCH { + case "amd64": + return "x64" + case "386": + return "ia32" + case "arm64", "arm", "ppc64le", "s390x", "riscv64", "loong64", "mips64le": + return runtime.GOARCH + default: + return runtime.GOARCH + } +} + +var memAvailableRe = regexp.MustCompile(`(?m)^MemAvailable:\s+(\d+) kB`) + +func systemInfo() systemReport { + // Node's os.totalmem()/freemem() use sysinfo(2) on Linux; do the same. + var si syscall.Sysinfo_t + var memTotal, memFree uint64 + if err := syscall.Sysinfo(&si); err == nil { + memTotal = si.Totalram * uint64(si.Unit) + memFree = si.Freeram * uint64(si.Unit) + } + memAvailable := memFree + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + if m := memAvailableRe.FindSubmatch(data); m != nil { + if kb, err := strconv.ParseUint(string(m[1]), 10, 64); err == nil { + memAvailable = kb * 1024 + } + } + } + var uptime int64 + if data, err := os.ReadFile("/proc/uptime"); err == nil { + fields := strings.Fields(string(data)) + if len(fields) > 0 { + if seconds, err := strconv.ParseFloat(fields[0], 64); err == nil { + uptime = int64(math.Round(seconds)) + } + } + } + var load1 float64 + if data, err := os.ReadFile("/proc/loadavg"); err == nil { + fields := strings.Fields(string(data)) + if len(fields) > 0 { + if v, err := strconv.ParseFloat(fields[0], 64); err == nil { + load1 = math.Round(v*100) / 100 // Number(loadavg[0].toFixed(2)) + } + } + } + release := "" + if data, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil { + release = strings.TrimSpace(string(data)) + } + hostname, _ := os.Hostname() + report := systemReport{ + Hostname: hostname, + Platform: runtime.GOOS, + Release: release, + Arch: nodeArch(), + UptimeSeconds: uptime, + Load1: load1, + CPUCount: runtime.NumCPU(), + MemoryTotal: memTotal, + MemoryFree: memFree, + MemoryAvailable: memAvailable, + } + var fs syscall.Statfs_t + if err := syscall.Statfs("/", &fs); err == nil { + total := fs.Blocks * uint64(fs.Bsize) + avail := fs.Bavail * uint64(fs.Bsize) + report.DiskTotal = &total + report.DiskAvailable = &avail + } + return report +} + +// ---- installedCertificates --------------------------------------------------- + +type certCache struct { + fingerprint string + checkedAt time.Time + domains []string +} + +var ( + certCacheMu sync.Mutex + certCacheV certCache +) + +func opensslOK(timeout time.Duration, args ...string) (string, bool) { + stdout, _, code := runCapture(timeout, "openssl", args...) + return stdout, code == 0 +} + +// installedCertificates lists TLS domains under NG_TLS_DIR whose cert/key +// pairs verify — agent.js installedCertificates(), including its 5-minute +// fingerprint cache. +func installedCertificates() []string { + root := cfg.TLSDir + entries, err := os.ReadDir(root) + if err != nil { + return []string{} + } + domains := []string{} + for _, entry := range entries { + domains = append(domains, entry.Name()) + } + if len(domains) > 50 { + domains = domains[:50] + } + // Fingerprint over (domain, cert mtime/size, key mtime/size) tuples. + fpParts := []interface{}{} + for _, domain := range domains { + certStat, certErr := os.Stat(filepath.Join(root, domain, "fullchain.pem")) + keyStat, keyErr := os.Stat(filepath.Join(root, domain, "privkey.pem")) + if certErr == nil && keyErr == nil { + fpParts = append(fpParts, []interface{}{domain, + float64(certStat.ModTime().UnixMilli()), certStat.Size(), + float64(keyStat.ModTime().UnixMilli()), keyStat.Size()}) + } else { + fpParts = append(fpParts, []interface{}{domain}) + } + } + fingerprint, _ := marshalJSON(fpParts) + certCacheMu.Lock() + defer certCacheMu.Unlock() + if certCacheV.fingerprint == fingerprint && time.Since(certCacheV.checkedAt) < 300*time.Second { + return append([]string{}, certCacheV.domains...) + } + valid := []string{} + for _, domain := range domains { + if !domainRe.MatchString(domain) { + continue + } + cert := filepath.Join(root, domain, "fullchain.pem") + key := filepath.Join(root, domain, "privkey.pem") + if !fileExists(cert) || !fileExists(key) { + continue + } + if _, ok := opensslOK(3*time.Second, "x509", "-in", cert, "-noout", "-checkend", "86400"); !ok { + continue + } + hostOut, ok := opensslOK(3*time.Second, "x509", "-in", cert, "-noout", "-checkhost", domain) + if !ok || !strings.Contains(hostOut, "does match") { + continue + } + if _, ok := opensslOK(3*time.Second, "verify", "-verify_hostname", domain, "-untrusted", cert, cert); !ok { + continue + } + certKey, ok1 := opensslOK(3*time.Second, "x509", "-in", cert, "-pubkey", "-noout") + privateKey, ok2 := opensslOK(3*time.Second, "pkey", "-in", key, "-pubout") + if !ok1 || !ok2 || strings.TrimSpace(certKey) != strings.TrimSpace(privateKey) { + continue + } + valid = append(valid, domain) + } + certCacheV = certCache{fingerprint: fingerprint, checkedAt: time.Now(), domains: valid} + return append([]string{}, valid...) +} + +// ---- engineHealth / heartbeat ------------------------------------------------- + +type engineReport struct { + Status string `json:"status"` + Detail string `json:"detail"` + SingBoxInstalled bool `json:"singBoxInstalled"` + Certificates []string `json:"certificates"` + SingBoxStatus string `json:"singBoxStatus"` +} + +// engineHealth mirrors agent.js engineHealth(). Callers must hold stateMu. +// A readResources error propagates (JS throws, so the heartbeat is skipped). +func engineHealthLocked() (engineReport, error) { + resources, err := readResources() + if err != nil { + return engineReport{}, err + } + installed := fileExists(cfg.SingBoxBin) && fileExists(cfg.SingBoxStatsBin) + service := serviceHealth("nexusgate-sing-box") + result := engineReport{ + SingBoxInstalled: installed, + Certificates: installedCertificates(), + SingBoxStatus: service.Status, + } + if installed { + result.Status = service.Status + result.Detail = service.Detail + } else { + result.Status = "error" + result.Detail = "sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install" + } + if result.Status == "ready" && len(resources) > 0 { + missing, err := missingHysteriaListeners(resources) + if err != nil { + result.Status = "error" + result.Detail = safeError(err.Error()) + } else if len(missing) > 0 { + parts := make([]string, len(missing)) + for i, port := range missing { + parts[i] = strconv.Itoa(port) + } + result.Status = "error" + result.Detail = "Hysteria 2 UDP 未监听:" + strings.Join(parts, ", ") + } + } + return result, nil +} + +func heartbeat() error { + stateMu.Lock() + engine, err := engineHealthLocked() + stateMu.Unlock() + if err != nil { + return err + } + body := map[string]interface{}{ + "version": version, + "system": systemInfo(), + "engine": engine, + } + if _, err := requestJSON("/api/agent/heartbeat", body); err != nil { + return err + } + stamp, _ := marshalJSON(map[string]interface{}{"at": isoNow()}) + if err := os.WriteFile("/etc/nexusgate/last-heartbeat.json", []byte(stamp+"\n"), 0o600); err != nil { + // JS logs this but does not fail the heartbeat. + logf("Heartbeat reached controller but readiness file failed %s", err.Error()) + } + return nil +} diff --git a/agent-go/jobs.go b/agent-go/jobs.go new file mode 100644 index 0000000..349bca1 --- /dev/null +++ b/agent-go/jobs.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "strings" + "time" +) + +type agentJob struct { + ID string `json:"id"` + Action string `json:"action"` + Payload struct { + Resource json.RawMessage `json:"resource"` + ResourceID string `json:"resourceId"` + } `json:"payload"` +} + +var domainRe = regexp.MustCompile(`^(?:[a-z0-9-]+\.)+[a-z]{2,63}$`) + +func tlsCertMissingError(domain string) error { + return fmt.Errorf("TLS certificate for %s is missing; run ng-agent cert issue %s EMAIL or ng-agent cert import", domain, domain) +} + +// applyResourceLocked validates and persists one materialized resource, then +// re-activates sing-box, restoring the previous resource on failure — +// agent.js applyResource(). Callers must hold stateMu. +func applyResourceLocked(rawResource json.RawMessage) (map[string]interface{}, error) { + if len(rawResource) == 0 { + return nil, fmt.Errorf("Missing resource payload") + } + decoded, err := decodeJSON(rawResource) + if err != nil { + return nil, err + } + resMap, ok := asMap(decoded) + if !ok || asString(resMap["id"]) == "" { + return nil, fmt.Errorf("Missing resource payload") + } + art := &artifacts{} + materialized, err := materialize(resMap, art) + if err != nil { + return nil, err + } + resource, _ := asMap(materialized) + if engine, present := resource["engine"]; present && jsTruthy(engine) && asString(engine) != "sing-box" { + return nil, fmt.Errorf("Unsupported engine: %v(Xray 已弃用,只支持 sing-box)", engine) + } + for _, inboundRaw := range asSlice(resource["inbounds"]) { + inbound, ok := asMap(inboundRaw) + if !ok { + continue + } + // Hysteria 2 refuses to run an unauthenticated entry. + if asString(inbound["type"]) == "hysteria2" { + users := asSlice(inbound["users"]) + unprotected := len(users) == 0 + for _, user := range users { + um, ok := asMap(user) + if !ok || asString(um["password"]) == "" { + unprotected = true + } + } + if unprotected { + return nil, fmt.Errorf("Hysteria 2 入口没有认证账户;拒绝启动未受保护的入口") + } + } + tls, ok := asMap(inbound["tls"]) + if !ok { + continue + } + if reality, ok := asMap(tls["reality"]); ok && jsTruthy(reality["enabled"]) { + continue // Reality has no certificate to check + } + domain := asString(tls["server_name"]) + if !domainRe.MatchString(domain) { + return nil, fmt.Errorf("TLS 域名无效") + } + expected := "/etc/nexusgate/tls/" + domain + "/" + certFile := asString(tls["certificate_path"]) + keyFile := asString(tls["key_path"]) + if certFile == "" || keyFile == "" { + return nil, tlsCertMissingError(domain) + } + if !strings.HasPrefix(certFile, expected) || !strings.HasPrefix(keyFile, expected) || + !fileExists(certFile) || !fileExists(keyFile) { + return nil, tlsCertMissingError(domain) + } + if _, err := runThrow(30*time.Second, "openssl", "x509", "-in", certFile, "-noout", "-checkend", "86400"); err != nil { + return nil, err + } + nameCheck, err := runThrow(30*time.Second, "openssl", "x509", "-in", certFile, "-noout", "-checkhost", domain) + if err != nil { + return nil, err + } + if !strings.Contains(nameCheck, "does match") { + return nil, fmt.Errorf("TLS certificate does not match %s", domain) + } + } + target, err := safeResourcePath(asString(resource["id"])) + if err != nil { + return nil, err + } + var backup []byte + if data, err := os.ReadFile(target); err == nil { + backup = data + } + if err := flushUsageBeforeChangeLocked(); err != nil { + return nil, err + } + body, err := marshalIndentJSON(resource) + if err != nil { + return nil, err + } + if err := os.WriteFile(target, []byte(body+"\n"), 0o600); err != nil { + return nil, err + } + if err := activateSingBoxLocked(); err != nil { + if backup != nil { + _ = os.WriteFile(target, backup, 0o600) + } else { + _ = os.Remove(target) + } + _ = activateSingBoxLocked() // preserve original error + return nil, err + } + setLastEngineError("") + return map[string]interface{}{"artifacts": art.toMap()}, nil +} + +// deleteResourceLocked removes one resource and re-activates — agent.js +// deleteResource(). Callers must hold stateMu. +func deleteResourceLocked(resourceID string) (map[string]interface{}, error) { + target, err := safeResourcePath(resourceID) + if err != nil { + return nil, err + } + if !fileExists(target) { + return map[string]interface{}{"alreadyAbsent": true}, nil + } + backup, err := os.ReadFile(target) + if err != nil { + return nil, err + } + if err := flushUsageBeforeChangeLocked(); err != nil { + return nil, err + } + if err := os.Remove(target); err != nil { + return nil, err + } + if err := activateSingBoxLocked(); err != nil { + _ = os.WriteFile(target, backup, 0o600) + return nil, err + } + return map[string]interface{}{"removed": true}, nil +} + +// execute runs one job and reports completion to the panel — agent.js execute(). +func execute(job *agentJob) { + logf("Executing %s %s", job.ID, job.Action) + stateMu.Lock() + var result map[string]interface{} + var jobErr error + switch job.Action { + case "apply_resource": + result, jobErr = applyResourceLocked(job.Payload.Resource) + case "delete_resource": + result, jobErr = deleteResourceLocked(job.Payload.ResourceID) + default: + jobErr = fmt.Errorf("Unsupported job action: %s", job.Action) + } + stateMu.Unlock() + completeURL := "/api/agent/jobs/" + job.ID + "/complete" + // agent.js wraps the job AND the success report in one try/catch: if the + // completion POST fails after a successful apply, it still falls through + // to the failure report below. Faithfully replicated (see PARITY.md). + err := jobErr + if err == nil { + _, err = requestJSON(completeURL, map[string]interface{}{"success": true, "result": result}) + if err == nil { + logf("Completed %s", job.ID) + return + } + } + logf("Failed %s %s", job.ID, safeError(err.Error())) + if _, reportErr := requestJSON(completeURL, map[string]interface{}{ + "success": false, "error": safeError(err.Error()), + }); reportErr != nil { + logf("Unable to report failure %s", reportErr.Error()) + } +} diff --git a/agent-go/jsonutil.go b/agent-go/jsonutil.go new file mode 100644 index 0000000..3256559 --- /dev/null +++ b/agent-go/jsonutil.go @@ -0,0 +1,203 @@ +package main + +import ( + "bytes" + "encoding/json" + "math" + "regexp" + "strconv" + "strings" +) + +// decodeJSON parses JSON the way JS JSON.parse + later property access behaves +// for this codebase: numbers keep their literal form (json.Number) so large +// counters never lose precision through float64. +func decodeJSON(data []byte) (interface{}, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var v interface{} + if err := dec.Decode(&v); err != nil { + return nil, err + } + return v, nil +} + +// marshalJSON renders compact JSON like JSON.stringify: UTF-8 output, no HTML +// escaping (Go's default would escape <, >, & which JS does not). +func marshalJSON(v interface{}) (string, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return "", err + } + return strings.TrimRight(buf.String(), "\n"), nil +} + +// marshalIndentJSON renders like JSON.stringify(v, null, 2). +func marshalIndentJSON(v interface{}) (string, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + return "", err + } + return strings.TrimRight(buf.String(), "\n"), nil +} + +func asMap(v interface{}) (map[string]interface{}, bool) { + m, ok := v.(map[string]interface{}) + return m, ok +} + +func asSlice(v interface{}) []interface{} { + if s, ok := v.([]interface{}); ok { + return s + } + return nil +} + +func asString(v interface{}) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +// jsTruthy mirrors JS truthiness for JSON-decoded values. +func jsTruthy(v interface{}) bool { + switch t := v.(type) { + case nil: + return false + case bool: + return t + case string: + return t != "" + case json.Number: + f, err := t.Float64() + return err == nil && f != 0 + case float64: + return t != 0 + default: + return true // objects and arrays are always truthy in JS + } +} + +// jsonNumberToInt converts a decoded JSON number to int (0 for anything else). +func jsonNumberToInt(v interface{}) int { + switch n := v.(type) { + case json.Number: + if i, err := n.Int64(); err == nil { + return int(i) + } + if f, err := n.Float64(); err == nil { + return int(f) + } + case float64: + return int(n) + } + return 0 +} + +const maxSafeInteger = 9007199254740991 // 2^53 - 1 + +var intStringRe = regexp.MustCompile(`^-?\d+$`) + +// jsNumberString evaluates JS Number(string). Returns (value, ok) where ok is +// false when JS would produce NaN. It supports the forms Number() supports: +// decimal/exponent, hex 0x, binary 0b, octal 0o, surrounding whitespace. +func jsNumberString(s string) (float64, bool) { + t := strings.TrimSpace(s) + if t == "" { + return 0, true // Number("") and Number(" ") are 0 in JS + } + if t == "Infinity" || t == "+Infinity" { + return math.Inf(1), true + } + if t == "-Infinity" { + return math.Inf(-1), true + } + // Hex/binary/octal literals (no sign allowed before the prefix in JS). + if len(t) > 2 && t[0] == '0' { + var base int + switch t[1] { + case 'x', 'X': + base = 16 + case 'b', 'B': + base = 2 + case 'o', 'O': + base = 8 + } + if base != 0 { + n, err := strconv.ParseUint(t[2:], base, 64) + if err != nil { + return 0, false + } + return float64(n), true + } + } + f, err := strconv.ParseFloat(t, 64) + if err != nil { + return 0, false + } + return f, true +} + +// jsStatValue validates one stats counter value with the exact semantics of +// parseUsageStats in agent.js. raw is the JSON literal of the `value` field +// (nil RawMessage when the field is absent). Returns (value, reason, valid); +// reason is the Chinese diagnostic fragment JS embeds in its error message. +func jsStatValue(raw json.RawMessage) (int64, string, bool) { + if len(raw) == 0 { + return 0, "", true // missing field: protobuf default zero, JS uses 0 + } + text := strings.TrimSpace(string(raw)) + if text == "null" { + return 0, "格式 object", false + } + if text == "true" { + return 1, "", true // Number(true) === 1 + } + if text == "false" { + return 0, "", true + } + if strings.HasPrefix(text, `"`) { + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return 0, "格式 string", false + } + if s == "" { + return 0, "格式 string", false // JS rejects value === '' explicitly + } + if intStringRe.MatchString(s) { + if strings.HasPrefix(s, "-") { + // "-0" is valid in JS (Number("-0") is -0, not < 0). + n, err := strconv.ParseInt(s, 10, 64) + if err == nil && n == 0 { + return 0, "", true + } + return 0, "负数", false + } + n, err := strconv.ParseUint(s, 10, 64) + if err != nil || n > maxSafeInteger { + return 0, "超出安全整数范围", false + } + return int64(n), "", true + } + f, ok := jsNumberString(s) + if !ok || math.IsNaN(f) || math.IsInf(f, 0) || f != math.Trunc(f) || f < 0 || f > maxSafeInteger { + return 0, "格式 string", false + } + return int64(f), "", true + } + if strings.HasPrefix(text, "{") || strings.HasPrefix(text, "[") { + return 0, "格式 object", false + } + // Bare JSON number. + f, err := strconv.ParseFloat(text, 64) + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) || f != math.Trunc(f) || f < 0 || f > maxSafeInteger { + return 0, "格式 number", false + } + return int64(f), "", true +} diff --git a/agent-go/keys.go b/agent-go/keys.go new file mode 100644 index 0000000..0873691 --- /dev/null +++ b/agent-go/keys.go @@ -0,0 +1,148 @@ +package main + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "regexp" +) + +// keys.json stores Reality keypairs by key id: +// { "": { "privateKey": "...", "publicKey": "...", "createdAt": "..." } } +// Entries are kept as raw JSON so keys written by the Node agent (or any +// future fields) survive a save by this agent untouched. + +type realityKeyPair struct { + PrivateKey string `json:"privateKey"` + PublicKey string `json:"publicKey"` + CreatedAt string `json:"createdAt"` +} + +func loadKeys() (map[string]json.RawMessage, error) { + data, err := os.ReadFile(cfg.KeyFile) + if err != nil { + if os.IsNotExist(err) { + return map[string]json.RawMessage{}, nil + } + return nil, err + } + keys := map[string]json.RawMessage{} + if err := json.Unmarshal(data, &keys); err != nil { + return nil, err + } + if keys == nil { + keys = map[string]json.RawMessage{} + } + return keys, nil +} + +func saveKeys(keys map[string]json.RawMessage) error { + body, err := marshalIndentJSON(keys) + if err != nil { + return err + } + tmp := cfg.KeyFile + ".tmp" + if err := os.WriteFile(tmp, []byte(body+"\n"), 0o600); err != nil { + return err + } + if err := os.Rename(tmp, cfg.KeyFile); err != nil { + return err + } + return os.Chmod(cfg.KeyFile, 0o600) +} + +// realityKey returns the stored keypair for keyId or generates and persists a +// new X25519 pair. Encoding is base64url WITHOUT padding (base64.RawURLEncoding +// of the raw 32-byte keys) — byte-identical to the JWK `d`/`x` values Node's +// crypto.generateKeyPairSync('x25519') produces, and to the output of +// `sing-box generate reality-keypair`. Converting to padded standard base64 +// here was a production bug; do not reintroduce it. +func realityKey(keyID string) (realityKeyPair, error) { + keys, err := loadKeys() + if err != nil { + return realityKeyPair{}, err + } + if raw, ok := keys[keyID]; ok { + var pair realityKeyPair + if err := json.Unmarshal(raw, &pair); err == nil && pair.PrivateKey != "" { + return pair, nil + } + } + priv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return realityKeyPair{}, fmt.Errorf("X25519 密钥生成失败: %w", err) + } + pair := realityKeyPair{ + PrivateKey: base64.RawURLEncoding.EncodeToString(priv.Bytes()), + PublicKey: base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()), + CreatedAt: isoNow(), + } + encoded, err := json.Marshal(pair) + if err != nil { + return realityKeyPair{}, err + } + keys[keyID] = encoded + if err := saveKeys(keys); err != nil { + return realityKeyPair{}, err + } + return pair, nil +} + +type artifacts struct { + RealityPublicKey *string `json:"realityPublicKey,omitempty"` +} + +func (a *artifacts) toMap() map[string]interface{} { + out := map[string]interface{}{} + if a.RealityPublicKey != nil { + out["realityPublicKey"] = *a.RealityPublicKey + } + return out +} + +var realityPlaceholderRe = regexp.MustCompile(`^\$\{REALITY_PRIVATE:(.+)}$`) + +// materialize deep-walks a job resource replacing whole-string +// "${REALITY_PRIVATE:}" placeholders with the stored private key, +// recording the matching public key in artifacts — agent.js materialize(). +func materialize(value interface{}, art *artifacts) (interface{}, error) { + switch v := value.(type) { + case []interface{}: + out := make([]interface{}, len(v)) + for i, item := range v { + replaced, err := materialize(item, art) + if err != nil { + return nil, err + } + out[i] = replaced + } + return out, nil + case map[string]interface{}: + out := make(map[string]interface{}, len(v)) + for key, item := range v { + replaced, err := materialize(item, art) + if err != nil { + return nil, err + } + out[key] = replaced + } + return out, nil + case string: + match := realityPlaceholderRe.FindStringSubmatch(v) + if match == nil { + return v, nil + } + pair, err := realityKey(match[1]) + if err != nil { + return nil, err + } + pub := pair.PublicKey + art.RealityPublicKey = &pub + return pair.PrivateKey, nil + default: + return value, nil + } +} diff --git a/agent-go/main.go b/agent-go/main.go new file mode 100644 index 0000000..3d0bb67 --- /dev/null +++ b/agent-go/main.go @@ -0,0 +1,281 @@ +// NexusGate node agent — Go rewrite of agent/agent.js (Node.js) with 1:1 +// protocol parity against the panel's /api/agent/* endpoints. +// +// Configuration comes from the process environment, exactly like the Node +// version: systemd EnvironmentFile or agent/run.sh sources +// /etc/nexusgate/agent.env before exec. This binary never reads the env +// file itself, same as agent.js. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// version is reported to the panel in every heartbeat. It MUST stay a plain +// semver: lib/orchestrator.js parses it with split('.').map(Number) and +// requires >= 0.6.1 before allowing AnyTLS deploys, so a suffix such as +// "0.6.8-go" would parse to NaN and break that gate. +const version = "0.6.8" + +type config struct { + Controller string + AgentKey string + SingBoxBin string + SingBoxStatsBin string + Root string // NG_CONFIG_DIR + ResourceDir string // Root + "/resources" + SingBoxConfig string + SingBoxLog string + SingBoxCursor string + KeyFile string + TLSDir string + APIPort string // NG_SINGBOX_API_PORT (string, as in JS) + PollMS int64 +} + +func envOr(name, def string) string { + if v, ok := os.LookupEnv(name); ok { + return v + } + return def +} + +func loadConfig() config { + root := envOr("NG_CONFIG_DIR", "/etc/nexusgate/agent") + // POLL_MS = Math.max(3, Number(NG_POLL_SECONDS || 8)) * 1000. + // JS yields NaN for a non-numeric value; we clamp to the 3s floor instead + // (documented divergence, only affects a misconfigured env). + pollSec := 8.0 + if raw, ok := os.LookupEnv("NG_POLL_SECONDS"); ok && raw != "" { + if n, err := strconv.ParseFloat(raw, 64); err == nil { + pollSec = n + } else { + pollSec = 0 + } + } + if pollSec < 3 { + pollSec = 3 + } + return config{ + Controller: strings.TrimRight(os.Getenv("NG_CONTROLLER"), "/"), + AgentKey: os.Getenv("NG_AGENT_KEY"), + SingBoxBin: envOr("NG_SINGBOX_BIN", "/usr/local/bin/nexusgate-sing-box"), + SingBoxStatsBin: envOr("NG_SINGBOX_STATS_BIN", "/usr/local/bin/nexusgate-sing-box-stats"), + Root: root, + ResourceDir: root + "/resources", + SingBoxConfig: envOr("NG_SINGBOX_CONFIG", "/etc/nexusgate/sing-box/config.json"), + SingBoxLog: envOr("NG_SINGBOX_LOG", "/var/log/nexusgate/sing-box-access.log"), + SingBoxCursor: envOr("NG_SINGBOX_CURSOR", "/etc/nexusgate/sing-box-cursor.json"), + KeyFile: envOr("NG_KEY_FILE", "/etc/nexusgate/keys.json"), + TLSDir: envOr("NG_TLS_DIR", "/etc/nexusgate/tls"), + APIPort: envOr("NG_SINGBOX_API_PORT", "10086"), + PollMS: int64(pollSec * 1000), + } +} + +var cfg = loadConfig() + +// stateMu serializes access to on-disk agent state (resource files, sing-box +// config, keys, cursor). agent.js gets this for free from Node's event loop +// plus synchronous fs calls; the Go goroutines need it explicitly. +var stateMu sync.Mutex + +// lastEngineError holds the most recent engine failure text; serviceHealth +// surfaces it as the detail string while sing-box is down (agent.js keeps a +// module-level `lastEngineError` variable for the same purpose). +var lastEngineError struct { + mu sync.Mutex + s string +} + +func setLastEngineError(v string) { + lastEngineError.mu.Lock() + lastEngineError.s = v + lastEngineError.mu.Unlock() +} + +func getLastEngineError() string { + lastEngineError.mu.Lock() + defer lastEngineError.mu.Unlock() + return lastEngineError.s +} + +var httpClient = &http.Client{Timeout: 20 * time.Second} + +// isoNow mirrors new Date().toISOString(): UTC, millisecond precision, Z suffix. +func isoNow() string { + return time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") +} + +func logf(format string, args ...interface{}) { + fmt.Printf("%s %s\n", isoNow(), fmt.Sprintf(format, args...)) +} + +var ( + rePrivateKey = regexp.MustCompile(`(?i)\bPrivate\s*Key\s*:\s*[^\s,;]+`) + reBearer = regexp.MustCompile(`(?i)\bBearer\s+[^\s,;]+`) +) + +// safeError mirrors agent.js safeError(): redacts private keys and bearer +// tokens from text that may be logged or reported to the panel. +func safeError(value string) string { + value = rePrivateKey.ReplaceAllString(value, "PrivateKey: [REDACTED]") + value = reBearer.ReplaceAllString(value, "Bearer [REDACTED]") + return value +} + +// truncateRunes ~ JS String.slice(0, n) (JS counts UTF-16 units; close enough +// for diagnostics, and safer than cutting a UTF-8 sequence mid-rune). +func truncateRunes(s string, n int) string { + r := []rune(s) + if len(r) > n { + return string(r[:n]) + } + return s +} + +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +// request mirrors agent.js request(): POST with bearer auth, 20s timeout, +// JSON body best-effort parsed ({} on failure), error message taken from +// body.message or synthesized from the HTTP status. +func request(endpoint string, body []byte) (map[string]interface{}, error) { + req, err := http.NewRequest(http.MethodPost, cfg.Controller+endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+cfg.AgentKey) + req.Header.Set("Content-Type", "application/json") + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + parsed := map[string]interface{}{} + if len(raw) > 0 { + _ = json.Unmarshal(raw, &parsed) // .catch(() => ({})) in JS + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + if msg, ok := parsed["message"].(string); ok && msg != "" { + return nil, fmt.Errorf("%s", msg) + } + return nil, fmt.Errorf("Controller returned %d", resp.StatusCode) + } + return parsed, nil +} + +func requestJSON(endpoint string, payload interface{}) (map[string]interface{}, error) { + body, err := marshalJSON(payload) + if err != nil { + return nil, err + } + return request(endpoint, []byte(body)) +} + +func ensureDirectories() error { + if err := os.MkdirAll(cfg.ResourceDir, 0o700); err != nil { + return err + } + return os.MkdirAll("/var/log/nexusgate", 0o750) +} + +func sleepMS(ms int64) { + time.Sleep(time.Duration(ms) * time.Millisecond) +} + +func pollLoop() { + for { + result, err := request("/api/agent/poll", []byte("{}")) + if err != nil { + logf("Poll failed %s", err.Error()) + ms := cfg.PollMS + if ms < 10000 { + ms = 10000 + } + sleepMS(ms) + continue + } + jobRaw, ok := result["job"] + if !ok || jobRaw == nil { + sleepMS(cfg.PollMS) + continue + } + encoded, _ := json.Marshal(jobRaw) + var job agentJob + if err := json.Unmarshal(encoded, &job); err != nil { + logf("Poll failed %s", err.Error()) + sleepMS(cfg.PollMS) + continue + } + execute(&job) + } +} + +func heartbeatLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + if err := heartbeat(); err != nil { + logf("Heartbeat failed %s", err.Error()) + } + } +} + +func runAgent() error { + if cfg.Controller == "" || cfg.AgentKey == "" { + return fmt.Errorf("NG_CONTROLLER and NG_AGENT_KEY are required") + } + if err := ensureDirectories(); err != nil { + return err + } + logf("NexusGate Agent v%s starting", version) + // Keep the control channel alive even when a stale node configuration + // cannot start (same rationale as agent.js main()). + stateMu.Lock() + if err := activateSingBoxLocked(); err != nil { + setLastEngineError(safeError(err.Error())) + logf("Initial sing-box activation failed %s", getLastEngineError()) + } + stateMu.Unlock() + if err := heartbeat(); err != nil { + logf("Initial heartbeat failed %s", err.Error()) + } + go heartbeatLoop() + go usageLoop() + pollLoop() + return nil +} + +func main() { + // `nexusgate-agent flush-usage` mirrors `node agent.js flush-usage`, + // invoked by agent-update.sh before the agent binary is replaced so the + // running sing-box counters are persisted first. + if len(os.Args) > 1 && os.Args[1] == "flush-usage" { + if err := flushAllUsageBeforeRestart(); err != nil { + fmt.Fprintln(os.Stderr, safeError(err.Error())) + os.Exit(1) + } + return + } + if err := runAgent(); err != nil { + fmt.Fprintln(os.Stderr, safeError(err.Error())) + os.Exit(1) + } +} diff --git a/agent-go/main_test.go b/agent-go/main_test.go new file mode 100644 index 0000000..1bea33d --- /dev/null +++ b/agent-go/main_test.go @@ -0,0 +1,369 @@ +package main + +import ( + "crypto/ecdh" + "encoding/base64" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// ---- Reality keys ----------------------------------------------------------- + +func TestX25519RFC7748Vector(t *testing.T) { + // RFC 7748 §5.2 Alice keypair: proves Go's ecdh byte conventions + // (raw little-endian scalar / u-coordinate) match the JWK d/x values + // Node produces, which is what sing-box expects. + privBytes, _ := hex.DecodeString("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a") + wantPub, _ := hex.DecodeString("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a") + priv, err := ecdh.X25519().NewPrivateKey(privBytes) + if err != nil { + t.Fatal(err) + } + got := priv.PublicKey().Bytes() + if len(got) != 32 { + t.Fatalf("public key length %d, want 32", len(got)) + } + for i := range got { + if got[i] != wantPub[i] { + t.Fatalf("public key mismatch at %d: got %x want %x", i, got, wantPub) + } + } + // JWK d/x are base64url of exactly these byte strings: the encoding + // must round-trip to the RFC bytes with the raw (unpadded) alphabet. + enc := base64.RawURLEncoding.EncodeToString(privBytes) + back, err := base64.RawURLEncoding.DecodeString(enc) + if err != nil || len(back) != 32 { + t.Fatalf("base64url round-trip failed: %q %v", enc, err) + } + for i := range back { + if back[i] != privBytes[i] { + t.Fatalf("base64url round-trip mismatch at %d", i) + } + } +} + +func TestRealityKeyFormatAndPersistence(t *testing.T) { + dir := t.TempDir() + old := cfg.KeyFile + cfg.KeyFile = filepath.Join(dir, "keys.json") + defer func() { cfg.KeyFile = old }() + + pair, err := realityKey("reality-node-test") + if err != nil { + t.Fatal(err) + } + for _, k := range []string{pair.PrivateKey, pair.PublicKey} { + if len(k) != 43 { + t.Fatalf("key %q length %d, want 43 (32 bytes, base64url no padding)", k, len(k)) + } + if strings.Contains(k, "=") || strings.Contains(k, "+") || strings.Contains(k, "/") { + t.Fatalf("key %q is not raw base64url", k) + } + raw, err := base64.RawURLEncoding.DecodeString(k) + if err != nil || len(raw) != 32 { + t.Fatalf("key %q does not decode to 32 bytes: %v", k, err) + } + } + // Public key must derive from the private key. + priv, err := ecdh.X25519().NewPrivateKey(mustDecode(t, pair.PrivateKey)) + if err != nil { + t.Fatal(err) + } + if base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()) != pair.PublicKey { + t.Fatal("stored public key does not match private key") + } + // Second call returns the persisted pair; file is 0600 with the JS shape. + again, err := realityKey("reality-node-test") + if err != nil { + t.Fatal(err) + } + if again != pair { + t.Fatalf("key not persisted: %+v vs %+v", again, pair) + } + info, err := os.Stat(cfg.KeyFile) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("keys.json mode %o, want 600", info.Mode().Perm()) + } + data, _ := os.ReadFile(cfg.KeyFile) + for _, field := range []string{`"privateKey"`, `"publicKey"`, `"createdAt"`, `"reality-node-test"`} { + if !strings.Contains(string(data), field) { + t.Fatalf("keys.json missing %s: %s", field, data) + } + } +} + +func mustDecode(t *testing.T, s string) []byte { + t.Helper() + b, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + t.Fatal(err) + } + return b +} + +// ---- Combined config ---------------------------------------------------------- + +const testSSKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" // 32 bytes 0x00..0x1f + +func sampleResources(t *testing.T) []map[string]interface{} { + t.Helper() + old := cfg.KeyFile + cfg.KeyFile = filepath.Join(t.TempDir(), "keys.json") + defer func() { cfg.KeyFile = old }() + art := &artifacts{} + v1, err := decodeJSON([]byte(`{ + "id": "res-node-0001", "engine": "sing-box", + "meta": {"kind": "node", "nodeId": "node-abc", "metricsTag": "ng-node-abc-in", "protocol": "vless-reality"}, + "inbounds": [{ + "type": "vless", "tag": "ng-node-abc-in", "listen": "203.0.113.9", "listen_port": 22038, + "users": [{"name": "ng:cust-1", "uuid": "11111111-1111-1111-1111-111111111111", "flow": "xtls-rprx-vision"}], + "tls": {"enabled": true, "server_name": "www.microsoft.com", + "reality": {"enabled": true, "handshake": {"server": "www.microsoft.com", "server_port": 443}, + "private_key": "${REALITY_PRIVATE:reality-node-abc}", "short_id": ["abcd1234"]}} + }], + "outbounds": [{"type": "direct", "tag": "ng-node-abc-direct"}], + "routingRules": [{"inbound": ["ng-node-abc-in"], "action": "route", "outbound": "ng-node-abc-direct"}] + }`)) + if err != nil { + t.Fatal(err) + } + m1, err := materialize(v1, art) + if err != nil { + t.Fatal(err) + } + if art.RealityPublicKey == nil || *art.RealityPublicKey == "" { + t.Fatal("materialize did not record the reality public key artifact") + } + v2, err := decodeJSON([]byte(`{ + "id": "res-node-0002", "engine": "sing-box", + "meta": {"kind": "node", "nodeId": "node-def", "metricsTag": "ng-node-def-in", "protocol": "ss2022"}, + "inbounds": [ + {"type": "shadowsocks", "tag": "ng-node-def-in", "listen": "0.0.0.0", "listen_port": 8388, + "method": "2022-blake3-aes-256-gcm", "password": "` + testSSKey + `", + "users": [{"name": "ng:cust-2", "password": "` + testSSKey + `"}]}, + {"type": "socks", "tag": "ng-node-def-socks", "listen": "127.0.0.1", "listen_port": 1080, + "users": [{"username": "ng:cust-3", "password": "pw"}]} + ], + "outbounds": [{"type": "direct", "tag": "ng-node-def-direct"}], + "routingRules": [] + }`)) + if err != nil { + t.Fatal(err) + } + r1, _ := asMap(m1) + r2, _ := asMap(v2) + return []map[string]interface{}{r1, r2} +} + +func TestCombinedSingBoxConfig(t *testing.T) { + resources := sampleResources(t) + combined := combinedSingBoxConfig(resources) + + if len(combined.Inbounds) != 3 { + t.Fatalf("inbounds %d, want 3", len(combined.Inbounds)) + } + first, _ := asMap(combined.Inbounds[0]) + if first["listen"] != "0.0.0.0" { + t.Fatalf("non-local listen 203.0.113.9 should fall back to 0.0.0.0, got %v", first["listen"]) + } + third, _ := asMap(combined.Inbounds[2]) + if third["listen"] != "127.0.0.1" { + t.Fatalf("local listen 127.0.0.1 must be kept, got %v", third["listen"]) + } + if len(combined.Outbounds) != 3 { + t.Fatalf("outbounds %d, want 3 (fallback + 2)", len(combined.Outbounds)) + } + ob0, _ := asMap(combined.Outbounds[0]) + if ob0["tag"] != "ng-fallback-direct" { + t.Fatalf("first outbound = %v, want ng-fallback-direct", ob0["tag"]) + } + if combined.Route.Final != "ng-fallback-direct" || len(combined.Route.Rules) != 1 { + t.Fatalf("route = %+v", combined.Route) + } + stats := combined.Experimental.V2RayAPI.Stats + wantTags := []string{"ng-node-abc-in", "ng-node-def-in", "ng-node-def-socks"} + if len(stats.Inbounds) != len(wantTags) { + t.Fatalf("stats inbounds %v", stats.Inbounds) + } + for i, tag := range wantTags { + if stats.Inbounds[i] != tag { + t.Fatalf("stats inbounds[%d] = %v, want %s", i, stats.Inbounds[i], tag) + } + } + wantUsers := []string{"ng:cust-1", "ng:cust-2", "ng:cust-3"} + if len(stats.Users) != len(wantUsers) { + t.Fatalf("stats users %v", stats.Users) + } + for i, u := range wantUsers { + if stats.Users[i] != u { + t.Fatalf("stats users[%d] = %v, want %s", i, stats.Users[i], u) + } + } + // Deterministic output (the activate path byte-compares against the + // existing config to decide whether a restart is needed). + a, _ := marshalIndentJSON(combined) + b, _ := marshalIndentJSON(combinedSingBoxConfig(resources)) + if a != b { + t.Fatal("combined config marshaling is not deterministic") + } + // The materialized reality private key must be a raw-base64url 32-byte key. + in0, _ := asMap(combined.Inbounds[0]) + tls, _ := asMap(in0["tls"]) + reality, _ := asMap(tls["reality"]) + pk := asString(reality["private_key"]) + if len(pk) != 43 { + t.Fatalf("materialized private_key %q is not a 43-char raw base64url key", pk) + } +} + +// TestCombinedConfigAcceptedBySingBox runs the real sing-box binary's +// `check` over a generated config when the release binary is present. +func TestCombinedConfigAcceptedBySingBox(t *testing.T) { + bin := "/home/hatch/workspace/nexusgate-release/bin/nexusgate-sing-box-linux-amd64-v1.13.21" + if _, err := os.Stat(bin); err != nil { + t.Skip("sing-box release binary not present") + } + oldLog := cfg.SingBoxLog + cfg.SingBoxLog = filepath.Join(t.TempDir(), "access.log") + defer func() { cfg.SingBoxLog = oldLog }() + resources := sampleResources(t) + body, err := marshalIndentJSON(combinedSingBoxConfig(resources)) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(body+"\n"), 0o600); err != nil { + t.Fatal(err) + } + out, err := exec.Command(bin, "check", "-c", path).CombinedOutput() + if err != nil { + t.Fatalf("sing-box check rejected generated config: %v\n%s", err, out) + } +} + +// ---- parseUsageStats ----------------------------------------------------------- + +func TestParseUsageStats(t *testing.T) { + parsed, err := parseUsageStats(`{"stat":null}`) + if err != nil { + t.Fatal(err) + } + if len(parsed.inbounds.keys) != 0 || len(parsed.users.keys) != 0 { + t.Fatal("null stat should yield no counters") + } + + parsed, err = parseUsageStats(`{"stat":[ + {"name":"inbound>>>ng-a-in>>>traffic>>>uplink","value":"100"}, + {"name":"inbound>>>ng-a-in>>>traffic>>>downlink","value":"250"}, + {"name":"inbound>>>ng-a-in>>>traffic>>>uplink","value":"130"}, + {"name":"user>>>ng:cust-1>>>traffic>>>uplink","value":"40"}, + {"name":"user>>>ng:cust-1>>>traffic>>>downlink"}, + {"name":"outbound>>>direct>>>traffic>>>uplink","value":"999"}, + {"name":"user>>>other>>>traffic>>>uplink","value":42} + ]}`) + if err != nil { + t.Fatal(err) + } + in := parsed.inbounds.m["ng-a-in"] + if in == nil || in.Uplink != 130 || in.Downlink != 250 { + t.Fatalf("inbound counters = %+v (later value must overwrite)", in) + } + u := parsed.users.m["ng:cust-1"] + if u == nil || u.Uplink != 40 || u.Downlink != 0 { + t.Fatalf("user counters = %+v (missing value must default to 0)", u) + } + if parsed.users.m["other"].Uplink != 42 { + t.Fatal("bare JSON number value 42 not accepted") + } + + cases := []struct { + name string + input string + wantErr string + }{ + {"negative", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":"-5"}]}`, "负数"}, + {"overflow", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":"99999999999999999999999"}]}`, "超出安全整数范围"}, + {"alpha", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":"abc"}]}`, "格式 string"}, + {"empty-string", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":""}]}`, "格式 string"}, + {"null-value", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":null}]}`, "格式 object"}, + {"top-array", `[]`, "统计接口返回的 JSON stat 格式无效"}, + {"stat-object", `{"stat":{}}`, "统计接口返回的 JSON stat 格式无效"}, + {"garbage", `not json`, "统计接口返回的 JSON stat 格式无效"}, + } + for _, tc := range cases { + _, err := parseUsageStats(tc.input) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("%s: err = %v, want substring %q", tc.name, err, tc.wantErr) + } + } +} + +// ---- observations ------------------------------------------------------------ + +func TestReadSingBoxObservations(t *testing.T) { + dir := t.TempDir() + oldRes, oldLog, oldCursor := cfg.ResourceDir, cfg.SingBoxLog, cfg.SingBoxCursor + cfg.ResourceDir = filepath.Join(dir, "resources") + cfg.SingBoxLog = filepath.Join(dir, "access.log") + cfg.SingBoxCursor = filepath.Join(dir, "cursor.json") + defer func() { cfg.ResourceDir, cfg.SingBoxLog, cfg.SingBoxCursor = oldRes, oldLog, oldCursor }() + + if err := os.MkdirAll(cfg.ResourceDir, 0o700); err != nil { + t.Fatal(err) + } + res := `{"id":"res-node-0001","engine":"sing-box","meta":{"kind":"node","nodeId":"node-abc","metricsTag":"ng-node-abc-in"},"inbounds":[],"outbounds":[],"routingRules":[]}` + if err := os.WriteFile(filepath.Join(cfg.ResourceDir, "res-node-0001.json"), []byte(res), 0o600); err != nil { + t.Fatal(err) + } + logBody := strings.Join([]string{ + `2026-10-20T05:00:01+08:00 INFO [111 5ms] inbound/anytls[ng-node-abc-in]: inbound connection from 203.0.113.5:40001`, + `2026-10-20T05:00:01+08:00 INFO [111 9ms] inbound/anytls[ng-node-abc-in]: [ng:cust-9] inbound connection to example.com:443`, + `2026-10-20T05:00:02+08:00 INFO [222 3ms] inbound/anytls[ng-node-abc-in]: inbound connection from 203.0.113.6:40002`, + `2026-10-20T05:00:03+08:00 INFO [333 3ms] inbound/anytls[other-tag]: inbound connection from 203.0.113.7:40003`, + `2026-10-20T05:00:03+08:00 INFO [333 4ms] inbound/anytls[other-tag]: [ng:cust-9] inbound connection to example.org:443`, + "", + }, "\n") + if err := os.WriteFile(cfg.SingBoxLog, []byte(logBody), 0o600); err != nil { + t.Fatal(err) + } + result := readSingBoxObservationsLocked() + if len(result.observations) != 1 { + t.Fatalf("observations = %+v, want exactly the one authenticated session", result.observations) + } + obs := result.observations[0] + if obs.CustomerID != "cust-9" || obs.IP != "203.0.113.5" { + t.Fatalf("observation = %+v", obs) + } + if result.offset == nil || *result.offset != int64(len(logBody)) { + t.Fatalf("offset = %v, want %d (end of file)", result.offset, len(logBody)) + } + // With the cursor persisted at EOF, a second read finds nothing new. + body, _ := marshalJSON(map[string]interface{}{"offset": *result.offset}) + if err := os.WriteFile(cfg.SingBoxCursor, []byte(body+"\n"), 0o600); err != nil { + t.Fatal(err) + } + second := readSingBoxObservationsLocked() + if len(second.observations) != 0 || second.offset != nil { + t.Fatalf("second read = %+v, want empty with nil offset", second) + } +} + +// ---- misc ----------------------------------------------------------------------- + +func TestSafeError(t *testing.T) { + in := "dial failed Private Key: abcDEF123, more; Authorization: Bearer tok123 end" + out := safeError(in) + if strings.Contains(out, "abcDEF123") || strings.Contains(out, "tok123") { + t.Fatalf("safeError did not redact: %s", out) + } + if !strings.Contains(out, "PrivateKey: [REDACTED]") || !strings.Contains(out, "Bearer [REDACTED]") { + t.Fatalf("safeError replacements missing: %s", out) + } +} diff --git a/agent-go/resources.go b/agent-go/resources.go new file mode 100644 index 0000000..f8f836a --- /dev/null +++ b/agent-go/resources.go @@ -0,0 +1,209 @@ +package main + +import ( + "fmt" + "net" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// readResources loads every *.json in the resource dir, filename-sorted, +// exactly like agent.js readResources(). Files hold the *materialized* +// resource objects written by applyResource. +func readResources() ([]map[string]interface{}, error) { + entries, err := os.ReadDir(cfg.ResourceDir) + if err != nil { + return nil, err + } + names := []string{} + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".json") { + names = append(names, entry.Name()) + } + } + sort.Strings(names) + resources := []map[string]interface{}{} + for _, name := range names { + data, err := os.ReadFile(filepath.Join(cfg.ResourceDir, name)) + if err != nil { + return nil, err + } + decoded, err := decodeJSON(data) + if err != nil { + return nil, err + } + if m, ok := asMap(decoded); ok { + resources = append(resources, m) + continue + } + if decoded == nil { + // JS would throw a TypeError on item.inbounds for a null resource. + return nil, fmt.Errorf("resource %s is null", name) + } + // Non-object JSON: JS property access yields undefined everywhere, + // which behaves like an empty resource. Mirror with an empty map. + resources = append(resources, map[string]interface{}{}) + } + return resources, nil +} + +var resourceIDRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{8,100}$`) + +func safeResourcePath(resourceID string) (string, error) { + if !resourceIDRe.MatchString(resourceID) { + return "", fmt.Errorf("Invalid resource id") + } + return filepath.Join(cfg.ResourceDir, resourceID+".json"), nil +} + +func resMeta(res map[string]interface{}) map[string]interface{} { + meta, _ := asMap(res["meta"]) + return meta +} + +func isLocalAddress(text string) bool { + target := net.ParseIP(text) + if target == nil { + return false + } + ifaces, err := net.Interfaces() + if err != nil { + return false + } + for _, iface := range ifaces { + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + var ip net.IP + switch a := addr.(type) { + case *net.IPNet: + ip = a.IP + case *net.IPAddr: + ip = a.IP + } + if ip != nil && ip.Equal(target) { + return true + } + } + } + return false +} + +// normalizeInboundListen mirrors agent.js: a public IP that is not assigned +// to a local interface (typical NAT cloud VM) cannot be bound, so fall back +// to the wildcard address of the same family. +func normalizeInboundListen(inbound interface{}) interface{} { + m, ok := asMap(inbound) + if !ok { + return inbound + } + raw, isStr := m["listen"].(string) + if !isStr { + return inbound + } + text := strings.TrimSpace(raw) + if text == "" || net.ParseIP(text) == nil || text == "0.0.0.0" || text == "::" { + return inbound + } + if isLocalAddress(text) { + return inbound + } + fallback := "0.0.0.0" + if strings.Contains(text, ":") { + fallback = "::" + } + logf("Listen %s 不是本机地址,回退为 %s", text, fallback) + out := make(map[string]interface{}, len(m)) + for k, v := range m { + out[k] = v + } + out["listen"] = fallback + return out +} + +// Ordered structs keep the generated sing-box config's top-level field order +// identical to the JS template literal in combinedSingBoxConfig (nested +// resource objects keep their own keys, reserialized alphabetically by Go — +// semantically irrelevant to sing-box). +type combinedLog struct { + Level string `json:"level"` + Output string `json:"output"` + Timestamp bool `json:"timestamp"` +} + +type combinedRoute struct { + Rules []interface{} `json:"rules"` + Final string `json:"final"` +} + +type v2rayAPIStats struct { + Enabled bool `json:"enabled"` + Inbounds []interface{} `json:"inbounds"` + Users []interface{} `json:"users"` +} + +type v2rayAPI struct { + Listen string `json:"listen"` + Stats v2rayAPIStats `json:"stats"` +} + +type combinedExperimental struct { + V2RayAPI v2rayAPI `json:"v2ray_api"` +} + +type combinedConfig struct { + Log combinedLog `json:"log"` + Inbounds []interface{} `json:"inbounds"` + Outbounds []interface{} `json:"outbounds"` + Route combinedRoute `json:"route"` + Experimental combinedExperimental `json:"experimental"` +} + +// combinedSingBoxConfig merges all resources into the single sing-box config +// — agent.js combinedSingBoxConfig(). +func combinedSingBoxConfig(resources []map[string]interface{}) combinedConfig { + inbounds := []interface{}{} + statInbounds := []interface{}{} + statUsers := []interface{}{} + outbounds := []interface{}{map[string]interface{}{"type": "direct", "tag": "ng-fallback-direct"}} + rules := []interface{}{} + for _, res := range resources { + resInbounds := asSlice(res["inbounds"]) + for _, inbound := range resInbounds { + inbounds = append(inbounds, normalizeInboundListen(inbound)) + if im, ok := asMap(inbound); ok { + statInbounds = append(statInbounds, im["tag"]) + for _, user := range asSlice(im["users"]) { + um, ok := asMap(user) + if !ok { + continue + } + name := um["name"] + if !jsTruthy(name) { + name = um["username"] // AnyTLS/SS use name, SOCKS5 username + } + if jsTruthy(name) { + statUsers = append(statUsers, name) + } + } + } + } + outbounds = append(outbounds, asSlice(res["outbounds"])...) + rules = append(rules, asSlice(res["routingRules"])...) + } + return combinedConfig{ + Log: combinedLog{Level: "info", Output: cfg.SingBoxLog, Timestamp: true}, + Inbounds: inbounds, + Outbounds: outbounds, + Route: combinedRoute{Rules: rules, Final: "ng-fallback-direct"}, + Experimental: combinedExperimental{V2RayAPI: v2rayAPI{ + Listen: "127.0.0.1:" + cfg.APIPort, + Stats: v2rayAPIStats{Enabled: true, Inbounds: statInbounds, Users: statUsers}, + }}, + } +} diff --git a/agent-go/singbox.go b/agent-go/singbox.go new file mode 100644 index 0000000..90950ee --- /dev/null +++ b/agent-go/singbox.go @@ -0,0 +1,281 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// runCapture executes a command with a timeout and reports its exit code +// (-1 when the process could not be started or was killed by timeout, +// mirroring spawnSync's null status in agent.js). +func runCapture(timeout time.Duration, name string, args ...string) (stdout, stderr string, exitCode int) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + err := cmd.Run() + stdout, stderr = outBuf.String(), errBuf.String() + if err == nil { + return stdout, stderr, 0 + } + if exitErr, ok := err.(*exec.ExitError); ok { + return stdout, stderr, exitErr.ExitCode() + } + return stdout, stderr, -1 +} + +// runThrow mirrors agent.js run(): returns stdout, or throws +// " failed: ". +func runThrow(timeout time.Duration, name string, args ...string) (string, error) { + stdout, stderr, code := runCapture(timeout, name, args...) + if code != 0 { + detail := stderr + if detail == "" { + detail = stdout + } + return "", fmt.Errorf("%s %s failed: %s", name, strings.Join(args, " "), + truncateRunes(safeError(detail), 1200)) + } + return stdout, nil +} + +func systemdPresent() bool { + return fileExists("/run/systemd/system") +} + +// serviceCommand mirrors agent.js: systemctl under systemd, rc-service under +// OpenRC, selected by the presence of /run/systemd/system. +func serviceCommand(action, service string) error { + if systemdPresent() { + _, err := runThrow(30*time.Second, "systemctl", action, service+".service") + return err + } + _, err := runThrow(30*time.Second, "rc-service", service, action) + return err +} + +type serviceStatus struct { + Status string + Detail string +} + +func serviceHealth(service string) serviceStatus { + var stdout, stderr string + var code int + if systemdPresent() { + stdout, stderr, code = runCapture(3*time.Second, "systemctl", "is-active", service+".service") + } else { + stdout, stderr, code = runCapture(3*time.Second, "rc-service", service, "status") + } + status := "error" + detail := stdout + if code == 0 { + status = "ready" + } else { + detail = getLastEngineError() + if detail == "" { + detail = stderr + } + if detail == "" { + detail = stdout + } + } + return serviceStatus{Status: status, Detail: truncateRunes(strings.TrimSpace(detail), 400)} +} + +func copyFilePreserve(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + // JS copyFileSync would create dst at 0666&^umask; the config contains + // Reality private keys, so the Go agent deliberately keeps 0600 + // (functional parity, stricter permissions — see PARITY.md). + return os.WriteFile(dst, data, 0o600) +} + +// activateSingBoxLocked regenerates the combined sing-box config from all +// resources, validates it with `sing-box check`, swaps it in and restarts +// the service, rolling back on failure — agent.js activateSingBox(). +// Callers must hold stateMu. +func activateSingBoxLocked() error { + resources, err := readResources() + if err != nil { + return err + } + if len(resources) == 0 { + // No resources: the engine must be stopped (and any stale config + // removed), otherwise a fresh install loops restarts. + _ = serviceCommand("stop", "nexusgate-sing-box") + if fileExists(cfg.SingBoxConfig) { + _ = os.Remove(cfg.SingBoxConfig) + } + return nil + } + if !fileExists(cfg.SingBoxBin) || !fileExists(cfg.SingBoxStatsBin) { + return fmt.Errorf("sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install") + } + candidate := cfg.SingBoxConfig + ".candidate.json" + previous := cfg.SingBoxConfig + ".previous" + next, err := marshalIndentJSON(combinedSingBoxConfig(resources)) + if err != nil { + return err + } + next += "\n" + if current, err := os.ReadFile(cfg.SingBoxConfig); err == nil && string(current) == next && + serviceHealth("nexusgate-sing-box").Status == "ready" { + return nil // config unchanged and engine healthy: no restart + } + if err := os.MkdirAll(filepath.Dir(cfg.SingBoxConfig), 0o700); err != nil { + return err + } + if err := os.WriteFile(candidate, []byte(next), 0o600); err != nil { + return err + } + if _, err := runThrow(30*time.Second, cfg.SingBoxBin, "check", "-c", candidate); err != nil { + _ = os.Remove(candidate) + return err + } + if fileExists(cfg.SingBoxConfig) { + if err := copyFilePreserve(cfg.SingBoxConfig, previous); err != nil { + return err + } + } + if err := os.Rename(candidate, cfg.SingBoxConfig); err != nil { + return err + } + if err := serviceCommand("restart", "nexusgate-sing-box"); err != nil { + if fileExists(previous) { + _ = copyFilePreserve(previous, cfg.SingBoxConfig) + } else { + _ = os.Remove(cfg.SingBoxConfig) + } + if fileExists(cfg.SingBoxConfig) { + _ = serviceCommand("restart", "nexusgate-sing-box") // preserve original error + } + return err + } + return nil +} + +// serviceEpoch identifies one sing-box process instance as ":" +// (PID alone can be reused) — agent.js serviceEpoch(). "" when unknown. +func serviceEpoch() string { + const service = "nexusgate-sing-box" + pid := 0 + if systemdPresent() { + stdout, _, code := runCapture(3*time.Second, "systemctl", "show", "-p", "MainPID", "--value", service+".service") + if code == 0 { + pid, _ = strconv.Atoi(strings.TrimSpace(stdout)) + } + } else { + if data, err := os.ReadFile("/run/" + service + ".pid"); err == nil { + pid, _ = strconv.Atoi(strings.TrimSpace(string(data))) + } + } + if pid <= 0 { + return "" + } + stat, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return "" + } + text := string(stat) + idx := strings.LastIndex(text, ") ") + if idx < 0 { + return "" + } + fields := strings.Fields(strings.TrimSpace(text[idx+2:])) + if len(fields) <= 19 || fields[19] == "" { + return "" + } + return fmt.Sprintf("%d:%s", pid, fields[19]) +} + +// udpPortsForPid returns the set of UDP ports a process listens on, resolved +// through /proc//fd socket inodes and /proc/net/udp{,6} — agent.js +// udpPortsForPid(). +func udpPortsForPid(pid int) (map[int]bool, error) { + sockets := map[string]bool{} + fds, err := os.ReadDir(fmt.Sprintf("/proc/%d/fd", pid)) + if err != nil { + return nil, err + } + for _, fd := range fds { + target, err := os.Readlink(filepath.Join(fmt.Sprintf("/proc/%d/fd", pid), fd.Name())) + if err != nil { + continue // descriptor closed during inspection + } + if strings.HasPrefix(target, "socket:[") && strings.HasSuffix(target, "]") { + sockets[target[len("socket:["):len(target)-1]] = true + } + } + ports := map[int]bool{} + for _, table := range []string{"/proc/net/udp", "/proc/net/udp6"} { + data, err := os.ReadFile(table) + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + for _, row := range lines[1:] { + fields := strings.Fields(row) + if len(fields) > 9 && fields[3] == "07" && sockets[fields[9]] { + local := fields[1] + if idx := strings.LastIndex(local, ":"); idx >= 0 { + if port, err := strconv.ParseInt(local[idx+1:], 16, 32); err == nil { + ports[int(port)] = true + } + } + } + } + } + return ports, nil +} + +// missingHysteriaListeners lists hysteria2 listen ports the running sing-box +// has no UDP socket for — agent.js missingHysteriaListeners(). +func missingHysteriaListeners(resources []map[string]interface{}) ([]int, error) { + expected := []int{} + seen := map[int]bool{} + for _, res := range resources { + for _, inbound := range asSlice(res["inbounds"]) { + im, ok := asMap(inbound) + if !ok || asString(im["type"]) != "hysteria2" { + continue + } + port := jsonNumberToInt(im["listen_port"]) + if !seen[port] { + seen[port] = true + expected = append(expected, port) + } + } + } + if len(expected) == 0 { + return []int{}, nil + } + epoch := serviceEpoch() + if epoch == "" { + return nil, fmt.Errorf("无法确认 sing-box 进程,无法验证 Hysteria 2 UDP 监听") + } + pid, _ := strconv.Atoi(strings.SplitN(epoch, ":", 2)[0]) + actual, err := udpPortsForPid(pid) + if err != nil { + return nil, err + } + missing := []int{} + for _, port := range expected { + if !actual[port] { + missing = append(missing, port) + } + } + return missing, nil +} diff --git a/agent-go/usage.go b/agent-go/usage.go new file mode 100644 index 0000000..f70c9eb --- /dev/null +++ b/agent-go/usage.go @@ -0,0 +1,422 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "regexp" + "strings" + "time" +) + +// ---- parseUsageStats ------------------------------------------------------- + +type counterPair struct { + Uplink int64 + Downlink int64 +} + +// orderedCounters keeps first-insertion key order like a JS Map. +type orderedCounters struct { + keys []string + m map[string]*counterPair +} + +func newOrderedCounters() *orderedCounters { + return &orderedCounters{m: map[string]*counterPair{}} +} + +func (o *orderedCounters) set(key, direction string, value int64) { + entry, ok := o.m[key] + if !ok { + entry = &counterPair{} + o.m[key] = entry + o.keys = append(o.keys, key) + } + if direction == "uplink" { + entry.Uplink = value + } else { + entry.Downlink = value + } +} + +var ( + inboundStatRe = regexp.MustCompile(`^inbound>>>(.+)>>>traffic>>>(uplink|downlink)$`) + userStatRe = regexp.MustCompile(`^user>>>(.+)>>>traffic>>>(uplink|downlink)$`) +) + +type parsedStats struct { + inbounds *orderedCounters + users *orderedCounters +} + +// parseUsageStats parses the stats helper's JSON output — agent.js +// parseUsageStats(). Zero traffic makes the helper print {"stat":null}, +// which is "no data", not a format error. +func parseUsageStats(output string) (*parsedStats, error) { + invalid := fmt.Errorf("统计接口返回的 JSON stat 格式无效") + var top map[string]json.RawMessage + if err := json.Unmarshal([]byte(output), &top); err != nil || top == nil { + return nil, invalid + } + statRaw, present := top["stat"] + if !present || strings.TrimSpace(string(statRaw)) == "null" { + return &parsedStats{inbounds: newOrderedCounters(), users: newOrderedCounters()}, nil + } + var entries []struct { + Name string `json:"name"` + Value json.RawMessage `json:"value"` + } + if err := json.Unmarshal(statRaw, &entries); err != nil { + return nil, invalid + } + parsed := &parsedStats{inbounds: newOrderedCounters(), users: newOrderedCounters()} + for _, stat := range entries { + var bucket *orderedCounters + var key, direction string + if m := inboundStatRe.FindStringSubmatch(stat.Name); m != nil { + bucket, key, direction = parsed.inbounds, m[1], m[2] + } else if m := userStatRe.FindStringSubmatch(stat.Name); m != nil { + bucket, key, direction = parsed.users, m[1], m[2] + } else { + continue + } + value, reason, valid := jsStatValue(stat.Value) + if !valid { + return nil, fmt.Errorf("入口计数异常(%s):%s;请运行 ng-agent doctor 查看原始统计", reason, stat.Name) + } + bucket.set(key, direction, value) + } + return parsed, nil +} + +// ---- queryUsage ------------------------------------------------------------ + +type usageSample struct { + Kind string `json:"kind"` + ID string `json:"id"` + Uplink int64 `json:"uplink"` + Downlink int64 `json:"downlink"` + Epoch string `json:"epoch"` +} + +type usageReport struct { + Samples []usageSample `json:"samples"` + Error *string `json:"error"` + EngineErrors []string `json:"engineErrors"` +} + +func (r *usageReport) toMap() map[string]interface{} { + return map[string]interface{}{ + "samples": r.Samples, + "error": r.Error, + "engineErrors": r.EngineErrors, + } +} + +// queryUsage reads sing-box counters through the stats helper binary and +// shapes them into per-node / per-user samples — agent.js queryUsage(). +// Callers must hold stateMu. +func queryUsageLocked() (*usageReport, error) { + all, err := readResources() + if err != nil { + return nil, err + } + resources := []map[string]interface{}{} + for _, res := range all { + meta := resMeta(res) + if meta != nil && jsTruthy(meta["metricsTag"]) && asString(meta["kind"]) == "node" { + resources = append(resources, res) + } + } + samples := []usageSample{} + errorsList := []string{} + engineErrors := []string{} + if len(resources) == 0 { + return &usageReport{Samples: samples, Error: nil, EngineErrors: engineErrors}, nil + } + before := serviceEpoch() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + cmd := exec.CommandContext(ctx, cfg.SingBoxStatsBin, "--server=127.0.0.1:"+cfg.APIPort) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + runErr := cmd.Run() + cancel() + after := serviceEpoch() + failed := false + parsed := &parsedStats{inbounds: newOrderedCounters(), users: newOrderedCounters()} + if before != "" && after != "" && before != after { + errorsList = append(errorsList, "sing-box restarted during statistics query") + engineErrors = append(engineErrors, "sing-box") + failed = true + } else if runErr != nil { + detail := errBuf.String() + if detail == "" && runErr != nil { + detail = runErr.Error() + } + if detail == "" { + detail = outBuf.String() + } + errorsList = append(errorsList, fmt.Sprintf("sing-box statsquery failed: %s", + truncateRunes(safeError(detail), 160))) + engineErrors = append(engineErrors, "sing-box") + failed = true + } + if !failed { + part, err := parseUsageStats(outBuf.String()) + if err != nil { + errorsList = append(errorsList, "sing-box: "+safeError(err.Error())) + engineErrors = append(engineErrors, "sing-box") + failed = true + } else { + parsed = part + } + } + if !failed { + epochValue := before + if epochValue == "" { + epochValue = serviceEpoch() + } + if epochValue == "" { + epochValue = "unknown" + } + epoch := "sing-box:" + epochValue + tagNodes := map[string]string{} + for _, res := range resources { + meta := resMeta(res) + tagNodes[asString(meta["metricsTag"])] = asString(meta["nodeId"]) + } + for _, tag := range parsed.inbounds.keys { + counters := parsed.inbounds.m[tag] + nodeID, ok := tagNodes[tag] + if !ok || nodeID == "" { + continue + } + samples = append(samples, usageSample{Kind: "inbound", ID: nodeID, + Uplink: counters.Uplink, Downlink: counters.Downlink, Epoch: epoch}) + } + userRe := regexp.MustCompile(`^ng:(.+)$`) + for _, name := range parsed.users.keys { + counters := parsed.users.m[name] + match := userRe.FindStringSubmatch(name) + if match == nil { + continue + } + samples = append(samples, usageSample{Kind: "user", ID: match[1], + Uplink: counters.Uplink, Downlink: counters.Downlink, Epoch: epoch}) + } + if len(parsed.inbounds.keys) == 0 && len(parsed.users.keys) == 0 { + errorsList = append(errorsList, "sing-box 入口尚无统计记录") + } + } + var errPtr *string + if len(errorsList) > 0 { + joined := strings.Join(errorsList, "; ") + errPtr = &joined + } + return &usageReport{Samples: samples, Error: errPtr, EngineErrors: engineErrors}, nil +} + +func queryUsage() (*usageReport, error) { + stateMu.Lock() + defer stateMu.Unlock() + return queryUsageLocked() +} + +// ---- flush before config changes ------------------------------------------ + +// flushUsageBeforeChange persists the running engine's counters before an +// apply/delete restarts sing-box and destroys them — agent.js +// flushUsageBeforeChange(). Callers must hold stateMu. +func flushUsageBeforeChangeLocked() error { + resources, err := readResources() + if err != nil { + return err + } + live := false + for _, res := range resources { + meta := resMeta(res) + if meta != nil && jsTruthy(meta["metricsTag"]) && asString(meta["kind"]) == "node" { + live = true + break + } + } + if !live || serviceHealth("nexusgate-sing-box").Status != "ready" { + return nil + } + report, err := queryUsageLocked() + if err != nil { + return err + } + for _, e := range report.EngineErrors { + if e == "sing-box" { + return fmt.Errorf("sing-box 重载前无法读取入口计数;请先运行 ng-agent doctor") + } + } + // Await the controller's durable write before the restart. + _, err = requestJSON("/api/agent/usage", report.toMap()) + return err +} + +// flushAllUsageBeforeRestart backs the `flush-usage` CLI action used by +// agent-update.sh. +func flushAllUsageBeforeRestart() error { + stateMu.Lock() + defer stateMu.Unlock() + return flushUsageBeforeChangeLocked() +} + +// ---- AnyTLS IP observations ------------------------------------------------ + +var ( + anytlsCtxRe = regexp.MustCompile(`\[(\d+)\s+\d+ms\]\s+inbound/anytls\[([^\]]+)\]:\s+(.*)$`) + anytlsSrcRe = regexp.MustCompile(`inbound connection from\s+(\[[^\]]+\]|[\d.]+):\d+`) + anytlsAuthRe = regexp.MustCompile(`\[ng:([^\]]+)\] inbound connection to `) +) + +type observation struct { + CustomerID string `json:"customerId"` + IP string `json:"ip"` +} + +type observationsResult struct { + observations []observation + offset *int64 +} + +// readSingBoxObservations tails the sing-box access log from the saved cursor +// and extracts authenticated AnyTLS (customerId, source IP) pairs — agent.js +// readSingBoxObservations(). Callers must hold stateMu. +func readSingBoxObservationsLocked() observationsResult { + empty := observationsResult{observations: []observation{}, offset: nil} + info, err := os.Stat(cfg.SingBoxLog) + if err != nil { + return empty + } + var cursorOffset int64 + if data, err := os.ReadFile(cfg.SingBoxCursor); err == nil { + var cursor struct { + Offset int64 `json:"offset"` + } + if err := json.Unmarshal(data, &cursor); err == nil { + cursorOffset = cursor.Offset + } + } + size := info.Size() + if size < cursorOffset { + cursorOffset = 0 + } + start := cursorOffset + if floor := size - 2*1024*1024; floor > start { + start = floor + } + if start >= size { + return empty + } + f, err := os.Open(cfg.SingBoxLog) + if err != nil { + return empty + } + defer f.Close() + buffer := make([]byte, size-start) + if _, err := f.ReadAt(buffer, start); err != nil { + return empty + } + end := bytes.LastIndex(buffer, []byte("\n")) + if end < 0 { + return empty + } + resources, err := readResources() + if err != nil { + return empty + } + entryTags := map[string]bool{} + for _, res := range resources { + if meta := resMeta(res); meta != nil && asString(meta["kind"]) == "node" { + if tag := asString(meta["metricsTag"]); tag != "" { + entryTags[tag] = true + } + } + } + sourceByID := map[string]string{} + uniqueKeys := []string{} + unique := map[string]observation{} + for _, line := range strings.Split(string(buffer[:end]), "\n") { + ctx := anytlsCtxRe.FindStringSubmatch(line) + if ctx == nil || !entryTags[ctx[2]] { + continue + } + key := ctx[2] + ":" + ctx[1] + if src := anytlsSrcRe.FindStringSubmatch(ctx[3]); src != nil { + ip := src[1] + ip = strings.TrimPrefix(ip, "[") + ip = strings.TrimSuffix(ip, "]") + sourceByID[key] = ip + } + // Only count authenticated AnyTLS sessions, never unauthenticated probes. + if authed := anytlsAuthRe.FindStringSubmatch(ctx[3]); authed != nil { + if ip, ok := sourceByID[key]; ok { + customerID := authed[1] + uniqKey := customerID + "|" + ip + if _, exists := unique[uniqKey]; !exists { + uniqueKeys = append(uniqueKeys, uniqKey) + } + unique[uniqKey] = observation{CustomerID: customerID, IP: ip} + } + } + } + result := observationsResult{observations: []observation{}} + for _, k := range uniqueKeys { + result.observations = append(result.observations, unique[k]) + } + offset := start + int64(end) + 1 + result.offset = &offset + return result +} + +// ---- periodic loops ---------------------------------------------------------- + +func usageLoop() { + for { + sleepMS(60000) + func() { + report, err := queryUsage() + var body map[string]interface{} + if err != nil { + // JS catch fallback: { samples: [], error } (no engineErrors key). + msg := safeError(err.Error()) + body = map[string]interface{}{"samples": []usageSample{}, "error": msg} + } else { + body = report.toMap() + } + if _, err := requestJSON("/api/agent/usage", body); err != nil { + logf("Usage report failed %s", safeError(err.Error())) + } + }() + func() { + stateMu.Lock() + defer stateMu.Unlock() + sing := readSingBoxObservationsLocked() + if len(sing.observations) > 0 { + if _, err := requestJSON("/api/agent/observations", + map[string]interface{}{"observations": sing.observations}); err != nil { + logf("IP observation report failed %s", safeError(err.Error())) + return + } + } + // A failed upload must not advance the cursor; the same log + // lines are retried next cycle (the early return above matches + // the JS try/catch scope). + if sing.offset != nil { + body, _ := marshalJSON(map[string]interface{}{"offset": *sing.offset}) + if err := os.WriteFile(cfg.SingBoxCursor, []byte(body+"\n"), 0o600); err != nil { + logf("IP observation report failed %s", safeError(err.Error())) + } + } + }() + } +}