feat: Go agent source (stdlib-only rewrite of agent.js) + parity/install docs; binaries ship as release assets
This commit is contained in:
@@ -0,0 +1 @@
|
||||
nexusgate-agent-linux-*
|
||||
@@ -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-<arch>`
|
||||
(`<arch>` 由 `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`
|
||||
@@ -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:<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.
|
||||
- Heartbeat `system.arch` uses Node vocabulary (`x64` for amd64) — mapped in
|
||||
`nodeArch()`.
|
||||
- Engine object sends `singBoxStatus` as the raw service status
|
||||
(`ready`/`error`); the panel maps non-`ready` to `inactive`, same as with
|
||||
the Node agent.
|
||||
|
||||
## Critical correctness points
|
||||
|
||||
1. **Reality keys**: generated with `crypto/ecdh` X25519; private/public are
|
||||
`base64.RawURLEncoding` of the raw 32-byte keys — byte-identical to Node
|
||||
JWK `d`/`x` and to `sing-box generate reality-keypair` output (verified
|
||||
against the RFC 7748 test vector, the real sing-box binary's output
|
||||
format, and a full `sing-box check` over a config containing a
|
||||
Go-generated key). No padded/standard-base64 conversion anywhere.
|
||||
2. **Usage flush ordering**: apply/delete flush counters (and await the
|
||||
panel's durable write) *before* writing/removing the resource file and
|
||||
restarting sing-box, exactly as JS. The flush aborts the job when the
|
||||
stats helper reports an engine error.
|
||||
3. **Observations**: only *authenticated* AnyTLS lines
|
||||
(`[ng:<customerId>] inbound connection to`) count, joined with the source
|
||||
IP from the same connection id; unauthenticated probes never count; the
|
||||
cursor file advances only after a successful upload.
|
||||
|
||||
## Stats helper decision (stats-query.go)
|
||||
|
||||
The Node agent shells out to the separate `nexusgate-sing-box-stats` binary
|
||||
(env `NG_SINGBOX_STATS_BIN`), which speaks gRPC to sing-box's v2ray API
|
||||
using sing-box's own protobuf stubs. The Go agent keeps that exact design:
|
||||
|
||||
- Integrating stats-query.go would import `github.com/sagernet/sing-box` +
|
||||
`google.golang.org/grpc` + protobuf — a large module graph and several
|
||||
extra MB — for a component that is *already built, deployed, and
|
||||
versioned together with the sing-box binary* by `agent-singbox.sh`.
|
||||
- Keeping the subprocess preserves the failure semantics 1:1 (exit code,
|
||||
stderr text in error messages, 15s timeout, epoch-change detection).
|
||||
- Result: the agent stays stdlib-only at ~6 MB. Integrating the gRPC client
|
||||
later is a drop-in change inside `queryUsageLocked()` if ever wanted.
|
||||
|
||||
## Env vars & paths preserved (drop-in checklist)
|
||||
|
||||
- `NG_CONTROLLER` (trailing slashes trimmed), `NG_AGENT_KEY`
|
||||
- `NG_SINGBOX_BIN` (default `/usr/local/bin/nexusgate-sing-box`)
|
||||
- `NG_SINGBOX_STATS_BIN` (default `/usr/local/bin/nexusgate-sing-box-stats`)
|
||||
- `NG_CONFIG_DIR` (default `/etc/nexusgate/agent`; resources in `<dir>/resources`)
|
||||
- `NG_SINGBOX_CONFIG` (default `/etc/nexusgate/sing-box/config.json`;
|
||||
`.candidate.json` / `.previous` siblings as in JS)
|
||||
- `NG_SINGBOX_LOG` (default `/var/log/nexusgate/sing-box-access.log`)
|
||||
- `NG_SINGBOX_CURSOR` (default `/etc/nexusgate/sing-box-cursor.json`, `{"offset":N}`)
|
||||
- `NG_KEY_FILE` (default `/etc/nexusgate/keys.json`, 0600, same JSON shape)
|
||||
- `NG_TLS_DIR` (default `/etc/nexusgate/tls`) — note the TLS path prefix
|
||||
check inside applyResource stays hardcoded to `/etc/nexusgate/tls/<domain>/`
|
||||
exactly as in JS.
|
||||
- `NG_SINGBOX_API_PORT` (default `10086`), `NG_POLL_SECONDS` (default 8, min 3)
|
||||
- `/etc/nexusgate/last-heartbeat.json` stamp after each successful heartbeat.
|
||||
- Resource files: materialized resource JSON, 2-space indent + `\n`, mode 0600.
|
||||
**Inherited JS behavior**: the stored resource contains the *materialized*
|
||||
Reality private key (not the placeholder). Kept for parity; flagged here
|
||||
because it means private keys live in `<NG_CONFIG_DIR>/resources/*.json`
|
||||
(dir mode 0700, files 0600) just as with the Node agent.
|
||||
|
||||
## Known divergences (all behavior-preserving)
|
||||
|
||||
1. **JSON key order inside resource files / combined config**: Go reserializes
|
||||
nested objects with alphabetically sorted keys; JS preserves insertion
|
||||
order. Semantically identical for sing-box and for the panel. Side effect:
|
||||
on the first Go-agent activation over a Node-written `config.json`, the
|
||||
byte-equality no-restart check misses once and sing-box restarts a single
|
||||
time; afterwards the Go output is byte-stable (verified deterministic).
|
||||
2. **HTTP request body key order** (heartbeat/usage bodies): Go maps sort
|
||||
keys; the panel parses JSON, so this is invisible.
|
||||
3. **`copyFilePreserve` mode**: `.previous` config backup is written 0600;
|
||||
JS `copyFileSync` would create it at 0666&^umask. Stricter, intentional.
|
||||
4. **`NG_POLL_SECONDS` non-numeric**: JS yields NaN (busy loop); Go clamps to
|
||||
the 3-second floor. Only affects a misconfigured env.
|
||||
5. **Error texts from OS layer**: spawn-failure details come from Go's exec
|
||||
package (`fork/exec …: no such file or directory`) where JS embeds V8's
|
||||
`spawnSync … ENOENT` wording; same position in the same message templates,
|
||||
only the OS-error phrasing differs. Likewise V8 vs Go JSON parse error
|
||||
texts inside the `sing-box: …` diagnostic prefix.
|
||||
6. **`execute()` completion-report quirk (faithfully kept)**: if a job
|
||||
succeeds but the success POST to the panel fails, JS falls into the same
|
||||
catch block and reports `{success:false}`; the Go agent does the same.
|
||||
Panel-side reconcile re-queues the apply, so state converges.
|
||||
7. **`readSingBoxObservations` internal failure logging**: a malformed
|
||||
resource file makes JS log `IP observation report failed` once per cycle;
|
||||
Go silently returns no observations (cursor not advanced in both).
|
||||
8. **Local-address detection** compares parsed IPs (Go `net.IP.Equal`)
|
||||
instead of JS's string equality against interface addresses — slightly
|
||||
more lenient for non-canonical IPv6 spellings; can only avoid a fallback,
|
||||
never cause a wrong bind.
|
||||
9. **Certificate fingerprint** uses integer milliseconds (JS float ms) and
|
||||
sorted directory order (Node readdir order is filesystem-dependent);
|
||||
the fingerprint is only an in-process cache key.
|
||||
10. **Job id in the complete URL** is concatenated raw; JS uses
|
||||
`encodeURIComponent`. Panel-generated job ids are URL-safe, so this is
|
||||
equivalent in practice.
|
||||
11. **Concurrency**: JS is single-threaded; Go runs poll/usage/heartbeat
|
||||
goroutines serialized around on-disk state with a mutex. Panel-visible
|
||||
behavior is unchanged (the panel already tolerates duplicate/out-of-order
|
||||
usage samples per agent.js's own comment).
|
||||
|
||||
## Not replicated / out of scope
|
||||
|
||||
- Enrollment (`/api/agent/enroll`) is performed by `agent-install.sh`, not
|
||||
the agent binary — unchanged.
|
||||
- `ng-agent` helper scripts (cert/doctor/engine/update/uninstall) are shell
|
||||
and unchanged; they invoke the agent only via the `flush-usage` action,
|
||||
which is supported.
|
||||
- Service unit files still exec Node (`systemd/nexusgate-agent.service`,
|
||||
`agent/run.sh`). Pointing them at this binary (and dropping the Node
|
||||
dependency from `agent-install.sh`) is a separate deployment step, not
|
||||
done here.
|
||||
|
||||
## Tests run in this environment
|
||||
|
||||
- `go test ./...` — all pass:
|
||||
- RFC 7748 X25519 vector + key format (43 chars, raw base64url, 32-byte
|
||||
round-trip) + keys.json persistence (0600, JS field shape, stable pair).
|
||||
- Combined config merge for fabricated panel-shaped resources (VLESS
|
||||
Reality + SS2022 + SOCKS): listen fallback, fallback outbound first,
|
||||
route final, stats inbounds/users (name *and* username forms),
|
||||
deterministic bytes.
|
||||
- Generated config accepted by the **real** sing-box v1.13.21 binary
|
||||
(`sing-box check`), incl. a Go-generated Reality private key.
|
||||
- `parseUsageStats`: `{"stat":null}`, overwrite semantics, missing value →
|
||||
0, bare JSON number, and all error branches (负数 / 超出安全整数范围 /
|
||||
格式 string / 格式 object / invalid envelope).
|
||||
- Observations: authenticated-only counting, per-connection IP join,
|
||||
entry-tag filtering, cursor EOF behavior.
|
||||
- `safeError` redaction.
|
||||
- Binary smoke test against a stub panel (real binary, stub
|
||||
systemctl/sing-box in PATH): startup heartbeat (version `0.6.8`, full
|
||||
system/engine bodies), poll → `apply_resource` → resource + config files
|
||||
written → `complete {success:true, result:{artifacts:{}}}`; then
|
||||
`delete_resource` → usage flush POST first
|
||||
(`{"samples":[],"error":"sing-box 入口尚无统计记录","engineErrors":[]}`),
|
||||
`complete {removed:true}`, config removed, engine stopped. 30s heartbeat
|
||||
cadence observed. `flush-usage` CLI exits 0; missing env exits 1 with the
|
||||
JS error message.
|
||||
- **Not tested here**: real-node deployment (systemd/OpenRC service restart
|
||||
of an actual sing-box, live traffic counters via the real stats binary,
|
||||
real panel). That is the user's scheduled real-machine step; nothing in
|
||||
this report claims e2e success on a live node.
|
||||
@@ -0,0 +1,3 @@
|
||||
module nexusgate-agent-go
|
||||
|
||||
go 1.24
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
// { "<keyId>": { "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:<keyId>}" 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// "<cmd> <args> failed: <stderr||stdout, redacted, 1200 chars>".
|
||||
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>:<startTick>"
|
||||
// (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/<pid>/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
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user