From 3951134940d75afce8bda5fddff800bc6b0c7bdb Mon Sep 17 00:00:00 2001 From: aaaamoooo Date: Tue, 20 Oct 2026 08:21:54 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Go=20port=20of=20the=20panel=20in=20pan?= =?UTF-8?q?el-go/=20=E2=80=94=20drop-in=20replacement=20for=20server.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-stdlib Go implementation of the whole control plane: same nexusgate.json schema (unknown fields preserved verbatim, explicit null/false tri-states modeled), same HTTP API and error strings, embedded frontend (NG_PUBLIC_DIR override), self-implemented scrypt auth, X25519 Reality keys, panel-hosted keyStore and heartbeat reconciliation included. Version stays 0.6.8. Verified: Go tests 33/33 (scrypt + Reality vectors generated from the Node implementation, subscription goldens byte-identical), Node suite 52/52 untouched, differential E2E against the Node panel 50/50 including two-way DB swap. See panel-go/PARITY.md and panel-go/INSTALL-GO.md. --- panel-go/.gitignore | 2 + panel-go/INSTALL-GO.md | 69 + panel-go/PARITY.md | 76 + panel-go/access.go | 156 ++ panel-go/access_test.go | 165 ++ panel-go/auth.go | 197 ++ panel-go/auth_test.go | 114 + panel-go/build.sh | 9 + panel-go/difftest/README.md | 146 ++ panel-go/difftest/lib.mjs | 517 ++++ panel-go/difftest/run.mjs | 432 +++ panel-go/difftest/seed-expect.json | 52 + panel-go/difftest/seed-log.json | 1124 ++++++++ panel-go/difftest/seed.json | 1 + panel-go/difftest/seed.mjs | 256 ++ panel-go/go.mod | 3 + panel-go/handlers.go | 2612 +++++++++++++++++++ panel-go/handlers_smoke_test.go | 174 ++ panel-go/httpd.go | 439 ++++ panel-go/main.go | 380 +++ panel-go/orchestrator.go | 1330 ++++++++++ panel-go/protocols.go | 800 ++++++ panel-go/protocols_test.go | 143 + panel-go/redact.go | 22 + panel-go/redact_test.go | 24 + panel-go/scrypt.go | 165 ++ panel-go/scrypt_test.go | 120 + panel-go/static.go | 130 + panel-go/store.go | 234 ++ panel-go/store_test.go | 243 ++ panel-go/subscriptions.go | 703 +++++ panel-go/subscriptions_test.go | 125 + panel-go/testdata/golden_resources.json | 142 + panel-go/testdata/golden_subscriptions.json | 66 + panel-go/testdata/reality_vectors.json | 18 + panel-go/testdata/scrypt_vectors.json | 32 + panel-go/types.go | 962 +++++++ 37 files changed, 12183 insertions(+) create mode 100644 panel-go/.gitignore create mode 100644 panel-go/INSTALL-GO.md create mode 100644 panel-go/PARITY.md create mode 100644 panel-go/access.go create mode 100644 panel-go/access_test.go create mode 100644 panel-go/auth.go create mode 100644 panel-go/auth_test.go create mode 100755 panel-go/build.sh create mode 100644 panel-go/difftest/README.md create mode 100644 panel-go/difftest/lib.mjs create mode 100644 panel-go/difftest/run.mjs create mode 100644 panel-go/difftest/seed-expect.json create mode 100644 panel-go/difftest/seed-log.json create mode 100644 panel-go/difftest/seed.json create mode 100644 panel-go/difftest/seed.mjs create mode 100644 panel-go/go.mod create mode 100644 panel-go/handlers.go create mode 100644 panel-go/handlers_smoke_test.go create mode 100644 panel-go/httpd.go create mode 100644 panel-go/main.go create mode 100644 panel-go/orchestrator.go create mode 100644 panel-go/protocols.go create mode 100644 panel-go/protocols_test.go create mode 100644 panel-go/redact.go create mode 100644 panel-go/redact_test.go create mode 100644 panel-go/scrypt.go create mode 100644 panel-go/scrypt_test.go create mode 100644 panel-go/static.go create mode 100644 panel-go/store.go create mode 100644 panel-go/store_test.go create mode 100644 panel-go/subscriptions.go create mode 100644 panel-go/subscriptions_test.go create mode 100644 panel-go/testdata/golden_resources.json create mode 100644 panel-go/testdata/golden_subscriptions.json create mode 100644 panel-go/testdata/reality_vectors.json create mode 100644 panel-go/testdata/scrypt_vectors.json create mode 100644 panel-go/types.go diff --git a/panel-go/.gitignore b/panel-go/.gitignore new file mode 100644 index 0000000..c543c93 --- /dev/null +++ b/panel-go/.gitignore @@ -0,0 +1,2 @@ +/embed_public/ +/nexusgate-panel* diff --git a/panel-go/INSTALL-GO.md b/panel-go/INSTALL-GO.md new file mode 100644 index 0000000..e707c99 --- /dev/null +++ b/panel-go/INSTALL-GO.md @@ -0,0 +1,69 @@ +# NexusGate 面板 Go 版 — 部署与回滚(路由器主控) + +Go 版面板是单文件静态二进制:前端已 embed、零运行时依赖、读写与 Node +版**同一个** `nexusgate.json`。切换只是把 procd 的启动命令从 node 换成 +二进制,环境变量与数据文件都不动;agent 协议与版本串(0.6.8)不变, +节点机无需任何改动。 + +## 产物 + +| 架构 | 文件 | 用途 | +|---|---|---| +| arm64 | `nexusgate-panel-linux-arm64` | AX6600 路由器(主控) | +| amd64 | `nexusgate-panel-linux-amd64` | x86 服务器/VPS | + +## 部署(OpenWrt 主控) + +1. 把 arm64 二进制放到路由器,如 `/opt/nexusgate/nexusgate-panel`: + + ```sh + chmod +x /opt/nexusgate/nexusgate-panel + ``` + +2. 先单独冒烟(用现有库与空闲端口,不停现服务): + + ```sh + NG_DATA_FILE=/etc/nexusgate/data/nexusgate.json NG_PORT=8899 /opt/nexusgate/nexusgate-panel + # 另一终端: curl http://127.0.0.1:8899/healthz → {"ok":true,"version":"0.6.8",...} + ``` + + 数据库已有管理员用户,不会触发 BOOTSTRAP;冒烟完 Ctrl-C 停掉。 + +3. 改 procd 服务 `/etc/init.d/nexusgate` 的启动命令:把 + `node ... server.js`(含其参数)换成二进制路径,**其余保持不变** + (`NG_DATA_FILE` 等 env、respawn、procd 的 env 文件引用都不要动): + + ``` + procd_set_param command /opt/nexusgate/nexusgate-panel + ``` + + 若原服务通过 `/etc/nexusgate.env` 注入环境变量,Go 版读的是同名 + 变量(NG_DATA_FILE、NG_HOST、NG_PORT、NG_COOKIE_SECURE、 + NG_ADMIN_USERNAME、NG_ADMIN_PASSWORD),无需改动。 + +4. 重启并验证(**只用 init 脚本,不要手动起进程**): + + ```sh + /etc/init.d/nexusgate restart + curl http://127.0.0.1:8787/healthz + ``` + + 再开面板确认:登录、概览计数、节点列表、任务队列、订阅链接各看一眼。 + +5. 可选:`NG_PUBLIC_DIR=/opt/nexusgate/public` 可让面板改从磁盘目录 + 读前端(调试前端时免重编);不设则用二进制内嵌的前端。 + +## 回滚 + +把 `/etc/init.d/nexusgate` 的启动命令改回原来的 `node ... server.js`, +`/etc/init.d/nexusgate restart` 即可。数据文件未做任何格式变更, +Node 版直接读 Go 版写过的库(已在差分测试中双向验证)。 + +## 备注 + +- 内存参考(沙箱实测、同一种子库):Go 版空闲 VmRSS 约 10.5MB, + 一轮登录+列表请求后约 30MB(Go 堆保留口径);Node 版空闲约 66MB + (生产路由器实测约 54MB)。 +- 二进制本体不含 data/,更新面板不会触碰数据库;更新前仍建议在 + 面板设置页下载一份 JSON 备份。 +- agent 侧(各节点机)与本次切换无关,仍用现有 Go agent。 diff --git a/panel-go/PARITY.md b/panel-go/PARITY.md new file mode 100644 index 0000000..fb0e8f3 --- /dev/null +++ b/panel-go/PARITY.md @@ -0,0 +1,76 @@ +# NexusGate 面板 Go 移植 — 行为对齐说明(PARITY) + +Go 版(本目录)是 Node 版(../server.js + ../lib/)的 1:1 移植:同一 +`nexusgate.json`、同一 HTTP API、同一前端(public/ 零改动,embed 进二进制)。 +版本号保持 `0.6.8`(agent 的 AnyTLS 版本门禁按数字解析面板下发的版本串, +且 overview/healthz 的 version 字段与 Node 一致)。 + +## 已验证等价(2026-10-20,沙箱内) + +- **Go 单元测试 33/33**:含 scrypt 测试向量 6 组(由 Node 实现生成, + `testdata/scrypt_vectors.json`,逐字节一致)、Reality 密钥向量 4 组 + (固定私钥→公钥推导与 Node JWK 导出一致)、资源构建黄金深比较、 + 订阅 8 种格式黄金**逐字节**一致、store 事务/原子写/未知字段透传。 +- **Node 测试 52/52**:未改动任何 Node 文件,原样全绿。 +- **差分 E2E 正式模式 50/50**(`difftest/run.mjs`):同一种子 DB(由 Node + 面板自身业务流生成)分别起 Node/Go 面板,跑登录/CSRF、建服务器+注册 + 令牌、agent enroll→带 resources 心跳→对账重排→poll→complete、建节点+ + 客户+分配→订阅全格式逐字比对、用量上报、节点删除/服务器遗忘、备份 + 比对、错误用例;随后**交换 DB 文件**互启对方实现,列表/订阅/写入均 + 正常,keyStore/jobs/用量不丢。 +- **三态字段已精确建模**(差分首轮抓到并修复):`nodes.autoRemoving` + (缺失 vs 显式 false)、`servers.engine`(缺失 vs 显式 null)、 + `customers.suspendReason`(缺失 vs 显式 null)在 Go 版用 + 指针/presence 标志区分,加载、响应、写回均与 Node 一致。 +- 未知字段(其他版本写入的键)在记录级与顶层均透传保留,Node 写的 DB + 由 Go 加载再写回不丢字段(store 测试 + 互通步骤双重覆盖)。 + +## 已知微偏差(均为边缘路径,不影响线上业务流) + +1. **Cookie 解析**:值含畸形 `%` 转义时 JS `decodeURIComponent` 抛错 + (请求 500);Go 跳过该对。Go 的 PathUnescape 也不拒绝非法 UTF-8 + 字节序列(JS 会抛错)。 +2. **VerifySecret 的 hex 解码**:JS `Buffer.from(hex,'hex')` 对非法 + hex 是截断容忍,Go 严格解码失败即 false;最终判定结果一致。 +3. **字符串截断口径**:CleanText、错误截断(1800/240/80)、access 的 + 128/200 上限,Go 按 rune、JS 按 UTF-16 码元;仅星平面字符(emoji + 等)恰在边界时可能差 1 字符。新密码 10 位长度校验同此口径。 +4. **settings 缺失判定**:Go 用值结构体,以「五字段全零且无 Extra」 + 近似 JS 的 `!data.settings`;真实写路径不会产生全零 settings, + 不可达。 +5. **订阅信息页日期**:Go 按存储值(UTC)以 `2006/01/02 15:04:05` + 格式化;JS `toLocaleString('zh-CN')` 按服务器本地时区渲染且随 + ICU 版本可能有差异(旧 ICU 带上午/下午标记)。百分比 `%.1f` 与 + JS `toFixed(1)` 在 .x5 边界可能差 0.1。 +6. **NG_DATA_FILE 默认值**:Go 取可执行文件同目录 `data/nexusgate.json`, + JS 取源码目录 `__dirname/data`;正式部署都显式设置该变量,无影响。 + NG_PORT 不可解析时 Go 回退 8787,JS 为 NaN 监听失败。 +7. **ReadJSON 边缘**:body 为 `null` 时 Go 视为空对象,JS 返回 null + 后在个别 handler 会 500;非对象 JSON 体 Go 返回 400,JS 各 handler + 自行退化。路由参数解码失败时 Go 保留原始段,JS 抛错 500。 +8. **JSString 近似**:对象/数组入参 Go 返回 "",JS 为 + `"[object Object]"`/逗号拼接;现有 handler 不可达。 + OptionalISO 只接受 ParseISO 覆盖的格式,JS `Date` 还接受 + `2026/10/20` 等写法(Go 返回 400)。 +9. **DNS 失败文案**:resolveBindAddress 报错括号内 Go 用解析器简述 + (如 `no such host`),JS 用 getaddrinfo 的 code(如 ENOTFOUND); + 仅 DNS 解析失败路径的文案细节不同。 +10. **keyMaterial 回填遍历顺序**:多 keyId 时 Go map 迭代序与 JS + 对象序不同,audit 先后可能不同;各 key 相互独立,结果等价。 +11. **JSON 键序**:Go 响应/落盘的键序为结构体字段序(合并未知字段 + 时为字母序),JS 为插入序;JSON 语义等价,双方互读无碍(互通 + 步骤已验证)。 +12. **HTTP 超时**:Go ReadHeaderTimeout 15s + ReadTimeout 30s 近似 + Node 的 headersTimeout/requestTimeout 语义。 +13. **TLS 域名校验**:RE2 不支持 lookahead,总长度 253 改在代码中 + 校验,接受/拒绝结果一致。 +14. **畸形请求建记录**:节点创建缺 name 时 Go 存 "",JS 该键缺失; + 服务器 labels 缺省 Go 为 `[]`,JS 为 undefined;仅手工畸形请求 + 可见,前端正常请求不触发。 + +## 构建与产物 + +- `build.sh`:从 ../public 刷新 embed_public 后 CGO_ENABLED=0 构建。 +- 交叉编译(发布):`CGO_ENABLED=0 GOOS=linux GOARCH=amd64|arm64 go build -trimpath -ldflags="-s -w"`, + 产物 `nexusgate-panel-linux-{amd64,arm64}`,纯静态、无外部依赖。 +- 运行时可用 `NG_PUBLIC_DIR` 指定外部静态目录覆盖 embed(调试用)。 diff --git a/panel-go/access.go b/panel-go/access.go new file mode 100644 index 0000000..f217279 --- /dev/null +++ b/panel-go/access.go @@ -0,0 +1,156 @@ +package main + +// Port of lib/access.js: subscription client identity, active-client and +// observed-IP windows, and access-log pruning. +// +// JS Date.parse failures yield NaN and every comparison against NaN is +// false; ParseISOMs reports those as ok=false and the filters below exclude +// such records, preserving the JS semantics. + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "strings" + "time" +) + +// Window constants from lib/access.js, in milliseconds. +const ( + ClientWindowMs = int64(24 * 60 * 60 * 1000) + LogRetentionMs = int64(30 * 24 * 60 * 60 * 1000) +) + +// ClientIdentityResult is the JS clientIdentity return shape +// { userAgent, kind, key }. +type ClientIdentityResult struct { + UserAgent string `json:"userAgent"` + Kind string `json:"kind"` + Key string `json:"key"` +} + +// truncRunes truncates to at most n characters. JS slice() counts UTF-16 +// code units; counting runes matches for all BMP text. +func truncRunes(s string, n int) string { + runes := []rune(s) + if len(runes) > n { + runes = runes[:n] + } + return string(runes) +} + +// ClientIdentity mirrors JS clientIdentity(headers, secret): the declared +// identity is x-device-id, falling back to x-client-id, trimmed and cut to +// 128 chars; the user agent is trimmed and cut to 200 chars; kind is +// "header" when a declared identity exists, else "user-agent" when a user +// agent exists, else "unknown". Key is HMAC-SHA256(secret, "kind:identity") +// as hex. +func ClientIdentity(headers http.Header, secret string) ClientIdentityResult { + declared := headers.Get("X-Device-Id") + if declared == "" { + declared = headers.Get("X-Client-Id") + } + declared = truncRunes(strings.TrimSpace(declared), 128) + userAgent := truncRunes(strings.TrimSpace(headers.Get("User-Agent")), 200) + kind := "unknown" + if declared != "" { + kind = "header" + } else if userAgent != "" { + kind = "user-agent" + } + identity := declared + if identity == "" { + identity = userAgent + } + if identity == "" { + identity = "unknown" + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(kind + ":" + identity)) + return ClientIdentityResult{ + UserAgent: userAgent, + Kind: kind, + Key: hex.EncodeToString(mac.Sum(nil)), + } +} + +func observationTtlMinutes(d *Data) float64 { + ttl := d.Settings.ObservationTtlMinutes + if ttl == 0 { + // JS: settings.observationTtlMinutes || 10 — 0 (and NaN) fall back. + ttl = 10 + } + return ttl +} + +// ActiveClients returns the clientKeys of the customer's subscription +// clients seen within the last 24h, in stored order. +func ActiveClients(d *Data, customerID string, now time.Time) []string { + cutoff := now.UnixMilli() - ClientWindowMs + out := []string{} + for _, item := range d.SubscriptionClients { + if item.CustomerID != customerID { + continue + } + ms, ok := ParseISOMs(item.LastSeenAt) + if ok && ms > cutoff { + out = append(out, item.ClientKey) + } + } + return out +} + +// ObservedIPs returns the customer's observations fresher than the +// configured observation TTL (settings.observationTtlMinutes || 10). +func ObservedIPs(d *Data, customerID string, now time.Time) []*Observation { + cutoff := now.UnixMilli() - int64(observationTtlMinutes(d)*60000) + out := []*Observation{} + for _, item := range d.Observations { + if item.CustomerID != customerID { + continue + } + ms, ok := ParseISOMs(item.LastSeenAt) + if ok && ms > cutoff { + out = append(out, item) + } + } + return out +} + +// PruneAccess mirrors JS pruneAccess: subscriptionAccess keeps the last +// 10000 entries fresher than 30 days, subscriptionClients keeps entries +// fresher than 24h, observations keep entries fresher than the TTL. +func PruneAccess(d *Data, now time.Time) { + nowMs := now.UnixMilli() + + retentionCutoff := nowMs - LogRetentionMs + access := []*SubscriptionAccess{} + for _, item := range d.SubscriptionAccess { + if ms, ok := ParseISOMs(item.At); ok && ms > retentionCutoff { + access = append(access, item) + } + } + if len(access) > 10000 { // JS: .slice(-10000) + access = access[len(access)-10000:] + } + d.SubscriptionAccess = access + + clientCutoff := nowMs - ClientWindowMs + clients := []*SubscriptionClient{} + for _, item := range d.SubscriptionClients { + if ms, ok := ParseISOMs(item.LastSeenAt); ok && ms > clientCutoff { + clients = append(clients, item) + } + } + d.SubscriptionClients = clients + + observationCutoff := nowMs - int64(observationTtlMinutes(d)*60000) + observations := []*Observation{} + for _, item := range d.Observations { + if ms, ok := ParseISOMs(item.LastSeenAt); ok && ms > observationCutoff { + observations = append(observations, item) + } + } + d.Observations = observations +} diff --git a/panel-go/access_test.go b/panel-go/access_test.go new file mode 100644 index 0000000..2e92a44 --- /dev/null +++ b/panel-go/access_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "strings" + "testing" + "time" +) + +func hmacHex(secret, msg string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(msg)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func headersOf(pairs ...string) http.Header { + h := http.Header{} + for i := 0; i+1 < len(pairs); i += 2 { + h.Set(pairs[i], pairs[i+1]) + } + return h +} + +func TestClientIdentityHeaderKind(t *testing.T) { + id := ClientIdentity(headersOf("X-Device-Id", " device-1 ", "User-Agent", "UA/1.0"), "s3cret") + if id.Kind != "header" { + t.Errorf("Kind = %q, want header", id.Kind) + } + if id.UserAgent != "UA/1.0" { + t.Errorf("UserAgent = %q", id.UserAgent) + } + if want := hmacHex("s3cret", "header:device-1"); id.Key != want { + t.Errorf("Key = %q, want %q", id.Key, want) + } +} + +func TestClientIdentityFallbacks(t *testing.T) { + // x-client-id is the fallback declared identity. + id := ClientIdentity(headersOf("X-Client-Id", "client-9"), "k") + if id.Kind != "header" || id.Key != hmacHex("k", "header:client-9") { + t.Errorf("x-client-id fallback = %+v", id) + } + // Empty x-device-id falls through to x-client-id (JS || semantics). + id = ClientIdentity(headersOf("X-Device-Id", "", "X-Client-Id", "client-9"), "k") + if id.Kind != "header" || id.Key != hmacHex("k", "header:client-9") { + t.Errorf("empty x-device-id fallback = %+v", id) + } + // No declared identity -> user-agent kind. + id = ClientIdentity(headersOf("User-Agent", "Agent X"), "k") + if id.Kind != "user-agent" || id.Key != hmacHex("k", "user-agent:Agent X") { + t.Errorf("user-agent kind = %+v", id) + } + // Nothing at all -> unknown. + id = ClientIdentity(http.Header{}, "k") + if id.Kind != "unknown" || id.UserAgent != "" || id.Key != hmacHex("k", "unknown:unknown") { + t.Errorf("unknown kind = %+v", id) + } +} + +func TestClientIdentityTruncation(t *testing.T) { + longDevice := strings.Repeat("d", 200) + id := ClientIdentity(headersOf("X-Device-Id", longDevice), "k") + if want := hmacHex("k", "header:"+strings.Repeat("d", 128)); id.Key != want { + t.Errorf("declared identity not truncated to 128: Key = %q want %q", id.Key, want) + } + longUA := strings.Repeat("u", 300) + id = ClientIdentity(headersOf("User-Agent", longUA), "k") + if len([]rune(id.UserAgent)) != 200 { + t.Errorf("UserAgent length = %d, want 200", len([]rune(id.UserAgent))) + } + if want := hmacHex("k", "user-agent:"+strings.Repeat("u", 200)); id.Key != want { + t.Errorf("user agent not truncated to 200 in key") + } +} + +var testNow = time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) + +func isoAgo(d time.Duration) string { return ISOFromTime(testNow.Add(-d)) } + +func TestActiveClientsWindow(t *testing.T) { + d := EmptyData() + d.SubscriptionClients = []*SubscriptionClient{ + {CustomerID: "c1", ClientKey: "fresh", LastSeenAt: isoAgo(time.Hour)}, + {CustomerID: "c1", ClientKey: "stale", LastSeenAt: isoAgo(25 * time.Hour)}, + {CustomerID: "c1", ClientKey: "boundary", LastSeenAt: isoAgo(24 * time.Hour)}, // > cutoff is strict + {CustomerID: "c2", ClientKey: "other", LastSeenAt: isoAgo(time.Hour)}, + {CustomerID: "c1", ClientKey: "garbage", LastSeenAt: "not-a-date"}, // NaN in JS: excluded + } + got := ActiveClients(d, "c1", testNow) + if len(got) != 1 || got[0] != "fresh" { + t.Errorf("ActiveClients = %v, want [fresh]", got) + } +} + +func TestObservedIPsWindow(t *testing.T) { + d := EmptyData() // observationTtlMinutes defaults to 10 + d.Observations = []*Observation{ + {CustomerID: "c1", IP: "1.1.1.1", LastSeenAt: isoAgo(5 * time.Minute)}, + {CustomerID: "c1", IP: "2.2.2.2", LastSeenAt: isoAgo(11 * time.Minute)}, + {CustomerID: "c2", IP: "3.3.3.3", LastSeenAt: isoAgo(time.Minute)}, + {CustomerID: "c1", IP: "4.4.4.4", LastSeenAt: "garbage"}, + } + got := ObservedIPs(d, "c1", testNow) + if len(got) != 1 || got[0].IP != "1.1.1.1" { + t.Errorf("ObservedIPs default TTL = %v", got) + } + d.Settings.ObservationTtlMinutes = 60 + got = ObservedIPs(d, "c1", testNow) + if len(got) != 2 { + t.Errorf("ObservedIPs 60m TTL = %d entries, want 2", len(got)) + } + // TTL of 0 falls back to 10 like JS `|| 10`. + d.Settings.ObservationTtlMinutes = 0 + got = ObservedIPs(d, "c1", testNow) + if len(got) != 1 { + t.Errorf("ObservedIPs 0 TTL fallback = %d entries, want 1", len(got)) + } +} + +func TestPruneAccess(t *testing.T) { + d := EmptyData() + d.SubscriptionAccess = []*SubscriptionAccess{ + {CustomerID: "c1", At: isoAgo(29 * 24 * time.Hour)}, + {CustomerID: "c1", At: isoAgo(31 * 24 * time.Hour)}, + {CustomerID: "c1", At: "garbage"}, + } + d.SubscriptionClients = []*SubscriptionClient{ + {CustomerID: "c1", ClientKey: "fresh", LastSeenAt: isoAgo(23 * time.Hour)}, + {CustomerID: "c1", ClientKey: "stale", LastSeenAt: isoAgo(24*time.Hour + time.Second)}, + } + d.Observations = []*Observation{ + {CustomerID: "c1", IP: "1.1.1.1", LastSeenAt: isoAgo(9 * time.Minute)}, + {CustomerID: "c1", IP: "2.2.2.2", LastSeenAt: isoAgo(10*time.Minute + time.Second)}, + } + PruneAccess(d, testNow) + if len(d.SubscriptionAccess) != 1 { + t.Errorf("access after prune = %d, want 1", len(d.SubscriptionAccess)) + } + if len(d.SubscriptionClients) != 1 || d.SubscriptionClients[0].ClientKey != "fresh" { + t.Errorf("clients after prune = %+v", d.SubscriptionClients) + } + if len(d.Observations) != 1 || d.Observations[0].IP != "1.1.1.1" { + t.Errorf("observations after prune = %+v", d.Observations) + } +} + +func TestPruneAccessKeepsLast10000(t *testing.T) { + d := EmptyData() + for i := 0; i < 10005; i++ { + d.SubscriptionAccess = append(d.SubscriptionAccess, &SubscriptionAccess{ + CustomerID: "c1", At: isoAgo(time.Minute), ClientKey: fmt.Sprintf("k%05d", i), + }) + } + PruneAccess(d, testNow) + if len(d.SubscriptionAccess) != 10000 { + t.Fatalf("access len = %d, want 10000 (slice(-10000))", len(d.SubscriptionAccess)) + } + if d.SubscriptionAccess[0].ClientKey != "k00005" { + t.Errorf("first retained = %q, want k00005", d.SubscriptionAccess[0].ClientKey) + } +} diff --git a/panel-go/auth.go b/panel-go/auth.go new file mode 100644 index 0000000..0b4d0b5 --- /dev/null +++ b/panel-go/auth.go @@ -0,0 +1,197 @@ +package main + +// Port of lib/auth.js: scrypt secret hashing, random tokens, cookie parsing +// and the in-memory session manager. + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// HashSecret hashes secret with a fresh random 16-byte salt (hex string), +// matching JS hashSecret's default parameter. +func HashSecret(secret string) string { + var saltBytes [16]byte + if _, err := rand.Read(saltBytes[:]); err != nil { + panic(err) + } + return HashSecretWithSalt(secret, hex.EncodeToString(saltBytes[:])) +} + +// HashSecretWithSalt returns `scrypt::`. The salt string is +// fed to scrypt as its raw UTF-8 bytes, exactly like Node. +func HashSecretWithSalt(secret, salt string) string { + hash := ScryptHash(secret, salt) + return "scrypt:" + salt + ":" + hex.EncodeToString(hash) +} + +// VerifySecret mirrors JS verifySecret: split on ':', destructure the first +// three parts, require kind == "scrypt", decode the expected hex and compare +// in constant time. Any malformed input returns false. +func VerifySecret(secret, encoded string) bool { + parts := strings.Split(encoded, ":") + if len(parts) < 3 { + return false + } + kind, salt, expectedHex := parts[0], parts[1], parts[2] + if kind != "scrypt" || salt == "" || expectedHex == "" { + return false + } + expected, err := hex.DecodeString(expectedHex) + if err != nil { + return false + } + actual := ScryptHash(secret, salt) + if len(actual) != len(expected) { + return false + } + return subtle.ConstantTimeCompare(actual, expected) == 1 +} + +// RandomToken mirrors JS randomToken: random bytes, base64url, no padding. +func RandomToken(nBytes int) string { + b := make([]byte, nBytes) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} + +// TokenFingerprint is the SHA-256 hex digest of s. +func TokenFingerprint(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// ParseCookies mirrors JS parseCookies: split on ';', split each part on the +// first '=', trim both sides, decodeURIComponent key and value, then drop +// pairs with an empty key; later duplicates overwrite earlier ones. +// +// Deviation from JS: decodeURIComponent throws on a malformed escape, which +// aborts the whole JS parse; here a pair whose key or value fails to decode +// is skipped instead (and url.PathUnescape, unlike decodeURIComponent, does +// not reject invalid UTF-8 byte sequences produced by %XX escapes). +func ParseCookies(header string) map[string]string { + out := map[string]string{} + for _, part := range strings.Split(header, ";") { + at := strings.Index(part, "=") + if at < 0 { + continue + } + key, err := url.PathUnescape(strings.TrimSpace(part[:at])) + if err != nil { + continue + } + value, err := url.PathUnescape(strings.TrimSpace(part[at+1:])) + if err != nil { + continue + } + if key == "" { + continue + } + out[key] = value + } + return out +} + +// Session is one login session. ExpiresAt is a time.Time where JS stores +// epoch milliseconds. +type Session struct { + ID string + UserID string + CSRF string + ExpiresAt time.Time +} + +// SessionManager is the in-memory session store from lib/auth.js. +type SessionManager struct { + TTL time.Duration + + mu sync.Mutex + sessions map[string]*Session +} + +// NewSessionManager builds a manager whose sessions live for hours hours +// (JS: ttlMs = hours * 60 * 60 * 1000). +func NewSessionManager(hours float64) *SessionManager { + return &SessionManager{ + TTL: time.Duration(hours * float64(time.Hour)), + sessions: map[string]*Session{}, + } +} + +// Create mints a session for userID: id = randomToken(32), +// csrf = randomToken(24), expiresAt = now + TTL. +func (m *SessionManager) Create(userID string) *Session { + session := &Session{ + ID: RandomToken(32), + UserID: userID, + CSRF: RandomToken(24), + ExpiresAt: time.Now().Add(m.TTL), + } + m.mu.Lock() + defer m.mu.Unlock() + if m.sessions == nil { + m.sessions = map[string]*Session{} + } + m.sessions[session.ID] = session + return session +} + +// Get returns the session named by the ng_session cookie, or nil. An expired +// session is deleted and reported as missing (JS: expiresAt <= Date.now()). +func (m *SessionManager) Get(r *http.Request) *Session { + id := sessionIDFromRequest(r) + if id == "" { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + session, ok := m.sessions[id] + if !ok { + return nil + } + if !session.ExpiresAt.After(time.Now()) { + delete(m.sessions, id) + return nil + } + return session +} + +// Delete drops the session named by the ng_session cookie, if any. +func (m *SessionManager) Delete(r *http.Request) { + id := sessionIDFromRequest(r) + if id == "" { + return + } + m.mu.Lock() + defer m.mu.Unlock() + delete(m.sessions, id) +} + +// Prune removes every expired session. +func (m *SessionManager) Prune() { + now := time.Now() + m.mu.Lock() + defer m.mu.Unlock() + for id, session := range m.sessions { + if !session.ExpiresAt.After(now) { + delete(m.sessions, id) + } + } +} + +func sessionIDFromRequest(r *http.Request) string { + if r == nil { + return "" + } + return ParseCookies(r.Header.Get("Cookie"))["ng_session"] +} diff --git a/panel-go/auth_test.go b/panel-go/auth_test.go new file mode 100644 index 0000000..5c69873 --- /dev/null +++ b/panel-go/auth_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestParseCookies(t *testing.T) { + got := ParseCookies("a=1; b=two; ng_session=abc%20def; empty=; =x; novalue; a=last") + want := map[string]string{ + "a": "last", // later duplicates overwrite, like Object.fromEntries + "b": "two", + "ng_session": "abc def", + "empty": "", + } + if len(got) != len(want) { + t.Fatalf("ParseCookies = %v, want %v", got, want) + } + for k, v := range want { + if got[k] != v { + t.Errorf("cookie %q = %q, want %q", k, got[k], v) + } + } + if _, ok := got[""]; ok { + t.Errorf("empty key must be filtered out: %v", got) + } + if _, ok := got["novalue"]; ok { + t.Errorf("pair without '=' must be skipped: %v", got) + } +} + +func TestParseCookiesDecodingAndEdges(t *testing.T) { + if got := ParseCookies(""); len(got) != 0 { + t.Errorf("empty header = %v, want empty", got) + } + got := ParseCookies("a%20b=c%3Dd; spaced = value ") + if got["a b"] != "c=d" { + t.Errorf("decoded key/value with '=' in value = %v", got) + } + if got["spaced"] != "value" { + t.Errorf("trimmed pair = %v", got) + } + // Malformed escape: pair is skipped (documented deviation from JS throw). + got = ParseCookies("bad=%zz; good=1") + if _, ok := got["bad"]; ok { + t.Errorf("malformed pair should be skipped: %v", got) + } + if got["good"] != "1" { + t.Errorf("good pair after malformed = %v", got) + } + // '+' is literal in decodeURIComponent semantics, not a space. + got = ParseCookies("plus=a+b") + if got["plus"] != "a+b" { + t.Errorf("plus handling = %q, want %q", got["plus"], "a+b") + } +} + +func requestWithSession(id string) *http.Request { + r := httptest.NewRequest("GET", "/", nil) + if id != "" { + r.Header.Set("Cookie", "ng_session="+id) + } + return r +} + +func TestSessionManagerLifecycle(t *testing.T) { + m := NewSessionManager(12) + if m.TTL != 12*time.Hour { + t.Fatalf("TTL = %v, want 12h", m.TTL) + } + s := m.Create("user-1") + if s.ID == "" || s.CSRF == "" || s.UserID != "user-1" { + t.Fatalf("created session = %+v", s) + } + if !s.ExpiresAt.After(time.Now()) { + t.Fatalf("ExpiresAt not in future: %v", s.ExpiresAt) + } + got := m.Get(requestWithSession(s.ID)) + if got != s { + t.Fatalf("Get = %+v, want the created session", got) + } + if m.Get(requestWithSession("nope")) != nil { + t.Errorf("Get unknown id should be nil") + } + if m.Get(requestWithSession("")) != nil { + t.Errorf("Get without cookie should be nil") + } + m.Delete(requestWithSession(s.ID)) + if m.Get(requestWithSession(s.ID)) != nil { + t.Errorf("Get after Delete should be nil") + } +} + +func TestSessionManagerExpiryAndPrune(t *testing.T) { + m := NewSessionManager(1) + live := m.Create("live") + dead := m.Create("dead") + dead.ExpiresAt = time.Now().Add(-time.Minute) + if m.Get(requestWithSession(dead.ID)) != nil { + t.Errorf("expired session should read as nil") + } + // Expired session is also deleted by Get. + dead2 := m.Create("dead2") + dead2.ExpiresAt = time.Now().Add(-time.Minute) + m.Prune() + if m.Get(requestWithSession(dead2.ID)) != nil { + t.Errorf("pruned session should be gone") + } + if m.Get(requestWithSession(live.ID)) != live { + t.Errorf("live session must survive Prune") + } +} diff --git a/panel-go/build.sh b/panel-go/build.sh new file mode 100755 index 0000000..34186a2 --- /dev/null +++ b/panel-go/build.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Build the Go panel: refresh the embedded static assets from ../public, +# then compile. The resulting binary serves the UI from the embedded copy +# unless NG_PUBLIC_DIR points at a directory on disk. +set -e +cd "$(dirname "$0")" +rm -rf embed_public +cp -R ../public embed_public +CGO_ENABLED=0 go build -trimpath -o nexusgate-panel . diff --git a/panel-go/difftest/README.md b/panel-go/difftest/README.md new file mode 100644 index 0000000..04520cc --- /dev/null +++ b/panel-go/difftest/README.md @@ -0,0 +1,146 @@ +# NexusGate 面板差分 E2E(Node 面板 vs Go 面板) + +同一种子 DB、同一套操作剧本,分别跑在 Node 面板(`server.js` + `lib/`)与 +Go 面板(`panel-go/` 的移植产物)上,逐步比对响应语义是否一致。脚手架只用 +Node ≥ 18 标准库(`fetch` / `fs` / `child_process` / `net`),文件全部在本 +目录:`lib.mjs`(进程管理 + HTTP 客户端 + 学习式归一化器 + 任务 drain)、 +`seed.mjs`(黄金种子生成)、`run.mjs`(差分主流程)。 + +## 快速开始 + +```bash +cd panel-go/difftest + +# 1) 生成黄金种子(真实启动一次 Node 面板,走完整业务流) +node seed.mjs + +# 2) 自检:Node vs Node 同剧本跑两遍,验证脚手架本身的归一化可靠 +node run.mjs --selftest # 期望末尾 SELFTEST PASS + +# 3) 正式模式:Node vs Go +node run.mjs --bin /path/to/nexusgate-panel-linux-amd64 +# 或 +GO_PANEL_BIN=/path/to/nexusgate-panel-linux-amd64 node run.mjs +# 二进制默认路径:panel-go/nexusgate-panel-linux-amd64(协调者构建) +node run.mjs # 有二进制走正式模式;没有则自检 + FORMAL SKIP +``` + +分辨率顺序:`--bin` > 环境变量 `GO_PANEL_BIN` > 默认路径。二进制缺失时只跑 +Node 自对自 sanity 并明确打印 `FORMAL SKIP (Go panel binary not found at ...)`, +退出码仍由自检结果决定(自检全过为 0)。`seed.json` 不存在时 `run.mjs` 会先 +自动调 `seed.mjs` 生成。其他参数:`--seed `(同目录须有 +`seed-log.json` / `seed-expect.json`)、`--keep`(保留临时目录便于排障)。 + +Go 面板需遵守与 Node 面板相同的启动契约(脚手架只通过环境变量配置): +`NG_HOST` / `NG_PORT` / `NG_DATA_FILE` / `NG_ADMIN_PASSWORD` / +`NG_COOKIE_SECURE=false`,并提供 `GET /healthz`。两侧都用随机高位端口、 +`fs.mkdtemp` 临时数据文件启动,结束可靠杀进程;仓库 `data/` 目录永不触碰。 + +## 种子(seed.mjs) + +以 `NG_ADMIN_PASSWORD=diff-admin-pass` 启动 Node 面板后,仅通过公开 HTTP +API 走完:登录 → 建入口服务器(`entry.example.com` / `v6.example.com` / +`tls.example.com`,端口范围 20000–50000)与出口服务器(`exit.example.com`)→ +两台各走 enrollment-token + 模拟 agent enroll → 客户 Alice(无限制)与 Bob +(流量上限 1073741824、ipLimit 5、deviceLimit 2)→ 节点 N1(forward、 +`vless-reality-vision`、出口 `shadowsocks-2022-aes256`、 +realityServerName `www.amd.com`)与 N2(direct、`shadowsocks-2022-aes128`)→ +N1 分配 Alice+Bob、N2 分配 Alice → deploy 两节点 → agent poll 取任务并以 +artifacts 回 complete(N1 的 `realityPublicKey` 取 poll 到的 +`payload.keyMaterial` 中 `reality-` 对应条目的 publicKey,模拟真实 +agent 的密钥落地回传;其余 artifacts 为 `{}`)→ 断言两节点 active → 拉取 +Alice 全部订阅格式存档为期望值 → 上报 usage(user 样本 Alice 上行 1000 / +下行 2000,inbound 样本 N1 上行 4000 / 下行 5000,epoch `diff-epoch-1`)与 +observations(Alice 一个 IP `203.0.113.10`)→ 导出此刻 DB。 + +产物(均在本目录): + +| 文件 | 内容 | +|---|---| +| `seed.json` | 导出的面板 DB(黄金种子;含 keyStore 中 N1 的 Reality 密钥对) | +| `seed-expect.json` | Alice 在 usage 上报**之前**的订阅响应(raw/base64/clash/clash-smart/singbox/surge/auto×2UA);base64 类 body 以解码后的比较形式存储 | +| `seed-log.json` | 全过程请求/响应日志;`meta.agentKeys` 保存种子里两台 agent 的原始 key(DB 只存哈希,阶段二驱动种子 agent 只能从这里恢复) | + +## 阶段二剧本(run.mjs) + +两边各复制一份 `seed.json` 起面板(不同端口;种子已有用户,故 bootstrap +不触发,直接用 `diff-admin-pass` 登录),逐步执行并逐步归一化比对 +(PASS/FAIL 不中断,跑完所有步;FAIL 给出首个差异的 JSON 路径与两边值摘要): + +1. 登录 / session → 种子 agent 心跳(不带 `resources`,不触发对账)→ + overview → servers/customers/nodes/jobs 列表; +2. 新建服务器 S3 + enrollment + enroll → 新建客户 Carol → 新建节点 N3 + (direct、`vless-reality`)→ 分配 Carol → deploy → poll/complete + (公钥同种子法)→ 节点列表断言 active; +3. Carol 订阅全格式两边逐字比(经归一化)→ Alice 订阅全格式两边比对, + 且每边 body 与 `seed-expect.json` 逐字比(`rawEqSeed`;userinfo 头不比 + 种子值,因为种子期望捕获于 usage 上报前); +4. usage 上报(Alice +500/+700,epoch `diff-phase2-epoch-1`)→ customers + 列表比对 usedBytes(应为 4200); +5. S3 agent heartbeat 带 `resources: []` 触发对账 → poll 应拿到被重排的 + apply(断言两边都有且 payload 语义等)→ complete 恢复 active; +6. 删除 N3(remove → poll delete → complete → 回到 draft)→ S3 forget + 流程:在线 forget 应 409(有关联时与删关联后各一次)、改名确认应 400、 + 最后经 DELETE 服务器完成清理; +7. backup 接口内容语义比对(整份 DB 快照经归一化深比); +8. 错误用例组:未登录 401、CSRF 缺失 403、登录失败文案、不存在节点 404 + 文案、订阅坏 token 404、订阅 info 页比对(归一化日期与 token 后)。 + +随后是 **DB 互通**:停服 → 先比对两边 DB 文件的关键计数(keyStore 条数、 +jobs 数、Alice usedBytes,应为 1 / 两边相等 / 4200)→ 交换 DB 文件 → 各自 +重启对方实现的 DB → 再跑登录、列表接口与 Alice 订阅拉取(仍应与种子期望 +逐字一致)→ 各自改一次客户 notes 确认能写。 + +汇总输出逐步 PASS/FAIL/SKIP 与 `SUMMARY total/pass/fail/skip`,退出码 +0(全过或仅 SKIP)/ 1(有 FAIL)。 + +## 归一化规则(lib.mjs 的 Normalizer,「学习表」机制) + +每侧持有一个 Normalizer;每个步骤的原始响应先 `learn()` 再 `normalize()` +后比对。对象按**排序后的键**遍历、数组保持语义顺序,因此占位编号不依赖 +任一实现的序列化键序。占位按类别内出现顺序编号,两侧同序喂入即同号: + +| 类别 | 规则 | 占位示例 | +|---|---|---| +| 带前缀 ID | `usr/srv/agt/enr/cus/nod/ncl/res/job/evt/obs/acc_` 全文正则(包括嵌在字符串、对象键如 `reality-`、`srv:` 中的) | `#2` | +| 节点 tag 片段 | sing-box tag 由 node id 去符号后取末 18 位派生(`ng-<片段>-in`),学到 node id 时同步登记片段 → 归属 id 的占位 | `ng-#3-in` | +| 裸 UUID | 凭据 `clientId` 等无前缀 UUID | `#4` | +| ISO 时间戳 | `…T…Z/±hh:mm` 全部 | `` | +| 本地化日期 | info 页 `2026/10/20 16:05:03` 形式 | `` | +| 字段学习型秘密 | 按字段名学习:`subscriptionToken`/`agentKey`/`token`(enrollment)/`csrf`/`passwordHash`/`tokenHash`/`keyHash`(含 `scrypt:…` 全文模式)/`keyFingerprint`/`clientKey`/`privateKey`/`publicKey`/`realityPublicKey`/`relayPassword`/`ssServerKey`/`wsPath`/`password`/`shortId`/`short_id`、ws 传输 `path`(`/` 形式);替换时含其 16 字符前缀形式(info 页展示) | `#1`、`#1`、`#3` | +| 随机端口 | 仅从字段 `port`/`exitPort`/`listen_port`/`server_port` 学习数值,JSON 中同名字段与文本中独立出现的该数字一并替换;`portRangeStart` 等确定性数字不学不换 | `#2` | +| ss:// userinfo | SS 链接的 base64 userinfo 整体按出现顺序编号(其内容是 ssServerKey 与 relayPassword 的确定性函数) | `ss://#1@…` | +| 面板自身地址 | info 页里的 `http://127.0.0.1:<本侧端口>` | `` | + +文本体(订阅)同样先归一化后逐字比;base64 类格式先解码再比(见 +`comparisonBody`)。深比对(`firstDiff`)对象键不敏感、数组顺序敏感。 + +## 已知易变字段清单 + +以下字段天生随机/易变,全部已被上表规则覆盖,比对时**不要**绕过归一化 +直接比原文:各类带前缀 ID 与裸 UUID、sing-box tag 片段、所有 ISO +时间戳(`createdAt`/`updatedAt`/`lastSeenAt`/`expiresAt`/`serverTime`/ +`leaseUntil`/`completedAt`/activity `at`、info 页本地化日期、backup 文件 +名日期)、`subscriptionToken`(含 info 页 16 字符前缀)、`agentKey`、 +enrollment token 值、session `csrf`、`passwordHash`/`tokenHash`/`keyHash` +的 scrypt 盐与哈希、`keyFingerprint`、订阅 `clientKey`(HMAC)、Reality +私钥/公钥(keyStore、keyMaterial、artifacts、订阅 `pbk=`)、 +`ssServerKey`、每客户 `relayPassword`、出口凭据密码、`wsPath`、 +`shortId`、节点随机分配的 `port`/`exitPort`、SS 链接 userinfo。 + +## 已知限制与注意事项 + +- **forget 成功路径在本脚手架内不可达**:服务器 `online` 由 lastSeenAt + 在 180s 内推导,造不出“离线”状态而不真等 3 分钟;剧本覆盖了在线 409 + 与名称确认 400 两个错误分支,成功 forget 待 Go 面板就绪后可用假时钟或 + 直接篡改 DB 中 lastSeenAt 的变体补测。 +- **服务器在线状态/ overview.online 是时间敏感量**:两侧步骤背靠背执行 + (亚秒级间隔),且剧本先用种子 agent 心跳把 lastSeenAt 刷新到确定性 + online;若单步耗时异常拉长,仍有极小概率在 120s/180s 边界两侧不一致。 +- **端口占位按数值全局映射**:不同服务器上的节点理论可分配到同一数值 + 端口(概率 ~1e-4/次),此时占位会合并,可能造成个别步假阳性 FAIL; + 重跑种子或重跑即可区分。 +- 未学到的秘密(如阶段二里 Alice 的 relayPassword 未再经 poll 出现)靠 + “两侧同源于同一 seed 副本、原文即相同”成立;若某未学秘密原文里恰含 + 已学端口数字的独立片段,存在可忽略概率的误替换。 +- 登录失败错误用例每侧只打 1 次,避免触发 6 次锁定(15 分钟)污染后续步。 diff --git a/panel-go/difftest/lib.mjs b/panel-go/difftest/lib.mjs new file mode 100644 index 0000000..6ee8753 --- /dev/null +++ b/panel-go/difftest/lib.mjs @@ -0,0 +1,517 @@ +// Shared harness for the NexusGate Node-vs-Go panel differential E2E tests. +// +// Only Node >= 18 standard library is used (fetch / fs / child_process / net). +// This module provides: +// - panel process management (start/stop, health wait, random free ports) +// - a small HTTP client that tracks the admin session cookie + CSRF token +// and can also speak as an agent (Bearer agentKey) or a subscriber +// - a "learning" normalizer: volatile values (prefixed UUID ids, bare +// UUIDs, ISO timestamps, tokens, hashes, Reality keys, per-node secrets, +// randomly allocated ports) are replaced by stable placeholders such as +// #2. Each side of a differential run owns one Normalizer instance; +// both instances are fed the same step sequence and walk objects with +// sorted keys, so the n-th distinct value of a category maps to the same +// placeholder on both sides even though the raw values differ. +// - job drain helpers that simulate a real agent (poll -> complete), where +// Reality apply jobs report artifacts.realityPublicKey taken from the +// polled payload.keyMaterial, exactly like the real agent materializing +// the panel-hosted keypair. + +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const DIFFTEST_DIR = path.dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = path.resolve(DIFFTEST_DIR, '..', '..'); +export const NODE_SERVER_JS = path.join(REPO_ROOT, 'server.js'); +export const DEFAULT_GO_BIN = path.resolve(DIFFTEST_DIR, '..', 'nexusgate-panel-linux-amd64'); + +export const ADMIN_USERNAME = 'admin'; +export const ADMIN_PASSWORD = 'diff-admin-pass'; + +// User-Agents used for subscription pulls. `auto` resolves to clash for +// clash-like UAs and to base64 otherwise (see formatSubscription). +export const UA_DEFAULT = 'NexusGate-DiffTest/1.0'; +export const UA_CLASH = 'clash-verge/v2.0.0'; +export const UA_GENERIC = 'Mozilla/5.0 (DiffTest)'; + +// All subscription shapes exercised by seed + phase 2, in a fixed order. +// `key` is the stable name used in seed-expect.json and step names. +export const SUB_CASES = [ + { key: 'raw', format: 'raw', ua: UA_DEFAULT }, + { key: 'base64', format: 'base64', ua: UA_DEFAULT }, + { key: 'clash', format: 'clash', ua: UA_DEFAULT }, + { key: 'clash-smart', format: 'clash-smart', ua: UA_DEFAULT }, + { key: 'singbox', format: 'singbox', ua: UA_DEFAULT }, + { key: 'surge', format: 'surge', ua: UA_DEFAULT }, + { key: 'auto-clash', format: 'auto', ua: UA_CLASH }, + { key: 'auto-generic', format: 'auto', ua: UA_GENERIC }, +]; + +// --------------------------------------------------------------------------- +// Process management +// --------------------------------------------------------------------------- + +export function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Start one panel process. + * kind 'node' runs the Node panel (server.js); kind 'go' execs the Go binary. + * Both are configured purely through the NG_* environment contract: + * NG_HOST / NG_PORT / NG_DATA_FILE / NG_ADMIN_PASSWORD / NG_COOKIE_SECURE. + */ +export function startPanel({ kind, bin, dataFile, port }) { + const env = { + ...process.env, + NG_HOST: '127.0.0.1', + NG_PORT: String(port), + NG_DATA_FILE: dataFile, + NG_ADMIN_PASSWORD: ADMIN_PASSWORD, + NG_COOKIE_SECURE: 'false', + }; + let child; + if (kind === 'node') { + child = spawn(process.execPath, [NODE_SERVER_JS], { cwd: REPO_ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] }); + } else { + child = spawn(bin, [], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + } + const proc = { + kind, child, port, dataFile, + base: `http://127.0.0.1:${port}`, + output: '', + exited: null, + }; + const capture = (chunk) => { proc.output = (proc.output + chunk.toString()).slice(-8000); }; + child.stdout.on('data', capture); + child.stderr.on('data', capture); + proc.exited = new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal }))); + return proc; +} + +export async function waitHealth(proc, timeoutMs = 20000) { + const deadline = Date.now() + timeoutMs; + let exited = false; + proc.exited.then(() => { exited = true; }); + while (Date.now() < deadline) { + if (exited) throw new Error(`panel (${proc.kind}) exited before becoming healthy:\n${proc.output}`); + try { + const res = await fetch(`${proc.base}/healthz`); + if (res.ok) { await res.text(); return; } + } catch { /* not up yet */ } + await sleep(100); + } + throw new Error(`panel (${proc.kind}) did not become healthy within ${timeoutMs}ms:\n${proc.output}`); +} + +export async function stopPanel(proc) { + if (!proc || !proc.child) return; + if (proc.child.exitCode !== null || proc.child.signalCode !== null) return; + proc.child.kill('SIGTERM'); + const done = await Promise.race([proc.exited.then(() => true), sleep(3000).then(() => false)]); + if (!done) { + proc.child.kill('SIGKILL'); + await Promise.race([proc.exited, sleep(2000)]); + } +} + +export function makeTempDir(prefix = 'nexusgate-difftest-') { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +// --------------------------------------------------------------------------- +// HTTP client (admin session / agent bearer / anonymous) +// --------------------------------------------------------------------------- + +export class PanelClient { + constructor(base) { + this.base = base; + this.cookie = null; + this.csrf = null; + } + + async request(method, pathname, { body, headers = {}, cookie, csrf } = {}) { + const finalHeaders = { ...headers }; + const useCookie = cookie !== undefined ? cookie : this.cookie; + if (useCookie) finalHeaders.cookie = useCookie; + if (csrf) finalHeaders['x-csrf-token'] = this.csrf; + let payload; + if (body !== undefined) { + finalHeaders['content-type'] = 'application/json'; + payload = JSON.stringify(body); + } + const res = await fetch(`${this.base}${pathname}`, { method, headers: finalHeaders, body: payload, redirect: 'manual' }); + const text = await res.text(); + let json = null; + try { json = JSON.parse(text); } catch { /* HTML / plain text bodies */ } + return { status: res.status, headers: res.headers, text, json }; + } + + // Admin call. Non-GET requests carry the session CSRF token unless + // csrf:false is passed (used by the CSRF error-case step). + async admin(method, pathname, body, { csrf = true } = {}) { + return this.request(method, pathname, { body, csrf: csrf && method !== 'GET' }); + } + + async login(username = ADMIN_USERNAME, password = ADMIN_PASSWORD) { + const res = await this.request('POST', '/api/auth/login', { body: { username, password }, cookie: null }); + if (res.status === 200 && res.json) { + const setCookie = res.headers.get('set-cookie') || ''; + this.cookie = setCookie.split(';')[0]; + this.csrf = res.json.csrf; + } + return res; + } + + async agentPost(pathname, agentKey, body = {}) { + return this.request('POST', pathname, { + body, cookie: null, headers: { authorization: `Bearer ${agentKey}` }, + }); + } + + async getSubscription(token, format, ua) { + const res = await fetch(`${this.base}/s/${token}/${format}`, { + headers: { 'user-agent': ua }, redirect: 'manual', + }); + const text = await res.text(); + return { + status: res.status, + contentType: res.headers.get('content-type') || '', + userinfo: res.headers.get('subscription-userinfo') || '', + retryAfter: res.headers.get('retry-after') || '', + body: text, + }; + } +} + +// --------------------------------------------------------------------------- +// Agent simulation helpers +// --------------------------------------------------------------------------- + +// Artifacts a real agent would report for a completed job. For Reality apply +// jobs (entry leg only) the agent materializes the panel-hosted keypair and +// reports its public key; everything else reports no artifacts. +export function artifactsForJob(job) { + if (!job || job.action !== 'apply_resource') return {}; + const payload = job.payload || {}; + const kind = payload.kind || (payload.resource && payload.resource.meta && payload.resource.meta.kind); + if (kind === 'node-exit') return {}; + const keyMaterial = payload.keyMaterial; + if (!keyMaterial || typeof keyMaterial !== 'object') return {}; + const pair = keyMaterial[`reality-${job.nodeId}`] || Object.values(keyMaterial)[0]; + return pair && pair.publicKey ? { realityPublicKey: pair.publicKey } : {}; +} + +export async function pollOnce(client, agentKey) { + const res = await client.agentPost('/api/agent/poll', agentKey, {}); + if (res.status !== 200) throw new Error(`poll failed: ${res.status} ${res.text}`); + return res.json.job; +} + +export async function completeJob(client, agentKey, job, { success = true } = {}) { + const res = await client.agentPost(`/api/agent/jobs/${job.id}/complete`, agentKey, { + success, result: { artifacts: artifactsForJob(job) }, + }); + return res; +} + +// Poll + complete until the agent's queue is empty. Returns every +// { job, complete } pair in the order the jobs were picked up. +export async function drainAgentJobs(client, agentKey, { maxRounds = 40 } = {}) { + const done = []; + for (let round = 0; round < maxRounds; round += 1) { + const job = await pollOnce(client, agentKey); + if (!job) return done; + const complete = await completeJob(client, agentKey, job); + if (complete.status !== 200) { + throw new Error(`complete for job ${job.id} failed: ${complete.status} ${complete.text}`); + } + done.push({ job, complete: complete.json }); + } + throw new Error(`agent queue did not drain within ${maxRounds} rounds`); +} + +// --------------------------------------------------------------------------- +// Subscription helpers +// --------------------------------------------------------------------------- + +// A base64 subscription body decodes to the raw URI list; comparing the +// decoded form is far more robust than comparing the opaque blob. +export function comparisonBody(caseKey, body) { + if (caseKey !== 'base64' && caseKey !== 'auto-generic') return body; + const compact = String(body).trim(); + if (!/^[A-Za-z0-9+/=]+$/.test(compact) || compact.length % 4 !== 0) return body; + try { + const decoded = Buffer.from(compact, 'base64').toString('utf8'); + return decoded.includes('://') ? decoded : body; + } catch { + return body; + } +} + +export async function fetchAllSubscriptions(client, token) { + const out = {}; + for (const c of SUB_CASES) { + const res = await client.getSubscription(token, c.format, c.ua); + out[c.key] = { + status: res.status, + contentType: res.contentType, + userinfo: res.userinfo, + body: comparisonBody(c.key, res.body), + }; + } + return out; +} + +// --------------------------------------------------------------------------- +// Learning normalizer +// --------------------------------------------------------------------------- + +const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc']; +const PREFIXED_ID_RE = new RegExp(`\\b(${ID_PREFIXES.join('|')})_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`, 'g'); +const BARE_UUID_RE = /(? placeholder category for high-entropy values that only +// ever appear under a recognizable field name. +const SECRET_FIELDS = { + subscriptionToken: 'subtoken', + agentKey: 'agentkey', + token: 'enrolltoken', + csrf: 'csrf', + passwordHash: 'hash', + tokenHash: 'hash', + keyHash: 'hash', + keyFingerprint: 'fingerprint', + clientKey: 'clientkey', + privateKey: 'rpriv', + publicKey: 'rpub', + realityPublicKey: 'rpub', + relayPassword: 'secret', + ssServerKey: 'secret', + wsPath: 'secret', + password: 'secret', + shortId: 'secret', + short_id: 'secret', +}; + +// Numeric fields whose values are randomly allocated ports. Only values +// under these field names are learned / replaced, so deterministic numbers +// (portRangeStart, usedBytes, traffic limits, ...) are never rewritten. +const PORT_FIELDS = new Set(['port', 'exitPort', 'listen_port', 'server_port']); + +export class Normalizer { + constructor({ panelOrigin = '' } = {}) { + this.panelOrigin = panelOrigin; + this.counters = new Map(); // category -> last n + this.secrets = new Map(); // raw string -> placeholder (field-learned) + this.ids = new Map(); // `${prefix}` -> Map(raw -> placeholder) + this.fragments = new Map(); // derived id fragment -> owning id placeholder + this.uuids = new Map(); // raw -> placeholder + this.ports = new Map(); // number -> placeholder + this.hashes = new Map(); // raw scrypt string -> placeholder + this.blobs = new Map(); // ss:// userinfo blob -> placeholder + } + + #assign(map, raw, label) { + let placeholder = map.get(raw); + if (!placeholder) { + const n = (this.counters.get(label) || 0) + 1; + this.counters.set(label, n); + placeholder = `<${label}>#${n}`; + map.set(raw, placeholder); + } + return placeholder; + } + + #idPlaceholder(prefix, raw) { + if (!this.ids.has(prefix)) this.ids.set(prefix, new Map()); + const placeholder = this.#assign(this.ids.get(prefix), raw, prefix); + // Derived forms: the panel builds sing-box tags from the node id with + // non-alphanumerics stripped, truncated to the last 18 chars + // (tagForNode -> "ng--in"). Register the fragment so those + // tags normalize through their owning id's placeholder. + if (prefix === 'nod') { + const fragment = raw.replace(/[^0-9a-z]/gi, '').slice(-18); + if (fragment && !this.fragments.has(fragment)) this.fragments.set(fragment, placeholder); + } + return placeholder; + } + + // Feed one raw step result into the learning tables. Object keys are + // walked in sorted order so the learning order does not depend on either + // implementation's serialization order; arrays keep their semantic order. + learn(value, keyName = null) { + if (Array.isArray(value)) { + for (const item of value) this.learn(item, keyName); + return; + } + if (value && typeof value === 'object') { + for (const key of Object.keys(value).sort()) this.learn(value[key], key); + return; + } + if (typeof value === 'number') { + if (keyName && PORT_FIELDS.has(keyName) && Number.isInteger(value) && value >= 1 && value <= 65535) { + this.#assign(this.ports, value, 'port'); + } + return; + } + if (typeof value !== 'string') return; + const category = SECRET_FIELDS[keyName]; + if (category && value.length >= 4) this.#assign(this.secrets, value, category); + // ws paths appear as transport `path` in job payloads. + if (keyName === 'path' && /^\/[0-9a-f]{12,}$/.test(value)) this.#assign(this.secrets, value, 'secret'); + for (const match of value.matchAll(PREFIXED_ID_RE)) this.#idPlaceholder(match[1], match[0]); + for (const match of value.matchAll(SCRYPT_RE)) this.#assign(this.hashes, match[0], 'hash'); + // Bare UUIDs are learned only when the whole value is one (credentials); + // embedded ones are assigned on the fly during normalizeString, in the + // same textual order on both sides. + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value)) { + this.#assign(this.uuids, value, 'uuid'); + } + } + + normalizeString(text) { + let out = String(text); + if (this.panelOrigin) out = out.split(this.panelOrigin).join(''); + // Learned secrets first (longest raw first so a full token wins over its + // own prefixes), including the 16-char prefix shown on the info page. + const secrets = [...this.secrets.entries()].sort((a, b) => b[0].length - a[0].length); + for (const [raw, placeholder] of secrets) { + if (raw.length >= 8) out = out.split(raw).join(placeholder); + if (raw.length > 16) out = out.split(raw.slice(0, 16)).join(placeholder); + } + for (const [raw, placeholder] of this.hashes) out = out.split(raw).join(placeholder); + out = out.replace(PREFIXED_ID_RE, (whole, prefix) => this.#idPlaceholder(prefix, whole)); + for (const [fragment, placeholder] of this.fragments) out = out.split(fragment).join(placeholder); + out = out.replace(BARE_UUID_RE, (whole) => this.#assign(this.uuids, whole, 'uuid')); + out = out.replace(SCRYPT_RE, (whole) => this.#assign(this.hashes, whole, 'hash')); + out = out.replace(ISO_TS_RE, ''); + out = out.replace(LOCALE_DATE_RE, ''); + out = out.replace(SS_URI_RE, (whole) => `ss://${this.#assign(this.blobs, whole, 'ssblob')}@`); + // Learned ports as standalone numbers, longest digit string first. + const ports = [...this.ports.entries()].sort((a, b) => String(b[0]).length - String(a[0]).length); + for (const [num, placeholder] of ports) { + out = out.replace(new RegExp(`(? this.normalize(item, keyName)); + if (value && typeof value === 'object') { + // Sorted-key traversal (like learn) so placeholder assignment order is + // canonical and independent of either side's serialization order. + // firstDiff compares objects key-insensitively, so reordering is safe. + const out = {}; + for (const key of Object.keys(value).sort()) out[this.normalizeString(key)] = this.normalize(value[key], key); + return out; + } + if (typeof value === 'number') { + if (keyName && PORT_FIELDS.has(keyName) && this.ports.has(value)) return this.ports.get(value); + return value; + } + if (typeof value === 'string') return this.normalizeString(value); + return value; + } + + // Full pipeline for one step result: learn, then normalize. + process(value) { + this.learn(value); + return this.normalize(value); + } + + normalizeText(text) { + return this.normalizeString(text); + } +} + +// --------------------------------------------------------------------------- +// Deep diff + reporter +// --------------------------------------------------------------------------- + +function summarize(value) { + let text; + try { text = JSON.stringify(value); } catch { text = String(value); } + if (text === undefined) text = String(value); + return text.length > 200 ? `${text.slice(0, 200)}…` : text; +} + +// Returns null when equal, otherwise the first difference found. +export function firstDiff(a, b, at = '$') { + if (Object.is(a, b)) return null; + if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') { + return { path: at, a: summarize(a), b: summarize(b) }; + } + if (Array.isArray(a) !== Array.isArray(b)) return { path: at, a: summarize(a), b: summarize(b) }; + if (Array.isArray(a)) { + if (a.length !== b.length) return { path: `${at}.length`, a: a.length, b: b.length }; + for (let i = 0; i < a.length; i += 1) { + const diff = firstDiff(a[i], b[i], `${at}[${i}]`); + if (diff) return diff; + } + return null; + } + const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort(); + for (const key of keys) { + if (!(key in a)) return { path: `${at}.${key}`, a: '', b: summarize(b[key]) }; + if (!(key in b)) return { path: `${at}.${key}`, a: summarize(a[key]), b: '' }; + const diff = firstDiff(a[key], b[key], `${at}.${key}`); + if (diff) return diff; + } + return null; +} + +export class Reporter { + constructor() { + this.total = 0; + this.passed = 0; + this.failed = 0; + this.skipped = 0; + this.failures = []; + } + + pass(name) { + this.total += 1; this.passed += 1; + console.log(`PASS ${name}`); + } + + fail(name, detail) { + this.total += 1; this.failed += 1; + this.failures.push({ name, detail }); + console.log(`FAIL ${name} — ${detail}`); + } + + skip(name, reason) { + this.total += 1; this.skipped += 1; + console.log(`SKIP ${name} — ${reason}`); + } + + // Compare two already-normalized values as one step. + compare(name, a, b) { + const diff = firstDiff(a, b); + if (!diff) { this.pass(name); return true; } + this.fail(name, `${diff.path}: A=${diff.a} B=${diff.b}`); + return false; + } + + summary(label) { + console.log(`SUMMARY ${label} total=${this.total} pass=${this.passed} fail=${this.failed} skip=${this.skipped}`); + } +} diff --git a/panel-go/difftest/run.mjs b/panel-go/difftest/run.mjs new file mode 100644 index 0000000..a1312ed --- /dev/null +++ b/panel-go/difftest/run.mjs @@ -0,0 +1,432 @@ +// Differential E2E runner: Node panel vs Go panel. +// +// node run.mjs auto: formal mode when a Go binary is +// resolvable, otherwise selftest + SKIP +// node run.mjs --selftest Node vs Node (validates the harness +// itself; must report SELFTEST PASS) +// node run.mjs --bin formal mode against this Go binary +// GO_PANEL_BIN= node run.mjs formal mode against $GO_PANEL_BIN +// node run.mjs --keep keep temp dirs for debugging +// +// Binary resolution order: --bin > $GO_PANEL_BIN > panel-go/ +// nexusgate-panel-linux-amd64 (built by the coordinator). When no binary +// exists, only the Node-vs-Node sanity runs and the formal mode is reported +// as SKIP (exit code still reflects the selftest result). +// +// Both sides start from byte-identical copies of seed.json (see seed.mjs; +// generated on demand when missing) and execute the same phase-2 script. +// Every response is fed through a per-side learning Normalizer (lib.mjs) +// before comparison, so random ids / tokens / ports / keys line up as +// #n placeholders. Failures never abort the run: every step is +// executed and the first differing JSON path is reported per step. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + ADMIN_USERNAME, DEFAULT_GO_BIN, DIFFTEST_DIR, Normalizer, PanelClient, + Reporter, UA_DEFAULT, completeJob, fetchAllSubscriptions, firstDiff, + freePort, makeTempDir, pollOnce, startPanel, stopPanel, waitHealth, +} from './lib.mjs'; +import { generateSeed } from './seed.mjs'; + +const view = (res) => ({ status: res.status, body: res.json !== null && res.json !== undefined ? res.json : res.text }); + +// --------------------------------------------------------------------------- +// Side lifecycle +// --------------------------------------------------------------------------- + +async function makeSide({ kind, bin, seedPath, label }) { + const tmpDir = makeTempDir(`nexusgate-diff-${label}-`); + const dataFile = path.join(tmpDir, 'nexusgate.json'); + fs.copyFileSync(seedPath, dataFile); + const port = await freePort(); + const proc = startPanel({ kind, bin, dataFile, port }); + await waitHealth(proc); + const side = { + label, kind, bin, tmpDir, dataFile, port, + proc, base: proc.base, + client: new PanelClient(proc.base), + norm: new Normalizer({ panelOrigin: proc.base }), + ids: {}, agentKeys: {}, subTokens: {}, pendingJob: null, + }; + return side; +} + +async function restartSide(side) { + await stopPanel(side.proc); + side.proc = startPanel({ kind: side.kind, bin: side.bin, dataFile: side.dataFile, port: side.port }); + side.base = side.proc.base; + await waitHealth(side.proc); + side.client = new PanelClient(side.base); + side.norm = new Normalizer({ panelOrigin: side.base }); + side.pendingJob = null; +} + +async function destroySide(side, keep) { + await stopPanel(side.proc).catch(() => {}); + if (!keep) fs.rmSync(side.tmpDir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// Step runner: executes the same async step body against both sides and +// compares the normalized results. +// --------------------------------------------------------------------------- + +class StepRunner { + constructor(reporter, sideA, sideB) { + this.reporter = reporter; + this.sideA = sideA; + this.sideB = sideB; + } + + async step(name, run) { + let ra; let rb; let ea = null; let eb = null; + try { ra = await run(this.sideA); } catch (error) { ea = error; } + try { rb = await run(this.sideB); } catch (error) { eb = error; } + if (ea || eb) { + this.reporter.fail(name, `exception A=${ea ? ea.message : '-'} B=${eb ? eb.message : '-'}`); + return { ra, rb }; + } + const na = this.sideA.norm.process(ra); + const nb = this.sideB.norm.process(rb); + this.reporter.compare(name, na, nb); + return { ra, rb }; + } +} + +// --------------------------------------------------------------------------- +// Phase 2 script (identical for selftest and formal mode) +// --------------------------------------------------------------------------- + +async function runPhase2(runner, { seedLog, seedExpect }) { + const seedIds = seedLog.meta.ids; + + // Seed-state wiring: raw ids and agent keys are identical on both sides + // because both DBs are copies of the same seed file. + for (const side of [runner.sideA, runner.sideB]) { + side.ids.entryServer = seedIds.entryServer; + side.ids.exitServer = seedIds.exitServer; + side.ids.n1 = seedIds.n1; + side.ids.n2 = seedIds.n2; + side.agentKeys.entry = seedLog.meta.agentKeys.entry; + side.agentKeys.exit = seedLog.meta.agentKeys.exit; + } + + await runner.step('p2/login', async (side) => view(await side.client.login())); + await runner.step('p2/session', async (side) => view(await side.client.admin('GET', '/api/session'))); + await runner.step('p2/heartbeat-seed-agents', async (side) => { + const entry = await side.client.agentPost('/api/agent/heartbeat', side.agentKeys.entry, { version: '0.6.8', system: {} }); + const exit = await side.client.agentPost('/api/agent/heartbeat', side.agentKeys.exit, { version: '0.6.8', system: {} }); + return { entry: view(entry), exit: view(exit) }; + }); + await runner.step('p2/overview', async (side) => view(await side.client.admin('GET', '/api/overview'))); + await runner.step('p2/list-servers', async (side) => view(await side.client.admin('GET', '/api/servers'))); + await runner.step('p2/list-customers', async (side) => { + const res = await side.client.admin('GET', '/api/customers'); + for (const customer of res.json.customers) { + const logical = customer.name.toLowerCase(); + side.ids[logical] = customer.id; + side.subTokens[logical] = customer.subscriptionToken; + } + return view(res); + }); + await runner.step('p2/list-nodes', async (side) => view(await side.client.admin('GET', '/api/nodes'))); + await runner.step('p2/list-jobs', async (side) => view(await side.client.admin('GET', '/api/jobs'))); + + await runner.step('p2/create-server-s3', async (side) => { + const res = await side.client.admin('POST', '/api/servers', { + name: 'S3', role: 'relay', region: '', publicAddress: 's3.example.com', + publicAddressV6: '', tlsDomain: '', portRangeStart: 20000, portRangeEnd: 50000, + }); + if (res.json && res.json.server) side.ids.s3 = res.json.server.id; + return view(res); + }); + await runner.step('p2/enroll-s3', async (side) => { + const ticket = await side.client.admin('POST', `/api/servers/${side.ids.s3}/enrollment-token`, {}); + const enrolled = await side.client.request('POST', '/api/agent/enroll', { + body: { token: ticket.json.token, hostname: 'diff-agent-s3', version: '0.6.8', system: {} }, + cookie: null, + }); + if (enrolled.json && enrolled.json.agentKey) side.agentKeys.s3 = enrolled.json.agentKey; + return { ticket: view(ticket), enroll: view(enrolled) }; + }); + await runner.step('p2/create-customer-carol', async (side) => { + const res = await side.client.admin('POST', '/api/customers', { name: 'Carol' }); + if (res.json && res.json.customer) { + side.ids.carol = res.json.customer.id; + side.subTokens.carol = res.json.customer.subscriptionToken; + } + return view(res); + }); + await runner.step('p2/create-node-n3', async (side) => { + const res = await side.client.admin('POST', '/api/nodes', { + name: 'N3', serverId: side.ids.s3, topology: 'direct', + protocol: 'vless-reality', realityServerName: 'www.amd.com', + }); + if (res.json && res.json.node) side.ids.n3 = res.json.node.id; + return view(res); + }); + await runner.step('p2/assign-n3-carol', async (side) => + view(await side.client.admin('POST', `/api/nodes/${side.ids.n3}/assign`, { customerId: side.ids.carol }))); + await runner.step('p2/deploy-n3', async (side) => + view(await side.client.admin('POST', `/api/nodes/${side.ids.n3}/deploy`, {}))); + await runner.step('p2/poll-n3-apply', async (side) => { + const job = await pollOnce(side.client, side.agentKeys.s3); + side.pendingJob = job; + return { job }; + }); + await runner.step('p2/complete-n3-apply', async (side) => { + if (!side.pendingJob) return { skipped: 'no job was polled' }; + return view(await completeJob(side.client, side.agentKeys.s3, side.pendingJob)); + }); + await runner.step('p2/list-nodes-n3-active', async (side) => view(await side.client.admin('GET', '/api/nodes'))); + + await runner.step('p2/carol-subscriptions', async (side) => + fetchAllSubscriptions(side.client, side.subTokens.carol)); + await runner.step('p2/alice-subscriptions', async (side) => { + const subs = await fetchAllSubscriptions(side.client, side.subTokens.alice); + // Bodies must also match the golden seed expectations byte-for-byte + // (comparison form): both DBs are copies of the seed, and nothing in + // phase 2 up to this point changes Alice's node credentials. The + // subscription-userinfo header is excluded here because the seed + // capture predates the seed usage report. + const rawEqSeed = {}; + for (const [key, sub] of Object.entries(subs)) { + const expected = seedExpect.alice[key]; + rawEqSeed[key] = Boolean(expected) && sub.status === expected.status && sub.body === expected.body; + } + return { ...subs, rawEqSeed }; + }); + + await runner.step('p2/usage-report-alice', async (side) => + view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, { + epoch: 'diff-phase2-epoch-1', + samples: [{ kind: 'user', id: side.ids.alice, uplink: 500, downlink: 700 }], + }))); + await runner.step('p2/list-customers-after-usage', async (side) => + view(await side.client.admin('GET', '/api/customers'))); + + // Heartbeat reconciliation: the S3 agent reports holding no resources, + // so the panel must re-queue the apply for N3 (which it believes is + // deployed there) with an equivalent payload. + await runner.step('p2/heartbeat-s3-reconcile', async (side) => + view(await side.client.agentPost('/api/agent/heartbeat', side.agentKeys.s3, { + version: '0.6.8', system: {}, resources: [], + }))); + await runner.step('p2/poll-n3-reconciled-apply', async (side) => { + const job = await pollOnce(side.client, side.agentKeys.s3); + side.pendingJob = job; + return { job }; + }); + await runner.step('p2/complete-n3-reconciled', async (side) => { + if (!side.pendingJob) return { skipped: 'no reconciled job was polled' }; + return view(await completeJob(side.client, side.agentKeys.s3, side.pendingJob)); + }); + + // Remove N3: undeploy -> agent polls the delete job -> complete. + await runner.step('p2/remove-n3', async (side) => + view(await side.client.admin('POST', `/api/nodes/${side.ids.n3}/remove`, {}))); + await runner.step('p2/poll-n3-delete', async (side) => { + const job = await pollOnce(side.client, side.agentKeys.s3); + side.pendingJob = job; + return { job }; + }); + await runner.step('p2/complete-n3-delete', async (side) => { + if (!side.pendingJob) return { skipped: 'no delete job was polled' }; + return view(await completeJob(side.client, side.agentKeys.s3, side.pendingJob)); + }); + await runner.step('p2/list-nodes-after-remove', async (side) => view(await side.client.admin('GET', '/api/nodes'))); + + // S3 forget flow: an online server cannot be forgotten (409), before or + // after its node association is deleted; the name confirmation is + // validated too (400). Cleanup finishes through the normal DELETE route. + // (A successful forget requires the server to be offline, which the + // panel only derives after >180s without a heartbeat — not reachable + // inside this run; see README.) + await runner.step('p2/forget-s3-online-with-node', async (side) => + view(await side.client.admin('POST', `/api/servers/${side.ids.s3}/forget`, { confirm: 'FORGET', name: 'S3' }))); + await runner.step('p2/delete-node-n3', async (side) => + view(await side.client.admin('DELETE', `/api/nodes/${side.ids.n3}`))); + await runner.step('p2/forget-s3-wrong-name', async (side) => + view(await side.client.admin('POST', `/api/servers/${side.ids.s3}/forget`, { confirm: 'FORGET', name: 'NotS3' }))); + await runner.step('p2/forget-s3-online', async (side) => + view(await side.client.admin('POST', `/api/servers/${side.ids.s3}/forget`, { confirm: 'FORGET', name: 'S3' }))); + await runner.step('p2/delete-server-s3', async (side) => + view(await side.client.admin('DELETE', `/api/servers/${side.ids.s3}`))); + await runner.step('p2/list-servers-final', async (side) => view(await side.client.admin('GET', '/api/servers'))); + + await runner.step('p2/backup', async (side) => { + const res = await side.client.admin('GET', '/api/backup'); + let parsed = null; + try { parsed = JSON.parse(res.text); } catch { /* reported via status/body below */ } + return { status: res.status, body: parsed !== null ? parsed : res.text }; + }); + + // --- error cases --------------------------------------------------------- + await runner.step('err/unauthenticated-401', async (side) => + view(await side.client.request('GET', '/api/customers', { cookie: null }))); + await runner.step('err/csrf-missing-403', async (side) => + view(await side.client.admin('POST', '/api/customers', { name: 'ShouldNotExist' }, { csrf: false }))); + await runner.step('err/login-failed-401', async (side) => + view(await side.client.request('POST', '/api/auth/login', { + body: { username: ADMIN_USERNAME, password: 'wrong-password' }, cookie: null, + }))); + await runner.step('err/node-not-found-404', async (side) => + view(await side.client.admin('POST', '/api/nodes/nod_00000000-0000-0000-0000-000000000000/deploy', {}))); + await runner.step('err/subscription-bad-token-404', async (side) => { + const res = await side.client.getSubscription('A'.repeat(43), 'raw', UA_DEFAULT); + return { status: res.status, body: res.body }; + }); + await runner.step('err/subscription-info-page', async (side) => { + const res = await side.client.getSubscription(side.subTokens.alice, 'info', UA_DEFAULT); + return { status: res.status, body: res.body }; + }); +} + +// --------------------------------------------------------------------------- +// DB interoperability: swap the two panels' DB files and keep working. +// --------------------------------------------------------------------------- + +async function runInterop(reporter, sideA, sideB, { seedExpect }) { + const statsRunner = new StepRunner(reporter, sideA, sideB); + await statsRunner.step('interop/db-stats-before-swap', async (side) => { + const db = JSON.parse(fs.readFileSync(side.dataFile, 'utf8')); + const alice = db.customers.find((item) => item.name === 'Alice'); + return { + keyStoreCount: Object.keys(db.keyStore || {}).length, + jobsCount: db.jobs.length, + aliceUsedBytes: alice ? alice.usedBytes : null, + }; + }); + + // Swap the database files and restart each implementation on the other's + // data. Fresh clients + normalizers: appearance order restarts here. + const aRaw = fs.readFileSync(sideA.dataFile); + const bRaw = fs.readFileSync(sideB.dataFile); + await stopPanel(sideA.proc); + await stopPanel(sideB.proc); + fs.writeFileSync(sideA.dataFile, bRaw); + fs.writeFileSync(sideB.dataFile, aRaw); + await restartSide(sideA); + await restartSide(sideB); + + const runner = new StepRunner(reporter, sideA, sideB); + await runner.step('interop/login', async (side) => view(await side.client.login())); + await runner.step('interop/list-servers', async (side) => view(await side.client.admin('GET', '/api/servers'))); + await runner.step('interop/list-customers', async (side) => { + const res = await side.client.admin('GET', '/api/customers'); + for (const customer of res.json.customers) { + const logical = customer.name.toLowerCase(); + side.ids[logical] = customer.id; + side.subTokens[logical] = customer.subscriptionToken; + } + return view(res); + }); + await runner.step('interop/list-nodes', async (side) => view(await side.client.admin('GET', '/api/nodes'))); + await runner.step('interop/list-jobs', async (side) => view(await side.client.admin('GET', '/api/jobs'))); + await runner.step('interop/alice-subscriptions', async (side) => { + const subs = await fetchAllSubscriptions(side.client, side.subTokens.alice); + const rawEqSeed = {}; + for (const [key, sub] of Object.entries(subs)) { + const expected = seedExpect.alice[key]; + rawEqSeed[key] = Boolean(expected) && sub.status === expected.status && sub.body === expected.body; + } + return { ...subs, rawEqSeed }; + }); + await runner.step('interop/patch-alice-notes', async (side) => + view(await side.client.admin('PATCH', `/api/customers/${side.ids.alice}`, { notes: 'interop-ok' }))); + await runner.step('interop/list-customers-after-notes', async (side) => + view(await side.client.admin('GET', '/api/customers'))); +} + +// --------------------------------------------------------------------------- +// Modes +// --------------------------------------------------------------------------- + +async function runPair({ reporter, kindA, kindB, bin, seedPath, seedLog, seedExpect, keep, label }) { + let sideA = null; + let sideB = null; + try { + sideA = await makeSide({ kind: kindA, bin, seedPath, label: `${label}-a` }); + sideB = await makeSide({ kind: kindB, bin, seedPath, label: `${label}-b` }); + } catch (error) { + reporter.fail(`setup/${label}`, error.message); + if (sideA) await destroySide(sideA, keep); + if (sideB) await destroySide(sideB, keep); + return false; + } + const failuresBefore = reporter.failed; + try { + const runner = new StepRunner(reporter, sideA, sideB); + await runPhase2(runner, { seedLog, seedExpect }); + await runInterop(reporter, sideA, sideB, { seedExpect }); + } finally { + await destroySide(sideA, keep); + await destroySide(sideB, keep); + } + return reporter.failed === failuresBefore; +} + +async function main() { + const args = process.argv.slice(2); + let argBin = null; + let seedPath = path.join(DIFFTEST_DIR, 'seed.json'); + let forceSelftest = false; + let keep = false; + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--bin' && args[i + 1]) { argBin = args[i + 1]; i += 1; } + else if (args[i] === '--seed' && args[i + 1]) { seedPath = path.resolve(args[i + 1]); i += 1; } + else if (args[i] === '--selftest') forceSelftest = true; + else if (args[i] === '--keep') keep = true; + } + const goBin = argBin || process.env.GO_PANEL_BIN || DEFAULT_GO_BIN; + const goBinExists = fs.existsSync(goBin); + + if (!fs.existsSync(seedPath)) { + console.log(`[run] seed not found at ${seedPath}; generating it with the Node panel first`); + await generateSeed({ outDir: path.dirname(seedPath), quiet: false }); + } + const seedLog = JSON.parse(fs.readFileSync(path.join(path.dirname(seedPath), 'seed-log.json'), 'utf8')); + const seedExpect = JSON.parse(fs.readFileSync(path.join(path.dirname(seedPath), 'seed-expect.json'), 'utf8')); + + const reporter = new Reporter(); + let selftestOk = null; + let formalOk = null; + + if (forceSelftest || !goBinExists) { + console.log('[run] SELFTEST: Node vs Node (same seed, same phase-2 script)'); + selftestOk = await runPair({ + reporter, kindA: 'node', kindB: 'node', seedPath, seedLog, seedExpect, keep, label: 'self', + }); + reporter.summary('selftest'); + console.log(`SELFTEST ${selftestOk ? 'PASS' : 'FAIL'}`); + } + + if (!forceSelftest) { + if (goBinExists) { + console.log(`[run] FORMAL: Node vs Go (${goBin})`); + formalOk = await runPair({ + reporter, kindA: 'node', kindB: 'go', bin: goBin, seedPath, seedLog, seedExpect, keep, label: 'formal', + }); + reporter.summary('formal'); + console.log(`FORMAL ${formalOk ? 'PASS' : 'FAIL'}`); + } else { + reporter.skip('formal/go-panel', `Go panel binary not found at ${goBin} (pass --bin or set GO_PANEL_BIN); ran Node-vs-Node selftest only`); + console.log(`FORMAL SKIP (Go panel binary not found at ${goBin})`); + reporter.summary('formal'); + } + } + + const failed = reporter.failed > 0 || selftestOk === false || formalOk === false; + process.exitCode = failed ? 1 : 0; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} + +export { firstDiff }; diff --git a/panel-go/difftest/seed-expect.json b/panel-go/difftest/seed-expect.json new file mode 100644 index 0000000..a34c539 --- /dev/null +++ b/panel-go/difftest/seed-expect.json @@ -0,0 +1,52 @@ +{ + "alice": { + "raw": { + "status": 200, + "contentType": "text/plain; charset=utf-8", + "userinfo": "upload=0; download=0; total=0; expire=0", + "body": "vless://631268da-ea76-479c-b9f6-9cf2c57825f6@entry.example.com:34035?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg&sid=45584502b5567121&type=tcp&headerType=none&flow=xtls-rprx-vision#N1\nss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206eE9jQjdYR2dvNlZwYUVIWmZOWHJsUT09OnpzRlFyTEEzMWNITTFVU1FWZ0h1WHc9PQ@entry.example.com:29256#N2\n" + }, + "base64": { + "status": 200, + "contentType": "text/plain; charset=utf-8", + "userinfo": "upload=0; download=0; total=0; expire=0", + "body": "vless://631268da-ea76-479c-b9f6-9cf2c57825f6@entry.example.com:34035?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg&sid=45584502b5567121&type=tcp&headerType=none&flow=xtls-rprx-vision#N1\nss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206eE9jQjdYR2dvNlZwYUVIWmZOWHJsUT09OnpzRlFyTEEzMWNITTFVU1FWZ0h1WHc9PQ@entry.example.com:29256#N2\n" + }, + "clash": { + "status": 200, + "contentType": "text/yaml; charset=utf-8", + "userinfo": "upload=0; download=0; total=0; expire=0", + "body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"N1\"\n server: \"entry.example.com\"\n port: 34035\n type: \"vless\"\n uuid: \"631268da-ea76-479c-b9f6-9cf2c57825f6\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg\"\n short-id: \"45584502b5567121\"\n flow: \"xtls-rprx-vision\"\n - name: \"N2\"\n server: \"entry.example.com\"\n port: 29256\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"xOcB7XGgo6VpaEHZfNXrlQ==:zsFQrLA31cHM1USQVgHuXw==\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"N1\"\n - \"N2\"\nrules:\n - \"MATCH,节点选择\"\n" + }, + "clash-smart": { + "status": 200, + "contentType": "text/yaml; charset=utf-8", + "userinfo": "upload=0; download=0; total=0; expire=0", + "body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"N1\"\n server: \"entry.example.com\"\n port: 34035\n type: \"vless\"\n uuid: \"631268da-ea76-479c-b9f6-9cf2c57825f6\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg\"\n short-id: \"45584502b5567121\"\n flow: \"xtls-rprx-vision\"\n - name: \"N2\"\n server: \"entry.example.com\"\n port: 29256\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"xOcB7XGgo6VpaEHZfNXrlQ==:zsFQrLA31cHM1USQVgHuXw==\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"自动选择\"\n - \"N1\"\n - \"N2\"\n - \"DIRECT\"\n - name: \"自动选择\"\n type: url-test\n url: \"https://www.gstatic.com/generate_204\"\n interval: 300\n tolerance: 80\n proxies:\n - \"N1\"\n - \"N2\"\n - name: \"流媒体\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"N1\"\n - \"N2\"\n - name: \"AI 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"N1\"\n - \"N2\"\n - name: \"Meta 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"N1\"\n - \"N2\"\nrules:\n - \"GEOSITE,category-ads-all,REJECT\"\n - \"GEOSITE,private,DIRECT\"\n - \"GEOSITE,openai,AI 服务\"\n - \"GEOSITE,netflix,流媒体\"\n - \"GEOSITE,facebook,Meta 服务\"\n - \"DOMAIN-SUFFIX,fbcdn.net,Meta 服务\"\n - \"DOMAIN-SUFFIX,instagram.com,Meta 服务\"\n - \"DOMAIN-SUFFIX,whatsapp.net,Meta 服务\"\n - \"GEOSITE,geolocation-!cn,节点选择\"\n - \"GEOSITE,cn,DIRECT\"\n - \"GEOIP,CN,DIRECT\"\n - \"MATCH,节点选择\"\n" + }, + "singbox": { + "status": 200, + "contentType": "application/json; charset=utf-8", + "userinfo": "upload=0; download=0; total=0; expire=0", + "body": "{\n \"log\": {\n \"level\": \"warn\"\n },\n \"inbounds\": [\n {\n \"type\": \"mixed\",\n \"tag\": \"local\",\n \"listen\": \"127.0.0.1\",\n \"listen_port\": 2080\n }\n ],\n \"outbounds\": [\n {\n \"type\": \"selector\",\n \"tag\": \"proxy\",\n \"outbounds\": [\n \"node-1\",\n \"node-2\"\n ]\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-1\",\n \"server\": \"entry.example.com\",\n \"server_port\": 34035,\n \"uuid\": \"631268da-ea76-479c-b9f6-9cf2c57825f6\",\n \"flow\": \"xtls-rprx-vision\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"www.amd.com\",\n \"reality\": {\n \"enabled\": true,\n \"public_key\": \"QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg\",\n \"short_id\": \"45584502b5567121\"\n }\n }\n },\n {\n \"type\": \"shadowsocks\",\n \"tag\": \"node-2\",\n \"server\": \"entry.example.com\",\n \"server_port\": 29256,\n \"method\": \"2022-blake3-aes-128-gcm\",\n \"password\": \"xOcB7XGgo6VpaEHZfNXrlQ==:zsFQrLA31cHM1USQVgHuXw==\"\n },\n {\n \"type\": \"direct\",\n \"tag\": \"direct\"\n }\n ],\n \"route\": {\n \"final\": \"proxy\"\n }\n}\n" + }, + "surge": { + "status": 200, + "contentType": "text/plain; charset=utf-8", + "userinfo": "upload=0; download=0; total=0; expire=0", + "body": "[General]\nloglevel = notify\n[Proxy]\nN2 = ss, entry.example.com, 29256, encrypt-method=2022-blake3-aes-128-gcm, password=xOcB7XGgo6VpaEHZfNXrlQ__:zsFQrLA31cHM1USQVgHuXw__\n[Proxy Group]\n节点选择 = select, N2, DIRECT\n自动选择 = url-test, N2, url=http://www.gstatic.com/generate_204, interval=600\n[Rule]\nGEOIP,CN,DIRECT\nFINAL,节点选择\n" + }, + "auto-clash": { + "status": 200, + "contentType": "text/yaml; charset=utf-8", + "userinfo": "upload=0; download=0; total=0; expire=0", + "body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"N1\"\n server: \"entry.example.com\"\n port: 34035\n type: \"vless\"\n uuid: \"631268da-ea76-479c-b9f6-9cf2c57825f6\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg\"\n short-id: \"45584502b5567121\"\n flow: \"xtls-rprx-vision\"\n - name: \"N2\"\n server: \"entry.example.com\"\n port: 29256\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"xOcB7XGgo6VpaEHZfNXrlQ==:zsFQrLA31cHM1USQVgHuXw==\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"N1\"\n - \"N2\"\nrules:\n - \"MATCH,节点选择\"\n" + }, + "auto-generic": { + "status": 200, + "contentType": "text/plain; charset=utf-8", + "userinfo": "upload=0; download=0; total=0; expire=0", + "body": "vless://631268da-ea76-479c-b9f6-9cf2c57825f6@entry.example.com:34035?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg&sid=45584502b5567121&type=tcp&headerType=none&flow=xtls-rprx-vision#N1\nss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206eE9jQjdYR2dvNlZwYUVIWmZOWHJsUT09OnpzRlFyTEEzMWNITTFVU1FWZ0h1WHc9PQ@entry.example.com:29256#N2\n" + } + } +} diff --git a/panel-go/difftest/seed-log.json b/panel-go/difftest/seed-log.json new file mode 100644 index 0000000..fd64fee --- /dev/null +++ b/panel-go/difftest/seed-log.json @@ -0,0 +1,1124 @@ +{ + "meta": { + "generatedAt": "2026-10-20T08:13:19.724Z", + "adminUsername": "admin", + "adminPassword": "diff-admin-pass", + "ids": { + "entryServer": "srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e", + "exitServer": "srv_67549f54-3db9-449a-93f8-57561930ac40", + "entryAgent": "agt_3e46d79e-f5a2-4740-bcb8-62c62c6fea81", + "exitAgent": "agt_fde3090a-2f91-4d6a-bb0d-f5a37558bbd7", + "alice": "cus_a4dca60e-fd93-431c-9003-a18f7e2919ee", + "bob": "cus_f109881a-aacf-4fba-846d-aee5ce8f22c1", + "n1": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "n2": "nod_bd819b9a-6f85-41fd-9270-4235daa4f20f" + }, + "agentKeys": { + "entry": "Ufhe2SW3_TNY9sa7aQSdj9wLyPtniU9ZT7uBZeZJfdgTIzYpcCuvqQ", + "exit": "PL5c1FYaxduu5MIB9gLy32VWwXhdvyfTc6TN2HI5hi6GV47TjfW0uQ" + }, + "seedUsage": { + "epoch": "diff-epoch-1", + "user": { + "uplink": 1000, + "downlink": 2000 + }, + "inbound": { + "uplink": 4000, + "downlink": 5000 + }, + "aliceIp": "203.0.113.10" + } + }, + "steps": [ + { + "name": "login", + "request": { + "method": "POST", + "pathname": "/api/auth/login", + "body": { + "username": "admin" + } + }, + "response": { + "status": 200, + "body": { + "user": { + "id": "usr_19357193-8c21-4854-820a-f8d70389f783", + "username": "admin", + "role": "owner" + }, + "csrf": "xN0ahW9N9qIIP9IgHJPzNthIl70jpFf3" + } + } + }, + { + "name": "create-server-entry", + "request": { + "method": "POST", + "pathname": "/api/servers", + "body": { + "name": "Entry", + "role": "relay", + "region": "", + "publicAddress": "entry.example.com", + "publicAddressV6": "v6.example.com", + "tlsDomain": "tls.example.com", + "portRangeStart": 20000, + "portRangeEnd": 50000 + } + }, + "response": { + "status": 201, + "body": { + "server": { + "name": "Entry", + "role": "relay", + "region": "", + "publicAddress": "entry.example.com", + "publicAddressV6": "v6.example.com", + "tlsDomain": "tls.example.com", + "portRangeStart": 20000, + "portRangeEnd": 50000, + "labels": [], + "id": "srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e", + "status": "pending", + "createdAt": "2026-10-20T08:13:15.284Z", + "updatedAt": "2026-10-20T08:13:15.284Z" + } + } + } + }, + { + "name": "create-server-exit", + "request": { + "method": "POST", + "pathname": "/api/servers", + "body": { + "name": "Exit", + "role": "exit", + "region": "", + "publicAddress": "exit.example.com", + "publicAddressV6": "", + "tlsDomain": "", + "portRangeStart": 20000, + "portRangeEnd": 50000 + } + }, + "response": { + "status": 201, + "body": { + "server": { + "name": "Exit", + "role": "exit", + "region": "", + "publicAddress": "exit.example.com", + "publicAddressV6": "", + "tlsDomain": "", + "portRangeStart": 20000, + "portRangeEnd": 50000, + "labels": [], + "id": "srv_67549f54-3db9-449a-93f8-57561930ac40", + "status": "pending", + "createdAt": "2026-10-20T08:13:15.306Z", + "updatedAt": "2026-10-20T08:13:15.306Z" + } + } + } + }, + { + "name": "enrollment-token-entry", + "request": { + "method": "POST", + "pathname": "/api/servers/srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e/enrollment-token", + "body": {} + }, + "response": { + "status": 201, + "body": { + "token": "ikyYP4mbpp3t6E0QrR5J8p5GItq__4rlShhFmva5", + "expiresAt": "2026-10-20T08:43:15.583Z" + } + } + }, + { + "name": "enroll-entry", + "request": { + "method": "POST", + "pathname": "/api/agent/enroll" + }, + "response": { + "status": 201, + "body": { + "agentId": "agt_3e46d79e-f5a2-4740-bcb8-62c62c6fea81", + "serverId": "srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e", + "serverName": "Entry", + "agentKey": "Ufhe2SW3_TNY9sa7aQSdj9wLyPtniU9ZT7uBZeZJfdgTIzYpcCuvqQ", + "reconciled": 0 + } + } + }, + { + "name": "heartbeat-entry", + "request": { + "method": "POST", + "pathname": "/api/agent/heartbeat" + }, + "response": { + "status": 200, + "body": { + "ok": true, + "serverTime": "2026-10-20T08:13:16.124Z" + } + } + }, + { + "name": "enrollment-token-exit", + "request": { + "method": "POST", + "pathname": "/api/servers/srv_67549f54-3db9-449a-93f8-57561930ac40/enrollment-token", + "body": {} + }, + "response": { + "status": 201, + "body": { + "token": "3QhbJ_ToVzXputaGOUc_bqzIo9TYo9Yc8kKRuBx2", + "expiresAt": "2026-10-20T08:43:16.550Z" + } + } + }, + { + "name": "enroll-exit", + "request": { + "method": "POST", + "pathname": "/api/agent/enroll" + }, + "response": { + "status": 201, + "body": { + "agentId": "agt_fde3090a-2f91-4d6a-bb0d-f5a37558bbd7", + "serverId": "srv_67549f54-3db9-449a-93f8-57561930ac40", + "serverName": "Exit", + "agentKey": "PL5c1FYaxduu5MIB9gLy32VWwXhdvyfTc6TN2HI5hi6GV47TjfW0uQ", + "reconciled": 0 + } + } + }, + { + "name": "heartbeat-exit", + "request": { + "method": "POST", + "pathname": "/api/agent/heartbeat" + }, + "response": { + "status": 200, + "body": { + "ok": true, + "serverTime": "2026-10-20T08:13:17.497Z" + } + } + }, + { + "name": "create-customer-alice", + "request": { + "method": "POST", + "pathname": "/api/customers", + "body": { + "name": "Alice" + } + }, + "response": { + "status": 201, + "body": { + "customer": { + "id": "cus_a4dca60e-fd93-431c-9003-a18f7e2919ee", + "subscriptionToken": "RVQBS6ONsmknOHquaj75fFGAkdCHFra6VY6WV4DDRSI", + "name": "Alice", + "group": "", + "status": "active", + "trafficLimitBytes": 0, + "usedBytes": 0, + "usedUplinkBytes": 0, + "usedDownlinkBytes": 0, + "expiresAt": null, + "ipLimit": 0, + "deviceLimit": 0, + "tags": [], + "notes": "", + "createdAt": "2026-10-20T08:13:17.517Z", + "updatedAt": "2026-10-20T08:13:17.517Z" + } + } + } + }, + { + "name": "create-customer-bob", + "request": { + "method": "POST", + "pathname": "/api/customers", + "body": { + "name": "Bob", + "trafficLimitBytes": 1073741824, + "ipLimit": 5, + "deviceLimit": 2 + } + }, + "response": { + "status": 201, + "body": { + "customer": { + "id": "cus_f109881a-aacf-4fba-846d-aee5ce8f22c1", + "subscriptionToken": "8ZyAzUjXF-OdPPuu5t4qZHkTdfX-z-0AYs8V0WqFwIg", + "name": "Bob", + "group": "", + "status": "active", + "trafficLimitBytes": 1073741824, + "usedBytes": 0, + "usedUplinkBytes": 0, + "usedDownlinkBytes": 0, + "expiresAt": null, + "ipLimit": 5, + "deviceLimit": 2, + "tags": [], + "notes": "", + "createdAt": "2026-10-20T08:13:17.529Z", + "updatedAt": "2026-10-20T08:13:17.529Z" + } + } + } + }, + { + "name": "create-node-n1", + "request": { + "method": "POST", + "pathname": "/api/nodes", + "body": { + "name": "N1", + "serverId": "srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e", + "topology": "forward", + "protocol": "vless-reality-vision", + "exitServerId": "srv_67549f54-3db9-449a-93f8-57561930ac40", + "exitProtocol": "shadowsocks-2022-aes256", + "realityServerName": "www.amd.com" + } + }, + "response": { + "status": 201, + "body": { + "node": { + "id": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "status": "draft", + "entryReady": false, + "exitReady": false, + "resourceId": "res_b28d761d-716d-4132-b6e2-250d606ec2e6", + "exitResourceId": "res_c10f3ebc-ca98-4498-bef3-a9d9951db4b0", + "name": "N1", + "serverId": "srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e", + "protocol": "vless-reality-vision", + "topology": "forward", + "exitServerId": "srv_67549f54-3db9-449a-93f8-57561930ac40", + "exitProtocol": "shadowsocks-2022-aes256", + "networkMode": "ipv4", + "realityServerName": "www.amd.com", + "realityDestPort": 443, + "portMode": "random", + "port": 34035, + "exitPortMode": "random", + "exitPort": 25230, + "artifacts": {}, + "error": null, + "pendingDelete": false, + "usageCounters": null, + "retiredUsageEpochs": [], + "meteredTraffic": null, + "lastUsageAt": null, + "createdAt": "2026-10-20T08:13:17.571Z", + "updatedAt": "2026-10-20T08:13:17.571Z", + "wsPath": "/dcc7c32a84832fc03a", + "reality": { + "keyId": "reality-nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "serverName": "www.amd.com", + "destPort": 443 + } + } + } + } + }, + { + "name": "create-node-n2", + "request": { + "method": "POST", + "pathname": "/api/nodes", + "body": { + "name": "N2", + "serverId": "srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e", + "topology": "direct", + "protocol": "shadowsocks-2022-aes128" + } + }, + "response": { + "status": 201, + "body": { + "node": { + "id": "nod_bd819b9a-6f85-41fd-9270-4235daa4f20f", + "status": "draft", + "entryReady": false, + "exitReady": false, + "resourceId": "res_1f7d0d11-f13a-4b8d-9332-9b8dd857c2c4", + "exitResourceId": "res_0927c2fe-8b8d-4ff4-9d61-c453961c46aa", + "name": "N2", + "serverId": "srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e", + "protocol": "shadowsocks-2022-aes128", + "topology": "direct", + "exitServerId": null, + "exitProtocol": null, + "networkMode": "ipv4", + "realityServerName": "www.tesla.com", + "realityDestPort": 443, + "portMode": "random", + "port": 29256, + "exitPortMode": null, + "exitPort": null, + "artifacts": {}, + "error": null, + "pendingDelete": false, + "usageCounters": null, + "retiredUsageEpochs": [], + "meteredTraffic": null, + "lastUsageAt": null, + "createdAt": "2026-10-20T08:13:17.590Z", + "updatedAt": "2026-10-20T08:13:17.590Z", + "wsPath": "/a655ba3a14d606bbe3" + } + } + } + }, + { + "name": "assign-n1-alice", + "request": { + "method": "POST", + "pathname": "/api/nodes/nod_35de3d63-181c-4966-a483-9ae5ac40c81c/assign", + "body": { + "customerId": "cus_a4dca60e-fd93-431c-9003-a18f7e2919ee" + } + }, + "response": { + "status": 200, + "body": { + "nodeClient": { + "id": "ncl_efa94560-7678-4232-8609-c2e077fd35e2", + "nodeId": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "customerId": "cus_a4dca60e-fd93-431c-9003-a18f7e2919ee", + "status": "active", + "createdAt": "2026-10-20T08:13:17.615Z", + "updatedAt": "2026-10-20T08:13:17.615Z" + } + } + } + }, + { + "name": "assign-n1-bob", + "request": { + "method": "POST", + "pathname": "/api/nodes/nod_35de3d63-181c-4966-a483-9ae5ac40c81c/assign", + "body": { + "customerId": "cus_f109881a-aacf-4fba-846d-aee5ce8f22c1" + } + }, + "response": { + "status": 200, + "body": { + "nodeClient": { + "id": "ncl_8ac57eff-cc72-4280-9c83-be338cdfb1e2", + "nodeId": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "customerId": "cus_f109881a-aacf-4fba-846d-aee5ce8f22c1", + "status": "active", + "createdAt": "2026-10-20T08:13:17.625Z", + "updatedAt": "2026-10-20T08:13:17.625Z" + } + } + } + }, + { + "name": "assign-n2-alice", + "request": { + "method": "POST", + "pathname": "/api/nodes/nod_bd819b9a-6f85-41fd-9270-4235daa4f20f/assign", + "body": { + "customerId": "cus_a4dca60e-fd93-431c-9003-a18f7e2919ee" + } + }, + "response": { + "status": 200, + "body": { + "nodeClient": { + "id": "ncl_a8622a82-296c-452e-a2b5-e6469316046c", + "nodeId": "nod_bd819b9a-6f85-41fd-9270-4235daa4f20f", + "customerId": "cus_a4dca60e-fd93-431c-9003-a18f7e2919ee", + "status": "active", + "createdAt": "2026-10-20T08:13:17.643Z", + "updatedAt": "2026-10-20T08:13:17.644Z" + } + } + } + }, + { + "name": "deploy-n1", + "request": { + "method": "POST", + "pathname": "/api/nodes/nod_35de3d63-181c-4966-a483-9ae5ac40c81c/deploy", + "body": {} + }, + "response": { + "status": 202, + "body": { + "ok": true + } + } + }, + { + "name": "deploy-n2", + "request": { + "method": "POST", + "pathname": "/api/nodes/nod_bd819b9a-6f85-41fd-9270-4235daa4f20f/deploy", + "body": {} + }, + "response": { + "status": 202, + "body": { + "ok": true + } + } + }, + { + "name": "drain-entry", + "jobs": [ + { + "job": { + "id": "job_cdf4b1d7-4b8c-4324-88d0-962a4eb47ca0", + "serverId": "srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e", + "nodeId": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "action": "apply_resource", + "payload": { + "resource": { + "id": "res_b28d761d-716d-4132-b6e2-250d606ec2e6", + "engine": "sing-box", + "meta": { + "kind": "node", + "nodeId": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "metricsTag": "ng-66a4839ae5ac40c81c-in", + "protocol": "vless-reality-vision" + }, + "inbounds": [ + { + "type": "vless", + "tag": "ng-66a4839ae5ac40c81c-in", + "listen": "198.18.60.35", + "listen_port": 34035, + "users": [ + { + "name": "ng:cus_a4dca60e-fd93-431c-9003-a18f7e2919ee", + "uuid": "631268da-ea76-479c-b9f6-9cf2c57825f6", + "flow": "xtls-rprx-vision" + }, + { + "name": "ng:cus_f109881a-aacf-4fba-846d-aee5ce8f22c1", + "uuid": "c666b8e4-a8b4-4806-a1ec-76242a578970", + "flow": "xtls-rprx-vision" + } + ], + "tls": { + "enabled": true, + "server_name": "www.amd.com", + "reality": { + "enabled": true, + "handshake": { + "server": "www.amd.com", + "server_port": 443 + }, + "private_key": "${REALITY_PRIVATE:reality-nod_35de3d63-181c-4966-a483-9ae5ac40c81c}", + "short_id": [ + "45584502b5567121", + "0adf5e3d9a002211" + ] + } + } + } + ], + "outbounds": [ + { + "type": "shadowsocks", + "tag": "ng-66a4839ae5ac40c81c-exit", + "server": "exit.example.com", + "server_port": 25230, + "method": "2022-blake3-aes-256-gcm", + "password": "bVnYmOnNEMI7C0Zoy3B56oYJxq5WsMPloRKC4bMNB+Q=" + } + ], + "routingRules": [ + { + "inbound": [ + "ng-66a4839ae5ac40c81c-in" + ], + "action": "route", + "outbound": "ng-66a4839ae5ac40c81c-exit" + } + ] + }, + "kind": "node", + "keyMaterial": { + "reality-nod_35de3d63-181c-4966-a483-9ae5ac40c81c": { + "privateKey": "cFu3lxTFohX5cUQ1ouS7ZVqXTk3Nm_VZoIKvqBNDC38", + "publicKey": "QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg" + } + } + }, + "status": "running", + "attempts": 1, + "createdAt": "2026-10-20T08:13:17.670Z", + "updatedAt": "2026-10-20T08:13:17.847Z", + "error": null, + "leaseUntil": "2026-10-20T08:18:17.847Z", + "startedAt": "2026-10-20T08:13:17.847Z" + }, + "complete": { + "job": { + "id": "job_cdf4b1d7-4b8c-4324-88d0-962a4eb47ca0", + "serverId": "srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e", + "nodeId": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "action": "apply_resource", + "payload": { + "resource": { + "id": "res_b28d761d-716d-4132-b6e2-250d606ec2e6", + "engine": "sing-box", + "meta": { + "kind": "node", + "nodeId": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "metricsTag": "ng-66a4839ae5ac40c81c-in", + "protocol": "vless-reality-vision" + }, + "inbounds": [ + { + "type": "vless", + "tag": "ng-66a4839ae5ac40c81c-in", + "listen": "198.18.60.35", + "listen_port": 34035, + "users": [ + { + "name": "ng:cus_a4dca60e-fd93-431c-9003-a18f7e2919ee", + "uuid": "631268da-ea76-479c-b9f6-9cf2c57825f6", + "flow": "xtls-rprx-vision" + }, + { + "name": "ng:cus_f109881a-aacf-4fba-846d-aee5ce8f22c1", + "uuid": "c666b8e4-a8b4-4806-a1ec-76242a578970", + "flow": "xtls-rprx-vision" + } + ], + "tls": { + "enabled": true, + "server_name": "www.amd.com", + "reality": { + "enabled": true, + "handshake": { + "server": "www.amd.com", + "server_port": 443 + }, + "private_key": "${REALITY_PRIVATE:reality-nod_35de3d63-181c-4966-a483-9ae5ac40c81c}", + "short_id": [ + "45584502b5567121", + "0adf5e3d9a002211" + ] + } + } + } + ], + "outbounds": [ + { + "type": "shadowsocks", + "tag": "ng-66a4839ae5ac40c81c-exit", + "server": "exit.example.com", + "server_port": 25230, + "method": "2022-blake3-aes-256-gcm", + "password": "bVnYmOnNEMI7C0Zoy3B56oYJxq5WsMPloRKC4bMNB+Q=" + } + ], + "routingRules": [ + { + "inbound": [ + "ng-66a4839ae5ac40c81c-in" + ], + "action": "route", + "outbound": "ng-66a4839ae5ac40c81c-exit" + } + ] + }, + "kind": "node", + "keyMaterial": { + "reality-nod_35de3d63-181c-4966-a483-9ae5ac40c81c": { + "privateKey": "cFu3lxTFohX5cUQ1ouS7ZVqXTk3Nm_VZoIKvqBNDC38", + "publicKey": "QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg" + } + } + }, + "status": "completed", + "attempts": 1, + "createdAt": "2026-10-20T08:13:17.670Z", + "updatedAt": "2026-10-20T08:13:18.055Z", + "error": null, + "leaseUntil": null, + "startedAt": "2026-10-20T08:13:17.847Z", + "completedAt": "2026-10-20T08:13:18.055Z" + } + } + }, + { + "job": { + "id": "job_7f411e08-58fe-4bff-81c6-75bdc361d5e5", + "serverId": "srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e", + "nodeId": "nod_bd819b9a-6f85-41fd-9270-4235daa4f20f", + "action": "apply_resource", + "payload": { + "resource": { + "id": "res_1f7d0d11-f13a-4b8d-9332-9b8dd857c2c4", + "engine": "sing-box", + "meta": { + "kind": "node", + "nodeId": "nod_bd819b9a-6f85-41fd-9270-4235daa4f20f", + "metricsTag": "ng-fd92704235daa4f20f-in", + "protocol": "shadowsocks-2022-aes128" + }, + "inbounds": [ + { + "type": "shadowsocks", + "tag": "ng-fd92704235daa4f20f-in", + "listen": "198.18.60.35", + "listen_port": 29256, + "method": "2022-blake3-aes-128-gcm", + "password": "xOcB7XGgo6VpaEHZfNXrlQ==", + "users": [ + { + "name": "ng:cus_a4dca60e-fd93-431c-9003-a18f7e2919ee", + "password": "zsFQrLA31cHM1USQVgHuXw==" + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "ng-fd92704235daa4f20f-direct" + } + ], + "routingRules": [ + { + "inbound": [ + "ng-fd92704235daa4f20f-in" + ], + "action": "route", + "outbound": "ng-fd92704235daa4f20f-direct" + } + ] + }, + "kind": "node" + }, + "status": "running", + "attempts": 1, + "createdAt": "2026-10-20T08:13:17.685Z", + "updatedAt": "2026-10-20T08:13:18.237Z", + "error": null, + "leaseUntil": "2026-10-20T08:18:18.237Z", + "startedAt": "2026-10-20T08:13:18.237Z" + }, + "complete": { + "job": { + "id": "job_7f411e08-58fe-4bff-81c6-75bdc361d5e5", + "serverId": "srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e", + "nodeId": "nod_bd819b9a-6f85-41fd-9270-4235daa4f20f", + "action": "apply_resource", + "payload": { + "resource": { + "id": "res_1f7d0d11-f13a-4b8d-9332-9b8dd857c2c4", + "engine": "sing-box", + "meta": { + "kind": "node", + "nodeId": "nod_bd819b9a-6f85-41fd-9270-4235daa4f20f", + "metricsTag": "ng-fd92704235daa4f20f-in", + "protocol": "shadowsocks-2022-aes128" + }, + "inbounds": [ + { + "type": "shadowsocks", + "tag": "ng-fd92704235daa4f20f-in", + "listen": "198.18.60.35", + "listen_port": 29256, + "method": "2022-blake3-aes-128-gcm", + "password": "xOcB7XGgo6VpaEHZfNXrlQ==", + "users": [ + { + "name": "ng:cus_a4dca60e-fd93-431c-9003-a18f7e2919ee", + "password": "zsFQrLA31cHM1USQVgHuXw==" + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "ng-fd92704235daa4f20f-direct" + } + ], + "routingRules": [ + { + "inbound": [ + "ng-fd92704235daa4f20f-in" + ], + "action": "route", + "outbound": "ng-fd92704235daa4f20f-direct" + } + ] + }, + "kind": "node" + }, + "status": "completed", + "attempts": 1, + "createdAt": "2026-10-20T08:13:17.685Z", + "updatedAt": "2026-10-20T08:13:18.424Z", + "error": null, + "leaseUntil": null, + "startedAt": "2026-10-20T08:13:18.237Z", + "completedAt": "2026-10-20T08:13:18.424Z" + } + } + } + ] + }, + { + "name": "drain-exit", + "jobs": [ + { + "job": { + "id": "job_8b30df25-5032-41c2-a25a-f08ea662634d", + "serverId": "srv_67549f54-3db9-449a-93f8-57561930ac40", + "nodeId": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "action": "apply_resource", + "payload": { + "resource": { + "id": "res_c10f3ebc-ca98-4498-bef3-a9d9951db4b0", + "engine": "sing-box", + "meta": { + "kind": "node-exit", + "nodeId": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "metricsTag": "ng-66a4839ae5ac40c81c-exit-in" + }, + "inbounds": [ + { + "type": "shadowsocks", + "tag": "ng-66a4839ae5ac40c81c-exit-in", + "listen": "198.18.60.37", + "listen_port": 25230, + "method": "2022-blake3-aes-256-gcm", + "password": "bVnYmOnNEMI7C0Zoy3B56oYJxq5WsMPloRKC4bMNB+Q=" + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "ng-66a4839ae5ac40c81c-exit-direct" + } + ], + "routingRules": [ + { + "inbound": [ + "ng-66a4839ae5ac40c81c-exit-in" + ], + "action": "route", + "outbound": "ng-66a4839ae5ac40c81c-exit-direct" + } + ] + }, + "kind": "node-exit" + }, + "status": "running", + "attempts": 1, + "createdAt": "2026-10-20T08:13:17.671Z", + "updatedAt": "2026-10-20T08:13:18.772Z", + "error": null, + "leaseUntil": "2026-10-20T08:18:18.772Z", + "startedAt": "2026-10-20T08:13:18.772Z" + }, + "complete": { + "job": { + "id": "job_8b30df25-5032-41c2-a25a-f08ea662634d", + "serverId": "srv_67549f54-3db9-449a-93f8-57561930ac40", + "nodeId": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "action": "apply_resource", + "payload": { + "resource": { + "id": "res_c10f3ebc-ca98-4498-bef3-a9d9951db4b0", + "engine": "sing-box", + "meta": { + "kind": "node-exit", + "nodeId": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "metricsTag": "ng-66a4839ae5ac40c81c-exit-in" + }, + "inbounds": [ + { + "type": "shadowsocks", + "tag": "ng-66a4839ae5ac40c81c-exit-in", + "listen": "198.18.60.37", + "listen_port": 25230, + "method": "2022-blake3-aes-256-gcm", + "password": "bVnYmOnNEMI7C0Zoy3B56oYJxq5WsMPloRKC4bMNB+Q=" + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "ng-66a4839ae5ac40c81c-exit-direct" + } + ], + "routingRules": [ + { + "inbound": [ + "ng-66a4839ae5ac40c81c-exit-in" + ], + "action": "route", + "outbound": "ng-66a4839ae5ac40c81c-exit-direct" + } + ] + }, + "kind": "node-exit" + }, + "status": "completed", + "attempts": 1, + "createdAt": "2026-10-20T08:13:17.671Z", + "updatedAt": "2026-10-20T08:13:18.921Z", + "error": null, + "leaseUntil": null, + "startedAt": "2026-10-20T08:13:18.772Z", + "completedAt": "2026-10-20T08:13:18.921Z" + } + } + } + ] + }, + { + "name": "list-nodes-after-deploy", + "request": { + "method": "GET", + "pathname": "/api/nodes" + }, + "response": { + "status": 200, + "body": { + "nodes": [ + { + "id": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "status": "active", + "entryReady": true, + "exitReady": true, + "resourceId": "res_b28d761d-716d-4132-b6e2-250d606ec2e6", + "exitResourceId": "res_c10f3ebc-ca98-4498-bef3-a9d9951db4b0", + "name": "N1", + "serverId": "srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e", + "protocol": "vless-reality-vision", + "topology": "forward", + "exitServerId": "srv_67549f54-3db9-449a-93f8-57561930ac40", + "exitProtocol": "shadowsocks-2022-aes256", + "networkMode": "ipv4", + "realityServerName": "www.amd.com", + "realityDestPort": 443, + "portMode": "random", + "port": 34035, + "exitPortMode": "random", + "exitPort": 25230, + "artifacts": { + "realityPublicKey": "QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg" + }, + "error": null, + "pendingDelete": false, + "usageCounters": null, + "retiredUsageEpochs": [], + "meteredTraffic": null, + "lastUsageAt": null, + "createdAt": "2026-10-20T08:13:17.571Z", + "updatedAt": "2026-10-20T08:13:18.921Z", + "wsPath": "/dcc7c32a84832fc03a", + "reality": { + "keyId": "reality-nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "serverName": "www.amd.com", + "destPort": 443 + }, + "autoRemoving": false + }, + { + "id": "nod_bd819b9a-6f85-41fd-9270-4235daa4f20f", + "status": "active", + "entryReady": true, + "exitReady": false, + "resourceId": "res_1f7d0d11-f13a-4b8d-9332-9b8dd857c2c4", + "exitResourceId": "res_0927c2fe-8b8d-4ff4-9d61-c453961c46aa", + "name": "N2", + "serverId": "srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e", + "protocol": "shadowsocks-2022-aes128", + "topology": "direct", + "exitServerId": null, + "exitProtocol": null, + "networkMode": "ipv4", + "realityServerName": "www.tesla.com", + "realityDestPort": 443, + "portMode": "random", + "port": 29256, + "exitPortMode": null, + "exitPort": null, + "artifacts": {}, + "error": null, + "pendingDelete": false, + "usageCounters": null, + "retiredUsageEpochs": [], + "meteredTraffic": null, + "lastUsageAt": null, + "createdAt": "2026-10-20T08:13:17.590Z", + "updatedAt": "2026-10-20T08:13:18.424Z", + "wsPath": "/a655ba3a14d606bbe3", + "autoRemoving": false + } + ], + "nodeClients": [ + { + "id": "ncl_efa94560-7678-4232-8609-c2e077fd35e2", + "nodeId": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "customerId": "cus_a4dca60e-fd93-431c-9003-a18f7e2919ee", + "status": "active", + "createdAt": "2026-10-20T08:13:17.615Z", + "updatedAt": "2026-10-20T08:13:17.615Z", + "clientUri": "vless://631268da-ea76-479c-b9f6-9cf2c57825f6@entry.example.com:34035?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg&sid=45584502b5567121&type=tcp&headerType=none&flow=xtls-rprx-vision#N1" + }, + { + "id": "ncl_8ac57eff-cc72-4280-9c83-be338cdfb1e2", + "nodeId": "nod_35de3d63-181c-4966-a483-9ae5ac40c81c", + "customerId": "cus_f109881a-aacf-4fba-846d-aee5ce8f22c1", + "status": "active", + "createdAt": "2026-10-20T08:13:17.625Z", + "updatedAt": "2026-10-20T08:13:17.625Z", + "clientUri": "vless://c666b8e4-a8b4-4806-a1ec-76242a578970@entry.example.com:34035?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg&sid=0adf5e3d9a002211&type=tcp&headerType=none&flow=xtls-rprx-vision#N1" + }, + { + "id": "ncl_a8622a82-296c-452e-a2b5-e6469316046c", + "nodeId": "nod_bd819b9a-6f85-41fd-9270-4235daa4f20f", + "customerId": "cus_a4dca60e-fd93-431c-9003-a18f7e2919ee", + "status": "active", + "createdAt": "2026-10-20T08:13:17.643Z", + "updatedAt": "2026-10-20T08:13:17.644Z", + "clientUri": "ss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206eE9jQjdYR2dvNlZwYUVIWmZOWHJsUT09OnpzRlFyTEEzMWNITTFVU1FWZ0h1WHc9PQ@entry.example.com:29256#N2" + } + ] + } + } + }, + { + "name": "alice-subscriptions", + "cases": [ + "raw", + "base64", + "clash", + "clash-smart", + "singbox", + "surge", + "auto-clash", + "auto-generic" + ] + }, + { + "name": "usage-report", + "request": { + "method": "POST", + "pathname": "/api/agent/usage" + }, + "response": { + "status": 200, + "body": { + "ok": true + } + } + }, + { + "name": "list-customers-after-usage", + "request": { + "method": "GET", + "pathname": "/api/customers" + }, + "response": { + "status": 200, + "body": { + "customers": [ + { + "id": "cus_a4dca60e-fd93-431c-9003-a18f7e2919ee", + "subscriptionToken": "RVQBS6ONsmknOHquaj75fFGAkdCHFra6VY6WV4DDRSI", + "name": "Alice", + "group": "", + "status": "active", + "trafficLimitBytes": 0, + "usedBytes": 3000, + "usedUplinkBytes": 1000, + "usedDownlinkBytes": 2000, + "expiresAt": null, + "ipLimit": 0, + "deviceLimit": 0, + "tags": [], + "notes": "", + "createdAt": "2026-10-20T08:13:17.517Z", + "updatedAt": "2026-10-20T08:13:19.471Z", + "serverUsage": { + "srv:srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e": { + "usageCounters": { + "uplink": 1000, + "downlink": 2000, + "epoch": "diff-epoch-1" + } + } + }, + "lastUsageAt": "2026-10-20T08:13:19.471Z", + "observedIpCount": 0, + "subscriptionClientCount": 3 + }, + { + "id": "cus_f109881a-aacf-4fba-846d-aee5ce8f22c1", + "subscriptionToken": "8ZyAzUjXF-OdPPuu5t4qZHkTdfX-z-0AYs8V0WqFwIg", + "name": "Bob", + "group": "", + "status": "active", + "trafficLimitBytes": 1073741824, + "usedBytes": 0, + "usedUplinkBytes": 0, + "usedDownlinkBytes": 0, + "expiresAt": null, + "ipLimit": 5, + "deviceLimit": 2, + "tags": [], + "notes": "", + "createdAt": "2026-10-20T08:13:17.529Z", + "updatedAt": "2026-10-20T08:13:17.529Z", + "observedIpCount": 0, + "subscriptionClientCount": 0 + } + ] + } + } + }, + { + "name": "observations-report", + "request": { + "method": "POST", + "pathname": "/api/agent/observations" + }, + "response": { + "status": 200, + "body": { + "ok": true + } + } + } + ] +} diff --git a/panel-go/difftest/seed.json b/panel-go/difftest/seed.json new file mode 100644 index 0000000..39ee9cb --- /dev/null +++ b/panel-go/difftest/seed.json @@ -0,0 +1 @@ +{"schemaVersion":1,"createdAt":"2026-10-20T08:13:14.524Z","settings":{"brandName":"NexusGate","sessionHours":12,"observationTtlMinutes":10,"activityRetentionDays":30,"completedJobRetentionDays":7},"users":[{"id":"usr_19357193-8c21-4854-820a-f8d70389f783","username":"admin","passwordHash":"scrypt:c82c6bbdcb5fe4baa42117161dc3aeae:7813657d8226f66a502caf87c4fe9b6ae023c3fcfd1dba780be97e5d0e3797f9abd91f72275d029253c3bb828addcae3c77622e43984355e887cf05f54304a50","role":"owner","status":"active","createdAt":"2026-10-20T08:13:14.693Z"}],"servers":[{"name":"Entry","role":"relay","region":"","publicAddress":"entry.example.com","publicAddressV6":"v6.example.com","tlsDomain":"tls.example.com","portRangeStart":20000,"portRangeEnd":50000,"labels":[],"id":"srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e","status":"online","createdAt":"2026-10-20T08:13:15.284Z","updatedAt":"2026-10-20T08:13:15.925Z","lastSeenAt":"2026-10-20T08:13:16.124Z","system":{},"engine":null,"usage":{"lastReportAt":"2026-10-20T08:13:19.471Z","sampleCount":2,"error":null}},{"name":"Exit","role":"exit","region":"","publicAddress":"exit.example.com","publicAddressV6":"","tlsDomain":"","portRangeStart":20000,"portRangeEnd":50000,"labels":[],"id":"srv_67549f54-3db9-449a-93f8-57561930ac40","status":"online","createdAt":"2026-10-20T08:13:15.306Z","updatedAt":"2026-10-20T08:13:17.269Z","lastSeenAt":"2026-10-20T08:13:17.497Z","system":{},"engine":null}],"agents":[{"id":"agt_3e46d79e-f5a2-4740-bcb8-62c62c6fea81","serverId":"srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e","keyHash":"scrypt:4f7328cfaca55ab23b876d09a67a740d:a5715d375659c1f9d61256de9b2a50082b5ac6f80fa1fffe6c7623bdb8d57a39cfb98014410275b8693afc800eafbb2ef07252d030ce733733a8492e100de56d","keyFingerprint":"441d9c6e758b07c585c0ea2ef7197ddb6f7854f744b310fc88c77eb8115a5421","status":"active","hostname":"diff-agent-entry","version":"0.6.8","createdAt":"2026-10-20T08:13:15.925Z","lastSeenAt":"2026-10-20T08:13:16.124Z"},{"id":"agt_fde3090a-2f91-4d6a-bb0d-f5a37558bbd7","serverId":"srv_67549f54-3db9-449a-93f8-57561930ac40","keyHash":"scrypt:72f361fa83de546f5a7bf6ac8ec12d06:46024bc996e4ed8e14b3d64e6d3dcc331d9da406bf31fe951f52b67367912fba1c5bd7c147ee73ddf0447c2f8f2e60b0647ccae55ba7ab86df3a2794899c4af3","keyFingerprint":"48a07d1e8b148600c4a8bfcb797ea943f4fdd5eb6709c86cc14d9a1f81f4e9c3","status":"active","hostname":"diff-agent-exit","version":"0.6.8","createdAt":"2026-10-20T08:13:17.269Z","lastSeenAt":"2026-10-20T08:13:17.497Z"}],"enrollmentTokens":[{"id":"enr_62525f37-0d03-432e-9fcb-5cfac69b3c30","serverId":"srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e","tokenHash":"scrypt:79b908b790a9802e0d6178e7d68e3017:fd92c35373382daa0f21b0a9fe56e6c7febeb1208681a8cc2d3e85bf3e481d61ed375a014cc8eb371e49fffb2343539bdc23c4829a00c773e319cc00adb9aae8","createdAt":"2026-10-20T08:13:15.583Z","expiresAt":"2026-10-20T08:43:15.583Z","usedAt":"2026-10-20T08:13:15.780Z"},{"id":"enr_a4d242d0-c2ff-4580-bd6a-7c3f7eb91aac","serverId":"srv_67549f54-3db9-449a-93f8-57561930ac40","tokenHash":"scrypt:8abd4678273bae907219d82aa1cfd376:05e6245c4135b02e39045511bf3954fc654bd83868eaf2b172f1022d1059b26203e321a53b9b891d7a52d916badf64e7f7b4c755258c22a6a0c9cbd5c382c761","createdAt":"2026-10-20T08:13:16.550Z","expiresAt":"2026-10-20T08:43:16.550Z","usedAt":"2026-10-20T08:13:17.034Z"}],"customers":[{"id":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","subscriptionToken":"RVQBS6ONsmknOHquaj75fFGAkdCHFra6VY6WV4DDRSI","name":"Alice","group":"","status":"active","trafficLimitBytes":0,"usedBytes":3000,"usedUplinkBytes":1000,"usedDownlinkBytes":2000,"expiresAt":null,"ipLimit":0,"deviceLimit":0,"tags":[],"notes":"","createdAt":"2026-10-20T08:13:17.517Z","updatedAt":"2026-10-20T08:13:19.471Z","serverUsage":{"srv:srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e":{"usageCounters":{"uplink":1000,"downlink":2000,"epoch":"diff-epoch-1"}}},"lastUsageAt":"2026-10-20T08:13:19.471Z"},{"id":"cus_f109881a-aacf-4fba-846d-aee5ce8f22c1","subscriptionToken":"8ZyAzUjXF-OdPPuu5t4qZHkTdfX-z-0AYs8V0WqFwIg","name":"Bob","group":"","status":"active","trafficLimitBytes":1073741824,"usedBytes":0,"usedUplinkBytes":0,"usedDownlinkBytes":0,"expiresAt":null,"ipLimit":5,"deviceLimit":2,"tags":[],"notes":"","createdAt":"2026-10-20T08:13:17.529Z","updatedAt":"2026-10-20T08:13:17.529Z"}],"nodes":[{"id":"nod_35de3d63-181c-4966-a483-9ae5ac40c81c","status":"active","entryReady":true,"exitReady":true,"resourceId":"res_b28d761d-716d-4132-b6e2-250d606ec2e6","exitResourceId":"res_c10f3ebc-ca98-4498-bef3-a9d9951db4b0","name":"N1","serverId":"srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e","protocol":"vless-reality-vision","topology":"forward","exitServerId":"srv_67549f54-3db9-449a-93f8-57561930ac40","exitProtocol":"shadowsocks-2022-aes256","networkMode":"ipv4","realityServerName":"www.amd.com","realityDestPort":443,"portMode":"random","port":34035,"exitPortMode":"random","exitPort":25230,"artifacts":{"realityPublicKey":"QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg"},"error":null,"pendingDelete":false,"usageCounters":{"uplink":4000,"downlink":5000,"epoch":"diff-epoch-1"},"retiredUsageEpochs":[],"meteredTraffic":{"uplink":4000,"downlink":5000},"lastUsageAt":"2026-10-20T08:13:19.471Z","createdAt":"2026-10-20T08:13:17.571Z","updatedAt":"2026-10-20T08:13:18.921Z","wsPath":"/dcc7c32a84832fc03a","reality":{"keyId":"reality-nod_35de3d63-181c-4966-a483-9ae5ac40c81c","serverName":"www.amd.com","destPort":443},"exitCredentials":{"clientId":"8ef4fd13-355f-4feb-a5b5-9f3fb26b0bad","password":"bVnYmOnNEMI7C0Zoy3B56oYJxq5WsMPloRKC4bMNB+Q=","socksUser":"ng_5b8dd6b991"},"autoRemoving":false},{"id":"nod_bd819b9a-6f85-41fd-9270-4235daa4f20f","status":"active","entryReady":true,"exitReady":false,"resourceId":"res_1f7d0d11-f13a-4b8d-9332-9b8dd857c2c4","exitResourceId":"res_0927c2fe-8b8d-4ff4-9d61-c453961c46aa","name":"N2","serverId":"srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e","protocol":"shadowsocks-2022-aes128","topology":"direct","exitServerId":null,"exitProtocol":null,"networkMode":"ipv4","realityServerName":"www.tesla.com","realityDestPort":443,"portMode":"random","port":29256,"exitPortMode":null,"exitPort":null,"artifacts":{},"error":null,"pendingDelete":false,"usageCounters":null,"retiredUsageEpochs":[],"meteredTraffic":null,"lastUsageAt":null,"createdAt":"2026-10-20T08:13:17.590Z","updatedAt":"2026-10-20T08:13:18.424Z","wsPath":"/a655ba3a14d606bbe3","ssServerKey":"xOcB7XGgo6VpaEHZfNXrlQ==","autoRemoving":false}],"nodeClients":[{"id":"ncl_efa94560-7678-4232-8609-c2e077fd35e2","nodeId":"nod_35de3d63-181c-4966-a483-9ae5ac40c81c","customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","credentials":{"clientId":"631268da-ea76-479c-b9f6-9cf2c57825f6","relayPassword":"G1ODs1izggmy+h8fPfmY10+Kc0DPNC8t","shortId":"45584502b5567121"},"status":"active","createdAt":"2026-10-20T08:13:17.615Z","updatedAt":"2026-10-20T08:13:17.615Z"},{"id":"ncl_8ac57eff-cc72-4280-9c83-be338cdfb1e2","nodeId":"nod_35de3d63-181c-4966-a483-9ae5ac40c81c","customerId":"cus_f109881a-aacf-4fba-846d-aee5ce8f22c1","credentials":{"clientId":"c666b8e4-a8b4-4806-a1ec-76242a578970","relayPassword":"3LFd5YkOvo9t2qLEnKWWUbubpY1/CGcl","shortId":"0adf5e3d9a002211"},"status":"active","createdAt":"2026-10-20T08:13:17.625Z","updatedAt":"2026-10-20T08:13:17.625Z"},{"id":"ncl_a8622a82-296c-452e-a2b5-e6469316046c","nodeId":"nod_bd819b9a-6f85-41fd-9270-4235daa4f20f","customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","credentials":{"clientId":"3fd84649-b7b1-4d35-bd93-c4e69e10dc32","relayPassword":"zsFQrLA31cHM1USQVgHuXw==","shortId":"03a494d5acced011"},"status":"active","createdAt":"2026-10-20T08:13:17.643Z","updatedAt":"2026-10-20T08:13:17.644Z"}],"jobs":[{"id":"job_cdf4b1d7-4b8c-4324-88d0-962a4eb47ca0","serverId":"srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e","nodeId":"nod_35de3d63-181c-4966-a483-9ae5ac40c81c","action":"apply_resource","payload":{"resource":{"id":"res_b28d761d-716d-4132-b6e2-250d606ec2e6","engine":"sing-box","meta":{"kind":"node","nodeId":"nod_35de3d63-181c-4966-a483-9ae5ac40c81c","metricsTag":"ng-66a4839ae5ac40c81c-in","protocol":"vless-reality-vision"},"inbounds":[{"type":"vless","tag":"ng-66a4839ae5ac40c81c-in","listen":"198.18.60.35","listen_port":34035,"users":[{"name":"ng:cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","uuid":"631268da-ea76-479c-b9f6-9cf2c57825f6","flow":"xtls-rprx-vision"},{"name":"ng:cus_f109881a-aacf-4fba-846d-aee5ce8f22c1","uuid":"c666b8e4-a8b4-4806-a1ec-76242a578970","flow":"xtls-rprx-vision"}],"tls":{"enabled":true,"server_name":"www.amd.com","reality":{"enabled":true,"handshake":{"server":"www.amd.com","server_port":443},"private_key":"${REALITY_PRIVATE:reality-nod_35de3d63-181c-4966-a483-9ae5ac40c81c}","short_id":["45584502b5567121","0adf5e3d9a002211"]}}}],"outbounds":[{"type":"shadowsocks","tag":"ng-66a4839ae5ac40c81c-exit","server":"exit.example.com","server_port":25230,"method":"2022-blake3-aes-256-gcm","password":"bVnYmOnNEMI7C0Zoy3B56oYJxq5WsMPloRKC4bMNB+Q="}],"routingRules":[{"inbound":["ng-66a4839ae5ac40c81c-in"],"action":"route","outbound":"ng-66a4839ae5ac40c81c-exit"}]},"kind":"node","keyMaterial":{"reality-nod_35de3d63-181c-4966-a483-9ae5ac40c81c":{"privateKey":"cFu3lxTFohX5cUQ1ouS7ZVqXTk3Nm_VZoIKvqBNDC38","publicKey":"QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg"}}},"status":"completed","attempts":1,"createdAt":"2026-10-20T08:13:17.670Z","updatedAt":"2026-10-20T08:13:18.055Z","error":null,"leaseUntil":null,"startedAt":"2026-10-20T08:13:17.847Z","completedAt":"2026-10-20T08:13:18.055Z"},{"id":"job_8b30df25-5032-41c2-a25a-f08ea662634d","serverId":"srv_67549f54-3db9-449a-93f8-57561930ac40","nodeId":"nod_35de3d63-181c-4966-a483-9ae5ac40c81c","action":"apply_resource","payload":{"resource":{"id":"res_c10f3ebc-ca98-4498-bef3-a9d9951db4b0","engine":"sing-box","meta":{"kind":"node-exit","nodeId":"nod_35de3d63-181c-4966-a483-9ae5ac40c81c","metricsTag":"ng-66a4839ae5ac40c81c-exit-in"},"inbounds":[{"type":"shadowsocks","tag":"ng-66a4839ae5ac40c81c-exit-in","listen":"198.18.60.37","listen_port":25230,"method":"2022-blake3-aes-256-gcm","password":"bVnYmOnNEMI7C0Zoy3B56oYJxq5WsMPloRKC4bMNB+Q="}],"outbounds":[{"type":"direct","tag":"ng-66a4839ae5ac40c81c-exit-direct"}],"routingRules":[{"inbound":["ng-66a4839ae5ac40c81c-exit-in"],"action":"route","outbound":"ng-66a4839ae5ac40c81c-exit-direct"}]},"kind":"node-exit"},"status":"completed","attempts":1,"createdAt":"2026-10-20T08:13:17.671Z","updatedAt":"2026-10-20T08:13:18.921Z","error":null,"leaseUntil":null,"startedAt":"2026-10-20T08:13:18.772Z","completedAt":"2026-10-20T08:13:18.921Z"},{"id":"job_7f411e08-58fe-4bff-81c6-75bdc361d5e5","serverId":"srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e","nodeId":"nod_bd819b9a-6f85-41fd-9270-4235daa4f20f","action":"apply_resource","payload":{"resource":{"id":"res_1f7d0d11-f13a-4b8d-9332-9b8dd857c2c4","engine":"sing-box","meta":{"kind":"node","nodeId":"nod_bd819b9a-6f85-41fd-9270-4235daa4f20f","metricsTag":"ng-fd92704235daa4f20f-in","protocol":"shadowsocks-2022-aes128"},"inbounds":[{"type":"shadowsocks","tag":"ng-fd92704235daa4f20f-in","listen":"198.18.60.35","listen_port":29256,"method":"2022-blake3-aes-128-gcm","password":"xOcB7XGgo6VpaEHZfNXrlQ==","users":[{"name":"ng:cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","password":"zsFQrLA31cHM1USQVgHuXw=="}]}],"outbounds":[{"type":"direct","tag":"ng-fd92704235daa4f20f-direct"}],"routingRules":[{"inbound":["ng-fd92704235daa4f20f-in"],"action":"route","outbound":"ng-fd92704235daa4f20f-direct"}]},"kind":"node"},"status":"completed","attempts":1,"createdAt":"2026-10-20T08:13:17.685Z","updatedAt":"2026-10-20T08:13:18.424Z","error":null,"leaseUntil":null,"startedAt":"2026-10-20T08:13:18.237Z","completedAt":"2026-10-20T08:13:18.424Z"}],"activity":[{"id":"evt_fe856256-a70e-45e8-99a5-a4f4316cafd0","at":"2026-10-20T08:13:17.685Z","actor":"admin","action":"deploy_node","target":"nod_bd819b9a-6f85-41fd-9270-4235daa4f20f","detail":{"name":"N2"}},{"id":"evt_a7ddffc1-541d-4681-bc44-2dcd09715695","at":"2026-10-20T08:13:17.672Z","actor":"admin","action":"deploy_node","target":"nod_35de3d63-181c-4966-a483-9ae5ac40c81c","detail":{"name":"N1"}},{"id":"evt_31e7c50c-1e16-4d5c-817b-a2fbc1286317","at":"2026-10-20T08:13:17.644Z","actor":"admin","action":"assign_customer","target":"nod_bd819b9a-6f85-41fd-9270-4235daa4f20f","detail":{"customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","customer":"Alice"}},{"id":"evt_07834bd4-c5c0-4673-ba15-f539d9d219f8","at":"2026-10-20T08:13:17.625Z","actor":"admin","action":"assign_customer","target":"nod_35de3d63-181c-4966-a483-9ae5ac40c81c","detail":{"customerId":"cus_f109881a-aacf-4fba-846d-aee5ce8f22c1","customer":"Bob"}},{"id":"evt_6d57612a-a82b-4fd7-ad77-bac85c7b7089","at":"2026-10-20T08:13:17.615Z","actor":"admin","action":"assign_customer","target":"nod_35de3d63-181c-4966-a483-9ae5ac40c81c","detail":{"customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","customer":"Alice"}},{"id":"evt_ca29a6f5-a16f-4a44-aeec-0990bbb7f533","at":"2026-10-20T08:13:17.590Z","actor":"admin","action":"create_node","target":"nod_bd819b9a-6f85-41fd-9270-4235daa4f20f","detail":{"name":"N2"}},{"id":"evt_8bb14a4a-79f4-4f77-96d4-d8ed09404a23","at":"2026-10-20T08:13:17.572Z","actor":"admin","action":"create_node","target":"nod_35de3d63-181c-4966-a483-9ae5ac40c81c","detail":{"name":"N1"}},{"id":"evt_8cf65b4c-5c5d-49dc-af5a-dc1b5404d4dc","at":"2026-10-20T08:13:17.529Z","actor":"admin","action":"create_customer","target":"cus_f109881a-aacf-4fba-846d-aee5ce8f22c1","detail":{"name":"Bob"}},{"id":"evt_87e3689b-c9ec-4a87-8198-d213d6288355","at":"2026-10-20T08:13:17.517Z","actor":"admin","action":"create_customer","target":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","detail":{"name":"Alice"}},{"id":"evt_431f85d3-04b2-4f45-897d-b6d94ae23a6e","at":"2026-10-20T08:13:17.278Z","actor":"agent:agt_fde3090a-2f91-4d6a-bb0d-f5a37558bbd7","action":"reconcile_server","target":"srv_67549f54-3db9-449a-93f8-57561930ac40","detail":{"nodes":0}},{"id":"evt_2043dd90-368b-4ecc-9a23-cee6eb248751","at":"2026-10-20T08:13:17.269Z","actor":"agent:agt_fde3090a-2f91-4d6a-bb0d-f5a37558bbd7","action":"enroll_agent","target":"srv_67549f54-3db9-449a-93f8-57561930ac40","detail":{}},{"id":"evt_8dd1dfd0-3710-4231-bf38-15db8ff43aa3","at":"2026-10-20T08:13:16.550Z","actor":"admin","action":"create_enrollment","target":"srv_67549f54-3db9-449a-93f8-57561930ac40","detail":{}},{"id":"evt_cd722b71-5e54-49be-9b5e-604567e6fc67","at":"2026-10-20T08:13:15.957Z","actor":"agent:agt_3e46d79e-f5a2-4740-bcb8-62c62c6fea81","action":"reconcile_server","target":"srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e","detail":{"nodes":0}},{"id":"evt_9f9280a4-b5ab-4fd1-bbc6-94ee13a664d5","at":"2026-10-20T08:13:15.925Z","actor":"agent:agt_3e46d79e-f5a2-4740-bcb8-62c62c6fea81","action":"enroll_agent","target":"srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e","detail":{}},{"id":"evt_198b4ddc-9b40-4d08-b807-444b595bd484","at":"2026-10-20T08:13:15.583Z","actor":"admin","action":"create_enrollment","target":"srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e","detail":{}},{"id":"evt_2622ca7e-781f-4822-991a-ea7805cb7f45","at":"2026-10-20T08:13:15.306Z","actor":"admin","action":"create_server","target":"srv_67549f54-3db9-449a-93f8-57561930ac40","detail":{"name":"Exit"}},{"id":"evt_378c8421-6a81-4aea-8d89-1c10bc826464","at":"2026-10-20T08:13:15.284Z","actor":"admin","action":"create_server","target":"srv_d042baf3-83eb-4fcc-b7f9-92ea6b42198e","detail":{"name":"Entry"}}],"observations":[{"id":"obs_f69c26b0-27aa-4e7d-99e2-6a55f85a30b0","customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","ip":"203.0.113.10","firstSeenAt":"2026-10-20T08:13:19.664Z","lastSeenAt":"2026-10-20T08:13:19.664Z"}],"subscriptionAccess":[{"id":"acc_745f9070-3409-4dc1-b94b-0cbd8de646d3","customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","at":"2026-10-20T08:13:19.174Z","ip":"127.0.0.1","userAgent":"NexusGate-DiffTest/1.0","identityType":"user-agent","clientKey":"9b7dd0e2f1dcf37e463a5053727aff9d968b411fde00fa2227633cf7de3143a0","format":"raw","status":200,"reason":"已返回","bytes":378},{"id":"acc_d4626092-6d12-4797-812c-a0c0094fd218","customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","at":"2026-10-20T08:13:19.183Z","ip":"127.0.0.1","userAgent":"NexusGate-DiffTest/1.0","identityType":"user-agent","clientKey":"9b7dd0e2f1dcf37e463a5053727aff9d968b411fde00fa2227633cf7de3143a0","format":"base64","status":200,"reason":"已返回","bytes":504},{"id":"acc_1f26df3d-68f7-4b4f-9811-5e600328c890","customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","at":"2026-10-20T08:13:19.195Z","ip":"127.0.0.1","userAgent":"NexusGate-DiffTest/1.0","identityType":"user-agent","clientKey":"9b7dd0e2f1dcf37e463a5053727aff9d968b411fde00fa2227633cf7de3143a0","format":"clash","status":200,"reason":"已返回","bytes":736},{"id":"acc_5e92acdc-1bd4-4bb1-b254-02e8620bf424","customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","at":"2026-10-20T08:13:19.210Z","ip":"127.0.0.1","userAgent":"NexusGate-DiffTest/1.0","identityType":"user-agent","clientKey":"9b7dd0e2f1dcf37e463a5053727aff9d968b411fde00fa2227633cf7de3143a0","format":"clash-smart","status":200,"reason":"已返回","bytes":1704},{"id":"acc_e0315fff-83cf-4d34-8445-3fbe0f9ec23b","customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","at":"2026-10-20T08:13:19.220Z","ip":"127.0.0.1","userAgent":"NexusGate-DiffTest/1.0","identityType":"user-agent","clientKey":"9b7dd0e2f1dcf37e463a5053727aff9d968b411fde00fa2227633cf7de3143a0","format":"singbox","status":200,"reason":"已返回","bytes":1127},{"id":"acc_59973d7c-1c6b-4bbd-a191-cf7f0d2edb91","customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","at":"2026-10-20T08:13:19.238Z","ip":"127.0.0.1","userAgent":"NexusGate-DiffTest/1.0","identityType":"user-agent","clientKey":"9b7dd0e2f1dcf37e463a5053727aff9d968b411fde00fa2227633cf7de3143a0","format":"surge","status":200,"reason":"已返回","bytes":343},{"id":"acc_1169a266-2bb9-4799-8101-2d3a4d716cdf","customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","at":"2026-10-20T08:13:19.266Z","ip":"127.0.0.1","userAgent":"clash-verge/v2.0.0","identityType":"user-agent","clientKey":"fa1f8f56a226ad47e168e49c62ce4874f478ba7f65b9df6093bdc1cc45479d1e","format":"auto","status":200,"reason":"已返回","bytes":736},{"id":"acc_849e7de5-1e02-4e29-bb89-5aeac1aa865c","customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","at":"2026-10-20T08:13:19.278Z","ip":"127.0.0.1","userAgent":"Mozilla/5.0 (DiffTest)","identityType":"user-agent","clientKey":"ecd082d3bc727e0e362d207093e951e514675fd6885aeb9e6384da7c9c4f052c","format":"auto","status":200,"reason":"已返回","bytes":504}],"subscriptionClients":[{"customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","clientKey":"9b7dd0e2f1dcf37e463a5053727aff9d968b411fde00fa2227633cf7de3143a0","identityType":"user-agent","firstSeenAt":"2026-10-20T08:13:19.174Z","lastSeenAt":"2026-10-20T08:13:19.238Z"},{"customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","clientKey":"fa1f8f56a226ad47e168e49c62ce4874f478ba7f65b9df6093bdc1cc45479d1e","identityType":"user-agent","firstSeenAt":"2026-10-20T08:13:19.266Z","lastSeenAt":"2026-10-20T08:13:19.266Z"},{"customerId":"cus_a4dca60e-fd93-431c-9003-a18f7e2919ee","clientKey":"ecd082d3bc727e0e362d207093e951e514675fd6885aeb9e6384da7c9c4f052c","identityType":"user-agent","firstSeenAt":"2026-10-20T08:13:19.278Z","lastSeenAt":"2026-10-20T08:13:19.278Z"}],"keyStore":{"reality-nod_35de3d63-181c-4966-a483-9ae5ac40c81c":{"privateKey":"cFu3lxTFohX5cUQ1ouS7ZVqXTk3Nm_VZoIKvqBNDC38","publicKey":"QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg","updatedAt":"2026-10-20T08:13:17.669Z"}}} diff --git a/panel-go/difftest/seed.mjs b/panel-go/difftest/seed.mjs new file mode 100644 index 0000000..7713e4d --- /dev/null +++ b/panel-go/difftest/seed.mjs @@ -0,0 +1,256 @@ +// Golden-seed generator for the NexusGate panel differential tests. +// +// Starts the NODE panel on a random high port with a throwaway data file +// (NG_DATA_FILE inside an fs.mkdtemp directory — the repository data/ dir is +// never touched) and NG_ADMIN_PASSWORD=diff-admin-pass, then drives the full +// business flow through the public HTTP APIs only: +// +// login +// -> create entry server (entry.example.com / v6.example.com / +// tls.example.com, ports 20000-50000) and exit server (exit.example.com) +// -> enrollment-token + simulated agent enroll for both servers +// -> customers Alice (unlimited) and Bob (1 GiB, ipLimit 5, deviceLimit 2) +// -> nodes N1 (forward, vless-reality-vision, exit +// shadowsocks-2022-aes256, realityServerName www.amd.com) and +// N2 (direct, shadowsocks-2022-aes128) +// -> assign N1: Alice+Bob, N2: Alice; deploy both +// -> agents poll and complete apply jobs (Reality public key reported +// from payload.keyMaterial, like the real agent) +// -> assert both nodes active +// -> capture Alice's subscriptions in every format as expectations +// -> report usage (user sample for Alice, inbound sample for N1) +// and one observation (one IP for Alice) +// -> export the panel DB as seed.json +// +// Outputs (in --out-dir, default: this directory): +// seed.json the exported panel database (the golden seed) +// seed-log.json every request/response of the run + recovered agentKeys +// (run.mjs needs the raw seed agentKeys to keep driving +// the seed agents in phase 2; they only exist in the +// enroll responses, the DB stores hashes) +// seed-expect.json Alice's subscription responses before the usage report +// (bodies stored in comparison form: base64 cases are +// stored decoded, matching fetchAllSubscriptions in +// lib.mjs, so run.mjs can compare them directly) +// +// Usage: node seed.mjs [--out-dir ] + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + ADMIN_PASSWORD, ADMIN_USERNAME, PanelClient, DIFFTEST_DIR, + drainAgentJobs, fetchAllSubscriptions, freePort, makeTempDir, + startPanel, stopPanel, waitHealth, +} from './lib.mjs'; + +// Deterministic seed traffic numbers. After the usage report Alice must +// have usedBytes = 1000 + 2000 = 3000 and N1 meteredTraffic 4000/5000. +export const SEED_USAGE = { + epoch: 'diff-epoch-1', + user: { uplink: 1000, downlink: 2000 }, + inbound: { uplink: 4000, downlink: 5000 }, + aliceIp: '203.0.113.10', +}; + +export async function generateSeed({ outDir = DIFFTEST_DIR, quiet = false } = {}) { + const log = (...args) => { if (!quiet) console.log(...args); }; + const tmpDir = makeTempDir('nexusgate-seed-'); + const dataFile = path.join(tmpDir, 'nexusgate.json'); + const port = await freePort(); + const proc = startPanel({ kind: 'node', dataFile, port }); + const client = new PanelClient(proc.base); + const steps = []; + const ids = {}; + const agentKeys = {}; + let aliceSubs = null; + + const record = (name, request, res) => { + steps.push({ + name, + request, + response: { status: res.status, body: res.json !== null && res.json !== undefined ? res.json : res.text }, + }); + }; + const must = (cond, what, extra = '') => { + if (!cond) throw new Error(`seed step failed: ${what} ${extra}`); + }; + const admin = async (name, method, pathname, body) => { + const res = await client.admin(method, pathname, body); + record(name, { method, pathname, body }, res); + return res; + }; + + try { + await waitHealth(proc); + log(`[seed] node panel on ${proc.base} (data ${dataFile})`); + + // --- login ------------------------------------------------------------- + const login = await client.login(); + record('login', { method: 'POST', pathname: '/api/auth/login', body: { username: ADMIN_USERNAME } }, login); + must(login.status === 200, 'login', login.text); + + // --- servers ----------------------------------------------------------- + const entryRes = await admin('create-server-entry', 'POST', '/api/servers', { + name: 'Entry', role: 'relay', region: '', + publicAddress: 'entry.example.com', publicAddressV6: 'v6.example.com', + tlsDomain: 'tls.example.com', portRangeStart: 20000, portRangeEnd: 50000, + }); + must(entryRes.status === 201, 'create entry server', entryRes.text); + ids.entryServer = entryRes.json.server.id; + + const exitRes = await admin('create-server-exit', 'POST', '/api/servers', { + name: 'Exit', role: 'exit', region: '', + publicAddress: 'exit.example.com', publicAddressV6: '', + tlsDomain: '', portRangeStart: 20000, portRangeEnd: 50000, + }); + must(exitRes.status === 201, 'create exit server', exitRes.text); + ids.exitServer = exitRes.json.server.id; + + // --- enroll both agents -------------------------------------------------- + for (const [label, serverId] of [['entry', ids.entryServer], ['exit', ids.exitServer]]) { + const ticket = await admin(`enrollment-token-${label}`, 'POST', `/api/servers/${serverId}/enrollment-token`, {}); + must(ticket.status === 201, `enrollment token (${label})`, ticket.text); + const enrolled = await client.request('POST', '/api/agent/enroll', { + body: { token: ticket.json.token, hostname: `diff-agent-${label}`, version: '0.6.8', system: {} }, + cookie: null, + }); + record(`enroll-${label}`, { method: 'POST', pathname: '/api/agent/enroll' }, enrolled); + must(enrolled.status === 201, `enroll (${label})`, enrolled.text); + agentKeys[label] = enrolled.json.agentKey; + ids[`${label}Agent`] = enrolled.json.agentId; + const hb = await client.agentPost('/api/agent/heartbeat', agentKeys[label], { version: '0.6.8', system: {} }); + record(`heartbeat-${label}`, { method: 'POST', pathname: '/api/agent/heartbeat' }, hb); + must(hb.status === 200, `heartbeat (${label})`, hb.text); + } + + // --- customers ----------------------------------------------------------- + const aliceRes = await admin('create-customer-alice', 'POST', '/api/customers', { name: 'Alice' }); + must(aliceRes.status === 201, 'create Alice', aliceRes.text); + ids.alice = aliceRes.json.customer.id; + const aliceToken = aliceRes.json.customer.subscriptionToken; + + const bobRes = await admin('create-customer-bob', 'POST', '/api/customers', { + name: 'Bob', trafficLimitBytes: 1073741824, ipLimit: 5, deviceLimit: 2, + }); + must(bobRes.status === 201, 'create Bob', bobRes.text); + ids.bob = bobRes.json.customer.id; + + // --- nodes --------------------------------------------------------------- + const n1Res = await admin('create-node-n1', 'POST', '/api/nodes', { + name: 'N1', serverId: ids.entryServer, topology: 'forward', + protocol: 'vless-reality-vision', exitServerId: ids.exitServer, + exitProtocol: 'shadowsocks-2022-aes256', realityServerName: 'www.amd.com', + }); + must(n1Res.status === 201, 'create N1', n1Res.text); + ids.n1 = n1Res.json.node.id; + + const n2Res = await admin('create-node-n2', 'POST', '/api/nodes', { + name: 'N2', serverId: ids.entryServer, topology: 'direct', + protocol: 'shadowsocks-2022-aes128', + }); + must(n2Res.status === 201, 'create N2', n2Res.text); + ids.n2 = n2Res.json.node.id; + + // --- assignments --------------------------------------------------------- + for (const [nodeLabel, nodeId, customerLabel, customerId] of [ + ['n1', ids.n1, 'alice', ids.alice], + ['n1', ids.n1, 'bob', ids.bob], + ['n2', ids.n2, 'alice', ids.alice], + ]) { + const res = await admin(`assign-${nodeLabel}-${customerLabel}`, 'POST', `/api/nodes/${nodeId}/assign`, { customerId }); + must(res.status === 200, `assign ${nodeLabel}/${customerLabel}`, res.text); + } + + // --- deploy + drain ------------------------------------------------------ + for (const [label, nodeId] of [['n1', ids.n1], ['n2', ids.n2]]) { + const res = await admin(`deploy-${label}`, 'POST', `/api/nodes/${nodeId}/deploy`, {}); + must(res.status === 202, `deploy ${label}`, res.text); + } + for (const label of ['entry', 'exit']) { + const drained = await drainAgentJobs(client, agentKeys[label]); + steps.push({ name: `drain-${label}`, jobs: drained.map(({ job, complete }) => ({ job, complete })) }); + log(`[seed] drained ${drained.length} job(s) on ${label} agent`); + } + + const nodesRes = await admin('list-nodes-after-deploy', 'GET', '/api/nodes'); + for (const [label, nodeId] of [['N1', ids.n1], ['N2', ids.n2]]) { + const node = nodesRes.json.nodes.find((item) => item.id === nodeId); + must(node && node.status === 'active', `node ${label} active after deploy`, + `got ${node && node.status}`); + } + log('[seed] N1 and N2 are active'); + + // --- Alice subscription expectations (pre-usage state) ------------------- + aliceSubs = await fetchAllSubscriptions(client, aliceToken); + for (const [key, sub] of Object.entries(aliceSubs)) { + must(sub.status === 200, `Alice subscription ${key}`, `status ${sub.status} body ${sub.body.slice(0, 120)}`); + } + steps.push({ name: 'alice-subscriptions', cases: Object.keys(aliceSubs) }); + + // --- usage + observations -------------------------------------------------- + const usage = await client.agentPost('/api/agent/usage', agentKeys.entry, { + epoch: SEED_USAGE.epoch, + samples: [ + { kind: 'user', id: ids.alice, uplink: SEED_USAGE.user.uplink, downlink: SEED_USAGE.user.downlink }, + { kind: 'inbound', id: ids.n1, uplink: SEED_USAGE.inbound.uplink, downlink: SEED_USAGE.inbound.downlink }, + ], + }); + record('usage-report', { method: 'POST', pathname: '/api/agent/usage' }, usage); + must(usage.status === 200, 'usage report', usage.text); + + const customersRes = await admin('list-customers-after-usage', 'GET', '/api/customers'); + const aliceAfter = customersRes.json.customers.find((item) => item.id === ids.alice); + must(aliceAfter && aliceAfter.usedBytes === SEED_USAGE.user.uplink + SEED_USAGE.user.downlink, + 'Alice usedBytes after usage report', `got ${aliceAfter && aliceAfter.usedBytes}`); + + const observations = await client.agentPost('/api/agent/observations', agentKeys.entry, { + observations: [{ customerId: ids.alice, ip: SEED_USAGE.aliceIp }], + }); + record('observations-report', { method: 'POST', pathname: '/api/agent/observations' }, observations); + must(observations.status === 200, 'observations report', observations.text); + + log(`[seed] usage recorded (Alice usedBytes=${aliceAfter.usedBytes}), observation ${SEED_USAGE.aliceIp} recorded`); + } finally { + await stopPanel(proc); + } + + // --- export --------------------------------------------------------------- + fs.mkdirSync(outDir, { recursive: true }); + const seedPath = path.join(outDir, 'seed.json'); + const db = JSON.parse(fs.readFileSync(dataFile, 'utf8')); + must(db.keyStore && Object.keys(db.keyStore).length === 1, 'seed keyStore holds exactly the N1 Reality key', + `got ${db.keyStore ? Object.keys(db.keyStore).length : 'none'}`); + fs.copyFileSync(dataFile, seedPath); + + const expectPath = path.join(outDir, 'seed-expect.json'); + fs.writeFileSync(expectPath, `${JSON.stringify({ alice: aliceSubs }, null, 2)}\n`); + + const logPath = path.join(outDir, 'seed-log.json'); + fs.writeFileSync(logPath, `${JSON.stringify({ + meta: { + generatedAt: new Date().toISOString(), + adminUsername: ADMIN_USERNAME, + adminPassword: ADMIN_PASSWORD, + ids, + agentKeys: { entry: agentKeys.entry, exit: agentKeys.exit }, + seedUsage: SEED_USAGE, + }, + steps, + }, null, 2)}\n`); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + log(`[seed] wrote ${seedPath}`); + log(`[seed] wrote ${expectPath}`); + log(`[seed] wrote ${logPath}`); + return { seedPath, expectPath, logPath, ids }; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + const args = process.argv.slice(2); + let outDir = DIFFTEST_DIR; + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--out-dir' && args[i + 1]) { outDir = path.resolve(args[i + 1]); i += 1; } + } + await generateSeed({ outDir }); +} diff --git a/panel-go/go.mod b/panel-go/go.mod new file mode 100644 index 0000000..2bac9d5 --- /dev/null +++ b/panel-go/go.mod @@ -0,0 +1,3 @@ +module nexusgate-panel + +go 1.24 diff --git a/panel-go/handlers.go b/panel-go/handlers.go new file mode 100644 index 0000000..aaca072 --- /dev/null +++ b/panel-go/handlers.go @@ -0,0 +1,2612 @@ +package main + +// All HTTP handlers + the server.js internal helpers (normalize/public +// shapers, auth guards, login rate limiting, subscription endpoint). +// Route order, status codes, error codes and message text follow server.js +// line by line. + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "math" + "net" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// ---------- app state ---------- + +type loginAttempt struct { + Count int + BlockedUntil int64 // epoch ms +} + +type App struct { + store *Store + orchestrator *Orchestrator + + sessMu sync.RWMutex + sessions *SessionManager + + loginMu sync.Mutex + loginAttempts map[string]*loginAttempt +} + +func NewApp(store *Store, sessions *SessionManager, orch *Orchestrator) *App { + return &App{ + store: store, + orchestrator: orch, + sessions: sessions, + loginAttempts: map[string]*loginAttempt{}, + } +} + +func (a *App) getSessions() *SessionManager { + a.sessMu.RLock() + defer a.sessMu.RUnlock() + return a.sessions +} + +// replaceSessions swaps the whole SessionManager (account PATCH / restore), +// which invalidates every existing session, as in server.js. +func (a *App) replaceSessions(sm *SessionManager) { + a.sessMu.Lock() + defer a.sessMu.Unlock() + a.sessions = sm +} + +func (a *App) sessionHours() float64 { + h := a.store.Snapshot().Settings.SessionHours + if h == 0 || math.IsNaN(h) { + return 12 + } + return h +} + +// ---------- normalize ---------- + +// The JS pattern anchors the total length with a lookahead +// (?=.{1,253}$), which RE2 lacks; the length is checked in code instead. +var tlsDomainRe = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$`) + +func validTLSDomain(s string) bool { + return len(s) >= 1 && len(s) <= 253 && tlsDomainRe.MatchString(s) +} + +var certDomainRe = regexp.MustCompile(`^(?:[a-z0-9-]+\.)+[a-z]{2,63}$`) + +func numOr(v float64, fallback float64) float64 { + if v == 0 || math.IsNaN(v) { + return fallback + } + return v +} + +// NormalizeServer is normalizeServer. Presence semantics come from map key +// existence, exactly like JS `'field' in body`. +func NormalizeServer(body map[string]any, current *Server) (*Server, error) { + var next Server + if current != nil { + next = *current + } + if _, ok := body["name"]; ok { + name, err := RequiredText(body["name"], "服务器名称", 120) + if err != nil { + return nil, err + } + next.Name = name + } + if v, ok := body["role"]; ok { + if s, isStr := v.(string); isStr && (s == "relay" || s == "exit" || s == "hybrid") { + next.Role = s + } else if next.Role == "" { + next.Role = "hybrid" + } + } else if next.Role == "" { + next.Role = "hybrid" + } + if _, ok := body["region"]; ok { + next.Region = CleanText(body["region"], 80) + } + if _, ok := body["publicAddress"]; ok { + addr, err := RequiredText(body["publicAddress"], "公网地址", 255) + if err != nil { + return nil, err + } + next.PublicAddress = addr + } + if _, ok := body["publicAddressV6"]; ok { + next.PublicAddressV6 = CleanText(body["publicAddressV6"], 255) + } + if _, ok := body["tlsDomain"]; ok { + next.TLSDomain = strings.ToLower(CleanText(body["tlsDomain"], 253)) + } + // Port range: JS computes Number(body value) when the key is present + // (Number(null) is 0), else Number(current || default). Validation is on + // the float values (integer/range checks), then stored as ints. + portValue := func(key string, currentVal, def int) (float64, bool) { + if v, ok := body[key]; ok { + if v == nil { + return 0, true + } + return jsNumber(v) + } + if currentVal == 0 { + return float64(def), true + } + return float64(currentVal), true + } + startF, startOk := portValue("portRangeStart", next.PortRangeStart, 20000) + endF, endOk := portValue("portRangeEnd", next.PortRangeEnd, 50000) + next.PortRangeStart = int(startF) + next.PortRangeEnd = int(endF) + if _, ok := body["labels"]; ok { + next.Labels = sliceStrings(asIds(body["labels"]), 20) + } else if next.Labels == nil { + next.Labels = []string{} + } + if !startOk || !endOk || startF != math.Trunc(startF) || endF != math.Trunc(endF) || + startF < 1024 || endF > 65535 || startF > endF { + return nil, httpErr(400, "端口范围必须是 1024–65535 之间的整数") + } + if next.TLSDomain != "" && !validTLSDomain(next.TLSDomain) { + return nil, httpErr(400, "TLS 域名必须是有效的公网域名") + } + return &next, nil +} + +// NormalizeNode is normalizeNode, including its port-(re)selection rules. +func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error) { + var next Node + if current != nil { + next = *current + } + topology := next.Topology + if _, ok := body["topology"]; ok { + if s, _ := body["topology"].(string); s == "direct" { + topology = "direct" + } else { + topology = "forward" + } + } else if topology == "" { + topology = "forward" + } + direct := topology == "direct" + + protocol := next.Protocol + if _, ok := body["protocol"]; ok { + protocol = CleanText(body["protocol"], 64) + } + serverID := next.ServerID + if _, ok := body["serverId"]; ok { + id, err := RequiredText(body["serverId"], "服务器", 120) + if err != nil { + return nil, err + } + serverID = id + } + var server *Server + for _, item := range data.Servers { + if item.ID == serverID { + server = item + break + } + } + if server == nil { + return nil, httpErr(404, "服务器不存在") + } + var exitServerID *string + if direct { + exitServerID = nil + } else if _, ok := body["exitServerId"]; ok { + id, err := RequiredText(body["exitServerId"], "出口服务器 ID", 120) + if err != nil { + return nil, err + } + exitServerID = &id + } else { + exitServerID = next.ExitServerID + } + if !direct { + found := false + for _, item := range data.Servers { + if item.ID == strVal(exitServerID) { + found = true + break + } + } + if !found { + return nil, httpErr(404, "出口服务器不存在") + } + } + if _, ok := body["name"]; ok { + name, err := RequiredText(body["name"], "节点名称", 120) + if err != nil { + return nil, err + } + next.Name = name + } + next.ServerID = serverID + next.Protocol = protocol + next.Topology = topology + next.ExitServerID = exitServerID + if direct { + next.ExitProtocol = nil + } else if _, ok := body["exitProtocol"]; ok { + s := CleanText(body["exitProtocol"], 64) + next.ExitProtocol = &s + } + if v, ok := body["networkMode"]; ok { + if s, isStr := v.(string); isStr && (s == "ipv4" || s == "ipv6" || s == "dual") { + next.NetworkMode = s + } else if next.NetworkMode == "" { + next.NetworkMode = "ipv4" + } + } else if next.NetworkMode == "" { + next.NetworkMode = "ipv4" + } + if _, ok := body["realityServerName"]; ok { + next.RealityServerName = CleanText(body["realityServerName"], 255) + } else if next.RealityServerName == "" { + next.RealityServerName = "www.tesla.com" + } + if _, ok := body["realityDestPort"]; ok { + f, valid := jsNumber(body["realityDestPort"]) + if !valid || f == 0 { + next.RealityDestPort = 443 + } else { + next.RealityDestPort = int(f) + } + } else { + next.RealityDestPort = int(numOr(float64(next.RealityDestPort), 443)) + } + portMode := next.PortMode + if _, ok := body["portMode"]; ok { + if s, _ := body["portMode"].(string); s == "fixed" { + portMode = "fixed" + } else { + portMode = "random" + } + } else if portMode == "" { + portMode = "random" + } + next.PortMode = portMode + currentPortMode := "random" + var currentID string + var currentPort int + if current != nil { + if current.PortMode != "" { + currentPortMode = current.PortMode + } + currentID = current.ID + currentPort = current.Port + } + _, portModeInBody := body["portMode"] + _, portInBody := body["port"] + portModeChanged := portModeInBody && portMode != currentPortMode + _, serverIDInBody := body["serverId"] + if portModeChanged || portInBody || serverIDInBody || currentPort == 0 { + var requested any + if portInBody { + requested = body["port"] + } else if current != nil { + requested = json.Number(fmt.Sprint(currentPort)) + } + port, err := SelectPort(data, server, portMode, requested, currentID) + if err != nil { + return nil, err + } + next.Port = port + } + if !direct { + exitPortMode := strVal(next.ExitPortMode) + if _, ok := body["exitPortMode"]; ok { + if s, _ := body["exitPortMode"].(string); s == "fixed" { + exitPortMode = "fixed" + } else { + exitPortMode = "random" + } + } else if exitPortMode == "" { + exitPortMode = "random" + } + next.ExitPortMode = &exitPortMode + var exitServer *Server + for _, item := range data.Servers { + if item.ID == strVal(exitServerID) { + exitServer = item + break + } + } + currentExitPortMode := "random" + var currentExitPort *int + if current != nil { + if strVal(current.ExitPortMode) != "" { + currentExitPortMode = strVal(current.ExitPortMode) + } + currentExitPort = current.ExitPort + } + _, exitPortModeInBody := body["exitPortMode"] + _, exitPortInBody := body["exitPort"] + exitPortModeChanged := exitPortModeInBody && exitPortMode != currentExitPortMode + _, exitServerIDInBody := body["exitServerId"] + if exitPortModeChanged || exitPortInBody || exitServerIDInBody || currentExitPort == nil { + var requested any + if exitPortInBody { + requested = body["exitPort"] + } else if currentExitPort != nil { + requested = json.Number(fmt.Sprint(*currentExitPort)) + } + port, err := SelectPort(data, exitServer, exitPortMode, requested, currentID) + if err != nil { + return nil, err + } + next.ExitPort = &port + } + } else { + next.ExitPortMode = nil + next.ExitPort = nil + } + if direct { + if herr := ValidateEntryProtocol(protocol); herr != nil { + return nil, herr + } + } else { + if herr := ValidateProtocolPair(protocol, strVal(next.ExitProtocol)); herr != nil { + return nil, herr + } + } + if IsRealityProtocol(protocol) && next.RealityServerName == "" { + return nil, httpErr(400, "Reality SNI 不能为空") + } + if IsRealityProtocol(protocol) && (next.RealityDestPort < 1 || next.RealityDestPort > 65535) { + return nil, httpErr(400, "Reality 目标端口无效") + } + return &next, nil +} + +// ---------- public shapers ---------- + +// PublicNode is publicNode: credentials stripped, error redacted. Built via +// the record's MarshalJSON so unknown (Extra) fields pass through. +func PublicNode(item *Node) map[string]any { + out := recordToMap(item) + delete(out, "exitCredentials") + delete(out, "ssServerKey") + if s, ok := out["error"].(string); ok && s != "" { + out["error"] = RedactSecrets(s) + } + return out +} + +// PublicServer is publicServer: engine detail redacted, plus agentVersion +// and pendingCleanup computed against data. +func PublicServer(item *Server, data *Data) map[string]any { + out := recordToMap(item) + if eng, ok := out["engine"].(map[string]any); ok && eng != nil { + copied := map[string]any{} + for k, v := range eng { + copied[k] = v + } + if d, isStr := copied["detail"].(string); isStr { + copied["detail"] = RedactSecrets(d) + } + out["engine"] = copied + } + var version any + for _, agent := range data.Agents { + if agent.ServerID == item.ID && agent.Status == "active" { + version = agent.Version + break + } + } + out["agentVersion"] = version + pending := 0 + for _, n := range data.Nodes { + if (n.ServerID == item.ID || strVal(n.ExitServerID) == item.ID) && n.Status == "removing" { + pending++ + } + } + out["pendingCleanup"] = pending + return out +} + +// PublicCustomer is publicCustomer plus observed/subscription counters. +func PublicCustomer(item *Customer, data *Data) map[string]any { + out := recordToMap(item) + out["observedIpCount"] = len(ObservedIPs(data, item.ID, time.Now())) + out["subscriptionClientCount"] = len(ActiveClients(data, item.ID, time.Now())) + return out +} + +// CustomerBlockReason is customerBlockReason: "expired" / "traffic_limit" / +// "ip_limit", or "" for none (JS null). +func CustomerBlockReason(data *Data, customer *Customer) string { + if customer.ExpiresAt != nil { + if ms, ok := ParseISOMs(strVal(customer.ExpiresAt)); ok && ms <= time.Now().UnixMilli() { + return "expired" + } + } + if customer.TrafficLimitBytes > 0 && float64(customer.UsedBytes) >= customer.TrafficLimitBytes { + return "traffic_limit" + } + if customer.IPLimit > 0 { + seen := map[string]bool{} + for _, obs := range ObservedIPs(data, customer.ID, time.Now()) { + seen[obs.IP] = true + } + if int64(len(seen)) > customer.IPLimit { + return "ip_limit" + } + } + return "" +} + +// GetIP is getIp: first x-forwarded-for segment, else the peer address. +func GetIP(r *http.Request) string { + first := strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0] + if first == "" { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + first = host + } + return CleanTextStr(first, 80) +} + +// ---------- admin auth ---------- + +type adminAuth struct { + Session *Session + User *User +} + +// CurrentAdmin is currentAdmin. +func (a *App) CurrentAdmin(r *http.Request) *adminAuth { + session := a.getSessions().Get(r) + if session == nil { + return nil + } + data := a.store.Snapshot() + for _, user := range data.Users { + if user.ID == session.UserID && user.Status == "active" { + return &adminAuth{Session: session, User: user} + } + } + return nil +} + +// RequireAdmin is requireAdmin: sends 401/403 itself and returns nil. +func (a *App) RequireAdmin(w http.ResponseWriter, r *http.Request, csrf bool) *adminAuth { + auth := a.CurrentAdmin(r) + if auth == nil { + SendError(w, 401, "请先登录", "unauthorized") + return nil + } + if csrf && r.Header.Get("X-CSRF-Token") != auth.Session.CSRF { + SendError(w, 403, "请求校验失败,请刷新页面后重试", "csrf_failed") + return nil + } + return auth +} + +// FindAgentByBearer is findAgentByBearer: fingerprint fast path, then a +// full VerifySecret scan over legacy agents without a fingerprint. +func (a *App) FindAgentByBearer(r *http.Request) *Agent { + header := r.Header.Get("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + return nil + } + token := header[len("Bearer "):] + fingerprint := TokenFingerprint(token) + data := a.store.Snapshot() + var fast *Agent + for _, agent := range data.Agents { + if agent.Status == "active" && agent.KeyFingerprint != nil && *agent.KeyFingerprint == fingerprint { + fast = agent + break + } + } + if fast != nil && VerifySecret(token, fast.KeyHash) { + return fast + } + for _, agent := range data.Agents { + if agent.Status == "active" && agent.KeyFingerprint == nil && VerifySecret(token, agent.KeyHash) { + return agent + } + } + return nil +} + +func clearSessionCookie() map[string]string { + return map[string]string{"Set-Cookie": "ng_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"} +} + +// ---------- auth routes ---------- + +func (a *App) handleAuth(w http.ResponseWriter, r *http.Request, pathname string) (bool, error) { + if r.Method == http.MethodPost && pathname == "/api/auth/login" { + ip := GetIP(r) + now := time.Now().UnixMilli() + a.loginMu.Lock() + record := a.loginAttempts[ip] + blocked := record != nil && record.BlockedUntil > now + a.loginMu.Unlock() + if blocked { + SendError(w, 429, "尝试次数过多,请稍后再试", "rate_limited") + return true, nil + } + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + username := CleanText(body["username"], 64) + data := a.store.Snapshot() + var user *User + for _, item := range data.Users { + if item.Username == username && item.Status == "active" { + user = item + break + } + } + if user == nil || !VerifySecret(secretString(body["password"]), user.PasswordHash) { + a.loginMu.Lock() + rec := a.loginAttempts[ip] + if rec == nil { + rec = &loginAttempt{} + a.loginAttempts[ip] = rec + } + rec.Count++ + if rec.Count >= 6 { + rec.BlockedUntil = now + 15*60*1000 + rec.Count = 0 + } + a.loginMu.Unlock() + SendError(w, 401, "账号或密码不正确", "invalid_credentials") + return true, nil + } + a.loginMu.Lock() + delete(a.loginAttempts, ip) + a.loginMu.Unlock() + sessions := a.getSessions() + session := sessions.Create(user.ID) + secure := "" + if cookieSecure { + secure = "; Secure" + } + cookie := fmt.Sprintf("ng_session=%s; HttpOnly; SameSite=Strict; Path=/; Max-Age=%d%s", + session.ID, int64(sessions.TTL/time.Second), secure) + SendJSON(w, 200, map[string]any{ + "user": map[string]any{"id": user.ID, "username": user.Username, "role": user.Role}, + "csrf": session.CSRF, + }, map[string]string{"Set-Cookie": cookie}) + return true, nil + } + if r.Method == http.MethodPost && pathname == "/api/auth/logout" { + auth := a.RequireAdmin(w, r, true) + if auth == nil { + return true, nil + } + a.getSessions().Delete(r) + SendJSON(w, 200, map[string]any{"ok": true}, clearSessionCookie()) + return true, nil + } + if r.Method == http.MethodGet && pathname == "/api/session" { + auth := a.CurrentAdmin(r) + if auth == nil { + SendJSON(w, 200, map[string]any{"authenticated": false}, nil) + } else { + SendJSON(w, 200, map[string]any{ + "authenticated": true, + "csrf": auth.Session.CSRF, + "user": map[string]any{"id": auth.User.ID, "username": auth.User.Username, "role": auth.User.Role}, + }, nil) + } + return true, nil + } + return false, nil +} + +// ---------- agent routes ---------- + +// rawSystemValue mirrors `body.system || {}` for the RawMessage system blob. +func rawSystemValue(v any) json.RawMessage { + switch t := v.(type) { + case nil: + return json.RawMessage("{}") + case bool: + if !t { + return json.RawMessage("{}") + } + case string: + if t == "" { + return json.RawMessage("{}") + } + case json.Number: + if t.String() == "0" { + return json.RawMessage("{}") + } + } + raw, err := marshalJSON(v) + if err != nil { + return json.RawMessage("{}") + } + return raw +} + +func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname string) (bool, error) { + if r.Method == http.MethodPost && pathname == "/api/agent/enroll" { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + token := JSString(body["token"]) + data := a.store.Snapshot() + var enrollment *EnrollmentToken + nowMs := time.Now().UnixMilli() + for _, item := range data.EnrollmentTokens { + if item.UsedAt != nil { + continue + } + expMs, ok := ParseISOMs(item.ExpiresAt) + if !ok || expMs <= nowMs { + continue + } + if VerifySecret(token, item.TokenHash) { + enrollment = item + break + } + } + if enrollment == nil { + SendError(w, 401, "注册令牌无效或已过期", "invalid_enrollment") + return true, nil + } + agentKey := RandomToken(40) + resultAny, err := a.store.Transaction(func(d *Data) (any, error) { + var target *EnrollmentToken + for _, item := range d.EnrollmentTokens { + if item.ID == enrollment.ID { + target = item + break + } + } + if target == nil || target.UsedAt != nil { + return nil, httpErr(409, "注册令牌已被使用") + } + now := NowISO() + target.UsedAt = &now + var server *Server + for _, item := range d.Servers { + if item.ID == target.ServerID { + server = item + break + } + } + if server == nil { + return nil, httpErr(404, "目标服务器不存在") + } + for _, old := range d.Agents { + if old.ServerID == server.ID { + old.Status = "revoked" + } + } + fp := TokenFingerprint(agentKey) + agent := &Agent{ + ID: NewID("agt"), + ServerID: server.ID, + KeyHash: HashSecret(agentKey), + KeyFingerprint: &fp, + Status: "active", + Hostname: CleanText(body["hostname"], 128), + Version: CleanText(body["version"], 32), + CreatedAt: now, + LastSeenAt: now, + } + d.Agents = append(d.Agents, agent) + server.Status = "online" + server.LastSeenAt = &now + server.System = rawSystemValue(body["system"]) + server.UpdatedAt = now + Audit(d, "agent:"+agent.ID, "enroll_agent", server.ID, nil) + return map[string]any{"agentId": agent.ID, "serverId": server.ID, "serverName": server.Name}, nil + }) + if err != nil { + return true, err + } + result := resultAny.(map[string]any) + reconciled := 0 + if n, rerr := a.orchestrator.ReconcileServer(result["serverId"].(string), "agent:"+result["agentId"].(string)); rerr != nil { + fmt.Fprintf(os.Stderr, "Agent reconciliation failed: %v\n", rerr) + } else { + reconciled = n + } + SendJSON(w, 201, map[string]any{ + "agentId": result["agentId"], + "serverId": result["serverId"], + "serverName": result["serverName"], + "agentKey": agentKey, + "reconciled": reconciled, + }, nil) + return true, nil + } + + if !strings.HasPrefix(pathname, "/api/agent/") { + return false, nil + } + agent := a.FindAgentByBearer(r) + if agent == nil { + SendError(w, 401, "Agent 身份校验失败", "invalid_agent") + return true, nil + } + + if r.Method == http.MethodPost && pathname == "/api/agent/heartbeat" { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + a.store.Transient(func(d *Data) { + now := NowISO() + var current *Agent + for _, item := range d.Agents { + if item.ID == agent.ID { + current = item + break + } + } + var server *Server + for _, item := range d.Servers { + if item.ID == agent.ServerID { + server = item + break + } + } + if current != nil { + current.LastSeenAt = now + version := JSString(body["version"]) + if version == "" { + version = current.Version + } + current.Version = CleanTextStr(version, 32) + } + if server != nil { + server.Status = "online" + server.LastSeenAt = &now + if sys, ok := body["system"]; ok && jsTruthy(sys) { + server.System = rawSystemValue(sys) + } else if len(server.System) == 0 { + server.System = json.RawMessage("{}") + } + server.EnginePresent = true + if eng, ok := body["engine"].(map[string]any); ok { + status, _ := eng["status"].(string) + if status == "ready" || status == "error" { + certs := []string{} + if arr, isArr := eng["certificates"].([]any); isArr { + for _, v := range arr[:min(50, len(arr))] { + c := strings.ToLower(CleanText(v, 253)) + if certDomainRe.MatchString(c) { + certs = append(certs, c) + } + } + } + singBoxStatus := "inactive" + if s, _ := eng["singBoxStatus"].(string); s == "ready" { + singBoxStatus = "ready" + } + installed, _ := eng["singBoxInstalled"].(bool) + server.Engine = &EngineState{ + Status: status, + Detail: RedactSecrets(CleanText(eng["detail"], 400)), + SingBoxInstalled: installed, + Certificates: certs, + SingBoxStatus: singBoxStatus, + At: now, + } + } + } + } + }) + if arr, ok := body["resources"].([]any); ok { + resources := []string{} + for _, v := range arr { + if s, isStr := v.(string); isStr { + resources = append(resources, s) + } + } + if _, rerr := a.orchestrator.ReconcileFromHeartbeat(agent.ServerID, resources); rerr != nil { + fmt.Fprintf(os.Stderr, "Heartbeat reconciliation failed: %v\n", rerr) + } + } + SendJSON(w, 200, map[string]any{"ok": true, "serverTime": NowISO()}, nil) + return true, nil + } + + if r.Method == http.MethodPost && pathname == "/api/agent/tls-domain" { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + domainText, err := RequiredText(body["domain"], "节点 TLS 域名", 253) + if err != nil { + return true, err + } + domain := strings.ToLower(domainText) + _, err = a.store.Transaction(func(d *Data) (any, error) { + var server *Server + for _, item := range d.Servers { + if item.ID == agent.ServerID { + server = item + break + } + } + if server == nil { + return nil, httpErr(404, "设备不存在") + } + if _, verr := NormalizeServer(map[string]any{"tlsDomain": domain}, server); verr != nil { + return nil, verr + } + if server.TLSDomain != "" && server.TLSDomain != domain { + for _, node := range d.Nodes { + if node.ServerID != server.ID { + continue + } + if node.Protocol != "anytls" && node.Protocol != "hysteria2" && node.Protocol != "vless-ws-tls" { + continue + } + if node.Status == "active" || node.Status == "deploying" { + return nil, httpErr(409, "设备已有使用其他域名的 TLS 节点;请先处理现有节点") + } + } + } + if server.TLSDomain != domain { + server.TLSDomain = domain + server.UpdatedAt = NowISO() + Audit(d, "agent:"+agent.ID, "set_tls_domain", server.ID, map[string]any{"domain": domain}) + } + if server.Engine == nil { + server.Engine = &EngineState{} + } + found := false + for _, c := range server.Engine.Certificates { + if c == domain { + found = true + break + } + } + if !found { + server.Engine.Certificates = append(server.Engine.Certificates, domain) + } + return nil, nil + }) + if err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"ok": true, "domain": domain}, nil) + return true, nil + } + + if r.Method == http.MethodPost && pathname == "/api/agent/poll" { + data := a.store.Snapshot() + hasQueued := false + for _, job := range data.Jobs { + if job.ServerID == agent.ServerID && job.Status == "queued" { + hasQueued = true + break + } + } + if !hasQueued { + SendJSON(w, 200, map[string]any{"job": nil}, nil) + return true, nil + } + pickedAny, err := a.store.Transaction(func(d *Data) (any, error) { + for _, job := range d.Jobs { + if job.ServerID == agent.ServerID && job.Status == "queued" { + now := NowISO() + job.Status = "running" + job.Attempts++ + job.StartedAt = &now + job.UpdatedAt = now + lease := ISOFromTime(time.Now().Add(5 * time.Minute)) + job.LeaseUntil = &lease + return cloneJSONValue(job), nil + } + } + return nil, ErrSkip + }) + if err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"job": pickedAny}, nil) + return true, nil + } + + if params, ok := RoutePattern("/api/agent/jobs/:id/complete", pathname); ok && r.Method == http.MethodPost { + data := a.store.Snapshot() + var job *Job + for _, item := range data.Jobs { + if item.ID == params["id"] && item.ServerID == agent.ServerID { + job = item + break + } + } + if job == nil { + SendError(w, 404, "任务不存在", "not_found") + return true, nil + } + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + artifacts := map[string]json.RawMessage{} + if resMap, ok := body["result"].(map[string]any); ok { + if artMap, ok := resMap["artifacts"].(map[string]any); ok { + for k, v := range artMap { + raw, merr := marshalJSON(v) + if merr == nil { + artifacts[k] = raw + } + } + } + } + var errPtr *string + if s := JSString(body["error"]); s != "" { + errPtr = &s + } + updated, err := a.orchestrator.CompleteJob(job.ID, jsTruthy(body["success"]), CompleteJobResult{Artifacts: artifacts}, errPtr) + if err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"job": updated}, nil) + return true, nil + } + + if r.Method == http.MethodPost && pathname == "/api/agent/usage" { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + epoch := CleanText(body["epoch"], 80) + samples := []UsageSample{} + if arr, ok := body["samples"].([]any); ok { + for _, item := range arr { + m, isMap := item.(map[string]any) + if !isMap { + continue + } + sampleEpoch := epoch + if s, isStr := m["epoch"].(string); isStr { + sampleEpoch = truncateRunes(s, 80) + } + sample := UsageSample{ + Kind: JSString(m["kind"]), + ID: JSString(m["id"]), + Uplink: toJSONNumber(m["uplink"]), + Downlink: toJSONNumber(m["downlink"]), + Epoch: &sampleEpoch, + } + samples = append(samples, sample) + } + } + errText := RedactSecrets(CleanText(body["error"], 240)) + if err := a.orchestrator.RecordUsage(agent.ServerID, samples, errText, epoch); err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"ok": true}, nil) + return true, nil + } + + if r.Method == http.MethodPost && pathname == "/api/agent/observations" { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + _, err = a.store.Transaction(func(d *Data) (any, error) { + ttlMinutes := d.Settings.ObservationTtlMinutes + if ttlMinutes == 0 { + ttlMinutes = 10 + } + cutoff := time.Now().UnixMilli() - int64(ttlMinutes*60000) + kept := d.Observations[:0] + for _, item := range d.Observations { + if ms, ok := ParseISOMs(item.LastSeenAt); ok && ms > cutoff { + kept = append(kept, item) + } + } + d.Observations = kept + affected := []string{} + affectedSet := map[string]bool{} + samples, _ := body["observations"].([]any) + if len(samples) > 1000 { + samples = samples[:1000] + } + now := NowISO() + for _, rawSample := range samples { + m, isMap := rawSample.(map[string]any) + if !isMap { + continue + } + customerID := CleanText(m["customerId"], 100) + ip := CleanText(m["ip"], 64) + ip = strings.TrimPrefix(ip, "[") + ip = strings.TrimSuffix(ip, "]") + if net.ParseIP(ip) == nil { + continue + } + valid := false + for _, nc := range d.NodeClients { + if nc.CustomerID != customerID || nc.Status != "active" { + continue + } + for _, node := range d.Nodes { + if node.ID == nc.NodeID && node.ServerID == agent.ServerID && node.Status == "active" { + valid = true + break + } + } + if valid { + break + } + } + if !valid { + continue + } + var existing *Observation + for _, item := range d.Observations { + if item.CustomerID == customerID && item.IP == ip { + existing = item + break + } + } + if existing != nil { + existing.LastSeenAt = now + } else { + d.Observations = append(d.Observations, &Observation{ + ID: NewID("obs"), + CustomerID: customerID, + IP: ip, + FirstSeen: now, + LastSeenAt: now, + }) + } + if !affectedSet[customerID] { + affectedSet[customerID] = true + affected = append(affected, customerID) + } + } + for _, customerID := range affected { + var customer *Customer + for _, item := range d.Customers { + if item.ID == customerID { + customer = item + break + } + } + if customer == nil || customer.Status != "active" || customer.IPLimit <= 0 { + continue + } + ips := map[string]bool{} + for _, item := range d.Observations { + if item.CustomerID == customerID { + ips[item.IP] = true + } + } + if int64(len(ips)) <= customer.IPLimit { + continue + } + reason := "ip_limit" + customer.Status = "suspended" + customer.SuspendReason = &reason + customer.SuspendReasonPresent = true + customer.UpdatedAt = NowISO() + _, _ = SuspendCustomerResources(d, customer, "agent:"+agent.ID) + Audit(d, "agent:"+agent.ID, "suspend_ip_limit", customer.ID, map[string]any{"ipCount": len(ips), "limit": customer.IPLimit}) + } + return nil, nil + }) + if err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"ok": true}, nil) + return true, nil + } + return false, nil +} + +// toJSONNumber normalizes a body value into a json.Number for +// UsageSample fields; the orchestrator applies the JS safe-integer +// validation (empty/invalid numbers are skipped there). +func toJSONNumber(v any) json.Number { + switch t := v.(type) { + case json.Number: + return t + case string: + return json.Number(t) + case bool: + if t { + return json.Number("1") + } + return json.Number("0") + case float64: + return json.Number(strconv.FormatFloat(t, 'g', -1, 64)) + default: + return json.Number("") + } +} + +// ---------- admin routes ---------- + +func findServer(d *Data, id string) *Server { + for _, item := range d.Servers { + if item.ID == id { + return item + } + } + return nil +} + +func findCustomer(d *Data, id string) *Customer { + for _, item := range d.Customers { + if item.ID == id { + return item + } + } + return nil +} + +func findNode(d *Data, id string) *Node { + for _, item := range d.Nodes { + if item.ID == id { + return item + } + } + return nil +} + +func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname string) (bool, error) { + if !strings.HasPrefix(pathname, "/api/") { + return false, nil + } + changing := r.Method != http.MethodGet && r.Method != http.MethodHead + auth := a.RequireAdmin(w, r, changing) + if auth == nil { + return true, nil + } + actor := auth.User.Username + + if r.Method == http.MethodGet && pathname == "/api/overview" { + data := a.store.Snapshot() + nowMs := time.Now().UnixMilli() + online := 0 + for _, item := range data.Servers { + if item.Status != "online" { + continue + } + var seenMs int64 + if item.LastSeenAt != nil { + if ms, ok := ParseISOMs(*item.LastSeenAt); ok { + seenMs = ms + } + } + if nowMs-seenMs < 120000 { + online++ + } + } + activeNodes := 0 + for _, item := range data.Nodes { + if item.Status == "active" { + activeNodes++ + } + } + queuedJobs := 0 + for _, item := range data.Jobs { + if item.Status == "queued" || item.Status == "running" { + queuedJobs++ + } + } + activity := data.Activity + if len(activity) > 12 { + activity = activity[:12] + } + if activity == nil { + activity = []*Activity{} + } + degraded := []map[string]any{} + for _, item := range data.Nodes { + if item.Status == "failed" || item.Status == "degraded" { + degraded = append(degraded, PublicNode(item)) + if len(degraded) == 10 { + break + } + } + } + SendJSON(w, 200, map[string]any{ + "counts": map[string]any{ + "servers": len(data.Servers), + "online": online, + "customers": len(data.Customers), + "nodes": len(data.Nodes), + "activeNodes": activeNodes, + "queuedJobs": queuedJobs, + }, + "version": VERSION, + "activity": activity, + "degraded": degraded, + }, nil) + return true, nil + } + + if r.Method == http.MethodGet && pathname == "/api/protocols" { + SendJSON(w, 200, map[string]any{"profiles": ProfileCatalog, "realityPresets": RealityPresets}, nil) + return true, nil + } + + if r.Method == http.MethodPatch && pathname == "/api/account" { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + if !VerifySecret(secretString(body["currentPassword"]), auth.User.PasswordHash) { + return true, httpErr(400, "当前密码不正确") + } + username := auth.User.Username + if _, ok := body["username"]; ok { + name, err := RequiredText(body["username"], "登录账号", 64) + if err != nil { + return true, err + } + username = name + } + newPassword := "" + if body["newPassword"] != nil { + newPassword = truncateRunes(JSString(body["newPassword"]), 256) + } + if newPassword != "" && len([]rune(newPassword)) < 10 { + return true, httpErr(400, "新密码至少需要 10 位") + } + _, err = a.store.Transaction(func(d *Data) (any, error) { + var item *User + for _, entry := range d.Users { + if entry.ID == auth.User.ID { + item = entry + break + } + } + if item == nil { + return nil, httpErr(404, "账号不存在") + } + for _, entry := range d.Users { + if entry.ID != item.ID && entry.Username == username { + return nil, httpErr(409, "该登录账号已被使用") + } + } + item.Username = username + if newPassword != "" { + item.PasswordHash = HashSecret(newPassword) + } + now := NowISO() + item.UpdatedAt = &now + Audit(d, actor, "update_account", item.ID, map[string]any{"username": username}) + return nil, nil + }) + if err != nil { + return true, err + } + a.replaceSessions(NewSessionManager(a.sessionHours())) + SendJSON(w, 200, map[string]any{"ok": true, "reauthenticate": true}, clearSessionCookie()) + return true, nil + } + + if r.Method == http.MethodGet && pathname == "/api/servers" { + data := a.store.Snapshot() + servers := []map[string]any{} + for _, item := range data.Servers { + servers = append(servers, PublicServer(item, data)) + } + SendJSON(w, 200, map[string]any{"servers": servers}, nil) + return true, nil + } + if r.Method == http.MethodPost && pathname == "/api/servers" { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + serverAny, err := a.store.Transaction(func(d *Data) (any, error) { + next, err := NormalizeServer(body, nil) + if err != nil { + return nil, err + } + next.ID = NewID("srv") + next.Status = "pending" + next.CreatedAt = NowISO() + next.UpdatedAt = NowISO() + d.Servers = append(d.Servers, next) + Audit(d, actor, "create_server", next.ID, map[string]any{"name": next.Name}) + return cloneJSONValue(next), nil + }) + if err != nil { + return true, err + } + SendJSON(w, 201, map[string]any{"server": serverAny}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/servers/:id", pathname); ok && r.Method == http.MethodPatch { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + serverAny, err := a.store.Transaction(func(d *Data) (any, error) { + item := findServer(d, params["id"]) + if item == nil { + return nil, httpErr(404, "服务器不存在") + } + next, err := NormalizeServer(body, item) + if err != nil { + return nil, err + } + *item = *next + item.UpdatedAt = NowISO() + Audit(d, actor, "update_server", item.ID, map[string]any{"name": item.Name}) + return cloneJSONValue(item), nil + }) + if err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"server": serverAny}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/servers/:id", pathname); ok && r.Method == http.MethodDelete { + _, err := a.store.Transaction(func(d *Data) (any, error) { + index := -1 + for i, item := range d.Servers { + if item.ID == params["id"] { + index = i + break + } + } + if index < 0 { + return nil, httpErr(404, "服务器不存在") + } + var referencing []*Node + for _, item := range d.Nodes { + if item.ServerID == params["id"] || strVal(item.ExitServerID) == params["id"] { + referencing = append(referencing, item) + } + } + for _, item := range referencing { + if item.Status == "removing" { + return nil, httpErr(409, "设备仍有待清理资源。等待 Agent 上线确认清理后,再删除设备") + } + } + if len(referencing) > 0 { + return nil, httpErr(409, "设备仍被节点引用。请先删除关联节点") + } + d.Servers = append(d.Servers[:index], d.Servers[index+1:]...) + for _, agent := range d.Agents { + if agent.ServerID == params["id"] { + agent.Status = "revoked" + } + } + Audit(d, actor, "delete_server", params["id"], nil) + return nil, nil + }) + if err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"ok": true}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/servers/:id/enrollment-token", pathname); ok && r.Method == http.MethodPost { + raw := RandomToken(30) + tokenAny, err := a.store.Transaction(func(d *Data) (any, error) { + if findServer(d, params["id"]) == nil { + return nil, httpErr(404, "服务器不存在") + } + next := &EnrollmentToken{ + ID: NewID("enr"), + ServerID: params["id"], + TokenHash: HashSecret(raw), + CreatedAt: NowISO(), + ExpiresAt: ISOFromTime(time.Now().Add(30 * time.Minute)), + UsedAt: nil, + } + d.EnrollmentTokens = append(d.EnrollmentTokens, next) + Audit(d, actor, "create_enrollment", params["id"], nil) + return next, nil + }) + if err != nil { + return true, err + } + SendJSON(w, 201, map[string]any{"token": raw, "expiresAt": tokenAny.(*EnrollmentToken).ExpiresAt}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/servers/:id/cleanup", pathname); ok && r.Method == http.MethodPost { + countAny, err := a.store.Transaction(func(d *Data) (any, error) { + if findServer(d, params["id"]) == nil { + return nil, httpErr(404, "服务器不存在") + } + queued := 0 + for _, node := range d.Nodes { + if node.ServerID != params["id"] && strVal(node.ExitServerID) != params["id"] { + continue + } + if node.Status != "removing" { + continue + } + type leg struct { + kind, resourceID string + } + legs := []leg{} + if node.ServerID == params["id"] { + legs = append(legs, leg{"node", node.ResourceID}) + } + if strVal(node.ExitServerID) == params["id"] && forwardNode(node) && node.ExitResourceID != "" { + legs = append(legs, leg{"node-exit", node.ExitResourceID}) + } + for _, l := range legs { + pending := false + for _, job := range d.Jobs { + if job.NodeID == node.ID && job.Action == "delete_resource" && job.Payload != nil && + job.Payload.ResourceID == l.resourceID && (job.Status == "queued" || job.Status == "running") { + pending = true + break + } + } + if pending { + continue + } + QueueJob(d, params["id"], node.ID, "delete_resource", &JobPayload{ResourceID: l.resourceID, Kind: l.kind}) + queued++ + } + } + Audit(d, actor, "retry_cleanup", params["id"], map[string]any{"queued": queued}) + return queued, nil + }) + if err != nil { + return true, err + } + SendJSON(w, 202, map[string]any{"queued": countAny}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/servers/:id/forget", pathname); ok && r.Method == http.MethodPost { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + resultAny, err := a.store.Transaction(func(d *Data) (any, error) { + index := -1 + for i, item := range d.Servers { + if item.ID == params["id"] { + index = i + break + } + } + if index < 0 { + return nil, httpErr(404, "服务器不存在") + } + server := d.Servers[index] + if JSString(body["confirm"]) != "FORGET" || JSString(body["name"]) != server.Name { + return nil, httpErr(400, "请输入准确设备名称以确认") + } + if server.Status == "online" { + return nil, httpErr(409, "在线设备不能强制遗忘,请先正常停用并清理资源") + } + var residual []*Node + for _, item := range d.Nodes { + if item.ServerID == server.ID || strVal(item.ExitServerID) == server.ID { + residual = append(residual, item) + } + } + for _, item := range residual { + if item.Status != "removing" { + return nil, httpErr(409, "仍有未停用的节点,请先删除关联节点") + } + } + residualIDs := map[string]bool{} + for _, item := range residual { + residualIDs[item.ID] = true + } + for _, item := range d.Jobs { + if residualIDs[item.NodeID] && item.Status == "running" { + return nil, httpErr(409, "设备任务尚在执行,不能强制遗忘") + } + } + jobs := d.Jobs[:0] + for _, item := range d.Jobs { + if !residualIDs[item.NodeID] { + jobs = append(jobs, item) + } + } + d.Jobs = jobs + ncs := d.NodeClients[:0] + for _, item := range d.NodeClients { + if !residualIDs[item.NodeID] { + ncs = append(ncs, item) + } + } + d.NodeClients = ncs + for _, item := range residual { + DropRealityKey(d, item) + } + nodes := d.Nodes[:0] + for _, item := range d.Nodes { + if !residualIDs[item.ID] { + nodes = append(nodes, item) + } + } + d.Nodes = nodes + for _, agent := range d.Agents { + if agent.ServerID == server.ID { + agent.Status = "revoked" + } + } + d.Servers = append(d.Servers[:index], d.Servers[index+1:]...) + Audit(d, actor, "force_forget_server", server.ID, map[string]any{"name": server.Name, "unconfirmedResources": len(residual)}) + return map[string]any{"unconfirmedResources": len(residual)}, nil + }) + if err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"ok": true, "unconfirmedResources": resultAny.(map[string]any)["unconfirmedResources"]}, nil) + return true, nil + } + + if r.Method == http.MethodGet && pathname == "/api/customers" { + data := a.store.Snapshot() + customers := []map[string]any{} + for _, item := range data.Customers { + customers = append(customers, PublicCustomer(item, data)) + } + SendJSON(w, 200, map[string]any{"customers": customers}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/customers/:id/access", pathname); ok && r.Method == http.MethodGet { + data := a.store.Snapshot() + customer := findCustomer(data, params["id"]) + if customer == nil { + return true, httpErr(404, "客户不存在") + } + observed := []map[string]any{} + for _, obs := range ObservedIPs(data, customer.ID, time.Now()) { + observed = append(observed, map[string]any{"ip": obs.IP, "firstSeenAt": obs.FirstSeen, "lastSeenAt": obs.LastSeenAt}) + } + var events []*SubscriptionAccess + for _, item := range data.SubscriptionAccess { + if item.CustomerID == customer.ID { + events = append(events, item) + } + } + if len(events) > 100 { + events = events[len(events)-100:] + } + eventList := []map[string]any{} + for i := len(events) - 1; i >= 0; i-- { + m := recordToMap(events[i]) + delete(m, "clientKey") + eventList = append(eventList, m) + } + SendJSON(w, 200, map[string]any{ + "limits": map[string]any{"ip": customer.IPLimit, "subscriptionClients": customer.DeviceLimit}, + "observedIps": observed, + "subscriptionClients": len(ActiveClients(data, customer.ID, time.Now())), + "events": eventList, + }, nil) + return true, nil + } + if params, ok := RoutePattern("/api/customers/:id/access", pathname); ok && r.Method == http.MethodDelete { + _, err := a.store.Transaction(func(d *Data) (any, error) { + if findCustomer(d, params["id"]) == nil { + return nil, httpErr(404, "客户不存在") + } + access := d.SubscriptionAccess[:0] + for _, item := range d.SubscriptionAccess { + if item.CustomerID != params["id"] { + access = append(access, item) + } + } + d.SubscriptionAccess = access + clients := d.SubscriptionClients[:0] + for _, item := range d.SubscriptionClients { + if item.CustomerID != params["id"] { + clients = append(clients, item) + } + } + d.SubscriptionClients = clients + Audit(d, actor, "reset_subscription_access", params["id"], nil) + return nil, nil + }) + if err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"ok": true}, nil) + return true, nil + } + if r.Method == http.MethodPost && pathname == "/api/customers" { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + customerAny, err := a.store.Transaction(func(d *Data) (any, error) { + name, err := RequiredText(body["name"], "客户名称", 120) + if err != nil { + return nil, err + } + traffic, err := NonNegativeNumber(body["trafficLimitBytes"], "流量上限", false) + if err != nil { + return nil, err + } + expires, err := OptionalISO(body["expiresAt"], "到期日期") + if err != nil { + return nil, err + } + ipLimit, err := NonNegativeNumber(body["ipLimit"], "IP 上限", true) + if err != nil { + return nil, err + } + deviceLimit, err := NonNegativeNumber(body["deviceLimit"], "设备上限", true) + if err != nil { + return nil, err + } + next := &Customer{ + ID: NewID("cus"), + SubscriptionToken: RandomToken(32), + Name: name, + Group: CleanText(body["group"], 80), + Status: "active", + TrafficLimitBytes: traffic, + UsedBytes: 0, + UsedUplinkBytes: 0, + UsedDownlinkBytes: 0, + ExpiresAt: expires, + IPLimit: int64(ipLimit), + DeviceLimit: int64(deviceLimit), + Tags: sliceStrings(asIds(body["tags"]), 20), + Notes: CleanText(body["notes"], 1000), + CreatedAt: NowISO(), + UpdatedAt: NowISO(), + } + d.Customers = append(d.Customers, next) + Audit(d, actor, "create_customer", next.ID, map[string]any{"name": next.Name}) + return cloneJSONValue(next), nil + }) + if err != nil { + return true, err + } + SendJSON(w, 201, map[string]any{"customer": customerAny}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/customers/:id", pathname); ok && r.Method == http.MethodPatch { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + customerAny, err := a.store.Transaction(func(d *Data) (any, error) { + item := findCustomer(d, params["id"]) + if item == nil { + return nil, httpErr(404, "客户不存在") + } + wasActive := item.Status == "active" + previousReason := strVal(item.SuspendReason) + if _, ok := body["name"]; ok { + name, err := RequiredText(body["name"], "客户名称", 120) + if err != nil { + return nil, err + } + item.Name = name + } + if _, ok := body["group"]; ok { + item.Group = CleanText(body["group"], 80) + } + if _, ok := body["notes"]; ok { + item.Notes = CleanText(body["notes"], 1000) + } + if _, ok := body["trafficLimitBytes"]; ok { + v, err := NonNegativeNumber(body["trafficLimitBytes"], "流量上限", false) + if err != nil { + return nil, err + } + item.TrafficLimitBytes = v + } + if _, ok := body["ipLimit"]; ok { + v, err := NonNegativeNumber(body["ipLimit"], "IP 上限", true) + if err != nil { + return nil, err + } + item.IPLimit = int64(v) + } + if _, ok := body["deviceLimit"]; ok { + v, err := NonNegativeNumber(body["deviceLimit"], "设备上限", true) + if err != nil { + return nil, err + } + item.DeviceLimit = int64(v) + } + if _, ok := body["expiresAt"]; ok { + v, err := OptionalISO(body["expiresAt"], "到期日期") + if err != nil { + return nil, err + } + item.ExpiresAt = v + } + if _, ok := body["tags"]; ok { + item.Tags = sliceStrings(asIds(body["tags"]), 20) + } + reason := CustomerBlockReason(d, item) + statusWanted := JSString(body["status"]) + if statusWanted == "active" && reason != "" { + labels := map[string]string{"expired": "到期时间", "traffic_limit": "流量额度", "ip_limit": "节点 IP 上限"} + return nil, httpErr(409, "客户仍受%s限制,请先修改限制再启用", labels[reason]) + } + if statusWanted == "suspended" { + item.Status = "suspended" + item.SuspendReason = StrPtr("manual") + item.SuspendReasonPresent = true + } else if reason != "" && wasActive { + item.Status = "suspended" + item.SuspendReason = StrPtr(reason) + item.SuspendReasonPresent = true + } else if statusWanted == "active" || (!wasActive && reason == "" && + (previousReason == "traffic_limit" || previousReason == "expired" || previousReason == "ip_limit")) { + item.Status = "active" + item.SuspendReason = nil + item.SuspendReasonPresent = true + } + if wasActive && item.Status == "suspended" { + _, _ = SuspendCustomerResources(d, item, actor) + } + if !wasActive && item.Status == "active" { + if _, err := ResumeCustomerResources(d, item, actor); err != nil { + return nil, err + } + } + item.UpdatedAt = NowISO() + Audit(d, actor, "update_customer", item.ID, nil) + return cloneJSONValue(item), nil + }) + if err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"customer": customerAny}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/customers/:id/reset-usage", pathname); ok && r.Method == http.MethodPost { + _, err := a.store.Transaction(func(d *Data) (any, error) { + item := findCustomer(d, params["id"]) + if item == nil { + return nil, httpErr(404, "客户不存在") + } + item.UsedBytes = 0 + item.UsedUplinkBytes = 0 + item.UsedDownlinkBytes = 0 + if item.Status == "suspended" && strVal(item.SuspendReason) == "traffic_limit" && CustomerBlockReason(d, item) == "" { + item.Status = "active" + item.SuspendReason = nil + item.SuspendReasonPresent = true + if _, err := ResumeCustomerResources(d, item, actor); err != nil { + return nil, err + } + } + item.UpdatedAt = NowISO() + Audit(d, actor, "reset_customer_usage", item.ID, nil) + return nil, nil + }) + if err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"ok": true}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/customers/:id/rotate-subscription", pathname); ok && r.Method == http.MethodPost { + customerAny, err := a.store.Transaction(func(d *Data) (any, error) { + item := findCustomer(d, params["id"]) + if item == nil { + return nil, httpErr(404, "客户不存在") + } + item.SubscriptionToken = RandomToken(32) + item.UpdatedAt = NowISO() + Audit(d, actor, "rotate_subscription", item.ID, nil) + return PublicCustomer(item, d), nil + }) + if err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"customer": customerAny}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/customers/:id", pathname); ok && r.Method == http.MethodDelete { + _, err := a.store.Transaction(func(d *Data) (any, error) { + index := -1 + for i, item := range d.Customers { + if item.ID == params["id"] { + index = i + break + } + } + if index < 0 { + return nil, httpErr(404, "客户不存在") + } + for _, nc := range d.NodeClients { + if nc.CustomerID != params["id"] { + continue + } + if node := findNode(d, nc.NodeID); node != nil && !node.PendingDelete { + return nil, httpErr(409, "客户仍有节点分配。请先移除全部分配") + } + } + d.Customers = append(d.Customers[:index], d.Customers[index+1:]...) + access := d.SubscriptionAccess[:0] + for _, item := range d.SubscriptionAccess { + if item.CustomerID != params["id"] { + access = append(access, item) + } + } + d.SubscriptionAccess = access + clients := d.SubscriptionClients[:0] + for _, item := range d.SubscriptionClients { + if item.CustomerID != params["id"] { + clients = append(clients, item) + } + } + d.SubscriptionClients = clients + obs := d.Observations[:0] + for _, item := range d.Observations { + if item.CustomerID != params["id"] { + obs = append(obs, item) + } + } + d.Observations = obs + Audit(d, actor, "delete_customer", params["id"], nil) + return nil, nil + }) + if err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"ok": true}, nil) + return true, nil + } + + if r.Method == http.MethodGet && pathname == "/api/nodes" { + data := a.store.Snapshot() + nodes := []map[string]any{} + visibleIDs := map[string]bool{} + for _, item := range data.Nodes { + if item.PendingDelete { + continue + } + visibleIDs[item.ID] = true + nodes = append(nodes, PublicNode(item)) + } + nodeClients := []map[string]any{} + for _, nc := range data.NodeClients { + if !visibleIDs[nc.NodeID] { + continue + } + m := recordToMap(nc) + delete(m, "credentials") + uri := NodeClientURI(data, nc) + if uri == "" { + m["clientUri"] = nil + } else { + m["clientUri"] = uri + } + nodeClients = append(nodeClients, m) + } + SendJSON(w, 200, map[string]any{"nodes": nodes, "nodeClients": nodeClients}, nil) + return true, nil + } + if r.Method == http.MethodPost && pathname == "/api/nodes" { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + nodeAny, err := a.store.Transaction(func(d *Data) (any, error) { + normalized, err := NormalizeNode(body, nil, d) + if err != nil { + return nil, err + } + next := normalized + next.ID = NewID("nod") + next.Status = "draft" + next.EntryReady = false + next.ExitReady = false + next.ResourceID = NewID("res") + next.ExitResourceID = NewID("res") + next.Artifacts = map[string]json.RawMessage{} + next.Error = nil + next.PendingDelete = false + next.UsageCounters = nil + next.RetiredUsageEpochs = []string{} + next.MeteredTraffic = nil + next.LastUsageAt = nil + next.CreatedAt = NowISO() + next.UpdatedAt = NowISO() + EnsureNodeSecrets(next) + d.Nodes = append(d.Nodes, next) + Audit(d, actor, "create_node", next.ID, map[string]any{"name": next.Name}) + return cloneJSONValue(next), nil + }) + if err != nil { + return true, err + } + SendJSON(w, 201, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/nodes/:id", pathname); ok && r.Method == http.MethodPatch { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + nodeAny, err := a.store.Transaction(func(d *Data) (any, error) { + item := findNode(d, params["id"]) + if item == nil { + return nil, httpErr(404, "节点不存在") + } + if item.Status == "removing" { + return nil, httpErr(409, "节点正在移除中,请等待完成后再编辑") + } + oldServerID := item.ServerID + oldExitServerID := strVal(item.ExitServerID) + oldForward := forwardNode(item) + oldProtocol := item.Protocol + next, err := NormalizeNode(body, item, d) + if err != nil { + return nil, err + } + *item = *next + EnsureNodeSecrets(item) + if item.Protocol != oldProtocol { + for _, nc := range d.NodeClients { + if nc.NodeID == item.ID { + nc.Credentials = NewNodeClientCredentials(item.Protocol) + nc.UpdatedAt = NowISO() + } + } + } + if item.ServerID != oldServerID { + QueueJob(d, oldServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ResourceID, Kind: "node"}) + item.EntryReady = false + } + if oldForward && (!forwardNode(item) || strVal(item.ExitServerID) != oldExitServerID) { + QueueJob(d, oldExitServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ExitResourceID, Kind: "node-exit"}) + item.ExitReady = false + } + if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" { + if err := RebuildNode(d, item, true); err != nil { + return nil, err + } + } + item.UpdatedAt = NowISO() + Audit(d, actor, "update_node", item.ID, map[string]any{"name": item.Name}) + return cloneJSONValue(item), nil + }) + if err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/nodes/:id", pathname); ok && r.Method == http.MethodDelete { + data := a.store.Snapshot() + for _, job := range data.Jobs { + if job.NodeID == params["id"] && job.Status == "running" { + return true, httpErr(409, "节点任务正在设备上执行,请稍后刷新再删除") + } + } + if _, err := a.orchestrator.RemoveNode(params["id"], actor, true); err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"ok": true}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/nodes/:id/deploy", pathname); ok && r.Method == http.MethodPost { + if _, err := a.orchestrator.DeployNode(params["id"], actor); err != nil { + return true, err + } + SendJSON(w, 202, map[string]any{"ok": true}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/nodes/:id/remove", pathname); ok && r.Method == http.MethodPost { + if _, err := a.orchestrator.RemoveNode(params["id"], actor, false); err != nil { + return true, err + } + SendJSON(w, 202, map[string]any{"ok": true}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/nodes/:id/repair", pathname); ok && r.Method == http.MethodPost { + if _, err := a.orchestrator.RepairNode(params["id"], actor); err != nil { + return true, err + } + SendJSON(w, 202, map[string]any{"ok": true}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/nodes/:id/assign", pathname); ok && r.Method == http.MethodPost { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + customerID, err := RequiredText(body["customerId"], "客户", 120) + if err != nil { + return true, err + } + nodeClient, err := a.orchestrator.AssignCustomer(params["id"], customerID, actor) + if err != nil { + return true, err + } + m := recordToMap(nodeClient) + delete(m, "credentials") + SendJSON(w, 200, map[string]any{"nodeClient": m}, nil) + return true, nil + } + if params, ok := RoutePattern("/api/nodes/:id/unassign", pathname); ok && r.Method == http.MethodPost { + body, err := ReadJSON(r, defaultMaxBody) + if err != nil { + return true, err + } + customerID, err := RequiredText(body["customerId"], "客户", 120) + if err != nil { + return true, err + } + if _, err := a.orchestrator.UnassignCustomer(params["id"], customerID, actor); err != nil { + return true, err + } + SendJSON(w, 200, map[string]any{"ok": true}, nil) + return true, nil + } + + if r.Method == http.MethodGet && pathname == "/api/jobs" { + data := a.store.Snapshot() + jobs := data.Jobs + if len(jobs) > 200 { + jobs = jobs[len(jobs)-200:] + } + out := []map[string]any{} + for i := len(jobs) - 1; i >= 0; i-- { + m := recordToMap(jobs[i]) + delete(m, "payload") + if s, ok := m["error"].(string); ok && s != "" { + m["error"] = RedactSecrets(s) + } + out = append(out, m) + } + SendJSON(w, 200, map[string]any{"jobs": out}, nil) + return true, nil + } + if r.Method == http.MethodGet && pathname == "/api/backup" { + body, err := marshalIndentJSON(a.store.Snapshot()) + if err != nil { + return true, err + } + h := w.Header() + h.Set("Content-Type", "application/json; charset=utf-8") + h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="nexusgate-backup-%s.json"`, NowISO()[:10])) + h.Set("Content-Length", fmt.Sprint(len(body))) + w.WriteHeader(200) + _, _ = w.Write(body) + return true, nil + } + if r.Method == http.MethodPost && pathname == "/api/restore" { + body, err := ReadJSON(r, 20*1024*1024) + if err != nil { + return true, err + } + dataRaw, hasData := body["data"] + if JSString(body["confirm"]) != "RESTORE" || !hasData || dataRaw == nil { + return true, httpErr(400, "恢复确认信息不正确") + } + raw, err := marshalJSON(dataRaw) + if err != nil { + return true, err + } + var restored Data + if err := unmarshalJSONUseNumber(raw, &restored); err != nil { + return true, err + } + if err := a.store.Replace(&restored); err != nil { + return true, err + } + snapshot := a.store.Snapshot() + needsToken := false + for _, item := range snapshot.Customers { + if item.SubscriptionToken == "" { + needsToken = true + break + } + } + if needsToken { + _, err := a.store.Transaction(func(d *Data) (any, error) { + for _, customer := range d.Customers { + if customer.SubscriptionToken == "" { + customer.SubscriptionToken = RandomToken(32) + } + } + return nil, nil + }) + if err != nil { + return true, err + } + } + a.replaceSessions(NewSessionManager(a.sessionHours())) + SendJSON(w, 200, map[string]any{"ok": true, "message": "备份已恢复,请重新登录"}, clearSessionCookie()) + return true, nil + } + return false, nil +} + +// ---------- subscription ---------- + +// FormatBytes is formatBytes from server.js. +func FormatBytes(bytes float64) string { + if bytes == 0 || bytes <= 0 { + return "0 B" + } + units := []string{"B", "KB", "MB", "GB", "TB"} + v := bytes + i := 0 + for v >= 1024 && i < len(units)-1 { + v /= 1024 + i++ + } + return fmt.Sprintf("%.2f%s", v, units[i]) +} + +var htmlEscaper = strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", +) + +func escHTML(s string) string { return htmlEscaper.Replace(s) } + +// formatSubscriptionDate renders a stored ISO time the way the info page's +// toLocaleString('zh-CN') does on the reference deployment +// ("2006/01/02 15:04:05"); see the report for the locale/timezone caveat. +func formatSubscriptionDate(iso string) string { + t, ok := ParseISO(iso) + if !ok { + return "Invalid Date" + } + return t.Format("2006/01/02 15:04:05") +} + +func tokenPrefix16(token string) string { + r := []rune(token) + if len(r) > 16 { + r = r[:16] + } + return string(r) +} + +// BuildSubscriptionInfoPage is buildSubscriptionInfoPage, template verbatim. +func BuildSubscriptionInfoPage(data *Data, customer *Customer, r *http.Request) string { + used := float64(customer.UsedBytes) + up := float64(customer.UsedUplinkBytes) + down := float64(customer.UsedDownlinkBytes) + total := customer.TrafficLimitBytes + remaining := -1.0 + if total > 0 { + remaining = math.Max(0, total-used) + } + pct := 0.0 + if total > 0 { + pct = math.Min(100, used/total*100) + } + statusText := "停用" + statusClass := "off" + if customer.Status == "active" { + statusText = "启用" + statusClass = "on" + } + expiresText := "永不过期" + if customer.ExpiresAt != nil { + expiresText = formatSubscriptionDate(*customer.ExpiresAt) + } + lastOnline := "从未" + if customer.LastOnlineAt != nil { + lastOnline = formatSubscriptionDate(*customer.LastOnlineAt) + } + + var assignments []*NodeClient + for _, nc := range data.NodeClients { + if nc.CustomerID == customer.ID && nc.Status == "active" { + assignments = append(assignments, nc) + } + } + host := r.Host + proto := r.Header.Get("X-Forwarded-Proto") + if proto == "" { + proto = "http" + } + subURL := proto + "://" + host + "/s/" + customer.SubscriptionToken + "/auto" + + var rows strings.Builder + for _, nc := range assignments { + node := findNode(data, nc.NodeID) + if node == nil || node.Status != "active" { + continue + } + uri := NodeClientURI(data, nc) + if uri == "" { + continue + } + protoLabel := "AnyTLS" + switch { + case strings.Contains(node.Protocol, "vless"): + protoLabel = "Vless" + case strings.Contains(node.Protocol, "shadowsocks"): + protoLabel = "Shadowsocks" + case strings.Contains(node.Protocol, "socks"): + protoLabel = "Socks" + case strings.Contains(node.Protocol, "hysteria"): + protoLabel = "Hysteria2" + } + secLabel := "" + switch { + case strings.Contains(node.Protocol, "reality"): + secLabel = "REALITY" + case strings.Contains(node.Protocol, "tls"): + secLabel = "TLS" + } + nodeName := node.Name + "-" + customer.Name + secBadge := "" + if secLabel != "" { + secBadge = `` + secLabel + "" + } + rows.WriteString("
\n" + + " " + escHTML(protoLabel) + "\n" + + " TCP\n" + + " " + secBadge + "\n" + + " " + escHTML(nodeName) + "\n" + + " \n" + + " \n" + + " \n" + + "
") + } + nodeRows := rows.String() + + var uris []string + for _, nc := range assignments { + if uri := NodeClientURI(data, nc); uri != "" { + uris = append(uris, uri) + } + } + allUris := strings.Join(uris, "\n") + + totalText := "无限制" + if total > 0 { + totalText = FormatBytes(total) + } + remainingText := "无限制" + if remaining >= 0 { + remainingText = FormatBytes(remaining) + } + progress := "" + if total > 0 { + progress = "
\n" + + "
" + FormatBytes(used) + " / " + FormatBytes(total) + "
\n" + + "
\n" + + "
" + FormatBytes(remaining) + " 剩余" + fmt.Sprintf("%.1f", pct) + "%
\n" + + "
" + } + nodesHTML := nodeRows + if nodesHTML == "" { + nodesHTML = `

