feat: Go port of the panel in panel-go/ — drop-in replacement for server.js
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:
@@ -0,0 +1,2 @@
|
|||||||
|
/embed_public/
|
||||||
|
/nexusgate-panel*
|
||||||
@@ -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。
|
||||||
@@ -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(调试用)。
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+9
@@ -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 .
|
||||||
@@ -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 分钟)污染后续步。
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module nexusgate-panel
|
||||||
|
|
||||||
|
go 1.24
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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:])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user