feat: Go port of the panel in panel-go/ — drop-in replacement for server.js
CI / test (push) Canceled after 0s
CI / anytls-smoke (push) Canceled after 0s

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.
This commit is contained in:
aaaamoooo
2026-10-20 08:21:54 +00:00
parent abb1a4344b
commit 3951134940
37 changed files with 12183 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/embed_public/
/nexusgate-panel*
+69
View File
@@ -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。
+76
View File
@@ -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(调试用)。
+156
View File
@@ -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
}
+165
View File
@@ -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)
}
}
+197
View File
@@ -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:<salt>:<hex hash>`. 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"]
}
+114
View File
@@ -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")
}
}
+9
View File
@@ -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 .
+146
View File
@@ -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.json 路径>`(同目录须有
`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-<nodeId>` 对应条目的 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_<uuid>` 全文正则(包括嵌在字符串、对象键如 `reality-<nod id>`、`srv:<id>` 中的) | `<cus>#2` |
| 节点 tag 片段 | sing-box tag 由 node id 去符号后取末 18 位派生(`ng-<片段>-in`),学到 node id 时同步登记片段 → 归属 id 的占位 | `ng-<nod>#3-in` |
| 裸 UUID | 凭据 `clientId` 等无前缀 UUID | `<uuid>#4` |
| ISO 时间戳 | `…T…Z/±hh:mm` 全部 | `<ts>` |
| 本地化日期 | info 页 `2026/10/20 16:05:03` 形式 | `<date>` |
| 字段学习型秘密 | 按字段名学习:`subscriptionToken`/`agentKey`/`token`(enrollment)/`csrf`/`passwordHash`/`tokenHash`/`keyHash`(含 `scrypt:…` 全文模式)/`keyFingerprint`/`clientKey`/`privateKey`/`publicKey`/`realityPublicKey`/`relayPassword`/`ssServerKey`/`wsPath`/`password`/`shortId`/`short_id`、ws 传输 `path`(`/<hex>` 形式);替换时含其 16 字符前缀形式(info 页展示) | `<subtoken>#1`、`<rpub>#1`、`<secret>#3` |
| 随机端口 | 仅从字段 `port`/`exitPort`/`listen_port`/`server_port` 学习数值,JSON 中同名字段与文本中独立出现的该数字一并替换;`portRangeStart` 等确定性数字不学不换 | `<port>#2` |
| ss:// userinfo | SS 链接的 base64 userinfo 整体按出现顺序编号(其内容是 ssServerKey 与 relayPassword 的确定性函数) | `ss://<ssblob>#1@…` |
| 面板自身地址 | info 页里的 `http://127.0.0.1:<本侧端口>` | `<panel>` |
文本体(订阅)同样先归一化后逐字比;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 分钟)污染后续步。
+517
View File
@@ -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
// <cus>#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 = /(?<![0-9A-Za-z_-])[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![0-9A-Za-z_-])/g;
const ISO_TS_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})/g;
const LOCALE_DATE_RE = /\d{4}\/\d{1,2}\/\d{1,2}\s+\d{1,2}:\d{2}:\d{2}/g;
const SCRYPT_RE = /scrypt:[0-9a-f]+:[0-9a-f]+/g;
const SS_URI_RE = /ss:\/\/[A-Za-z0-9_-]+@/g;
// JSON field name -> 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-<fragment>-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('<panel>');
// 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, '<ts>');
out = out.replace(LOCALE_DATE_RE, '<date>');
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(`(?<![0-9])${num}(?![0-9])`, 'g'), placeholder);
}
return out;
}
normalize(value, keyName = null) {
if (Array.isArray(value)) return value.map((item) => 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: '<absent>', b: summarize(b[key]) };
if (!(key in b)) return { path: `${at}.${key}`, a: summarize(a[key]), b: '<absent>' };
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}`);
}
}
+432
View File
@@ -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 <path> formal mode against this Go binary
// GO_PANEL_BIN=<path> 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
// <prefix>#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 <path> 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 };
+52
View File
@@ -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"
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+256
View File
@@ -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 <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 });
}
+3
View File
@@ -0,0 +1,3 @@
module nexusgate-panel
go 1.24
+2612
View File
File diff suppressed because it is too large Load Diff
+174
View File
@@ -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)
}
}
+439
View File
@@ -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 `<label>不能为空`.
func RequiredText(v any, label string, max int) (string, error) {
result := CleanText(v, max)
if result == "" {
return "", httpErr(400, "%s不能为空", label)
}
return result, nil
}
// OptionalISO is optionalIso: null/” -> nil; a parseable date -> canonical
// ISO string; otherwise 400 `<label>格式无效`. A JSON number is treated as
// epoch milliseconds, like JS `new Date(number)`.
func OptionalISO(v any, label string) (*string, error) {
if v == nil {
return nil, nil
}
if s, ok := v.(string); ok {
if s == "" {
return nil, nil
}
t, ok := ParseISO(s)
if !ok {
return nil, httpErr(400, "%s格式无效", label)
}
out := ISOFromTime(t)
return &out, nil
}
if n, ok := v.(json.Number); ok {
ms, err := n.Int64()
if err != nil {
f, ferr := n.Float64()
if ferr != nil {
return nil, httpErr(400, "%s格式无效", label)
}
ms = int64(f)
}
out := ISOFromTime(unixMs(ms))
return &out, nil
}
return nil, httpErr(400, "%s格式无效", label)
}
// NonNegativeNumber is nonNegativeNumber: Number(null/”) is 0; the value
// must be finite, >= 0, and integral when integer is set.
func NonNegativeNumber(v any, label string, integer bool) (float64, error) {
fail := func() (float64, error) {
kind := "数字"
if integer {
kind = "整数"
}
return 0, httpErr(400, "%s必须是非负%s", label, kind)
}
var n float64
if v == nil {
n = 0
} else if s, ok := v.(string); ok && s == "" {
n = 0
} else {
f, ok := jsNumber(v)
if !ok {
return fail()
}
n = f
}
if math.IsNaN(n) || math.IsInf(n, 0) || n < 0 || (integer && n != math.Trunc(n)) {
return fail()
}
return n, nil
}
// asIds is asIds: array -> String(each), drop empties, dedupe in order.
func asIds(v any) []string {
arr, ok := v.([]any)
if !ok {
return []string{}
}
seen := map[string]bool{}
out := []string{}
for _, item := range arr {
s := JSString(item)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
func sliceStrings(v []string, max int) []string {
if len(v) > max {
return v[:max]
}
return v
}
func strVal(p *string) string {
if p == nil {
return ""
}
return *p
}
func unixMs(ms int64) time.Time { return time.UnixMilli(ms).UTC() }
// marshalIndentJSON is JSON.stringify(v, null, 2) (no trailing newline).
func marshalIndentJSON(v any) ([]byte, error) {
raw, err := marshalJSON(v)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := json.Indent(&buf, raw, "", " "); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// recordToMap decodes a record through its MarshalJSON (so Extra fields
// survive) into a generic map for response shaping.
func recordToMap(v any) map[string]any {
raw, err := marshalJSON(v)
if err != nil {
return map[string]any{}
}
var out map[string]any
if err := unmarshalJSONUseNumber(raw, &out); err != nil || out == nil {
return map[string]any{}
}
return out
}
+380
View File
@@ -0,0 +1,380 @@
package main
// Program entry point: environment, store bootstrap, startup secret
// scrubbing, admin bootstrap, HTTP server and the 60s housekeeping loop —
// mirroring server.js main() and housekeeping().
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
)
// VERSION is the panel version; do not change (agents and the AnyTLS gate
// parse it numerically).
const VERSION = "0.6.8"
// cookieSecure mirrors NG_COOKIE_SECURE !== 'false'; set in main().
var cookieSecure = true
// ---------- startup scrub helpers ----------
func extraStringValue(ex extraFields, key string) (string, bool) {
raw, ok := ex[key]
if !ok {
return "", false
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return "", false
}
return s, true
}
func setExtraString(ex extraFields, key, value string) extraFields {
if ex == nil {
ex = extraFields{}
}
raw, _ := json.Marshal(value)
ex[key] = raw
return ex
}
// extraEngineDetail reads engine.detail when a record carries its engine
// state as an unknown (Extra) field rather than the typed field.
func extraEngineDetail(ex extraFields) (string, bool) {
raw, ok := ex["engine"]
if !ok {
return "", false
}
var eng map[string]any
if err := json.Unmarshal(raw, &eng); err != nil {
return "", false
}
s, ok := eng["detail"].(string)
return s, ok
}
func setExtraEngineDetail(ex extraFields, value string) {
raw, ok := ex["engine"]
if !ok {
return
}
var eng map[string]any
if err := json.Unmarshal(raw, &eng); err != nil {
return
}
eng["detail"] = value
out, err := json.Marshal(eng)
if err == nil {
ex["engine"] = out
}
}
func needsScrub(s string) bool {
return s != "" && s != RedactSecrets(s)
}
// startupNeedsCleaning reports whether any persisted job/node/server still
// holds an un-redacted secret in error/lastError/engine.detail.
func startupNeedsCleaning(d *Data) bool {
checkExtra := func(ex extraFields) bool {
for _, key := range []string{"error", "lastError"} {
if s, ok := extraStringValue(ex, key); ok && needsScrub(s) {
return true
}
}
if s, ok := extraEngineDetail(ex); ok && needsScrub(s) {
return true
}
return false
}
for _, job := range d.Jobs {
if job.Error != nil && needsScrub(*job.Error) || checkExtra(job.Extra) {
return true
}
}
for _, node := range d.Nodes {
if node.Error != nil && needsScrub(*node.Error) || checkExtra(node.Extra) {
return true
}
}
for _, server := range d.Servers {
if server.Engine != nil && needsScrub(server.Engine.Detail) || checkExtra(server.Extra) {
return true
}
}
return false
}
func scrubExtra(ex extraFields) {
for _, key := range []string{"error", "lastError"} {
if s, ok := extraStringValue(ex, key); ok && s != "" {
setExtraString(ex, key, RedactSecrets(s))
}
}
if s, ok := extraEngineDetail(ex); ok && s != "" {
setExtraEngineDetail(ex, RedactSecrets(s))
}
}
func scrubRecordSecrets(d *Data) {
for _, job := range d.Jobs {
if job.Error != nil && *job.Error != "" {
redacted := RedactSecrets(*job.Error)
job.Error = &redacted
}
scrubExtra(job.Extra)
}
for _, node := range d.Nodes {
if node.Error != nil && *node.Error != "" {
redacted := RedactSecrets(*node.Error)
node.Error = &redacted
}
scrubExtra(node.Extra)
}
for _, server := range d.Servers {
if server.Engine != nil && server.Engine.Detail != "" {
server.Engine.Detail = RedactSecrets(server.Engine.Detail)
}
scrubExtra(server.Extra)
}
}
// ---------- housekeeping ----------
// housekeeping is server.js housekeeping(): offline servers, expired job
// leases, quota suspensions/resumes, retention pruning, access pruning and
// session pruning, every 60 seconds. The transaction result is never SKIP,
// so a successful pass persists.
func (a *App) housekeeping() {
_, err := a.store.Transaction(func(d *Data) (any, error) {
now := time.Now().UnixMilli()
nowISO := NowISO()
for _, server := range d.Servers {
if server.LastSeenAt == nil {
continue
}
if ms, ok := ParseISOMs(*server.LastSeenAt); ok && now-ms > 180000 {
server.Status = "offline"
}
}
for _, job := range d.Jobs {
if job.Status != "running" || job.LeaseUntil == nil {
continue
}
ms, ok := ParseISOMs(*job.LeaseUntil)
if !ok || ms > now {
continue
}
job.LeaseUntil = nil
job.UpdatedAt = nowISO
if job.Attempts < 3 {
job.Status = "queued"
job.Error = StrPtr("Agent 未在租约内确认,任务已自动重试")
} else {
job.Status = "failed"
job.Error = StrPtr("Agent 连续 3 次未在租约内确认")
if node := findNode(d, job.NodeID); node != nil {
kind := ""
if job.Payload != nil {
kind = job.Payload.KindOf()
}
if kind == "node-exit" {
node.Status = "degraded"
} else {
node.Status = "failed"
}
node.Error = job.Error
node.UpdatedAt = nowISO
}
}
}
for _, customer := range d.Customers {
expired := false
if customer.ExpiresAt != nil {
if ms, ok := ParseISOMs(*customer.ExpiresAt); ok && ms <= now {
expired = true
}
}
exhausted := customer.TrafficLimitBytes > 0 && float64(customer.UsedBytes) >= customer.TrafficLimitBytes
if (expired || exhausted) && customer.Status == "active" {
customer.Status = "suspended"
if expired {
customer.SuspendReason = StrPtr("expired")
customer.SuspendReasonPresent = true
} else {
customer.SuspendReason = StrPtr("traffic_limit")
customer.SuspendReasonPresent = true
}
customer.UpdatedAt = nowISO
if _, err := SuspendCustomerResources(d, customer, "system:quota"); err != nil {
return nil, err
}
} else if customer.Status == "suspended" && strVal(customer.SuspendReason) == "ip_limit" && CustomerBlockReason(d, customer) == "" {
customer.Status = "active"
customer.SuspendReason = nil
customer.SuspendReasonPresent = true
customer.UpdatedAt = nowISO
if _, err := ResumeCustomerResources(d, customer, "system:ip_window"); err != nil {
return nil, err
}
}
}
jobRetention := d.Settings.CompletedJobRetentionDays
if jobRetention == 0 {
jobRetention = 7
}
jobCutoff := now - int64(jobRetention*86400000)
jobs := d.Jobs[:0]
for _, job := range d.Jobs {
if job.Status == "completed" || job.Status == "failed" {
if ms, ok := ParseISOMs(job.UpdatedAt); !ok || ms <= jobCutoff {
continue
}
}
jobs = append(jobs, job)
}
d.Jobs = jobs
activityRetention := d.Settings.ActivityRetentionDays
if activityRetention == 0 {
activityRetention = 30
}
activityCutoff := now - int64(activityRetention*86400000)
activity := d.Activity[:0]
for _, event := range d.Activity {
if ms, ok := ParseISOMs(event.At); ok && ms > activityCutoff {
activity = append(activity, event)
}
}
if len(activity) > 2000 {
activity = activity[:2000]
}
d.Activity = activity
PruneAccess(d, time.Now())
return nil, nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Housekeeping failed: %v\n", err)
return
}
a.getSessions().Prune()
}
// ---------- main ----------
func defaultDataFile() string {
exe, err := os.Executable()
if err != nil {
return filepath.Join("data", "nexusgate.json")
}
return filepath.Join(filepath.Dir(exe), "data", "nexusgate.json")
}
func run() error {
dataFile := os.Getenv("NG_DATA_FILE")
if dataFile == "" {
dataFile = defaultDataFile()
}
host := os.Getenv("NG_HOST")
if host == "" {
host = "127.0.0.1"
}
port := 8787
if p := os.Getenv("NG_PORT"); p != "" {
if n, err := strconv.Atoi(p); err == nil {
port = n
}
}
cookieSecure = os.Getenv("NG_COOKIE_SECURE") != "false"
store := NewStore(dataFile)
if err := store.Init(); err != nil {
return err
}
if startupNeedsCleaning(store.Snapshot()) {
if _, err := store.Transaction(func(d *Data) (any, error) {
scrubRecordSecrets(d)
return nil, nil
}); err != nil {
return err
}
}
missingToken := false
for _, customer := range store.Snapshot().Customers {
if customer.SubscriptionToken == "" {
missingToken = true
break
}
}
if missingToken {
if _, err := store.Transaction(func(d *Data) (any, error) {
for _, customer := range d.Customers {
if customer.SubscriptionToken == "" {
customer.SubscriptionToken = RandomToken(32)
}
}
return nil, nil
}); err != nil {
return err
}
}
if len(store.Snapshot().Users) == 0 {
password := os.Getenv("NG_ADMIN_PASSWORD")
generated := false
if password == "" {
password = RandomToken(15)
generated = true
}
username := os.Getenv("NG_ADMIN_USERNAME")
if username == "" {
username = "admin"
}
if _, err := store.Transaction(func(d *Data) (any, error) {
d.Users = append(d.Users, &User{
ID: NewID("usr"),
Username: username,
PasswordHash: HashSecret(password),
Role: "owner",
Status: "active",
CreatedAt: NowISO(),
})
return nil, nil
}); err != nil {
return err
}
if generated {
fmt.Printf("BOOTSTRAP_PASSWORD=%s\n", password)
}
}
app := NewApp(store, nil, NewOrchestrator(store))
app.replaceSessions(NewSessionManager(app.sessionHours()))
server := &http.Server{
Addr: host + ":" + strconv.Itoa(port),
Handler: app,
ReadHeaderTimeout: 15 * time.Second,
ReadTimeout: 30 * time.Second,
}
go func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for range ticker.C {
app.housekeeping()
}
}()
fmt.Printf("NexusGate v%s listening on http://%s:%d\n", VERSION, host, port)
return server.ListenAndServe()
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
File diff suppressed because it is too large Load Diff
+800
View File
@@ -0,0 +1,800 @@
package main
import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"math/big"
"net"
"strings"
)
// ---------- catalog data (mirrors lib/protocols.js) ----------
type Profile struct {
ID string `json:"id"`
Role string `json:"role"`
Name string `json:"name"`
Status string `json:"status"`
Deployable bool `json:"deployable"`
Capabilities []string `json:"capabilities"`
Description string `json:"description"`
}
type RealityPreset struct {
ID string `json:"id"`
Label string `json:"label"`
ServerName string `json:"serverName"`
DestPort int `json:"destPort"`
}
var RealityPresets = []RealityPreset{
{ID: "tesla", Label: "Tesla", ServerName: "www.tesla.com", DestPort: 443},
{ID: "amazon", Label: "Amazon", ServerName: "www.amazon.com", DestPort: 443},
{ID: "apple", Label: "Apple", ServerName: "www.apple.com", DestPort: 443},
{ID: "intel", Label: "Intel", ServerName: "www.intel.com", DestPort: 443},
{ID: "amd", Label: "AMD", ServerName: "www.amd.com", DestPort: 443},
}
var ProfileCatalog = []Profile{
{ID: "vless-reality-vision", Role: "relay-ingress", Name: "VLESS · Reality · Vision", Status: "stable", Deployable: true, Capabilities: []string{"sing-box", "reality"}, Description: "推荐公网入口,无需在节点机部署证书。"},
{ID: "vless-reality", Role: "relay-ingress", Name: "VLESS · Reality", Status: "stable", Deployable: true, Capabilities: []string{"sing-box", "reality"}, Description: "不启用 Vision 流控的 Reality 入口。"},
{ID: "vless-ws", Role: "relay-ingress", Name: "VLESS · WebSocket", Status: "beta", Deployable: true, Capabilities: []string{"sing-box", "websocket"}, Description: "无 TLS 的兼容模式,建议仅在可信或外层 TLS 环境使用。"},
{ID: "shadowsocks-2022-aes128", Role: "relay-ingress", Name: "SS 2022 · AES-128", Status: "stable", Deployable: true, Capabilities: []string{"sing-box"}, Description: "轻量、推荐的 AEAD 2022 入口。"},
{ID: "shadowsocks-2022-aes256", Role: "relay-ingress", Name: "SS 2022 · AES-256", Status: "stable", Deployable: true, Capabilities: []string{"sing-box"}, Description: "32 字节密钥的 AEAD 2022 入口。"},
{ID: "shadowsocks-aes128-gcm", Role: "relay-ingress", Name: "SS · AES-128-GCM", Status: "stable", Deployable: true, Capabilities: []string{"sing-box"}, Description: "兼容主流旧版 Shadowsocks 客户端。"},
{ID: "shadowsocks-aes256-gcm", Role: "relay-ingress", Name: "SS · AES-256-GCM", Status: "stable", Deployable: true, Capabilities: []string{"sing-box"}, Description: "兼容主流旧版 Shadowsocks 客户端。"},
{ID: "socks5-auth", Role: "relay-ingress", Name: "SOCKS5 · 用户密码", Status: "stable", Deployable: true, Capabilities: []string{"sing-box"}, Description: "适合受控网络或程序级代理。"},
{ID: "vless-ws-tls", Role: "relay-ingress", Name: "VLESS · WS · TLS", Status: "beta", Deployable: true, Capabilities: []string{"sing-box", "tls-domain"}, Description: "需为每台入口设备填写 TLS 域名,并在 Agent 上安装对应证书。"},
{ID: "hysteria2", Role: "relay-ingress", Name: "Hysteria 2 · UDP", Status: "beta", Deployable: true, Capabilities: []string{"sing-box", "udp", "tls-domain"}, Description: "由 sing-box 承载;入口须安装有效节点证书并放行所选 UDP 端口。"},
{ID: "anytls", Role: "relay-ingress", Name: "AnyTLS · TLS", Status: "beta", Deployable: true, Capabilities: []string{"sing-box", "tls-domain"}, Description: "由 sing-box 独立承载,需入口设备的有效节点证书;支持直连或转发到现有出口。"},
{ID: "shadowsocks-2022-aes128", Role: "exit-transport", Name: "SS 2022 · AES-128", Status: "stable", Deployable: true, Capabilities: []string{"encrypted"}, Description: "推荐的转发到出口传输。"},
{ID: "shadowsocks-2022-aes256", Role: "exit-transport", Name: "SS 2022 · AES-256", Status: "stable", Deployable: true, Capabilities: []string{"encrypted"}, Description: "增强密钥长度的转发传输。"},
{ID: "shadowsocks-aes128-gcm", Role: "exit-transport", Name: "SS · AES-128-GCM", Status: "stable", Deployable: true, Capabilities: []string{"encrypted"}, Description: "兼容旧环境的转发传输。"},
{ID: "shadowsocks-aes256-gcm", Role: "exit-transport", Name: "SS · AES-256-GCM", Status: "stable", Deployable: true, Capabilities: []string{"encrypted"}, Description: "兼容旧环境的转发传输。"},
{ID: "vless-tcp", Role: "exit-transport", Name: "VLESS · TCP", Status: "beta", Deployable: true, Capabilities: []string{}, Description: "适合可信内网或已有加密隧道,不建议裸露公网。"},
{ID: "socks5-auth", Role: "exit-transport", Name: "SOCKS5 · 用户密码", Status: "beta", Deployable: true, Capabilities: []string{}, Description: "不加密,仅用于可信网络或外层隧道。"},
}
type SSMethod struct {
Method string
KeyBytes int
}
var ShadowsocksMethods = map[string]SSMethod{
"shadowsocks-2022-aes128": {Method: "2022-blake3-aes-128-gcm", KeyBytes: 16},
"shadowsocks-2022-aes256": {Method: "2022-blake3-aes-256-gcm", KeyBytes: 32},
"shadowsocks-aes128-gcm": {Method: "aes-128-gcm", KeyBytes: 24},
"shadowsocks-aes256-gcm": {Method: "aes-256-gcm", KeyBytes: 32},
}
// ---------- small helpers ----------
func xbDeref(p *string) string {
if p == nil {
return ""
}
return *p
}
func xbDerefInt(p *int) int {
if p == nil {
return 0
}
return *p
}
func xbRandBytes(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return b
}
// b64 mirrors JS b64(): standard base64 (with padding) of n random bytes.
func b64(n int) string { return base64.StdEncoding.EncodeToString(xbRandBytes(n)) }
func shortId() string { return hex.EncodeToString(xbRandBytes(8)) }
func xbRandInt(n int) int {
v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
if err != nil {
panic(err)
}
return int(v.Int64())
}
// ---------- encoding helpers (JS-exact) ----------
const xbHexUpper = "0123456789ABCDEF"
func xbPctEncodeByte(sb *strings.Builder, c byte) {
sb.WriteByte('%')
sb.WriteByte(xbHexUpper[c>>4])
sb.WriteByte(xbHexUpper[c&0x0f])
}
// formEncode is URLSearchParams' application/x-www-form-urlencoded
// serialization: alphanumerics and * - . _ pass through, space becomes +,
// everything else (including ~) is %XX uppercase.
func formEncode(s string) string {
var sb strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' ||
c == '*' || c == '-' || c == '.' || c == '_':
sb.WriteByte(c)
case c == ' ':
sb.WriteByte('+')
default:
xbPctEncodeByte(&sb, c)
}
}
return sb.String()
}
// encodeURIComponent: A-Za-z0-9-_.!~*'() pass through, everything else %XX.
func encodeURIComponent(s string) string {
var sb strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' ||
c == '-' || c == '_' || c == '.' || c == '!' || c == '~' || c == '*' ||
c == '\'' || c == '(' || c == ')':
sb.WriteByte(c)
default:
xbPctEncodeByte(&sb, c)
}
}
return sb.String()
}
type xbQueryParam struct{ k, v string }
type xbQuery []xbQueryParam
func (q *xbQuery) add(k, v string) { *q = append(*q, xbQueryParam{k, v}) }
// set mirrors URLSearchParams.set: replace first occurrence, drop later
// duplicates, append when absent.
func (q *xbQuery) set(k, v string) {
out := (*q)[:0]
found := false
for _, p := range *q {
if p.k == k {
if found {
continue
}
found = true
out = append(out, xbQueryParam{k, v})
} else {
out = append(out, p)
}
}
if !found {
out = append(out, xbQueryParam{k, v})
}
*q = out
}
func (q xbQuery) String() string {
parts := make([]string, len(q))
for i, p := range q {
parts[i] = formEncode(p.k) + "=" + formEncode(p.v)
}
return strings.Join(parts, "&")
}
// ---------- node secrets ----------
func NodeEmail(customerID string) string { return "ng:" + customerID }
func NewNodeClientCredentials(protocol string) Credentials {
keyBytes := 24
if cipher, ok := ShadowsocksMethods[protocol]; ok {
keyBytes = cipher.KeyBytes
}
return Credentials{
ClientID: NewUUID(),
RelayPassword: b64(keyBytes),
ShortID: shortId(),
}
}
func EnsureNodeSecrets(node *Node) {
if node.WSPath == nil || *node.WSPath == "" {
node.WSPath = StrPtr("/" + hex.EncodeToString(xbRandBytes(9)))
}
if cipher, ok := ShadowsocksMethods[node.Protocol]; ok {
if node.SSServerKey == nil || *node.SSServerKey == "" {
node.SSServerKey = StrPtr(b64(cipher.KeyBytes))
}
}
if IsRealityProtocol(node.Protocol) {
if node.Reality == nil {
node.Reality = &RealityConfig{KeyID: "reality-" + node.ID}
}
serverName := node.RealityServerName
if serverName == "" {
serverName = "www.tesla.com"
}
node.Reality.ServerName = serverName
destPort := node.RealityDestPort
if destPort == 0 {
destPort = 443
}
node.Reality.DestPort = destPort
}
topology := node.Topology
if topology == "" {
topology = "forward"
}
if topology != "direct" && node.ExitCredentials == nil {
keyBytes := 24
if cipher, ok := ShadowsocksMethods[xbDeref(node.ExitProtocol)]; ok {
keyBytes = cipher.KeyBytes
}
node.ExitCredentials = &ExitCredentials{
ClientID: NewUUID(),
Password: b64(keyBytes),
SocksUser: "ng_" + hex.EncodeToString(xbRandBytes(5)),
}
}
}
// ---------- addresses ----------
func listenAddress(networkMode string, server *Server, bindAddress string) string {
if bindAddress != "" {
return bindAddress
}
if networkMode == "ipv6" {
if server != nil && server.PublicAddressV6 != "" {
return server.PublicAddressV6
}
return "::"
}
if networkMode == "dual" {
return "::"
}
if server != nil && server.PublicAddress != "" {
return server.PublicAddress
}
return "0.0.0.0"
}
func serverAddress(server *Server, networkMode string) (string, error) {
if server == nil {
return "", errors.New("Missing server for address selection")
}
if networkMode == "ipv6" {
return server.PublicAddressV6, nil
}
return server.PublicAddress, nil
}
// ResolveBindAddress mirrors resolveBindAddress: dual/absent -> ("", nil).
// DNS failures are HTTPError 400; the parenthesized code approximates
// err.code with the Go resolver's short error text (e.g. "no such host"),
// since Go exposes no getaddrinfo code.
func ResolveBindAddress(networkMode string, server *Server) (string, error) {
if networkMode == "dual" {
return "", nil
}
var raw string
if server != nil {
if networkMode == "ipv6" {
raw = server.PublicAddressV6
} else {
raw = server.PublicAddress
}
}
if raw == "" {
return "", nil
}
text := strings.TrimSpace(raw)
family := 4
record := "A"
network := "ip4"
if networkMode == "ipv6" {
family = 6
record = "AAAA"
network = "ip6"
}
_ = family
if ip := net.ParseIP(text); ip != nil {
return text, nil
}
addrs, err := net.DefaultResolver.LookupIP(context.Background(), network, text)
if err != nil || len(addrs) == 0 {
code := ""
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.Err != "" {
code = dnsErr.Err
} else {
code = err.Error()
}
} else {
code = "no addresses"
}
return "", httpErr(400, "绑定地址 DNS 解析失败:%s 没有 %s 记录(%s),请检查该服务器登记的地址", text, record, code)
}
return addrs[0].String(), nil
}
// ---------- sing-box resource builders ----------
func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, server *Server, bindAddress string) (map[string]any, error) {
protocol := node.Protocol
port := node.Port
networkMode := node.NetworkMode
if networkMode == "" {
networkMode = "ipv4"
}
listen := listenAddress(networkMode, server, bindAddress)
tlsDomain := ""
if server != nil {
tlsDomain = server.TLSDomain
}
if protocol == "vless-reality-vision" || protocol == "vless-reality" {
if node.Reality == nil {
return nil, errors.New("Reality 节点缺少密钥配置")
}
users := make([]any, 0, len(nodeClients))
shortIDs := make([]any, 0, len(nodeClients))
for _, nc := range nodeClients {
user := map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": nc.Credentials.ClientID}
if protocol == "vless-reality-vision" {
user["flow"] = "xtls-rprx-vision"
}
users = append(users, user)
shortIDs = append(shortIDs, nc.Credentials.ShortID)
}
destPort := node.Reality.DestPort
if destPort == 0 {
destPort = 443
}
return map[string]any{
"type": "vless", "tag": tag, "listen": listen, "listen_port": port, "users": users,
"tls": map[string]any{
"enabled": true, "server_name": node.Reality.ServerName,
"reality": map[string]any{
"enabled": true,
"handshake": map[string]any{"server": node.Reality.ServerName, "server_port": destPort},
"private_key": "${REALITY_PRIVATE:" + node.Reality.KeyID + "}",
"short_id": shortIDs,
},
},
}, nil
}
if protocol == "vless-ws" || protocol == "vless-ws-tls" {
if protocol == "vless-ws-tls" && tlsDomain == "" {
return nil, errors.New("TLS 节点缺少有效证书域名")
}
users := make([]any, 0, len(nodeClients))
for _, nc := range nodeClients {
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": nc.Credentials.ClientID})
}
wsPath := xbDeref(node.WSPath)
if wsPath == "" {
wsPath = "/"
}
inbound := map[string]any{
"type": "vless", "tag": tag, "listen": listen, "listen_port": port, "users": users,
"transport": map[string]any{"type": "ws", "path": wsPath},
}
if protocol == "vless-ws-tls" {
inbound["tls"] = map[string]any{
"enabled": true, "server_name": tlsDomain,
"certificate_path": "/etc/nexusgate/tls/" + tlsDomain + "/fullchain.pem",
"key_path": "/etc/nexusgate/tls/" + tlsDomain + "/privkey.pem",
}
}
return inbound, nil
}
if cipher, ok := ShadowsocksMethods[protocol]; ok {
users := make([]any, 0, len(nodeClients))
for _, nc := range nodeClients {
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": nc.Credentials.RelayPassword})
}
return map[string]any{
"type": "shadowsocks", "tag": tag, "listen": listen, "listen_port": port,
"method": cipher.Method, "password": xbDeref(node.SSServerKey), "users": users,
}, nil
}
if protocol == "socks5-auth" {
users := make([]any, 0, len(nodeClients))
for _, nc := range nodeClients {
users = append(users, map[string]any{"username": NodeEmail(nc.CustomerID), "password": nc.Credentials.RelayPassword})
}
return map[string]any{"type": "socks", "tag": tag, "listen": listen, "listen_port": port, "users": users}, nil
}
if protocol == "hysteria2" {
if tlsDomain == "" {
return nil, errors.New("Hysteria 2 入口缺少有效证书域名")
}
users := make([]any, 0, len(nodeClients))
for _, nc := range nodeClients {
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": nc.Credentials.RelayPassword})
}
return map[string]any{
"type": "hysteria2", "tag": tag, "listen": listen, "listen_port": port, "users": users,
"tls": map[string]any{
"enabled": true, "server_name": tlsDomain, "alpn": []any{"h3"},
"certificate_path": "/etc/nexusgate/tls/" + tlsDomain + "/fullchain.pem",
"key_path": "/etc/nexusgate/tls/" + tlsDomain + "/privkey.pem",
},
}, nil
}
if protocol == "anytls" {
if tlsDomain == "" {
return nil, errors.New("AnyTLS 入口缺少有效证书域名")
}
users := make([]any, 0, len(nodeClients))
for _, nc := range nodeClients {
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": nc.Credentials.RelayPassword})
}
return map[string]any{
"type": "anytls", "tag": tag, "listen": listen, "listen_port": port, "users": users,
"tls": map[string]any{
"enabled": true, "server_name": tlsDomain,
"certificate_path": "/etc/nexusgate/tls/" + tlsDomain + "/fullchain.pem",
"key_path": "/etc/nexusgate/tls/" + tlsDomain + "/privkey.pem",
},
}, nil
}
return nil, fmt.Errorf("sing-box 不支持的协议: %s", protocol)
}
type BuildNodeResourceParams struct {
ResourceID string
TagPrefix string
Node *Node
NodeClients []*NodeClient
Server *Server
BindAddress string
ExitServer *Server
}
func BuildNodeResource(p BuildNodeResourceParams) (map[string]any, error) {
node := p.Node
EnsureNodeSecrets(node)
tag := p.TagPrefix + "-in"
topology := node.Topology
if topology == "" {
topology = "forward"
}
forward := topology == "forward"
outboundTag := p.TagPrefix + "-direct"
if forward {
outboundTag = p.TagPrefix + "-exit"
}
networkMode := node.NetworkMode
if networkMode == "" {
networkMode = "ipv4"
}
var outbound map[string]any
if !forward {
outbound = map[string]any{"type": "direct", "tag": outboundTag}
} else {
exitAddr, err := serverAddress(p.ExitServer, networkMode)
if err != nil {
return nil, err
}
base := map[string]any{"tag": outboundTag, "server": exitAddr, "server_port": xbDerefInt(node.ExitPort)}
creds := node.ExitCredentials
if creds == nil {
creds = &ExitCredentials{}
}
exitProtocol := xbDeref(node.ExitProtocol)
if cipher, ok := ShadowsocksMethods[exitProtocol]; ok {
outbound = cloneMap(base)
outbound["type"] = "shadowsocks"
outbound["method"] = cipher.Method
outbound["password"] = creds.Password
} else if exitProtocol == "socks5-auth" {
outbound = cloneMap(base)
outbound["type"] = "socks"
outbound["version"] = "5"
outbound["username"] = creds.SocksUser
outbound["password"] = creds.Password
} else if exitProtocol == "vless-tcp" {
outbound = cloneMap(base)
outbound["type"] = "vless"
outbound["uuid"] = creds.ClientID
} else {
return nil, fmt.Errorf("Unsupported exit transport: %s", exitProtocol)
}
}
inbound, err := buildSingBoxInbound(tag, node, p.NodeClients, p.Server, p.BindAddress)
if err != nil {
return nil, err
}
return map[string]any{
"id": p.ResourceID, "engine": "sing-box",
"meta": map[string]any{"kind": "node", "nodeId": node.ID, "metricsTag": tag, "protocol": node.Protocol},
"inbounds": []any{inbound},
"outbounds": []any{outbound},
"routingRules": []any{
map[string]any{"inbound": []any{tag}, "action": "route", "outbound": outboundTag},
},
}, nil
}
func cloneMap(m map[string]any) map[string]any {
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = v
}
return out
}
type BuildNodeExitResourceParams struct {
ResourceID string
TagPrefix string
Node *Node
Server *Server
BindAddress string
}
func BuildNodeExitResource(p BuildNodeExitResourceParams) (map[string]any, error) {
node := p.Node
tag := p.TagPrefix + "-exit-in"
directTag := p.TagPrefix + "-exit-direct"
protocol := xbDeref(node.ExitProtocol)
creds := node.ExitCredentials
if creds == nil {
creds = &ExitCredentials{}
}
networkMode := node.NetworkMode
if networkMode == "" {
networkMode = "ipv4"
}
listen := listenAddress(networkMode, p.Server, p.BindAddress)
var inbound map[string]any
if cipher, ok := ShadowsocksMethods[protocol]; ok {
inbound = map[string]any{
"type": "shadowsocks", "tag": tag, "listen": listen, "listen_port": xbDerefInt(node.ExitPort),
"method": cipher.Method, "password": creds.Password,
}
} else if protocol == "vless-tcp" {
inbound = map[string]any{
"type": "vless", "tag": tag, "listen": listen, "listen_port": xbDerefInt(node.ExitPort),
"users": []any{map[string]any{"uuid": creds.ClientID}},
}
} else if protocol == "socks5-auth" {
inbound = map[string]any{
"type": "socks", "tag": tag, "listen": listen, "listen_port": xbDerefInt(node.ExitPort),
"users": []any{map[string]any{"username": creds.SocksUser, "password": creds.Password}},
}
} else {
return nil, fmt.Errorf("Unsupported exit transport: %s", protocol)
}
return map[string]any{
"id": p.ResourceID, "engine": "sing-box",
"meta": map[string]any{"kind": "node-exit", "nodeId": node.ID, "metricsTag": tag},
"inbounds": []any{inbound},
"outbounds": []any{
map[string]any{"type": "direct", "tag": directTag},
},
"routingRules": []any{
map[string]any{"inbound": []any{tag}, "action": "route", "outbound": directTag},
},
}, nil
}
// ---------- client URIs ----------
func uriHost(address string) string {
if ip := net.ParseIP(address); ip != nil && strings.Contains(address, ":") {
return "[" + address + "]"
}
return address
}
type BuildClientURIParams struct {
Protocol string
RelayServer *Server
RelayPort int
ClientID string
RelayPassword string
ShortID string
WSPath string
SocksUser string
Reality *RealityConfig
PublicKey string
Name string
NetworkMode string
TLSDomain string
SSServerKey string
}
// BuildClientURI mirrors buildClientUri; ok=false mirrors JS null.
func BuildClientURI(p BuildClientURIParams) (string, bool) {
label := encodeURIComponent(p.Name)
publicAddress, err := serverAddress(p.RelayServer, p.NetworkMode)
if err != nil {
return "", false
}
host := uriHost(publicAddress)
protocol := p.Protocol
if protocol == "hysteria2" {
var q xbQuery
q.add("sni", p.TLSDomain)
q.add("alpn", "h3")
return fmt.Sprintf("hysteria2://%s@%s:%d?%s#%s", encodeURIComponent(p.RelayPassword), host, p.RelayPort, q.String(), label), true
}
if protocol == "anytls" {
var q xbQuery
q.add("sni", p.TLSDomain)
return fmt.Sprintf("anytls://%s@%s:%d?%s#%s", encodeURIComponent(p.RelayPassword), host, p.RelayPort, q.String(), label), true
}
if protocol == "vless-reality-vision" || protocol == "vless-reality" {
if p.PublicKey == "" {
return "", false
}
serverName := ""
if p.Reality != nil {
serverName = p.Reality.ServerName
}
var q xbQuery
q.add("encryption", "none")
q.add("security", "reality")
q.add("sni", serverName)
q.add("fp", "chrome")
q.add("pbk", p.PublicKey)
q.add("sid", p.ShortID)
q.add("type", "tcp")
q.add("headerType", "none")
if protocol == "vless-reality-vision" {
q.set("flow", "xtls-rprx-vision")
}
return fmt.Sprintf("vless://%s@%s:%d?%s#%s", p.ClientID, host, p.RelayPort, q.String(), label), true
}
if protocol == "vless-ws" || protocol == "vless-ws-tls" {
security := "none"
if protocol == "vless-ws-tls" {
security = "tls"
}
var q xbQuery
q.add("encryption", "none")
q.add("security", security)
q.add("type", "ws")
q.add("path", p.WSPath)
if protocol == "vless-ws-tls" {
q.set("sni", p.TLSDomain)
}
return fmt.Sprintf("vless://%s@%s:%d?%s#%s", p.ClientID, host, p.RelayPort, q.String(), label), true
}
if cipher, ok := ShadowsocksMethods[protocol]; ok {
password := p.RelayPassword
if strings.HasPrefix(cipher.Method, "2022-blake3-") && p.SSServerKey != "" {
password = p.SSServerKey + ":" + p.RelayPassword
}
user := base64.RawURLEncoding.EncodeToString([]byte(cipher.Method + ":" + password))
return fmt.Sprintf("ss://%s@%s:%d#%s", user, host, p.RelayPort, label), true
}
if protocol == "socks5-auth" {
return fmt.Sprintf("socks5://%s:%s@%s:%d#%s", encodeURIComponent(p.SocksUser), encodeURIComponent(p.RelayPassword), host, p.RelayPort, label), true
}
return "", false
}
// ---------- validation ----------
func profileFor(role, protocol string) *Profile {
for i := range ProfileCatalog {
if ProfileCatalog[i].Role == role && ProfileCatalog[i].ID == protocol {
return &ProfileCatalog[i]
}
}
return nil
}
func ValidateEntryProtocol(protocol string) *HTTPError {
item := profileFor("relay-ingress", protocol)
if item == nil || !item.Deployable {
return httpErr(400, "Unsupported relay ingress: %s", protocol)
}
return nil
}
func ValidateProtocolPair(relayProtocol, exitProtocol string) *HTTPError {
if err := ValidateEntryProtocol(relayProtocol); err != nil {
return err
}
exit := profileFor("exit-transport", exitProtocol)
if exit == nil || !exit.Deployable {
return httpErr(400, "Unsupported exit transport: %s", exitProtocol)
}
return nil
}
func IsRealityProtocol(protocol string) bool {
return protocol == "vless-reality-vision" || protocol == "vless-reality"
}
// ---------- Reality keys ----------
func GenerateRealityKeyPair() KeyPair {
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
panic(err)
}
return KeyPair{
PrivateKey: base64.RawURLEncoding.EncodeToString(priv.Bytes()),
PublicKey: base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()),
}
}
func realityPublicKeyFromPrivate(privateKey string) (string, error) {
raw, err := base64.RawURLEncoding.DecodeString(privateKey)
if err != nil {
// Tolerate padded input.
raw, err = base64.URLEncoding.DecodeString(privateKey)
if err != nil {
return "", err
}
}
priv, err := ecdh.X25519().NewPrivateKey(raw)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()), nil
}
func RealityKeyIDForNode(node *Node) string {
if node.Reality != nil && node.Reality.KeyID != "" {
return node.Reality.KeyID
}
return "reality-" + node.ID
}
func EnsureRealityKey(d *Data, node *Node) *KeyEntry {
if !IsRealityProtocol(node.Protocol) {
return nil
}
EnsureNodeSecrets(node)
keyID := RealityKeyIDForNode(node)
if d.KeyStore == nil {
d.KeyStore = map[string]*KeyEntry{}
}
entry := d.KeyStore[keyID]
if entry == nil || entry.PrivateKey == "" || entry.PublicKey == "" {
pair := GenerateRealityKeyPair()
entry = &KeyEntry{PrivateKey: pair.PrivateKey, PublicKey: pair.PublicKey, UpdatedAt: NowISO()}
d.KeyStore[keyID] = entry
}
return entry
}
func KeyMaterialForNode(d *Data, node *Node) map[string]KeyPair {
entry := EnsureRealityKey(d, node)
if entry == nil {
return nil
}
return map[string]KeyPair{
RealityKeyIDForNode(node): {PrivateKey: entry.PrivateKey, PublicKey: entry.PublicKey},
}
}
func DropRealityKey(d *Data, node *Node) {
if d.KeyStore != nil {
delete(d.KeyStore, RealityKeyIDForNode(node))
}
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"encoding/base64"
"os"
"reflect"
"testing"
)
func TestRealityVectors(t *testing.T) {
raw, err := os.ReadFile("testdata/reality_vectors.json")
if err != nil {
t.Fatal(err)
}
var vectors []KeyPair
if err := unmarshalJSONUseNumber(raw, &vectors); err != nil {
t.Fatal(err)
}
if len(vectors) == 0 {
t.Fatal("no vectors")
}
for _, v := range vectors {
got, err := realityPublicKeyFromPrivate(v.PrivateKey)
if err != nil {
t.Fatalf("derive from %s: %v", v.PrivateKey, err)
}
if got != v.PublicKey {
t.Errorf("private %s: got public %s, want %s", v.PrivateKey, got, v.PublicKey)
}
}
}
func TestGenerateRealityKeyPairFormat(t *testing.T) {
pair := GenerateRealityKeyPair()
for _, k := range []string{pair.PrivateKey, pair.PublicKey} {
if len(k) != 43 {
t.Errorf("key %q length %d, want 43", k, len(k))
}
raw, err := base64.RawURLEncoding.DecodeString(k)
if err != nil {
t.Errorf("key %q not base64url: %v", k, err)
}
if len(raw) != 32 {
t.Errorf("key %q decodes to %d bytes, want 32", k, len(raw))
}
}
derived, err := realityPublicKeyFromPrivate(pair.PrivateKey)
if err != nil {
t.Fatal(err)
}
if derived != pair.PublicKey {
t.Errorf("generated pair inconsistent: derived %s, public %s", derived, pair.PublicKey)
}
}
func TestGoldenResources(t *testing.T) {
raw, err := os.ReadFile("testdata/golden_resources.json")
if err != nil {
t.Fatal(err)
}
var golden map[string]any
if err := unmarshalJSONUseNumber(raw, &golden); err != nil {
t.Fatal(err)
}
exitPassword := "AwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISI="
rfNode := &Node{
ID: "nod_rf", Name: "RF-Reality", ServerID: "srv_entry",
ExitServerID: StrPtr("srv_exit"),
Protocol: "vless-reality-vision",
Topology: "forward",
ExitProtocol: StrPtr("shadowsocks-2022-aes256"),
NetworkMode: "ipv4",
RealityServerName: "www.amd.com",
RealityDestPort: 443,
Port: 23456,
ExitPort: IntPtr(23457),
Status: "active",
ResourceID: "res_rf",
ExitResourceID: "res_rfx",
WSPath: StrPtr("/rfpath"),
Reality: &RealityConfig{KeyID: "reality-nod_rf", ServerName: "www.amd.com", DestPort: 443},
ExitCredentials: &ExitCredentials{ClientID: "11111111-2222-3333-4444-0000000000ff", Password: exitPassword, SocksUser: "ng_0123456789"},
}
rfClients := []*NodeClient{
{NodeID: "nod_rf", CustomerID: "cus_1", Credentials: Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "unused1", ShortID: "abcd0001ef01"}},
{NodeID: "nod_rf", CustomerID: "cus_2", Credentials: Credentials{ClientID: "11111111-2222-3333-4444-000000000002", RelayPassword: "unused2", ShortID: "abcd0002ef01"}},
}
entryServer := &Server{ID: "srv_entry", PublicAddress: "203.0.113.10"}
exitServer := &Server{ID: "srv_exit", PublicAddress: "exit.example.com"}
exitServerLocal := &Server{ID: "srv_exit", PublicAddress: "203.0.113.20"}
ssNode := &Node{
ID: "nod_ss", Name: "SS-Direct", ServerID: "srv_entry",
Protocol: "shadowsocks-2022-aes128",
Topology: "direct",
NetworkMode: "ipv4",
Port: 23458,
Status: "active",
ResourceID: "res_ss",
WSPath: StrPtr("/sspath"),
SSServerKey: StrPtr("CwwNDg8QERITFBUWFxgZGg=="),
}
ssClients := []*NodeClient{
{NodeID: "nod_ss", CustomerID: "cus_1", Credentials: Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001ef01"}},
}
ssServer := &Server{ID: "srv_entry", PublicAddress: "203.0.113.11"}
rfEntry, err := BuildNodeResource(BuildNodeResourceParams{
ResourceID: "res_rf", TagPrefix: tagForNode(rfNode), Node: rfNode,
NodeClients: rfClients, Server: entryServer, ExitServer: exitServer,
})
if err != nil {
t.Fatal(err)
}
rfExit, err := BuildNodeExitResource(BuildNodeExitResourceParams{
ResourceID: "res_rfx", TagPrefix: tagForNode(rfNode), Node: rfNode, Server: exitServerLocal,
})
if err != nil {
t.Fatal(err)
}
ssEntry, err := BuildNodeResource(BuildNodeResourceParams{
ResourceID: "res_ss", TagPrefix: tagForNode(ssNode), Node: ssNode,
NodeClients: ssClients, Server: ssServer,
})
if err != nil {
t.Fatal(err)
}
for name, got := range map[string]map[string]any{"rfEntry": rfEntry, "rfExit": rfExit, "ssEntry": ssEntry} {
rawGot, err := marshalJSON(got)
if err != nil {
t.Fatal(err)
}
var decoded any
if err := unmarshalJSONUseNumber(rawGot, &decoded); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(decoded, golden[name]) {
t.Errorf("%s mismatch:\n got: %s\nwant: %v", name, rawGot, golden[name])
}
}
}
+22
View File
@@ -0,0 +1,22 @@
package main
// Port of lib/redact.js. The three patterns and replacements are copied
// verbatim from the JS regexes (all case-insensitive) and applied in the
// same order.
import "regexp"
var (
rePrivateKey = regexp.MustCompile(`(?i)\bPrivate\s*Key\s*:\s*[^\s,;]+`)
reAgentKey = regexp.MustCompile(`(?i)\bNG_AGENT_KEY\s*=\s*[^\s,;]+`)
reBearerToken = regexp.MustCompile(`(?i)\bBearer\s+[^\s,;]+`)
)
// RedactSecrets strips Reality private keys, agent keys and bearer tokens
// from a string before it is retained in job history or API responses.
func RedactSecrets(s string) string {
s = rePrivateKey.ReplaceAllString(s, "PrivateKey: [REDACTED]")
s = reAgentKey.ReplaceAllString(s, "NG_AGENT_KEY=[REDACTED]")
s = reBearerToken.ReplaceAllString(s, "Bearer [REDACTED]")
return s
}
+24
View File
@@ -0,0 +1,24 @@
package main
import "testing"
func TestRedactSecrets(t *testing.T) {
cases := []struct{ in, want string }{
{"Private Key: abc123", "PrivateKey: [REDACTED]"},
{"PrivateKey: abc123", "PrivateKey: [REDACTED]"},
{"private key: secret,rest", "PrivateKey: [REDACTED],rest"},
{" Private Key : spaced-secret;next", " PrivateKey: [REDACTED];next"},
{"NG_AGENT_KEY=deadbeef", "NG_AGENT_KEY=[REDACTED]"},
{"ng_agent_key = deadbeef,more", "NG_AGENT_KEY=[REDACTED],more"},
{"Bearer token123", "Bearer [REDACTED]"},
{"authorization: bearer token123;", "authorization: Bearer [REDACTED];"},
{"nothing to hide", "nothing to hide"},
{"", ""},
{"Private Key: a NG_AGENT_KEY=b Bearer c", "PrivateKey: [REDACTED] NG_AGENT_KEY=[REDACTED] Bearer [REDACTED]"},
}
for _, c := range cases {
if got := RedactSecrets(c.in); got != c.want {
t.Errorf("RedactSecrets(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+165
View File
@@ -0,0 +1,165 @@
package main
// Self-contained scrypt (RFC 7914) with Node's crypto.scryptSync defaults:
// N=16384, r=8, p=1, keyLen=64. The stdlib has no scrypt, so PBKDF2-HMAC-SHA256,
// the Salsa20/8 core, BlockMix and ROMix are implemented here from the spec.
//
// Important Node quirk: crypto.scryptSync(secret, salt, 64) takes the salt as
// a string and uses its raw UTF-8 bytes — the hex-looking salt produced by
// HashSecret is NOT hex-decoded before use.
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"math/bits"
)
// ScryptHash derives 64 bytes from secret and salt with N=16384, r=8, p=1.
func ScryptHash(secret, salt string) []byte {
const (
n = 16384
r = 8
p = 1
keyLen = 64
)
password := []byte(secret)
saltBytes := []byte(salt)
b := pbkdf2SHA256(password, saltBytes, 1, p*128*r)
// Work in uint32 words for ROMix/BlockMix.
words := make([]uint32, len(b)/4)
for i := range words {
words[i] = binary.LittleEndian.Uint32(b[i*4:])
}
blockWords := 32 * r
for i := 0; i < p; i++ {
romix(words[i*blockWords:(i+1)*blockWords], n, r)
}
for i := range words {
binary.LittleEndian.PutUint32(b[i*4:], words[i])
}
return pbkdf2SHA256(password, b, 1, keyLen)
}
func pbkdf2SHA256(password, salt []byte, iter, keyLen int) []byte {
hashLen := sha256.Size
numBlocks := (keyLen + hashLen - 1) / hashLen
dk := make([]byte, 0, numBlocks*hashLen)
var counter [4]byte
for block := 1; block <= numBlocks; block++ {
mac := hmac.New(sha256.New, password)
mac.Write(salt)
binary.BigEndian.PutUint32(counter[:], uint32(block))
mac.Write(counter[:])
u := mac.Sum(nil)
t := make([]byte, len(u))
copy(t, u)
for i := 1; i < iter; i++ {
mac = hmac.New(sha256.New, password)
mac.Write(u)
u = mac.Sum(nil)
for j := range t {
t[j] ^= u[j]
}
}
dk = append(dk, t...)
}
return dk[:keyLen]
}
// salsa208Core applies the Salsa20/8 core function in place to 16 words.
func salsa208Core(x *[16]uint32) {
orig := *x
for i := 0; i < 8; i += 2 {
// Column round.
x[4] ^= bits.RotateLeft32(x[0]+x[12], 7)
x[8] ^= bits.RotateLeft32(x[4]+x[0], 9)
x[12] ^= bits.RotateLeft32(x[8]+x[4], 13)
x[0] ^= bits.RotateLeft32(x[12]+x[8], 18)
x[9] ^= bits.RotateLeft32(x[5]+x[1], 7)
x[13] ^= bits.RotateLeft32(x[9]+x[5], 9)
x[1] ^= bits.RotateLeft32(x[13]+x[9], 13)
x[5] ^= bits.RotateLeft32(x[1]+x[13], 18)
x[14] ^= bits.RotateLeft32(x[10]+x[6], 7)
x[2] ^= bits.RotateLeft32(x[14]+x[10], 9)
x[6] ^= bits.RotateLeft32(x[2]+x[14], 13)
x[10] ^= bits.RotateLeft32(x[6]+x[2], 18)
x[3] ^= bits.RotateLeft32(x[15]+x[11], 7)
x[7] ^= bits.RotateLeft32(x[3]+x[15], 9)
x[11] ^= bits.RotateLeft32(x[7]+x[3], 13)
x[15] ^= bits.RotateLeft32(x[11]+x[7], 18)
// Row round.
x[1] ^= bits.RotateLeft32(x[0]+x[3], 7)
x[2] ^= bits.RotateLeft32(x[1]+x[0], 9)
x[3] ^= bits.RotateLeft32(x[2]+x[1], 13)
x[0] ^= bits.RotateLeft32(x[3]+x[2], 18)
x[6] ^= bits.RotateLeft32(x[5]+x[4], 7)
x[7] ^= bits.RotateLeft32(x[6]+x[5], 9)
x[4] ^= bits.RotateLeft32(x[7]+x[6], 13)
x[5] ^= bits.RotateLeft32(x[4]+x[7], 18)
x[11] ^= bits.RotateLeft32(x[10]+x[9], 7)
x[8] ^= bits.RotateLeft32(x[11]+x[10], 9)
x[9] ^= bits.RotateLeft32(x[8]+x[11], 13)
x[10] ^= bits.RotateLeft32(x[9]+x[8], 18)
x[12] ^= bits.RotateLeft32(x[15]+x[14], 7)
x[13] ^= bits.RotateLeft32(x[12]+x[15], 9)
x[14] ^= bits.RotateLeft32(x[13]+x[12], 13)
x[15] ^= bits.RotateLeft32(x[14]+x[13], 18)
}
for i := range x {
x[i] += orig[i]
}
}
// blockMix performs scryptBlockMix over 2r 64-byte blocks held as uint32
// words, writing the result into out (which must not alias in). tmp is the
// running 64-byte block.
func blockMix(in, out []uint32, r int, tmp *[16]uint32) {
copy(tmp[:], in[(2*r-1)*16:(2*r)*16])
for i := 0; i < 2*r; i++ {
block := in[i*16 : (i+1)*16]
for j := 0; j < 16; j++ {
tmp[j] ^= block[j]
}
salsa208Core(tmp)
// Y_i goes to the even blocks first, then the odd blocks.
dest := i / 2
if i%2 == 1 {
dest = dest + r
}
copy(out[dest*16:(dest+1)*16], tmp[:])
}
}
// romix is scryptROMix over one 128*r-byte chunk held as uint32 words.
func romix(b []uint32, n, r int) {
blockWords := 32 * r
v := make([]uint32, n*blockWords)
x := make([]uint32, blockWords)
y := make([]uint32, blockWords)
copy(x, b)
for i := 0; i < n; i++ {
copy(v[i*blockWords:], x)
blockMix(x, y, r, &[16]uint32{})
copy(x, y)
}
for i := 0; i < n; i++ {
// Integerify: first 64 bits of the last 64-byte block, little-endian.
pos := (2*r - 1) * 16
j := int((uint64(x[pos]) | uint64(x[pos+1])<<32) % uint64(n))
vj := v[j*blockWords:]
for k := range x {
x[k] ^= vj[k]
}
blockMix(x, y, r, &[16]uint32{})
copy(x, y)
}
copy(b, x)
}
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"encoding/json"
"os"
"testing"
)
type scryptVector struct {
Password string `json:"password"`
Salt string `json:"salt"`
Encoded string `json:"encoded"`
}
func loadScryptVectors(t *testing.T) []scryptVector {
t.Helper()
raw, err := os.ReadFile("testdata/scrypt_vectors.json")
if err != nil {
t.Fatalf("read vectors: %v", err)
}
var vectors []scryptVector
if err := json.Unmarshal(raw, &vectors); err != nil {
t.Fatalf("parse vectors: %v", err)
}
if len(vectors) == 0 {
t.Fatal("no scrypt vectors")
}
return vectors
}
func TestHashSecretWithSaltMatchesVectors(t *testing.T) {
for i, v := range loadScryptVectors(t) {
got := HashSecretWithSalt(v.Password, v.Salt)
if got != v.Encoded {
t.Errorf("vector %d: HashSecretWithSalt(%q, %q)\n got %s\nwant %s", i, v.Password, v.Salt, got, v.Encoded)
}
}
}
func TestVerifySecretAgainstVectors(t *testing.T) {
for i, v := range loadScryptVectors(t) {
if !VerifySecret(v.Password, v.Encoded) {
t.Errorf("vector %d: VerifySecret(correct) = false, want true", i)
}
if VerifySecret(v.Password+"wrong", v.Encoded) {
t.Errorf("vector %d: VerifySecret(wrong password) = true, want false", i)
}
}
}
func TestVerifySecretMalformed(t *testing.T) {
cases := []string{
"",
"scrypt",
"scrypt:",
"scrypt::",
"scrypt:salt:",
"scrypt::abcd",
"bcrypt:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:abcd",
"scrypt:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:not-hex!!",
"scrypt:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:abcd", // wrong length
"scrypt:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:zz",
}
for _, c := range cases {
if VerifySecret("password", c) {
t.Errorf("VerifySecret(%q) = true, want false", c)
}
}
}
func TestHashSecretRoundTrip(t *testing.T) {
encoded := HashSecret("s3cret")
parts := len(encoded)
_ = parts
if !VerifySecret("s3cret", encoded) {
t.Errorf("VerifySecret with fresh HashSecret = false")
}
if VerifySecret("other", encoded) {
t.Errorf("VerifySecret wrong secret = true")
}
// Format: scrypt:<32 hex salt chars>:<128 hex hash chars>
var kind, salt, hash string
n, err := parseEncoded(encoded, &kind, &salt, &hash)
if err != nil || n != 3 {
t.Fatalf("encoded format: n=%d err=%v encoded=%q", n, err, encoded)
}
if kind != "scrypt" || len(salt) != 32 || len(hash) != 128 {
t.Errorf("encoded parts: kind=%q salt len=%d hash len=%d", kind, len(salt), len(hash))
}
// Two hashes of the same secret differ (random salt).
if HashSecret("s3cret") == encoded {
t.Errorf("HashSecret produced identical output twice; salt is not random")
}
}
func parseEncoded(encoded string, kind, salt, hash *string) (int, error) {
parts := splitColon(encoded)
if len(parts) > 0 {
*kind = parts[0]
}
if len(parts) > 1 {
*salt = parts[1]
}
if len(parts) > 2 {
*hash = parts[2]
}
return len(parts), nil
}
func splitColon(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == ':' {
out = append(out, s[start:i])
start = i + 1
}
}
return append(out, s[start:])
}
+130
View File
@@ -0,0 +1,130 @@
package main
// Static file serving: the Go port of lib/http.js serveStatic. Assets are
// embedded from embed_public/ (populated by build.sh from ../public);
// when NG_PUBLIC_DIR is set, files are read from that directory instead.
import (
"embed"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
)
//go:embed embed_public
var embeddedPublic embed.FS
const embedRoot = "embed_public"
var staticMIME = map[string]string{
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".json": "application/json; charset=utf-8",
}
// ServeStatic serves pathname from the public root; returns false when no
// regular file matches (mirroring serveStatic's false), and an error for
// unexpected I/O failures (mirroring its throw).
func ServeStatic(w http.ResponseWriter, pathname string) (bool, error) {
if dir := os.Getenv("NG_PUBLIC_DIR"); dir != "" {
return serveStaticDisk(w, dir, pathname)
}
relative := "index.html"
if pathname != "/" {
relative = strings.TrimLeft(pathname, "/")
}
// Reject any path that would escape the root (JS path.resolve check).
if escapesRoot(relative) {
return false, nil
}
name := embedRoot + "/" + relative
info, err := fs.Stat(embeddedPublic, name)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
if !info.Mode().IsRegular() {
return false, nil
}
body, err := fs.ReadFile(embeddedPublic, name)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
writeStatic(w, relative, body)
return true, nil
}
// escapesRoot reports whether a slash-separated relative path climbs above
// its root via ".." segments.
func escapesRoot(relative string) bool {
depth := 0
for _, seg := range strings.Split(relative, "/") {
switch seg {
case "..":
if depth == 0 {
return true
}
depth--
case "", ".":
default:
depth++
}
}
return false
}
func serveStaticDisk(w http.ResponseWriter, publicDir, pathname string) (bool, error) {
relative := "index.html"
if pathname != "/" {
relative = strings.TrimLeft(pathname, "/")
}
root := filepath.Clean(publicDir)
target := filepath.Join(root, filepath.FromSlash(relative))
if !strings.HasPrefix(target, root+string(filepath.Separator)) && target != filepath.Join(root, "index.html") {
return false, nil
}
info, err := os.Stat(target)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
if !info.Mode().IsRegular() {
return false, nil
}
body, err := os.ReadFile(target)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
writeStatic(w, relative, body)
return true, nil
}
func writeStatic(w http.ResponseWriter, name string, body []byte) {
contentType := staticMIME[path.Ext(name)]
if contentType == "" {
contentType = "application/octet-stream"
}
h := w.Header()
h.Set("Content-Type", contentType)
h.Set("Content-Length", strconv.Itoa(len(body)))
h.Set("Cache-Control", "no-store")
h.Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(200)
_, _ = w.Write(body)
}
+234
View File
@@ -0,0 +1,234 @@
package main
// Port of lib/store.js: the whole database is one JSON file, rewritten
// atomically (temp file + rename, mode 0600) on every durable transaction.
// JS serializes concurrent operations on a promise queue; a mutex provides
// the same serialization here.
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
// ErrSkip is the counterpart of JS Store.SKIP: a Transaction mutator returns
// it (wrapped or directly) to abort without persisting.
var ErrSkip = errors.New("no data changes")
// collections lists the validated collections in the JS COLLECTIONS order;
// validation error messages name the first offending one.
var collections = []struct {
name string
isNil func(d *Data) bool
}{
{"users", func(d *Data) bool { return d.Users == nil }},
{"servers", func(d *Data) bool { return d.Servers == nil }},
{"agents", func(d *Data) bool { return d.Agents == nil }},
{"enrollmentTokens", func(d *Data) bool { return d.EnrollmentTokens == nil }},
{"customers", func(d *Data) bool { return d.Customers == nil }},
{"nodes", func(d *Data) bool { return d.Nodes == nil }},
{"nodeClients", func(d *Data) bool { return d.NodeClients == nil }},
{"jobs", func(d *Data) bool { return d.Jobs == nil }},
{"activity", func(d *Data) bool { return d.Activity == nil }},
{"observations", func(d *Data) bool { return d.Observations == nil }},
{"subscriptionAccess", func(d *Data) bool { return d.SubscriptionAccess == nil }},
{"subscriptionClients", func(d *Data) bool { return d.SubscriptionClients == nil }},
}
// EmptyData mirrors JS emptyData().
func EmptyData() *Data {
return &Data{
SchemaVersion: 1,
CreatedAt: NowISO(),
Settings: Settings{
BrandName: "NexusGate",
SessionHours: 12,
ObservationTtlMinutes: 10,
ActivityRetentionDays: 30,
CompletedJobRetentionDays: 7,
},
Users: []*User{},
Servers: []*Server{},
Agents: []*Agent{},
EnrollmentTokens: []*EnrollmentToken{},
Customers: []*Customer{},
Nodes: []*Node{},
NodeClients: []*NodeClient{},
Jobs: []*Job{},
Activity: []*Activity{},
Observations: []*Observation{},
SubscriptionAccess: []*SubscriptionAccess{},
SubscriptionClients: []*SubscriptionClient{},
KeyStore: map[string]*KeyEntry{},
}
}
func settingsMissing(s Settings) bool {
return s.BrandName == "" && s.SessionHours == 0 && s.ObservationTtlMinutes == 0 &&
s.ActivityRetentionDays == 0 && s.CompletedJobRetentionDays == 0 && len(s.Extra) == 0
}
// validateData mirrors the JS #validate.
func validateData(d *Data) error {
if d == nil || d.SchemaVersion != 1 || settingsMissing(d.Settings) {
return errors.New("Unsupported or corrupt NexusGate data file")
}
for _, c := range collections {
if c.isNil(d) {
return fmt.Errorf("Invalid collection: %s", c.name)
}
}
return nil
}
// Store is the file-backed database handle.
type Store struct {
File string
Data *Data
mu sync.Mutex
}
// NewStore mirrors the JS constructor (file is stored resolved/absolutized,
// like path.resolve).
func NewStore(file string) *Store {
if abs, err := filepath.Abs(file); err == nil {
file = abs
}
return &Store{File: file}
}
// Init loads the database file. A missing file seeds EmptyData and persists
// it. Parse failures are returned as errors (JS rethrows anything that is
// not ENOENT).
func (s *Store) Init() error {
s.mu.Lock()
defer s.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(s.File), 0o700); err != nil {
return err
}
raw, err := os.ReadFile(s.File)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
s.Data = EmptyData()
return s.persist(s.Data)
}
var data Data
if err := unmarshalJSONUseNumber(raw, &data); err != nil {
return err
}
if data.SubscriptionAccess == nil {
data.SubscriptionAccess = []*SubscriptionAccess{}
}
if data.SubscriptionClients == nil {
data.SubscriptionClients = []*SubscriptionClient{}
}
if data.KeyStore == nil {
data.KeyStore = map[string]*KeyEntry{}
}
if err := validateData(&data); err != nil {
return err
}
s.Data = &data
return nil
}
// Snapshot returns a deep copy of the current data (JS structuredClone).
func (s *Store) Snapshot() *Data {
s.mu.Lock()
defer s.mu.Unlock()
if s.Data == nil {
return nil
}
return s.Data.Clone()
}
// Transaction runs fn against a clone of the data under the store lock. If
// fn returns ErrSkip, nothing is persisted and (nil, nil) is returned. If
// fn returns any other error, nothing is persisted and the error is returned
// as-is. Otherwise (including fn returning (nil, nil), like a JS mutator
// returning undefined) the draft is validated, persisted and installed.
func (s *Store) Transaction(fn func(d *Data) (any, error)) (any, error) {
s.mu.Lock()
defer s.mu.Unlock()
draft := s.Data.Clone()
result, err := fn(draft)
if err != nil {
if errors.Is(err, ErrSkip) {
return nil, nil
}
return nil, err
}
if err := validateData(draft); err != nil {
return nil, err
}
if err := s.persist(draft); err != nil {
return nil, err
}
s.Data = draft
return result, nil
}
// Transient runs fn against the live data under the same lock, without
// validating or persisting (JS transient).
func (s *Store) Transient(fn func(d *Data)) {
s.mu.Lock()
defer s.mu.Unlock()
fn(s.Data)
}
// View runs fn against the live data under the lock for read-only access.
func (s *Store) View(fn func(d *Data)) {
s.mu.Lock()
defer s.mu.Unlock()
fn(s.Data)
}
// Replace swaps the whole database for next (JS replace): next is cloned,
// missing subscription collections / keyStore are defaulted, and the copy is
// validated before it lands.
func (s *Store) Replace(next *Data) error {
if next == nil {
return errors.New("Unsupported or corrupt NexusGate data file")
}
_, err := s.Transaction(func(draft *Data) (any, error) {
copy := next.Clone()
if copy.SubscriptionAccess == nil {
copy.SubscriptionAccess = []*SubscriptionAccess{}
}
if copy.SubscriptionClients == nil {
copy.SubscriptionClients = []*SubscriptionClient{}
}
if copy.KeyStore == nil {
copy.KeyStore = map[string]*KeyEntry{}
}
if err := validateData(copy); err != nil {
return nil, err
}
*draft = *copy
return nil, nil
})
return err
}
// persist writes data atomically: `<file>.<pid>.tmp`, mode 0600, rename,
// chmod 0600. Callers hold s.mu (Init excepted, before publication).
func (s *Store) persist(data *Data) error {
temp := fmt.Sprintf("%s.%d.tmp", s.File, os.Getpid())
body, err := marshalJSON(data)
if err != nil {
return err
}
body = append(body, '\n')
if err := os.WriteFile(temp, body, 0o600); err != nil {
return err
}
if err := os.Rename(temp, s.File); err != nil {
return err
}
return os.Chmod(s.File, 0o600)
}
+243
View File
@@ -0,0 +1,243 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func newTestStore(t *testing.T) (*Store, string) {
t.Helper()
file := filepath.Join(t.TempDir(), "nested", "data.json")
s := NewStore(file)
if err := s.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
return s, file
}
func TestInitCreatesMissingFile(t *testing.T) {
s, file := newTestStore(t)
info, err := os.Stat(file)
if err != nil {
t.Fatalf("data file not created: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("file perm = %o, want 600", perm)
}
if s.Data.SchemaVersion != 1 {
t.Errorf("SchemaVersion = %d", s.Data.SchemaVersion)
}
if s.Data.Settings.BrandName != "NexusGate" || s.Data.Settings.SessionHours != 12 ||
s.Data.Settings.ObservationTtlMinutes != 10 || s.Data.Settings.ActivityRetentionDays != 30 ||
s.Data.Settings.CompletedJobRetentionDays != 7 {
t.Errorf("settings defaults = %+v", s.Data.Settings)
}
if s.Data.CreatedAt == "" {
t.Errorf("CreatedAt empty")
}
for _, c := range collections {
if c.isNil(s.Data) {
t.Errorf("collection %s is nil after EmptyData", c.name)
}
}
if s.Data.KeyStore == nil {
t.Errorf("KeyStore nil")
}
}
func TestInitLoadPreservesUnknownFields(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "data.json")
raw := `{"schemaVersion":1,"createdAt":"2026-01-01T00:00:00.000Z",` +
`"settings":{"brandName":"NexusGate","sessionHours":12,"observationTtlMinutes":10,"activityRetentionDays":30,"completedJobRetentionDays":7,"futureSetting":true},` +
`"users":[{"id":"u1","username":"admin","passwordHash":"h","role":"admin","status":"active","createdAt":"2026-01-01T00:00:00.000Z","legacyNote":"keep-me"}],` +
`"servers":[],"agents":[],"enrollmentTokens":[],"customers":[],"nodes":[],"nodeClients":[],"jobs":[],"activity":[],"observations":[],` +
`"keyStore":{},"futureTop":{"x":1}}` + "\n"
// subscriptionAccess / subscriptionClients deliberately absent: Init must default them.
if err := os.WriteFile(file, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
s := NewStore(file)
if err := s.Init(); err != nil {
t.Fatalf("Init with unknown fields: %v", err)
}
if s.Data.SubscriptionAccess == nil || s.Data.SubscriptionClients == nil {
t.Fatalf("missing subscription collections not defaulted")
}
if _, err := s.Transaction(func(d *Data) (any, error) { return nil, nil }); err != nil {
t.Fatalf("no-op transaction: %v", err)
}
out, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
for _, needle := range []string{`"futureTop"`, `"legacyNote":"keep-me"`, `"futureSetting":true`} {
if !strings.Contains(string(out), needle) {
t.Errorf("round trip dropped %s; file: %s", needle, out)
}
}
}
func TestTransactionPersistAndResult(t *testing.T) {
s, file := newTestStore(t)
result, err := s.Transaction(func(d *Data) (any, error) {
d.Settings.BrandName = "Renamed"
return "the-result", nil
})
if err != nil {
t.Fatalf("Transaction: %v", err)
}
if result != "the-result" {
t.Errorf("result = %v", result)
}
if s.Data.Settings.BrandName != "Renamed" {
t.Errorf("in-memory data not updated")
}
reloaded := NewStore(file)
if err := reloaded.Init(); err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Data.Settings.BrandName != "Renamed" {
t.Errorf("persisted brand = %q", reloaded.Data.Settings.BrandName)
}
}
func TestTransactionErrorDoesNotPersist(t *testing.T) {
s, file := newTestStore(t)
before, _ := os.ReadFile(file)
myErr := errors.New("boom")
_, err := s.Transaction(func(d *Data) (any, error) {
d.Settings.BrandName = "ShouldNotStick"
return nil, myErr
})
if !errors.Is(err, myErr) {
t.Fatalf("err = %v, want myErr", err)
}
after, _ := os.ReadFile(file)
if string(before) != string(after) {
t.Errorf("file changed despite transaction error")
}
if s.Data.Settings.BrandName == "ShouldNotStick" {
t.Errorf("in-memory data changed despite transaction error")
}
}
func TestTransactionSkipDoesNotPersist(t *testing.T) {
s, file := newTestStore(t)
before, _ := os.ReadFile(file)
result, err := s.Transaction(func(d *Data) (any, error) {
d.Settings.BrandName = "Skipped"
return "ignored", ErrSkip
})
if err != nil {
t.Fatalf("ErrSkip transaction err = %v", err)
}
if result != nil {
t.Errorf("ErrSkip result = %v, want nil", result)
}
after, _ := os.ReadFile(file)
if string(before) != string(after) {
t.Errorf("file changed despite ErrSkip")
}
if s.Data.Settings.BrandName == "Skipped" {
t.Errorf("in-memory data changed despite ErrSkip")
}
}
func TestReplaceRoundTrip(t *testing.T) {
s, file := newTestStore(t)
next := EmptyData()
next.Settings.BrandName = "Replaced"
next.Users = []*User{{ID: "u9", Username: "root", PasswordHash: "h", Role: "admin", Status: "active", CreatedAt: "2026-01-01T00:00:00.000Z"}}
next.SubscriptionAccess = nil // Replace must default these
next.SubscriptionClients = nil
next.KeyStore = nil
if err := s.Replace(next); err != nil {
t.Fatalf("Replace: %v", err)
}
// Mutating the caller's copy after Replace must not affect the store.
next.Settings.BrandName = "MutatedAfterwards"
snap := s.Snapshot()
if snap.Settings.BrandName != "Replaced" {
t.Errorf("snapshot brand = %q", snap.Settings.BrandName)
}
if len(snap.Users) != 1 || snap.Users[0].ID != "u9" {
t.Errorf("snapshot users = %+v", snap.Users)
}
if snap.SubscriptionAccess == nil || snap.KeyStore == nil {
t.Errorf("Replace did not default collections/keyStore")
}
reloaded := NewStore(file)
if err := reloaded.Init(); err != nil {
t.Fatalf("reload after Replace: %v", err)
}
if reloaded.Data.Settings.BrandName != "Replaced" || len(reloaded.Data.Users) != 1 {
t.Errorf("reloaded data does not match Replace input")
}
}
func TestValidateErrorMessages(t *testing.T) {
s, _ := newTestStore(t)
_, err := s.Transaction(func(d *Data) (any, error) {
d.SchemaVersion = 2
return nil, nil
})
if err == nil || err.Error() != "Unsupported or corrupt NexusGate data file" {
t.Errorf("schemaVersion err = %v", err)
}
_, err = s.Transaction(func(d *Data) (any, error) {
d.Nodes = nil
return nil, nil
})
if err == nil || err.Error() != "Invalid collection: nodes" {
t.Errorf("collection err = %v", err)
}
// Failed validations must not have persisted the bad drafts.
if s.Data.SchemaVersion != 1 || s.Data.Nodes == nil {
t.Errorf("store data corrupted by failed validation")
}
}
func TestInitRejectsCorruptFiles(t *testing.T) {
dir := t.TempDir()
bad := filepath.Join(dir, "bad.json")
if err := os.WriteFile(bad, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
if err := NewStore(bad).Init(); err == nil {
t.Errorf("corrupt JSON should error")
}
wrongVersion := filepath.Join(dir, "v2.json")
raw, _ := marshalJSON(EmptyData())
var v2 map[string]any
if err := unmarshalJSONUseNumber(raw, &v2); err != nil {
t.Fatal(err)
}
v2["schemaVersion"] = 2
raw2, _ := marshalJSON(v2)
if err := os.WriteFile(wrongVersion, raw2, 0o600); err != nil {
t.Fatal(err)
}
err := NewStore(wrongVersion).Init()
if err == nil || err.Error() != "Unsupported or corrupt NexusGate data file" {
t.Errorf("schemaVersion 2 err = %v", err)
}
}
func TestTransientAndViewDoNotPersist(t *testing.T) {
s, file := newTestStore(t)
before, _ := os.ReadFile(file)
s.Transient(func(d *Data) { d.Settings.BrandName = "InMemoryOnly" })
after, _ := os.ReadFile(file)
if string(before) != string(after) {
t.Errorf("Transient persisted to disk")
}
var seen string
s.View(func(d *Data) { seen = d.Settings.BrandName })
if seen != "InMemoryOnly" {
t.Errorf("View saw %q", seen)
}
}
+703
View File
@@ -0,0 +1,703 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
)
// ---------- ordered map (JS object insertion order for Clash proxies) ----------
type OrderedKV struct {
Key string
Value any
}
type OrderedMap []OrderedKV
func (m OrderedMap) Get(key string) (any, bool) {
for _, kv := range m {
if kv.Key == key {
return kv.Value, true
}
}
return nil, false
}
func (m *OrderedMap) Set(key string, value any) {
for i := range *m {
if (*m)[i].Key == key {
(*m)[i].Value = value
return
}
}
*m = append(*m, OrderedKV{Key: key, Value: value})
}
func (m OrderedMap) Str(key string) string {
v, ok := m.Get(key)
if !ok || v == nil {
return ""
}
s, _ := v.(string)
return s
}
func (m OrderedMap) Bool(key string) bool {
v, _ := m.Get(key)
b, _ := v.(bool)
return b
}
// ---------- entries ----------
type SubEntry struct {
NodeID string `json:"nodeId"`
ClientURI string `json:"clientUri"`
}
type SubResult struct {
Body string
ContentType string
}
func decodeURIComponent(s string) string {
out, err := url.PathUnescape(s)
if err != nil {
return s
}
return out
}
func NodeClientURI(d *Data, nc *NodeClient) string {
var node *Node
for _, item := range d.Nodes {
if item.ID == nc.NodeID {
node = item
break
}
}
if node == nil || node.Status != "active" {
return ""
}
var server *Server
for _, item := range d.Servers {
if item.ID == node.ServerID {
server = item
break
}
}
if server == nil {
return ""
}
publicKey := ""
if raw, ok := node.Artifacts["realityPublicKey"]; ok && len(raw) > 0 {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
publicKey = s
}
}
networkMode := node.NetworkMode
if networkMode == "" {
networkMode = "ipv4"
}
uri, ok := BuildClientURI(BuildClientURIParams{
Protocol: node.Protocol,
RelayServer: server,
RelayPort: node.Port,
ClientID: nc.Credentials.ClientID,
RelayPassword: nc.Credentials.RelayPassword,
ShortID: nc.Credentials.ShortID,
WSPath: xbDeref(node.WSPath),
SocksUser: NodeEmail(nc.CustomerID),
Reality: node.Reality,
PublicKey: publicKey,
Name: node.Name,
NetworkMode: networkMode,
TLSDomain: server.TLSDomain,
SSServerKey: xbDeref(node.SSServerKey),
})
if !ok {
return ""
}
return uri
}
func Entries(d *Data, customerID string) []SubEntry {
seen := map[string]int{}
items := []SubEntry{}
for _, nc := range d.NodeClients {
if nc.CustomerID != customerID || nc.Status != "active" {
continue
}
uri := NodeClientURI(d, nc)
if uri == "" {
continue
}
nodeName := nc.NodeID
for _, item := range d.Nodes {
if item.ID == nc.NodeID {
nodeName = item.Name
break
}
}
index := seen[nodeName] + 1
seen[nodeName] = index
name := nodeName
if index != 1 {
name = fmt.Sprintf("%s (%d)", nodeName, index)
}
separator := strings.LastIndex(uri, "#")
prefix := uri
if separator >= 0 {
prefix = uri[:separator]
}
items = append(items, SubEntry{
NodeID: nc.NodeID,
ClientURI: prefix + "#" + encodeURIComponent(name),
})
}
return items
}
// ---------- Clash ----------
func q(value any) string {
// JSON.stringify(String(value)); String(null) is "null".
b, err := marshalJSON(toJSString(value))
if err != nil {
panic(err)
}
return string(b)
}
func toJSString(value any) string {
switch v := value.(type) {
case string:
return v
case bool:
if v {
return "true"
}
return "false"
case int:
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
case float64:
return strconv.FormatFloat(v, 'g', -1, 64)
case nil:
return "null"
default:
return fmt.Sprintf("%v", v)
}
}
func ClashProxy(item SubEntry) (OrderedMap, error) {
uri := item.ClientURI
hashIdx := strings.LastIndex(uri, "#")
name := decodeURIComponent(uri[hashIdx+1:])
u, err := url.Parse(uri)
if err != nil {
return nil, err
}
port := 0
if p := u.Port(); p != "" {
if n, err := strconv.Atoi(p); err == nil {
port = n
}
}
base := OrderedMap{
{Key: "name", Value: name},
{Key: "server", Value: u.Hostname()},
{Key: "port", Value: port},
}
query := u.Query()
switch u.Scheme + ":" {
case "hysteria2:":
password := ""
if u.User != nil {
password = u.User.Username()
}
out := append(OrderedMap{}, base...)
out = append(out,
OrderedKV{Key: "type", Value: "hysteria2"},
OrderedKV{Key: "password", Value: password},
OrderedKV{Key: "sni", Value: queryGetOrNil(query, "sni")},
OrderedKV{Key: "alpn", Value: []string{"h3"}},
)
return out, nil
case "anytls:":
password := ""
if u.User != nil {
password = u.User.Username()
}
out := append(OrderedMap{}, base...)
out = append(out,
OrderedKV{Key: "type", Value: "anytls"},
OrderedKV{Key: "password", Value: password},
OrderedKV{Key: "sni", Value: queryGetOrNil(query, "sni")},
OrderedKV{Key: "udp", Value: true},
)
return out, nil
case "vless:":
security := query.Get("security")
uuid := ""
if u.User != nil {
uuid = u.User.Username()
}
isTLS := security == "reality" || security == "tls"
network := "tcp"
if query.Get("type") == "ws" {
network = "ws"
}
out := append(OrderedMap{}, base...)
out = append(out,
OrderedKV{Key: "type", Value: "vless"},
OrderedKV{Key: "uuid", Value: uuid},
OrderedKV{Key: "tls", Value: isTLS},
OrderedKV{Key: "network", Value: network},
)
if network == "ws" {
path := query.Get("path")
if path == "" {
path = "/"
}
out = append(out, OrderedKV{Key: "ws-opts", Value: OrderedMap{{Key: "path", Value: path}}})
}
if isTLS {
out = append(out, OrderedKV{Key: "servername", Value: queryGetOrNil(query, "sni")})
if security == "reality" {
fp := query.Get("fp")
if fp == "" {
fp = "chrome"
}
out = append(out,
OrderedKV{Key: "client-fingerprint", Value: fp},
OrderedKV{Key: "reality-opts", Value: OrderedMap{
{Key: "public-key", Value: queryGetOrNil(query, "pbk")},
{Key: "short-id", Value: queryGetOrNil(query, "sid")},
}},
)
}
if _, has := query["flow"]; has {
out = append(out, OrderedKV{Key: "flow", Value: query.Get("flow")})
}
}
return out, nil
case "ss:":
username := ""
if u.User != nil {
username = u.User.Username()
}
raw, err := decodeBase64URL(username)
if err != nil {
return nil, errors.New("Invalid Shadowsocks URI")
}
decoded := string(raw)
separator := strings.Index(decoded, ":")
if separator < 0 {
return nil, errors.New("Invalid Shadowsocks URI")
}
out := append(OrderedMap{}, base...)
out = append(out,
OrderedKV{Key: "type", Value: "ss"},
OrderedKV{Key: "cipher", Value: decoded[:separator]},
OrderedKV{Key: "password", Value: decoded[separator+1:]},
)
return out, nil
case "socks5:":
username, password := "", ""
if u.User != nil {
username = u.User.Username()
password, _ = u.User.Password()
}
out := append(OrderedMap{}, base...)
out = append(out,
OrderedKV{Key: "type", Value: "socks5"},
OrderedKV{Key: "username", Value: username},
OrderedKV{Key: "password", Value: password},
)
return out, nil
}
return nil, errors.New("Unsupported client URI")
}
func queryGetOrNil(query url.Values, key string) any {
vals, ok := query[key]
if !ok || len(vals) == 0 {
return nil
}
return vals[0]
}
func decodeBase64URL(s string) ([]byte, error) {
if raw, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return raw, nil
}
padded := s
for len(padded)%4 != 0 {
padded += "="
}
return base64.URLEncoding.DecodeString(padded)
}
func yamlValue(value any, indent int) string {
switch v := value.(type) {
case []string:
raw, err := marshalJSON(v)
if err != nil {
panic(err)
}
return string(raw)
case []any:
raw, err := marshalJSON(v)
if err != nil {
panic(err)
}
return string(raw)
case OrderedMap:
parts := make([]string, 0, len(v))
pad := strings.Repeat(" ", indent)
for _, kv := range v {
if child, isObj := kv.Value.(OrderedMap); isObj {
parts = append(parts, pad+kv.Key+": "+"\n"+yamlValue(child, indent+2))
} else {
parts = append(parts, pad+kv.Key+": "+yamlValue(kv.Value, 0))
}
}
return strings.Join(parts, "\n")
case bool:
if v {
return "true"
}
return "false"
case int:
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
case float64:
return strconv.FormatFloat(v, 'g', -1, 64)
case json.Number:
return v.String()
default:
return q(value)
}
}
func MakeClash(items []SubEntry, smart bool) string {
reserved := []string{"节点选择", "DIRECT", "REJECT"}
if smart {
reserved = []string{"节点选择", "自动选择", "流媒体", "AI 服务", "Meta 服务", "DIRECT", "REJECT"}
}
used := map[string]bool{}
for _, r := range reserved {
used[r] = true
}
proxies := make([]OrderedMap, 0, len(items))
for _, item := range items {
proxy, err := ClashProxy(item)
if err != nil {
panic(err)
}
name := proxy.Str("name")
finalName := name
if used[finalName] {
index := 1
for {
suffix := ""
if index != 1 {
suffix = fmt.Sprintf(" %d", index)
}
finalName = fmt.Sprintf("%s (节点%s)", name, suffix)
index++
if !used[finalName] {
break
}
}
}
used[finalName] = true
proxy.Set("name", finalName)
proxies = append(proxies, proxy)
}
names := make([]string, 0, len(proxies))
yamlParts := make([]string, 0, len(proxies))
for _, proxy := range proxies {
names = append(names, proxy.Str("name"))
y := yamlValue(proxy, 4)
if strings.HasPrefix(y, " ") {
y = " - " + y[4:]
}
yamlParts = append(yamlParts, y)
}
yaml := strings.Join(yamlParts, "\n")
group := func(name, typ string, members []string) string {
var sb strings.Builder
sb.WriteString(" - name: " + q(name) + "\n")
sb.WriteString(" type: " + typ + "\n")
if typ == "url-test" {
sb.WriteString(" url: \"https://www.gstatic.com/generate_204\"\n interval: 300\n tolerance: 80\n")
}
sb.WriteString(" proxies:\n")
lines := make([]string, len(members))
for i, m := range members {
lines[i] = " - " + q(m)
}
sb.WriteString(strings.Join(lines, "\n"))
sb.WriteString("\n")
return sb.String()
}
var groups string
if smart {
groups = group("节点选择", "select", append([]string{"自动选择"}, append(append([]string{}, names...), "DIRECT")...)) +
group("自动选择", "url-test", names) +
group("流媒体", "select", append([]string{"节点选择", "自动选择"}, names...)) +
group("AI 服务", "select", append([]string{"节点选择", "自动选择"}, names...)) +
group("Meta 服务", "select", append([]string{"节点选择", "自动选择"}, names...))
} else {
groups = group("节点选择", "select", names)
}
rules := []string{"MATCH,节点选择"}
if smart {
rules = []string{
"GEOSITE,category-ads-all,REJECT", "GEOSITE,private,DIRECT",
"GEOSITE,openai,AI 服务", "GEOSITE,netflix,流媒体",
"GEOSITE,facebook,Meta 服务", "DOMAIN-SUFFIX,fbcdn.net,Meta 服务",
"DOMAIN-SUFFIX,instagram.com,Meta 服务", "DOMAIN-SUFFIX,whatsapp.net,Meta 服务",
"GEOSITE,geolocation-!cn,节点选择", "GEOSITE,cn,DIRECT",
"GEOIP,CN,DIRECT", "MATCH,节点选择",
}
}
ruleLines := make([]string, len(rules))
for i, r := range rules {
ruleLines[i] = " - " + q(r)
}
return "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n" + yaml +
"\nproxy-groups:\n" + groups + "rules:\n" + strings.Join(ruleLines, "\n") + "\n"
}
// ---------- Surge ----------
// MakeSurge mirrors makeSurge; ok=false mirrors JS null.
func MakeSurge(items []SubEntry) (string, bool) {
proxies := []OrderedMap{}
for _, item := range items {
proxy, err := ClashProxy(item)
if err != nil {
panic(err)
}
typ := proxy.Str("type")
if typ == "ss" || typ == "socks5" {
proxies = append(proxies, proxy)
}
}
if len(proxies) == 0 {
return "", false
}
safeReplacer := strings.NewReplacer("\r", "_", "\n", "_", ",", "_", "=", "_")
safe := func(v string) string { return safeReplacer.Replace(v) }
policies := make([]string, 0, len(proxies))
names := make([]string, 0, len(proxies))
for _, proxy := range proxies {
prefix := safe(proxy.Str("name")) + " = "
server, _ := proxy.Get("server")
port, _ := proxy.Get("port")
if proxy.Str("type") == "ss" {
policies = append(policies, fmt.Sprintf("%sss, %s, %s, encrypt-method=%s, password=%s",
prefix, toJSString(server), toJSString(port), proxy.Str("cipher"), safe(proxy.Str("password"))))
} else {
policies = append(policies, fmt.Sprintf("%ssocks5, %s, %s, %s, %s",
prefix, toJSString(server), toJSString(port), safe(proxy.Str("username")), safe(proxy.Str("password"))))
}
names = append(names, safe(proxy.Str("name")))
}
joined := strings.Join(names, ", ")
body := "[General]\nloglevel = notify\n[Proxy]\n" + strings.Join(policies, "\n") +
"\n[Proxy Group]\n节点选择 = select, " + joined + ", DIRECT\n自动选择 = url-test, " + joined +
", url=http://www.gstatic.com/generate_204, interval=600\n[Rule]\nGEOIP,CN,DIRECT\nFINAL,节点选择\n"
return body, true
}
// ---------- sing-box ----------
type sbTransport struct {
Type string `json:"type"`
Path string `json:"path"`
}
type sbReality struct {
Enabled bool `json:"enabled"`
PublicKey string `json:"public_key"`
ShortID string `json:"short_id"`
}
type sbTLS struct {
Enabled bool `json:"enabled"`
ServerName string `json:"server_name"`
Alpn []string `json:"alpn,omitempty"`
Reality *sbReality `json:"reality,omitempty"`
}
type sbOutbound struct {
Type string `json:"type"`
Tag string `json:"tag"`
Server string `json:"server,omitempty"`
ServerPort int `json:"server_port,omitempty"`
Method string `json:"method,omitempty"`
Version string `json:"version,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
UUID string `json:"uuid,omitempty"`
Flow string `json:"flow,omitempty"`
Transport *sbTransport `json:"transport,omitempty"`
TLS *sbTLS `json:"tls,omitempty"`
Outbounds []string `json:"outbounds,omitempty"`
}
type sbInbound struct {
Type string `json:"type"`
Tag string `json:"tag"`
Listen string `json:"listen"`
ListenPort int `json:"listen_port"`
}
type sbConfig struct {
Log struct {
Level string `json:"level"`
} `json:"log"`
Inbounds []sbInbound `json:"inbounds"`
Outbounds []sbOutbound `json:"outbounds"`
Route struct {
Final string `json:"final"`
} `json:"route"`
}
func subMarshalIndentJSON(v any) string {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
panic(err)
}
return buf.String()
}
func MakeSingBox(items []SubEntry) string {
proxies := make([]sbOutbound, 0, len(items))
tags := make([]string, 0, len(items))
for i, item := range items {
proxy, err := ClashProxy(item)
if err != nil {
panic(err)
}
tag := fmt.Sprintf("node-%d", i+1)
tags = append(tags, tag)
server := proxy.Str("server")
portVal, _ := proxy.Get("port")
port, _ := portVal.(int)
base := sbOutbound{Tag: tag, Server: server, ServerPort: port}
switch proxy.Str("type") {
case "ss":
base.Type = "shadowsocks"
base.Method = proxy.Str("cipher")
base.Password = proxy.Str("password")
case "hysteria2":
base.Type = "hysteria2"
base.Password = proxy.Str("password")
base.TLS = &sbTLS{Enabled: true, ServerName: proxy.Str("sni"), Alpn: []string{"h3"}}
case "anytls":
base.Type = "anytls"
base.Password = proxy.Str("password")
base.TLS = &sbTLS{Enabled: true, ServerName: proxy.Str("sni")}
case "socks5":
base.Type = "socks"
base.Version = "5"
base.Username = proxy.Str("username")
base.Password = proxy.Str("password")
default: // vless
base.Type = "vless"
base.UUID = proxy.Str("uuid")
if flow := proxy.Str("flow"); flow != "" {
base.Flow = flow
}
if proxy.Str("network") == "ws" {
path := "/"
if wsOpts, ok := proxy.Get("ws-opts"); ok {
if om, isOM := wsOpts.(OrderedMap); isOM {
if p := om.Str("path"); p != "" {
path = p
}
}
}
base.Transport = &sbTransport{Type: "ws", Path: path}
}
if proxy.Bool("tls") {
tls := &sbTLS{Enabled: true, ServerName: proxy.Str("servername")}
if ro, ok := proxy.Get("reality-opts"); ok && ro != nil {
if om, isOM := ro.(OrderedMap); isOM {
tls.Reality = &sbReality{Enabled: true, PublicKey: om.Str("public-key"), ShortID: om.Str("short-id")}
}
}
base.TLS = tls
}
}
proxies = append(proxies, base)
}
cfg := sbConfig{}
cfg.Log.Level = "warn"
cfg.Inbounds = []sbInbound{{Type: "mixed", Tag: "local", Listen: "127.0.0.1", ListenPort: 2080}}
cfg.Outbounds = append([]sbOutbound{{Type: "selector", Tag: "proxy", Outbounds: tags}}, proxies...)
cfg.Outbounds = append(cfg.Outbounds, sbOutbound{Type: "direct", Tag: "direct"})
cfg.Route.Final = "proxy"
return subMarshalIndentJSON(cfg)
}
// ---------- format dispatch ----------
var clashUARe = regexp.MustCompile(`(?i)clash|mihomo`)
func FormatSubscription(d *Data, customer *Customer, format, userAgent string) (*SubResult, bool) {
items := Entries(d, customer.ID)
if len(items) == 0 {
return nil, false
}
selected := format
if format == "auto" {
if clashUARe.MatchString(userAgent) {
selected = "clash"
} else {
selected = "base64"
}
}
joined := func() string {
uris := make([]string, len(items))
for i, item := range items {
uris[i] = item.ClientURI
}
return strings.Join(uris, "\n") + "\n"
}
switch selected {
case "raw":
return &SubResult{Body: joined(), ContentType: "text/plain; charset=utf-8"}, true
case "base64":
return &SubResult{Body: base64.StdEncoding.EncodeToString([]byte(joined())), ContentType: "text/plain; charset=utf-8"}, true
case "clash", "clash-smart":
return &SubResult{Body: MakeClash(items, selected == "clash-smart"), ContentType: "text/yaml; charset=utf-8"}, true
case "singbox":
return &SubResult{Body: MakeSingBox(items), ContentType: "application/json; charset=utf-8"}, true
case "surge":
body, ok := MakeSurge(items)
if !ok {
return nil, false
}
return &SubResult{Body: body, ContentType: "text/plain; charset=utf-8"}, true
}
return nil, false
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"encoding/json"
"os"
"reflect"
"testing"
)
func goldenSubData() *Data {
entryServer := &Server{ID: "srv_entry", PublicAddress: "entry.example.com", TLSDomain: "tls.example.com"}
v6Server := &Server{ID: "srv_v6", PublicAddress: "v6.example.com", TLSDomain: "tls.example.com"}
art := func(pub string) map[string]json.RawMessage {
raw, _ := marshalJSON(pub)
return map[string]json.RawMessage{"realityPublicKey": raw}
}
nodes := []*Node{
{ID: "nod_rf", Name: "RF-Reality", ServerID: "srv_entry", Protocol: "vless-reality-vision",
Topology: "direct", NetworkMode: "ipv4", Port: 23456, Status: "active",
Reality: &RealityConfig{KeyID: "reality-nod_rf", ServerName: "www.amd.com", DestPort: 443},
Artifacts: art("FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu")},
{ID: "nod_ss", Name: "SS-Direct", ServerID: "srv_entry", Protocol: "shadowsocks-2022-aes128",
Topology: "direct", NetworkMode: "ipv4", Port: 23458, Status: "active",
SSServerKey: StrPtr("CwwNDg8QERITFBUWFxgZGg=="), Artifacts: map[string]json.RawMessage{}},
{ID: "nod_hy", Name: "HY2", ServerID: "srv_entry", Protocol: "hysteria2",
Topology: "direct", NetworkMode: "ipv4", Port: 23459, Status: "active", Artifacts: map[string]json.RawMessage{}},
{ID: "nod_any", Name: "AnyTLS", ServerID: "srv_entry", Protocol: "anytls",
Topology: "direct", NetworkMode: "ipv4", Port: 23460, Status: "active", Artifacts: map[string]json.RawMessage{}},
{ID: "nod_ws", Name: "WS-TLS", ServerID: "srv_entry", Protocol: "vless-ws-tls",
Topology: "direct", NetworkMode: "ipv4", Port: 23461, Status: "active",
WSPath: StrPtr("/wspath123"), Artifacts: map[string]json.RawMessage{}},
{ID: "nod_sk", Name: "Socks", ServerID: "srv_entry", Protocol: "socks5-auth",
Topology: "direct", NetworkMode: "ipv4", Port: 23462, Status: "active", Artifacts: map[string]json.RawMessage{}},
{ID: "nod_rv", Name: "RV-Plain", ServerID: "srv_v6", Protocol: "vless-reality",
Topology: "direct", NetworkMode: "ipv4", Port: 23463, Status: "active",
Reality: &RealityConfig{KeyID: "reality-nod_rv", ServerName: "www.apple.com", DestPort: 443},
Artifacts: art("FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu")},
}
nc := func(nodeID, clientID, relayPassword, shortID string) *NodeClient {
return &NodeClient{
NodeID: nodeID, CustomerID: "cus_1", Status: "active",
Credentials: Credentials{ClientID: clientID, RelayPassword: relayPassword, ShortID: shortID},
}
}
clients := []*NodeClient{
nc("nod_rf", "11111111-2222-3333-4444-000000000001", "unused", "abcd0001ef01"),
nc("nod_ss", "11111111-2222-3333-4444-000000000003", "FRYXGBkaGxwdHh8gISIjJA==", "abcd0003ef01"),
nc("nod_hy", "11111111-2222-3333-4444-000000000004", "HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz", "abcd0004ef01"),
nc("nod_any", "11111111-2222-3333-4444-000000000005", "IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6", "abcd0005ef01"),
nc("nod_ws", "11111111-2222-3333-4444-000000000006", "unused", "abcd0006ef01"),
nc("nod_sk", "11111111-2222-3333-4444-000000000007", "MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI", "abcd0007ef01"),
nc("nod_rv", "11111111-2222-3333-4444-000000000008", "unused", "abcd0008ef01"),
}
return &Data{
Servers: []*Server{entryServer, v6Server},
Nodes: nodes,
NodeClients: clients,
Customers: []*Customer{{ID: "cus_1"}},
}
}
type goldenSubs struct {
Entries []SubEntry `json:"entries"`
Formats map[string]struct {
Body string `json:"body"`
ContentType string `json:"contentType"`
} `json:"formats"`
}
func loadGoldenSubs(t *testing.T) goldenSubs {
t.Helper()
raw, err := os.ReadFile("testdata/golden_subscriptions.json")
if err != nil {
t.Fatal(err)
}
var g goldenSubs
if err := unmarshalJSONUseNumber(raw, &g); err != nil {
t.Fatal(err)
}
return g
}
func TestGoldenEntries(t *testing.T) {
g := loadGoldenSubs(t)
got := Entries(goldenSubData(), "cus_1")
if !reflect.DeepEqual(got, g.Entries) {
t.Fatalf("entries mismatch:\n got: %+v\nwant: %+v", got, g.Entries)
}
}
func TestGoldenFormats(t *testing.T) {
g := loadGoldenSubs(t)
d := goldenSubData()
customer := &Customer{ID: "cus_1"}
cases := []struct {
key string
format string
ua string
}{
{"raw", "raw", ""},
{"base64", "base64", ""},
{"clash", "clash", ""},
{"clash-smart", "clash-smart", ""},
{"singbox", "singbox", ""},
{"surge", "surge", ""},
{"auto-clash-ua", "auto", "ClashForAndroid/2.0"},
{"auto-plain-ua", "auto", "Mozilla/5.0"},
}
for _, c := range cases {
want, ok := g.Formats[c.key]
if !ok {
t.Fatalf("golden missing format %s", c.key)
}
res, ok := FormatSubscription(d, customer, c.format, c.ua)
if !ok || res == nil {
t.Fatalf("format %s: FormatSubscription returned null", c.key)
}
if res.ContentType != want.ContentType {
t.Errorf("format %s contentType: got %q want %q", c.key, res.ContentType, want.ContentType)
}
if res.Body != want.Body {
t.Errorf("format %s body mismatch:\n got: %q\nwant: %q", c.key, res.Body, want.Body)
}
}
}
+142
View File
@@ -0,0 +1,142 @@
{
"rfEntry": {
"id": "res_rf",
"engine": "sing-box",
"meta": {
"kind": "node",
"nodeId": "nod_rf",
"metricsTag": "ng-nodrf-in",
"protocol": "vless-reality-vision"
},
"inbounds": [
{
"type": "vless",
"tag": "ng-nodrf-in",
"listen": "203.0.113.10",
"listen_port": 23456,
"users": [
{
"name": "ng:cus_1",
"uuid": "11111111-2222-3333-4444-000000000001",
"flow": "xtls-rprx-vision"
},
{
"name": "ng:cus_2",
"uuid": "11111111-2222-3333-4444-000000000002",
"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_rf}",
"short_id": [
"abcd0001ef01",
"abcd0002ef01"
]
}
}
}
],
"outbounds": [
{
"type": "shadowsocks",
"tag": "ng-nodrf-exit",
"server": "exit.example.com",
"server_port": 23457,
"method": "2022-blake3-aes-256-gcm",
"password": "AwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISI="
}
],
"routingRules": [
{
"inbound": [
"ng-nodrf-in"
],
"action": "route",
"outbound": "ng-nodrf-exit"
}
]
},
"rfExit": {
"id": "res_rfx",
"engine": "sing-box",
"meta": {
"kind": "node-exit",
"nodeId": "nod_rf",
"metricsTag": "ng-nodrf-exit-in"
},
"inbounds": [
{
"type": "shadowsocks",
"tag": "ng-nodrf-exit-in",
"listen": "203.0.113.20",
"listen_port": 23457,
"method": "2022-blake3-aes-256-gcm",
"password": "AwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISI="
}
],
"outbounds": [
{
"type": "direct",
"tag": "ng-nodrf-exit-direct"
}
],
"routingRules": [
{
"inbound": [
"ng-nodrf-exit-in"
],
"action": "route",
"outbound": "ng-nodrf-exit-direct"
}
]
},
"ssEntry": {
"id": "res_ss",
"engine": "sing-box",
"meta": {
"kind": "node",
"nodeId": "nod_ss",
"metricsTag": "ng-nodss-in",
"protocol": "shadowsocks-2022-aes128"
},
"inbounds": [
{
"type": "shadowsocks",
"tag": "ng-nodss-in",
"listen": "203.0.113.11",
"listen_port": 23458,
"method": "2022-blake3-aes-128-gcm",
"password": "CwwNDg8QERITFBUWFxgZGg==",
"users": [
{
"name": "ng:cus_1",
"password": "FRYXGBkaGxwdHh8gISIjJA=="
}
]
}
],
"outbounds": [
{
"type": "direct",
"tag": "ng-nodss-direct"
}
],
"routingRules": [
{
"inbound": [
"ng-nodss-in"
],
"action": "route",
"outbound": "ng-nodss-direct"
}
]
}
}
+66
View File
@@ -0,0 +1,66 @@
{
"entries": [
{
"nodeId": "nod_rf",
"clientUri": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23456?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu&sid=abcd0001ef01&type=tcp&headerType=none&flow=xtls-rprx-vision#RF-Reality"
},
{
"nodeId": "nod_ss",
"clientUri": "ss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206Q3d3TkRnOFFFUklURkJVV0Z4Z1pHZz09OkZSWVhHQmthR3h3ZEhoOGdJU0lqSkE9PQ@entry.example.com:23458#SS-Direct"
},
{
"nodeId": "nod_hy",
"clientUri": "hysteria2://HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz@entry.example.com:23459?sni=tls.example.com&alpn=h3#HY2"
},
{
"nodeId": "nod_any",
"clientUri": "anytls://IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6@entry.example.com:23460?sni=tls.example.com#AnyTLS"
},
{
"nodeId": "nod_ws",
"clientUri": "vless://11111111-2222-3333-4444-000000000006@entry.example.com:23461?encryption=none&security=tls&type=ws&path=%2Fwspath123&sni=tls.example.com#WS-TLS"
},
{
"nodeId": "nod_sk",
"clientUri": "socks5://ng%3Acus_1:MTIzNDU2Nzg5Ojs8PT4%2FQEFCQ0RFRkdI@entry.example.com:23462#Socks"
},
{
"nodeId": "nod_rv",
"clientUri": "vless://11111111-2222-3333-4444-000000000008@v6.example.com:23463?encryption=none&security=reality&sni=www.apple.com&fp=chrome&pbk=FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu&sid=abcd0008ef01&type=tcp&headerType=none#RV-Plain"
}
],
"formats": {
"raw": {
"body": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23456?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu&sid=abcd0001ef01&type=tcp&headerType=none&flow=xtls-rprx-vision#RF-Reality\nss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206Q3d3TkRnOFFFUklURkJVV0Z4Z1pHZz09OkZSWVhHQmthR3h3ZEhoOGdJU0lqSkE9PQ@entry.example.com:23458#SS-Direct\nhysteria2://HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz@entry.example.com:23459?sni=tls.example.com&alpn=h3#HY2\nanytls://IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6@entry.example.com:23460?sni=tls.example.com#AnyTLS\nvless://11111111-2222-3333-4444-000000000006@entry.example.com:23461?encryption=none&security=tls&type=ws&path=%2Fwspath123&sni=tls.example.com#WS-TLS\nsocks5://ng%3Acus_1:MTIzNDU2Nzg5Ojs8PT4%2FQEFCQ0RFRkdI@entry.example.com:23462#Socks\nvless://11111111-2222-3333-4444-000000000008@v6.example.com:23463?encryption=none&security=reality&sni=www.apple.com&fp=chrome&pbk=FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu&sid=abcd0008ef01&type=tcp&headerType=none#RV-Plain\n",
"contentType": "text/plain; charset=utf-8"
},
"base64": {
"body": "dmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NTY/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy5hbWQuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSZktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDAxZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUmZmxvdz14dGxzLXJwcngtdmlzaW9uI1JGLVJlYWxpdHkKc3M6Ly9NakF5TWkxaWJHRnJaVE10WVdWekxURXlPQzFuWTIwNlEzZDNUa1JuT0ZGRlVrbFVSa0pWVjBaNFoxcEhaejA5T2taU1dWaEhRbXRoUjNoM1pFaG9PR2RKVTBscVNrRTlQUUBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OCNTUy1EaXJlY3QKaHlzdGVyaWEyOi8vSEIwZUh5QWhJaU1rSlNZbktDa3FLeXd0TGk4d01USXpAZW50cnkuZXhhbXBsZS5jb206MjM0NTk/c25pPXRscy5leGFtcGxlLmNvbSZhbHBuPWgzI0hZMgphbnl0bHM6Ly9JeVFsSmljb0tTb3JMQzB1THpBeE1qTTBOVFkzT0RrNkBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MD9zbmk9dGxzLmV4YW1wbGUuY29tI0FueVRMUwp2bGVzczovLzExMTExMTExLTIyMjItMzMzMy00NDQ0LTAwMDAwMDAwMDAwNkBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MT9lbmNyeXB0aW9uPW5vbmUmc2VjdXJpdHk9dGxzJnR5cGU9d3MmcGF0aD0lMkZ3c3BhdGgxMjMmc25pPXRscy5leGFtcGxlLmNvbSNXUy1UTFMKc29ja3M1Oi8vbmclM0FjdXNfMTpNVEl6TkRVMk56ZzVPanM4UFQ0JTJGUUVGQ1EwUkZSa2RJQGVudHJ5LmV4YW1wbGUuY29tOjIzNDYyI1NvY2tzCnZsZXNzOi8vMTExMTExMTEtMjIyMi0zMzMzLTQ0NDQtMDAwMDAwMDAwMDA4QHY2LmV4YW1wbGUuY29tOjIzNDYzP2VuY3J5cHRpb249bm9uZSZzZWN1cml0eT1yZWFsaXR5JnNuaT13d3cuYXBwbGUuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSdktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDA4ZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUjUlYtUGxhaW4K",
"contentType": "text/plain; charset=utf-8"
},
"clash": {
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000006\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000008\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0008ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"MATCH,节点选择\"\n",
"contentType": "text/yaml; charset=utf-8"
},
"clash-smart": {
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000006\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000008\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0008ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\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 - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"流媒体\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"AI 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"Meta 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\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",
"contentType": "text/yaml; charset=utf-8"
},
"singbox": {
"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 \"node-3\",\n \"node-4\",\n \"node-5\",\n \"node-6\",\n \"node-7\"\n ]\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-1\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23456,\n \"uuid\": \"11111111-2222-3333-4444-000000000001\",\n \"flow\": \"xtls-rprx-vision\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"www.amd.com\",\n \"reality\": {\n \"enabled\": true,\n \"public_key\": \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\",\n \"short_id\": \"abcd0001ef01\"\n }\n }\n },\n {\n \"type\": \"shadowsocks\",\n \"tag\": \"node-2\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23458,\n \"method\": \"2022-blake3-aes-128-gcm\",\n \"password\": \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n },\n {\n \"type\": \"hysteria2\",\n \"tag\": \"node-3\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23459,\n \"password\": \"HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\",\n \"alpn\": [\n \"h3\"\n ]\n }\n },\n {\n \"type\": \"anytls\",\n \"tag\": \"node-4\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23460,\n \"password\": \"IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\"\n }\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-5\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23461,\n \"uuid\": \"11111111-2222-3333-4444-000000000006\",\n \"transport\": {\n \"type\": \"ws\",\n \"path\": \"/wspath123\"\n },\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\"\n }\n },\n {\n \"type\": \"socks\",\n \"tag\": \"node-6\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23462,\n \"version\": \"5\",\n \"username\": \"ng:cus_1\",\n \"password\": \"MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\"\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-7\",\n \"server\": \"v6.example.com\",\n \"server_port\": 23463,\n \"uuid\": \"11111111-2222-3333-4444-000000000008\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"www.apple.com\",\n \"reality\": {\n \"enabled\": true,\n \"public_key\": \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\",\n \"short_id\": \"abcd0008ef01\"\n }\n }\n },\n {\n \"type\": \"direct\",\n \"tag\": \"direct\"\n }\n ],\n \"route\": {\n \"final\": \"proxy\"\n }\n}\n",
"contentType": "application/json; charset=utf-8"
},
"surge": {
"body": "[General]\nloglevel = notify\n[Proxy]\nSS-Direct = ss, entry.example.com, 23458, encrypt-method=2022-blake3-aes-128-gcm, password=CwwNDg8QERITFBUWFxgZGg__:FRYXGBkaGxwdHh8gISIjJA__\nSocks = socks5, entry.example.com, 23462, ng:cus_1, MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\n[Proxy Group]\n节点选择 = select, SS-Direct, Socks, DIRECT\n自动选择 = url-test, SS-Direct, Socks, url=http://www.gstatic.com/generate_204, interval=600\n[Rule]\nGEOIP,CN,DIRECT\nFINAL,节点选择\n",
"contentType": "text/plain; charset=utf-8"
},
"auto-clash-ua": {
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000006\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000008\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0008ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"MATCH,节点选择\"\n",
"contentType": "text/yaml; charset=utf-8"
},
"auto-plain-ua": {
"body": "dmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NTY/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy5hbWQuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSZktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDAxZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUmZmxvdz14dGxzLXJwcngtdmlzaW9uI1JGLVJlYWxpdHkKc3M6Ly9NakF5TWkxaWJHRnJaVE10WVdWekxURXlPQzFuWTIwNlEzZDNUa1JuT0ZGRlVrbFVSa0pWVjBaNFoxcEhaejA5T2taU1dWaEhRbXRoUjNoM1pFaG9PR2RKVTBscVNrRTlQUUBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OCNTUy1EaXJlY3QKaHlzdGVyaWEyOi8vSEIwZUh5QWhJaU1rSlNZbktDa3FLeXd0TGk4d01USXpAZW50cnkuZXhhbXBsZS5jb206MjM0NTk/c25pPXRscy5leGFtcGxlLmNvbSZhbHBuPWgzI0hZMgphbnl0bHM6Ly9JeVFsSmljb0tTb3JMQzB1THpBeE1qTTBOVFkzT0RrNkBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MD9zbmk9dGxzLmV4YW1wbGUuY29tI0FueVRMUwp2bGVzczovLzExMTExMTExLTIyMjItMzMzMy00NDQ0LTAwMDAwMDAwMDAwNkBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MT9lbmNyeXB0aW9uPW5vbmUmc2VjdXJpdHk9dGxzJnR5cGU9d3MmcGF0aD0lMkZ3c3BhdGgxMjMmc25pPXRscy5leGFtcGxlLmNvbSNXUy1UTFMKc29ja3M1Oi8vbmclM0FjdXNfMTpNVEl6TkRVMk56ZzVPanM4UFQ0JTJGUUVGQ1EwUkZSa2RJQGVudHJ5LmV4YW1wbGUuY29tOjIzNDYyI1NvY2tzCnZsZXNzOi8vMTExMTExMTEtMjIyMi0zMzMzLTQ0NDQtMDAwMDAwMDAwMDA4QHY2LmV4YW1wbGUuY29tOjIzNDYzP2VuY3J5cHRpb249bm9uZSZzZWN1cml0eT1yZWFsaXR5JnNuaT13d3cuYXBwbGUuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSdktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDA4ZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUjUlYtUGxhaW4K",
"contentType": "text/plain; charset=utf-8"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
[
{
"privateKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"publicKey": "L-V9o0fNYkMVKNqsX7spBzD_9oSvxM_C7ZCZX1jLO3Q"
},
{
"privateKey": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8",
"publicKey": "j0DFrbaPJWJK5bIU6nZ6bslNgp09e14a0bpvPiE4KF8"
},
{
"privateKey": "__________________________________________8",
"publicKey": "hHwNLDdSNPNl5mCVUYejc1oPdhPRYJ06ak2MU66qWiI"
},
{
"privateKey": "gAUuu2ffviCGvQau_u_M5kl0JJ64KZX7rKEIJCD4oXM",
"publicKey": "j0YvfpdzBX6p2dVsLxR5ViXC-8XM2NwPzeptc7VO1mk"
}
]
+32
View File
@@ -0,0 +1,32 @@
[
{
"password": "password",
"salt": "0123456789abcdeffedcba9876543210",
"encoded": "scrypt:0123456789abcdeffedcba9876543210:c333395faa1db0ad575a6bf315b798ab2592dc383fc12e004c9dec0ba8b300c95e0b19d641dcb45f9dfed65fc1213a4ac92dd065d029d5dcdc4e7f226cc41836"
},
{
"password": "admin123",
"salt": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"encoded": "scrypt:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:2848cd08ab0496c46b33b784bdc32c247544c2c02e75b18169d4593bdae61121e13a7861ecc28182e27dab52cffc61da7642b403549ab7fd04c63414d70a3166"
},
{
"password": "",
"salt": "0123456789abcdeffedcba9876543210",
"encoded": "scrypt:0123456789abcdeffedcba9876543210:73e46810e0a589e88a8e3399e6156f45febc2af97943c909390f67e681144308170363363dbd4c40aa4526027dcc0a14b9cc334d6a8b19490096f4678efa856b"
},
{
"password": "密码测试🔐 passphrase",
"salt": "deadbeefcafebabe0123456789abcdef",
"encoded": "scrypt:deadbeefcafebabe0123456789abcdef:80efbc107d84d817600d6b23d0244393b4d48b2bad97cd2287b9a45f72390bb9d1841aaa0085b284bcfbded1070335aff4666460adbd0dcbdce4e6f1177783e0"
},
{
"password": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"salt": "my-custom-salt",
"encoded": "scrypt:my-custom-salt:0bcdee35103e612e72d18df9ca2a9d0df3be6680e1dbab773dfea7efd642ac970e7bb8e2b50921c5d179cfea8dcc95a747f2d835d50cc4b3f8bff723bfabc06f"
},
{
"password": "correct horse battery staple",
"salt": "00",
"encoded": "scrypt:00:f2d1fc35078e5f73fe37f990d5bb080beb82188dba8ac20eafb60db0321854e06d999bec6b2cee009f45f6e9b52734830669cec9c1388ff05bc5b1b9da807dfb"
}
]
+962
View File
@@ -0,0 +1,962 @@
package main
// Shared data model + JSON plumbing for the Go port of the NexusGate panel.
//
// Compatibility contract with the Node implementation:
// - The database is one JSON object (see Data). Every record type mirrors
// the JS object shapes field by field; fields JS may leave absent use
// pointer+omitempty only where "absent" and "null" are semantically
// identical in the JS code (all reads are truthiness-based). Where the
// JS create paths write an explicit null, the Go field has NO omitempty
// so a nil pointer re-marshals as null.
// - Unknown fields (written by other panel versions) are captured per
// record in Extra and re-emitted verbatim on marshal, so a load/save
// round trip never drops data.
// - All JSON output goes through marshalJSON (HTML escaping disabled) to
// match JSON.stringify.
import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"reflect"
"strings"
"sync"
"time"
)
// ---------- errors ----------
// HTTPError mirrors a JS Error carrying statusCode.
type HTTPError struct {
Status int
Message string
}
func (e *HTTPError) Error() string { return e.Message }
func httpErr(status int, format string, args ...any) *HTTPError {
return &HTTPError{Status: status, Message: fmt.Sprintf(format, args...)}
}
// ---------- time ----------
// NowISO is JS `new Date().toISOString()`: UTC, millisecond precision, Z.
func NowISO() string { return ISOFromTime(time.Now()) }
func ISOFromTime(t time.Time) string {
return t.UTC().Format("2006-01-02T15:04:05.000Z")
}
// ParseISO accepts the forms JS `new Date(value)` accepts for the inputs
// this panel produces/consumes: RFC3339 (with optional fractional seconds
// and offsets) and date-only "2006-01-02" (interpreted as UTC midnight).
func ParseISO(s string) (time.Time, bool) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, false
}
for _, layout := range []string{
time.RFC3339Nano,
"2006-01-02T15:04:05.000Z0700",
"2006-01-02T15:04:05Z0700",
"2006-01-02T15:04Z0700",
"2006-01-02",
} {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
}
// ParseISOMs is JS Date.parse: milliseconds since epoch; ok=false is NaN
// (comparisons against NaN are always false in the JS code paths).
func ParseISOMs(s string) (int64, bool) {
t, ok := ParseISO(s)
if !ok {
return 0, false
}
return t.UnixMilli(), true
}
// ---------- ids ----------
func NewUUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// NewID mirrors JS `id(prefix)` => `${prefix}_${crypto.randomUUID()}`.
func NewID(prefix string) string { return prefix + "_" + NewUUID() }
func StrPtr(s string) *string { return &s }
func BoolPtr(b bool) *bool { return &b }
// boolVal is JS truthiness for an optional boolean field.
func boolVal(p *bool) bool { return p != nil && *p }
func IntPtr(v int) *int { return &v }
// ---------- JSON helpers ----------
// marshalJSON is JSON.stringify: compact, no HTML escaping.
func marshalJSON(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
// unmarshalJSONUseNumber decodes with UseNumber so generic `any` fields
// (activity detail, job payloads from agents) keep integer literals exact.
func unmarshalJSONUseNumber(b []byte, v any) error {
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
return dec.Decode(v)
}
// cloneJSONValue is structuredClone for JSON-shaped values.
func cloneJSONValue[T any](v T) T {
raw, err := marshalJSON(v)
if err != nil {
panic(err)
}
var out T
if err := unmarshalJSONUseNumber(raw, &out); err != nil {
panic(err)
}
return out
}
// ---------- unknown-field passthrough ----------
type extraFields map[string]json.RawMessage
var knownKeyCache sync.Map // reflect.Type -> map[string]struct{}
func knownJSONKeys(t reflect.Type) map[string]struct{} {
if cached, ok := knownKeyCache.Load(t); ok {
return cached.(map[string]struct{})
}
keys := map[string]struct{}{}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if f.PkgPath != "" { // unexported
continue
}
tag := f.Tag.Get("json")
if tag == "-" {
continue
}
name := strings.Split(tag, ",")[0]
if name == "" {
name = f.Name
}
keys[name] = struct{}{}
}
knownKeyCache.Store(t, keys)
return keys
}
func captureExtra(raw []byte, sample any) extraFields {
var all map[string]json.RawMessage
if err := json.Unmarshal(raw, &all); err != nil || len(all) == 0 {
return nil
}
known := knownJSONKeys(reflect.TypeOf(sample))
var extra extraFields
for k, v := range all {
if _, ok := known[k]; !ok {
if extra == nil {
extra = extraFields{}
}
extra[k] = v
}
}
return extra
}
// mergeMarshal marshals a record and re-attaches its unknown fields.
func mergeMarshal(sample any, extra extraFields) ([]byte, error) {
base, err := marshalJSON(sample)
if err != nil || len(extra) == 0 {
return base, err
}
var merged map[string]json.RawMessage
if err := json.Unmarshal(base, &merged); err != nil {
return nil, err
}
for k, v := range extra {
if _, ok := merged[k]; !ok {
merged[k] = v
}
}
return marshalJSON(merged)
}
// recordJSON implements the UnmarshalJSON half shared by every record type.
func recordJSON[T any](raw []byte, dst *T, extra *extraFields) error {
var plain T
if err := unmarshalJSONUseNumber(raw, &plain); err != nil {
return err
}
*dst = plain
*extra = captureExtra(raw, plain)
return nil
}
// ---------- database ----------
type Data struct {
SchemaVersion int `json:"schemaVersion"`
CreatedAt string `json:"createdAt"`
Settings Settings `json:"settings"`
Users []*User `json:"users"`
Servers []*Server `json:"servers"`
Agents []*Agent `json:"agents"`
EnrollmentTokens []*EnrollmentToken `json:"enrollmentTokens"`
Customers []*Customer `json:"customers"`
Nodes []*Node `json:"nodes"`
NodeClients []*NodeClient `json:"nodeClients"`
Jobs []*Job `json:"jobs"`
Activity []*Activity `json:"activity"`
Observations []*Observation `json:"observations"`
SubscriptionAccess []*SubscriptionAccess `json:"subscriptionAccess"`
SubscriptionClients []*SubscriptionClient `json:"subscriptionClients"`
KeyStore map[string]*KeyEntry `json:"keyStore"`
Extra extraFields `json:"-"`
}
func (d *Data) UnmarshalJSON(raw []byte) error {
type plain Data
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*d = Data(p)
d.Extra = extra
return nil
}
func (d Data) MarshalJSON() ([]byte, error) {
type plain Data
return mergeMarshal(plain(d), d.Extra)
}
// Clone is structuredClone(this.data).
func (d *Data) Clone() *Data {
raw, err := marshalJSON(d)
if err != nil {
panic(err)
}
var out Data
if err := unmarshalJSONUseNumber(raw, &out); err != nil {
panic(err)
}
return &out
}
type Settings struct {
BrandName string `json:"brandName"`
SessionHours float64 `json:"sessionHours"`
ObservationTtlMinutes float64 `json:"observationTtlMinutes"`
ActivityRetentionDays float64 `json:"activityRetentionDays"`
CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"`
Extra extraFields `json:"-"`
}
func (s *Settings) UnmarshalJSON(raw []byte) error {
type plain Settings
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*s = Settings(p)
s.Extra = extra
return nil
}
func (s Settings) MarshalJSON() ([]byte, error) {
type plain Settings
return mergeMarshal(plain(s), s.Extra)
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"passwordHash"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt,omitempty"`
Extra extraFields `json:"-"`
}
func (u *User) UnmarshalJSON(raw []byte) error {
type plain User
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*u = User(p)
u.Extra = extra
return nil
}
func (u User) MarshalJSON() ([]byte, error) {
type plain User
return mergeMarshal(plain(u), u.Extra)
}
type EngineState struct {
Status string `json:"status"`
Detail string `json:"detail"`
SingBoxInstalled bool `json:"singBoxInstalled"`
Certificates []string `json:"certificates"`
SingBoxStatus string `json:"singBoxStatus"`
At string `json:"at"`
Extra extraFields `json:"-"`
}
func (e *EngineState) UnmarshalJSON(raw []byte) error {
type plain EngineState
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*e = EngineState(p)
e.Extra = extra
return nil
}
func (e EngineState) MarshalJSON() ([]byte, error) {
type plain EngineState
return mergeMarshal(plain(e), e.Extra)
}
type ServerUsage struct {
LastReportAt string `json:"lastReportAt"`
SampleCount int `json:"sampleCount"`
Error *string `json:"error"`
Extra extraFields `json:"-"`
}
func (u *ServerUsage) UnmarshalJSON(raw []byte) error {
type plain ServerUsage
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*u = ServerUsage(p)
u.Extra = extra
return nil
}
func (u ServerUsage) MarshalJSON() ([]byte, error) {
type plain ServerUsage
return mergeMarshal(plain(u), u.Extra)
}
type Server struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Region string `json:"region"`
PublicAddress string `json:"publicAddress"`
PublicAddressV6 string `json:"publicAddressV6"`
TLSDomain string `json:"tlsDomain"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
Labels []string `json:"labels"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
LastSeenAt *string `json:"lastSeenAt,omitempty"`
System json.RawMessage `json:"system,omitempty"`
Engine *EngineState `json:"engine,omitempty"`
EnginePresent bool `json:"-"`
Usage *ServerUsage `json:"usage,omitempty"`
Extra extraFields `json:"-"`
}
func (s *Server) UnmarshalJSON(raw []byte) error {
type plain Server
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*s = Server(p)
s.Extra = extra
var probe map[string]json.RawMessage
if json.Unmarshal(raw, &probe) == nil {
_, s.EnginePresent = probe["engine"]
}
return nil
}
func (s Server) MarshalJSON() ([]byte, error) {
type plain Server
base, err := mergeMarshal(plain(s), s.Extra)
if err != nil || s.Engine != nil || !s.EnginePresent {
return base, err
}
// JS assigns server.engine = ... || null on heartbeat: once the key
// exists it stays present with value null.
var merged map[string]json.RawMessage
if err := json.Unmarshal(base, &merged); err != nil {
return nil, err
}
merged["engine"] = json.RawMessage("null")
return marshalJSON(merged)
}
type Agent struct {
ID string `json:"id"`
ServerID string `json:"serverId"`
KeyHash string `json:"keyHash"`
KeyFingerprint *string `json:"keyFingerprint,omitempty"`
Status string `json:"status"`
Hostname string `json:"hostname"`
Version string `json:"version"`
CreatedAt string `json:"createdAt"`
LastSeenAt string `json:"lastSeenAt"`
Extra extraFields `json:"-"`
}
func (a *Agent) UnmarshalJSON(raw []byte) error {
type plain Agent
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*a = Agent(p)
a.Extra = extra
return nil
}
func (a Agent) MarshalJSON() ([]byte, error) {
type plain Agent
return mergeMarshal(plain(a), a.Extra)
}
type EnrollmentToken struct {
ID string `json:"id"`
ServerID string `json:"serverId"`
TokenHash string `json:"tokenHash"`
CreatedAt string `json:"createdAt"`
ExpiresAt string `json:"expiresAt"`
UsedAt *string `json:"usedAt"`
Extra extraFields `json:"-"`
}
func (t *EnrollmentToken) UnmarshalJSON(raw []byte) error {
type plain EnrollmentToken
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*t = EnrollmentToken(p)
t.Extra = extra
return nil
}
func (t EnrollmentToken) MarshalJSON() ([]byte, error) {
type plain EnrollmentToken
return mergeMarshal(plain(t), t.Extra)
}
type UsageCounters struct {
Uplink int64 `json:"uplink"`
Downlink int64 `json:"downlink"`
Epoch *string `json:"epoch"`
Extra extraFields `json:"-"`
}
func (c *UsageCounters) UnmarshalJSON(raw []byte) error {
type plain UsageCounters
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*c = UsageCounters(p)
c.Extra = extra
return nil
}
func (c UsageCounters) MarshalJSON() ([]byte, error) {
type plain UsageCounters
return mergeMarshal(plain(c), c.Extra)
}
type UsageSlot struct {
UsageCounters *UsageCounters `json:"usageCounters,omitempty"`
RetiredUsageEpochs []string `json:"retiredUsageEpochs,omitempty"`
Extra extraFields `json:"-"`
}
func (s *UsageSlot) UnmarshalJSON(raw []byte) error {
type plain UsageSlot
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*s = UsageSlot(p)
s.Extra = extra
return nil
}
func (s UsageSlot) MarshalJSON() ([]byte, error) {
type plain UsageSlot
return mergeMarshal(plain(s), s.Extra)
}
type Customer struct {
ID string `json:"id"`
SubscriptionToken string `json:"subscriptionToken"`
Name string `json:"name"`
Group string `json:"group"`
Status string `json:"status"`
SuspendReason *string `json:"suspendReason,omitempty"`
SuspendReasonPresent bool `json:"-"`
TrafficLimitBytes float64 `json:"trafficLimitBytes"`
UsedBytes int64 `json:"usedBytes"`
UsedUplinkBytes int64 `json:"usedUplinkBytes"`
UsedDownlinkBytes int64 `json:"usedDownlinkBytes"`
ExpiresAt *string `json:"expiresAt"`
IPLimit int64 `json:"ipLimit"`
DeviceLimit int64 `json:"deviceLimit"`
Tags []string `json:"tags"`
Notes string `json:"notes"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ServerUsage map[string]*UsageSlot `json:"serverUsage,omitempty"`
LastUsageAt *string `json:"lastUsageAt,omitempty"`
LastOnlineAt *string `json:"lastOnlineAt,omitempty"`
Extra extraFields `json:"-"`
}
func (c *Customer) UnmarshalJSON(raw []byte) error {
type plain Customer
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*c = Customer(p)
c.Extra = extra
var probe map[string]json.RawMessage
if json.Unmarshal(raw, &probe) == nil {
_, c.SuspendReasonPresent = probe["suspendReason"]
}
return nil
}
func (c Customer) MarshalJSON() ([]byte, error) {
type plain Customer
base, err := mergeMarshal(plain(c), c.Extra)
if err != nil || c.SuspendReason != nil || !c.SuspendReasonPresent {
return base, err
}
// JS writes suspendReason: null explicitly on resume paths.
var merged map[string]json.RawMessage
if err := json.Unmarshal(base, &merged); err != nil {
return nil, err
}
merged["suspendReason"] = json.RawMessage("null")
return marshalJSON(merged)
}
type RealityConfig struct {
KeyID string `json:"keyId"`
ServerName string `json:"serverName"`
DestPort int `json:"destPort"`
Extra extraFields `json:"-"`
}
func (r *RealityConfig) UnmarshalJSON(raw []byte) error {
type plain RealityConfig
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*r = RealityConfig(p)
r.Extra = extra
return nil
}
func (r RealityConfig) MarshalJSON() ([]byte, error) {
type plain RealityConfig
return mergeMarshal(plain(r), r.Extra)
}
type ExitCredentials struct {
ClientID string `json:"clientId"`
Password string `json:"password"`
SocksUser string `json:"socksUser"`
Extra extraFields `json:"-"`
}
func (c *ExitCredentials) UnmarshalJSON(raw []byte) error {
type plain ExitCredentials
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*c = ExitCredentials(p)
c.Extra = extra
return nil
}
func (c ExitCredentials) MarshalJSON() ([]byte, error) {
type plain ExitCredentials
return mergeMarshal(plain(c), c.Extra)
}
type MeteredTraffic struct {
Uplink int64 `json:"uplink"`
Downlink int64 `json:"downlink"`
Extra extraFields `json:"-"`
}
func (m *MeteredTraffic) UnmarshalJSON(raw []byte) error {
type plain MeteredTraffic
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*m = MeteredTraffic(p)
m.Extra = extra
return nil
}
func (m MeteredTraffic) MarshalJSON() ([]byte, error) {
type plain MeteredTraffic
return mergeMarshal(plain(m), m.Extra)
}
type Node struct {
ID string `json:"id"`
Name string `json:"name"`
ServerID string `json:"serverId"`
ExitServerID *string `json:"exitServerId"`
Protocol string `json:"protocol"`
Topology string `json:"topology"`
ExitProtocol *string `json:"exitProtocol"`
NetworkMode string `json:"networkMode"`
RealityServerName string `json:"realityServerName"`
RealityDestPort int `json:"realityDestPort"`
PortMode string `json:"portMode"`
Port int `json:"port"`
ExitPortMode *string `json:"exitPortMode"`
ExitPort *int `json:"exitPort"`
Status string `json:"status"`
EntryReady bool `json:"entryReady"`
ExitReady bool `json:"exitReady"`
ResourceID string `json:"resourceId"`
ExitResourceID string `json:"exitResourceId"`
Artifacts map[string]json.RawMessage `json:"artifacts"`
Error *string `json:"error"`
PendingDelete bool `json:"pendingDelete"`
AutoRemoving *bool `json:"autoRemoving,omitempty"`
WSPath *string `json:"wsPath,omitempty"`
SSServerKey *string `json:"ssServerKey,omitempty"`
Reality *RealityConfig `json:"reality,omitempty"`
ExitCredentials *ExitCredentials `json:"exitCredentials,omitempty"`
UsageCounters *UsageCounters `json:"usageCounters"`
RetiredUsageEpochs []string `json:"retiredUsageEpochs"`
MeteredTraffic *MeteredTraffic `json:"meteredTraffic"`
LastUsageAt *string `json:"lastUsageAt"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Extra extraFields `json:"-"`
}
func (n *Node) UnmarshalJSON(raw []byte) error {
type plain Node
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*n = Node(p)
n.Extra = extra
return nil
}
func (n Node) MarshalJSON() ([]byte, error) {
type plain Node
return mergeMarshal(plain(n), n.Extra)
}
type Credentials struct {
ClientID string `json:"clientId"`
RelayPassword string `json:"relayPassword"`
ShortID string `json:"shortId"`
Extra extraFields `json:"-"`
}
func (c *Credentials) UnmarshalJSON(raw []byte) error {
type plain Credentials
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*c = Credentials(p)
c.Extra = extra
return nil
}
func (c Credentials) MarshalJSON() ([]byte, error) {
type plain Credentials
return mergeMarshal(plain(c), c.Extra)
}
type NodeClient struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
CustomerID string `json:"customerId"`
Credentials Credentials `json:"credentials"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Extra extraFields `json:"-"`
}
func (n *NodeClient) UnmarshalJSON(raw []byte) error {
type plain NodeClient
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*n = NodeClient(p)
n.Extra = extra
return nil
}
func (n NodeClient) MarshalJSON() ([]byte, error) {
type plain NodeClient
return mergeMarshal(plain(n), n.Extra)
}
// KeyPair is a Reality keypair as shipped in job payload keyMaterial.
type KeyPair struct {
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
}
// KeyEntry is a keyStore record (KeyPair + updatedAt).
type KeyEntry struct {
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
UpdatedAt string `json:"updatedAt"`
Extra extraFields `json:"-"`
}
func (k *KeyEntry) UnmarshalJSON(raw []byte) error {
type plain KeyEntry
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*k = KeyEntry(p)
k.Extra = extra
return nil
}
func (k KeyEntry) MarshalJSON() ([]byte, error) {
type plain KeyEntry
return mergeMarshal(plain(k), k.Extra)
}
type JobPayload struct {
Resource json.RawMessage `json:"resource,omitempty"`
Kind string `json:"kind,omitempty"`
KeyMaterial map[string]KeyPair `json:"keyMaterial,omitempty"`
ResourceID string `json:"resourceId,omitempty"`
Extra extraFields `json:"-"`
}
func (p *JobPayload) UnmarshalJSON(raw []byte) error {
type plain JobPayload
var q plain
var extra extraFields
if err := recordJSON(raw, &q, &extra); err != nil {
return err
}
*p = JobPayload(q)
p.Extra = extra
return nil
}
func (p JobPayload) MarshalJSON() ([]byte, error) {
type plain JobPayload
return mergeMarshal(plain(p), p.Extra)
}
// KindOf mirrors JS `payload.kind || payload.resource?.meta?.kind`.
func (p *JobPayload) KindOf() string {
if p == nil {
return ""
}
if p.Kind != "" {
return p.Kind
}
if len(p.Resource) > 0 {
var probe struct {
Meta struct {
Kind string `json:"kind"`
} `json:"meta"`
}
if err := json.Unmarshal(p.Resource, &probe); err == nil {
return probe.Meta.Kind
}
}
return ""
}
type Job struct {
ID string `json:"id"`
ServerID string `json:"serverId"`
NodeID string `json:"nodeId"`
Action string `json:"action"`
Payload *JobPayload `json:"payload"`
Status string `json:"status"`
Attempts int `json:"attempts"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Error *string `json:"error"`
LeaseUntil *string `json:"leaseUntil"`
StartedAt *string `json:"startedAt,omitempty"`
CompletedAt *string `json:"completedAt,omitempty"`
Extra extraFields `json:"-"`
}
func (j *Job) UnmarshalJSON(raw []byte) error {
type plain Job
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*j = Job(p)
j.Extra = extra
return nil
}
func (j Job) MarshalJSON() ([]byte, error) {
type plain Job
return mergeMarshal(plain(j), j.Extra)
}
type Activity struct {
ID string `json:"id"`
At string `json:"at"`
Actor string `json:"actor"`
Action string `json:"action"`
Target string `json:"target"`
Detail map[string]any `json:"detail"`
Extra extraFields `json:"-"`
}
func (a *Activity) UnmarshalJSON(raw []byte) error {
type plain Activity
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*a = Activity(p)
a.Extra = extra
return nil
}
func (a Activity) MarshalJSON() ([]byte, error) {
type plain Activity
return mergeMarshal(plain(a), a.Extra)
}
type Observation struct {
ID string `json:"id"`
CustomerID string `json:"customerId"`
IP string `json:"ip"`
FirstSeen string `json:"firstSeenAt"`
LastSeenAt string `json:"lastSeenAt"`
Extra extraFields `json:"-"`
}
func (o *Observation) UnmarshalJSON(raw []byte) error {
type plain Observation
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*o = Observation(p)
o.Extra = extra
return nil
}
func (o Observation) MarshalJSON() ([]byte, error) {
type plain Observation
return mergeMarshal(plain(o), o.Extra)
}
type SubscriptionAccess struct {
ID string `json:"id"`
CustomerID string `json:"customerId"`
At string `json:"at"`
IP string `json:"ip"`
UserAgent string `json:"userAgent"`
IdentityType string `json:"identityType"`
ClientKey string `json:"clientKey"`
Format string `json:"format"`
Status int `json:"status"`
Reason string `json:"reason"`
Bytes int64 `json:"bytes"`
Extra extraFields `json:"-"`
}
func (a *SubscriptionAccess) UnmarshalJSON(raw []byte) error {
type plain SubscriptionAccess
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*a = SubscriptionAccess(p)
a.Extra = extra
return nil
}
func (a SubscriptionAccess) MarshalJSON() ([]byte, error) {
type plain SubscriptionAccess
return mergeMarshal(plain(a), a.Extra)
}
type SubscriptionClient struct {
CustomerID string `json:"customerId"`
ClientKey string `json:"clientKey"`
IdentityType string `json:"identityType"`
FirstSeenAt string `json:"firstSeenAt"`
LastSeenAt string `json:"lastSeenAt"`
Extra extraFields `json:"-"`
}
func (c *SubscriptionClient) UnmarshalJSON(raw []byte) error {
type plain SubscriptionClient
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*c = SubscriptionClient(p)
c.Extra = extra
return nil
}
func (c SubscriptionClient) MarshalJSON() ([]byte, error) {
type plain SubscriptionClient
return mergeMarshal(plain(c), c.Extra)
}