暂无可用节点

` + } + token16 := escHTML(tokenPrefix16(customer.SubscriptionToken)) + + return ` + +订阅信息 - ` + escHTML(customer.Name) + ` + +
+

订阅信息 ` + token16 + `

+ + + + + + + + + + + +
订阅 ID` + token16 + `
用户` + escHTML(customer.Name) + `
状态` + statusText + `
已下载` + FormatBytes(down) + `
已上传` + FormatBytes(up) + `
使用情况` + FormatBytes(used) + `
总配额` + totalText + `
剩余` + remainingText + `
上次在线` + escHTML(lastOnline) + `
到期` + escHTML(expiresText) + `
+` + progress + ` +
订阅信息
+
SUB` + escHTML(subURL) + ` + +
+
复制链接
+
复制全部配置
+` + nodesHTML + ` +
+
+

+
+` +} + +var subscriptionPathRe = regexp.MustCompile(`^/s/([A-Za-z0-9_-]{32,100})/(auto|raw|base64|v2ray|shadowrocket|clash|clash-smart|mihomo|singbox|surge|info)$`) + +func (a *App) handleSubscription(w http.ResponseWriter, r *http.Request, pathname string) (bool, error) { + match := subscriptionPathRe.FindStringSubmatch(pathname) + if match == nil { + return false, nil + } + headers := map[string]string{ + "Cache-Control": "no-store, private", + "X-Robots-Tag": "noindex, nofollow", + "Vary": "User-Agent", + } + if r.Method != http.MethodGet { + SendError(w, 405, "只支持 GET 请求", "request_error") + return true, nil + } + tokenText := match[1] + token := []byte(tokenText) + data := a.store.Snapshot() + var customer *Customer + for _, item := range data.Customers { + if item.SubscriptionToken == "" { + continue + } + stored := []byte(item.SubscriptionToken) + if len(stored) == len(token) && subtle.ConstantTimeCompare(stored, token) == 1 { + customer = item + break + } + } + if customer == nil { + SendJSON(w, 404, map[string]any{"message": "订阅链接不存在或已重置"}, headers) + return true, nil + } + identity := ClientIdentity(r.Header, data.CreatedAt) + access := &SubscriptionAccess{ + ID: NewID("acc"), + CustomerID: customer.ID, + At: NowISO(), + IP: GetIP(r), + UserAgent: identity.UserAgent, + IdentityType: identity.Kind, + ClientKey: identity.Key, + Format: match[2], + Status: 0, + Reason: "", + Bytes: 0, + } + nowMs := time.Now().UnixMilli() + blocked := customer.Status != "active" + if customer.ExpiresAt != nil { + if ms, ok := ParseISOMs(*customer.ExpiresAt); ok && ms <= nowMs { + blocked = true + } + } + if customer.TrafficLimitBytes > 0 && float64(customer.UsedBytes) >= customer.TrafficLimitBytes { + blocked = true + } + if blocked { + access.Status = 403 + access.Reason = "客户停用、到期或流量用尽" + _, err := a.store.Transaction(func(d *Data) (any, error) { + d.SubscriptionAccess = append(d.SubscriptionAccess, access) + PruneAccess(d, time.Now()) + return nil, nil + }) + if err != nil { + return true, err + } + SendJSON(w, 403, map[string]any{"message": "客户已停用、到期或流量用尽"}, headers) + return true, nil + } + if match[2] == "info" { + html := BuildSubscriptionInfoPage(data, customer, r) + h := w.Header() + for k, v := range headers { + h.Set(k, v) + } + h.Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(200) + _, _ = w.Write([]byte(html)) + return true, nil + } + format := match[2] + switch format { + case "v2ray", "shadowrocket": + format = "base64" + case "mihomo": + format = "clash-smart" + } + result, formatOK := FormatSubscription(data, customer, format, r.Header.Get("User-Agent")) + if !formatOK || result == nil { + restoring := false + for _, nc := range data.NodeClients { + if nc.CustomerID != customer.ID || nc.Status != "active" { + continue + } + node := findNode(data, nc.NodeID) + if node == nil || (node.Status != "deploying" && node.Status != "removing") { + continue + } + for _, job := range data.Jobs { + if job.NodeID == nc.NodeID && (job.Status == "queued" || job.Status == "running") { + restoring = true + break + } + } + if restoring { + break + } + } + access.Status = 503 + if restoring { + access.Reason = "入口节点恢复中" + } else { + access.Reason = "没有已部署的入口节点" + } + _, err := a.store.Transaction(func(d *Data) (any, error) { + d.SubscriptionAccess = append(d.SubscriptionAccess, access) + PruneAccess(d, time.Now()) + return nil, nil + }) + if err != nil { + return true, err + } + respHeaders := map[string]string{} + for k, v := range headers { + respHeaders[k] = v + } + message := "当前没有部署成功的入口节点,请在“转发与节点”部署节点" + if restoring { + respHeaders["Retry-After"] = "15" + message = "节点正在恢复,请稍后更新订阅" + } + SendJSON(w, 503, map[string]any{"message": message}, respHeaders) + return true, nil + } + body := []byte(result.Body) + allowedAny, err := a.store.Transaction(func(d *Data) (any, error) { + current := findCustomer(d, customer.ID) + clients := ActiveClients(d, customer.ID, time.Now()) + permitted := false + if current != nil && current.SubscriptionToken == tokenText && current.Status == "active" { + permitted = true + if current.ExpiresAt != nil { + if ms, ok := ParseISOMs(*current.ExpiresAt); !ok || ms <= time.Now().UnixMilli() { + permitted = false + } + } + if current.TrafficLimitBytes > 0 && !(float64(current.UsedBytes) < current.TrafficLimitBytes) { + permitted = false + } + if current.DeviceLimit != 0 { + contains := false + for _, key := range clients { + if key == identity.Key { + contains = true + break + } + } + if !contains && int64(len(clients)) >= current.DeviceLimit { + permitted = false + } + } + } + if permitted { + access.Status = 200 + access.Reason = "已返回" + access.Bytes = int64(len(body)) + var session *SubscriptionClient + for _, item := range d.SubscriptionClients { + if item.CustomerID == customer.ID && item.ClientKey == identity.Key { + session = item + break + } + } + if session != nil { + session.LastSeenAt = access.At + } else { + d.SubscriptionClients = append(d.SubscriptionClients, &SubscriptionClient{ + CustomerID: customer.ID, + ClientKey: identity.Key, + IdentityType: identity.Kind, + FirstSeenAt: access.At, + LastSeenAt: access.At, + }) + } + } else { + access.Status = 429 + access.Reason = "订阅客户端估计数超限" + access.Bytes = 0 + } + d.SubscriptionAccess = append(d.SubscriptionAccess, access) + PruneAccess(d, time.Now()) + return permitted, nil + }) + if err != nil { + return true, err + } + if allowed, _ := allowedAny.(bool); !allowed { + SendJSON(w, 429, map[string]any{"message": "订阅客户端数达到上限。可在客户访问记录中检查并重置观察窗口。"}, headers) + return true, nil + } + historical := customer.UsedBytes - customer.UsedUplinkBytes - customer.UsedDownlinkBytes + if historical < 0 { + historical = 0 + } + expire := int64(0) + if customer.ExpiresAt != nil { + if ms, ok := ParseISOMs(*customer.ExpiresAt); ok { + expire = int64(math.Floor(float64(ms) / 1000)) + } + } + totalFloor := int64(math.Floor(customer.TrafficLimitBytes)) + if totalFloor < 0 { + totalFloor = 0 + } + userinfo := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", + customer.UsedUplinkBytes+historical, customer.UsedDownlinkBytes, totalFloor, expire) + h := w.Header() + for k, v := range headers { + h.Set(k, v) + } + h.Set("Content-Type", result.ContentType) + h.Set("Content-Length", fmt.Sprint(len(body))) + h.Set("Subscription-Userinfo", userinfo) + w.WriteHeader(200) + _, _ = w.Write(body) + return true, nil +} diff --git a/panel-go/handlers_smoke_test.go b/panel-go/handlers_smoke_test.go new file mode 100644 index 0000000..1bb454f --- /dev/null +++ b/panel-go/handlers_smoke_test.go @@ -0,0 +1,174 @@ +package main + +// Minimal smoke tests for the HTTP layer: healthz, the login gate, session +// round-trip, server creation shape, static + SPA fallback, and the 404 +// shape. Uses a throwaway Store in t.TempDir(). + +import ( + "encoding/json" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func newSmokeApp(t *testing.T) *App { + t.Helper() + cookieSecure = false + store := NewStore(filepath.Join(t.TempDir(), "nexusgate.json")) + if err := store.Init(); err != nil { + t.Fatalf("store init: %v", err) + } + _, err := store.Transaction(func(d *Data) (any, error) { + d.Users = append(d.Users, &User{ + ID: NewID("usr"), + Username: "admin", + PasswordHash: HashSecret("password12345"), + Role: "owner", + Status: "active", + CreatedAt: NowISO(), + }) + return nil, nil + }) + if err != nil { + t.Fatalf("seed user: %v", err) + } + app := NewApp(store, NewSessionManager(12), NewOrchestrator(store)) + return app +} + +func doRequest(app *App, method, path, body string, headers map[string]string) *httptest.ResponseRecorder { + var reader *strings.Reader + if body != "" { + reader = strings.NewReader(body) + } else { + reader = strings.NewReader("") + } + req := httptest.NewRequest(method, path, reader) + for k, v := range headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + return rec +} + +func decodeBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode %q: %v", rec.Body.String(), err) + } + return out +} + +func TestSmokeHealthz(t *testing.T) { + app := newSmokeApp(t) + rec := doRequest(app, "GET", "/healthz", "", nil) + if rec.Code != 200 { + t.Fatalf("healthz status %d", rec.Code) + } + body := decodeBody(t, rec) + if body["ok"] != true || body["version"] != VERSION { + t.Fatalf("healthz body %v", body) + } +} + +func TestSmokeLoginFlowAndServerCreate(t *testing.T) { + app := newSmokeApp(t) + + // Unauthenticated admin API -> 401 unauthorized shape. + rec := doRequest(app, "GET", "/api/servers", "", nil) + if rec.Code != 401 { + t.Fatalf("unauth status %d", rec.Code) + } + body := decodeBody(t, rec) + if body["error"] != "unauthorized" || body["message"] != "请先登录" { + t.Fatalf("unauth body %v", body) + } + + // Wrong password -> 401 invalid_credentials. + rec = doRequest(app, "POST", "/api/auth/login", `{"username":"admin","password":"wrong"}`, nil) + if rec.Code != 401 || decodeBody(t, rec)["error"] != "invalid_credentials" { + t.Fatalf("bad login %d %s", rec.Code, rec.Body.String()) + } + + // Correct login -> 200, csrf + session cookie. + rec = doRequest(app, "POST", "/api/auth/login", `{"username":"admin","password":"password12345"}`, nil) + if rec.Code != 200 { + t.Fatalf("login status %d body %s", rec.Code, rec.Body.String()) + } + body = decodeBody(t, rec) + csrf, _ := body["csrf"].(string) + if csrf == "" { + t.Fatalf("login csrf missing: %v", body) + } + cookie := rec.Header().Get("Set-Cookie") + if !strings.HasPrefix(cookie, "ng_session=") || !strings.Contains(cookie, "HttpOnly") { + t.Fatalf("login cookie %q", cookie) + } + sessionCookie := strings.Split(cookie, ";")[0] + authHeaders := map[string]string{"Cookie": sessionCookie} + + // /api/session reflects the login. + rec = doRequest(app, "GET", "/api/session", "", authHeaders) + body = decodeBody(t, rec) + if body["authenticated"] != true || body["csrf"] != csrf { + t.Fatalf("session body %v", body) + } + + // Changing request without CSRF -> 403 csrf_failed. + rec = doRequest(app, "POST", "/api/servers", `{"name":"s1","publicAddress":"192.0.2.1"}`, authHeaders) + if rec.Code != 403 || decodeBody(t, rec)["error"] != "csrf_failed" { + t.Fatalf("csrf gate %d %s", rec.Code, rec.Body.String()) + } + + // Create server -> 201 shape. + createHeaders := map[string]string{"Cookie": sessionCookie, "X-CSRF-Token": csrf} + rec = doRequest(app, "POST", "/api/servers", `{"name":"s1","publicAddress":"192.0.2.1"}`, createHeaders) + if rec.Code != 201 { + t.Fatalf("create server %d %s", rec.Code, rec.Body.String()) + } + body = decodeBody(t, rec) + server, _ := body["server"].(map[string]any) + if server == nil || server["name"] != "s1" || server["status"] != "pending" || + !strings.HasPrefix(server["id"].(string), "srv_") || server["portRangeStart"] != float64(20000) { + t.Fatalf("server shape %v", body) + } + + // Authenticated unknown API -> 404 not_found shape. + rec = doRequest(app, "GET", "/api/does-not-exist", "", authHeaders) + if rec.Code != 404 { + t.Fatalf("404 status %d", rec.Code) + } + body = decodeBody(t, rec) + if body["error"] != "not_found" || body["message"] != "接口不存在" { + t.Fatalf("404 body %v", body) + } +} + +func TestSmokeStaticAndSPAFallback(t *testing.T) { + app := newSmokeApp(t) + rec := doRequest(app, "GET", "/", "", nil) + if rec.Code != 200 { + t.Fatalf("index status %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "<") { + t.Fatalf("index body too small: %q", rec.Body.String()) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("index cache-control %q", rec.Header().Get("Cache-Control")) + } + indexBody := rec.Body.String() + + rec = doRequest(app, "GET", "/some/client/route", "", nil) + if rec.Code != 200 || rec.Body.String() != indexBody { + t.Fatalf("spa fallback %d", rec.Code) + } + + // Unknown /api path is not swallowed by the SPA fallback. + rec = doRequest(app, "GET", "/api/nope", "", nil) + if rec.Code != 401 { // admin gate fires before the 404 for /api/* + t.Fatalf("api unknown status %d", rec.Code) + } +} diff --git a/panel-go/httpd.go b/panel-go/httpd.go new file mode 100644 index 0000000..47418c2 --- /dev/null +++ b/panel-go/httpd.go @@ -0,0 +1,439 @@ +package main + +// HTTP plumbing + JS value-semantics helpers: the Go port of lib/http.js +// plus the small coercion helpers server.js relies on (cleanText, +// requiredText, optionalIso, nonNegativeNumber, asIds). + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +// ---------- lib/http.js ---------- + +// SendJSON is sendJson: compact JSON, HTML escaping off (via marshalJSON), +// content-type/length set first, caller headers override. +func SendJSON(w http.ResponseWriter, status int, v any, headers map[string]string) { + body, err := marshalJSON(v) + if err != nil { + status = 500 + body = []byte(`{"error":"internal_error","message":"服务器内部错误"}`) + } + h := w.Header() + h.Set("Content-Type", "application/json; charset=utf-8") + h.Set("Content-Length", strconv.Itoa(len(body))) + for k, val := range headers { + h.Set(k, val) + } + w.WriteHeader(status) + _, _ = w.Write(body) +} + +// SendError is sendError: {"error":code,"message":message}. +func SendError(w http.ResponseWriter, status int, message, code string) { + SendJSON(w, status, map[string]any{"error": code, "message": message}, nil) +} + +const defaultMaxBody = 1024 * 1024 + +// ReadJSON is readJson: empty body -> empty map; over maxBytes -> 413; +// invalid JSON -> 400. Numbers decode as json.Number (UseNumber). +func ReadJSON(r *http.Request, maxBytes int64) (map[string]any, error) { + raw, err := io.ReadAll(io.LimitReader(r.Body, maxBytes+1)) + if err != nil { + return nil, err + } + if int64(len(raw)) > maxBytes { + return nil, httpErr(413, "Request body is too large") + } + if len(raw) == 0 { + return map[string]any{}, nil + } + var out map[string]any + if err := unmarshalJSONUseNumber(raw, &out); err != nil { + return nil, httpErr(400, "Invalid JSON body") + } + if out == nil { // body was `null` + out = map[string]any{} + } + return out, nil +} + +// RoutePattern is lib/http.js route(): ":param" matches a single non-empty +// path segment; a single trailing slash on the pathname is optional. +// Captured values are URI-decoded like decodeURIComponent (on a malformed +// escape the raw segment is kept). +func RoutePattern(pattern, pathname string) (map[string]string, bool) { + p := pathname + if len(p) > 1 && strings.HasSuffix(p, "/") { + p = p[:len(p)-1] + } + patSegs := strings.Split(strings.TrimPrefix(pattern, "/"), "/") + pathSegs := strings.Split(strings.TrimPrefix(p, "/"), "/") + if len(patSegs) != len(pathSegs) { + return nil, false + } + params := map[string]string{} + for i, seg := range patSegs { + if strings.HasPrefix(seg, ":") { + if pathSegs[i] == "" { + return nil, false + } + v := pathSegs[i] + if dec, err := url.PathUnescape(v); err == nil { + v = dec + } + params[seg[1:]] = v + continue + } + if seg != pathSegs[i] { + return nil, false + } + } + return params, true +} + +// securityHeaders mirrors server.js securityHeaders(). +func securityHeaders(w http.ResponseWriter) { + h := w.Header() + h.Set("X-Frame-Options", "DENY") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") +} + +// ---------- dispatch ---------- + +// trackingWriter records whether a response has been committed, mirroring +// the JS `if (!res.headersSent) ... else res.end()` error path. +type trackingWriter struct { + http.ResponseWriter + wrote bool +} + +func (t *trackingWriter) WriteHeader(status int) { + t.wrote = true + t.ResponseWriter.WriteHeader(status) +} + +func (t *trackingWriter) Write(b []byte) (int, error) { + t.wrote = true + return t.ResponseWriter.Write(b) +} + +// ServeHTTP is requestHandler: security headers first, then healthz, auth, +// agent, subscription, admin, static, SPA fallback, 404 — in that order. +func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { + tw := &trackingWriter{ResponseWriter: w} + securityHeaders(tw) + pathname := r.URL.EscapedPath() + if err := a.dispatch(tw, r, pathname); err != nil { + fmt.Fprintf(os.Stderr, "[%s] %v\n", NowISO(), err) + if !tw.wrote { + var he *HTTPError + if errors.As(err, &he) { + SendError(tw, he.Status, he.Message, "request_error") + } else { + SendError(tw, 500, "服务器内部错误", "internal_error") + } + } + } +} + +func (a *App) dispatch(w http.ResponseWriter, r *http.Request, pathname string) error { + if pathname == "/healthz" { + SendJSON(w, 200, map[string]any{"ok": true, "version": VERSION, "time": NowISO()}, nil) + return nil + } + if handled, err := a.handleAuth(w, r, pathname); err != nil || handled { + return err + } + if handled, err := a.handleAgent(w, r, pathname); err != nil || handled { + return err + } + if handled, err := a.handleSubscription(w, r, pathname); err != nil || handled { + return err + } + if handled, err := a.handleAdminApi(w, r, pathname); err != nil || handled { + return err + } + if r.Method == http.MethodGet { + served, err := ServeStatic(w, pathname) + if err != nil { + return err + } + if served { + return nil + } + } + if r.Method == http.MethodGet && !strings.HasPrefix(pathname, "/api/") { + _, err := ServeStatic(w, "/") + return err + } + SendError(w, 404, "接口不存在", "not_found") + return nil +} + +// ---------- JS value semantics ---------- + +// JSString approximates JS String(value) for the shapes JSON bodies carry: +// nil -> "", string as-is, json.Number -> its literal, bool -> "true"/"false". +// Objects/arrays yield "" (JS would produce "[object Object]"/joined text; +// no panel code path depends on those). +func JSString(v any) string { + switch t := v.(type) { + case nil: + return "" + case string: + return t + case json.Number: + return t.String() + case bool: + if t { + return "true" + } + return "false" + case float64: + return strconv.FormatFloat(t, 'g', -1, 64) + default: + return "" + } +} + +// secretString is String(value) as used by verifySecret/hashSecret inputs: +// strings pass through, everything else goes through JSString. +func secretString(v any) string { + if s, ok := v.(string); ok { + return s + } + return JSString(v) +} + +// jsTruthy is JS Boolean(value) / `value || fallback` truthiness. +func jsTruthy(v any) 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 && !math.IsNaN(f) + case float64: + return t != 0 && !math.IsNaN(t) + default: + return true // arrays and objects are always truthy in JS + } +} + +// jsNumber approximates JS Number(value); ok=false is NaN. +func jsNumber(v any) (float64, bool) { + switch t := v.(type) { + case nil: + return 0, false // Number(null) is 0 in JS, but panel paths treat + // null like undefined via `value == null` guards before calling + // Number; the remaining direct uses (ports) want NaN semantics. + case json.Number: + f, err := t.Float64() + if err != nil { + return 0, false + } + return f, true + case float64: + return t, true + case bool: + if t { + return 1, true + } + return 0, true + case string: + s := strings.TrimSpace(t) + if s == "" { + return 0, true + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, false + } + return f, true + case []any: + if len(t) == 0 { + return 0, true + } + if len(t) == 1 { + return jsNumber(t[0]) + } + return 0, false + default: + return 0, false + } +} + +// truncateRunes slices by rune count. JS slice() counts UTF-16 code units, +// so astral-plane characters count once here but twice in JS. +func truncateRunes(s string, max int) string { + if max < 0 { + max = 0 + } + if utf8.RuneCountInString(s) <= max { + return s + } + r := []rune(s) + return string(r[:max]) +} + +// CleanTextStr is cleanText for an already-string value. +func CleanTextStr(s string, max int) string { + return truncateRunes(strings.TrimSpace(s), max) +} + +// CleanText is String(value ?? ”).trim().slice(0, max). +func CleanText(v any, max int) string { + return CleanTextStr(JSString(v), max) +} + +// RequiredText is requiredText: CleanText, or 400 `