Compare commits
33
Commits
612035ac4e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9bf21a847 | ||
|
|
1486a67124 | ||
|
|
733ec38022 | ||
|
|
5cfc5bebd7 | ||
|
|
1e7ccd3cbe | ||
|
|
408b03ee68 | ||
|
|
18074daf50 | ||
|
|
08bf2d08a2 | ||
|
|
0f7495546f | ||
|
|
6d97d4e172 | ||
|
|
05366d9177 | ||
|
|
0ab31ae32c | ||
|
|
4199a43660 | ||
|
|
a9d98675ac | ||
|
|
8189a513e1 | ||
|
|
80140f391f | ||
|
|
2fafd85d1f | ||
|
|
bb974e2ed7 | ||
|
|
aa2ee62d32 | ||
|
|
79639ac338 | ||
|
|
6d12e54606 | ||
|
|
070e699a14 | ||
|
|
18048f309e | ||
|
|
ad18e16ac6 | ||
|
|
bb8a0cb0c4 | ||
|
|
6e82d0d06a | ||
|
|
161a5d78d1 | ||
|
|
80f1370f1b | ||
|
|
d576219a27 | ||
|
|
156a31c131 | ||
|
|
e4ee6981f9 | ||
|
|
011eecc096 | ||
|
|
7c21742810 |
@@ -0,0 +1,72 @@
|
||||
# NexusGate 分发链接迁移报告(GitHub → 自建 Gitea)
|
||||
|
||||
- 日期:2026-10-20
|
||||
- 提交:`f4ef3d9` chore: migrate self-distribution links from GitHub to self-hosted Gitea(8 文件,本地已 commit,**未 push**,等云电脑侧续推)
|
||||
- 新基址:`https://git.881218.xyz/rogue/NexusGate`
|
||||
|
||||
## 映射规则(已按此执行)
|
||||
|
||||
| 类型 | 旧(GitHub) | 新(Gitea) |
|
||||
|---|---|---|
|
||||
| raw | `raw.githubusercontent.com/aaaamoooo/NexusGate/<branch>/<path>` | `git.881218.xyz/rogue/NexusGate/raw/branch/<branch>/<path>` |
|
||||
| release | `github.com/aaaamoooo/NexusGate/releases/download/<tag>/<file>` | `git.881218.xyz/rogue/NexusGate/releases/download/<tag>/<file>` |
|
||||
| archive | `github.com/<REPO>/archive/refs/heads/<branch>.tar.gz` | `git.881218.xyz/rogue/NexusGate/archive/<branch>.tar.gz` |
|
||||
| clone | `github.com/aaaamoooo/NexusGate.git` | `git.881218.xyz/rogue/NexusGate.git` |
|
||||
|
||||
## 逐文件改动
|
||||
|
||||
- `public/app.js`(2 处):面板生成的节点注册命令、旧版卸载兼容命令的 raw 链接 → Gitea raw。
|
||||
- `panel-go/embed_public/app.js`:同上同步改毕,两份 app.js 全文件逐字一致。注意该目录是 panel-go 的构建产物(`.gitignore` 排除,`build.sh` 每次从 `../public` 重新拷贝),不入仓;panel-go 已编译的三个二进制(同为 gitignore 产物)内嵌的仍是旧链接,**需重跑 panel-go/build.sh 重建后才生效**。
|
||||
- `scripts/agent-install.sh` / `scripts/agent-update.sh`:新增 `GIT_BASE="https://git.881218.xyz/${REPO}"`,raw 与 release 链接改由 GIT_BASE 拼装;`REPO` 默认 `aaaamoooo/NexusGate` → `rogue/NexusGate`;`NG_REPO`/`NG_BRANCH`/`--repo`/`--branch`/`NG_AGENT_RELEASE` 语义与默认值不变;raw 的 `?t=` 缓存破坏参数保留。相关注释 "GitHub Release" → "Gitea Release"。
|
||||
- `scripts/install.sh` / `scripts/nexusgate.sh`:同上加 GIT_BASE;归档链接改 Gitea archive 格式(两脚本的解包逻辑对顶层目录的处理与 Gitea 包结构兼容:install.sh 取唯一顶层目录、nexusgate.sh 用 --strip-components=1)。
|
||||
- `scripts/agent-singbox.sh`:release 基址改为 `https://git.881218.xyz/${NG_REPO:-rogue/NexusGate}/releases/download/${release_tag}`(调用方 agent-install.sh 本就透传 NG_REPO);stats-query.go 的 raw 拉取改 Gitea raw 格式;提示文案 GitHub → Gitea。
|
||||
- `README.md`:clone 地址、一键注册命令 raw 链接、"GitHub Release" 文案。
|
||||
- `agent-go/INSTALL-GO.md`:release 下载地址与文案。
|
||||
|
||||
## ⚠️ 判断性改动(请复核)
|
||||
|
||||
以下三处脚本的 `REPO` 默认值原本指向**上游原作者** `a2899882/NexusGate`,本次一并改为 `rogue/NexusGate`:
|
||||
|
||||
- `scripts/install.sh`、`scripts/nexusgate.sh` 的源码归档下载
|
||||
- `scripts/agent-singbox.sh` 中 stats-query.go 的拉取源
|
||||
|
||||
理由:这三处下载的是**本 fork 的面板/Agent 代码本身**(上游原版没有本 fork 的二开改动,装错即装成原版),且 a2899882 在自建 Gitea 上不存在、保留只会 404。调用方均可继续用 `NG_REPO` 覆盖。如认为应保留上游默认,回滚这三行即可。
|
||||
|
||||
## 未改动(残留清单及理由)
|
||||
|
||||
- `agent/stats-query.go` 的 import、`scripts/agent-singbox.sh` 的 `go install`/`go get github.com/sagernet/sing-box@...`、`agent-go/PARITY.md` 中的提及:均为**上游 sing-box 依赖包名**,不是分发链接。
|
||||
- `scripts/agent-singbox.sh` 中 Go 工具链下载 `go.dev/dl/...`:上游官方地址、非 GitHub。
|
||||
- `.github/workflows/ci.yml`:GitHub Actions 配置(actions/checkout 等),与分发无关;Gitea 侧默认不执行,保持原样。
|
||||
- 全仓已复查:无 ghproxy / jsdelivr 等镜像域名、无其他写死的旧分发域名。
|
||||
|
||||
## 自检结果
|
||||
|
||||
- grep 复查:文本文件中已无指向 `aaaamoooo` / `a2899882` / `imamoxilin` 或以 `${REPO}` 拼 GitHub 主机的自有分发链接(残留仅上表上游依赖)。
|
||||
- `bash -n` 五个改过的脚本全部通过。
|
||||
- `public/app.js` 与 `panel-go/embed_public/app.js` diff 为空(逐字一致)。
|
||||
|
||||
## ★ Gitea 上尚不存在、需手工建的 Release 清单
|
||||
|
||||
脚本已指向以下地址,但在把文件传上去之前它们是 404。raw 与源码归档由 Gitea 按仓自动提供,无需手工处理;**只有这两组 release 要建**:
|
||||
|
||||
### 1. 标签 `agent-v0.6.8`(agent-install.sh / agent-update.sh 的 `AGENT_RELEASE` 默认值)
|
||||
|
||||
| 附件名(必须逐字一致) | 本地现存文件 |
|
||||
|---|---|
|
||||
| `nexusgate-agent-linux-amd64` | `~/workspace/backups/2026-10-20/binaries/nexusgate-agent-linux-amd64` |
|
||||
| `nexusgate-agent-linux-arm64` | `~/workspace/backups/2026-10-20/binaries/nexusgate-agent-linux-arm64` |
|
||||
|
||||
(同名同大小副本也在 `~/workspace/NexusGate/agent-go/` 下,任选一处上传,文件名不用改。)
|
||||
|
||||
### 2. 标签 `sing-box-v1.13.21`(agent-singbox.sh 的 `release_tag` 默认值,可由 `NG_SINGBOX_RELEASE` 覆盖)
|
||||
|
||||
| 附件名(必须逐字一致) | 本地现存文件(上传时需改名去掉 `-v1.13.21` 后缀) |
|
||||
|---|---|
|
||||
| `nexusgate-sing-box-linux-amd64` | `~/workspace/nexusgate-release/bin/nexusgate-sing-box-linux-amd64-v1.13.21` |
|
||||
| `nexusgate-sing-box-linux-arm64` | `~/workspace/nexusgate-release/bin/nexusgate-sing-box-linux-arm64-v1.13.21` |
|
||||
| `nexusgate-sing-box-stats-linux-amd64` | `~/workspace/nexusgate-release/bin/nexusgate-sing-box-stats-linux-amd64-v1.13.21` |
|
||||
| `nexusgate-sing-box-stats-linux-arm64` | `~/workspace/nexusgate-release/bin/nexusgate-sing-box-stats-linux-arm64-v1.13.21` |
|
||||
|
||||
(`~/workspace/backups/2026-10-20/binaries/` 下有同名镜像副本。)
|
||||
|
||||
注:sing-box 预编译下载失败时脚本会自动回退现场源码编译,所以这组 release 缺失不会让装机彻底失败,只是慢;agent 那组缺失则 Go 版 agent 无法新装(Node 回退路径走 raw,不受影响)。
|
||||
@@ -0,0 +1,172 @@
|
||||
# amo 下一版修改清单
|
||||
|
||||
用户 2026-10-21 上线当晚起陆续口述,攒齐后统一施工。
|
||||
|
||||
## 0. 施工口径变更(2026-10-21 04:36 用户拍板)
|
||||
|
||||
Node 实现(server.js / lib / agent.js)冻结退役:本版起 **Go 单一真源**,只改 panel-go + agent-go + 共用前端,不再双写、不再对 Node 做 difftest;验收金标准改为冻结 Go 现状行为作回归基线。Node 代码留仓作历史与回滚参考,不删。
|
||||
|
||||
## 1. 客户页顶部加统计条(2026-10-21 03:53,用户附 3x-ui 截图)
|
||||
|
||||
照 3x-ui 客户端页顶部那排,在 amo「客户与订阅」页上部加统计条,六个数:
|
||||
- 客户端(总数)
|
||||
- 在线(当前在线数,沿用现有 3 分钟 presence 窗口口径)
|
||||
- 耗尽(流量已用满的客户数)
|
||||
- 即将耗尽(接近用满的客户数,阈值口径施工时对齐 3x-ui)
|
||||
- 关闭(已停用客户数)
|
||||
- 启用(已启用客户数)
|
||||
|
||||
出处:用户在路由器 3x-ui(:55889/rogue)客户端页截图,顶栏现网数字为 26 / 6 / 0 / 0 / 0 / 26。
|
||||
|
||||
## 2. 客户凭据改为全节点共用一套(2026-10-21 03:56,用户附 amo 凭据页截图)
|
||||
|
||||
现状(amo):凭据长在每个节点绑定上——同一客户在 HWSG / ZouSG-V6 / ZouSG-V4 / LEGJP-V4 / LEGJP-V6 各有一套不同的 UUID、密码、Reality Short ID。
|
||||
|
||||
目标(照 3x-ui):凭据收归客户级——一个客户一套 UUID + 密码 + Short ID,名下所有节点共用;节点绑定不再各存一套。
|
||||
|
||||
实证(2026-10-21 查用户 2026-10-20 发的路由器 3x-ui 备份库,只读):clients 表 uuid/password/sub_id 只存一份(email 唯一);client_inbounds 是纯关联表,无任何凭据列。26 个客户中 19 个挂了多个入站,最多的 rogue 挂 20 个入站、共用同一套凭据。此模型与日后 3x-ui→amo 正式迁移正好 1:1 对齐。
|
||||
|
||||
施工前要定的点:
|
||||
- 节点侧 Reality 密钥对是服务器身份,不动,仍按节点托管(keyStore)。
|
||||
- 存量不是问题:用户 2026-10-21 确认现网 amo 里服务器/客户/节点全是测试数据(随便加的),切换凭据模型时测试数据直接按新模型重建,无需收敛迁移;真实用户仍在 3x-ui 侧(待正式迁移)。
|
||||
|
||||
## 3. 内容区左右间距调小到与 3x-ui 一致(2026-10-21 04:02,用户附 amo 节点页 + 3x-ui 主机页对照图)
|
||||
|
||||
amo 各页主内容区两侧留白偏大;把左右间距(padding/最大宽度约束)调小,调到与 3x-ui 一样的贴边宽度。所有页面统一,不是一页一调。
|
||||
|
||||
## 4. 服务器页「核心版本」加真实探测(2026-10-21 04:11 用户)
|
||||
|
||||
现状:服务器页核心版本恒显"—"(agent 心跳没报这个字段)。
|
||||
改法:agent 探测本机 sing-box 版本随心跳上报,面板落到核心版本列显示。
|
||||
实现口径(已与用户对过内存账):版本探测只在 agent 启动 / 引擎安装更新 / 二进制文件变化时跑一次 `sing-box version` 并缓存,心跳只多带一个短字符串字段;不许每轮心跳起子进程。老 agent 无此字段时继续显"—",向后兼容。
|
||||
|
||||
## 5. 删除「延迟」(2026-10-21 04:12 用户)
|
||||
|
||||
服务器页的延迟列删掉,统计条里的"平均延迟"一并去掉;系统状态页如有同名占位同删。
|
||||
理由(用户原话口径):延迟要不断探测上报、没什么用、还费内存——明确不做延迟探测功能,占位列也不要留。
|
||||
|
||||
## 6. 行操作小图标加悬停名称提示(2026-10-21 04:15,用户附客户行图标特写)
|
||||
|
||||
列表行内的功能小图标(二维码、详情、重置、编辑、删除等)鼠标悬停时显示对应功能名称(tooltip/title)。客户页先行,全站同类图标统一补齐。
|
||||
样式照 3x-ui(用户 2026-10-21 附特写):黑底白字圆角气泡带小尖角、出现在图标上方;被悬停的图标本身带浅灰背景高亮。名称与 3x-ui 对齐:二维码 / 详情(i 图标)/ 重置流量(⇄ 图标)/ 编辑 / 删除。
|
||||
|
||||
## 7. 节点端口「范围内随机」保存后反复重掷(2026-10-21 04:19,用户附编辑节点截图)
|
||||
|
||||
现状:新建节点时入口端口为"范围内随机",分配到随机端口;若不手工改成固定,之后每次编辑该节点(哪怕只改其他参数)点保存,端口都会被重新随机分配成另一个——端口静默漂移,客户端随之失效。
|
||||
|
||||
要求:随机只在新建时掷一次;已分配的端口在后续编辑中必须保持不变,除非用户显式改端口模式/数值。实现口径施工时定(如:编辑既有节点时端口模式默认按已分配端口固定呈现),验收要点:编辑既有节点任意字段保存后端口逐字不变。
|
||||
|
||||
## 8. 中转模型与 3x-ui 不对齐:缺"A 入站经内网 IP 中转到 B 节点"(2026-10-21 04:26,用户附中转节点编辑截图)
|
||||
|
||||
现状:amo 的中转是"入口 → 出口设备"模型(转发拓扑 + 出口 SOCKS5),与 3x-ui 的"出站 + 路由规则"完全不同。
|
||||
缺口:无法实现用户在 3x-ui 上的既有用法——A 服务器的入站节点通过内网 IP 直接中转到 B 服务器的节点(线路鸡 → 落地鸡内网中转)。
|
||||
要求:下一版补齐这种中转形态(这是用户现网在用的模式,迁移前置之一)。
|
||||
|
||||
设计方向(2026-10-21 04:28 已与用户对过):出口目标二选一——① 受管设备(现状,面板管两边);② 新增"手动落地端点":填地址(内网 IP/域名)+ 端口 + 协议 + 凭据(VLESS=UUID、SS2022=密码、SOCKS5=账密),面板只在 A 侧生成出站+路由(sing-box outbound + route),B 侧不要求装 agent、可以是 3x-ui 的现成节点。凭据只存主控、随部署下发。
|
||||
补充(用户 2026-10-21 确认的理解):第三方/别人提供的节点同样可作落地——只要有 IP+协议+ID+密码即可,来源不限(自家内网鸡、3x-ui 节点、别人给的节点都行);前提是 A 能直达该地址端口。一个 vless:// / ss:// 分享链接本身就含全这四样,施工时可顺带做"粘贴链接自动填表"。
|
||||
|
||||
## 9. 删除右上角头像圆图标(2026-10-21 04:34,用户附顶栏特写)
|
||||
|
||||
顶栏右侧的圆形头像(显用户名首字母"G")是纯装饰 span,无任何点击事件(退出登录在侧栏底部另有按钮)——删掉。已查证同排的「刷新」按钮有效(绑定 load() 重新拉数重渲染,与 45 秒自动刷新同一套),保留不动。
|
||||
|
||||
## 10. 删除顶栏全局搜索框(2026-10-21 04:39,用户附顶栏截图)
|
||||
|
||||
顶栏"搜索服务器、客户、节点…"输入框是写死 disabled 的摆设、无任何功能;且每页都有自己的可用搜索框,此框多余——删除(顶栏共用一处,删一处全站生效)。
|
||||
|
||||
## 11. 菜单与命名调整 + 页面合并(2026-10-21 04:47,用户附节点页全图)
|
||||
|
||||
- 左侧菜单「客户与订阅」→ 改名「用户订阅」(页面标题/面包屑同步)
|
||||
- 左侧菜单「服务器」→ 改名「节点管理」
|
||||
- 左侧菜单「转发与节点」→ 改名「入站&出站」(页面标题/面包屑同步)
|
||||
- 该页「+ 新建节点」按钮 → 改为「添加入站」;页内计数文案「9 个节点」→「9 个入站」(同页其他"节点"计数文案同步对齐)
|
||||
- 左侧菜单「设置与运维」→ 改名「设置」
|
||||
- 「安装与命令」页面的全部内容合并进「设置」页面,合并后删除「安装与命令」页面(菜单项与路由一并摘除)
|
||||
|
||||
## 12. 核心版本显示为空——根因已明,下批再修(2026-10-21 11:52 用户定:小问题,下次一起修)
|
||||
|
||||
v2 上线后现网四台核心版本列全空。已查实根因:节点 sing-box 是自编版(agent-singbox.sh 固定 v1.13.21),构建未打版本戳,二进制 `version` 输出 `sing-box version unknown`,agent 探针按设计把 unknown 当无版本。代码链(探针→心跳→落库→渲染)无误。
|
||||
已定修法(未施工):agent 探针解析为空时兜底读安装记录 /etc/nexusgate/sing-box/version;agent-singbox.sh 安装成功后写该记录(去 v 前缀);现网四台补写 1.13.21 即可(安装脚本写死的版本,有据)。长期根治:下次重编 sing-box 时打版本戳。
|
||||
|
||||
## 13. 删除系统状态页「最近活动」模块(2026-10-21 04:56,用户附截图)
|
||||
|
||||
首页(系统状态页)底部的「最近活动」面板整个删除(该面板是改版时为保旧功能补回的,用户现明确不要)。只删展示模块,后端活动/审计记录本身保留不动。
|
||||
|
||||
## 14. 设置页「任务队列」表格排紧凑(2026-10-21 04:58,用户附截图)
|
||||
|
||||
现状毛病:列宽分配失衡——任务 ID 被挤到换行两行、状态"已完成"竖排三行、时间两行,而错误列大片空白。
|
||||
要求:重配列宽与换行——ID/状态/时间等短内容不换行、单行呈现;空白列不占大宽度;整表紧凑。合并进设置页后保持此排版。
|
||||
|
||||
## 15. 凭据页签删掉按绑定逐行的 Reality 公钥区块(2026-10-21 14:13 用户附截图)
|
||||
用户原话:红框里的节点 Reality 公钥好像没什么意义,不需要显示出来,只需要显示上面的凭据就可以了。
|
||||
处理:编辑客户「凭据」页签删除整个按绑定逐行列表(节点名/状态/协议/Reality 公钥+复制按钮),只保留订阅令牌 + 凭据(全部节点共用)三件套(UUID/密码/Reality Short ID)。仅前端展示删除:公钥数据本身(主控 keyStore 托管、订阅生成、部署下发)一律不动。
|
||||
|
||||
## 16. 节点表单加 REALITY 目标扫描器(2026-10-21 14:21 用户附 3x-ui 扫描器截图)
|
||||
用户原话:查找目标点一下,就会扫描出很多可用的域名可以用,点一下使用即可,很方便。
|
||||
规格(照 3x-ui 扫描器形态):
|
||||
- 入口:编辑/新建节点的 Reality 目标预设旁加「查找目标」按钮,打开扫描器弹窗。
|
||||
- 输入:域名 / IP / CIDR,可留空——留空则探测内置常用候选池(microsoft/google/nvidia/amazon/samsung/intel 等,与现有目标预设同源扩充)。
|
||||
- 探测:主控侧对每个候选做 TLS 握手,列表展示 目标/状态(可用与否)/TLS 版本/ALPN/密钥交换(须支持 X25519 或 X25519MLKEM768)/证书主域名(SAN)/延迟;不可用行给出简短原因。
|
||||
- 回填:每行「使用」按钮,点击即把该目标回填为节点的 Reality SNI / 目标地址与端口并关弹窗。
|
||||
- 注意:v1 与 3x-ui 一致在主控侧扫描,延迟/可达性是主控视角(路由器经代理出网,与海外节点的真实视角有偏差,UI 上不夸大表述);节点侧探测(走 agent job)留作二期,不在本条范围。
|
||||
|
||||
## 17. 节点编辑页显示 Reality 公钥+私钥(2026-10-21 14:22 用户附 3x-ui 入站安全页截图)
|
||||
用户原话:之前用户页面删掉的那个公钥展示,把它和私钥一起和 3x-ui 加到对应的节点页面显示吧。
|
||||
处理(与第 15 条配套):编辑节点弹窗(Reality 协议时)增加密钥对展示区:公钥、私钥各一行,带复制按钮,数据取自主控 keyStore 托管的该服务器密钥对。注意 amo 的密钥对是**服务器级**身份(同机多个 Reality 节点共用同一对),节点页显示的是其所属服务器的那一对,不要做成节点级新生成。本条只要求显示;是否加「换一对」(相当于 3x-ui 获取新证书,会触发该服务器全部 Reality 节点重部署+订阅公钥变更)本轮不做,用户未要求。
|
||||
第 15 条相应修订:客户凭据页签删的是"按绑定逐行重复展示公钥"的区块;公钥信息自此只在节点页出现一份。
|
||||
|
||||
## 18. 入站监听钉死公网 IP,内网中转打不通(2026-10-21 15:04 实测确诊;同日已热修上线并真机验收:commit aa2ee62,路由器面板已换装(旧二进制备份 .bak-hotfix-pre),PPFL1-SK5 重部署后 ss 见 0.0.0.0:47814,纯 amo 中转链实测通、出口正确。aa2ee62 尚未推 Gitea,随下一批一并推。)
|
||||
证据:用户 PPF 两机内网中转(L3 入口 VLESS → 手动落地 L1 的 SOCKS5 入站,落地地址填内网 10.240.64.2:47814)在 amo 不通;同一对机器在原版 3x-ui 正常(xray 入站默认听全部地址)。L1 本机 ss 实测:47814 只绑公网 IP 140.235.229.19,未绑 0.0.0.0/内网地址,进程为 nexusgate-sing-box(节点本身部署成功)。
|
||||
根因:panel-go/protocols.go listenAddress() 在 ipv4 模式返回服务器登记的 PublicAddress(ipv6 模式同理返回 PublicAddressV6),入站只听公网网卡,内网地址无人监听。
|
||||
修法(2026-10-21 15:07 用户定现在热修;15:09 范围终版):默认监听全改通配——ipv4→0.0.0.0、ipv6→"::"、dual→"::"(不变);显式 BindAddress 覆盖保留。v6 也改通配的理由(用户原话):有的节点鸡有很多个 v6 公网地址,只绑登记的那一个会漏掉其余 v6 地址上的连接。代价用户已知并接受:通配 v6 套接字在 Linux 默认会顺带收 v4 映射连接,仅 v6 节点事实上 v4 也可达。改动在主控侧 listenAddress()(BuildNodeResource/BuildNodeExitResource 共用),agent 无需改;换面板后对节点重新部署即生效。
|
||||
|
||||
## 19. SOCKS5 用户名上收到客户共用凭据区(2026-10-21 15:42 用户指认,与第 15 条配套)
|
||||
现状:客户的 SOCKS5 用户名(ng:cus_<客户ID>,即 NodeEmail)目前仅在客户凭据页签的"按绑定逐行"区块里随节点行显示(如 PPFL1-SK5 行),而该区块第 15 条要删。删后此用户名将无任何界面可见,但手动落地端点填表等场景必须人读它(用户本次中转测试就靠抄它)。
|
||||
处理:删绑定区块时,把「用户名」作为第四行加进凭据页签上部的共用凭据区(UUID / 密码 / ShortID / 用户名),带复制按钮;值由 ng:+客户ID 确定性生成,纯展示、无新存储。订阅与入站生成逻辑不变。
|
||||
|
||||
## 20. 入站先建后绑:放开"必须先分配客户才能部署"(2026-10-21 16:11 用户提出,方案已拍板)
|
||||
现状:orchestrator.go 部署校验写死 len(activeNodeClients)==0 时 400 拒绝("请先给节点分配至少一个客户再部署");节点能建能存,卡的是部署/启用。改成 3x-ui 式:先把入站建起来部署,关联客户随时增减(绑定变更本来就会触发重新部署)。
|
||||
方案(用户 18:01 按推荐方案确认):删除该校验,零客户节点可部署(VLESS/SS/AnyTLS/Hysteria2 等空用户只是无人能连,无副作用);**唯 socks5-auth 例外保留拦截**——sing-box 的 socks 入站在 users 为空时不做认证=无密码开放代理(xray 空账号是拒绝,语义不同),且监听已改通配、公网可达,拦截提示语写明此原因。
|
||||
验收:零客户建一个 VLESS 节点部署成功且在节点机监听;随后绑客户自动重新部署可连;零客户建 socks5-auth 节点部署被拦且提示正确;绑客户后部署成功。
|
||||
|
||||
## 21. SOCKS5 节点改固定账号密码(3x-ui 式,入站级)(2026-10-21 18:06 用户拍板)
|
||||
用户原话:socks5 节点改成 3x-ui 那样固定的账号和密码,直接在入站里固定;不然多人共享同一个内网中转的 socks5 节点,流量会全部算在同一个用户名下。
|
||||
诊断(已核代码):现模型 socks5-auth 入站 users 按绑定客户逐个派生(ng:cus_<id>+共用密码),但中转链入口侧出站只有一组静态 ExitCredentials,全体客户的流量到落地侧都以那一组身份认证,落地侧"按人分账"是假的、全记一人头上(还会误触其限额)。入口侧才是真分账点(客户各自 UUID 认证)。
|
||||
改法:
|
||||
- 节点(协议=socks5-auth)新增入站级固定 socksUser/socksPassword:建节点时可填、留空自动生成随机对;节点编辑表单在 socks 协议时显示这两项(可改、带复制);入站 users 只放这一组。
|
||||
- 订阅:socks 节点链接携带这组固定账号密码(所有绑定客户同一份),绑定只控制订阅里出不出现该节点,不再派生凭据。
|
||||
- 记账:该固定账号的流量记为节点级(不归任何客户);面板展示口径随之调整,别再把落地 socks 流量摊给客户。中转链的按人记账在入口侧完成(现状已如此)。
|
||||
- 存量:现网 socks 节点(PPFL1-SK5 等,测试数据)迁移时生成一组固定凭据替换 users;入口手动落地端点里填的 ng 账号随之作废,改填节点固定账号(文档/提示同步)。
|
||||
连带修订:
|
||||
- 第 19 条作废(被本条取代):客户凭据区不再加"用户名"行——socks 用户名从此在节点上,客户凭据页签的绑定区块可径直删除,无需上收。
|
||||
- 第 20 条修订:socks5-auth 的零客户拦截例外取消——入站恒有固定账号,不存在空 users 变开放代理的问题,先建后绑对全部协议一致放开。
|
||||
|
||||
## 22. 面板切菜单慢:每请求全量克隆的成本(2026-10-21 18:12 用户报,晚间实测定位)
|
||||
现象:路由器上点菜单约 3 秒才切换;3x-ui 同机同路(内网/隧道)都快。
|
||||
实测证据(已逐项排除):DB 文件仅 225KB;两条访问路径都慢(非隧道问题);favicon(同进程静态文件)6–21ms 而全部 /api 接口 250–1560ms 且与响应大小无关(1.2KB 的 system 也要约 1s);放宽 GOMEMLIMIT 28MiB→64MiB、GOGC 50→100 无改善(GC 无关,已恢复原参数,RSS 回 14MB 级);SIGQUIT 协程转储无空转循环(全在 IO/通道等待);整机 load 约 3(4 核),面板单请求成本高是主因、整机繁忙是放大器。
|
||||
机制:每个 admin GET 请求至少两次全量 Snapshot()——RequireAdmin 为找一个用户克隆整库一次,handler 再克隆一次;Clone() 走 JSON marshal+unmarshal 全量往返;切一页 = 2–5 个这种请求串行排队(同一把 store 锁,还要和 agent 心跳的 Transaction 克隆+落盘竞争)。3x-ui 每请求只做索引查询,故同机快。
|
||||
修法(下一批,性能专项):
|
||||
1. RequireAdmin 不再全量克隆找用户:用户表小规模缓存/按会话直接映射,请求级只克隆一次并在 handler 间共享 snapshot。
|
||||
2. 读路径去全量 JSON 往返:以不可变快照+按需部分解析或写时复制替代 Clone() 的全量 marshal/unmarshal(保数据文件格式与事务语义不变,金标准必须全过)。
|
||||
3. 前端切页不重拉全量:servers/nodes/customers 等基础数据做页间缓存(带失效点:写操作后失效),渲染不等全量 Promise.all 才上屏。
|
||||
验收口径:路由器真机、同数据量下 devtools 复测,常用页切换的接口耗时回到几十 ms 级;RSS 不回涨(保持 28MiB/50 参数)。
|
||||
附带教训(已发生):给用户的 sed 命令里 `.*` 被其客户端吞成 `.`,把 GOMEMLIMIT 改成 96MiB8MiB 导致 Go 运行时启动崩溃循环——已恢复;今后改值命令只用全字面替换,不用通配。
|
||||
|
||||
## 23. 绑定变更合并下发:连续加人只重启一次(2026-10-21 18:34 用户提出、18:36 拍板并入本批)
|
||||
现状(已核代码):给节点分配/移除客户(assignCustomer/unassignCustomer)只要节点非草稿就立刻 RebuildNode 并排 apply_resource 任务,agent 重写 sing-box 配置重启生效——每加/减一个人,该节点全体用户断一次重连;连加十人重启十次。sing-box 无动态加人接口,单次重启消不掉,只能合并。
|
||||
改法:绑定类变更触发的重建按下发合并——同一节点在短窗口(10 秒量级)内的多次 RebuildNode 只产生一次 apply_resource,以窗口结束时的最终状态为准。
|
||||
约束:①手动点部署(/api/nodes/:id/deploy)保持立即执行,不进合并窗口;②删除/移除流程语义不变,窗口内节点被删除时待发 apply 必须正确作废、不许复活;③面板在窗口内重启不能把节点卡在未下发状态(待发状态可落盘或启动对账补发,二选一并在报告注明);④凭据重置等其他触发 RebuildNode 的路径按同一合并口径(按节点合并即可)。
|
||||
验收:同一节点快速连加 5 个客户,最终只执行 1 次 apply_resource 且 5 人齐全;窗口内删节点不复活;手动部署仍立即;既有测试与金标准不回归。
|
||||
口径补充(同日用户问答确认):合并窗口为固定窗口(从该节点第一次待发变更起算,不滑动延长);代价=新加入用户最多晚约 10 秒 + 一次 apply 时间才能连通该节点,窗口内老配置照常服务、老用户无感。此代价用户已知悉并接受。
|
||||
|
||||
## 24. 列表页勾选是空壳:补齐批量操作(2026-10-21 20:55 用户实测指出)
|
||||
现象(用户附 3x-ui 对照图):3x-ui 勾选行后顶部出现「已选 N 项 + 删除」,可一键删除全部选中;amo 三个列表页(节点管理/入站&出站/用户订阅)的行勾选框与表头全选都在,全选也能勾上,但选中后界面毫无变化、无任何批量动作可用。
|
||||
已核代码(public/app.js):data-row-check 渲染在 servers/customers/nodes 三页;唯一的 data-check-all 处理只是把全选状态镜像到每行 checkbox(约 1426 行);row-check 本身无 change 处理、全站无任何代码读取勾选集合(grep 无 已选/bulk 字样)。
|
||||
修法:每页维护选中集合,选中 ≥1 时在工具条区显示批量操作条「已选 N 项」+ 动作:批量删除、批量启用、批量停用(按各页既有单项语义:节点 启用=部署/停用=移除、客户与服务器走各自启停);删除一次确认弹窗说清数量与不可逆;逐项执行既有单项接口/端点,守卫规则与单项完全一致(如服务器有未删节点拒绝、节点有运行中任务拒绝),结果汇总回报成功/失败数与失败原因;操作后刷新列表并清空选择。切页/重渲染后选择状态的去留施工时定并在报告注明。
|
||||
范围补充(同日用户强调):**所有页面都要改**——凡是带行勾选框/全选框的列表页全部补齐批量操作,不许只做一页;施工时以 data-row-check / data-check-all 在 public/app.js 的全部出现处逐一排查销账(当前已知:节点管理、入站&出站、用户订阅三页),报告中逐页列明。
|
||||
|
||||
## 25. agent 鉴权令牌化:压掉待机 CPU 的 scrypt 主项(2026-10-21 21:01 用户拍板,与第 24 条同批)
|
||||
背景(v3 第 22 条归因):面板待机 CPU 的主项是 agent 每次请求都跑一次 scrypt VerifySecret 验 KeyHash(沙箱单次约 88ms、20 台模拟稳态 32.3%);二开版 3x-ui 无此问题——慢哈希只用于人类登录,agent 通信走 API 令牌比对(已查其代码证实)。
|
||||
修法(用户原话口径:往令牌制靠):agent 首次(或令牌失效时)仍走指纹定位 + scrypt 验一次身份,面板随即签发一张短期令牌(随机值,面板只存其快速哈希如 SHA-256 + 过期时间,绑定该 agent 身份);agent 后续请求带令牌,面板只做快速哈希比对,不再跑 scrypt。令牌过期/不符自动回退 scrypt 路径并重签。吊销语义不降级:删除/吊销 agent、重置其密钥时令牌一并失效。
|
||||
兼容:旧 agent(不支持令牌)继续走 scrypt 路径可用、只是不提速;新 agent 对旧面板自动回退。鉴权强度不许降级:令牌随机强度与现有密钥同级、比对用常时比较、传输通道沿用现状(现制下密钥本就逐请求在同一通道上传输,令牌不弱于此)。
|
||||
验收:①稳态 CPU 按 v3 同法复测(20 台 agent 模拟、无界面请求)——目标个位数 %,报告给改动前后同口径数字;②功能回归:agent 注册/心跳/任务拉取/用量上报全链路冒烟、面板或 agent 单侧重启后自动恢复(重签或凭令牌续用)、吊销后令牌立即失效、旧 agent 混跑可用;③全量测试与金标准不回归。
|
||||
部署提示:面板与 agent 双侧都改;面板先行兼容旧 agent,agent 逐台换新后逐台提速。
|
||||
@@ -0,0 +1,132 @@
|
||||
# amo v3 批次构建报告(NEXT-ROUND-NOTES 第 12/15/16/17/20/21/22/23 条)
|
||||
|
||||
- 基线:`aa2ee62`(开工时核对一致);本批 10 个 item-scoped 提交,HEAD 见文末清单。
|
||||
- 施工口径:第 0 条 Go 单一真源——只改 `panel-go/`、`agent-go/`、共用前端 `public/`、安装脚本 `scripts/`;Node 实现零改动(`git diff --name-only aa2ee62..HEAD` 可核)。数据文件既有字段语义未改,新字段全部 `omitempty` 向后兼容。
|
||||
- 验收环境:沙箱 linux/amd64(AMD EPYC 9D64)、Go 1.24.6、自编 sing-box v1.13.21 沙箱现成二进制。**未部署、未推 Gitea**;真机(路由器 A53)数字待部署后复测。
|
||||
|
||||
## 逐项结果
|
||||
|
||||
### 1. 第 12 条 核心版本显空修复 — ✅ 完成(`bb974e2`)
|
||||
|
||||
- `agent-go/health.go`:探针 `singBoxVersion()` 在二进制版本为空/`unknown` 时兜底读安装记录;记录文件 mtime 纳入探针缓存键,补写记录能被及时感知。新增 `installedSingBoxVersion()`:去空白与可选 `v` 前缀、正则校验版本号形状,垃圾内容忽略。
|
||||
- `agent-go/main.go`:config 新增 `SingBoxVersionFile`,默认 `/etc/nexusgate/sing-box/version`,env `NG_SINGBOX_VERSION_FILE` 可覆盖。
|
||||
- `scripts/agent-singbox.sh`:新增 `write_version_record()`(写去 `v` 前缀的 `1.13.21`),在已安装早退、预编译安装、源码编译安装三条成功路径全部调用。
|
||||
- agent 版本号保持 `0.6.8`(面板 AnyTLS 版本门禁按数字解析,铁律未动)。
|
||||
- 测试:新增 `TestSingBoxVersionFallsBackToInstallRecord`(unknown→读记录、带戳二进制优先、垃圾记录忽略、v 前缀剥离)通过;脚本 `bash -n` 通过。现网四台补写记录属部署动作,不在本批。
|
||||
|
||||
### 2. 第 15 条 凭据页签删绑定区块 — ✅ 完成(`8189a51`)
|
||||
|
||||
- `public/app.js` `renderCredentialsPane`:删除按绑定逐行列表(节点名/状态/协议/Reality 公钥+复制),只留订阅令牌+共用凭据三件套。后端 `customerCredentialsView` 载荷、keyStore、订阅、下发数据未动。
|
||||
- 第 19 条已被第 21 条取代作废:未按旧口径往共用凭据区加「用户名」行(socks 用户名自此在节点上)。
|
||||
|
||||
### 3. 第 16 条 REALITY 目标扫描器 — ✅ 完成(`0ab31ae`)
|
||||
|
||||
- 后端 `panel-go/realityscan.go`+接口 `POST /api/reality/scan`(RequireAdmin 之后):入参 `{input}` 或 `{targets:[...]}`。
|
||||
- 输入解析:留空→内置候选池 14 个(与目标预设同源的 tesla/amd 等+ microsoft/google/nvidia/samsung/intel/cloudflare/bing/github/yahoo/mozilla,均为 443);支持域名/IP/host:port/IPv4 CIDR(展开上限 32 主机);显式输入超 32 目标报 400。
|
||||
- 探测:真实 TLS 握手,并发上限 8、单探针超时 4 秒;返回 target/host/port/ok/reason/tlsVersion/alpn/keyExchange/keyExchangeOk/certName/latencyMs,顺序与输入一致。密钥交换判定:Go 1.24 的 `tls.ConnectionState` 无 CurveID 字段,故改为限定组握手实测——依次只提供 X25519MLKEM768、只提供 X25519、组合兜底,任一成功即记支持。探测带 `InsecureSkipVerify`(只判可达性与密钥交换能力,证书 SAN 仍回报展示;理由已注释在代码内)。
|
||||
- 前端:预设旁「查找目标」按钮;扫描器为独立叠加层(DOM 元素,不走 openModal,不冲未保存的节点表单状态),带主控视角说明文案(「从主控所在网络发起探测……节点所在网络可能不同」);每行「使用」回填 Reality SNI/目标端口、预设切 custom、关层。样式 `.scan-table` 在 `public/styles.css`。
|
||||
- 测试:`realityscan_test.go` 通过(池/输入/CIDR 解析、本地自签 TLS 服务器 loopback 实测 OK/TLS 1.3/密钥交换非空、死端口给 reason);冒烟实测见下。
|
||||
|
||||
### 4. 第 17 条 节点页 Reality 公私钥 — ✅ 完成(`a9d9867`+测试修复 `4199a43`)
|
||||
|
||||
- 新只读接口 `GET /api/nodes/:id/reality-keys`:取 keyStore 中该节点所属**服务器**的托管密钥对;**绝不调用生成逻辑**,无条目返回 null;非 Reality 节点 400、节点不存在 404。
|
||||
- 前端:节点编辑弹窗(Reality 协议时)加展示区,公钥/私钥各一行带复制;无密钥对显示「尚未生成(首次部署时由主控生成)」。只显示,无「换一对」。
|
||||
- 测试:`realitykeys_test.go` 通过;冒烟实测接口返回完整公私钥对、非 Reality 节点 400。
|
||||
|
||||
### 5. 第 20+21 条 先建后绑+SOCKS5 固定账号 — ✅ 完成(`2fafd85`)
|
||||
|
||||
- 先建后绑:`orchestrator.go` 删除 `ValidateNodeDeploy` 的零客户 400 校验,全部协议一致放开(socks 例外按第 21 条连带修订取消);删除 `RebuildNode` 零客户自动移除分支与对账路径的零客户 skip——绑客户归零只重建、不下架。
|
||||
- 固定账号:`types.go` Node 新增 `socksUser`/`socksPassword`(omitempty;老文件可读、老二进制读新文件忽略新字段不崩)。`protocols.go` `EnsureSocksCredentials()` 生成:用户名 `ngs_`+10 位 hex——**刻意避开 `ng:` 前缀**,因 agent 侧 sing-box 日志解析用 `^ng:(.+)$` 把 user 计数映射为客户级 usage 样本,固定账号带该前缀会被误记到某客户头上;密码为 base64 32 字节随机(与 RelayPassword 同级)。
|
||||
- 入站 users 恒为固定账号一组,与绑定数无关(0/1/2 客户均有测试覆盖)。
|
||||
- 订阅:`subscriptions.go` `NodeClientURI` 对所有绑定客户携带节点固定账密;链接只在客户已绑定时出现,绑定只控制出不出现。
|
||||
- 编辑表单:socks 协议时显示两字段(可改、带复制,含未保存值复制);表单提交空值=保留旧值(与 exitCredentials 合并语义一致);用户名校验拒绝 `ng:` 开头与空白;协议切离 socks 时清空两字段。新建留空保存后自动生成。
|
||||
- 存量迁移:`store.go` `MigrateSocksNodeCredentials()` 在 Store.Init 与既有凭据收敛迁移合并落盘,幂等只填空缺。
|
||||
- 记账:固定账号流量在 agent 侧不匹配任何客户 user 样本,走 inbound 样本记节点级 MeteredTraffic;入口侧按客户 UUID 分账逻辑未动。
|
||||
- 测试:新增 `panel-go/socksfixed_test.go` 全过;订阅金标准夹具的 socks 节点改存固定账密对(取旧派生值使全部格式产物逐字不变,见金标准节)。
|
||||
|
||||
### 6. 第 22 条 性能专项 — ✅ 代码完成,真机复测待部署(`05366d9`+`6d97d4e`+基准扩容 `0f74955`)
|
||||
|
||||
后端:
|
||||
|
||||
- `types.go` `Clone()` 改反射深拷贝(指针/接口/切片/映射/结构体递归、[]byte 逐字拷贝、未导出字段经浅拷贝保留);原 JSON 往返保留为 `cloneViaJSON()` 供等价对照。`clone_equivalence_test.go` 在真实种子库与合成数据(含 json.Number 嵌套、keyStore、Extra)上证明两种克隆序列化逐字节相等且深独立、nil 安全。
|
||||
- `handlers.go` `CurrentAdmin` 改走 `store.View` 只读命中单条 User 记录,不再为找用户整库 Snapshot——每个 admin GET 的全量克隆由至少两次降为一次(handler 自身的一次 Snapshot 保留,未逐个改 View,以保同一请求内读一致性)。事务语义、数据文件格式、并发模型不变。
|
||||
|
||||
前端(`public/app.js`):
|
||||
|
||||
- 基础数据集页间缓存(servers/customers/nodes/protocols,TTL 30 秒);`api()` 内任何非 GET 成功统一失效缓存——全站写路径都过 `api()`,无漏点。
|
||||
- `load()` 改渐进渲染:先以已有数据立即渲染当前页,各数据集到达后逐个应用并重渲染;overview/system/jobs 恒新鲜拉取;仅当本次全部请求皆失败才弹错误提示(保持旧失败面)。
|
||||
|
||||
量化证据(沙箱 amd64,合成种子 **20 服务器 / 40 客户 / 40 节点 / 160 绑定**,改动前后同种子同测法;旧版取 `aa2ee62` worktree 构建,基准文件同源移植):
|
||||
|
||||
| 指标 | 改动前 | 改动后 | 倍数 |
|
||||
|---|---|---|---|
|
||||
| Clone 一次 | 13.14 ms(JSON 往返) | 0.54 ms(反射) | ≈24× |
|
||||
| GET /api/overview | 31.74 ms | 0.48 ms | ≈66× |
|
||||
| GET /api/servers | 33.65 ms | 1.29 ms | ≈26× |
|
||||
| GET /api/customers | 31.17 ms | 2.29 ms | ≈14× |
|
||||
| GET /api/nodes | 31.31 ms | 5.08 ms | ≈6× |
|
||||
| GET /api/jobs | 24.63 ms | 0.64 ms | ≈39× |
|
||||
|
||||
注:改动后 nodes 接口剩余耗时主要是 PublicNode/订阅 URI 等业务序列化,不再是克隆。以上是 amd64 沙箱数值;路由器真机(A53+整机 load 约 3)验收待部署后用 devtools 同数据量复测,预期回到几十 ms 级的结论以真机数字为准。
|
||||
|
||||
空闲稳态 CPU 对照(用户追加项;规模按最终口径 **20 台 agent**):
|
||||
|
||||
- 测法:同一种子(20 服务器/40 客户/40 节点,含 20 个带指纹的 agent 凭据),新旧二进制各起一份、无任何界面请求,驱动脚本按 agent-go 现网节奏模拟 20 台 agent——poll 每 8 秒、heartbeat 每 30 秒、usage 每 60 秒(空 samples),错峰启动;预热 15 秒后取 60.06 秒窗口,用 `/proc/<pid>/stat` 的 utime+stime 差折算 %CPU(100 ticks=1 秒 CPU)。两版驱动错误数均为 0。
|
||||
- 结果:**改动前 38.41% → 改动后 32.31%**。**未达到个位数目标,如实报告。**
|
||||
- 剩余成本主项(实测归因):agent 鉴权 `VerifySecret` 的 scrypt 校验。指纹快路径只负责定位 agent 记录,定位后仍须对 KeyHash 做一次 scrypt 验证(不可跳过,否则指纹即凭据);沙箱实测单次 VerifySecret ≈ 87.9 ms。稳态请求率 = poll 2.5/s+heartbeat 0.67/s+usage 0.33/s ≈ 3.5/s,scrypt 一项即 ≈ 3.5×87.9ms ≈ 30.7% CPU,与改动后 32.31% 几乎完全吻合——即克隆成本已被基本消除(旧版多出的约 6 个百分点即 JSON 全量克隆+usage 事务克隆落盘),稳态 CPU 现由**密码学校验**主导,与第 22 条的克隆问题是两个独立瓶颈。若后续要继续压稳态 CPU,方向是 agent 鉴权改造(如指纹命中后改用廉价 HMAC 校验或会话化令牌),属新立项,本批未动鉴权语义。
|
||||
- 事务路径说明:`Transaction` 仍做一次全量克隆+落盘(写路径语义要求),稳态下仅 usage 每分钟 20 次触发,成本已被 scrypt 淹没,不是主项;60 秒 housekeeping 的事务+Snapshot 在两版 RSS 采样中均表现为分钟级瞬时尖峰。
|
||||
|
||||
RSS 对照(同种子、无界面请求):
|
||||
|
||||
- 空闲(带现网参数 `GOMEMLIMIT=28MiB GOGC=50`,启动 20 秒后连取 3 次):改动前 10,876 KB 稳定;改动后 10,672–10,840 KB。**无回涨**,满足第 22 条「RSS 不回涨」口径。
|
||||
- 稳态负载下(未加 GOMEMLIMIT 的沙箱默认,60 秒窗口每 10 秒采样):改动前 47→197 MB 持续爬升(JSON 克隆分配压力),改动后多数样本约 46 MB、housekeeping 分钟边界出现一次约 150 MB 瞬时尖峰后回落。
|
||||
|
||||
### 7. 第 23 条 绑定变更合并下发(本批第 8 项)— ✅ 完成(`80140f3`)
|
||||
|
||||
- 窗口语义:**固定窗口 10 秒**——从该节点第一次待发变更起算,后续变更不延长(用户补充口径);窗口内多次绑定类变更只在到期时以最终状态下发一次 `apply_resource`。
|
||||
- 实现:Node 新增落盘字段 `rebuildPending`/`rebuildPendingAt`/`rebuildPendingExit`(omitempty);`Orchestrator.FlushPendingRebuilds(now)` 由主进程 1 秒 ticker 调用。**待发状态落盘**(二选一中取此方案):面板在窗口内重启,首个 tick 即补发,不会卡死。
|
||||
- 打标路径:assign/unassign、AlignCustomerNodes、Suspend/ResumeCustomerResources、凭据 regenerate(原逐节点立即重建改打标)。
|
||||
- 保持即时并清标:手动 `POST /api/nodes/:id/deploy`(约束①)、RepairNode、节点 PATCH 编辑、对账路径;RemoveNode 清标(约束②)——flusher 对 PendingDelete/removing/draft 节点的标直接丢弃,绝不复活;flush 失败写 node.Error+审计 `rebuild_flush_failed` 并继续其他节点。
|
||||
- 测试:`rebuild_coalesce_test.go` 五项全过——连加 5 客户窗口内 0 次 apply、到期只 flush 1 次且资源含齐 5 人;固定窗口不滑动;窗口内删节点 flush 0 次且无 apply;手动部署立即 1 次并消费待发标;Store 重载后标仍在且可 flush。
|
||||
|
||||
## 横向验收
|
||||
|
||||
- **全量测试**:`panel-go` 与 `agent-go` 各 `go test ./...` 全绿(含第 18 条 `listen_hotfix_test`、本批新增 socksfixed/rebuild_coalesce/realitykeys/realityscan/clone_equivalence/singboxversion 等)。
|
||||
- **金标准**:`golden/golden.mjs verify` 仅有 1 处 diff——`lists.customers.body.customers[0].subscriptionClientCount 3 → 0`。已判定为**种子时间漂移、非本批回归**:用 `aa2ee62` 旧二进制复跑得到逐字相同的 diff(种子内订阅客户端的活跃时间窗已随墙钟过期,过期后恒为 0)。其余 31 个 section 逐字一致(含订阅全格式、部署任务、用量投影)。因此**未 recapture 基线**:该 diff 与本批行为变更无关,贸然 recapture 会把环境漂移写进基线。
|
||||
- **真实 sing-box check**:冒烟中用改动后面板实际生成的两份节点资源(SOCKS5 固定账号节点+VLESS Reality 节点),按 agent-go `combinedSingBoxConfig` 同规则组装合并配置、keyMaterial 替换 `${REALITY_PRIVATE:...}` 占位后,跑沙箱自编 sing-box v1.13.21 `check` —— **通过**(入站:socks 听 0.0.0.0、users 恰 1 组;vless 听 0.0.0.0、Reality 私钥就位)。
|
||||
- **沙箱真启冒烟**(新二进制、空库引导):登录成功;overview/servers/customers/nodes/jobs/system/protocols/session 全部 200;建服务器→令牌注册 agent 成功;零客户 socks 节点建单成功且固定账密自动生成(`ngs_` 前缀)、部署不被拦(202)、agent 侧 job 资源 inbounds users 恰固定一组;零客户 VLESS Reality 部署不被拦、资源完整;绑定客户后订阅 raw 链接为 `socks5://<固定用户名>:<固定密码>@…`(密码 URL 编码);reality-keys 接口对 Reality 节点返回完整公私钥对、非 Reality 节点 400;扫描接口对 www.microsoft.com 返回结构完整的结果行(沙箱直连出网被代理拦截,ok=false 且 reason 为握手失败——见缺口),对本地 TLS 1.3 服务器返回 ok=true、tlsVersion=TLS 1.3、keyExchange=X25519、keyExchangeOk=true、certName 与延迟齐全。冒烟全项 PASS。
|
||||
|
||||
## 发布包
|
||||
|
||||
`~/workspace/NexusGate-release/amo-v3/`(CGO_ENABLED=0、-trimpath;面板构建前经 build.sh 刷新 embed_public):
|
||||
|
||||
| 文件 | SHA256 |
|
||||
|---|---|
|
||||
| nexusgate-panel-linux-amd64 | `5698b7f5861078e8244c4d558d5191408b32590fd3b699446f1b564de9d6fcd9` |
|
||||
| nexusgate-panel-linux-arm64 | `57e7b5cd43fd0582bb0aa917a36734483fac3f46d6e9d61ce141d46473a4ff4b` |
|
||||
| nexusgate-agent-linux-amd64 | `b0aedfdcafa5cde7051f6cce6eb82eea709628f5e060688828ab8d107ed9df92` |
|
||||
| nexusgate-agent-linux-arm64 | `e8173e6712769184e87efd6c796129c12f3daf0c0127bfc629d758f412d8afa6` |
|
||||
|
||||
同目录附 `SHA256SUMS` 清单。
|
||||
|
||||
## 未决缺口(如实)
|
||||
|
||||
1. **稳态 CPU 未达个位数**:32.31%(旧 38.41%),主因是 agent 鉴权 scrypt 而非克隆,详见第 22 条节内归因;需另立项改鉴权,本批未动。
|
||||
2. **扫描器真实域名成功路径未在沙箱验到**:沙箱直连 TCP 出网被运行环境代理拦截(curl 走代理正常、直连 TLS 全部 WRONG_VERSION 类失败,python 直连复测同结果),故 www.microsoft.com 只验到结构完整的失败行;成功路径已由本地 TLS 1.3 服务器经面板接口实测通过,真实域名待部署后在路由器网络复测。
|
||||
3. **真机数字待复测**:接口耗时、切页体感、RSS 均为沙箱 amd64 数值;路由器(arm64 A53)部署后需按第 22 条验收口径用 devtools 复测。
|
||||
4. **取舍记录**:`PublicNode` 未剥离 socksUser/socksPassword(管理员接口内,编辑表单回显需要);扫描器探测用 InsecureSkipVerify(只判可达性/密钥交换,理由见第 16 条)。
|
||||
5. **未部署、未推 Gitea**:`aa2ee62` 热修与本批提交均只在本地;现网四台 sing-box 版本记录补写、节点重新部署等部署动作不在本批范围。
|
||||
|
||||
## 提交清单(aa2ee62 之后)
|
||||
|
||||
| 提交 | 内容 |
|
||||
|---|---|
|
||||
| `bb974e2` | 第 12 条 agent 核心版本兜底 |
|
||||
| `2fafd85` | 第 20+21 条 先建后绑+SOCKS5 固定账号 |
|
||||
| `80140f3` | 第 23 条 绑定合并下发(固定窗口) |
|
||||
| `8189a51` | 第 15 条 凭据页签删绑定区块 |
|
||||
| `a9d9867`+`4199a43` | 第 17 条 节点页 Reality 公私钥(+测试辅助改名) |
|
||||
| `0ab31ae` | 第 16 条 Reality 目标扫描器 |
|
||||
| `05366d9`+`6d97d4e` | 第 22 条 后端克隆消除+前端缓存/渐进渲染 |
|
||||
| `0f74955` | 第 22 条 基准种子扩容到 20 台规模 |
|
||||
| (收尾提交) | NEXT-ROUND-NOTES.md 原文(未改一字)+本报告 |
|
||||
@@ -0,0 +1,44 @@
|
||||
# amo v4 批报告(第 24、25 条)
|
||||
|
||||
- 基线:`18074da`(开工核对一致)
|
||||
- 提交:`408b03e`(第 25 条)、`1e7ccd3`(第 24 条),文档收尾提交随本报告
|
||||
- 范围口径:第 0 条 Go 单一真源——只改 `panel-go/`、`agent-go/`、共用前端 `public/`,Node 零改动
|
||||
- 状态:**第 25 条完成、第 24 条完成**(完成状态只在此报告标注,NEXT-ROUND-NOTES 原文未删改,随本批一并提交)
|
||||
- 未部署、未推 Gitea
|
||||
|
||||
## 第 25 条 agent 鉴权令牌化(提交 408b03e)
|
||||
|
||||
### 设计
|
||||
|
||||
- **签发**:agent 请求先按原路径鉴权——指纹(KeyFingerprint)定位记录后 `VerifySecret` 跑 scrypt——通过后才签发会话令牌。令牌为 `RandomToken(40)`,与 agent 密钥同一强度;每次密钥路径鉴权成功都会**轮换**签发新令牌(旧令牌即刻失效)。
|
||||
- **存储(面板)**:agent 记录只存令牌的 SHA-256 哈希(`SessionTokenHash`,复用 `TokenFingerprint`)与过期时间(`SessionTokenExpiresAt`),明文不落库。两字段 omitempty,旧数据文件零迁移。
|
||||
- **有效期**:**24 小时**。经响应头 `X-Agent-Session-Token` / `X-Agent-Session-Token-Expires` 下发。
|
||||
- **校验**:后续请求带令牌作 bearer,面板走 `agentBySessionToken()`——`store.View` 内扫描、hex 解码后 `subtle.ConstantTimeCompare` 常时比对、校验记录 active 与过期时间。不再跑 scrypt。
|
||||
- **回退**:令牌过期/哈希不符/记录吊销 → 该请求自动回退密钥路径(scrypt),成功后重签新令牌。agent 侧:收到 401 且本次用的是令牌时,清掉本地令牌、以密钥重试恰好一次(防循环);面板未签发(旧面板)时不写令牌文件、逐请求带密钥,行为与旧版一致。
|
||||
- **吊销语义**:`(*Agent).Revoke()` 统一收口——置 revoked 并清空令牌两字段。三处调用:重新 enroll 时吊销同服务器旧 agent、`delete_server`、`force_forget_server`。全仓 `"revoked"` 字面量只剩 Revoke 内部一处,不存在遗漏的失效路径(agent 密钥本身从不在原记录上重置,重置只经重新 enroll 建新记录 + 吊销旧记录)。
|
||||
- **存储(agent)**:`session-token.json` 放在既有状态目录(默认 `/etc/nexusgate/agent`,可经 `NG_SESSION_TOKEN_FILE` 覆盖),权限 0600、临时文件 + rename 原子落盘,与 keys.json 同规格。读取时带 1 分钟过期余量,过期直接用密钥。
|
||||
- **强度不降级**:签发必须在 scrypt 验证通过之后;库里始终只有哈希(密钥哈希 + 令牌哈希);令牌绑定单条 agent 记录。
|
||||
- **兼容**:旧 agent 不带令牌 → 继续逐请求 scrypt,功能不受影响;新 agent 连旧面板 → 无签发头,自动回退。agent 版本号保持 `0.6.8`。线格式不变,差异已登记 `agent-go/PARITY.md` 第 0 条。
|
||||
|
||||
### 改动点
|
||||
|
||||
面板(panel-go):
|
||||
- `types.go`:`Agent` 新增 `SessionTokenHash` / `SessionTokenExpiresAt`(omitempty);新增 `(*Agent).Revoke()`。
|
||||
- `handlers.go`:`AgentSessionTokenTTL = 24h`;`agentBySessionToken()`、`issueAgentSessionToken()`(Transaction 落盘、记录已非 active 则不签发)、`authenticateAgent()`(令牌路径 → 密钥路径;候选记录先从 View 拷出、scrypt 在锁外执行、成功后签发);`FindAgentByBearer` 保留为兼容 wrapper;handleAgent 在签发时设置两响应头;三处吊销改走 `Revoke()`。
|
||||
- 新增 `agenttoken_test.go`:7 例(签发且只存哈希、篡改 KeyHash 后令牌仍可鉴权即令牌路径不跑 scrypt、过期被拒 + 密钥重签轮换、哈希不符被拒 + 重签、吊销后令牌与密钥双拒、令牌绑定本记录、纯密钥请求逐次轮换可用)。
|
||||
|
||||
agent(agent-go):
|
||||
- `main.go`:`SessionTokenFile` 配置(env `NG_SESSION_TOKEN_FILE`);`agentSessionToken` + 懒加载状态(mutex);`currentSessionToken()`(1 分钟余量)、`storeSessionToken()`(0600 + rename)、`clearSessionToken()`;`request()` 拆为 `requestAttempt`(bearer 优先令牌、每响应检查签发头并落盘、401 用令牌时清令牌以密钥重试一次)。
|
||||
- 新增 `sessiontoken_test.go`:5 例(fakePanel:捕获/落盘 0600/冷启重载/复用、401 回退 + 重签、旧面板无头时纯密钥、客户端已过期直接用密钥、令牌与密钥双拒时报错且恰好两次尝试)。
|
||||
|
||||
### 验收 ①:稳态 CPU(按 v3 同一测法复测)
|
||||
|
||||
测法:20 服务器种子(20 台 enrolled agent、40 节点、40 客户、约 160 绑定,数据文件 196KB,与现网 225KB 同量级);令牌感知驱动按现网节奏模拟 20 台 agent(poll 8s / heartbeat 30s / usage 60s、空 samples、错峰),无界面请求;预热 15s 后取 60s 窗口,按 `/proc/<pid>/stat` 的 utime+stime 差折算(100 ticks = 1 秒 CPU),同一台架、同一脚本、面板进程为脚本直系子进程采样。
|
||||
|
||||
| 二进制 | 稳态 CPU(单核 %) | 驱动侧证据 |
|
||||
|---|---|---|
|
||||
| 旧 `18074da`(本机同台架实测) | **28.98%** | 未签发令牌;请求延迟 74–254ms |
|
||||
| 新 v4(HEAD) | **1.52%** | 恰好签发 20 个令牌、20/20 agent 转令牌路径;请求延迟 5–8ms;失败 0 |
|
||||
|
||||
- v3 报告的旧版数字为 32.31%(其台架种子构成略有差异),本批同台架复跑为 28.98%,量级一致;新版 **1
|
||||
...[truncated 6135 chars]
|
||||
@@ -0,0 +1,77 @@
|
||||
# amo 下一版(v2)九项改造报告
|
||||
|
||||
本地提交于 `156a31c` 之上,共 7 个提交(基线冻结 + 6 个功能提交)。**未 push、未部署、未碰路由器**;发布包已备好(见 §5),换装等发话。
|
||||
|
||||
| 提交 | 内容 |
|
||||
|---|---|
|
||||
| `d576219` | Go 金标准基线冻结(见 §3) |
|
||||
| `80f1370` | 第 7 项 端口随机只掷一次 |
|
||||
| `161a5d7` | UI 批次:第 3/5/6/9 项 |
|
||||
| `6e82d0d` | 第 1 项 客户页统计条 |
|
||||
| `bb8a0cb` | 第 4 项 核心版本探测 |
|
||||
| `ad18e16` | 第 2 项 客户级凭据 |
|
||||
| `18048f3` | 第 8 项 手动落地端点 |
|
||||
|
||||
## 1. 逐项状态与验证证据
|
||||
|
||||
**第 1 项 客户页统计条 — 完成。**
|
||||
服务端 `CustomerSummary` 随 GET /api/customers 返回(total/online/depleted/depletingSoon/disabled/enabled),前端渲染六格统计条。口径逐条对齐 3x-ui 源码(`client_paging.go`):耗尽 =(限额>0∧已用≥限额)∨已到期,不论启停;即将耗尽 = 启用中∧未耗尽∧[(距到期<expireDiff 天)∨(剩余<trafficDiff GB)]。**偏离说明**:这两个阈值是 3x-ui 的面板设置,出厂默认均为 **0(恒不触发)**,本版原样落为 amo Settings 的 `expireDiff`/`trafficDiff` 字段(默认 0),没有自创"90%"口径——未改设置前"即将耗尽"恒为 0,这是与 3x-ui 一致的行为。证据:`summary_test.go` 覆盖每桶及阈值开关两态(设 3 天/1GB 后 soon=2);真机脚本核对种子库 summary={total 2, enabled 2, 其余 0} 与库一致。
|
||||
|
||||
**第 2 项 客户凭据收归客户级 — 完成。**
|
||||
一套凭据(UUID + 32 字节根密钥 + ShortID)挂在客户记录上,全节点共用;绑定不再存凭据。SS2022 的 user key 按方法密钥长度对根密钥做**确定性前缀截断派生**(16/24/32 字节),legacy SS 与其他协议用原值——一份密码即可服务全部协议,与 3x-ui 的单客户单凭据模型一致。Reality 公钥仍是节点属性,凭据页签按绑定逐行显示。重生成改为整套轮换并重建该客户全部已部署节点。**API 形状变更**:GET 凭据返回 `{credentials, bindings}`;重生成路由改为 `POST /api/customers/:id/credentials/regenerate`,旧的 `.../credentials/:ncid/regenerate` 已移除(前端同步改完,无其他调用方)。**存量迁移**(store 加载时执行、幂等):取第一个带凭据的绑定收敛;收敛后密码不是 32 字节根密钥时**仅重新生成密码**(UUID/ShortID 保留,老链接除 SS 密码外不变);绑定凭据全部清空、随下次保存落盘消失。证据:`credentials_test.go`(收敛优先序/仅换密码/幂等/派生用例);真机核对——种子客户 Alice 的 N1 链接与改前期望逐字节相同,N2 的 user key 恰为新根密钥前 16 字节派生值;重生成真机验证(部署载荷与订阅同步换成新 UUID、旧 UUID 消失);订阅 golden 夹具在单套凭据下重建并逐条人工审计(RF/SS 两条逐字节不变,余五条按预期变化)。
|
||||
|
||||
**第 3 项 内容区间距 — 完成。** `.content` 改为全宽 + `padding:24px`,≤760px 为 8px,与 3x-ui page-shell 一致,一处改动全站生效。
|
||||
|
||||
**第 4 项 核心版本探测 — 完成。** agent-go 在引擎二进制 mtime+size 变化时(启动首轮/换装)才跑一次 `sing-box version` 并缓存,心跳 engine 报告新增 `singBoxVersion`(短字符串);面板落库、服务器页核心版本列显示,老 agent 无字段显示"—",可与新面板混跑(金标准剧本用老式心跳 + 新式心跳各验一轮)。**agent 版本号保持 0.6.8 未动**(源码常量已核)。证据:`singboxversion_test.go`(解析/缓存命中/换文件重探/二进制缺失)。
|
||||
|
||||
**第 5 项 删除延迟 — 完成。** 服务器页延迟列与统计条"平均延迟"格删除(统计网格 4→3 列);系统状态页经查无延迟占位;后端本无 latency 字段。真机断言:服务中的 app.js 全文无"平均延迟"。
|
||||
|
||||
**第 6 项 图标悬停提示 — 完成。** 自研气泡(黑底白字圆角 + 尖角,挂 body 避免表格滚动裁剪),全部 icon-btn 的 title 换为 data-tip 并统一命名(二维码/详情/重置流量/编辑/删除/复制/重新生成凭据等);非图标元素的原生 title(速度数值、完整地址)保留。
|
||||
|
||||
**第 7 项 端口只掷一次 — 完成。** 根因:NormalizeNode 以"键存在"判定重选,而前端编辑恒提交 port:null。新语义:已分配端口仅在显式改地址时才动(切 random 模式重掷 / fixed 给不同值且校验占用 / 迁服务器且目标端口被占)。证据:`portlock_test.go` 6 用例;金标准冻结了 UI 式全量 PATCH 场景;真机连存两次:端口 34035→34035→34035、出口端口同步不变。
|
||||
|
||||
**第 8 项 手动落地端点 — 完成。** 节点出口二选一:受管设备(现状)/ 手动端点(地址 + 端口 + VLESS/SS2022/SOCKS5 + 对应凭据,第三方节点同样可用);面板只在入口侧生成 outbound + route、不下发出口资源、节点恒 exitReady。节点表单有粘贴 vless:// / ss:// / socks5:// 链接自动填表(SS 密码取 method 之后整段,即 serverKey:userKey 组合,与出站发送形态一致)。**两处规格外但必要的决定**:① 手动端口恒为显式值(远端服务的端口不可掷、也不查面板端口表),表单同此语义;② SS2022 密码在保存时前置校验 base64 密钥形态与长度(单段 PSK 或双段组合)——这是真 sing-box 对生成配置跑 `check` 时抓到的(非法形态会让整份配置被 sing-box 拒绝),与其部署时整节点失败,不如保存时给明确报错。**验收中另修一处真 bug**:DeployNode 的部署校验对无出口服务器的手动节点解引用了 nil(handler panic、连接断开),已修并加回归测试。证据:`manualexit_test.go`(校验/出站生成/不排出口 job/防 panic);金标准新增 5 个 section(恰好 1 个入口 job、出站指向 203.0.113.9、用户为客户级凭据、节点转 active;既有 27 个 section 零变化);生成配置经真 sing-box 1.13.21 `check` 通过。
|
||||
|
||||
**第 9 项 删除顶栏头像 — 完成。** span#avatar、app.js 赋值、.avatar CSS 三处全清;同排"刷新"按钮未动(真机断言仍在)。
|
||||
|
||||
## 2. 总验收
|
||||
|
||||
- 全量测试:panel-go `go test ./...` 全过、agent-go 全过、`go vet` 干净。
|
||||
- 金标准:32 个 section 的全链路剧本(登录/心跳/订阅全格式/部署载荷/用量与 presence/凭据/手动端点/备份/错误用例)对最终二进制连续 verify 通过。
|
||||
- 真机(最终 Go 面板 + 种子库)逐项核对:统计条对账、端口连存两次、凭据轮换全链路、核心版本全链路与老心跳兼容、手动端点 sing-box 配置 check、服务资源静态断言 13 条全过。
|
||||
- **内存**:同种子、同预热流量、无 GOMEMLIMIT 的 A/B 实测——基线(156a31c)VmRSS 39.1 MB,最终版 39.1 MB,**零增长**。(UI 改版报告的 43.2MB 是另一台机器/另一负载下的数字,本次以受控 A/B 差值为口径。)
|
||||
- **截图未产出**:本沙箱无浏览器二进制,Puppeteer 的 Chromium 下载源不可达(90 秒超时、无落地文件),`~/workspace/nexusgate-v2-shots/` 仍为空。前端验收改以"服务中真实 HTML/JS/CSS 断言 + API 全链路"完成;如需截图,需在有浏览器的环境补跑一轮。
|
||||
|
||||
## 3. Node 退役与基线冻结(用户 2026-10-21 04:36 决定)
|
||||
|
||||
Node 实现自本版起冻结退役,**Go 为单一真源**:八项(+第 9 项)只改了 panel-go、agent-go 与共用的 public/ 前端;server.js / lib/*.js / agent.js 未再同步,Node 测试不再作为门槛,difftest 差分退役。防行为漂移改用 Go 侧金标准:动手改模型前,先以 difftest 的确定性种子与剧本把 Go 现状行为冻结为 `panel-go/golden/`(golden.mjs + golden.json,27 个 section),此后每项改动只允许在对应提交中、经人工逐条审计后重新抓取基线(端口、summary 键、engine 字段、凭据形状、手动端点各一轮,提交说明均已列明受影响 section)。归一化器为此补了两条规则:base64 根密钥的前缀截断编码(第 2 项派生密钥)与 Surge 的 `=`→`_` 变体视为同一密钥。
|
||||
|
||||
## 4. 其他说明
|
||||
|
||||
- `LINK-MIGRATION.md`(非本任务产物)在第 2 项提交 `ad18e16` 中被 `git add -A` 一并扫入,内容未作任何改动;如需移出版本库请示下。
|
||||
- NEXT-ROUND-NOTES.md 已随基线提交入库(`d576219`)。
|
||||
|
||||
## 5. 发布准备包(未部署)
|
||||
|
||||
`~/workspace/NexusGate-release/amo-panel-v2/`:
|
||||
|
||||
| 文件 | 大小 | sha256 |
|
||||
|---|---|---|
|
||||
| nexusgate-panel-linux-arm64 | 10,211,310 B | 85bc78f19f7833eea19fcbfda70b5913f1aa9c68f301dffbf54f49277cce8d5e(含第 10–13 项,见 §6) |
|
||||
| nexusgate-agent-linux-amd64 | 6,332,600 B | 4b641d2e46eedd6177166cf6a69b15d083959ca15f22d6b023c0387973265818 |
|
||||
| nexusgate-agent-linux-arm64 | 5,963,960 B | 0e1ba1b0711b394944501b107381775d296642cf7b2f013825230a1288cb52d3 |
|
||||
| SHA256SUMS | — | 包内文件,换装前先验哈希 |
|
||||
|
||||
## 6. 附录:第 10–13 项(2026-10-21 追加,提交 `6d12e54`)
|
||||
|
||||
**第 10 项 删除顶栏全局搜索框 — 完成。** index.html 的 disabled 摆设输入框连同 .top-search 样式与移动端媒体查询引用一并删除;各页自有搜索框不受影响。
|
||||
|
||||
**第 11 项 菜单与命名 + 页面合并 — 完成。** 左侧菜单与页面标题/面包屑同步改名:服务器→「节点管理」、客户与订阅→「用户订阅」、转发与节点→「入站&出站」、设置与运维→「设置」;入站&出站页「+ 新建节点」→「+ 添加入站」、计数徽标「N 个节点」→「N 个入站」,系统页异常提示里的旧页名引用同步更新。「安装与命令」页全部内容(命令速查三组)合并进设置页(位于设置网格与任务队列之间),原页从菜单、PAGES、TITLES、load() 分支、视图映射中摘除;直访旧 `#commands` 按 SPA 既有回退规则落到系统状态页。内部页面键(servers/chains/operations)未动,零后端影响。
|
||||
|
||||
**第 12 项 删除系统页「最近活动」 — 完成。** 面板渲染、其专属的审计动作译名表与 .activity-list/.event-dot 样式一并摘除;后端活动/审计记录保留不动(概览接口的 activity 字段仍照常返回,前端不再呈现)。
|
||||
|
||||
**第 13 项 任务队列表格紧凑 — 完成。** 表格挂 w-jobs 类:ID/状态/时间等单元格 nowrap 单行、行高收紧(5px 纵 padding);错误列宽固定 22% 且可换行,空错误时不再撑大。
|
||||
|
||||
**顺带修复(上一批次隐患)**:第 8 项出口模式分段控件的 `.seg` 全局样式与系统页 info-bar 的同名 class 冲突(会给信息条分段加边框盒),控件改名 `.segctl` 隔离,info-bar 恢复原样。
|
||||
|
||||
验证:panel-go 全测试过;金标准仅 errors.badSubscription(坏订阅回退的首页 HTML)一处变化,已逐字符审计确认恰为四处改名 + 命令菜单删除 + 搜索框删除,重抓后 verify 通过;服务中真实 HTML/JS/CSS 静态断言 15 条全过(新名、合并、紧凑类、旧名消失)。发布包面板二进制已按新 HEAD 重建(大小不变属段对齐巧合,已验二进制内含新前端、无 top-search/最近活动残留),双架构 agent 未改动、哈希不变。
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 架构
|
||||
|
||||
- **主控**:Node.js 面板(约 61MB),跑在 OpenWrt 路由器或任意 Linux 上。管理节点、客户、订阅,下发配置到各 Agent。
|
||||
- **主控**:Go 静态二进制面板(约 13-30MB),跑在 OpenWrt 路由器或任意 Linux 上,无运行时依赖。管理节点、客户、订阅,下发配置到各 Agent。
|
||||
- **Agent**:跑在节点机上,通过 token 注册。管理 sing-box 进程,上报心跳和按用户流量。
|
||||
|
||||
## 与原版的区别
|
||||
@@ -13,20 +13,13 @@
|
||||
- 3x-ui 式共享节点:nodes 状态机 + 每个客户独立凭据(`ng:<customerId>` 身份)
|
||||
- 全面 sing-box:不再捆绑 xray
|
||||
- 按用户流量统计:AnyTLS 走日志解析,SS 走节点级
|
||||
- 内存优化:面板 `node --max-old-space-size=128 --optimize-for-size`;Agent 为 Go 静态二进制(约 12MB,节点机无需 Node.js);sing-box `GOMEMLIMIT=64MiB GOGC=50`
|
||||
- 内存优化:主控与 Agent 均为 Go 静态二进制、无 Node.js 依赖(主控约 13-30MB、Agent 约 12MB);主控可加 `GOMEMLIMIT=28MiB GOGC=50` 压内存,sing-box 服务为 `GOMEMLIMIT=64MiB GOGC=50`
|
||||
|
||||
## 安装
|
||||
|
||||
### 主控
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://git.881218.xyz/rogue/NexusGate.git /opt/nexusgate
|
||||
cd /opt/nexusgate
|
||||
|
||||
# 安装依赖并启动
|
||||
node --max-old-space-size=128 --optimize-for-size server.js
|
||||
```
|
||||
主控是 Go 静态二进制 `nexusgate-panel`(panel-go/),无运行时依赖,数据放在 `/etc/nexusgate`。部署与换装步骤见 `panel-go/INSTALL-GO.md`。
|
||||
|
||||
面板地址:`http://IP:8787`,默认账号 `admin`。
|
||||
|
||||
@@ -48,11 +41,11 @@ Agent 是 Go 静态二进制,安装时从 Gitea Release 下载,节点机不
|
||||
|
||||
VLESS / SS2022 / SS经典 / SOCKS5 / Hysteria2 / AnyTLS
|
||||
|
||||
## 内存占用(实测 2026-10-20,三台节点机)
|
||||
## 内存占用(实测 2026-10-21)
|
||||
|
||||
| 组件 | 内存 |
|
||||
|---|---|
|
||||
| 主控面板 | 约 61MB(OpenWrt 路由器) |
|
||||
| 主控面板 | 约 13-30MB(Go 静态二进制,OpenWrt 路由器) |
|
||||
| Agent | 约 12MB(Go 静态二进制) |
|
||||
| sing-box | 16–36MB |
|
||||
|
||||
|
||||
@@ -132,6 +132,14 @@ using sing-box's own protobuf stubs. The Go agent keeps that exact design:
|
||||
|
||||
## Known divergences (all behavior-preserving)
|
||||
|
||||
0. **Panel session tokens (round-4 item 25, protocol superset)**: against a
|
||||
panel that issues them, the Go agent persists the short-lived session
|
||||
token (`session-token.json` in the config dir, 0600) and presents it as
|
||||
the bearer instead of its key, falling back to the key on any 401. The
|
||||
Node agent has no such concept; wire format (a Bearer value) and all
|
||||
endpoints are unchanged, and panels without token support are
|
||||
unaffected. Divergent by design — it exists to take scrypt off the
|
||||
panel's per-request path.
|
||||
1. **JSON key order inside resource files / combined config**: Go reserializes
|
||||
nested objects with alphabetically sorted keys; JS preserves insertion
|
||||
order. Semantically identical for sing-box and for the panel. Side effect:
|
||||
|
||||
@@ -199,6 +199,86 @@ type engineReport struct {
|
||||
SingBoxInstalled bool `json:"singBoxInstalled"`
|
||||
Certificates []string `json:"certificates"`
|
||||
SingBoxStatus string `json:"singBoxStatus"`
|
||||
SingBoxVersion string `json:"singBoxVersion,omitempty"`
|
||||
}
|
||||
|
||||
// ---- sing-box version probe -------------------------------------------------
|
||||
// The core version is probed by running `sing-box version` exactly once and
|
||||
// cached against the binary's mtime+size. It is re-probed only when the file
|
||||
// changes (an engine install/update replaces the binary) — the first probe
|
||||
// happens on the first heartbeat after agent start. Steady-state heartbeats
|
||||
// pay one stat, never a subprocess.
|
||||
|
||||
var singBoxVersionMu sync.Mutex
|
||||
var singBoxVersionCache struct {
|
||||
path string
|
||||
mtimeNs int64
|
||||
size int64
|
||||
vfMtimeNs int64 // install-record file mtime (part of the cache key)
|
||||
version string
|
||||
probed bool
|
||||
}
|
||||
|
||||
// parseSingBoxVersion extracts the version token from the first line of
|
||||
// `sing-box version` output ("sing-box version 1.13.21"). Unstamped local
|
||||
// builds print "unknown"; those report no version.
|
||||
func parseSingBoxVersion(out string) string {
|
||||
line := out
|
||||
if i := strings.IndexByte(line, '\n'); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 && fields[0] == "sing-box" && fields[1] == "version" && fields[2] != "unknown" {
|
||||
return fields[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var singBoxVersionRe = regexp.MustCompile(`^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$`)
|
||||
|
||||
// installedSingBoxVersion reads the install record written by
|
||||
// agent-singbox.sh (/etc/nexusgate/sing-box/version, content like
|
||||
// "1.13.21"). It is the fallback for the locally built sing-box, whose
|
||||
// binary carries no version stamp and reports "unknown" (round-3 item
|
||||
// 12). A leading "v" is tolerated and stripped; anything that does not
|
||||
// look like a version is ignored.
|
||||
func installedSingBoxVersion() string {
|
||||
data, err := os.ReadFile(cfg.SingBoxVersionFile)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
v := strings.TrimSpace(string(data))
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
if !singBoxVersionRe.MatchString(v) {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func singBoxVersion() string {
|
||||
info, err := os.Stat(cfg.SingBoxBin)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var vfMtimeNs int64 = -1
|
||||
if vfInfo, err := os.Stat(cfg.SingBoxVersionFile); err == nil {
|
||||
vfMtimeNs = vfInfo.ModTime().UnixNano()
|
||||
}
|
||||
singBoxVersionMu.Lock()
|
||||
defer singBoxVersionMu.Unlock()
|
||||
c := &singBoxVersionCache
|
||||
if c.probed && c.path == cfg.SingBoxBin && c.mtimeNs == info.ModTime().UnixNano() && c.size == info.Size() && c.vfMtimeNs == vfMtimeNs {
|
||||
return c.version
|
||||
}
|
||||
version := ""
|
||||
if out, _, code := runCapture(5*time.Second, cfg.SingBoxBin, "version"); code == 0 {
|
||||
version = parseSingBoxVersion(out)
|
||||
}
|
||||
if version == "" {
|
||||
version = installedSingBoxVersion()
|
||||
}
|
||||
c.path, c.mtimeNs, c.size, c.vfMtimeNs, c.version, c.probed = cfg.SingBoxBin, info.ModTime().UnixNano(), info.Size(), vfMtimeNs, version, true
|
||||
return version
|
||||
}
|
||||
|
||||
// engineHealth mirrors agent.js engineHealth(). Callers must hold stateMu
|
||||
@@ -214,6 +294,7 @@ func engineHealthLocked(resources []map[string]interface{}) engineReport {
|
||||
if installed {
|
||||
result.Status = service.Status
|
||||
result.Detail = service.Detail
|
||||
result.SingBoxVersion = singBoxVersion()
|
||||
} else {
|
||||
result.Status = "error"
|
||||
result.Detail = "sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install"
|
||||
|
||||
+152
-28
@@ -28,19 +28,21 @@ import (
|
||||
const version = "0.6.8"
|
||||
|
||||
type config struct {
|
||||
Controller string
|
||||
AgentKey string
|
||||
SingBoxBin string
|
||||
SingBoxStatsBin string
|
||||
Root string // NG_CONFIG_DIR
|
||||
ResourceDir string // Root + "/resources"
|
||||
SingBoxConfig string
|
||||
SingBoxLog string
|
||||
SingBoxCursor string
|
||||
KeyFile string
|
||||
TLSDir string
|
||||
APIPort string // NG_SINGBOX_API_PORT (string, as in JS)
|
||||
PollMS int64
|
||||
Controller string
|
||||
AgentKey string
|
||||
SingBoxBin string
|
||||
SingBoxStatsBin string
|
||||
Root string // NG_CONFIG_DIR
|
||||
ResourceDir string // Root + "/resources"
|
||||
SingBoxConfig string
|
||||
SingBoxVersionFile string
|
||||
SingBoxLog string
|
||||
SingBoxCursor string
|
||||
KeyFile string
|
||||
SessionTokenFile string
|
||||
TLSDir string
|
||||
APIPort string // NG_SINGBOX_API_PORT (string, as in JS)
|
||||
PollMS int64
|
||||
}
|
||||
|
||||
func envOr(name, def string) string {
|
||||
@@ -67,19 +69,21 @@ func loadConfig() config {
|
||||
pollSec = 3
|
||||
}
|
||||
return config{
|
||||
Controller: strings.TrimRight(os.Getenv("NG_CONTROLLER"), "/"),
|
||||
AgentKey: os.Getenv("NG_AGENT_KEY"),
|
||||
SingBoxBin: envOr("NG_SINGBOX_BIN", "/usr/local/bin/nexusgate-sing-box"),
|
||||
SingBoxStatsBin: envOr("NG_SINGBOX_STATS_BIN", "/usr/local/bin/nexusgate-sing-box-stats"),
|
||||
Root: root,
|
||||
ResourceDir: root + "/resources",
|
||||
SingBoxConfig: envOr("NG_SINGBOX_CONFIG", "/etc/nexusgate/sing-box/config.json"),
|
||||
SingBoxLog: envOr("NG_SINGBOX_LOG", "/var/log/nexusgate/sing-box-access.log"),
|
||||
SingBoxCursor: envOr("NG_SINGBOX_CURSOR", "/etc/nexusgate/sing-box-cursor.json"),
|
||||
KeyFile: envOr("NG_KEY_FILE", "/etc/nexusgate/keys.json"),
|
||||
TLSDir: envOr("NG_TLS_DIR", "/etc/nexusgate/tls"),
|
||||
APIPort: envOr("NG_SINGBOX_API_PORT", "10086"),
|
||||
PollMS: int64(pollSec * 1000),
|
||||
Controller: strings.TrimRight(os.Getenv("NG_CONTROLLER"), "/"),
|
||||
AgentKey: os.Getenv("NG_AGENT_KEY"),
|
||||
SingBoxBin: envOr("NG_SINGBOX_BIN", "/usr/local/bin/nexusgate-sing-box"),
|
||||
SingBoxStatsBin: envOr("NG_SINGBOX_STATS_BIN", "/usr/local/bin/nexusgate-sing-box-stats"),
|
||||
Root: root,
|
||||
ResourceDir: root + "/resources",
|
||||
SingBoxConfig: envOr("NG_SINGBOX_CONFIG", "/etc/nexusgate/sing-box/config.json"),
|
||||
SingBoxVersionFile: envOr("NG_SINGBOX_VERSION_FILE", "/etc/nexusgate/sing-box/version"),
|
||||
SingBoxLog: envOr("NG_SINGBOX_LOG", "/var/log/nexusgate/sing-box-access.log"),
|
||||
SingBoxCursor: envOr("NG_SINGBOX_CURSOR", "/etc/nexusgate/sing-box-cursor.json"),
|
||||
KeyFile: envOr("NG_KEY_FILE", "/etc/nexusgate/keys.json"),
|
||||
SessionTokenFile: envOr("NG_SESSION_TOKEN_FILE", root+"/session-token.json"),
|
||||
TLSDir: envOr("NG_TLS_DIR", "/etc/nexusgate/tls"),
|
||||
APIPort: envOr("NG_SINGBOX_API_PORT", "10089"),
|
||||
PollMS: int64(pollSec * 1000),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,25 +153,145 @@ func fileExists(p string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ---------- panel session token (item 25) ----------
|
||||
//
|
||||
// After one scrypt-verified key auth the panel issues a short-lived
|
||||
// session token via the X-Agent-Session-Token response header. The
|
||||
// agent persists it in its state dir (0600, same handling as
|
||||
// keys.json) and presents it as the bearer on subsequent requests;
|
||||
// the panel then authenticates with a fast hash compare instead of
|
||||
// scrypt. Any 401 while presenting a token clears it and retries
|
||||
// once with the agent key, which makes the panel issue a fresh
|
||||
// token. Panels that predate session tokens never send the header,
|
||||
// so the agent simply keeps authenticating with its key — the token
|
||||
// layer is invisible in that direction too. This is a deliberate
|
||||
// divergence from the frozen Node agent (protocol superset, wire
|
||||
// compatible).
|
||||
|
||||
type agentSessionToken struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
var sessionTokenState struct {
|
||||
mu sync.Mutex
|
||||
loaded bool
|
||||
value *agentSessionToken
|
||||
}
|
||||
|
||||
func loadSessionTokenLocked() {
|
||||
sessionTokenState.loaded = true
|
||||
raw, err := os.ReadFile(cfg.SessionTokenFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var tok agentSessionToken
|
||||
if err := json.Unmarshal(raw, &tok); err == nil && tok.Token != "" {
|
||||
v := tok
|
||||
sessionTokenState.value = &v
|
||||
}
|
||||
}
|
||||
|
||||
// currentSessionToken returns the held token, or nil when none is
|
||||
// stored or it is expired (a one-minute margin keeps a request from
|
||||
// riding on a token that dies in flight).
|
||||
func currentSessionToken() *agentSessionToken {
|
||||
sessionTokenState.mu.Lock()
|
||||
defer sessionTokenState.mu.Unlock()
|
||||
if !sessionTokenState.loaded {
|
||||
loadSessionTokenLocked()
|
||||
}
|
||||
if sessionTokenState.value == nil {
|
||||
return nil
|
||||
}
|
||||
if exp, err := time.Parse(time.RFC3339, sessionTokenState.value.ExpiresAt); err == nil && time.Until(exp) < time.Minute {
|
||||
return nil
|
||||
}
|
||||
tok := *sessionTokenState.value
|
||||
return &tok
|
||||
}
|
||||
|
||||
func storeSessionToken(token, expiresAt string) {
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
sessionTokenState.mu.Lock()
|
||||
defer sessionTokenState.mu.Unlock()
|
||||
if !sessionTokenState.loaded {
|
||||
loadSessionTokenLocked()
|
||||
}
|
||||
if sessionTokenState.value != nil && sessionTokenState.value.Token == token {
|
||||
return
|
||||
}
|
||||
next := &agentSessionToken{Token: token, ExpiresAt: expiresAt}
|
||||
sessionTokenState.value = next
|
||||
body, err := json.Marshal(next)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmp := cfg.SessionTokenFile + ".tmp"
|
||||
if err := os.WriteFile(tmp, append(body, '\n'), 0o600); err != nil {
|
||||
logf("Session token persist failed %s", safeError(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp, cfg.SessionTokenFile); err != nil {
|
||||
logf("Session token persist failed %s", safeError(err.Error()))
|
||||
return
|
||||
}
|
||||
_ = os.Chmod(cfg.SessionTokenFile, 0o600)
|
||||
}
|
||||
|
||||
func clearSessionToken() {
|
||||
sessionTokenState.mu.Lock()
|
||||
defer sessionTokenState.mu.Unlock()
|
||||
sessionTokenState.value = nil
|
||||
sessionTokenState.loaded = true
|
||||
if err := os.Remove(cfg.SessionTokenFile); err != nil && !os.IsNotExist(err) {
|
||||
logf("Session token clear failed %s", safeError(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
// request mirrors agent.js request(): POST with bearer auth, 20s timeout,
|
||||
// JSON body best-effort parsed ({} on failure), error message taken from
|
||||
// body.message or synthesized from the HTTP status.
|
||||
// body.message or synthesized from the HTTP status. Bearer selection and
|
||||
// the 401 retry follow the session-token rules above.
|
||||
func request(endpoint string, body []byte) (map[string]interface{}, error) {
|
||||
return requestAttempt(endpoint, body, true)
|
||||
}
|
||||
|
||||
func requestAttempt(endpoint string, body []byte, mayRetry bool) (map[string]interface{}, error) {
|
||||
bearer := cfg.AgentKey
|
||||
usedToken := false
|
||||
if tok := currentSessionToken(); tok != nil {
|
||||
bearer = tok.Token
|
||||
usedToken = true
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, cfg.Controller+endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.AgentKey)
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// A freshly issued token can ride on any authenticated response.
|
||||
if issued := resp.Header.Get("X-Agent-Session-Token"); issued != "" {
|
||||
storeSessionToken(issued, resp.Header.Get("X-Agent-Session-Token-Expires"))
|
||||
}
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode == 401 && usedToken && mayRetry {
|
||||
// Token rejected (expired or revoked server-side): drop it
|
||||
// and retry once with the key; a valid key makes the panel
|
||||
// issue a fresh token on that response.
|
||||
clearSessionToken()
|
||||
return requestAttempt(endpoint, body, false)
|
||||
}
|
||||
parsed := map[string]interface{}{}
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &parsed) // .catch(() => ({})) in JS
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
// Item 25: agent-side session token handling. A fake panel (httptest)
|
||||
// issues tokens via response headers exactly like the real one; the
|
||||
// tests pin down capture, persistence, reuse, the 401 fallback to the
|
||||
// key, and old-panel behavior (no headers → key on every request).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakePanel records the bearer of every request and answers like the
|
||||
// real panel: the key always authenticates (and issues issueToken
|
||||
// when non-empty), the current valid token authenticates, anything
|
||||
// else is a 401.
|
||||
type fakePanel struct {
|
||||
key string
|
||||
validToken string
|
||||
issueToken string
|
||||
bearers []string
|
||||
}
|
||||
|
||||
func (p *fakePanel) handler(w http.ResponseWriter, r *http.Request) {
|
||||
bearer := ""
|
||||
if h := r.Header.Get("Authorization"); len(h) > len("Bearer ") {
|
||||
bearer = h[len("Bearer "):]
|
||||
}
|
||||
p.bearers = append(p.bearers, bearer)
|
||||
switch bearer {
|
||||
case p.key:
|
||||
if p.issueToken != "" {
|
||||
w.Header().Set("X-Agent-Session-Token", p.issueToken)
|
||||
w.Header().Set("X-Agent-Session-Token-Expires", time.Now().Add(24*time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprint(w, `{"ok":true}`)
|
||||
case p.validToken:
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprint(w, `{"ok":true}`)
|
||||
default:
|
||||
w.WriteHeader(401)
|
||||
fmt.Fprint(w, `{"message":"Agent 身份校验失败"}`)
|
||||
}
|
||||
}
|
||||
|
||||
func setupTokenAgent(t *testing.T, panel *fakePanel) func() {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(panel.handler))
|
||||
oldCfg := cfg
|
||||
cfg.Controller = srv.URL
|
||||
cfg.AgentKey = panel.key
|
||||
cfg.SessionTokenFile = filepath.Join(t.TempDir(), "session-token.json")
|
||||
sessionTokenState.mu.Lock()
|
||||
oldState := sessionTokenState.value
|
||||
oldLoaded := sessionTokenState.loaded
|
||||
sessionTokenState.value = nil
|
||||
sessionTokenState.loaded = false
|
||||
sessionTokenState.mu.Unlock()
|
||||
return func() {
|
||||
srv.Close()
|
||||
cfg = oldCfg
|
||||
sessionTokenState.mu.Lock()
|
||||
sessionTokenState.value = oldState
|
||||
sessionTokenState.loaded = oldLoaded
|
||||
sessionTokenState.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenCapturedPersistedAndReused(t *testing.T) {
|
||||
panel := &fakePanel{key: "the-key", issueToken: "tok-1", validToken: "tok-1"}
|
||||
defer setupTokenAgent(t, panel)()
|
||||
|
||||
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
|
||||
t.Fatalf("first request: %v", err)
|
||||
}
|
||||
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
|
||||
t.Fatalf("second request: %v", err)
|
||||
}
|
||||
want := []string{"the-key", "tok-1"}
|
||||
if fmt.Sprint(panel.bearers) != fmt.Sprint(want) {
|
||||
t.Fatalf("bearers = %v, want %v", panel.bearers, want)
|
||||
}
|
||||
// Persisted with keys.json-grade permissions.
|
||||
info, err := os.Stat(cfg.SessionTokenFile)
|
||||
if err != nil {
|
||||
t.Fatalf("token file not persisted: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("token file perm = %o, want 600", perm)
|
||||
}
|
||||
// And it survives a cold start: reset memory, it must load from disk.
|
||||
sessionTokenState.mu.Lock()
|
||||
sessionTokenState.value = nil
|
||||
sessionTokenState.loaded = false
|
||||
sessionTokenState.mu.Unlock()
|
||||
if tok := currentSessionToken(); tok == nil || tok.Token != "tok-1" {
|
||||
t.Fatalf("token not reloaded from disk: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionToken401FallsBackToKeyAndReissues(t *testing.T) {
|
||||
panel := &fakePanel{key: "the-key", issueToken: "tok-2", validToken: "tok-2"}
|
||||
defer setupTokenAgent(t, panel)()
|
||||
// Plant a stale token the panel no longer honors.
|
||||
storeSessionToken("tok-stale", time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
|
||||
|
||||
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
|
||||
t.Fatalf("request with stale token: %v", err)
|
||||
}
|
||||
want := []string{"tok-stale", "the-key"}
|
||||
if fmt.Sprint(panel.bearers) != fmt.Sprint(want) {
|
||||
t.Fatalf("bearers = %v, want %v (token attempt, then key retry)", panel.bearers, want)
|
||||
}
|
||||
if tok := currentSessionToken(); tok == nil || tok.Token != "tok-2" {
|
||||
t.Fatalf("fresh token not stored after retry: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenAbsentKeepsKeyAuth(t *testing.T) {
|
||||
// Old panel: never issues tokens.
|
||||
panel := &fakePanel{key: "the-key"}
|
||||
defer setupTokenAgent(t, panel)()
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
|
||||
t.Fatalf("request %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(panel.bearers) != "[the-key the-key]" {
|
||||
t.Fatalf("bearers = %v, want key on every request", panel.bearers)
|
||||
}
|
||||
if _, err := os.Stat(cfg.SessionTokenFile); !os.IsNotExist(err) {
|
||||
t.Fatalf("no token file expected against an old panel, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenExpiredClientSideUsesKey(t *testing.T) {
|
||||
panel := &fakePanel{key: "the-key", issueToken: "tok-3", validToken: "tok-3"}
|
||||
defer setupTokenAgent(t, panel)()
|
||||
storeSessionToken("tok-old", time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
|
||||
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
if len(panel.bearers) != 1 || panel.bearers[0] != "the-key" {
|
||||
t.Fatalf("bearers = %v, want the key used directly (no doomed token attempt)", panel.bearers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenKeyRejectedSurfacesError(t *testing.T) {
|
||||
panel := &fakePanel{key: "the-real-key"}
|
||||
defer setupTokenAgent(t, panel)()
|
||||
cfg.AgentKey = "wrong-key"
|
||||
storeSessionToken("tok-stale", time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
|
||||
if _, err := request("/api/agent/poll", []byte("{}")); err == nil {
|
||||
t.Fatal("expected an error when both token and key are rejected")
|
||||
}
|
||||
// Exactly two attempts: token, then key — never a loop.
|
||||
if len(panel.bearers) != 2 {
|
||||
t.Fatalf("attempts = %v, want exactly 2", panel.bearers)
|
||||
}
|
||||
if tok := currentSessionToken(); tok != nil {
|
||||
t.Fatalf("stale token should have been cleared, still held: %+v", tok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
// Tests for the sing-box version probe (round-2 item 4): parsing of
|
||||
// `sing-box version` output and the mtime/size-keyed cache that keeps
|
||||
// heartbeats from spawning the binary every round.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSingBoxVersion(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"sing-box version 1.13.21\n\nEnvironment: go1.24.6 linux/amd64\n", "1.13.21"},
|
||||
{"sing-box version 1.13.21", "1.13.21"},
|
||||
{"sing-box version unknown\n", ""},
|
||||
{"", ""},
|
||||
{"garbage output\n", ""},
|
||||
{"sing-box version\n", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := parseSingBoxVersion(c.in); got != c.want {
|
||||
t.Fatalf("parseSingBoxVersion(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeFakeSingBox(t *testing.T, dir, version string) (binPath, countPath string) {
|
||||
t.Helper()
|
||||
binPath = filepath.Join(dir, "fake-sing-box")
|
||||
countPath = filepath.Join(dir, "count")
|
||||
script := "#!/bin/sh\necho x >> \"" + countPath + "\"\necho 'sing-box version " + version + "'\n"
|
||||
if err := os.WriteFile(binPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return binPath, countPath
|
||||
}
|
||||
|
||||
func TestSingBoxVersionProbeCachesByMtime(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binPath, countPath := writeFakeSingBox(t, dir, "1.2.3")
|
||||
|
||||
oldBin := cfg.SingBoxBin
|
||||
cfg.SingBoxBin = binPath
|
||||
defer func() { cfg.SingBoxBin = oldBin }()
|
||||
singBoxVersionCache.probed = false
|
||||
defer func() { singBoxVersionCache.probed = false }()
|
||||
|
||||
count := func() int {
|
||||
data, _ := os.ReadFile(countPath)
|
||||
return strings.Count(string(data), "x")
|
||||
}
|
||||
if got := singBoxVersion(); got != "1.2.3" {
|
||||
t.Fatalf("first probe = %q, want 1.2.3", got)
|
||||
}
|
||||
if got := singBoxVersion(); got != "1.2.3" {
|
||||
t.Fatalf("cached probe = %q, want 1.2.3", got)
|
||||
}
|
||||
if n := count(); n != 1 {
|
||||
t.Fatalf("binary executed %d times across two heartbeats, want 1 (cached)", n)
|
||||
}
|
||||
// Replacing the binary (engine install/update) must trigger one re-probe.
|
||||
writeFakeSingBox(t, dir, "1.2.4")
|
||||
if got := singBoxVersion(); got != "1.2.4" {
|
||||
t.Fatalf("probe after binary replacement = %q, want 1.2.4", got)
|
||||
}
|
||||
if n := count(); n != 2 {
|
||||
t.Fatalf("binary executed %d times after replacement, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Round-3 item 12: when the binary reports "unknown" (the locally built
|
||||
// sing-box has no version stamp), the probe falls back to the install
|
||||
// record written by agent-singbox.sh.
|
||||
func TestSingBoxVersionFallsBackToInstallRecord(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binPath, _ := writeFakeSingBox(t, dir, "unknown")
|
||||
recordPath := filepath.Join(dir, "version")
|
||||
if err := os.WriteFile(recordPath, []byte("1.13.21\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldBin, oldRecord := cfg.SingBoxBin, cfg.SingBoxVersionFile
|
||||
cfg.SingBoxBin, cfg.SingBoxVersionFile = binPath, recordPath
|
||||
defer func() { cfg.SingBoxBin, cfg.SingBoxVersionFile = oldBin, oldRecord }()
|
||||
singBoxVersionCache.probed = false
|
||||
defer func() { singBoxVersionCache.probed = false }()
|
||||
|
||||
if got := singBoxVersion(); got != "1.13.21" {
|
||||
t.Fatalf("fallback probe = %q, want 1.13.21", got)
|
||||
}
|
||||
// A stamped binary wins over the record.
|
||||
writeFakeSingBox(t, dir, "1.13.22")
|
||||
if got := singBoxVersion(); got != "1.13.22" {
|
||||
t.Fatalf("stamped probe = %q, want 1.13.22 (record must not shadow it)", got)
|
||||
}
|
||||
// A garbage record is ignored.
|
||||
if err := os.WriteFile(recordPath, []byte("not-a-version\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFakeSingBox(t, dir, "unknown")
|
||||
if got := singBoxVersion(); got != "" {
|
||||
t.Fatalf("garbage record: got %q, want empty", got)
|
||||
}
|
||||
if got := installedSingBoxVersion(); got != "" {
|
||||
t.Fatalf("installedSingBoxVersion(garbage) = %q, want empty", got)
|
||||
}
|
||||
if err := os.WriteFile(recordPath, []byte("v1.13.21\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := installedSingBoxVersion(); got != "1.13.21" {
|
||||
t.Fatalf("installedSingBoxVersion(v-prefixed) = %q, want 1.13.21", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingBoxVersionMissingBinary(t *testing.T) {
|
||||
oldBin := cfg.SingBoxBin
|
||||
cfg.SingBoxBin = filepath.Join(t.TempDir(), "does-not-exist")
|
||||
defer func() { cfg.SingBoxBin = oldBin }()
|
||||
if got := singBoxVersion(); got != "" {
|
||||
t.Fatalf("missing binary: got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
server := flag.String("server", "127.0.0.1:10086", "sing-box statistics API address")
|
||||
server := flag.String("server", "127.0.0.1:10089", "sing-box statistics API address")
|
||||
flag.Parse()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package main
|
||||
|
||||
// Item 25: agent session tokens. After one scrypt-verified key auth the
|
||||
// panel issues a random session token (only its SHA-256 hash is stored);
|
||||
// subsequent requests authenticate by hash compare. These tests drive
|
||||
// the real /api/agent/heartbeat route through the smoke harness.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testAgentKey = "test-agent-key-0123456789abcdef-XYZ"
|
||||
|
||||
func seedAgentApp(t *testing.T, key string) (*App, string) {
|
||||
t.Helper()
|
||||
cookieSecure = false
|
||||
store := NewStore(filepath.Join(t.TempDir(), "nexusgate.json"))
|
||||
if err := store.Init(); err != nil {
|
||||
t.Fatalf("store init: %v", err)
|
||||
}
|
||||
fp := TokenFingerprint(key)
|
||||
agentID := ""
|
||||
_, err := store.Transaction(func(d *Data) (any, error) {
|
||||
d.Servers = append(d.Servers, &Server{
|
||||
ID: "srv_tok", Name: "tok", Status: "online",
|
||||
CreatedAt: NowISO(), UpdatedAt: NowISO(),
|
||||
})
|
||||
agent := &Agent{
|
||||
ID: NewID("agt"),
|
||||
ServerID: "srv_tok",
|
||||
KeyHash: HashSecret(key),
|
||||
KeyFingerprint: &fp,
|
||||
Status: "active",
|
||||
CreatedAt: NowISO(),
|
||||
LastSeenAt: "2000-01-01T00:00:00.000Z",
|
||||
}
|
||||
agentID = agent.ID
|
||||
d.Agents = append(d.Agents, agent)
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
return NewApp(store, NewSessionManager(12), NewOrchestrator(store)), agentID
|
||||
}
|
||||
|
||||
func agentHeartbeat(t *testing.T, app *App, bearer string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return doRequest(app, http.MethodPost, "/api/agent/heartbeat", "{}", map[string]string{
|
||||
"Authorization": "Bearer " + bearer,
|
||||
})
|
||||
}
|
||||
|
||||
func storedAgent(t *testing.T, app *App, agentID string) *Agent {
|
||||
t.Helper()
|
||||
var out *Agent
|
||||
app.store.View(func(d *Data) {
|
||||
for _, agent := range d.Agents {
|
||||
if agent.ID == agentID {
|
||||
copied := *agent
|
||||
out = &copied
|
||||
}
|
||||
}
|
||||
})
|
||||
if out == nil {
|
||||
t.Fatalf("agent %s not in store", agentID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mutateAgent(t *testing.T, app *App, agentID string, fn func(*Agent)) {
|
||||
t.Helper()
|
||||
_, err := app.store.Transaction(func(d *Data) (any, error) {
|
||||
for _, agent := range d.Agents {
|
||||
if agent.ID == agentID {
|
||||
fn(agent)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mutate agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Key auth succeeds and issues a session token whose hash (and only the
|
||||
// hash) lands on the agent record.
|
||||
func TestAgentSessionTokenIssuedOnKeyAuth(t *testing.T) {
|
||||
app, agentID := seedAgentApp(t, testAgentKey)
|
||||
rec := agentHeartbeat(t, app, testAgentKey)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("key heartbeat: got %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
token := rec.Header().Get(agentSessionTokenHeader)
|
||||
expires := rec.Header().Get(agentSessionTokenExpiresHeader)
|
||||
if token == "" || expires == "" {
|
||||
t.Fatalf("no session token issued: token=%q expires=%q", token, expires)
|
||||
}
|
||||
if token == testAgentKey {
|
||||
t.Fatal("session token must differ from the agent key")
|
||||
}
|
||||
stored := storedAgent(t, app, agentID)
|
||||
if stored.SessionTokenHash == nil || *stored.SessionTokenHash != TokenFingerprint(token) {
|
||||
t.Fatalf("stored hash does not match issued token: %+v", stored.SessionTokenHash)
|
||||
}
|
||||
if stored.SessionTokenExpiresAt == nil || *stored.SessionTokenExpiresAt != expires {
|
||||
t.Fatalf("stored expiry %v != header expiry %q", stored.SessionTokenExpiresAt, expires)
|
||||
}
|
||||
expMs, ok := ParseISOMs(expires)
|
||||
if !ok || expMs <= time.Now().UnixMilli() {
|
||||
t.Fatalf("issued expiry not in the future: %q", expires)
|
||||
}
|
||||
}
|
||||
|
||||
// Token auth succeeds even when the key hash can no longer verify —
|
||||
// proving the token path never touches scrypt — and does not re-issue.
|
||||
func TestAgentSessionTokenAuthSkipsScrypt(t *testing.T) {
|
||||
app, agentID := seedAgentApp(t, testAgentKey)
|
||||
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
|
||||
if token == "" {
|
||||
t.Fatal("setup: no token issued")
|
||||
}
|
||||
// Sabotage the key hash: any scrypt verification now fails.
|
||||
mutateAgent(t, app, agentID, func(agent *Agent) {
|
||||
agent.KeyHash = "scrypt:deadbeef:abcd"
|
||||
})
|
||||
rec := agentHeartbeat(t, app, token)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("token heartbeat with sabotaged key hash: got %d", rec.Code)
|
||||
}
|
||||
if again := rec.Header().Get(agentSessionTokenHeader); again != "" {
|
||||
t.Fatalf("token path must not re-issue, got %q", again)
|
||||
}
|
||||
// And the (now unverifiable) key is rejected.
|
||||
if rec := agentHeartbeat(t, app, testAgentKey); rec.Code != 401 {
|
||||
t.Fatalf("sabotaged key: got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// An expired token is rejected; the key still works and re-issues a
|
||||
// fresh token (rotation).
|
||||
func TestAgentSessionTokenExpiryFallsBackToKey(t *testing.T) {
|
||||
app, agentID := seedAgentApp(t, testAgentKey)
|
||||
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
|
||||
mutateAgent(t, app, agentID, func(agent *Agent) {
|
||||
past := ISOFromTime(time.Now().Add(-time.Hour))
|
||||
agent.SessionTokenExpiresAt = &past
|
||||
})
|
||||
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
|
||||
t.Fatalf("expired token: got %d, want 401", rec.Code)
|
||||
}
|
||||
rec := agentHeartbeat(t, app, testAgentKey)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("key after expiry: got %d", rec.Code)
|
||||
}
|
||||
fresh := rec.Header().Get(agentSessionTokenHeader)
|
||||
if fresh == "" || fresh == token {
|
||||
t.Fatalf("expected a fresh token after expiry, got %q", fresh)
|
||||
}
|
||||
if rec := agentHeartbeat(t, app, fresh); rec.Code != 200 {
|
||||
t.Fatalf("fresh token: got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A token whose stored hash no longer matches (tampered / rotated
|
||||
// server-side) is rejected; the key re-issues.
|
||||
func TestAgentSessionTokenHashMismatchFallsBack(t *testing.T) {
|
||||
app, agentID := seedAgentApp(t, testAgentKey)
|
||||
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
|
||||
mutateAgent(t, app, agentID, func(agent *Agent) {
|
||||
wrong := TokenFingerprint("some-other-token")
|
||||
agent.SessionTokenHash = &wrong
|
||||
})
|
||||
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
|
||||
t.Fatalf("mismatched token: got %d, want 401", rec.Code)
|
||||
}
|
||||
rec := agentHeartbeat(t, app, testAgentKey)
|
||||
if rec.Code != 200 || rec.Header().Get(agentSessionTokenHeader) == "" {
|
||||
t.Fatalf("key re-issue after mismatch: code=%d token=%q", rec.Code, rec.Header().Get(agentSessionTokenHeader))
|
||||
}
|
||||
}
|
||||
|
||||
// Revoking the agent kills both the token and the key immediately, and
|
||||
// Revoke() clears the stored token material.
|
||||
func TestAgentSessionTokenRevokedAgentRejected(t *testing.T) {
|
||||
app, agentID := seedAgentApp(t, testAgentKey)
|
||||
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
|
||||
mutateAgent(t, app, agentID, func(agent *Agent) { agent.Revoke() })
|
||||
stored := storedAgent(t, app, agentID)
|
||||
if stored.SessionTokenHash != nil || stored.SessionTokenExpiresAt != nil {
|
||||
t.Fatal("Revoke() must clear session token fields")
|
||||
}
|
||||
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
|
||||
t.Fatalf("revoked agent token: got %d, want 401", rec.Code)
|
||||
}
|
||||
if rec := agentHeartbeat(t, app, testAgentKey); rec.Code != 401 {
|
||||
t.Fatalf("revoked agent key: got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The token is bound to its own agent record: it must not authenticate
|
||||
// a request as a different agent (observed via which record's
|
||||
// LastSeenAt the heartbeat advances).
|
||||
func TestAgentSessionTokenBoundToRecord(t *testing.T) {
|
||||
app, agentID := seedAgentApp(t, testAgentKey)
|
||||
otherKey := "other-agent-key-9876543210"
|
||||
otherFP := TokenFingerprint(otherKey)
|
||||
otherID := ""
|
||||
_, err := app.store.Transaction(func(d *Data) (any, error) {
|
||||
other := &Agent{
|
||||
ID: NewID("agt"), ServerID: "srv_tok",
|
||||
KeyHash: HashSecret(otherKey), KeyFingerprint: &otherFP,
|
||||
Status: "active", CreatedAt: NowISO(),
|
||||
LastSeenAt: "2000-01-01T00:00:00.000Z",
|
||||
}
|
||||
otherID = other.ID
|
||||
d.Agents = append(d.Agents, other)
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed second agent: %v", err)
|
||||
}
|
||||
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
|
||||
if rec := agentHeartbeat(t, app, token); rec.Code != 200 {
|
||||
t.Fatalf("token heartbeat: got %d", rec.Code)
|
||||
}
|
||||
if got := storedAgent(t, app, agentID).LastSeenAt; got == "2000-01-01T00:00:00.000Z" {
|
||||
t.Fatal("token heartbeat did not advance its own agent's LastSeenAt")
|
||||
}
|
||||
if got := storedAgent(t, app, otherID).LastSeenAt; got != "2000-01-01T00:00:00.000Z" {
|
||||
t.Fatalf("token heartbeat advanced the WRONG agent (LastSeenAt=%s)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Key-only agents (pre-token clients) keep working request after
|
||||
// request; each key auth rotates the stored token.
|
||||
func TestAgentKeyOnlyRequestsKeepWorking(t *testing.T) {
|
||||
app, agentID := seedAgentApp(t, testAgentKey)
|
||||
var prevHash string
|
||||
for i := 0; i < 3; i++ {
|
||||
rec := agentHeartbeat(t, app, testAgentKey)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("key heartbeat %d: got %d", i, rec.Code)
|
||||
}
|
||||
stored := storedAgent(t, app, agentID)
|
||||
if stored.SessionTokenHash == nil || *stored.SessionTokenHash == prevHash {
|
||||
t.Fatalf("key heartbeat %d did not rotate the token", i)
|
||||
}
|
||||
prevHash = *stored.SessionTokenHash
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
// Item 22: the reflective Clone must be a perfect substitute for the
|
||||
// JSON round-trip clone — byte-identical serialization on the real
|
||||
// seed database, and fully independent of the original afterwards.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCloneEquivalenceOnSeed(t *testing.T) {
|
||||
raw, err := os.ReadFile("difftest/seed.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var d Data
|
||||
if err := unmarshalJSONUseNumber(raw, &d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fast := d.Clone()
|
||||
slow := d.cloneViaJSON()
|
||||
fastJSON, err := marshalJSON(fast)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slowJSON, err := marshalJSON(slow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(fastJSON, slowJSON) {
|
||||
t.Fatalf("clone mismatch:\nfast: %.400s\nslow: %.400s", fastJSON, slowJSON)
|
||||
}
|
||||
origJSON, err := marshalJSON(&d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(fastJSON, origJSON) {
|
||||
t.Fatal("clone must serialize identically to the source data")
|
||||
}
|
||||
// Deep independence: mutating the clone at several depths must not
|
||||
// leak into the source.
|
||||
if len(fast.Customers) > 0 {
|
||||
fast.Customers[0].Name = "MUTATED"
|
||||
if fast.Customers[0].Credentials != nil {
|
||||
fast.Customers[0].Credentials.RelayPassword = "MUTATED"
|
||||
}
|
||||
}
|
||||
if len(fast.Nodes) > 0 {
|
||||
fast.Nodes[0].Name = "MUTATED"
|
||||
}
|
||||
for key := range fast.KeyStore {
|
||||
fast.KeyStore[key].PrivateKey = "MUTATED"
|
||||
break
|
||||
}
|
||||
afterJSON, err := marshalJSON(&d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(afterJSON, origJSON) {
|
||||
t.Fatal("clone mutation leaked into the source data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneEquivalenceSynthetic(t *testing.T) {
|
||||
d := perfSeedData()
|
||||
// Add the shapes the perf seed lacks: activity detail with nested
|
||||
// generic values, keyStore entries, unknown (Extra) fields.
|
||||
d.Activity = []*Activity{{ID: "act_1", At: NowISO(), Actor: "admin", Action: "x", Target: "y",
|
||||
Detail: map[string]any{"n": json.Number("42"), "nested": map[string]any{"list": []any{"a", json.Number("1.5"), true, nil}}}}}
|
||||
d.KeyStore["reality-nod_0"] = &KeyEntry{PrivateKey: "priv", PublicKey: "pub", UpdatedAt: NowISO()}
|
||||
fastJSON, err := marshalJSON(d.Clone())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slowJSON, err := marshalJSON(d.cloneViaJSON())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(fastJSON, slowJSON) {
|
||||
t.Fatalf("synthetic clone mismatch:\nfast: %.300s\nslow: %.300s", fastJSON, slowJSON)
|
||||
}
|
||||
if d.Clone() == nil || EmptyData().Clone() == nil {
|
||||
t.Fatal("clone of empty data must be non-nil")
|
||||
}
|
||||
var nilData *Data
|
||||
if nilData.Clone() != nil {
|
||||
t.Fatal("clone of nil must be nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
// Tests for the customer-level credential model (round-2 item 2):
|
||||
// store-load convergence from legacy per-binding credentials, the
|
||||
// deterministic password rule, and the SS2022 user-key derivation.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrateCustomerCredentialsConvergesFirstBinding(t *testing.T) {
|
||||
d := &Data{
|
||||
Customers: []*Customer{{ID: "cus_1", Name: "Legacy"}},
|
||||
NodeClients: []*NodeClient{
|
||||
{ID: "ncl_1", NodeID: "nod_a", CustomerID: "cus_1", Credentials: &Credentials{
|
||||
ClientID: "11111111-2222-3333-4444-000000000001",
|
||||
// A 32-byte root key: must be kept verbatim.
|
||||
RelayPassword: base64.StdEncoding.EncodeToString(make([]byte, 32)),
|
||||
ShortID: "abcd0001ef01",
|
||||
}},
|
||||
{ID: "ncl_2", NodeID: "nod_b", CustomerID: "cus_1", Credentials: &Credentials{
|
||||
ClientID: "22222222-2222-3333-4444-000000000002", RelayPassword: "other", ShortID: "ffff0002ef01",
|
||||
}},
|
||||
},
|
||||
}
|
||||
if !MigrateCustomerCredentials(d) {
|
||||
t.Fatalf("migration reported no change")
|
||||
}
|
||||
creds := d.Customers[0].Credentials
|
||||
if creds == nil || creds.ClientID != "11111111-2222-3333-4444-000000000001" || creds.ShortID != "abcd0001ef01" {
|
||||
t.Fatalf("converged set is not the first binding's: %+v", creds)
|
||||
}
|
||||
if creds.RelayPassword != base64.StdEncoding.EncodeToString(make([]byte, 32)) {
|
||||
t.Fatalf("32-byte root key must be kept, got %q", creds.RelayPassword)
|
||||
}
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.Credentials != nil {
|
||||
t.Fatalf("binding %s still carries credentials", nc.ID)
|
||||
}
|
||||
}
|
||||
if MigrateCustomerCredentials(d) {
|
||||
t.Fatalf("migration must be idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateCustomerCredentialsRegeneratesShortPasswordOnly(t *testing.T) {
|
||||
d := &Data{
|
||||
Customers: []*Customer{{ID: "cus_1"}},
|
||||
NodeClients: []*NodeClient{
|
||||
{ID: "ncl_1", NodeID: "nod_a", CustomerID: "cus_1", Credentials: &Credentials{
|
||||
ClientID: "11111111-2222-3333-4444-000000000001",
|
||||
// 16-byte key (an old SS2022-aes128 binding set): too short
|
||||
// to derive a 32-byte user key from.
|
||||
RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==",
|
||||
ShortID: "abcd0001ef01",
|
||||
}},
|
||||
},
|
||||
}
|
||||
MigrateCustomerCredentials(d)
|
||||
creds := d.Customers[0].Credentials
|
||||
if creds.ClientID != "11111111-2222-3333-4444-000000000001" || creds.ShortID != "abcd0001ef01" {
|
||||
t.Fatalf("clientId/shortId must be preserved: %+v", creds)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(creds.RelayPassword)
|
||||
if err != nil || len(raw) != 32 {
|
||||
t.Fatalf("password must be regenerated as a 32-byte root key, got %q", creds.RelayPassword)
|
||||
}
|
||||
if creds.RelayPassword == "FRYXGBkaGxwdHh8gISIjJA==" {
|
||||
t.Fatalf("short password must not survive convergence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateCustomerCredentialsFreshForBindingless(t *testing.T) {
|
||||
d := &Data{Customers: []*Customer{{ID: "cus_new"}}}
|
||||
if !MigrateCustomerCredentials(d) {
|
||||
t.Fatalf("expected a change")
|
||||
}
|
||||
creds := d.Customers[0].Credentials
|
||||
if creds == nil || creds.ClientID == "" || creds.ShortID == "" {
|
||||
t.Fatalf("fresh set incomplete: %+v", creds)
|
||||
}
|
||||
if raw, err := base64.StdEncoding.DecodeString(creds.RelayPassword); err != nil || len(raw) != 32 {
|
||||
t.Fatalf("fresh set password is not a 32-byte root key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRelayPassword(t *testing.T) {
|
||||
root := make([]byte, 32)
|
||||
for i := range root {
|
||||
root[i] = byte(i)
|
||||
}
|
||||
rootB64 := base64.StdEncoding.EncodeToString(root)
|
||||
// aes128 (16-byte method): deterministic prefix truncation.
|
||||
if got := EffectiveRelayPassword("shadowsocks-2022-aes128", rootB64); got != base64.StdEncoding.EncodeToString(root[:16]) {
|
||||
t.Fatalf("aes128 derivation wrong: %q", got)
|
||||
}
|
||||
// aes256: the root key itself.
|
||||
if got := EffectiveRelayPassword("shadowsocks-2022-aes256", rootB64); got != rootB64 {
|
||||
t.Fatalf("aes256 derivation wrong: %q", got)
|
||||
}
|
||||
// Legacy methods and other protocols: verbatim.
|
||||
if got := EffectiveRelayPassword("shadowsocks-aes256-gcm", rootB64); got != rootB64 {
|
||||
t.Fatalf("legacy SS must be verbatim")
|
||||
}
|
||||
if got := EffectiveRelayPassword("hysteria2", "plain-password"); got != "plain-password" {
|
||||
t.Fatalf("non-SS must be verbatim")
|
||||
}
|
||||
// An exact-length legacy key passes through unchanged (identity).
|
||||
k16 := "FRYXGBkaGxwdHh8gISIjJA=="
|
||||
if got := EffectiveRelayPassword("shadowsocks-2022-aes128", k16); got != k16 {
|
||||
t.Fatalf("16-byte key on aes128 must be identity, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCustomerCredentialsShape(t *testing.T) {
|
||||
c := NewCustomerCredentials()
|
||||
if c.ClientID == "" || c.ShortID == "" {
|
||||
t.Fatalf("incomplete set: %+v", c)
|
||||
}
|
||||
if raw, err := base64.StdEncoding.DecodeString(c.RelayPassword); err != nil || len(raw) != 32 {
|
||||
t.Fatalf("relay password must be a base64 32-byte root key")
|
||||
}
|
||||
}
|
||||
@@ -392,11 +392,30 @@ export class Normalizer {
|
||||
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);
|
||||
// A base64 secret's prefix truncations (the SS2022 user keys derived
|
||||
// from a customer's 32-byte root key) are the same logical secret, so
|
||||
// their encodings are replaced with the same placeholder.
|
||||
const replacements = [];
|
||||
for (const [raw, placeholder] of this.secrets) {
|
||||
if (raw.length >= 8) replacements.push([raw, placeholder]);
|
||||
if (raw.length > 16) replacements.push([raw.slice(0, 16), placeholder]);
|
||||
if (/^[A-Za-z0-9+/]+={0,2}$/.test(raw) && raw.length % 4 === 0) {
|
||||
const bytes = Buffer.from(raw, 'base64');
|
||||
if (bytes.toString('base64') === raw) {
|
||||
for (const n of [16, 24]) {
|
||||
if (n < bytes.length) replacements.push([bytes.subarray(0, n).toString('base64'), placeholder]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The Surge formatter mangles '=' to '_' inside passwords, so the
|
||||
// mangled variant of every replacement is the same secret too.
|
||||
for (const [raw, placeholder] of [...replacements]) {
|
||||
const mangled = raw.replace(/=/g, '_');
|
||||
if (mangled !== raw) replacements.push([mangled, placeholder]);
|
||||
}
|
||||
replacements.sort((a, b) => b[0].length - a[0].length);
|
||||
for (const [raw, placeholder] of replacements) out = out.split(raw).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);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Go 面板金标准回归基线(golden)
|
||||
|
||||
2026-10-21 起 Node 实现冻结退役、Go 单一真源,difftest(Node 差分)随之退役。
|
||||
本目录把 **Go 面板在种子库上的当前行为**冻结为回归基线,替代 difftest 的
|
||||
守门角色:
|
||||
|
||||
- `golden.mjs` — 抓取/校验脚本。复用 `../difftest/` 的种子库(`seed.json`)
|
||||
与学习式 Normalizer(`../difftest/lib.mjs`):以种子副本启动一个 Go 面板,
|
||||
按固定剧本驱动(登录 → 种子 agent 心跳 → 列表 → 凭据 → 订阅全格式 →
|
||||
建服务器/注册 agent → 建客户/节点/分配/部署并 drain 部署任务 → 用量上报
|
||||
两轮 → 呈现投影 → UI 式全量 PATCH 节点 → `/api/system` 结构 → 备份导出 →
|
||||
错误用例),全部响应归一化后存入 `golden.json`(25 个 section)。
|
||||
- `golden.json` — 冻结的行为快照(2026-10-21,基线提交时的 Go 现状,
|
||||
二进制由 HEAD `156a31c` 构建)。
|
||||
|
||||
用法:
|
||||
|
||||
```bash
|
||||
node golden.mjs capture [--bin /path/to/nexusgate-panel] # 重新冻结(仅在确认行为变更为预期时)
|
||||
node golden.mjs verify [--bin /path/to/nexusgate-panel] # 与基线比对,有差异退出码 1 并打印首个 diff 路径
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- verify 出现 diff 时,先判定是**预期变更**(本版 9 项修改各自的预期 diff
|
||||
在 NEXT-ROUND-REPORT.md 中逐项登记)还是回归;预期变更在对应项提交里
|
||||
一并重新 capture 并在提交说明中注明 section 名。
|
||||
- `/api/system` 的数值多为实时采样,脚本只保留结构与确定性计数
|
||||
(nodesTotal/customersTotal/online* /connections),其余数值与 IP 压成
|
||||
占位;uptime 类键同样压平。其余 section 的数值都是剧本确定性结果,
|
||||
逐字比对。
|
||||
- 剧本对时间的依赖已被窗口设计吸收(presence 180s 窗口、心跳在线判定),
|
||||
正常复跑应恒 PASS;偶发 FAIL 先复跑一次区分抖动与真回归。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
// NexusGate Go panel golden-behavior harness.
|
||||
//
|
||||
// Freezes the CURRENT Go panel behavior as a regression baseline now that the
|
||||
// Node implementation is retired (2026-10-21 decision): a single Go panel is
|
||||
// started on a copy of the difftest seed DB, driven through a fixed script of
|
||||
// API calls, and every response is passed through the difftest learning
|
||||
// Normalizer (volatile ids / secrets / ports / timestamps become placeholders)
|
||||
// before being stored in golden.json. `verify` replays the same script and
|
||||
// diffs against the frozen file; any diff must be either an intended change
|
||||
// (re-capture and note it in the commit) or a regression.
|
||||
//
|
||||
// Usage:
|
||||
// node golden.mjs capture [--bin /path/to/nexusgate-panel]
|
||||
// node golden.mjs verify [--bin /path/to/nexusgate-panel] [--golden golden.json]
|
||||
//
|
||||
// The binary defaults to ../nexusgate-panel-linux-amd64 (same resolution as
|
||||
// the difftest runner). The repo data/ directory is never touched.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
startPanel, waitHealth, stopPanel, makeTempDir, freePort, PanelClient,
|
||||
Normalizer, firstDiff, fetchAllSubscriptions, drainAgentJobs, pollOnce,
|
||||
completeJob, DEFAULT_GO_BIN, DIFFTEST_DIR,
|
||||
} from '../difftest/lib.mjs';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SEED = path.join(DIFFTEST_DIR, 'seed.json');
|
||||
const SEED_LOG = path.join(DIFFTEST_DIR, 'seed-log.json');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { mode: argv[2], bin: process.env.GO_PANEL_BIN || DEFAULT_GO_BIN, golden: path.join(HERE, 'golden.json') };
|
||||
for (let i = 3; i < argv.length; i += 1) {
|
||||
if (argv[i] === '--bin') args.bin = argv[++i];
|
||||
else if (argv[i] === '--golden') args.golden = argv[++i];
|
||||
else throw new Error(`unknown argument: ${argv[i]}`);
|
||||
}
|
||||
if (!['capture', 'verify'].includes(args.mode)) throw new Error('mode must be capture or verify');
|
||||
return args;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// View of an admin/agent HTTP response, mirroring difftest's view().
|
||||
function view(res) {
|
||||
return { status: res.status, body: res.json !== null && res.json !== undefined ? res.json : res.text };
|
||||
}
|
||||
|
||||
// Keys whose numeric values are wall-clock derived and therefore not part of
|
||||
// the behavior contract; squashed wherever they appear.
|
||||
const VOLATILE_NUM_KEYS = /uptime|serverTimeMs|nowMs/i;
|
||||
function squashVolatile(value, keyName = null) {
|
||||
// A resource's inbound `listen` is the panel host's own interface address
|
||||
// (bind resolution) — environment-specific, not behavior.
|
||||
if (keyName === 'listen' && typeof value === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(value)) return '<hostip>';
|
||||
if (Array.isArray(value)) return value.map((v) => squashVolatile(v, keyName));
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
out[k] = typeof v === 'number' && VOLATILE_NUM_KEYS.test(k) ? '<num>' : squashVolatile(v, k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// /api/system: almost every number is a live sample. Only structural counts
|
||||
// that follow deterministically from the script are kept verbatim.
|
||||
const SYSTEM_KEEP_NUM = new Set(['nodesTotal', 'customersTotal', 'onlineNodes', 'onlineCustomers', 'connections']);
|
||||
function squashSystem(value, keyName = null) {
|
||||
if (typeof value === 'number') return keyName && SYSTEM_KEEP_NUM.has(keyName) ? value : '<num>';
|
||||
if (typeof value === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(value)) return '<ip>';
|
||||
if (Array.isArray(value)) return value.map((v) => squashSystem(v, keyName));
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const k of Object.keys(value).sort()) out[k] = squashSystem(value[k], k);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function runScript(bin) {
|
||||
const seedLog = JSON.parse(fs.readFileSync(SEED_LOG, 'utf8'));
|
||||
const seedIds = seedLog.meta.ids;
|
||||
const tmp = makeTempDir('nexusgate-golden-');
|
||||
const dataFile = path.join(tmp, 'nexusgate.json');
|
||||
fs.copyFileSync(SEED, dataFile);
|
||||
const port = await freePort();
|
||||
const proc = startPanel({ kind: 'go', bin, dataFile, port });
|
||||
const N = new Normalizer();
|
||||
const sections = {};
|
||||
const cap = (name, value) => { sections[name] = N.process(squashVolatile(structuredClone(value))); };
|
||||
try {
|
||||
await waitHealth(proc);
|
||||
const client = new PanelClient(proc.base);
|
||||
const ids = { entryServer: seedIds.entryServer, exitServer: seedIds.exitServer, n1: seedIds.n1, n2: seedIds.n2, alice: seedIds.alice, bob: seedIds.bob };
|
||||
const agentKeys = { entry: seedLog.meta.agentKeys.entry, exit: seedLog.meta.agentKeys.exit };
|
||||
const subTokens = {};
|
||||
|
||||
cap('login', view(await client.login()));
|
||||
cap('session', view(await client.admin('GET', '/api/session')));
|
||||
|
||||
// Old-shape heartbeats (no engine object at all): the panel must keep
|
||||
// accepting them and simply have no engine state to show.
|
||||
cap('heartbeatSeedAgents', {
|
||||
entry: view(await client.agentPost('/api/agent/heartbeat', agentKeys.entry, { version: '0.6.8', system: {} })),
|
||||
exit: view(await client.agentPost('/api/agent/heartbeat', agentKeys.exit, { version: '0.6.8', system: {} })),
|
||||
});
|
||||
|
||||
const customersRes = await client.admin('GET', '/api/customers');
|
||||
for (const c of customersRes.json.customers) subTokens[c.name.toLowerCase()] = c.subscriptionToken;
|
||||
const nodesRes = await client.admin('GET', '/api/nodes');
|
||||
cap('lists', {
|
||||
overview: view(await client.admin('GET', '/api/overview')),
|
||||
servers: view(await client.admin('GET', '/api/servers')),
|
||||
customers: view(customersRes),
|
||||
nodes: view(nodesRes),
|
||||
jobs: view(await client.admin('GET', '/api/jobs')),
|
||||
});
|
||||
|
||||
// New-shape heartbeat carrying the core version (round-2 item 4): the
|
||||
// panel must persist it on the server's engine state and expose it in
|
||||
// the servers list. The seed-shape heartbeats above (no engine object)
|
||||
// must keep working and leave engine state absent.
|
||||
cap('heartbeatWithEngine', view(await client.agentPost('/api/agent/heartbeat', agentKeys.entry, {
|
||||
version: '0.6.8', system: {},
|
||||
engine: { status: 'ready', detail: '', singBoxInstalled: true, certificates: [], singBoxStatus: 'ready', singBoxVersion: '1.13.21' },
|
||||
})));
|
||||
cap('serversAfterEngineHeartbeat', view(await client.admin('GET', '/api/servers')));
|
||||
|
||||
cap('credentials', {
|
||||
alice: view(await client.admin('GET', `/api/customers/${ids.alice}/credentials`)),
|
||||
bob: view(await client.admin('GET', `/api/customers/${ids.bob}/credentials`)),
|
||||
});
|
||||
|
||||
cap('subscriptionsSeed', {
|
||||
alice: await fetchAllSubscriptions(client, subTokens.alice),
|
||||
bob: await fetchAllSubscriptions(client, subTokens.bob),
|
||||
});
|
||||
|
||||
// New server + agent enrolment.
|
||||
const s3res = await client.admin('POST', '/api/servers', {
|
||||
name: 'S3', role: 'relay', region: '', publicAddress: 's3.example.com',
|
||||
publicAddressV6: '', tlsDomain: '', portRangeStart: 20000, portRangeEnd: 50000,
|
||||
});
|
||||
ids.s3 = s3res.json.server.id;
|
||||
cap('createServerS3', view(s3res));
|
||||
const ticket = await client.admin('POST', `/api/servers/${ids.s3}/enrollment-token`, {});
|
||||
const enrolled = await client.request('POST', '/api/agent/enroll', {
|
||||
body: { token: ticket.json.token, hostname: 'golden-agent-s3', version: '0.6.8', system: {} },
|
||||
cookie: null,
|
||||
});
|
||||
agentKeys.s3 = enrolled.json.agentKey;
|
||||
cap('enrollS3', { ticket: view(ticket), enroll: view(enrolled) });
|
||||
|
||||
const carolRes = await client.admin('POST', '/api/customers', { name: 'Carol' });
|
||||
ids.carol = carolRes.json.customer.id;
|
||||
subTokens.carol = carolRes.json.customer.subscriptionToken;
|
||||
cap('createCarol', view(carolRes));
|
||||
|
||||
const n3res = await client.admin('POST', '/api/nodes', {
|
||||
name: 'N3', serverId: ids.s3, topology: 'direct',
|
||||
protocol: 'vless-reality', realityServerName: 'www.amd.com',
|
||||
});
|
||||
ids.n3 = n3res.json.node.id;
|
||||
cap('createNodeN3', view(n3res));
|
||||
cap('assignN3', view(await client.admin('POST', `/api/nodes/${ids.n3}/assign`, { customerId: ids.carol })));
|
||||
cap('deployN3', view(await client.admin('POST', `/api/nodes/${ids.n3}/deploy`, {})));
|
||||
const drainedS3 = await drainAgentJobs(client, agentKeys.s3);
|
||||
cap('deployN3Jobs', drainedS3.map(({ job, complete }) => ({ job, complete })));
|
||||
cap('nodesAfterDeploy', view(await client.admin('GET', '/api/nodes')));
|
||||
cap('carolSubscriptions', await fetchAllSubscriptions(client, subTokens.carol));
|
||||
|
||||
// Usage + presence/speed projection (two cumulative reports 6s apart).
|
||||
const usageBody = (u1, d1, u2, d2) => ({
|
||||
epoch: 'golden-epoch-1',
|
||||
samples: [
|
||||
{ kind: 'user', id: ids.alice, uplink: u1, downlink: d1 },
|
||||
{ kind: 'inbound', id: ids.n1, uplink: u2, downlink: d2 },
|
||||
],
|
||||
});
|
||||
cap('usageReport1', view(await client.agentPost('/api/agent/usage', agentKeys.entry, usageBody(1500, 2100, 5000, 6000))));
|
||||
await sleep(6000);
|
||||
cap('usageReport2', view(await client.agentPost('/api/agent/usage', agentKeys.entry, usageBody(4500, 6300, 8000, 9000))));
|
||||
const customers2 = await client.admin('GET', '/api/customers');
|
||||
const nodes2 = await client.admin('GET', '/api/nodes');
|
||||
const nameOf = (id) => { const c = customers2.json.customers.find((x) => x.id === id); return c ? c.name : '?'; };
|
||||
sections.presenceProjection = {
|
||||
customers: customers2.json.customers.map((c) => ({
|
||||
name: c.name, online: c.online,
|
||||
speedPositive: (c.speedUpBps || 0) > 0 && (c.speedDownBps || 0) > 0,
|
||||
hasLastActiveAt: Boolean(c.lastActiveAt),
|
||||
})),
|
||||
nodes: nodes2.json.nodes.map((n) => ({
|
||||
name: n.name, clientCount: n.clientCount, onlineCount: n.onlineCount,
|
||||
onlineNames: (n.onlineCustomerIds || []).map(nameOf),
|
||||
speedPositive: (n.speedUpBps || 0) > 0,
|
||||
})),
|
||||
};
|
||||
cap('customersAfterUsage', view(customers2));
|
||||
|
||||
// UI-style full-payload PATCH of the seed forward node N1 (name change
|
||||
// only). The browser form always submits portMode + port(null in random
|
||||
// mode) + serverId; this is the exact traffic shape behind the port
|
||||
// re-roll bug, frozen here so the fix shows up as a deliberate diff.
|
||||
const n1 = nodes2.json.nodes.find((n) => n.id === ids.n1);
|
||||
const uiPatch = {
|
||||
name: 'N1-renamed', serverId: n1.serverId, protocol: n1.protocol,
|
||||
portMode: n1.portMode, port: n1.portMode === 'fixed' ? n1.port : null,
|
||||
networkMode: n1.networkMode, topology: n1.topology,
|
||||
exitServerId: n1.exitServerId, exitProtocol: n1.exitProtocol,
|
||||
exitPortMode: n1.exitPortMode, exitPort: n1.exitPortMode === 'fixed' ? n1.exitPort : null,
|
||||
realityServerName: n1.realityServerName || '', realityDestPort: n1.realityDestPort || null,
|
||||
};
|
||||
cap('nodePatchUiStyle', view(await client.admin('PATCH', `/api/nodes/${ids.n1}`, uiPatch)));
|
||||
const drainedEntry = await drainAgentJobs(client, agentKeys.entry);
|
||||
const drainedExit = await drainAgentJobs(client, agentKeys.exit);
|
||||
cap('nodePatchJobs', {
|
||||
entry: drainedEntry.map(({ job, complete }) => ({ job, complete })),
|
||||
exit: drainedExit.map(({ job, complete }) => ({ job, complete })),
|
||||
});
|
||||
cap('nodesAfterPatch', view(await client.admin('GET', '/api/nodes')));
|
||||
|
||||
const systemRes = await client.admin('GET', '/api/system');
|
||||
sections.systemShape = { status: systemRes.status, body: squashSystem(N.process(structuredClone(systemRes.json))) };
|
||||
|
||||
cap('backup', view(await client.admin('GET', '/api/backup')));
|
||||
|
||||
const anon = new PanelClient(proc.base);
|
||||
cap('errors', {
|
||||
unauthenticated: view(await anon.request('GET', '/api/customers')),
|
||||
loginFailure: view(await anon.request('POST', '/api/auth/login', { body: { username: 'admin', password: 'wrong-password' }, cookie: null })),
|
||||
missingNode: view(await client.admin('GET', '/api/nodes/nod_00000000-0000-0000-0000-000000000000')),
|
||||
badSubscription: await (async () => {
|
||||
const r = await client.getSubscription('nosuchtoken000000', 'raw', 'NexusGate-Golden/1.0');
|
||||
return { status: r.status, body: r.body };
|
||||
})(),
|
||||
});
|
||||
|
||||
// Round-2 item 8: manual landing endpoint. Creation is exit-ready with
|
||||
// no exit server; deploying queues only the entry-side job, whose
|
||||
// outbound targets the manual address with the operator's credentials.
|
||||
const manualCreate = await client.admin('POST', '/api/nodes', {
|
||||
name: 'N4-Manual', serverId: ids.entryServer, protocol: 'vless-reality-vision', topology: 'forward',
|
||||
exitMode: 'manual', exitAddress: '203.0.113.9', exitProtocol: 'shadowsocks-2022-aes128',
|
||||
exitPort: 23464, exitPortMode: 'fixed', exitCredentials: { password: 'FRYXGBkaGxwdHh8gISIjJA==:FRYXGBkaGxwdHh8gISIjJA==' },
|
||||
portMode: 'fixed', port: 23466, networkMode: 'ipv4', realityServerName: 'www.amd.com', realityDestPort: 443,
|
||||
});
|
||||
cap('manualCreate', view(manualCreate));
|
||||
const manualNodeId = manualCreate.json.node.id;
|
||||
cap('manualAssign', view(await client.admin('POST', `/api/nodes/${manualNodeId}/assign`, { customerId: ids.carol })));
|
||||
cap('manualDeploy', view(await client.admin('POST', `/api/nodes/${manualNodeId}/deploy`, {})));
|
||||
const drainedManual = await drainAgentJobs(client, agentKeys.entry);
|
||||
cap('manualJobs', drainedManual.map(({ job, complete }) => ({ job, complete })));
|
||||
cap('manualNodes', view(await client.admin('GET', '/api/nodes')));
|
||||
|
||||
return sections;
|
||||
} finally {
|
||||
await stopPanel(proc);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv);
|
||||
if (!fs.existsSync(args.bin)) { console.error(`Go panel binary not found: ${args.bin}`); process.exit(2); }
|
||||
const sections = await runScript(args.bin);
|
||||
if (args.mode === 'capture') {
|
||||
const payload = { meta: { capturedAt: new Date().toISOString(), note: 'Go panel golden baseline; see README.md in this directory.' }, sections };
|
||||
fs.writeFileSync(args.golden, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
console.log(`CAPTURED ${Object.keys(sections).length} sections -> ${args.golden}`);
|
||||
} else {
|
||||
const golden = JSON.parse(fs.readFileSync(args.golden, 'utf8'));
|
||||
const diff = firstDiff(golden.sections, sections);
|
||||
if (!diff) { console.log('GOLDEN PASS — behavior identical to frozen baseline'); process.exit(0); }
|
||||
console.log(`GOLDEN DIFF at ${diff.at}\n golden: ${diff.a}\n actual: ${diff.b}`);
|
||||
process.exit(1);
|
||||
}
|
||||
+577
-106
@@ -6,7 +6,10 @@ package main
|
||||
// line by line.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -208,6 +211,17 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
||||
topology = "forward"
|
||||
}
|
||||
direct := topology == "direct"
|
||||
// Exit target selection (round-2 item 8): "manual" addresses an
|
||||
// unmanaged landing endpoint; anything else keeps the managed server.
|
||||
exitMode := next.ExitMode
|
||||
if _, ok := body["exitMode"]; ok {
|
||||
if s, _ := body["exitMode"].(string); s == "manual" {
|
||||
exitMode = "manual"
|
||||
} else {
|
||||
exitMode = "managed"
|
||||
}
|
||||
}
|
||||
manual := !direct && exitMode == "manual"
|
||||
|
||||
protocol := next.Protocol
|
||||
if _, ok := body["protocol"]; ok {
|
||||
@@ -232,7 +246,7 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
||||
return nil, httpErr(404, "服务器不存在")
|
||||
}
|
||||
var exitServerID *string
|
||||
if direct {
|
||||
if direct || manual {
|
||||
exitServerID = nil
|
||||
} else if _, ok := body["exitServerId"]; ok {
|
||||
id, err := RequiredText(body["exitServerId"], "出口服务器 ID", 120)
|
||||
@@ -243,7 +257,7 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
||||
} else {
|
||||
exitServerID = next.ExitServerID
|
||||
}
|
||||
if !direct {
|
||||
if !direct && !manual {
|
||||
found := false
|
||||
for _, item := range data.Servers {
|
||||
if item.ID == strVal(exitServerID) {
|
||||
@@ -266,6 +280,26 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
||||
next.Protocol = protocol
|
||||
next.Topology = topology
|
||||
next.ExitServerID = exitServerID
|
||||
next.ExitMode = ""
|
||||
next.ExitAddress = ""
|
||||
if manual {
|
||||
next.ExitMode = "manual"
|
||||
address := ""
|
||||
if current != nil {
|
||||
address = current.ExitAddress
|
||||
}
|
||||
if _, ok := body["exitAddress"]; ok {
|
||||
s, err := RequiredText(body["exitAddress"], "落地端点地址", 255)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
address = s
|
||||
}
|
||||
if address == "" || strings.ContainsAny(address, " \t\r\n/") || strings.Contains(address, "://") {
|
||||
return nil, httpErr(400, "手动落地端点地址无效")
|
||||
}
|
||||
next.ExitAddress = address
|
||||
}
|
||||
if direct {
|
||||
next.ExitProtocol = nil
|
||||
} else if _, ok := body["exitProtocol"]; ok {
|
||||
@@ -320,8 +354,21 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
||||
_, portModeInBody := body["portMode"]
|
||||
_, portInBody := body["port"]
|
||||
portModeChanged := portModeInBody && portMode != currentPortMode
|
||||
_, serverIDInBody := body["serverId"]
|
||||
if portModeChanged || portInBody || serverIDInBody || currentPort == 0 {
|
||||
// An already-assigned port is sticky. The browser form always submits
|
||||
// portMode/port/serverId keys (port is null in random mode), so key
|
||||
// presence alone must never trigger a re-selection — a random port is
|
||||
// rolled exactly once, at creation. On edit the port only moves for an
|
||||
// explicit addressing change: switching the mode to random, supplying a
|
||||
// different fixed port, or moving to a server where the port is taken.
|
||||
portValueChanged := false
|
||||
if portInBody && !jsFalsy(body["port"]) {
|
||||
if f, valid := anyToJSNumber(body["port"]); valid && int(f) != currentPort {
|
||||
portValueChanged = true
|
||||
}
|
||||
}
|
||||
serverChanged := current != nil && serverID != current.ServerID
|
||||
switch {
|
||||
case current == nil || currentPort == 0:
|
||||
var requested any
|
||||
if portInBody {
|
||||
requested = body["port"]
|
||||
@@ -333,8 +380,52 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
||||
return nil, err
|
||||
}
|
||||
next.Port = port
|
||||
case portModeChanged && portMode == "random":
|
||||
// Explicit switch into random mode: roll a fresh port.
|
||||
port, err := SelectPort(data, server, portMode, nil, currentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next.Port = port
|
||||
case portValueChanged && portMode == "fixed":
|
||||
port, err := SelectPort(data, server, portMode, body["port"], currentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next.Port = port
|
||||
case serverChanged && NodePortInUse(data, serverID, currentPort, currentID):
|
||||
// The port is taken on the target server: fixed mode surfaces the
|
||||
// conflict, random mode rolls a replacement. (When it is free the
|
||||
// switch falls through and the port is kept verbatim.)
|
||||
var requested any
|
||||
if portMode == "fixed" {
|
||||
requested = json.Number(fmt.Sprint(currentPort))
|
||||
}
|
||||
port, err := SelectPort(data, server, portMode, requested, currentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next.Port = port
|
||||
}
|
||||
if !direct {
|
||||
if manual {
|
||||
// A manual endpoint's port belongs to the remote service: it is
|
||||
// always explicit (never rolled, never checked against a panel
|
||||
// server's port table).
|
||||
fixed := "fixed"
|
||||
next.ExitPortMode = &fixed
|
||||
port := 0
|
||||
if v, ok := body["exitPort"]; ok && !jsFalsy(v) {
|
||||
if f, valid := anyToJSNumber(v); valid {
|
||||
port = int(f)
|
||||
}
|
||||
} else if current != nil && current.ExitPort != nil {
|
||||
port = *current.ExitPort
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
return nil, httpErr(400, "手动落地端点端口无效")
|
||||
}
|
||||
next.ExitPort = &port
|
||||
} else if !direct {
|
||||
exitPortMode := strVal(next.ExitPortMode)
|
||||
if _, ok := body["exitPortMode"]; ok {
|
||||
if s, _ := body["exitPortMode"].(string); s == "fixed" {
|
||||
@@ -364,8 +455,17 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
||||
_, exitPortModeInBody := body["exitPortMode"]
|
||||
_, exitPortInBody := body["exitPort"]
|
||||
exitPortModeChanged := exitPortModeInBody && exitPortMode != currentExitPortMode
|
||||
_, exitServerIDInBody := body["exitServerId"]
|
||||
if exitPortModeChanged || exitPortInBody || exitServerIDInBody || currentExitPort == nil {
|
||||
// Same stickiness rule as the entry port: an assigned exit port is
|
||||
// only re-selected on creation or an explicit addressing change.
|
||||
exitPortValueChanged := false
|
||||
if exitPortInBody && !jsFalsy(body["exitPort"]) && currentExitPort != nil {
|
||||
if f, valid := anyToJSNumber(body["exitPort"]); valid && int(f) != *currentExitPort {
|
||||
exitPortValueChanged = true
|
||||
}
|
||||
}
|
||||
exitServerChanged := current != nil && strVal(exitServerID) != strVal(current.ExitServerID)
|
||||
switch {
|
||||
case current == nil || currentExitPort == nil:
|
||||
var requested any
|
||||
if exitPortInBody {
|
||||
requested = body["exitPort"]
|
||||
@@ -377,6 +477,28 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
||||
return nil, err
|
||||
}
|
||||
next.ExitPort = &port
|
||||
case exitPortModeChanged && exitPortMode == "random":
|
||||
port, err := SelectPort(data, exitServer, exitPortMode, nil, currentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next.ExitPort = &port
|
||||
case exitPortValueChanged && exitPortMode == "fixed":
|
||||
port, err := SelectPort(data, exitServer, exitPortMode, body["exitPort"], currentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next.ExitPort = &port
|
||||
case exitServerChanged && NodePortInUse(data, strVal(exitServerID), *currentExitPort, currentID):
|
||||
var requested any
|
||||
if exitPortMode == "fixed" {
|
||||
requested = json.Number(fmt.Sprint(*currentExitPort))
|
||||
}
|
||||
port, err := SelectPort(data, exitServer, exitPortMode, requested, currentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next.ExitPort = &port
|
||||
}
|
||||
} else {
|
||||
next.ExitPortMode = nil
|
||||
@@ -391,6 +513,88 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
||||
return nil, herr
|
||||
}
|
||||
}
|
||||
if manual {
|
||||
// Manual endpoint credentials describe a third-party service, so
|
||||
// they come from the operator (form fields or a pasted share link),
|
||||
// never generated. Fields merge over the stored set so an edit that
|
||||
// leaves a field blank keeps the previous value.
|
||||
if rawCreds, ok := body["exitCredentials"].(map[string]any); ok {
|
||||
creds := ExitCredentials{}
|
||||
if next.ExitCredentials != nil {
|
||||
creds = *next.ExitCredentials
|
||||
}
|
||||
if v, ok := rawCreds["clientId"]; ok {
|
||||
creds.ClientID = CleanText(v, 128)
|
||||
}
|
||||
if v, ok := rawCreds["password"]; ok {
|
||||
creds.Password = CleanText(v, 512)
|
||||
}
|
||||
if v, ok := rawCreds["socksUser"]; ok {
|
||||
creds.SocksUser = CleanText(v, 128)
|
||||
}
|
||||
next.ExitCredentials = &creds
|
||||
}
|
||||
creds := next.ExitCredentials
|
||||
exitProtocol := strVal(next.ExitProtocol)
|
||||
switch {
|
||||
case creds == nil:
|
||||
return nil, httpErr(400, "手动落地端点缺少凭据")
|
||||
case exitProtocol == "vless-tcp" && creds.ClientID == "":
|
||||
return nil, httpErr(400, "手动落地端点缺少 VLESS 用户 ID")
|
||||
case exitProtocol == "socks5-auth" && (creds.SocksUser == "" || creds.Password == ""):
|
||||
return nil, httpErr(400, "手动落地端点缺少 SOCKS5 用户名或密码")
|
||||
default:
|
||||
if cipher, isSS := ShadowsocksMethods[exitProtocol]; isSS {
|
||||
if creds.Password == "" {
|
||||
return nil, httpErr(400, "手动落地端点缺少 SS 密码")
|
||||
}
|
||||
// SS2022 keys are base64 key material (one PSK segment, or
|
||||
// serverKey:userKey); sing-box rejects anything else when
|
||||
// the config loads, so reject it here with a clear error.
|
||||
if strings.HasPrefix(cipher.Method, "2022-blake3-") {
|
||||
segments := strings.Split(creds.Password, ":")
|
||||
if len(segments) > 2 {
|
||||
return nil, httpErr(400, "手动落地端点 SS 密码格式无效")
|
||||
}
|
||||
for _, segment := range segments {
|
||||
raw, err := base64.StdEncoding.DecodeString(segment)
|
||||
if err != nil {
|
||||
if raw, err = base64.RawStdEncoding.DecodeString(segment); err != nil {
|
||||
return nil, httpErr(400, "手动落地端点 SS 密码必须是 base64 密钥")
|
||||
}
|
||||
}
|
||||
if len(raw) != cipher.KeyBytes {
|
||||
return nil, httpErr(400, "手动落地端点 SS 密钥长度与加密方法不符")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Round-3 item 21: a socks5-auth node carries its own fixed account.
|
||||
// Fields merge over the stored pair (blank keeps the old value, the
|
||||
// form's convention); a pair still missing afterwards is minted by
|
||||
// EnsureNodeSecrets on save. The username must not start with "ng:"
|
||||
// — that prefix is the per-customer identity namespace the agent
|
||||
// maps to usage samples.
|
||||
if protocol == "socks5-auth" {
|
||||
if v, ok := body["socksUser"]; ok {
|
||||
if user := CleanText(v, 128); user != "" {
|
||||
if strings.HasPrefix(user, "ng:") || strings.ContainsAny(user, " \t\r\n") {
|
||||
return nil, httpErr(400, "SOCKS5 用户名不能以 ng: 开头或包含空白字符")
|
||||
}
|
||||
next.SocksUser = user
|
||||
}
|
||||
}
|
||||
if v, ok := body["socksPassword"]; ok {
|
||||
if password := CleanText(v, 512); password != "" {
|
||||
next.SocksPassword = password
|
||||
}
|
||||
}
|
||||
} else {
|
||||
next.SocksUser = ""
|
||||
next.SocksPassword = ""
|
||||
}
|
||||
if IsRealityProtocol(protocol) && next.RealityServerName == "" {
|
||||
return nil, httpErr(400, "Reality SNI 不能为空")
|
||||
}
|
||||
@@ -481,9 +685,92 @@ func PublicCustomer(item *Customer, data *Data) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// credentialItems mirrors credentialItems in server.js.
|
||||
func credentialItems(d *Data, customer *Customer) []map[string]any {
|
||||
items := []map[string]any{}
|
||||
// CustomerSummary computes the customers-page stats strip, mirroring
|
||||
// 3x-ui's client summary semantics (client_paging.go): total / online /
|
||||
// depleted / depletingSoon / disabled / enabled. "depleted" is 3x-ui's
|
||||
// depletedExpr — traffic exhausted (limit > 0 and used >= limit) OR expiry
|
||||
// reached — counted regardless of status. "depletingSoon" is its
|
||||
// expiringExpr — status active, not depleted, and either the expiry is
|
||||
// less than settings.expireDiff days away or the remaining traffic is
|
||||
// less than settings.trafficDiff GB. Both thresholds default to 0 (the
|
||||
// 3x-ui factory default), under which nobody counts as depleting soon.
|
||||
// "disabled" counts manual suspensions only; auto-blocks (expired /
|
||||
// traffic_limit / ip_limit) surface through depleted instead.
|
||||
func CustomerSummary(data *Data, nowMs int64) map[string]any {
|
||||
expireDiffMs := data.Settings.ExpireDiff * 86400000
|
||||
trafficDiffBytes := data.Settings.TrafficDiff * 1073741824
|
||||
total, online, depleted, depletingSoon, disabled, enabled := len(data.Customers), 0, 0, 0, 0, 0
|
||||
for _, c := range data.Customers {
|
||||
if CustomerOnline(c, nowMs) {
|
||||
online++
|
||||
}
|
||||
if c.Status == "active" {
|
||||
enabled++
|
||||
}
|
||||
if c.Status == "suspended" && strVal(c.SuspendReason) == "manual" {
|
||||
disabled++
|
||||
}
|
||||
var expiryMs int64 = -1
|
||||
if c.ExpiresAt != nil {
|
||||
if ms, ok := ParseISOMs(*c.ExpiresAt); ok {
|
||||
expiryMs = ms
|
||||
}
|
||||
}
|
||||
isDepleted := (c.TrafficLimitBytes > 0 && float64(c.UsedBytes) >= c.TrafficLimitBytes) ||
|
||||
(expiryMs >= 0 && expiryMs <= nowMs)
|
||||
if isDepleted {
|
||||
depleted++
|
||||
continue
|
||||
}
|
||||
if c.Status != "active" {
|
||||
continue
|
||||
}
|
||||
nearExpiry := expiryMs >= 0 && float64(expiryMs-nowMs) < expireDiffMs
|
||||
nearLimit := c.TrafficLimitBytes > 0 && c.TrafficLimitBytes-float64(c.UsedBytes) < trafficDiffBytes
|
||||
if nearExpiry || nearLimit {
|
||||
depletingSoon++
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"total": total, "online": online, "depleted": depleted,
|
||||
"depletingSoon": depletingSoon, "disabled": disabled, "enabled": enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// realityPublicKeyOf returns the node's panel-hosted Reality public key as
|
||||
// reported back by the agent ("" when unknown). The keypair is the server's
|
||||
// identity — it stays per-node even though customer credentials are shared.
|
||||
func realityPublicKeyOf(node *Node) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
if raw, ok := node.Artifacts["realityPublicKey"]; ok && len(raw) > 0 {
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// customerCredentialsView is the credentials-tab payload (round-2 item 2):
|
||||
// the customer's ONE credential set, plus per-binding node facts. The
|
||||
// Reality public key is shown per node because it belongs to the server,
|
||||
// not to the customer.
|
||||
func customerCredentialsView(d *Data, customer *Customer) map[string]any {
|
||||
creds := map[string]any{"clientId": nil, "password": nil, "shortId": nil}
|
||||
if customer.Credentials != nil {
|
||||
if customer.Credentials.ClientID != "" {
|
||||
creds["clientId"] = customer.Credentials.ClientID
|
||||
}
|
||||
if customer.Credentials.RelayPassword != "" {
|
||||
creds["password"] = customer.Credentials.RelayPassword
|
||||
}
|
||||
if customer.Credentials.ShortID != "" {
|
||||
creds["shortId"] = customer.Credentials.ShortID
|
||||
}
|
||||
}
|
||||
bindings := []map[string]any{}
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.CustomerID != customer.ID {
|
||||
continue
|
||||
@@ -494,32 +781,30 @@ func credentialItems(d *Data, customer *Customer) []map[string]any {
|
||||
}
|
||||
username := ""
|
||||
if node.Protocol == "socks5-auth" {
|
||||
username = NodeEmail(customer.ID)
|
||||
// Item 21: the socks identity lives on the node now (fixed
|
||||
// account), not per customer.
|
||||
username = node.SocksUser
|
||||
}
|
||||
creds := nc.Credentials
|
||||
var clientID, password, shortID any
|
||||
if creds.ClientID != "" {
|
||||
clientID = creds.ClientID
|
||||
var publicKey any
|
||||
if key := realityPublicKeyOf(node); key != "" {
|
||||
publicKey = key
|
||||
}
|
||||
if creds.RelayPassword != "" {
|
||||
password = creds.RelayPassword
|
||||
}
|
||||
if creds.ShortID != "" {
|
||||
shortID = creds.ShortID
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"nodeClientId": nc.ID,
|
||||
"nodeId": node.ID,
|
||||
"nodeName": node.Name,
|
||||
"protocol": node.Protocol,
|
||||
"status": nc.Status,
|
||||
"clientId": clientID,
|
||||
"password": password,
|
||||
"shortId": shortID,
|
||||
"username": username,
|
||||
bindings = append(bindings, map[string]any{
|
||||
"nodeClientId": nc.ID,
|
||||
"nodeId": node.ID,
|
||||
"nodeName": node.Name,
|
||||
"protocol": node.Protocol,
|
||||
"status": nc.Status,
|
||||
"username": username,
|
||||
"realityPublicKey": publicKey,
|
||||
})
|
||||
}
|
||||
return items
|
||||
return map[string]any{
|
||||
"customerId": customer.ID,
|
||||
"subscriptionToken": customer.SubscriptionToken,
|
||||
"credentials": creds,
|
||||
"bindings": bindings,
|
||||
}
|
||||
}
|
||||
|
||||
// CustomerBlockReason is customerBlockReason: "expired" / "traffic_limit" /
|
||||
@@ -565,19 +850,29 @@ type adminAuth struct {
|
||||
User *User
|
||||
}
|
||||
|
||||
// CurrentAdmin is currentAdmin.
|
||||
// CurrentAdmin is currentAdmin. Since round-3 item 22 it resolves the
|
||||
// session's user under a read-only View instead of cloning the whole
|
||||
// database: admin auth runs on EVERY request, and the user table is
|
||||
// tiny. The returned User is a copy the caller may read freely.
|
||||
func (a *App) CurrentAdmin(r *http.Request) *adminAuth {
|
||||
session := a.getSessions().Get(r)
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
data := a.store.Snapshot()
|
||||
for _, user := range data.Users {
|
||||
if user.ID == session.UserID && user.Status == "active" {
|
||||
return &adminAuth{Session: session, User: user}
|
||||
var found *User
|
||||
a.store.View(func(d *Data) {
|
||||
for _, user := range d.Users {
|
||||
if user.ID == session.UserID && user.Status == "active" {
|
||||
copied := *user
|
||||
found = &copied
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
if found == nil {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
return &adminAuth{Session: session, User: found}
|
||||
}
|
||||
|
||||
// RequireAdmin is requireAdmin: sends 401/403 itself and returns nil.
|
||||
@@ -594,32 +889,148 @@ func (a *App) RequireAdmin(w http.ResponseWriter, r *http.Request, csrf bool) *a
|
||||
return auth
|
||||
}
|
||||
|
||||
// FindAgentByBearer is findAgentByBearer: fingerprint fast path, then a
|
||||
// full VerifySecret scan over legacy agents without a fingerprint.
|
||||
func (a *App) FindAgentByBearer(r *http.Request) *Agent {
|
||||
header := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
// ---------- agent session tokens (item 25) ----------
|
||||
|
||||
// AgentSessionTokenTTL is how long an issued agent session token stays
|
||||
// valid. Within the TTL the agent authenticates with a SHA-256 hash
|
||||
// compare; after it the agent falls back to one scrypt-verified key
|
||||
// auth and the panel issues a fresh token.
|
||||
const AgentSessionTokenTTL = 24 * time.Hour
|
||||
|
||||
const (
|
||||
agentSessionTokenHeader = "X-Agent-Session-Token"
|
||||
agentSessionTokenExpiresHeader = "X-Agent-Session-Token-Expires"
|
||||
)
|
||||
|
||||
// issuedAgentToken is a freshly minted session token to hand back to
|
||||
// the agent via response headers; only its SHA-256 hash is persisted.
|
||||
type issuedAgentToken struct {
|
||||
Token string
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
// agentBySessionToken resolves a session-token bearer: the stored hash
|
||||
// is compared in constant time and the token must be unexpired and the
|
||||
// agent active. Returns a copy of the agent record, or nil.
|
||||
func (a *App) agentBySessionToken(bearer string) *Agent {
|
||||
sum := sha256.Sum256([]byte(bearer))
|
||||
nowMs := time.Now().UnixMilli()
|
||||
var found *Agent
|
||||
a.store.View(func(d *Data) {
|
||||
for _, agent := range d.Agents {
|
||||
if agent.Status != "active" || agent.SessionTokenHash == nil || agent.SessionTokenExpiresAt == nil {
|
||||
continue
|
||||
}
|
||||
stored, err := hex.DecodeString(*agent.SessionTokenHash)
|
||||
if err != nil || len(stored) != len(sum) {
|
||||
continue
|
||||
}
|
||||
if subtle.ConstantTimeCompare(sum[:], stored) != 1 {
|
||||
continue
|
||||
}
|
||||
expMs, ok := ParseISOMs(*agent.SessionTokenExpiresAt)
|
||||
if !ok || expMs <= nowMs {
|
||||
continue
|
||||
}
|
||||
copied := *agent
|
||||
found = &copied
|
||||
return
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// issueAgentSessionToken mints a session token for the agent, persists
|
||||
// only its SHA-256 hash and expiry, and returns the plaintext for the
|
||||
// response headers. Returns nil when the record is gone or no longer
|
||||
// active (e.g. revoked between verification and issuance).
|
||||
func (a *App) issueAgentSessionToken(agentID string) *issuedAgentToken {
|
||||
token := RandomToken(40)
|
||||
hash := TokenFingerprint(token)
|
||||
expires := ISOFromTime(time.Now().Add(AgentSessionTokenTTL))
|
||||
issued := false
|
||||
_, err := a.store.Transaction(func(d *Data) (any, error) {
|
||||
for _, agent := range d.Agents {
|
||||
if agent.ID == agentID {
|
||||
if agent.Status != "active" {
|
||||
return nil, nil
|
||||
}
|
||||
agent.SessionTokenHash = &hash
|
||||
agent.SessionTokenExpiresAt = &expires
|
||||
issued = true
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil || !issued {
|
||||
return nil
|
||||
}
|
||||
token := header[len("Bearer "):]
|
||||
fingerprint := TokenFingerprint(token)
|
||||
data := a.store.Snapshot()
|
||||
return &issuedAgentToken{Token: token, ExpiresAt: expires}
|
||||
}
|
||||
|
||||
// authenticateAgent resolves the agent behind an /api/agent/* request.
|
||||
// Order: (1) session-token fast path — SHA-256 the bearer and
|
||||
// constant-time compare against the stored token hash of active,
|
||||
// unexpired agents, no scrypt; (2) the original key path — fingerprint
|
||||
// lookup, then a VerifySecret scan over legacy agents without a
|
||||
// fingerprint. A key-path success mints a session token and returns it
|
||||
// in the second value so the handler can hand it to the agent.
|
||||
func (a *App) authenticateAgent(r *http.Request) (*Agent, *issuedAgentToken) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
return nil, nil
|
||||
}
|
||||
bearer := header[len("Bearer "):]
|
||||
|
||||
if agent := a.agentBySessionToken(bearer); agent != nil {
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// Key path: copy candidates out under a read view, then run the
|
||||
// expensive scrypt verification outside the store lock.
|
||||
fingerprint := TokenFingerprint(bearer)
|
||||
var fast *Agent
|
||||
for _, agent := range data.Agents {
|
||||
if agent.Status == "active" && agent.KeyFingerprint != nil && *agent.KeyFingerprint == fingerprint {
|
||||
fast = agent
|
||||
break
|
||||
var legacy []*Agent
|
||||
a.store.View(func(d *Data) {
|
||||
for _, agent := range d.Agents {
|
||||
if agent.Status != "active" {
|
||||
continue
|
||||
}
|
||||
if agent.KeyFingerprint != nil && *agent.KeyFingerprint == fingerprint {
|
||||
if fast == nil {
|
||||
copied := *agent
|
||||
fast = &copied
|
||||
}
|
||||
} else if agent.KeyFingerprint == nil {
|
||||
copied := *agent
|
||||
legacy = append(legacy, &copied)
|
||||
}
|
||||
}
|
||||
})
|
||||
var verified *Agent
|
||||
if fast != nil && VerifySecret(bearer, fast.KeyHash) {
|
||||
verified = fast
|
||||
} else {
|
||||
for _, agent := range legacy {
|
||||
if VerifySecret(bearer, agent.KeyHash) {
|
||||
verified = agent
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if fast != nil && VerifySecret(token, fast.KeyHash) {
|
||||
return fast
|
||||
if verified == nil {
|
||||
return nil, nil
|
||||
}
|
||||
for _, agent := range data.Agents {
|
||||
if agent.Status == "active" && agent.KeyFingerprint == nil && VerifySecret(token, agent.KeyHash) {
|
||||
return agent
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return verified, a.issueAgentSessionToken(verified.ID)
|
||||
}
|
||||
|
||||
// FindAgentByBearer is findAgentByBearer: session-token fast path,
|
||||
// fingerprint lookup, then a full VerifySecret scan over legacy agents
|
||||
// without a fingerprint.
|
||||
func (a *App) FindAgentByBearer(r *http.Request) *Agent {
|
||||
agent, _ := a.authenticateAgent(r)
|
||||
return agent
|
||||
}
|
||||
|
||||
func clearSessionCookie() map[string]string {
|
||||
@@ -791,7 +1202,7 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
|
||||
}
|
||||
for _, old := range d.Agents {
|
||||
if old.ServerID == server.ID {
|
||||
old.Status = "revoked"
|
||||
old.Revoke()
|
||||
}
|
||||
}
|
||||
fp := TokenFingerprint(agentKey)
|
||||
@@ -837,11 +1248,19 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
|
||||
if !strings.HasPrefix(pathname, "/api/agent/") {
|
||||
return false, nil
|
||||
}
|
||||
agent := a.FindAgentByBearer(r)
|
||||
agent, issued := a.authenticateAgent(r)
|
||||
if agent == nil {
|
||||
SendError(w, 401, "Agent 身份校验失败", "invalid_agent")
|
||||
return true, nil
|
||||
}
|
||||
if issued != nil {
|
||||
// Hand the fresh session token to the agent (item 25): it
|
||||
// persists the token and uses it for subsequent requests
|
||||
// instead of its key. Agents that predate tokens ignore
|
||||
// these headers and keep authenticating with the key.
|
||||
w.Header().Set(agentSessionTokenHeader, issued.Token)
|
||||
w.Header().Set(agentSessionTokenExpiresHeader, issued.ExpiresAt)
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost && pathname == "/api/agent/heartbeat" {
|
||||
body, err := ReadJSON(r, defaultMaxBody)
|
||||
@@ -904,6 +1323,7 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
|
||||
SingBoxInstalled: installed,
|
||||
Certificates: certs,
|
||||
SingBoxStatus: singBoxStatus,
|
||||
SingBoxVersion: CleanText(eng["singBoxVersion"], 32),
|
||||
At: now,
|
||||
}
|
||||
}
|
||||
@@ -1496,7 +1916,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
d.Servers = append(d.Servers[:index], d.Servers[index+1:]...)
|
||||
for _, agent := range d.Agents {
|
||||
if agent.ServerID == params["id"] {
|
||||
agent.Status = "revoked"
|
||||
agent.Revoke()
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "delete_server", params["id"], nil)
|
||||
@@ -1649,7 +2069,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
d.Nodes = nodes
|
||||
for _, agent := range d.Agents {
|
||||
if agent.ServerID == server.ID {
|
||||
agent.Status = "revoked"
|
||||
agent.Revoke()
|
||||
}
|
||||
}
|
||||
d.Servers = append(d.Servers[:index], d.Servers[index+1:]...)
|
||||
@@ -1681,7 +2101,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
out["speedDownBps"] = down
|
||||
customers = append(customers, out)
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{"customers": customers}, nil)
|
||||
SendJSON(w, 200, map[string]any{"customers": customers, "summary": CustomerSummary(data, nowMs)}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if params, ok := RoutePattern("/api/customers/:id/access", pathname); ok && r.Method == http.MethodGet {
|
||||
@@ -1803,6 +2223,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
Notes: CleanText(body["notes"], 1000),
|
||||
CreatedAt: NowISO(),
|
||||
UpdatedAt: NowISO(),
|
||||
Credentials: func() *Credentials { c := NewCustomerCredentials(); return &c }(),
|
||||
}
|
||||
// Arm the monthly-reset schedule: it starts with the next
|
||||
// reset instant, it never fires retroactively at creation.
|
||||
@@ -2008,54 +2429,45 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
if customer == nil {
|
||||
return true, httpErr(404, "客户不存在")
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{
|
||||
"customerId": customer.ID,
|
||||
"subscriptionToken": customer.SubscriptionToken,
|
||||
"items": credentialItems(data, customer),
|
||||
}, nil)
|
||||
SendJSON(w, 200, customerCredentialsView(data, customer), nil)
|
||||
return true, nil
|
||||
}
|
||||
if params, ok := RoutePattern("/api/customers/:id/credentials/:ncid/regenerate", pathname); ok && r.Method == http.MethodPost {
|
||||
var responseItem map[string]any
|
||||
if params, ok := RoutePattern("/api/customers/:id/credentials/regenerate", pathname); ok && r.Method == http.MethodPost {
|
||||
var view map[string]any
|
||||
_, err := a.store.Transaction(func(d *Data) (any, error) {
|
||||
customer := findCustomer(d, params["id"])
|
||||
if customer == nil {
|
||||
return nil, httpErr(404, "客户不存在")
|
||||
}
|
||||
var binding *NodeClient
|
||||
// One set for the whole customer: rotating it rotates every
|
||||
// node at once, and every deployed node is rebuilt so the new
|
||||
// users land on the agents.
|
||||
creds := NewCustomerCredentials()
|
||||
customer.Credentials = &creds
|
||||
customer.UpdatedAt = NowISO()
|
||||
rebuilt := map[string]bool{}
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.ID == params["ncid"] && nc.CustomerID == customer.ID {
|
||||
binding = nc
|
||||
break
|
||||
if nc.CustomerID != customer.ID || nc.Status != "active" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if binding == nil {
|
||||
return nil, httpErr(404, "凭据不存在")
|
||||
}
|
||||
node := findNode(d, binding.NodeID)
|
||||
if node == nil {
|
||||
return nil, httpErr(404, "节点不存在")
|
||||
}
|
||||
binding.Credentials = NewNodeClientCredentials(node.Protocol)
|
||||
binding.UpdatedAt = NowISO()
|
||||
if node.Status != "draft" && node.Status != "removing" {
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodeId": node.ID, "node": node.Name})
|
||||
for _, entry := range credentialItems(d, customer) {
|
||||
if entry["nodeClientId"] == binding.ID {
|
||||
responseItem = entry
|
||||
break
|
||||
node := findNode(d, nc.NodeID)
|
||||
if node == nil || node.Status == "draft" || node.Status == "removing" || rebuilt[node.ID] {
|
||||
continue
|
||||
}
|
||||
rebuilt[node.ID] = true
|
||||
// Credential rotation is a binding-class change: it goes
|
||||
// through the coalesced window (item 23), not an
|
||||
// immediate per-node restart.
|
||||
markRebuildPending(node, false)
|
||||
}
|
||||
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodesRebuilt": len(rebuilt)})
|
||||
view = customerCredentialsView(d, customer)
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{"item": responseItem}, nil)
|
||||
SendJSON(w, 200, view, nil)
|
||||
return true, nil
|
||||
}
|
||||
if params, ok := RoutePattern("/api/customers/:id/external-sources/:sid/refresh", pathname); ok && r.Method == http.MethodPost {
|
||||
@@ -2271,6 +2683,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
next.ExitReady = false
|
||||
next.ResourceID = NewID("res")
|
||||
next.ExitResourceID = NewID("res")
|
||||
if manualExit(next) {
|
||||
// Nothing is deployed on a manual landing endpoint, so the
|
||||
// exit side is ready by definition and owns no resource.
|
||||
next.ExitReady = true
|
||||
next.ExitResourceID = ""
|
||||
}
|
||||
next.Artifacts = map[string]json.RawMessage{}
|
||||
next.Error = nil
|
||||
next.PendingDelete = false
|
||||
@@ -2298,6 +2716,52 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
SendJSON(w, 201, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if r.Method == http.MethodPost && pathname == "/api/reality/scan" {
|
||||
// Round-3 item 16: panel-side Reality target scan. Bounded by
|
||||
// parse/probe limits in realityscan.go.
|
||||
body, err := ReadJSON(r, defaultMaxBody)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
input, _ := body["input"].(string)
|
||||
if raw, ok := body["targets"].([]any); ok {
|
||||
parts := []string{}
|
||||
for _, item := range raw {
|
||||
if s, ok := item.(string); ok {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
input = strings.Join(parts, ",")
|
||||
}
|
||||
targets, herr := parseScanTargets(input)
|
||||
if herr != nil {
|
||||
return true, herr
|
||||
}
|
||||
results := scanRealityTargets(targets)
|
||||
SendJSON(w, 200, map[string]any{"results": results}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if params, ok := RoutePattern("/api/nodes/:id/reality-keys", pathname); ok && r.Method == http.MethodGet {
|
||||
// Round-3 item 17: display-only view of the panel-hosted Reality
|
||||
// keypair for the node's server. This NEVER generates a pair —
|
||||
// a missing entry simply reports nulls (the pair is minted by
|
||||
// EnsureRealityKey at deploy time).
|
||||
data := a.store.Snapshot()
|
||||
node := findNode(data, params["id"])
|
||||
if node == nil {
|
||||
return true, httpErr(404, "节点不存在")
|
||||
}
|
||||
if !IsRealityProtocol(node.Protocol) {
|
||||
return true, httpErr(400, "该节点不是 Reality 协议")
|
||||
}
|
||||
keyID := RealityKeyIDForNode(node)
|
||||
var publicKey, privateKey any
|
||||
if entry := data.KeyStore[keyID]; entry != nil {
|
||||
publicKey, privateKey = entry.PublicKey, entry.PrivateKey
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{"keyId": keyID, "publicKey": publicKey, "privateKey": privateKey}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if params, ok := RoutePattern("/api/nodes/:id", pathname); ok && r.Method == http.MethodPatch {
|
||||
body, err := ReadJSON(r, defaultMaxBody)
|
||||
if err != nil {
|
||||
@@ -2313,35 +2777,42 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
}
|
||||
oldServerID := item.ServerID
|
||||
oldExitServerID := strVal(item.ExitServerID)
|
||||
oldExitResourceID := item.ExitResourceID
|
||||
oldForward := forwardNode(item)
|
||||
oldProtocol := item.Protocol
|
||||
next, err := NormalizeNode(body, item, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
*item = *next
|
||||
EnsureNodeSecrets(item)
|
||||
if item.Protocol != oldProtocol {
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.NodeID == item.ID {
|
||||
nc.Credentials = NewNodeClientCredentials(item.Protocol)
|
||||
nc.UpdatedAt = NowISO()
|
||||
}
|
||||
}
|
||||
}
|
||||
if item.ServerID != oldServerID {
|
||||
QueueJob(d, oldServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ResourceID, Kind: "node"})
|
||||
item.EntryReady = false
|
||||
}
|
||||
if oldForward && (!forwardNode(item) || strVal(item.ExitServerID) != oldExitServerID) {
|
||||
QueueJob(d, oldExitServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ExitResourceID, Kind: "node-exit"})
|
||||
if oldForward && oldExitResourceID != "" && (!forwardNode(item) || manualExit(item) || strVal(item.ExitServerID) != oldExitServerID) {
|
||||
QueueJob(d, oldExitServerID, item.ID, "delete_resource", &JobPayload{ResourceID: oldExitResourceID, Kind: "node-exit"})
|
||||
item.ExitReady = false
|
||||
}
|
||||
if manualExit(item) {
|
||||
item.ExitReady = true
|
||||
item.ExitResourceID = ""
|
||||
} else if forwardNode(item) && item.ExitResourceID == "" {
|
||||
// Switching from a manual endpoint back to a managed exit
|
||||
// server needs a fresh exit resource identity.
|
||||
item.ExitResourceID = NewID("res")
|
||||
item.ExitReady = false
|
||||
}
|
||||
if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" {
|
||||
// A node edit ships immediately and consumes any pending
|
||||
// coalesced mark (item 23).
|
||||
clearRebuildPending(item)
|
||||
if err := RebuildNode(d, item, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if manualExit(item) {
|
||||
item.ExitReady = true
|
||||
}
|
||||
item.UpdatedAt = NowISO()
|
||||
Audit(d, actor, "update_node", item.ID, map[string]any{"name": item.Name})
|
||||
return cloneJSONValue(item), nil
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
// Tests for the 2026-10-21 listen hotfix: inbounds bind the wildcard
|
||||
// address of their stack instead of the server's registered public
|
||||
// address, so intranet addresses (relay landings) and additional v6
|
||||
// addresses are served. The registered address remains the connection
|
||||
// address (serverAddress) and is untouched by these tests.
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestListenAddressWildcardHotfix(t *testing.T) {
|
||||
v4Server := &Server{ID: "s4", PublicAddress: "203.0.113.10"}
|
||||
v6Server := &Server{ID: "s6", PublicAddressV6: "2001:db8::10"}
|
||||
dualServer := &Server{ID: "sd", PublicAddress: "203.0.113.10", PublicAddressV6: "2001:db8::10"}
|
||||
cases := []struct {
|
||||
name string
|
||||
mode string
|
||||
srv *Server
|
||||
bind string
|
||||
want string
|
||||
}{
|
||||
{"ipv4 with registered public address binds wildcard", "ipv4", v4Server, "", "0.0.0.0"},
|
||||
{"ipv4 explicit bind wins", "ipv4", v4Server, "192.0.2.5", "192.0.2.5"},
|
||||
{"ipv6 with registered v6 address binds wildcard", "ipv6", v6Server, "", "::"},
|
||||
{"ipv6 without registered address binds wildcard", "ipv6", nil, "", "::"},
|
||||
{"dual binds wildcard", "dual", dualServer, "", "::"},
|
||||
{"ipv6 explicit bind wins", "ipv6", v6Server, "2001:db8::99", "2001:db8::99"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := listenAddress(c.mode, c.srv, c.bind); got != c.want {
|
||||
t.Errorf("%s: listenAddress(%q) = %q, want %q", c.name, c.mode, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveBindAddress fed the pin into every deploy: it resolved the
|
||||
// registered public address into the bind address. It must no longer
|
||||
// return a pin for any stack.
|
||||
func TestResolveBindAddressNoPin(t *testing.T) {
|
||||
srv := &Server{ID: "s", PublicAddress: "203.0.113.10", PublicAddressV6: "2001:db8::10"}
|
||||
for _, mode := range []string{"ipv4", "ipv6", "dual"} {
|
||||
got, err := ResolveBindAddress(mode, srv)
|
||||
if err != nil {
|
||||
t.Fatalf("mode %s: unexpected error: %v", mode, err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("mode %s: ResolveBindAddress = %q, want empty (no pin)", mode, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstInbound(t *testing.T, res map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
inbounds, ok := res["inbounds"].([]any)
|
||||
if !ok || len(inbounds) == 0 {
|
||||
t.Fatalf("resource has no inbounds: %v", res)
|
||||
}
|
||||
ib, ok := inbounds[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("inbound is not a map: %T", inbounds[0])
|
||||
}
|
||||
return ib
|
||||
}
|
||||
|
||||
// Resource-level smoke through BuildNodeResource (the builder the
|
||||
// brief names): a SOCKS5 landing node on a server with a registered
|
||||
// public v4 address must come out listening on 0.0.0.0.
|
||||
func TestBuildNodeResourceSocksListenWildcard(t *testing.T) {
|
||||
server := &Server{ID: "srv_l1", Name: "L1", PublicAddress: "203.0.113.10"}
|
||||
node := &Node{
|
||||
ID: "node_sk5", Name: "SK5", ServerID: "srv_l1",
|
||||
Protocol: "socks5-auth", Topology: "direct",
|
||||
NetworkMode: "ipv4", Port: 47814,
|
||||
}
|
||||
res, err := BuildNodeResource(BuildNodeResourceParams{
|
||||
ResourceID: "res_sk5", TagPrefix: "sk5",
|
||||
Node: node, Server: server,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
ib := firstInbound(t, res)
|
||||
if ib["listen"] != "0.0.0.0" {
|
||||
t.Errorf("listen = %v, want 0.0.0.0", ib["listen"])
|
||||
}
|
||||
if ib["listen_port"] != 47814 {
|
||||
t.Errorf("listen_port = %v, want 47814", ib["listen_port"])
|
||||
}
|
||||
}
|
||||
|
||||
// The true deploy chain: resourceForNode runs ResolveBindAddress
|
||||
// before BuildNodeResource. With the hotfix the produced resources
|
||||
// must carry the wildcard listen for both stacks — an ipv4 SOCKS5
|
||||
// landing (the reported intranet-relay case) and an ipv6 SS2022 node
|
||||
// on a server with a registered v6 address.
|
||||
func TestResourceForNodeListenWildcard(t *testing.T) {
|
||||
d := &Data{Servers: []*Server{
|
||||
{ID: "srv_l1", Name: "L1", PublicAddress: "203.0.113.10"},
|
||||
{ID: "srv_v6", Name: "V6", PublicAddressV6: "2001:db8::10"},
|
||||
}}
|
||||
|
||||
socks := &Node{
|
||||
ID: "node_sk5", Name: "SK5", ServerID: "srv_l1",
|
||||
Protocol: "socks5-auth", Topology: "direct",
|
||||
NetworkMode: "ipv4", Port: 47814, ResourceID: "res_sk5",
|
||||
}
|
||||
res, err := resourceForNode(d, socks, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resourceForNode socks: %v", err)
|
||||
}
|
||||
ib := firstInbound(t, res)
|
||||
if ib["listen"] != "0.0.0.0" || ib["listen_port"] != 47814 {
|
||||
t.Errorf("socks inbound listen=%v port=%v, want 0.0.0.0:47814", ib["listen"], ib["listen_port"])
|
||||
}
|
||||
|
||||
ss := &Node{
|
||||
ID: "node_ss6", Name: "SS6", ServerID: "srv_v6",
|
||||
Protocol: "shadowsocks-2022-aes256", Topology: "direct",
|
||||
NetworkMode: "ipv6", Port: 47815, ResourceID: "res_ss6",
|
||||
}
|
||||
res, err = resourceForNode(d, ss, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resourceForNode ss2022: %v", err)
|
||||
}
|
||||
ib = firstInbound(t, res)
|
||||
if ib["listen"] != "::" || ib["listen_port"] != 47815 {
|
||||
t.Errorf("ss2022 inbound listen=%v port=%v, want :::47815", ib["listen"], ib["listen_port"])
|
||||
}
|
||||
}
|
||||
|
||||
// The exit-resource chain (resourceForNodeExit -> BuildNodeExitResource)
|
||||
// is the other listenAddress call site: an ipv4 exit server with a
|
||||
// registered public address must also produce a wildcard listen.
|
||||
func TestResourceForNodeExitListenWildcard(t *testing.T) {
|
||||
exitSrv := "srv_exit"
|
||||
exitPort := 47816
|
||||
exitProto := "socks5-auth"
|
||||
d := &Data{Servers: []*Server{
|
||||
{ID: "srv_entry", Name: "Entry", PublicAddress: "198.51.100.20"},
|
||||
{ID: exitSrv, Name: "Exit", PublicAddress: "203.0.113.10"},
|
||||
}}
|
||||
node := &Node{
|
||||
ID: "node_fwd", Name: "FWD", ServerID: "srv_entry",
|
||||
Protocol: "vless-tcp", Topology: "forward",
|
||||
NetworkMode: "ipv4", Port: 47817, ResourceID: "res_fwd",
|
||||
ExitServerID: &exitSrv, ExitPort: &exitPort, ExitProtocol: &exitProto,
|
||||
ExitResourceID: "res_fwd_exit",
|
||||
}
|
||||
res, err := resourceForNodeExit(d, node)
|
||||
if err != nil {
|
||||
t.Fatalf("resourceForNodeExit: %v", err)
|
||||
}
|
||||
ib := firstInbound(t, res)
|
||||
if ib["listen"] != "0.0.0.0" || ib["listen_port"] != 47816 {
|
||||
t.Errorf("exit inbound listen=%v port=%v, want 0.0.0.0:47816", ib["listen"], ib["listen_port"])
|
||||
}
|
||||
}
|
||||
@@ -407,6 +407,17 @@ func run() error {
|
||||
app.housekeeping()
|
||||
}
|
||||
}()
|
||||
// Coalesced rebuild flusher (round-3 item 23): applies pending
|
||||
// binding-class rebuilds once their fixed window elapses. Pending
|
||||
// marks are persisted, so this also drains whatever a previous
|
||||
// process left mid-window.
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
_, _ = app.orchestrator.FlushPendingRebuilds(time.Now())
|
||||
}
|
||||
}()
|
||||
fmt.Printf("NexusGate v%s listening on http://%s:%d\n", VERSION, host, port)
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
// Tests for manual landing endpoints (round-2 item 8): NormalizeNode
|
||||
// validation, entry-side outbound generation, and the no-exit-job rule.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manualData() *Data {
|
||||
return &Data{
|
||||
Servers: []*Server{{ID: "srv_entry", Name: "Entry", PublicAddress: "entry.example.com"}},
|
||||
}
|
||||
}
|
||||
|
||||
func manualBody() map[string]any {
|
||||
return map[string]any{
|
||||
"name": "Manual", "serverId": "srv_entry", "protocol": "vless-reality-vision",
|
||||
"topology": "forward", "exitMode": "manual", "exitAddress": "203.0.113.9",
|
||||
"exitProtocol": "shadowsocks-2022-aes128", "exitPort": json.Number("23464"),
|
||||
"exitCredentials": map[string]any{"password": "FRYXGBkaGxwdHh8gISIjJA==:FRYXGBkaGxwdHh8gISIjJA=="},
|
||||
"portMode": "fixed", "port": json.Number("23465"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeManualExit(t *testing.T) {
|
||||
node, err := NormalizeNode(manualBody(), nil, manualData())
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
if node.ExitMode != "manual" || node.ExitAddress != "203.0.113.9" {
|
||||
t.Fatalf("mode/address not stored: %q %q", node.ExitMode, node.ExitAddress)
|
||||
}
|
||||
if node.ExitServerID != nil {
|
||||
t.Fatalf("manual node must not reference an exit server")
|
||||
}
|
||||
if node.ExitPort == nil || *node.ExitPort != 23464 {
|
||||
t.Fatalf("manual port not kept: %v", node.ExitPort)
|
||||
}
|
||||
if node.ExitPortMode == nil || *node.ExitPortMode != "fixed" {
|
||||
t.Fatalf("manual port mode must be fixed")
|
||||
}
|
||||
if node.ExitCredentials == nil || node.ExitCredentials.Password != "FRYXGBkaGxwdHh8gISIjJA==:FRYXGBkaGxwdHh8gISIjJA==" {
|
||||
t.Fatalf("manual credentials not stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeManualExitValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(map[string]any)
|
||||
}{
|
||||
{"missing address", func(b map[string]any) { delete(b, "exitAddress") }},
|
||||
{"address with scheme", func(b map[string]any) { b["exitAddress"] = "https://x.example.com" }},
|
||||
{"missing port", func(b map[string]any) { delete(b, "exitPort") }},
|
||||
{"missing ss password", func(b map[string]any) { b["exitCredentials"] = map[string]any{} }},
|
||||
{"ss2022 password not base64", func(b map[string]any) {
|
||||
b["exitCredentials"] = map[string]any{"password": "not-a-base64-key"}
|
||||
}},
|
||||
{"ss2022 key wrong length", func(b map[string]any) {
|
||||
// A 16-byte key against the aes256 method (needs 32).
|
||||
b["exitCredentials"] = map[string]any{"password": "AAAAAAAAAAAAAAAAAAAAAA=="}
|
||||
b["exitProtocol"] = "shadowsocks-2022-aes256"
|
||||
}},
|
||||
{"vless without uuid", func(b map[string]any) {
|
||||
b["exitProtocol"] = "vless-tcp"
|
||||
b["exitCredentials"] = map[string]any{"password": "x"}
|
||||
}},
|
||||
{"socks without user", func(b map[string]any) {
|
||||
b["exitProtocol"] = "socks5-auth"
|
||||
b["exitCredentials"] = map[string]any{"password": "x"}
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
body := manualBody()
|
||||
tc.mutate(body)
|
||||
if _, err := NormalizeNode(body, nil, manualData()); err == nil {
|
||||
t.Fatalf("%s: expected an error", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualExitResourceAndJobs(t *testing.T) {
|
||||
node, err := NormalizeNode(manualBody(), nil, manualData())
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
node.ID = "nod_manual"
|
||||
node.ResourceID = "res_manual"
|
||||
node.ExitResourceID = ""
|
||||
node.ExitReady = true
|
||||
node.Status = "active"
|
||||
customer := &Customer{ID: "cus_1", Name: "C", Credentials: &Credentials{
|
||||
ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "pw", ShortID: "abcd0001ef01",
|
||||
}}
|
||||
d := manualData()
|
||||
d.Nodes = []*Node{node}
|
||||
d.Customers = []*Customer{customer}
|
||||
d.NodeClients = []*NodeClient{{ID: "ncl_1", NodeID: node.ID, CustomerID: "cus_1", Status: "active"}}
|
||||
|
||||
resource, err := resourceForNode(d, node, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resourceForNode: %v", err)
|
||||
}
|
||||
outbounds := resource["outbounds"].([]any)
|
||||
outbound := outbounds[0].(map[string]any)
|
||||
if outbound["server"] != "203.0.113.9" || outbound["type"] != "shadowsocks" {
|
||||
t.Fatalf("outbound does not target the manual endpoint: %v", outbound)
|
||||
}
|
||||
if outbound["password"] != "FRYXGBkaGxwdHh8gISIjJA==:FRYXGBkaGxwdHh8gISIjJA==" {
|
||||
t.Fatalf("outbound password wrong: %v", outbound["password"])
|
||||
}
|
||||
rules := resource["routingRules"].([]any)
|
||||
if rules[0].(map[string]any)["outbound"] != outbound["tag"] {
|
||||
t.Fatalf("routing rule does not point at the exit outbound")
|
||||
}
|
||||
|
||||
jobsBefore := len(d.Jobs)
|
||||
if err := RebuildNode(d, node, true); err != nil {
|
||||
t.Fatalf("RebuildNode: %v", err)
|
||||
}
|
||||
for _, job := range d.Jobs[jobsBefore:] {
|
||||
if job.Payload != nil && job.Payload.Kind == "node-exit" {
|
||||
t.Fatalf("manual node must not queue exit jobs")
|
||||
}
|
||||
if job.ServerID != "srv_entry" {
|
||||
t.Fatalf("job queued for a non-entry server: %s", job.ServerID)
|
||||
}
|
||||
}
|
||||
if !node.ExitReady {
|
||||
t.Fatalf("manual node must stay exit-ready")
|
||||
}
|
||||
|
||||
// Deploy validation must not look up an exit server for manual nodes
|
||||
// (regression: this dereferenced a nil exit server and panicked).
|
||||
d.Servers[0].Status = "online"
|
||||
if err := ValidateNodeDeploy(d, node); err != nil {
|
||||
t.Fatalf("ValidateNodeDeploy for a manual node: %v", err)
|
||||
}
|
||||
}
|
||||
+153
-60
@@ -59,6 +59,13 @@ func forwardNode(node *Node) bool {
|
||||
return topology == "forward"
|
||||
}
|
||||
|
||||
// manualExit reports whether a forward node's exit is an unmanaged landing
|
||||
// endpoint (round-2 item 8): no exit server, no exit resource, nothing to
|
||||
// deploy on the far side.
|
||||
func manualExit(node *Node) bool {
|
||||
return forwardNode(node) && node.ExitMode == "manual"
|
||||
}
|
||||
|
||||
func tagForNode(node *Node) string {
|
||||
var sb strings.Builder
|
||||
for _, r := range node.ID {
|
||||
@@ -239,6 +246,16 @@ func payloadWithResource(resource map[string]any, kind string, keyMaterial map[s
|
||||
return payload
|
||||
}
|
||||
|
||||
// ensureCustomerCredentials fills a missing customer-level credential set
|
||||
// (defensive: the store-load migration and customer creation provide one).
|
||||
func ensureCustomerCredentials(d *Data, customerID string) {
|
||||
customer := findCustomer(d, customerID)
|
||||
if customer != nil && (customer.Credentials == nil || customer.Credentials.ClientID == "") {
|
||||
creds := NewCustomerCredentials()
|
||||
customer.Credentials = &creds
|
||||
}
|
||||
}
|
||||
|
||||
func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any, error) {
|
||||
server := orchFindServer(d, node.ServerID)
|
||||
if server == nil {
|
||||
@@ -253,7 +270,10 @@ func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any
|
||||
return nil, err
|
||||
}
|
||||
var exitServer *Server
|
||||
if forwardNode(node) {
|
||||
exitAddress := ""
|
||||
if manualExit(node) {
|
||||
exitAddress = node.ExitAddress
|
||||
} else if forwardNode(node) {
|
||||
exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
|
||||
if exitServer == nil {
|
||||
return nil, orchNotFound("出口服务器")
|
||||
@@ -262,14 +282,30 @@ func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any
|
||||
if clients == nil {
|
||||
clients = activeNodeClients(d, node)
|
||||
}
|
||||
// Credentials live on the customer (round-2 item 2); stored bindings
|
||||
// carry none. Stamp each customer's set onto throwaway copies for the
|
||||
// resource builder so the stored records stay clean.
|
||||
resolved := make([]*NodeClient, len(clients))
|
||||
for i, nc := range clients {
|
||||
ensureCustomerCredentials(d, nc.CustomerID)
|
||||
cp := *nc
|
||||
if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil {
|
||||
creds := *customer.Credentials
|
||||
cp.Credentials = &creds
|
||||
} else {
|
||||
cp.Credentials = nil
|
||||
}
|
||||
resolved[i] = &cp
|
||||
}
|
||||
return BuildNodeResource(BuildNodeResourceParams{
|
||||
ResourceID: node.ResourceID,
|
||||
TagPrefix: tagForNode(node),
|
||||
Node: node,
|
||||
NodeClients: clients,
|
||||
NodeClients: resolved,
|
||||
Server: server,
|
||||
BindAddress: bindAddress,
|
||||
ExitServer: exitServer,
|
||||
ExitAddress: exitAddress,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -309,21 +345,12 @@ func cancelQueuedDeletes(d *Data, nodeID, reason string) bool {
|
||||
}
|
||||
|
||||
func RebuildNode(d *Data, node *Node, includeExit bool) error {
|
||||
// Round-3 item 20: a node whose bindings drop to zero is rebuilt
|
||||
// like any other (its inbound just ends up with no customer users);
|
||||
// it is NOT auto-removed. The pre-item-20 auto-removal branch is
|
||||
// gone; the AutoRemoving revival paths in assign/resume stay for
|
||||
// records an older panel left in that state.
|
||||
clients := activeNodeClients(d, node)
|
||||
if len(clients) == 0 {
|
||||
if node.Status != "draft" && node.Status != "removing" {
|
||||
QueueJob(d, node.ServerID, node.ID, "delete_resource", &JobPayload{ResourceID: node.ResourceID, Kind: "node"})
|
||||
if forwardNode(node) && node.ExitResourceID != "" {
|
||||
QueueJob(d, xbDeref(node.ExitServerID), node.ID, "delete_resource", &JobPayload{ResourceID: node.ExitResourceID, Kind: "node-exit"})
|
||||
}
|
||||
node.Status = "removing"
|
||||
node.AutoRemoving = BoolPtr(true)
|
||||
node.PendingDelete = false
|
||||
node.Error = nil
|
||||
}
|
||||
node.UpdatedAt = NowISO()
|
||||
return nil
|
||||
}
|
||||
node.AutoRemoving = BoolPtr(false)
|
||||
resource, err := resourceForNode(d, node, clients)
|
||||
if err != nil {
|
||||
@@ -331,7 +358,7 @@ func RebuildNode(d *Data, node *Node, includeExit bool) error {
|
||||
}
|
||||
payload := payloadWithResource(resource, "node", KeyMaterialForNode(d, node))
|
||||
QueueJob(d, node.ServerID, node.ID, "apply_resource", payload)
|
||||
if forwardNode(node) && includeExit {
|
||||
if forwardNode(node) && includeExit && !manualExit(node) {
|
||||
exitResource, err := resourceForNodeExit(d, node)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -351,7 +378,7 @@ func DeriveNodeStatus(node *Node) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if forwardNode(node) && !node.ExitReady {
|
||||
if forwardNode(node) && !node.ExitReady && !manualExit(node) {
|
||||
if node.Status != "degraded" {
|
||||
node.Status = "deploying"
|
||||
}
|
||||
@@ -361,6 +388,75 @@ func DeriveNodeStatus(node *Node) {
|
||||
node.Error = nil
|
||||
}
|
||||
|
||||
// ---------- coalesced rebuilds (round-3 item 23) ----------
|
||||
|
||||
// RebuildCoalesceWindow is the FIXED coalescing window for binding-class
|
||||
// rebuilds: it starts at a node's first pending change and is never
|
||||
// extended by later changes inside it, so a burst of assign/unassign
|
||||
// operations can never postpone the dispatch indefinitely. When the
|
||||
// window elapses the flusher applies exactly one rebuild with the
|
||||
// node's final state.
|
||||
const RebuildCoalesceWindow = 10 * time.Second
|
||||
|
||||
// markRebuildPending records a binding-class change for the coalesced
|
||||
// flusher instead of rebuilding (and restarting the node's sing-box)
|
||||
// immediately. The window is anchored at the first pending change:
|
||||
// re-marking inside the window only widens the exit flag.
|
||||
func markRebuildPending(node *Node, includeExit bool) {
|
||||
if !node.RebuildPending {
|
||||
node.RebuildPending = true
|
||||
node.RebuildPendingAt = NowISO()
|
||||
}
|
||||
if includeExit {
|
||||
node.RebuildPendingExit = true
|
||||
}
|
||||
node.UpdatedAt = NowISO()
|
||||
}
|
||||
|
||||
// clearRebuildPending drops a pending coalesced rebuild — used when an
|
||||
// immediate action (manual deploy, repair, node edit, removal, server
|
||||
// reconcile) already ships the node's current state or ends its life.
|
||||
func clearRebuildPending(node *Node) {
|
||||
node.RebuildPending = false
|
||||
node.RebuildPendingAt = ""
|
||||
node.RebuildPendingExit = false
|
||||
}
|
||||
|
||||
// FlushPendingRebuilds applies one rebuild per node whose fixed window
|
||||
// has elapsed, with the node's state as of now. Pending marks on nodes
|
||||
// that were deleted, are being removed, or fell back to draft are
|
||||
// discarded — a pending apply must never resurrect a node. Called once
|
||||
// per second from the panel's background loop; because the mark is
|
||||
// persisted in the data file, a panel restart inside the window simply
|
||||
// means the first post-boot pass flushes whatever is due.
|
||||
func (o *Orchestrator) FlushPendingRebuilds(now time.Time) (int, error) {
|
||||
flushed := 0
|
||||
err := o.tx(func(d *Data) error {
|
||||
for _, node := range d.Nodes {
|
||||
if !node.RebuildPending {
|
||||
continue
|
||||
}
|
||||
at, ok := ParseISO(node.RebuildPendingAt)
|
||||
if !ok || now.Sub(at) < RebuildCoalesceWindow {
|
||||
continue
|
||||
}
|
||||
includeExit := node.RebuildPendingExit
|
||||
clearRebuildPending(node)
|
||||
if node.PendingDelete || node.Status == "removing" || node.Status == "draft" {
|
||||
continue
|
||||
}
|
||||
if err := RebuildNode(d, node, includeExit); err != nil {
|
||||
node.Error = StrPtr(err.Error())
|
||||
Audit(d, "system", "rebuild_flush_failed", node.ID, map[string]any{"error": err.Error()})
|
||||
continue
|
||||
}
|
||||
flushed++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return flushed, err
|
||||
}
|
||||
|
||||
// ---------- customer suspend / resume ----------
|
||||
|
||||
func SuspendCustomerResources(d *Data, customer *Customer, actor string) (int, error) {
|
||||
@@ -382,9 +478,7 @@ func SuspendCustomerResources(d *Data, customer *Customer, actor string) (int, e
|
||||
for _, nodeID := range affected {
|
||||
node := orchFindNode(d, nodeID)
|
||||
if node != nil && node.Status != "draft" && node.Status != "removing" {
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return len(affected), err
|
||||
}
|
||||
markRebuildPending(node, false)
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "suspend_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
|
||||
@@ -435,9 +529,7 @@ func ResumeCustomerResources(d *Data, customer *Customer, actor string) (int, er
|
||||
}
|
||||
}
|
||||
includeExit := forwardNode(node) && !node.ExitReady
|
||||
if err := RebuildNode(d, node, includeExit); err != nil {
|
||||
return len(affected), err
|
||||
}
|
||||
markRebuildPending(node, includeExit)
|
||||
}
|
||||
Audit(d, actor, "resume_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
|
||||
return len(affected), nil
|
||||
@@ -606,14 +698,14 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
|
||||
return orchNotFound("服务器")
|
||||
}
|
||||
var exitServer *Server
|
||||
if !direct {
|
||||
if !direct && !manualExit(node) {
|
||||
exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
|
||||
if exitServer == nil {
|
||||
return orchNotFound("出口服务器")
|
||||
}
|
||||
}
|
||||
targets := []*Server{server}
|
||||
if !direct {
|
||||
if exitServer != nil {
|
||||
targets = append(targets, exitServer)
|
||||
}
|
||||
for _, target := range targets {
|
||||
@@ -621,9 +713,12 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
|
||||
return httpErr(409, "设备 %s 已停用", target.Name)
|
||||
}
|
||||
}
|
||||
if len(activeNodeClients(d, node)) == 0 {
|
||||
return httpErr(400, "请先给节点分配至少一个客户再部署")
|
||||
}
|
||||
// Round-3 item 20: no client-count gate. Nodes are built and deployed
|
||||
// first and bound later (the 3x-ui model); bindings may go to zero
|
||||
// and back without tearing the node down. A zero-client inbound
|
||||
// simply has no users who can connect — including socks5-auth,
|
||||
// whose inbound always carries its node-level fixed account
|
||||
// (item 21), so it never degenerates into an open proxy.
|
||||
switch node.Protocol {
|
||||
case "vless-ws-tls", "hysteria2", "anytls":
|
||||
if server.TLSDomain == "" {
|
||||
@@ -666,7 +761,7 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
|
||||
}
|
||||
}
|
||||
servers := []*Server{server}
|
||||
if !direct {
|
||||
if exitServer != nil {
|
||||
servers = []*Server{server, exitServer}
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
@@ -711,7 +806,7 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
|
||||
if NodePortInUse(d, node.ServerID, node.Port, node.ID) {
|
||||
return httpErr(409, "端口 %d 已被该服务器上的其他节点占用", node.Port)
|
||||
}
|
||||
if !direct && node.ExitPort != nil && NodePortInUse(d, xbDeref(node.ExitServerID), *node.ExitPort, node.ID) {
|
||||
if !direct && !manualExit(node) && node.ExitPort != nil && NodePortInUse(d, xbDeref(node.ExitServerID), *node.ExitPort, node.ID) {
|
||||
return httpErr(409, "出口端口 %d 已被该服务器上的其他节点占用", *node.ExitPort)
|
||||
}
|
||||
return nil
|
||||
@@ -771,9 +866,6 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
|
||||
}
|
||||
} else {
|
||||
clients := activeNodeClients(d, node)
|
||||
if len(clients) == 0 {
|
||||
continue
|
||||
}
|
||||
if isEntry {
|
||||
resource, err := resourceForNode(d, node, clients)
|
||||
if err != nil {
|
||||
@@ -788,6 +880,9 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
|
||||
}
|
||||
QueueJob(d, serverID, node.ID, "apply_resource", payloadWithResource(resource, "node-exit", nil))
|
||||
}
|
||||
// The reconcile just shipped the node's current state; a
|
||||
// coalesced pending mark for it is satisfied.
|
||||
clearRebuildPending(node)
|
||||
node.Status = "deploying"
|
||||
}
|
||||
node.UpdatedAt = NowISO()
|
||||
@@ -871,6 +966,9 @@ func (o *Orchestrator) DeployNode(nodeID, actor string) (*Node, error) {
|
||||
if err := ValidateNodeDeploy(d, node); err != nil {
|
||||
return err
|
||||
}
|
||||
// Manual deploy is immediate (item 23 constraint 1): it ships the
|
||||
// current state now and consumes any coalesced pending mark.
|
||||
clearRebuildPending(node)
|
||||
if err := RebuildNode(d, node, true); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -892,6 +990,9 @@ func (o *Orchestrator) RemoveNode(nodeID, actor string, deleteRecord bool) (bool
|
||||
}
|
||||
node.PendingDelete = deleteRecord
|
||||
node.AutoRemoving = BoolPtr(false)
|
||||
// A pending coalesced apply must never resurrect a node that is
|
||||
// being removed (item 23 constraint 2).
|
||||
clearRebuildPending(node)
|
||||
if node.Status != "draft" {
|
||||
QueueJob(d, node.ServerID, node.ID, "delete_resource", &JobPayload{ResourceID: node.ResourceID, Kind: "node"})
|
||||
if forwardNode(node) && node.ExitResourceID != "" {
|
||||
@@ -939,6 +1040,7 @@ func (o *Orchestrator) RepairNode(nodeID, actor string) (bool, error) {
|
||||
if node.Status != "failed" && node.Status != "degraded" {
|
||||
return httpErr(409, "节点状态正常,无需修复")
|
||||
}
|
||||
clearRebuildPending(node)
|
||||
if err := RebuildNode(d, node, true); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -977,14 +1079,14 @@ func assignCustomerData(d *Data, nodeID, customerID, actor string) (*NodeClient,
|
||||
nc.UpdatedAt = NowISO()
|
||||
} else {
|
||||
now := NowISO()
|
||||
ensureCustomerCredentials(d, customerID)
|
||||
nc = &NodeClient{
|
||||
ID: NewID("ncl"),
|
||||
NodeID: nodeID,
|
||||
CustomerID: customerID,
|
||||
Credentials: NewNodeClientCredentials(node.Protocol),
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: NewID("ncl"),
|
||||
NodeID: nodeID,
|
||||
CustomerID: customerID,
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
d.NodeClients = append(d.NodeClients, nc)
|
||||
}
|
||||
@@ -995,9 +1097,7 @@ func assignCustomerData(d *Data, nodeID, customerID, actor string) (*NodeClient,
|
||||
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
||||
node.AutoRemoving = BoolPtr(false)
|
||||
}
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
markRebuildPending(node, false)
|
||||
}
|
||||
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
|
||||
return nc, nil
|
||||
@@ -1020,9 +1120,7 @@ func unassignCustomerData(d *Data, nodeID, customerID, actor string) error {
|
||||
}
|
||||
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
|
||||
if node.Status != "draft" && node.Status != "removing" {
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return err
|
||||
}
|
||||
markRebuildPending(node, false)
|
||||
}
|
||||
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
|
||||
return nil
|
||||
@@ -1077,14 +1175,14 @@ func AlignCustomerNodes(d *Data, customer *Customer, desiredIds []string, actor
|
||||
status = "active"
|
||||
}
|
||||
now := NowISO()
|
||||
ensureCustomerCredentials(d, customer.ID)
|
||||
d.NodeClients = append(d.NodeClients, &NodeClient{
|
||||
ID: NewID("ncl"),
|
||||
NodeID: nodeID,
|
||||
CustomerID: customer.ID,
|
||||
Credentials: NewNodeClientCredentials(node.Protocol),
|
||||
Status: status,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: NewID("ncl"),
|
||||
NodeID: nodeID,
|
||||
CustomerID: customer.ID,
|
||||
Status: status,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customer.ID, "customer": customer.Name})
|
||||
if status == "active" {
|
||||
@@ -1103,9 +1201,7 @@ func AlignCustomerNodes(d *Data, customer *Customer, desiredIds []string, actor
|
||||
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
||||
node.AutoRemoving = BoolPtr(false)
|
||||
}
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return err
|
||||
}
|
||||
markRebuildPending(node, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1181,9 +1277,6 @@ func (o *Orchestrator) ReconcileFromHeartbeat(serverID string, reportedResourceI
|
||||
if node.Status == "draft" || node.Status == "removing" {
|
||||
continue
|
||||
}
|
||||
if len(activeNodeClients(d, node)) == 0 {
|
||||
continue
|
||||
}
|
||||
if node.ServerID == serverID && node.ResourceID != "" && !reported[node.ResourceID] {
|
||||
missing[node.ID] = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
// Benchmarks for round-3 item 22 (per-request full-clone cost). Not run
|
||||
// by `go test` without -bench. The seed is synthetic data at the scale
|
||||
// fixed for this round's acceptance: 20 servers / 40 customers /
|
||||
// 40 nodes / ~160 bindings (20-agent steady-state tests use the same
|
||||
// scale).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func perfSeedData() *Data {
|
||||
d := EmptyData()
|
||||
d.Users = []*User{{ID: "usr_1", Username: "admin", PasswordHash: "x", Role: "owner", Status: "active", CreatedAt: NowISO()}}
|
||||
for i := 0; i < 20; i++ {
|
||||
d.Servers = append(d.Servers, &Server{
|
||||
ID: fmt.Sprintf("srv_%d", i), Name: fmt.Sprintf("S%d", i), PublicAddress: "203.0.113.1",
|
||||
Status: "online", LastSeenAt: StrPtr(NowISO()), PortRangeStart: 20000, PortRangeEnd: 50000,
|
||||
Labels: []string{}, CreatedAt: NowISO(), UpdatedAt: NowISO(),
|
||||
})
|
||||
}
|
||||
for i := 0; i < 40; i++ {
|
||||
d.Customers = append(d.Customers, &Customer{
|
||||
ID: fmt.Sprintf("cus_%d", i), Name: fmt.Sprintf("C%d", i), Status: "active",
|
||||
SubscriptionToken: fmt.Sprintf("tok-%032d", i),
|
||||
Credentials: &Credentials{ClientID: NewUUID(), RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001"},
|
||||
Tags: []string{}, CreatedAt: NowISO(), UpdatedAt: NowISO(),
|
||||
})
|
||||
}
|
||||
protocols := []string{"vless-reality-vision", "shadowsocks-2022-aes128", "socks5-auth", "vless-ws", "hysteria2"}
|
||||
for i := 0; i < 40; i++ {
|
||||
node := &Node{
|
||||
ID: fmt.Sprintf("nod_%d", i), Name: fmt.Sprintf("N%d", i), ServerID: fmt.Sprintf("srv_%d", i%20),
|
||||
Protocol: protocols[i%len(protocols)], Topology: "direct", NetworkMode: "ipv4", Port: 23000 + i,
|
||||
Status: "active", ResourceID: fmt.Sprintf("res_%d", i), Artifacts: map[string]json.RawMessage{},
|
||||
}
|
||||
if node.Protocol == "socks5-auth" {
|
||||
node.SocksUser, node.SocksPassword = "ngs_fixed", "fixedpass"
|
||||
}
|
||||
if IsRealityProtocol(node.Protocol) {
|
||||
node.Reality = &RealityConfig{KeyID: fmt.Sprintf("reality-nod_%d", i), ServerName: "www.tesla.com", DestPort: 443}
|
||||
}
|
||||
d.Nodes = append(d.Nodes, node)
|
||||
for j := 0; j < 4; j++ {
|
||||
d.NodeClients = append(d.NodeClients, &NodeClient{
|
||||
ID: fmt.Sprintf("nc_%d_%d", i, j), NodeID: node.ID,
|
||||
CustomerID: fmt.Sprintf("cus_%d", (i+j)%40), Status: "active",
|
||||
CreatedAt: NowISO(), UpdatedAt: NowISO(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func BenchmarkCloneJSONRoundTrip(b *testing.B) {
|
||||
d := perfSeedData()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = d.cloneViaJSON()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCloneFast(b *testing.B) {
|
||||
d := perfSeedData()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = d.Clone()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAdminGET(b *testing.B) {
|
||||
cookieSecure = false
|
||||
store := NewStore(filepath.Join(b.TempDir(), "nexusgate.json"))
|
||||
if err := store.Init(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
seed := perfSeedData()
|
||||
seed.Users[0].PasswordHash = HashSecret("password12345")
|
||||
if _, err := store.Transaction(func(d *Data) (any, error) { *d = *seed; return nil, nil }); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
app := NewApp(store, NewSessionManager(12), NewOrchestrator(store))
|
||||
login := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"username":"admin","password":"password12345"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, login)
|
||||
if rec.Code != 200 {
|
||||
b.Fatalf("login %d", rec.Code)
|
||||
}
|
||||
cookie := strings.Split(rec.Header().Get("Set-Cookie"), ";")[0]
|
||||
for _, path := range []string{"/api/overview", "/api/servers", "/api/customers", "/api/nodes", "/api/jobs"} {
|
||||
b.Run(strings.TrimPrefix(path, "/api/"), func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
b.Fatalf("%s -> %d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
// Regression tests for the port re-roll bug (round-2 item 7): a node whose
|
||||
// entry/exit ports were assigned in "random" mode must keep them verbatim
|
||||
// across later edits. The browser form always submits portMode + port (null
|
||||
// in random mode) + serverId, so NormalizeNode must treat an edit as
|
||||
// addressing-neutral unless the user explicitly changes the mode, supplies
|
||||
// a different fixed port, or moves the node to a server where the port is
|
||||
// already taken.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func portLockFixture() (*Data, *Node) {
|
||||
exitID := "srv_exit"
|
||||
exitProto := "shadowsocks-2022-aes256"
|
||||
exitMode := "random"
|
||||
exitPort := 31001
|
||||
node := &Node{
|
||||
ID: "nod_lock", Name: "N1", ServerID: "srv_entry", Topology: "forward",
|
||||
Protocol: "vless-reality-vision", PortMode: "random", Port: 26522,
|
||||
ExitServerID: &exitID, ExitProtocol: &exitProto,
|
||||
ExitPortMode: &exitMode, ExitPort: &exitPort,
|
||||
NetworkMode: "ipv4", RealityServerName: "www.tesla.com", RealityDestPort: 443,
|
||||
}
|
||||
data := &Data{
|
||||
Servers: []*Server{
|
||||
{ID: "srv_entry", Name: "Entry", PortRangeStart: 20000, PortRangeEnd: 50000},
|
||||
{ID: "srv_exit", Name: "Exit", PortRangeStart: 20000, PortRangeEnd: 50000},
|
||||
{ID: "srv_other", Name: "Other", PortRangeStart: 20000, PortRangeEnd: 50000},
|
||||
},
|
||||
Nodes: []*Node{node},
|
||||
}
|
||||
return data, node
|
||||
}
|
||||
|
||||
// The exact payload shape the browser node form submits on save.
|
||||
func uiNodeBody(name string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name, "serverId": "srv_entry", "protocol": "vless-reality-vision",
|
||||
"portMode": "random", "port": nil, "networkMode": "ipv4", "topology": "forward",
|
||||
"exitServerId": "srv_exit", "exitProtocol": "shadowsocks-2022-aes256",
|
||||
"exitPortMode": "random", "exitPort": nil,
|
||||
"realityServerName": "www.tesla.com", "realityDestPort": json.Number("443"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortLockUIEditKeepsPortsVerbatim(t *testing.T) {
|
||||
data, node := portLockFixture()
|
||||
current := node
|
||||
for i := 0; i < 2; i++ {
|
||||
next, err := NormalizeNode(uiNodeBody("N1-renamed"), current, data)
|
||||
if err != nil {
|
||||
t.Fatalf("edit %d: %v", i, err)
|
||||
}
|
||||
if next.Port != 26522 {
|
||||
t.Fatalf("edit %d: entry port re-rolled: got %d want 26522", i, next.Port)
|
||||
}
|
||||
if next.ExitPort == nil || *next.ExitPort != 31001 {
|
||||
t.Fatalf("edit %d: exit port re-rolled: got %v want 31001", i, next.ExitPort)
|
||||
}
|
||||
current = next
|
||||
data.Nodes[0] = next
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortLockRenameOnlyKeepsPorts(t *testing.T) {
|
||||
data, node := portLockFixture()
|
||||
next, err := NormalizeNode(map[string]any{"name": "Other name"}, node, data)
|
||||
if err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
if next.Port != 26522 || next.ExitPort == nil || *next.ExitPort != 31001 {
|
||||
t.Fatalf("rename moved ports: port=%d exit=%v", next.Port, next.ExitPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortLockRandomToFixedPinsCurrentPort(t *testing.T) {
|
||||
data, node := portLockFixture()
|
||||
body := uiNodeBody("N1")
|
||||
body["portMode"] = "fixed" // port key stays null, as the disabled input submits nothing
|
||||
next, err := NormalizeNode(body, node, data)
|
||||
if err != nil {
|
||||
t.Fatalf("mode switch: %v", err)
|
||||
}
|
||||
if next.PortMode != "fixed" || next.Port != 26522 {
|
||||
t.Fatalf("random->fixed should pin current port: mode=%s port=%d", next.PortMode, next.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortLockFixedExplicitPortChangeAndConflict(t *testing.T) {
|
||||
data, node := portLockFixture()
|
||||
node.PortMode = "fixed"
|
||||
body := uiNodeBody("N1")
|
||||
body["portMode"] = "fixed"
|
||||
body["port"] = json.Number("27000")
|
||||
next, err := NormalizeNode(body, node, data)
|
||||
if err != nil {
|
||||
t.Fatalf("fixed change: %v", err)
|
||||
}
|
||||
if next.Port != 27000 {
|
||||
t.Fatalf("fixed explicit port not applied: got %d", next.Port)
|
||||
}
|
||||
// Occupied port on the same server must 409.
|
||||
other := &Node{ID: "nod_other", ServerID: "srv_entry", Port: 28000}
|
||||
data.Nodes = append(data.Nodes, other)
|
||||
body["port"] = json.Number("28000")
|
||||
if _, err := NormalizeNode(body, next, data); err == nil {
|
||||
t.Fatalf("expected conflict error for occupied fixed port")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortLockFixedToRandomRerolls(t *testing.T) {
|
||||
data, node := portLockFixture()
|
||||
node.PortMode = "fixed"
|
||||
body := uiNodeBody("N1") // portMode random in body
|
||||
next, err := NormalizeNode(body, node, data)
|
||||
if err != nil {
|
||||
t.Fatalf("fixed->random: %v", err)
|
||||
}
|
||||
if next.Port < 20000 || next.Port > 50000 {
|
||||
t.Fatalf("re-rolled port out of range: %d", next.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortLockServerMoveKeepsFreePortAndRerollsTaken(t *testing.T) {
|
||||
data, node := portLockFixture()
|
||||
body := uiNodeBody("N1")
|
||||
body["serverId"] = "srv_other"
|
||||
next, err := NormalizeNode(body, node, data)
|
||||
if err != nil {
|
||||
t.Fatalf("server move (free): %v", err)
|
||||
}
|
||||
if next.Port != 26522 {
|
||||
t.Fatalf("server move should keep free port: got %d", next.Port)
|
||||
}
|
||||
// Occupy 26522 on the target server: random mode must roll a replacement.
|
||||
blocker := &Node{ID: "nod_blocker", ServerID: "srv_other", Port: 26522}
|
||||
data.Nodes = append(data.Nodes, blocker)
|
||||
next2, err := NormalizeNode(body, node, data)
|
||||
if err != nil {
|
||||
t.Fatalf("server move (taken): %v", err)
|
||||
}
|
||||
if next2.Port == 26522 {
|
||||
t.Fatalf("server move kept a taken port")
|
||||
}
|
||||
// Fixed mode must surface the conflict instead of silently moving.
|
||||
node.PortMode = "fixed"
|
||||
body["portMode"] = "fixed"
|
||||
body["port"] = json.Number("26522")
|
||||
if _, err := NormalizeNode(body, node, data); err == nil {
|
||||
t.Fatalf("expected conflict error when moving fixed port onto a taken port")
|
||||
}
|
||||
}
|
||||
+103
-80
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
@@ -196,19 +195,68 @@ func (q xbQuery) String() string {
|
||||
|
||||
func NodeEmail(customerID string) string { return "ng:" + customerID }
|
||||
|
||||
func NewNodeClientCredentials(protocol string) Credentials {
|
||||
keyBytes := 24
|
||||
if cipher, ok := ShadowsocksMethods[protocol]; ok {
|
||||
keyBytes = cipher.KeyBytes
|
||||
}
|
||||
// NewCustomerCredentials mints the single credential set a customer uses on
|
||||
// every node (round-2 item 2, 3x-ui model). The relay password is always a
|
||||
// base64 32-byte root key: SS2022 user keys are derived from it per method
|
||||
// (see EffectiveRelayPassword), so one set serves aes128 and aes256 nodes
|
||||
// alike; every other protocol uses it verbatim.
|
||||
func NewCustomerCredentials() Credentials {
|
||||
return Credentials{
|
||||
ClientID: NewUUID(),
|
||||
RelayPassword: b64(keyBytes),
|
||||
RelayPassword: b64(32),
|
||||
ShortID: shortId(),
|
||||
}
|
||||
}
|
||||
|
||||
// EffectiveRelayPassword maps a customer's relay password onto a node's
|
||||
// protocol. SS2022 methods require the user key to be base64 of exactly the
|
||||
// method's key length, so the user key is the deterministic prefix
|
||||
// truncation of the 32-byte root key. Legacy SS methods and all other
|
||||
// protocols take the password verbatim.
|
||||
func EffectiveRelayPassword(protocol, relayPassword string) string {
|
||||
cipher, ok := ShadowsocksMethods[protocol]
|
||||
if !ok || !strings.HasPrefix(cipher.Method, "2022-blake3-") {
|
||||
return relayPassword
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(relayPassword)
|
||||
if err != nil || len(raw) < cipher.KeyBytes {
|
||||
return relayPassword
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(raw[:cipher.KeyBytes])
|
||||
}
|
||||
|
||||
// clientCredentials resolves the credentials stamped onto a binding for
|
||||
// resource building. Stored bindings no longer carry credentials (they live
|
||||
// on the customer); builders receive resolved copies, and this accessor
|
||||
// keeps a nil hole from becoming a panic.
|
||||
func clientCredentials(nc *NodeClient) Credentials {
|
||||
if nc == nil || nc.Credentials == nil {
|
||||
return Credentials{}
|
||||
}
|
||||
return *nc.Credentials
|
||||
}
|
||||
|
||||
// EnsureSocksCredentials mints the node-level fixed account of a
|
||||
// socks5-auth node (round-3 item 21) when either half is missing. The
|
||||
// username deliberately avoids the "ng:" prefix: the agent maps sing-box
|
||||
// user counters named "ng:<customerId>" to per-customer usage samples,
|
||||
// and the fixed account's traffic must stay node-level (the "inbound"
|
||||
// sample), never attributed to a customer. The password is a base64
|
||||
// 32-byte random, the same strength as a customer's relay password.
|
||||
func EnsureSocksCredentials(node *Node) {
|
||||
if node.Protocol != "socks5-auth" {
|
||||
return
|
||||
}
|
||||
if node.SocksUser == "" {
|
||||
node.SocksUser = "ngs_" + hex.EncodeToString(xbRandBytes(5))
|
||||
}
|
||||
if node.SocksPassword == "" {
|
||||
node.SocksPassword = b64(32)
|
||||
}
|
||||
}
|
||||
|
||||
func EnsureNodeSecrets(node *Node) {
|
||||
EnsureSocksCredentials(node)
|
||||
if node.WSPath == nil || *node.WSPath == "" {
|
||||
node.WSPath = StrPtr("/" + hex.EncodeToString(xbRandBytes(9)))
|
||||
}
|
||||
@@ -255,19 +303,19 @@ func listenAddress(networkMode string, server *Server, bindAddress string) strin
|
||||
if bindAddress != "" {
|
||||
return bindAddress
|
||||
}
|
||||
if networkMode == "ipv6" {
|
||||
if server != nil && server.PublicAddressV6 != "" {
|
||||
return server.PublicAddressV6
|
||||
}
|
||||
// Hotfix 2026-10-21: inbounds bind the wildcard address of their
|
||||
// stack. The registered public address is a *connection* address
|
||||
// (see serverAddress); pinning the listener to it left the
|
||||
// machine's other addresses (intranet NICs, additional v6
|
||||
// addresses) unserved and broke intranet relay landings, which
|
||||
// 3x-ui/xray serve by binding the wildcard. The server parameter
|
||||
// is kept for signature parity with the Node implementation.
|
||||
switch networkMode {
|
||||
case "ipv6", "dual":
|
||||
return "::"
|
||||
default: // ipv4
|
||||
return "0.0.0.0"
|
||||
}
|
||||
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) {
|
||||
@@ -280,54 +328,19 @@ func serverAddress(server *Server, networkMode string) (string, error) {
|
||||
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.
|
||||
// ResolveBindAddress mirrors resolveBindAddress. Since the 2026-10-21
|
||||
// listen hotfix it always returns ("", nil): inbounds bind the wildcard
|
||||
// address of their stack (see listenAddress), and the server's
|
||||
// registered public address is a connection address (serverAddress),
|
||||
// never a bind pin — resolving it into a bind address here is what
|
||||
// pinned every inbound to the public NIC and broke intranet relays.
|
||||
// It previously also returned ("", nil) for dual and for servers
|
||||
// without a registered address, so wildcard binding was already the
|
||||
// fallback semantics. The function is kept so the orchestrator's
|
||||
// deploy path keeps its shape and an explicit per-deploy bind address
|
||||
// can be reintroduced here if one is ever needed.
|
||||
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
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ---------- sing-box resource builders ----------
|
||||
@@ -352,12 +365,13 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
|
||||
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}
|
||||
creds := clientCredentials(nc)
|
||||
user := map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": creds.ClientID}
|
||||
if protocol == "vless-reality-vision" {
|
||||
user["flow"] = "xtls-rprx-vision"
|
||||
}
|
||||
users = append(users, user)
|
||||
shortIDs = append(shortIDs, nc.Credentials.ShortID)
|
||||
shortIDs = append(shortIDs, creds.ShortID)
|
||||
}
|
||||
destPort := node.Reality.DestPort
|
||||
if destPort == 0 {
|
||||
@@ -383,7 +397,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
|
||||
}
|
||||
users := make([]any, 0, len(nodeClients))
|
||||
for _, nc := range nodeClients {
|
||||
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": nc.Credentials.ClientID})
|
||||
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": clientCredentials(nc).ClientID})
|
||||
}
|
||||
wsPath := xbDeref(node.WSPath)
|
||||
if wsPath == "" {
|
||||
@@ -406,7 +420,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
|
||||
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})
|
||||
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": EffectiveRelayPassword(protocol, clientCredentials(nc).RelayPassword)})
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "shadowsocks", "tag": tag, "listen": listen, "listen_port": port,
|
||||
@@ -415,10 +429,12 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
// Round-3 item 21: the inbound carries exactly one user — the
|
||||
// node's fixed account — regardless of bindings. Bindings only
|
||||
// control subscription visibility; per-person accounting for
|
||||
// relay chains happens at the entry side, not here.
|
||||
EnsureSocksCredentials(node)
|
||||
users := []any{map[string]any{"username": node.SocksUser, "password": node.SocksPassword}}
|
||||
return map[string]any{"type": "socks", "tag": tag, "listen": listen, "listen_port": port, "users": users}, nil
|
||||
}
|
||||
|
||||
@@ -428,7 +444,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
|
||||
}
|
||||
users := make([]any, 0, len(nodeClients))
|
||||
for _, nc := range nodeClients {
|
||||
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": nc.Credentials.RelayPassword})
|
||||
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": clientCredentials(nc).RelayPassword})
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "hysteria2", "tag": tag, "listen": listen, "listen_port": port, "users": users,
|
||||
@@ -446,7 +462,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
|
||||
}
|
||||
users := make([]any, 0, len(nodeClients))
|
||||
for _, nc := range nodeClients {
|
||||
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": nc.Credentials.RelayPassword})
|
||||
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": clientCredentials(nc).RelayPassword})
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "anytls", "tag": tag, "listen": listen, "listen_port": port, "users": users,
|
||||
@@ -469,6 +485,9 @@ type BuildNodeResourceParams struct {
|
||||
Server *Server
|
||||
BindAddress string
|
||||
ExitServer *Server
|
||||
// ExitAddress, when non-empty, is the manual landing endpoint the
|
||||
// entry outbound connects to (no managed exit server involved).
|
||||
ExitAddress string
|
||||
}
|
||||
|
||||
func BuildNodeResource(p BuildNodeResourceParams) (map[string]any, error) {
|
||||
@@ -492,9 +511,13 @@ func BuildNodeResource(p BuildNodeResourceParams) (map[string]any, error) {
|
||||
if !forward {
|
||||
outbound = map[string]any{"type": "direct", "tag": outboundTag}
|
||||
} else {
|
||||
exitAddr, err := serverAddress(p.ExitServer, networkMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
exitAddr := p.ExitAddress
|
||||
if exitAddr == "" {
|
||||
var err error
|
||||
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
|
||||
@@ -682,9 +705,9 @@ func BuildClientURI(p BuildClientURIParams) (string, bool) {
|
||||
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
|
||||
password := EffectiveRelayPassword(protocol, p.RelayPassword)
|
||||
if strings.HasPrefix(cipher.Method, "2022-blake3-") && p.SSServerKey != "" {
|
||||
password = p.SSServerKey + ":" + p.RelayPassword
|
||||
password = p.SSServerKey + ":" + password
|
||||
}
|
||||
user := base64.RawURLEncoding.EncodeToString([]byte(cipher.Method + ":" + password))
|
||||
return fmt.Sprintf("ss://%s@%s:%d#%s", user, host, p.RelayPort, label), true
|
||||
|
||||
@@ -83,8 +83,8 @@ func TestGoldenResources(t *testing.T) {
|
||||
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"}},
|
||||
{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"}
|
||||
@@ -102,7 +102,7 @@ func TestGoldenResources(t *testing.T) {
|
||||
SSServerKey: StrPtr("CwwNDg8QERITFBUWFxgZGg=="),
|
||||
}
|
||||
ssClients := []*NodeClient{
|
||||
{NodeID: "nod_ss", CustomerID: "cus_1", Credentials: Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001ef01"}},
|
||||
{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"}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
// Item 17: GET /api/nodes/:id/reality-keys is a display-only view of the
|
||||
// panel-hosted keypair — it must never mint one.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func realityLoginHeaders(t *testing.T, app *App) map[string]string {
|
||||
t.Helper()
|
||||
rec := doRequest(app, "POST", "/api/auth/login", `{"username":"admin","password":"password12345"}`, nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("login %d", rec.Code)
|
||||
}
|
||||
cookie := strings.Split(rec.Header().Get("Set-Cookie"), ";")[0]
|
||||
return map[string]string{"Cookie": cookie}
|
||||
}
|
||||
|
||||
func TestRealityKeysEndpoint(t *testing.T) {
|
||||
app := newSmokeApp(t)
|
||||
_, err := app.store.Transaction(func(d *Data) (any, error) {
|
||||
d.Servers = append(d.Servers, &Server{ID: "srv_1", Name: "S1", PublicAddress: "entry.example.com"})
|
||||
d.Nodes = append(d.Nodes,
|
||||
&Node{ID: "nod_r", Protocol: "vless-reality-vision", ServerID: "srv_1", Status: "active",
|
||||
Reality: &RealityConfig{KeyID: "reality-nod_r", ServerName: "www.tesla.com", DestPort: 443}},
|
||||
&Node{ID: "nod_s", Protocol: "socks5-auth", ServerID: "srv_1", Status: "active"},
|
||||
)
|
||||
d.KeyStore["reality-nod_r"] = &KeyEntry{PrivateKey: "PRIV", PublicKey: "PUB", UpdatedAt: NowISO()}
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers := realityLoginHeaders(t, app)
|
||||
|
||||
rec := doRequest(app, "GET", "/api/nodes/nod_r/reality-keys", "", headers)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeBody(t, rec)
|
||||
if body["publicKey"] != "PUB" || body["privateKey"] != "PRIV" || body["keyId"] != "reality-nod_r" {
|
||||
t.Fatalf("body %v", body)
|
||||
}
|
||||
|
||||
// Non-Reality node -> 400; unknown node -> 404.
|
||||
if rec := doRequest(app, "GET", "/api/nodes/nod_s/reality-keys", "", headers); rec.Code != 400 {
|
||||
t.Fatalf("socks node status %d, want 400", rec.Code)
|
||||
}
|
||||
if rec := doRequest(app, "GET", "/api/nodes/nope/reality-keys", "", headers); rec.Code != 404 {
|
||||
t.Fatalf("unknown node status %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
// A Reality node without a stored pair reports nulls and the
|
||||
// endpoint must NOT create one.
|
||||
_, err = app.store.Transaction(func(d *Data) (any, error) {
|
||||
d.Nodes = append(d.Nodes, &Node{ID: "nod_r2", Protocol: "vless-reality", ServerID: "srv_1", Status: "draft",
|
||||
Reality: &RealityConfig{KeyID: "reality-nod_r2", ServerName: "www.tesla.com", DestPort: 443}})
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = doRequest(app, "GET", "/api/nodes/nod_r2/reality-keys", "", headers)
|
||||
body = decodeBody(t, rec)
|
||||
if rec.Code != 200 || body["publicKey"] != nil || body["privateKey"] != nil {
|
||||
t.Fatalf("missing pair: %d %v", rec.Code, body)
|
||||
}
|
||||
if _, exists := app.store.Snapshot().KeyStore["reality-nod_r2"]; exists {
|
||||
t.Fatal("display endpoint must never generate a keypair")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package main
|
||||
|
||||
// Reality target scanner (round-3 item 16, modeled on the 3x-ui
|
||||
// scanner): the panel itself performs a real TLS handshake against
|
||||
// each candidate and reports whether it can serve as a Reality dest —
|
||||
// handshake success plus an X25519-family key exchange. Results are
|
||||
// the PANEL's point of view (the router reaches the internet through
|
||||
// its own egress; a node's view can differ), and the UI says so.
|
||||
//
|
||||
// Bounds: at most scanMaxTargets probes per request, scanConcurrency
|
||||
// in flight, scanProbeTimeout per probe — a scan can never wedge the
|
||||
// panel.
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
scanMaxTargets = 32
|
||||
scanConcurrency = 8
|
||||
scanProbeTimeout = 4 * time.Second
|
||||
scanCIDRMaxHosts = 32
|
||||
)
|
||||
|
||||
// scanPool is the built-in candidate pool: the form's Reality presets
|
||||
// (same source) plus the usual scanner extras.
|
||||
func scanPool() []string {
|
||||
seen := map[string]bool{}
|
||||
pool := []string{}
|
||||
add := func(host string) {
|
||||
if !seen[host] {
|
||||
seen[host] = true
|
||||
pool = append(pool, host)
|
||||
}
|
||||
}
|
||||
for _, preset := range RealityPresets {
|
||||
add(preset.ServerName)
|
||||
}
|
||||
for _, host := range []string{
|
||||
"www.microsoft.com", "www.google.com", "www.nvidia.com",
|
||||
"www.samsung.com", "www.cloudflare.com", "www.bing.com",
|
||||
"www.github.com", "www.yahoo.com", "www.mozilla.org",
|
||||
} {
|
||||
add(host)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
type scanTarget struct {
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
// parseScanTargets expands operator input (comma/space separated
|
||||
// domains, IPs, host:port pairs and CIDRs) into probe targets. Empty
|
||||
// input means the built-in pool.
|
||||
func parseScanTargets(input string) ([]scanTarget, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
pool := scanPool()
|
||||
out := make([]scanTarget, 0, len(pool))
|
||||
for _, host := range pool {
|
||||
out = append(out, scanTarget{Host: host, Port: 443})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
out := []scanTarget{}
|
||||
for _, part := range strings.FieldsFunc(input, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == ';'
|
||||
}) {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(part, "/") {
|
||||
hosts, err := expandCIDR(part)
|
||||
if err != nil {
|
||||
return nil, httpErr(400, "CIDR 无效: %s", part)
|
||||
}
|
||||
for _, host := range hosts {
|
||||
out = append(out, scanTarget{Host: host, Port: 443})
|
||||
}
|
||||
continue
|
||||
}
|
||||
host, port := part, 443
|
||||
if h, p, err := net.SplitHostPort(part); err == nil {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return nil, httpErr(400, "端口无效: %s", part)
|
||||
}
|
||||
host, port = h, n
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
if host == "" || strings.ContainsAny(host, " \t") {
|
||||
return nil, httpErr(400, "目标无效: %s", part)
|
||||
}
|
||||
out = append(out, scanTarget{Host: host, Port: port})
|
||||
if len(out) > scanMaxTargets {
|
||||
return nil, httpErr(400, "一次最多扫描 %d 个目标", scanMaxTargets)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, httpErr(400, "没有可扫描的目标")
|
||||
}
|
||||
if len(out) > scanMaxTargets {
|
||||
out = out[:scanMaxTargets]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func expandCIDR(cidr string) ([]string, error) {
|
||||
ip, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ip.To4() == nil {
|
||||
return nil, fmt.Errorf("only IPv4 CIDR supported")
|
||||
}
|
||||
base := network.IP.To4()
|
||||
mask := binary.BigEndian.Uint32(network.Mask)
|
||||
start := binary.BigEndian.Uint32(base) & mask
|
||||
count := uint32(1) << (32 - onesCount(network.Mask))
|
||||
hosts := []string{}
|
||||
for i := uint32(0); i < count && len(hosts) < scanCIDRMaxHosts; i++ {
|
||||
addr := make(net.IP, 4)
|
||||
binary.BigEndian.PutUint32(addr, start+i)
|
||||
hosts = append(hosts, addr.String())
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func onesCount(mask net.IPMask) int {
|
||||
ones, _ := mask.Size()
|
||||
return ones
|
||||
}
|
||||
|
||||
type scanResult struct {
|
||||
Target string `json:"target"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
TLSVersion string `json:"tlsVersion,omitempty"`
|
||||
ALPN string `json:"alpn,omitempty"`
|
||||
KeyExchange string `json:"keyExchange,omitempty"`
|
||||
KeyExchangeOK bool `json:"keyExchangeOk"`
|
||||
CertName string `json:"certName,omitempty"`
|
||||
LatencyMs int64 `json:"latencyMs"`
|
||||
}
|
||||
|
||||
func tlsVersionName(v uint16) string {
|
||||
switch v {
|
||||
case tls.VersionTLS13:
|
||||
return "TLS 1.3"
|
||||
case tls.VersionTLS12:
|
||||
return "TLS 1.2"
|
||||
case tls.VersionTLS11:
|
||||
return "TLS 1.1"
|
||||
case tls.VersionTLS10:
|
||||
return "TLS 1.0"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// dialScanTLS performs one bounded TLS handshake. curves, when
|
||||
// non-nil, restricts the offered key-exchange groups — the only way a
|
||||
// Go TLS client can learn which groups a server accepts (the negotiated
|
||||
// group is not exposed on ConnectionState in this Go version).
|
||||
func dialScanTLS(target scanTarget, curves []tls.CurveID) (*tls.Conn, error) {
|
||||
dialer := &net.Dialer{Timeout: scanProbeTimeout}
|
||||
conf := &tls.Config{
|
||||
ServerName: target.Host,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
// The probe judges reachability and key exchange, not trust:
|
||||
// Reality dests are public sites whose cert the panel has no
|
||||
// business pinning. The SAN is still reported for the operator.
|
||||
InsecureSkipVerify: true, //nolint:gosec -- probe only, see above
|
||||
}
|
||||
if curves != nil {
|
||||
conf.CurvePreferences = curves
|
||||
}
|
||||
return tls.DialWithDialer(dialer, "tcp", net.JoinHostPort(target.Host, strconv.Itoa(target.Port)), conf)
|
||||
}
|
||||
|
||||
func curveSupported(target scanTarget, curves ...tls.CurveID) bool {
|
||||
conn, err := dialScanTLS(target, curves)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// probeRealityTarget judges a target by the Reality dest criteria: a
|
||||
// TLS handshake completes and the key exchange can be X25519 or
|
||||
// X25519MLKEM768 (probed strongest first; a combined-offer handshake
|
||||
// is the fallback so a server that refuses single-group offers is not
|
||||
// misjudged).
|
||||
func probeRealityTarget(target scanTarget) scanResult {
|
||||
result := scanResult{Target: target.Host, Host: target.Host, Port: target.Port}
|
||||
start := time.Now()
|
||||
conn, err := dialScanTLS(target, nil)
|
||||
result.LatencyMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
result.Reason = "连接或握手失败: " + truncateRunes(err.Error(), 120)
|
||||
return result
|
||||
}
|
||||
state := conn.ConnectionState()
|
||||
_ = conn.Close()
|
||||
result.TLSVersion = tlsVersionName(state.Version)
|
||||
result.ALPN = state.NegotiatedProtocol
|
||||
if len(state.PeerCertificates) > 0 {
|
||||
cert := state.PeerCertificates[0]
|
||||
if len(cert.DNSNames) > 0 {
|
||||
result.CertName = cert.DNSNames[0]
|
||||
} else {
|
||||
result.CertName = cert.Subject.CommonName
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case curveSupported(target, tls.X25519MLKEM768):
|
||||
result.KeyExchange = "X25519MLKEM768"
|
||||
case curveSupported(target, tls.X25519):
|
||||
result.KeyExchange = "X25519"
|
||||
case curveSupported(target, tls.X25519MLKEM768, tls.X25519):
|
||||
result.KeyExchange = "X25519 / X25519MLKEM768"
|
||||
}
|
||||
result.KeyExchangeOK = result.KeyExchange != ""
|
||||
if !result.KeyExchangeOK {
|
||||
result.Reason = "密钥交换不支持 X25519"
|
||||
return result
|
||||
}
|
||||
result.OK = true
|
||||
return result
|
||||
}
|
||||
|
||||
// scanRealityTargets probes all targets with bounded concurrency and
|
||||
// returns results in input order.
|
||||
func scanRealityTargets(targets []scanTarget) []scanResult {
|
||||
results := make([]scanResult, len(targets))
|
||||
sem := make(chan struct{}, scanConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, target := range targets {
|
||||
wg.Add(1)
|
||||
go func(i int, target scanTarget) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
results[i] = probeRealityTarget(target)
|
||||
}(i, target)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
// Item 16 scanner tests: input parsing (offline) and the probe against
|
||||
// a local TLS server (loopback only — the live-domain check happens in
|
||||
// the sandbox smoke run).
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseScanTargetsPool(t *testing.T) {
|
||||
targets, err := parseScanTargets("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(targets) < len(RealityPresets) || len(targets) > scanMaxTargets {
|
||||
t.Fatalf("pool size %d out of bounds", len(targets))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, target := range targets {
|
||||
seen[target.Host] = true
|
||||
if target.Port != 443 {
|
||||
t.Fatalf("pool target %s port %d, want 443", target.Host, target.Port)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"www.microsoft.com", "www.google.com", "www.nvidia.com", "www.samsung.com", "www.intel.com"} {
|
||||
if !seen[want] {
|
||||
t.Fatalf("pool missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScanTargetsInput(t *testing.T) {
|
||||
targets, err := parseScanTargets("a.example.com, b.example.com:8443 203.0.113.7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(targets) != 3 || targets[0].Port != 443 || targets[1].Port != 8443 || targets[2].Host != "203.0.113.7" {
|
||||
t.Fatalf("targets %+v", targets)
|
||||
}
|
||||
cidr, err := parseScanTargets("203.0.113.0/30")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cidr) != 4 {
|
||||
t.Fatalf("cidr /30 expanded to %d hosts, want 4", len(cidr))
|
||||
}
|
||||
if _, err := parseScanTargets("10.0.0.0/33"); err == nil {
|
||||
t.Fatal("invalid CIDR must error")
|
||||
}
|
||||
if _, err := parseScanTargets("a.example.com:99999"); err == nil {
|
||||
t.Fatal("invalid port must error")
|
||||
}
|
||||
many := strings.TrimSuffix(strings.Repeat("a.example.com,", scanMaxTargets+1), ",")
|
||||
if _, err := parseScanTargets(many); err == nil {
|
||||
t.Fatal("over-cap input must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeRealityTargetLocalTLS(t *testing.T) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
DNSNames: []string{"localhost"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
||||
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
_ = conn.(*tls.Conn).Handshake()
|
||||
}()
|
||||
}
|
||||
}()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
result := probeRealityTarget(scanTarget{Host: "127.0.0.1", Port: port})
|
||||
if !result.OK {
|
||||
t.Fatalf("local TLS target must be OK: %+v", result)
|
||||
}
|
||||
if result.TLSVersion != "TLS 1.3" {
|
||||
t.Fatalf("tlsVersion %q, want TLS 1.3", result.TLSVersion)
|
||||
}
|
||||
if result.KeyExchange == "" || !result.KeyExchangeOK {
|
||||
t.Fatalf("keyExchange %q ok=%v", result.KeyExchange, result.KeyExchangeOK)
|
||||
}
|
||||
// A dead port reports not-OK with a reason, never a panic.
|
||||
dead := probeRealityTarget(scanTarget{Host: "127.0.0.1", Port: 1})
|
||||
if dead.OK || dead.Reason == "" {
|
||||
t.Fatalf("dead target: %+v", dead)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
// Tests for round-3 item 23: binding-class rebuilds are coalesced in a
|
||||
// FIXED 10-second window (anchored at the first pending change, never
|
||||
// extended), manual deploy stays immediate, and a pending apply never
|
||||
// resurrects a node removed inside the window.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func coalesceFixture(t *testing.T) (*Store, *Orchestrator, *Node) {
|
||||
t.Helper()
|
||||
store, _ := newTestStore(t)
|
||||
d := store.Data
|
||||
d.Servers = []*Server{{ID: "srv_1", Name: "S1", PublicAddress: "entry.example.com",
|
||||
PortRangeStart: 20000, PortRangeEnd: 50000, Status: "online", LastSeenAt: StrPtr(NowISO())}}
|
||||
for i := 1; i <= 5; i++ {
|
||||
d.Customers = append(d.Customers, &Customer{
|
||||
ID: "cus_" + string(rune('0'+i)), Name: "C", Status: "active",
|
||||
Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-00000000000" + string(rune('0'+i)), RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd000" + string(rune('0'+i))},
|
||||
})
|
||||
}
|
||||
node := &Node{ID: "nod_1", Name: "N1", Protocol: "vless-reality-vision", ServerID: "srv_1", Topology: "direct",
|
||||
Status: "active", Port: 23456, ResourceID: "res_1", EntryReady: true,
|
||||
Reality: &RealityConfig{KeyID: "reality-nod_1", ServerName: "www.tesla.com", DestPort: 443}}
|
||||
d.Nodes = []*Node{node}
|
||||
return store, NewOrchestrator(store), node
|
||||
}
|
||||
|
||||
func applyJobsFor(d *Data, nodeID string) []*Job {
|
||||
out := []*Job{}
|
||||
for _, job := range d.Jobs {
|
||||
if job.NodeID == nodeID && job.Action == "apply_resource" {
|
||||
out = append(out, job)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resourceUsers(t *testing.T, job *Job) []any {
|
||||
t.Helper()
|
||||
var resource struct {
|
||||
Inbounds []struct {
|
||||
Users []any `json:"users"`
|
||||
} `json:"inbounds"`
|
||||
}
|
||||
if err := json.Unmarshal(job.Payload.Resource, &resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resource.Inbounds) != 1 {
|
||||
t.Fatalf("inbounds = %d, want 1", len(resource.Inbounds))
|
||||
}
|
||||
return resource.Inbounds[0].Users
|
||||
}
|
||||
|
||||
func TestCoalescedAssignsFlushOnceWithFinalState(t *testing.T) {
|
||||
store, orch, node := coalesceFixture(t)
|
||||
var firstMark string
|
||||
for i := 1; i <= 5; i++ {
|
||||
if _, err := orch.AssignCustomer(node.ID, "cus_"+string(rune('0'+i)), "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := store.Snapshot()
|
||||
n := snap.Nodes[0]
|
||||
if !n.RebuildPending {
|
||||
t.Fatal("assign must mark the node pending, not rebuild immediately")
|
||||
}
|
||||
if i == 1 {
|
||||
firstMark = n.RebuildPendingAt
|
||||
} else if n.RebuildPendingAt != firstMark {
|
||||
t.Fatalf("fixed window violated: mark moved from %s to %s", firstMark, n.RebuildPendingAt)
|
||||
}
|
||||
if got := applyJobsFor(snap, node.ID); len(got) != 0 {
|
||||
t.Fatalf("after assign %d: %d apply jobs, want 0 inside the window", i, len(got))
|
||||
}
|
||||
}
|
||||
// Flushing before the window elapses is a no-op.
|
||||
markTime, ok := ParseISO(firstMark)
|
||||
if !ok {
|
||||
t.Fatalf("unparseable pending mark %q", firstMark)
|
||||
}
|
||||
if n, err := orch.FlushPendingRebuilds(markTime.Add(5 * time.Second)); err != nil || n != 0 {
|
||||
t.Fatalf("early flush = %d, %v; want 0, nil", n, err)
|
||||
}
|
||||
// At window end exactly one apply ships, carrying all five users.
|
||||
flushed, err := orch.FlushPendingRebuilds(markTime.Add(RebuildCoalesceWindow + time.Second))
|
||||
if err != nil || flushed != 1 {
|
||||
t.Fatalf("flush = %d, %v; want 1, nil", flushed, err)
|
||||
}
|
||||
snap := store.Snapshot()
|
||||
jobs := applyJobsFor(snap, node.ID)
|
||||
if len(jobs) != 1 {
|
||||
t.Fatalf("apply jobs = %d, want exactly 1", len(jobs))
|
||||
}
|
||||
if users := resourceUsers(t, jobs[0]); len(users) != 5 {
|
||||
t.Fatalf("flushed resource has %d users, want all 5", len(users))
|
||||
}
|
||||
if snap.Nodes[0].RebuildPending {
|
||||
t.Fatal("pending mark must be consumed by the flush")
|
||||
}
|
||||
// A second flush ships nothing more.
|
||||
if n, _ := orch.FlushPendingRebuilds(markTime.Add(time.Hour)); n != 0 {
|
||||
t.Fatalf("second flush = %d, want 0", n)
|
||||
}
|
||||
if got := applyJobsFor(store.Snapshot(), node.ID); len(got) != 1 {
|
||||
t.Fatalf("apply jobs after second flush = %d, want 1", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedWindowDoesNotSlide(t *testing.T) {
|
||||
store, orch, node := coalesceFixture(t)
|
||||
if _, err := orch.AssignCustomer(node.ID, "cus_1", "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstMark := store.Snapshot().Nodes[0].RebuildPendingAt
|
||||
markTime, _ := ParseISO(firstMark)
|
||||
// A change late in the window must not push the deadline out: the
|
||||
// flush still happens at first-mark + window.
|
||||
if _, err := orch.AssignCustomer(node.ID, "cus_2", "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
flushed, err := orch.FlushPendingRebuilds(markTime.Add(RebuildCoalesceWindow + 100*time.Millisecond))
|
||||
if err != nil || flushed != 1 {
|
||||
t.Fatalf("flush at first-mark+window = %d, %v; want 1 (fixed window)", flushed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingApplyDiscardedWhenNodeRemoved(t *testing.T) {
|
||||
store, orch, node := coalesceFixture(t)
|
||||
if _, err := orch.AssignCustomer(node.ID, "cus_1", "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
markTime, _ := ParseISO(store.Snapshot().Nodes[0].RebuildPendingAt)
|
||||
if _, err := orch.RemoveNode(node.ID, "admin", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
flushed, err := orch.FlushPendingRebuilds(markTime.Add(time.Hour))
|
||||
if err != nil || flushed != 0 {
|
||||
t.Fatalf("flush after removal = %d, %v; want 0", flushed, err)
|
||||
}
|
||||
if got := applyJobsFor(store.Snapshot(), node.ID); len(got) != 0 {
|
||||
t.Fatalf("removed node got %d apply jobs; a pending apply must never resurrect it", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualDeployImmediateAndConsumesPending(t *testing.T) {
|
||||
store, orch, node := coalesceFixture(t)
|
||||
if _, err := orch.AssignCustomer(node.ID, "cus_1", "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := orch.DeployNode(node.ID, "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := store.Snapshot()
|
||||
if got := applyJobsFor(snap, node.ID); len(got) != 1 {
|
||||
t.Fatalf("manual deploy must apply immediately: %d jobs", len(got))
|
||||
}
|
||||
if snap.Nodes[0].RebuildPending {
|
||||
t.Fatal("manual deploy must consume the pending mark")
|
||||
}
|
||||
if n, _ := orch.FlushPendingRebuilds(time.Now().Add(time.Hour)); n != 0 {
|
||||
t.Fatalf("flush after manual deploy = %d, want 0", n)
|
||||
}
|
||||
if got := applyJobsFor(store.Snapshot(), node.ID); len(got) != 1 {
|
||||
t.Fatalf("apply jobs = %d, want still 1", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingMarkSurvivesStoreReload(t *testing.T) {
|
||||
store, orch, node := coalesceFixture(t)
|
||||
if _, err := orch.AssignCustomer(node.ID, "cus_1", "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simulate a panel restart inside the window: a fresh Store over the
|
||||
// same file must still see the pending mark and flush it when due —
|
||||
// the node can never be stranded un-deployed.
|
||||
reloaded := NewStore(store.File)
|
||||
if err := reloaded.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := reloaded.Data.Nodes[0]
|
||||
if !n.RebuildPending {
|
||||
t.Fatal("pending mark must be persisted in the data file")
|
||||
}
|
||||
markTime, _ := ParseISO(n.RebuildPendingAt)
|
||||
orch2 := NewOrchestrator(reloaded)
|
||||
flushed, err := orch2.FlushPendingRebuilds(markTime.Add(RebuildCoalesceWindow + time.Second))
|
||||
if err != nil || flushed != 1 {
|
||||
t.Fatalf("post-restart flush = %d, %v; want 1", flushed, err)
|
||||
}
|
||||
if got := applyJobsFor(reloaded.Snapshot(), node.ID); len(got) != 1 {
|
||||
t.Fatalf("apply jobs after restart flush = %d, want 1", len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
// Tests for round-3 items 20+21: build-before-bind (no client-count
|
||||
// deploy gate, no auto-removal at zero bindings) and the socks5-auth
|
||||
// node-level fixed account.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func socksNodeData() *Data {
|
||||
d := EmptyData()
|
||||
d.Servers = []*Server{{ID: "srv_1", Name: "S1", PublicAddress: "entry.example.com", PortRangeStart: 20000, PortRangeEnd: 50000,
|
||||
Status: "online", LastSeenAt: StrPtr(NowISO())}}
|
||||
d.Customers = []*Customer{
|
||||
{ID: "cus_1", Name: "A", Status: "active", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001"}},
|
||||
{ID: "cus_2", Name: "B", Status: "active", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000002", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0002"}},
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestSocksInboundAlwaysFixedAccount(t *testing.T) {
|
||||
node := &Node{ID: "nod_sk", Protocol: "socks5-auth", Port: 23462, NetworkMode: "ipv4",
|
||||
SocksUser: "fixeduser", SocksPassword: "fixedpass"}
|
||||
server := &Server{ID: "srv_1", PublicAddress: "entry.example.com"}
|
||||
for _, nClients := range []int{0, 1, 2} {
|
||||
clients := []*NodeClient{}
|
||||
for i := 0; i < nClients; i++ {
|
||||
clients = append(clients, &NodeClient{NodeID: node.ID, CustomerID: "cus_1", Status: "active",
|
||||
Credentials: &Credentials{ClientID: "x", RelayPassword: "y", ShortID: "z"}})
|
||||
}
|
||||
inbound, err := buildSingBoxInbound("t-in", node, clients, server, "")
|
||||
if err != nil {
|
||||
t.Fatalf("clients=%d: %v", nClients, err)
|
||||
}
|
||||
users, _ := inbound["users"].([]any)
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("clients=%d: users = %d, want exactly the fixed account", nClients, len(users))
|
||||
}
|
||||
u, _ := users[0].(map[string]any)
|
||||
if u["username"] != "fixeduser" || u["password"] != "fixedpass" {
|
||||
t.Fatalf("clients=%d: user = %v, want the fixed pair", nClients, u)
|
||||
}
|
||||
}
|
||||
// A node without a stored pair gets one minted, never an ng: identity.
|
||||
bare := &Node{ID: "nod_bare", Protocol: "socks5-auth", Port: 23463, NetworkMode: "ipv4"}
|
||||
if _, err := buildSingBoxInbound("t-in", bare, nil, server, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bare.SocksUser == "" || strings.HasPrefix(bare.SocksUser, "ng:") {
|
||||
t.Fatalf("minted user %q must exist and avoid the ng: namespace", bare.SocksUser)
|
||||
}
|
||||
if raw, err := base64.StdEncoding.DecodeString(bare.SocksPassword); err != nil || len(raw) != 32 {
|
||||
t.Fatalf("minted password must be a base64 32-byte key, got %q", bare.SocksPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocksMigrationIdempotent(t *testing.T) {
|
||||
d := socksNodeData()
|
||||
d.Nodes = []*Node{
|
||||
{ID: "nod_old", Protocol: "socks5-auth", Status: "active"},
|
||||
{ID: "nod_set", Protocol: "socks5-auth", Status: "active", SocksUser: "kept", SocksPassword: "keptpw"},
|
||||
{ID: "nod_vless", Protocol: "vless-reality", Status: "active"},
|
||||
}
|
||||
if !MigrateSocksNodeCredentials(d) {
|
||||
t.Fatal("first migration must report a change")
|
||||
}
|
||||
old := d.Nodes[0]
|
||||
if old.SocksUser == "" || old.SocksPassword == "" {
|
||||
t.Fatal("legacy socks node must receive a fixed pair")
|
||||
}
|
||||
if d.Nodes[1].SocksUser != "kept" || d.Nodes[1].SocksPassword != "keptpw" {
|
||||
t.Fatal("an existing pair must be preserved")
|
||||
}
|
||||
if d.Nodes[2].SocksUser != "" {
|
||||
t.Fatal("non-socks nodes must not gain an account")
|
||||
}
|
||||
user, password := old.SocksUser, old.SocksPassword
|
||||
if MigrateSocksNodeCredentials(d) {
|
||||
t.Fatal("second migration must be a no-op")
|
||||
}
|
||||
if old.SocksUser != user || old.SocksPassword != password {
|
||||
t.Fatal("migration must be idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocksMigrationRunsOnStoreLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "data.json")
|
||||
d := socksNodeData()
|
||||
seed := EmptyData()
|
||||
seed.Servers = d.Servers
|
||||
seed.Customers = d.Customers
|
||||
seed.Nodes = []*Node{{ID: "nod_old", Protocol: "socks5-auth", Status: "active"}}
|
||||
raw, err := marshalJSON(seed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(file, append(raw, '\n'), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewStore(file)
|
||||
if err := store.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node := store.Data.Nodes[0]
|
||||
if node.SocksUser == "" || node.SocksPassword == "" {
|
||||
t.Fatal("store load must migrate legacy socks nodes")
|
||||
}
|
||||
// And the migration is persisted, not just in memory.
|
||||
reloaded := NewStore(file)
|
||||
if err := reloaded.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.Data.Nodes[0].SocksUser != node.SocksUser {
|
||||
t.Fatal("migrated pair must be persisted to disk")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroClientDeployNotGated(t *testing.T) {
|
||||
d := socksNodeData()
|
||||
vless := &Node{ID: "nod_v", Protocol: "vless-reality-vision", ServerID: "srv_1", Topology: "direct", Status: "draft", Port: 23456,
|
||||
Reality: &RealityConfig{KeyID: "reality-nod_v", ServerName: "www.tesla.com", DestPort: 443}}
|
||||
socks := &Node{ID: "nod_s", Protocol: "socks5-auth", ServerID: "srv_1", Topology: "direct", Status: "draft", Port: 23457}
|
||||
d.Nodes = []*Node{vless, socks}
|
||||
for _, node := range d.Nodes {
|
||||
if err := ValidateNodeDeploy(d, node); err != nil {
|
||||
t.Fatalf("%s zero-client deploy must be allowed: %v", node.Protocol, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroClientRebuildKeepsNode(t *testing.T) {
|
||||
d := socksNodeData()
|
||||
node := &Node{ID: "nod_v", Protocol: "vless-reality-vision", ServerID: "srv_1", Topology: "direct", Status: "active",
|
||||
ResourceID: "res_v", Reality: &RealityConfig{KeyID: "reality-nod_v", ServerName: "www.tesla.com", DestPort: 443}}
|
||||
d.Nodes = []*Node{node}
|
||||
if err := RebuildNode(d, node, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if node.Status == "removing" {
|
||||
t.Fatal("zero-client rebuild must not auto-remove the node")
|
||||
}
|
||||
applies, deletes := 0, 0
|
||||
for _, job := range d.Jobs {
|
||||
if job.NodeID == node.ID && job.Action == "apply_resource" {
|
||||
applies++
|
||||
}
|
||||
if job.NodeID == node.ID && job.Action == "delete_resource" {
|
||||
deletes++
|
||||
}
|
||||
}
|
||||
if applies != 1 || deletes != 0 {
|
||||
t.Fatalf("jobs: applies=%d deletes=%d, want 1/0", applies, deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocksSubscriptionCarriesFixedAccount(t *testing.T) {
|
||||
d := socksNodeData()
|
||||
node := &Node{ID: "nod_s", Name: "Socks", Protocol: "socks5-auth", ServerID: "srv_1", Topology: "direct",
|
||||
NetworkMode: "ipv4", Port: 23462, Status: "active", SocksUser: "fixeduser", SocksPassword: "fixedpass"}
|
||||
d.Nodes = []*Node{node}
|
||||
for _, customerID := range []string{"cus_1", "cus_2"} {
|
||||
nc := &NodeClient{NodeID: node.ID, CustomerID: customerID, Status: "active"}
|
||||
uri := NodeClientURI(d, nc)
|
||||
if !strings.HasPrefix(uri, "socks5://fixeduser:fixedpass@entry.example.com:23462") {
|
||||
t.Fatalf("%s: uri = %q, want the fixed account", customerID, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package main
|
||||
// the same serialization here.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -133,10 +134,95 @@ func (s *Store) Init() error {
|
||||
if err := validateData(&data); err != nil {
|
||||
return err
|
||||
}
|
||||
migrated := MigrateCustomerCredentials(&data)
|
||||
if MigrateSocksNodeCredentials(&data) {
|
||||
migrated = true
|
||||
}
|
||||
if migrated {
|
||||
// Persist the converged credential model immediately, so a crash
|
||||
// before the next transaction cannot resurrect per-binding sets.
|
||||
if err := s.persist(&data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.Data = &data
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateSocksNodeCredentials gives every stored socks5-auth node its
|
||||
// node-level fixed account (round-3 item 21): nodes created before the
|
||||
// fixed-account model carried per-customer users derived at build time
|
||||
// and stored no account of their own. A node missing either half gets a
|
||||
// freshly minted pair (EnsureSocksCredentials only fills empty fields,
|
||||
// so the migration is idempotent). Returns true when anything changed.
|
||||
func MigrateSocksNodeCredentials(d *Data) bool {
|
||||
changed := false
|
||||
for _, node := range d.Nodes {
|
||||
if node.Protocol != "socks5-auth" {
|
||||
continue
|
||||
}
|
||||
user, password := node.SocksUser, node.SocksPassword
|
||||
EnsureSocksCredentials(node)
|
||||
if node.SocksUser != user || node.SocksPassword != password {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// MigrateCustomerCredentials converges legacy per-binding credentials into
|
||||
// each customer's single set (round-2 item 2; the 3x-ui model of one
|
||||
// credential row per client). Deterministic rules:
|
||||
// - A customer with a complete stored set keeps it.
|
||||
// - Otherwise the set is copied from the customer's first binding (in
|
||||
// stored order) that carries credentials; a customer with no usable
|
||||
// binding credentials gets a fresh set.
|
||||
// - The relay password must be a base64 32-byte root key, because SS2022
|
||||
// user keys derive from it by prefix truncation (EffectiveRelayPassword).
|
||||
// If the converged password does not decode to exactly 32 bytes, only
|
||||
// the password is regenerated — clientId and shortId are preserved.
|
||||
// - Every binding's stored credentials are then cleared, so bindings stop
|
||||
// carrying credentials at all.
|
||||
//
|
||||
// Returns true when anything changed.
|
||||
func MigrateCustomerCredentials(d *Data) bool {
|
||||
changed := false
|
||||
for _, customer := range d.Customers {
|
||||
var creds *Credentials
|
||||
if customer.Credentials != nil && customer.Credentials.ClientID != "" {
|
||||
copied := *customer.Credentials
|
||||
creds = &copied
|
||||
} else {
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.CustomerID == customer.ID && nc.Credentials != nil && nc.Credentials.ClientID != "" {
|
||||
copied := *nc.Credentials
|
||||
creds = &copied
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if creds == nil {
|
||||
fresh := NewCustomerCredentials()
|
||||
creds = &fresh
|
||||
}
|
||||
if raw, err := base64.StdEncoding.DecodeString(creds.RelayPassword); err != nil || len(raw) != 32 {
|
||||
creds.RelayPassword = b64(32)
|
||||
}
|
||||
old := customer.Credentials
|
||||
if old == nil || old.ClientID != creds.ClientID || old.RelayPassword != creds.RelayPassword || old.ShortID != creds.ShortID {
|
||||
customer.Credentials = creds
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.Credentials != nil {
|
||||
nc.Credentials = nil
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// Snapshot returns a deep copy of the current data (JS structuredClone).
|
||||
func (s *Store) Snapshot() *Data {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -108,15 +108,35 @@ func NodeClientURI(d *Data, nc *NodeClient) string {
|
||||
if networkMode == "" {
|
||||
networkMode = "ipv4"
|
||||
}
|
||||
creds := Credentials{}
|
||||
if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil {
|
||||
creds = *customer.Credentials
|
||||
}
|
||||
// Round-3 item 21: a socks node's link carries the node's fixed
|
||||
// account, identical for every bound customer; the binding only
|
||||
// controls whether the node appears in the subscription at all.
|
||||
socksUser := NodeEmail(nc.CustomerID)
|
||||
relayPassword := creds.RelayPassword
|
||||
if node.Protocol == "socks5-auth" {
|
||||
socksUser, relayPassword = node.SocksUser, node.SocksPassword
|
||||
if socksUser == "" || relayPassword == "" {
|
||||
// Legacy record the load migration has not reached (only
|
||||
// possible on an in-memory fixture): mint into a throwaway
|
||||
// copy — a read path must never mutate the node record.
|
||||
probe := *node
|
||||
EnsureSocksCredentials(&probe)
|
||||
socksUser, relayPassword = probe.SocksUser, probe.SocksPassword
|
||||
}
|
||||
}
|
||||
uri, ok := BuildClientURI(BuildClientURIParams{
|
||||
Protocol: node.Protocol,
|
||||
RelayServer: server,
|
||||
RelayPort: node.Port,
|
||||
ClientID: nc.Credentials.ClientID,
|
||||
RelayPassword: nc.Credentials.RelayPassword,
|
||||
ShortID: nc.Credentials.ShortID,
|
||||
ClientID: creds.ClientID,
|
||||
RelayPassword: relayPassword,
|
||||
ShortID: creds.ShortID,
|
||||
WSPath: xbDeref(node.WSPath),
|
||||
SocksUser: NodeEmail(nc.CustomerID),
|
||||
SocksUser: socksUser,
|
||||
Reality: node.Reality,
|
||||
PublicKey: publicKey,
|
||||
Name: node.Name,
|
||||
|
||||
@@ -30,32 +30,37 @@ func goldenSubData() *Data {
|
||||
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{}},
|
||||
Topology: "direct", NetworkMode: "ipv4", Port: 23462, Status: "active", Artifacts: map[string]json.RawMessage{},
|
||||
// Round-3 item 21: the pair lives on the node now. The values
|
||||
// deliberately equal the old per-binding derivation
|
||||
// (ng:cus_1 + the customer's relay password) so every format
|
||||
// body stays byte-identical — the change is only in where
|
||||
// the pair comes from.
|
||||
SocksUser: "ng:cus_1", SocksPassword: "FRYXGBkaGxwdHh8gISIjJA=="},
|
||||
{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},
|
||||
}
|
||||
nc := func(nodeID string) *NodeClient {
|
||||
return &NodeClient{NodeID: nodeID, CustomerID: "cus_1", Status: "active"}
|
||||
}
|
||||
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"),
|
||||
nc("nod_rf"), nc("nod_ss"), nc("nod_hy"), nc("nod_any"),
|
||||
nc("nod_ws"), nc("nod_sk"), nc("nod_rv"),
|
||||
}
|
||||
return &Data{
|
||||
Servers: []*Server{entryServer, v6Server},
|
||||
Nodes: nodes,
|
||||
NodeClients: clients,
|
||||
Customers: []*Customer{{ID: "cus_1"}},
|
||||
// One credential set for the whole customer (round-2 item 2). The
|
||||
// values are the old RF binding's uuid/shortId and the old SS
|
||||
// binding's password, so the RF and SS entries are byte-stable
|
||||
// against the previous golden file; every other entry now carries
|
||||
// this same set.
|
||||
Customers: []*Customer{{ID: "cus_1", Credentials: &Credentials{
|
||||
ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001ef01",
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
// Tests for CustomerSummary (round-2 item 1): the six counts on the
|
||||
// customers page, with depleted/depletingSoon predicates aligned to
|
||||
// 3x-ui's depletedExpr/expiringExpr (client_paging.go) — including its
|
||||
// panel-configurable expireDiff (days) / trafficDiff (GB) thresholds,
|
||||
// which default to 0 and then count nobody as depleting soon.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func summaryFixture(now time.Time) *Data {
|
||||
iso := func(t time.Time) *string { s := t.UTC().Format(time.RFC3339); return &s }
|
||||
manual := "manual"
|
||||
expiredReason := "expired"
|
||||
gb := float64(1073741824)
|
||||
return &Data{Customers: []*Customer{
|
||||
// active + online + plain
|
||||
{ID: "cus_a", Name: "A", Status: "active", LastOnlineAt: iso(now)},
|
||||
// active, traffic exhausted
|
||||
{ID: "cus_b", Name: "B", Status: "active", TrafficLimitBytes: 1000, UsedBytes: 1000},
|
||||
// active, 0.5GB remaining of 2GB
|
||||
{ID: "cus_c", Name: "C", Status: "active", TrafficLimitBytes: 2 * gb, UsedBytes: int64(1.5 * gb)},
|
||||
// active, expires in 2 days
|
||||
{ID: "cus_d", Name: "D", Status: "active", ExpiresAt: iso(now.Add(48 * time.Hour))},
|
||||
// active, expires in 10 days
|
||||
{ID: "cus_e", Name: "E", Status: "active", ExpiresAt: iso(now.Add(240 * time.Hour))},
|
||||
// manually disabled
|
||||
{ID: "cus_f", Name: "F", Status: "suspended", SuspendReason: &manual, SuspendReasonPresent: true},
|
||||
// expired (auto-suspended): depleted, not "disabled"
|
||||
{ID: "cus_g", Name: "G", Status: "suspended", SuspendReason: &expiredReason, SuspendReasonPresent: true, ExpiresAt: iso(now.Add(-time.Hour))},
|
||||
// active, unlimited with heavy usage: never depleted
|
||||
{ID: "cus_h", Name: "H", Status: "active", UsedBytes: int64(9 * gb)},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestCustomerSummaryDefaultThresholds(t *testing.T) {
|
||||
now := time.Now()
|
||||
d := summaryFixture(now)
|
||||
s := CustomerSummary(d, now.UnixMilli())
|
||||
want := map[string]int{"total": 8, "online": 1, "depleted": 2, "depletingSoon": 0, "disabled": 1, "enabled": 6}
|
||||
for k, v := range want {
|
||||
if s[k] != v {
|
||||
t.Fatalf("summary[%s] = %v, want %d (full: %v)", k, s[k], v, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerSummaryWithThresholds(t *testing.T) {
|
||||
now := time.Now()
|
||||
d := summaryFixture(now)
|
||||
d.Settings.ExpireDiff = 3 // days
|
||||
d.Settings.TrafficDiff = 1 // GB
|
||||
s := CustomerSummary(d, now.UnixMilli())
|
||||
// C (0.5GB remaining < 1GB) and D (expiry in 2d < 3d) become depleting soon;
|
||||
// E (10d) does not. Nothing else moves.
|
||||
if s["depletingSoon"] != 2 {
|
||||
t.Fatalf("depletingSoon = %v, want 2 (full: %v)", s["depletingSoon"], s)
|
||||
}
|
||||
if s["depleted"] != 2 || s["enabled"] != 6 || s["disabled"] != 1 {
|
||||
t.Fatalf("other counts moved: %v", s)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -12,7 +12,7 @@
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "ng-nodrf-in",
|
||||
"listen": "203.0.113.10",
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": 23456,
|
||||
"users": [
|
||||
{
|
||||
@@ -76,7 +76,7 @@
|
||||
{
|
||||
"type": "shadowsocks",
|
||||
"tag": "ng-nodrf-exit-in",
|
||||
"listen": "203.0.113.20",
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": 23457,
|
||||
"method": "2022-blake3-aes-256-gcm",
|
||||
"password": "AwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISI="
|
||||
@@ -111,7 +111,7 @@
|
||||
{
|
||||
"type": "shadowsocks",
|
||||
"tag": "ng-nodss-in",
|
||||
"listen": "203.0.113.11",
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": 23458,
|
||||
"method": "2022-blake3-aes-128-gcm",
|
||||
"password": "CwwNDg8QERITFBUWFxgZGg==",
|
||||
|
||||
+32
-32
@@ -2,7 +2,7 @@
|
||||
"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"
|
||||
"clientUri": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23456?encryption=none\u0026security=reality\u0026sni=www.amd.com\u0026fp=chrome\u0026pbk=FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\u0026sid=abcd0001ef01\u0026type=tcp\u0026headerType=none\u0026flow=xtls-rprx-vision#RF-Reality"
|
||||
},
|
||||
{
|
||||
"nodeId": "nod_ss",
|
||||
@@ -10,56 +10,56 @@
|
||||
},
|
||||
{
|
||||
"nodeId": "nod_hy",
|
||||
"clientUri": "hysteria2://HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz@entry.example.com:23459?sni=tls.example.com&alpn=h3#HY2"
|
||||
"clientUri": "hysteria2://FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23459?sni=tls.example.com\u0026alpn=h3#HY2"
|
||||
},
|
||||
{
|
||||
"nodeId": "nod_any",
|
||||
"clientUri": "anytls://IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6@entry.example.com:23460?sni=tls.example.com#AnyTLS"
|
||||
"clientUri": "anytls://FRYXGBkaGxwdHh8gISIjJA%3D%3D@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"
|
||||
"clientUri": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23461?encryption=none\u0026security=tls\u0026type=ws\u0026path=%2Fwspath123\u0026sni=tls.example.com#WS-TLS"
|
||||
},
|
||||
{
|
||||
"nodeId": "nod_sk",
|
||||
"clientUri": "socks5://ng%3Acus_1:MTIzNDU2Nzg5Ojs8PT4%2FQEFCQ0RFRkdI@entry.example.com:23462#Socks"
|
||||
"clientUri": "socks5://ng%3Acus_1:FRYXGBkaGxwdHh8gISIjJA%3D%3D@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"
|
||||
"clientUri": "vless://11111111-2222-3333-4444-000000000001@v6.example.com:23463?encryption=none\u0026security=reality\u0026sni=www.apple.com\u0026fp=chrome\u0026pbk=FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\u0026sid=abcd0001ef01\u0026type=tcp\u0026headerType=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",
|
||||
"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: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\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-000000000001\"\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: \"FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\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: \"abcd0001ef01\"\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",
|
||||
"body": "dmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NTY/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy5hbWQuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSZktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDAxZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUmZmxvdz14dGxzLXJwcngtdmlzaW9uI1JGLVJlYWxpdHkKc3M6Ly9NakF5TWkxaWJHRnJaVE10WVdWekxURXlPQzFuWTIwNlEzZDNUa1JuT0ZGRlVrbFVSa0pWVjBaNFoxcEhaejA5T2taU1dWaEhRbXRoUjNoM1pFaG9PR2RKVTBscVNrRTlQUUBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OCNTUy1EaXJlY3QKaHlzdGVyaWEyOi8vRlJZWEdCa2FHeHdkSGg4Z0lTSWpKQSUzRCUzREBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OT9zbmk9dGxzLmV4YW1wbGUuY29tJmFscG49aDMjSFkyCmFueXRsczovL0ZSWVhHQmthR3h3ZEhoOGdJU0lqSkElM0QlM0RAZW50cnkuZXhhbXBsZS5jb206MjM0NjA/c25pPXRscy5leGFtcGxlLmNvbSNBbnlUTFMKdmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NjE/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXRscyZ0eXBlPXdzJnBhdGg9JTJGd3NwYXRoMTIzJnNuaT10bHMuZXhhbXBsZS5jb20jV1MtVExTCnNvY2tzNTovL25nJTNBY3VzXzE6RlJZWEdCa2FHeHdkSGg4Z0lTSWpKQSUzRCUzREBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MiNTb2Nrcwp2bGVzczovLzExMTExMTExLTIyMjItMzMzMy00NDQ0LTAwMDAwMDAwMDAwMUB2Ni5leGFtcGxlLmNvbToyMzQ2Mz9lbmNyeXB0aW9uPW5vbmUmc2VjdXJpdHk9cmVhbGl0eSZzbmk9d3d3LmFwcGxlLmNvbSZmcD1jaHJvbWUmcGJrPUZJWEVEUEJLUnZLZXkwMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFyc3R1JnNpZD1hYmNkMDAwMWVmMDEmdHlwZT10Y3AmaGVhZGVyVHlwZT1ub25lI1JWLVBsYWluCg==",
|
||||
"contentType": "text/plain; charset=utf-8"
|
||||
},
|
||||
"base64": {
|
||||
"body": "dmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NTY/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy5hbWQuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSZktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDAxZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUmZmxvdz14dGxzLXJwcngtdmlzaW9uI1JGLVJlYWxpdHkKc3M6Ly9NakF5TWkxaWJHRnJaVE10WVdWekxURXlPQzFuWTIwNlEzZDNUa1JuT0ZGRlVrbFVSa0pWVjBaNFoxcEhaejA5T2taU1dWaEhRbXRoUjNoM1pFaG9PR2RKVTBscVNrRTlQUUBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OCNTUy1EaXJlY3QKaHlzdGVyaWEyOi8vRlJZWEdCa2FHeHdkSGg4Z0lTSWpKQSUzRCUzREBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OT9zbmk9dGxzLmV4YW1wbGUuY29tJmFscG49aDMjSFkyCmFueXRsczovL0ZSWVhHQmthR3h3ZEhoOGdJU0lqSkElM0QlM0RAZW50cnkuZXhhbXBsZS5jb206MjM0NjA/c25pPXRscy5leGFtcGxlLmNvbSNBbnlUTFMKdmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NjE/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXRscyZ0eXBlPXdzJnBhdGg9JTJGd3NwYXRoMTIzJnNuaT10bHMuZXhhbXBsZS5jb20jV1MtVExTCnNvY2tzNTovL25nJTNBY3VzXzE6RlJZWEdCa2FHeHdkSGg4Z0lTSWpKQSUzRCUzREBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MiNTb2Nrcwp2bGVzczovLzExMTExMTExLTIyMjItMzMzMy00NDQ0LTAwMDAwMDAwMDAwMUB2Ni5leGFtcGxlLmNvbToyMzQ2Mz9lbmNyeXB0aW9uPW5vbmUmc2VjdXJpdHk9cmVhbGl0eSZzbmk9d3d3LmFwcGxlLmNvbSZmcD1jaHJvbWUmcGJrPUZJWEVEUEJLUnZLZXkwMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFyc3R1JnNpZD1hYmNkMDAwMWVmMDEmdHlwZT10Y3AmaGVhZGVyVHlwZT1ub25lI1JWLVBsYWluCg==",
|
||||
"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: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\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-000000000001\"\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: \"FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\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: \"abcd0001ef01\"\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: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\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-000000000001\"\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: \"FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\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: \"abcd0001ef01\"\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"
|
||||
},
|
||||
"raw": {
|
||||
"body": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23456?encryption=none\u0026security=reality\u0026sni=www.amd.com\u0026fp=chrome\u0026pbk=FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\u0026sid=abcd0001ef01\u0026type=tcp\u0026headerType=none\u0026flow=xtls-rprx-vision#RF-Reality\nss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206Q3d3TkRnOFFFUklURkJVV0Z4Z1pHZz09OkZSWVhHQmthR3h3ZEhoOGdJU0lqSkE9PQ@entry.example.com:23458#SS-Direct\nhysteria2://FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23459?sni=tls.example.com\u0026alpn=h3#HY2\nanytls://FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23460?sni=tls.example.com#AnyTLS\nvless://11111111-2222-3333-4444-000000000001@entry.example.com:23461?encryption=none\u0026security=tls\u0026type=ws\u0026path=%2Fwspath123\u0026sni=tls.example.com#WS-TLS\nsocks5://ng%3Acus_1:FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23462#Socks\nvless://11111111-2222-3333-4444-000000000001@v6.example.com:23463?encryption=none\u0026security=reality\u0026sni=www.apple.com\u0026fp=chrome\u0026pbk=FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\u0026sid=abcd0001ef01\u0026type=tcp\u0026headerType=none#RV-Plain\n",
|
||||
"contentType": "text/plain; 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\": \"FRYXGBkaGxwdHh8gISIjJA==\",\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\": \"FRYXGBkaGxwdHh8gISIjJA==\",\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-000000000001\",\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\": \"FRYXGBkaGxwdHh8gISIjJA==\"\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-7\",\n \"server\": \"v6.example.com\",\n \"server_port\": 23463,\n \"uuid\": \"11111111-2222-3333-4444-000000000001\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"www.apple.com\",\n \"reality\": {\n \"enabled\": true,\n \"public_key\": \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\",\n \"short_id\": \"abcd0001ef01\"\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, FRYXGBkaGxwdHh8gISIjJA__\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"
|
||||
}
|
||||
}
|
||||
|
||||
+199
-70
@@ -254,8 +254,85 @@ func (d Data) MarshalJSON() ([]byte, error) {
|
||||
return mergeMarshal(plain(d), d.Extra)
|
||||
}
|
||||
|
||||
// Clone is structuredClone(this.data).
|
||||
// Clone is structuredClone(this.data). Since round-3 item 22 it is a
|
||||
// reflective deep copy: the old JSON marshal/unmarshal round trip cost
|
||||
// a full serialize + parse of the whole database (with a per-record
|
||||
// map dance for unknown fields) on EVERY store read — twice per admin
|
||||
// request — and dominated panel latency on the router's CPU. The data
|
||||
// graph is plain exported structs/slices/maps/strings/numbers, which
|
||||
// reflection copies exactly; TestCloneEquivalence proves byte-identical
|
||||
// output against cloneViaJSON on the real seed database.
|
||||
func (d *Data) Clone() *Data {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneReflectValue(reflect.ValueOf(d)).Interface().(*Data)
|
||||
}
|
||||
|
||||
// cloneReflectValue deep-copies v. Structs are shallow-copied first (so
|
||||
// any unexported field keeps its value) and every exported field is
|
||||
// then replaced by its deep copy; []byte (incl. json.RawMessage) is
|
||||
// copied verbatim; scalars pass through.
|
||||
func cloneReflectValue(v reflect.Value) reflect.Value {
|
||||
if !v.IsValid() {
|
||||
return v
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.Pointer:
|
||||
if v.IsNil() {
|
||||
return v
|
||||
}
|
||||
out := reflect.New(v.Type().Elem())
|
||||
out.Elem().Set(cloneReflectValue(v.Elem()))
|
||||
return out
|
||||
case reflect.Interface:
|
||||
if v.IsNil() {
|
||||
return v
|
||||
}
|
||||
return reflect.ValueOf(cloneReflectValue(v.Elem()).Interface())
|
||||
case reflect.Slice:
|
||||
if v.IsNil() {
|
||||
return v
|
||||
}
|
||||
if v.Type().Elem().Kind() == reflect.Uint8 {
|
||||
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
|
||||
reflect.Copy(out, v)
|
||||
return out
|
||||
}
|
||||
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
out.Index(i).Set(cloneReflectValue(v.Index(i)))
|
||||
}
|
||||
return out
|
||||
case reflect.Map:
|
||||
if v.IsNil() {
|
||||
return v
|
||||
}
|
||||
out := reflect.MakeMapWithSize(v.Type(), v.Len())
|
||||
iter := v.MapRange()
|
||||
for iter.Next() {
|
||||
out.SetMapIndex(cloneReflectValue(iter.Key()), cloneReflectValue(iter.Value()))
|
||||
}
|
||||
return out
|
||||
case reflect.Struct:
|
||||
out := reflect.New(v.Type()).Elem()
|
||||
out.Set(v)
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
if v.Type().Field(i).PkgPath != "" {
|
||||
continue // unexported: kept from the shallow copy
|
||||
}
|
||||
out.Field(i).Set(cloneReflectValue(v.Field(i)))
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// cloneViaJSON is the original Clone implementation: a full JSON
|
||||
// marshal/unmarshal round trip. Kept for the equivalence test and the
|
||||
// item-22 benchmarks that quantify what the reflective Clone replaced.
|
||||
func (d *Data) cloneViaJSON() *Data {
|
||||
raw, err := marshalJSON(d)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -268,14 +345,19 @@ func (d *Data) Clone() *Data {
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
BrandName string `json:"brandName"`
|
||||
SessionHours float64 `json:"sessionHours"`
|
||||
ObservationTtlMinutes float64 `json:"observationTtlMinutes"`
|
||||
ActivityRetentionDays float64 `json:"activityRetentionDays"`
|
||||
CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"`
|
||||
ExternalSubRefreshMinutes float64 `json:"externalSubRefreshMinutes,omitempty"`
|
||||
AllowPrivateSubSources bool `json:"allowPrivateSubSources,omitempty"`
|
||||
Extra extraFields `json:"-"`
|
||||
BrandName string `json:"brandName"`
|
||||
SessionHours float64 `json:"sessionHours"`
|
||||
ObservationTtlMinutes float64 `json:"observationTtlMinutes"`
|
||||
ActivityRetentionDays float64 `json:"activityRetentionDays"`
|
||||
CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"`
|
||||
ExternalSubRefreshMinutes float64 `json:"externalSubRefreshMinutes,omitempty"`
|
||||
AllowPrivateSubSources bool `json:"allowPrivateSubSources,omitempty"`
|
||||
// Depleting-soon thresholds for the customers summary, aligned with
|
||||
// 3x-ui's expireDiff/trafficDiff settings (same names, units and
|
||||
// factory default of 0 = nobody counts as depleting soon).
|
||||
ExpireDiff float64 `json:"expireDiff,omitempty"`
|
||||
TrafficDiff float64 `json:"trafficDiff,omitempty"`
|
||||
Extra extraFields `json:"-"`
|
||||
}
|
||||
|
||||
func (s *Settings) UnmarshalJSON(raw []byte) error {
|
||||
@@ -327,6 +409,7 @@ type EngineState struct {
|
||||
SingBoxInstalled bool `json:"singBoxInstalled"`
|
||||
Certificates []string `json:"certificates"`
|
||||
SingBoxStatus string `json:"singBoxStatus"`
|
||||
SingBoxVersion string `json:"singBoxVersion,omitempty"`
|
||||
At string `json:"at"`
|
||||
Extra extraFields `json:"-"`
|
||||
}
|
||||
@@ -425,16 +508,23 @@ func (s Server) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
|
||||
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:"-"`
|
||||
ID string `json:"id"`
|
||||
ServerID string `json:"serverId"`
|
||||
KeyHash string `json:"keyHash"`
|
||||
KeyFingerprint *string `json:"keyFingerprint,omitempty"`
|
||||
// SessionTokenHash is the SHA-256 hex of the short-lived agent session
|
||||
// token (item 25): after one scrypt-verified key auth the panel issues
|
||||
// a random token and stores only its hash here, so steady-state agent
|
||||
// requests authenticate with a constant-time hash compare instead of
|
||||
// a scrypt run. The plaintext token is never persisted panel-side.
|
||||
SessionTokenHash *string `json:"sessionTokenHash,omitempty"`
|
||||
SessionTokenExpiresAt *string `json:"sessionTokenExpiresAt,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 {
|
||||
@@ -453,6 +543,15 @@ func (a Agent) MarshalJSON() ([]byte, error) {
|
||||
return mergeMarshal(plain(a), a.Extra)
|
||||
}
|
||||
|
||||
// Revoke marks the agent revoked and drops any issued session token
|
||||
// (item 25), so the token dies with the revocation even if the record's
|
||||
// status were flipped back later.
|
||||
func (a *Agent) Revoke() {
|
||||
a.Status = "revoked"
|
||||
a.SessionTokenHash = nil
|
||||
a.SessionTokenExpiresAt = nil
|
||||
}
|
||||
|
||||
type EnrollmentToken struct {
|
||||
ID string `json:"id"`
|
||||
ServerID string `json:"serverId"`
|
||||
@@ -554,9 +653,13 @@ type Customer struct {
|
||||
ServerUsage map[string]*UsageSlot `json:"serverUsage,omitempty"`
|
||||
LastUsageAt *string `json:"lastUsageAt,omitempty"`
|
||||
LastOnlineAt *string `json:"lastOnlineAt,omitempty"`
|
||||
ExternalSources []ExtSource `json:"externalSources,omitempty"`
|
||||
ManualLinks []ManualLink `json:"manualLinks,omitempty"`
|
||||
Extra extraFields `json:"-"`
|
||||
// The customer's single credential set, shared by all of their node
|
||||
// bindings (round-2 item 2). Populated by the store-load migration for
|
||||
// legacy records and at creation for new ones.
|
||||
Credentials *Credentials `json:"credentials,omitempty"`
|
||||
ExternalSources []ExtSource `json:"externalSources,omitempty"`
|
||||
ManualLinks []ManualLink `json:"manualLinks,omitempty"`
|
||||
Extra extraFields `json:"-"`
|
||||
}
|
||||
|
||||
// ExtSource is one external subscription source on a customer
|
||||
@@ -693,46 +796,68 @@ func (m MeteredTraffic) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
|
||||
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"`
|
||||
SortIndex int `json:"sortIndex"`
|
||||
SpeedUpBps *float64 `json:"speedUpBps,omitempty"`
|
||||
SpeedDownBps *float64 `json:"speedDownBps,omitempty"`
|
||||
SpeedAt *string `json:"speedAt,omitempty"`
|
||||
LastActiveAt *string `json:"lastActiveAt,omitempty"`
|
||||
LastSampleAt *string `json:"lastSampleAt,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Extra extraFields `json:"-"`
|
||||
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"`
|
||||
// ExitMode selects the forward exit target: "" / "managed" is a panel
|
||||
// server (ExitServerID); "manual" is an unmanaged landing endpoint
|
||||
// described by ExitAddress + ExitPort + ExitProtocol + ExitCredentials
|
||||
// (round-2 item 8). Manual exits have no exit resource and are always
|
||||
// ExitReady — the panel only builds the entry-side outbound.
|
||||
ExitMode string `json:"exitMode,omitempty"`
|
||||
ExitAddress string `json:"exitAddress,omitempty"`
|
||||
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"`
|
||||
// SocksUser/SocksPassword are the node-level fixed account of a
|
||||
// socks5-auth inbound (round-3 item 21, the 3x-ui inbound model):
|
||||
// the inbound's only user, carried by every subscriber's link for
|
||||
// this node. Empty on legacy records until the store-load migration
|
||||
// (MigrateSocksNodeCredentials) mints a pair.
|
||||
SocksUser string `json:"socksUser,omitempty"`
|
||||
SocksPassword string `json:"socksPassword,omitempty"`
|
||||
// RebuildPending marks a node whose binding-class changes are being
|
||||
// coalesced (round-3 item 23): the flusher applies ONE rebuild with
|
||||
// the final state once the fixed window (from RebuildPendingAt, the
|
||||
// FIRST pending change) elapses. Persisted so a panel restart inside
|
||||
// the window cannot strand the node un-deployed.
|
||||
RebuildPending bool `json:"rebuildPending,omitempty"`
|
||||
RebuildPendingAt string `json:"rebuildPendingAt,omitempty"`
|
||||
RebuildPendingExit bool `json:"rebuildPendingExit,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"`
|
||||
SortIndex int `json:"sortIndex"`
|
||||
SpeedUpBps *float64 `json:"speedUpBps,omitempty"`
|
||||
SpeedDownBps *float64 `json:"speedDownBps,omitempty"`
|
||||
SpeedAt *string `json:"speedAt,omitempty"`
|
||||
LastActiveAt *string `json:"lastActiveAt,omitempty"`
|
||||
LastSampleAt *string `json:"lastSampleAt,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Extra extraFields `json:"-"`
|
||||
}
|
||||
|
||||
func (n *Node) UnmarshalJSON(raw []byte) error {
|
||||
@@ -775,14 +900,18 @@ func (c Credentials) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
|
||||
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:"-"`
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
CustomerID string `json:"customerId"`
|
||||
// Deprecated: credentials live on the Customer (round-2 item 2). This
|
||||
// field is only a migration input — the store-load migration converges
|
||||
// it into Customer.Credentials and clears it, so it disappears from
|
||||
// disk on the next save. Builders receive resolved copies instead.
|
||||
Credentials *Credentials `json:"credentials,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Extra extraFields `json:"-"`
|
||||
}
|
||||
|
||||
func (n *NodeClient) UnmarshalJSON(raw []byte) error {
|
||||
|
||||
@@ -53,8 +53,8 @@ func TestAggregateEntriesOrderAndDedup(t *testing.T) {
|
||||
},
|
||||
Servers: []*Server{{ID: "srv_1", Name: "S", PublicAddress: "s.example.com"}},
|
||||
NodeClients: []*NodeClient{
|
||||
{ID: "ncl_b", NodeID: "nod_b", CustomerID: "cus_1", Status: "active", Credentials: Credentials{RelayPassword: "pw-b"}},
|
||||
{ID: "ncl_a", NodeID: "nod_a", CustomerID: "cus_1", Status: "active", Credentials: Credentials{RelayPassword: "pw-a"}},
|
||||
{ID: "ncl_b", NodeID: "nod_b", CustomerID: "cus_1", Status: "active"},
|
||||
{ID: "ncl_a", NodeID: "nod_a", CustomerID: "cus_1", Status: "active"},
|
||||
},
|
||||
SubSourceCache: map[string]*SubSourceCacheEntry{
|
||||
"src_1": {Items: []SubCacheItem{{URI: "ss://YWVzLTI1Ni1nY206cHctYQ@s.example.com:20002#Dup", Name: "Dup"}}},
|
||||
@@ -63,12 +63,13 @@ func TestAggregateEntriesOrderAndDedup(t *testing.T) {
|
||||
okAt := NowISO()
|
||||
d.SubSourceCache["src_1"].OkAt = &okAt
|
||||
customer := &Customer{
|
||||
ID: "cus_1",
|
||||
ID: "cus_1", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "pw-a", ShortID: "abcd0001ef01"},
|
||||
ManualLinks: []ManualLink{
|
||||
{ID: "lnk_1", URI: "ss://YWVzLTI1Ni1nY206cHctYQ@s.example.com:20002#Whatever", Remark: "手动一号", Enabled: true},
|
||||
},
|
||||
ExternalSources: []ExtSource{{ID: "src_1", URL: "https://example.com/sub", Remark: "机场", Enabled: true}},
|
||||
}
|
||||
d.Customers = []*Customer{customer}
|
||||
entries := AggregateEntries(d, customer)
|
||||
names := []string{}
|
||||
for _, entry := range entries {
|
||||
|
||||
+416
-83
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
/* amo 控制台前端。六个页面:系统状态 / 服务器 / 客户与订阅 / 转发与节点 /
|
||||
安装与命令 / 设置与运维。hash 路由,45 秒自动刷新(弹窗打开时暂停)。 */
|
||||
/* amo 控制台前端。五个页面:系统状态 / 节点管理 / 用户订阅 / 入站&出站 /
|
||||
设置。hash 路由,45 秒自动刷新(弹窗打开时暂停)。 */
|
||||
|
||||
const state = {
|
||||
session: null, page: 'overview', overview: null, system: null,
|
||||
@@ -9,7 +9,9 @@ const state = {
|
||||
protocols: [], realityPresets: [], version: '0.6.8',
|
||||
search: '', groupFilter: '', customerNodeFilter: '',
|
||||
pages: { servers: 1, customers: 1, nodes: 1 },
|
||||
addrVisible: false, expandedServers: new Set()
|
||||
addrVisible: false, expandedServers: new Set(),
|
||||
selection: { servers: new Set(), customers: new Set(), nodes: new Set() },
|
||||
bulkRunning: false
|
||||
};
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
@@ -62,14 +64,7 @@ const statusText = {
|
||||
};
|
||||
const roleText = { relay: '入口 / 转发', exit: '出口 / 落地', hybrid: '综合节点' };
|
||||
const topologyText = { forward: '转发线路', direct: '单机直连' };
|
||||
const auditText = {
|
||||
create_server: '添加设备', update_server: '编辑设备', delete_server: '删除设备', create_enrollment: '生成注册令牌', enroll_agent: 'Agent 注册',
|
||||
reconcile_server: '设备配置对账', set_tls_domain: '节点证书同步', create_customer: '添加客户', update_customer: '编辑客户', reset_customer_usage: '重置客户流量', delete_customer: '删除客户',
|
||||
create_node: '创建节点', update_node: '编辑节点', deploy_node: '部署节点', repair_node: '修复节点', remove_node: '停用节点', reorder_nodes: '节点排序',
|
||||
delete_node: '删除节点', assign_customer: '分配客户', unassign_customer: '移除分配', suspend_customer_resources: '暂停客户资源', resume_customer_resources: '恢复客户资源',
|
||||
retry_cleanup: '重试遗留清理', force_forget_server: '强制遗忘离线设备', update_account: '修改管理员账号', suspend_ip_limit: 'IP 超限停用', rotate_subscription: '重置订阅链接',
|
||||
regenerate_credentials: '重新生成凭据', monthly_reset: '按月重置流量'
|
||||
};
|
||||
|
||||
const suspendReasonText = { traffic_limit: '额度耗尽', expired: '已到期', ip_limit: '节点 IP 超限', manual: '手动停用' };
|
||||
|
||||
const ICONS = {
|
||||
@@ -135,6 +130,9 @@ async function api(path, options = {}) {
|
||||
if (response.status === 401 && path !== '/api/auth/login') showLogin();
|
||||
throw new Error((data && data.message) || `请求失败 (${response.status})`);
|
||||
}
|
||||
// Any successful mutation invalidates the page-data cache (item 22):
|
||||
// every write path in this file funnels through api().
|
||||
if (!['GET', 'HEAD'].includes(options.method || 'GET')) invalidateDataCache();
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -144,7 +142,6 @@ function showLogin() {
|
||||
function showApp() {
|
||||
$('#login').classList.add('hidden'); $('#app').classList.remove('hidden');
|
||||
$('#user-chip').textContent = state.session.user.username;
|
||||
$('#avatar').textContent = (state.session.user.username || '管').slice(0, 1).toUpperCase();
|
||||
$('#version-label').textContent = `v${state.version}`;
|
||||
}
|
||||
|
||||
@@ -197,6 +194,38 @@ function showPop(anchor, html) {
|
||||
}
|
||||
function hidePop() { const pop = $('#float-pop'); pop.classList.add('hidden'); pop.innerHTML = ''; pop._anchor = null; }
|
||||
|
||||
/* ---------- 图标悬停提示(data-tip,3x-ui 式气泡,挂 body 防裁剪) ---------- */
|
||||
let tipBubble = null;
|
||||
let tipTarget = null;
|
||||
function showTip(el) {
|
||||
const text = el.getAttribute('data-tip');
|
||||
if (!text) return;
|
||||
if (!tipBubble) {
|
||||
tipBubble = document.createElement('div');
|
||||
tipBubble.className = 'tip-bubble';
|
||||
document.body.appendChild(tipBubble);
|
||||
}
|
||||
tipTarget = el;
|
||||
tipBubble.textContent = text;
|
||||
tipBubble.classList.add('show');
|
||||
const rect = el.getBoundingClientRect();
|
||||
const bw = tipBubble.offsetWidth;
|
||||
const bh = tipBubble.offsetHeight;
|
||||
let left = rect.left + rect.width / 2 - bw / 2;
|
||||
left = Math.max(6, Math.min(left, window.innerWidth - bw - 6));
|
||||
tipBubble.style.left = `${left}px`;
|
||||
tipBubble.style.top = `${Math.max(4, rect.top - bh - 9)}px`;
|
||||
tipBubble.style.setProperty('--tip-arrow-left', `${rect.left + rect.width / 2 - left}px`);
|
||||
}
|
||||
function hideTip() { tipTarget = null; if (tipBubble) tipBubble.classList.remove('show'); }
|
||||
document.addEventListener('mouseover', (event) => {
|
||||
const el = event.target && event.target.closest ? event.target.closest('[data-tip]') : null;
|
||||
if (el === tipTarget) return;
|
||||
if (el) showTip(el); else hideTip();
|
||||
});
|
||||
document.addEventListener('mousedown', hideTip);
|
||||
window.addEventListener('scroll', hideTip, true);
|
||||
|
||||
/* ---------- 分页 ---------- */
|
||||
function pageSlice(items, key) {
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
|
||||
@@ -216,35 +245,64 @@ function pagerHTML(key, total) {
|
||||
}
|
||||
|
||||
/* ---------- 数据加载与路由 ---------- */
|
||||
/* 基础数据集页间缓存(第 22 条):servers/customers/nodes/protocols 在
|
||||
30 秒内切页直接复用、不重复请求;任何写操作经 api() 统一失效。
|
||||
渲染不再等全部请求落地:每份数据到达即更新 state 并重渲染当前页。 */
|
||||
const DATA_CACHE_TTL_MS = 30000;
|
||||
const dataCacheAt = { servers: 0, customers: 0, nodes: 0, protocols: 0 };
|
||||
function invalidateDataCache() { for (const key of Object.keys(dataCacheAt)) dataCacheAt[key] = 0; }
|
||||
function cacheFresh(key) { return dataCacheAt[key] && Date.now() - dataCacheAt[key] < DATA_CACHE_TTL_MS; }
|
||||
|
||||
async function load(page = state.page) {
|
||||
const isCurrent = () => state.page === page && !modalOpen();
|
||||
const apply = (fn) => { fn(); if (isCurrent()) render(); };
|
||||
const jobs = [];
|
||||
const errors = [];
|
||||
// Cached base dataset: fetch only when stale. A partial failure keeps
|
||||
// whatever data is already in state (the page renders progressively);
|
||||
// only a total failure surfaces a toast, as the old all-or-nothing
|
||||
// load did.
|
||||
const base = (key, path, fn) => {
|
||||
if (cacheFresh(key)) return;
|
||||
jobs.push(api(path).then((data) => { dataCacheAt[key] = Date.now(); apply(() => fn(data)); }).catch((error) => errors.push(error)));
|
||||
};
|
||||
const fresh = (path, fn) => { jobs.push(api(path).then((data) => apply(() => fn(data))).catch((error) => errors.push(error))); };
|
||||
const setServers = (d) => { state.servers = d.servers; };
|
||||
const setCustomers = (d) => { state.customers = d.customers; state.customerSummary = d.summary || null; };
|
||||
const setNodes = (d) => { state.nodes = d.nodes; state.nodeClients = d.nodeClients; };
|
||||
const setOverview = (d) => { state.overview = d; state.version = d.version || state.version; $('#version-label').textContent = `v${state.version}`; };
|
||||
try {
|
||||
if (page === 'overview') {
|
||||
const [overview, system, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/system'), api('/api/servers'), api('/api/customers'), api('/api/nodes')]);
|
||||
state.overview = overview; state.system = system; state.version = overview.version || state.version;
|
||||
state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
$('#version-label').textContent = `v${state.version}`;
|
||||
fresh('/api/overview', setOverview);
|
||||
fresh('/api/system', (d) => { state.system = d; });
|
||||
base('servers', '/api/servers', setServers);
|
||||
base('customers', '/api/customers', setCustomers);
|
||||
base('nodes', '/api/nodes', setNodes);
|
||||
} else if (page === 'servers') {
|
||||
const [servers, routes] = await Promise.all([api('/api/servers'), api('/api/nodes')]);
|
||||
state.servers = servers.servers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
base('servers', '/api/servers', setServers);
|
||||
base('nodes', '/api/nodes', setNodes);
|
||||
} else if (page === 'customers') {
|
||||
const [customers, routes] = await Promise.all([api('/api/customers'), api('/api/nodes')]);
|
||||
state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
base('customers', '/api/customers', setCustomers);
|
||||
base('nodes', '/api/nodes', setNodes);
|
||||
} else if (page === 'chains') {
|
||||
const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/nodes'), api('/api/protocols')]);
|
||||
state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || [];
|
||||
} else if (page === 'commands') { /* 静态页 */ }
|
||||
else if (page === 'operations') {
|
||||
const [jobs, overview] = await Promise.all([api('/api/jobs'), api('/api/overview')]);
|
||||
state.jobs = jobs.jobs; state.overview = overview; state.version = overview.version || state.version;
|
||||
$('#version-label').textContent = `v${state.version}`;
|
||||
base('servers', '/api/servers', setServers);
|
||||
base('customers', '/api/customers', setCustomers);
|
||||
base('nodes', '/api/nodes', setNodes);
|
||||
base('protocols', '/api/protocols', (d) => { state.protocols = d.profiles; state.realityPresets = d.realityPresets || []; });
|
||||
} else if (page === 'operations') {
|
||||
fresh('/api/jobs', (d) => { state.jobs = d.jobs; });
|
||||
fresh('/api/overview', setOverview);
|
||||
}
|
||||
render();
|
||||
// Render immediately with whatever is already in state (cached or
|
||||
// previously loaded), then re-render as the rest lands.
|
||||
if (isCurrent()) render();
|
||||
await Promise.allSettled(jobs);
|
||||
if (errors.length && errors.length === jobs.length) throw errors[0];
|
||||
} catch (error) { toast(error.message, true); }
|
||||
}
|
||||
|
||||
const PAGES = ['overview', 'servers', 'customers', 'chains', 'commands', 'operations'];
|
||||
const TITLES = { overview: '系统状态', servers: '服务器', customers: '客户与订阅', chains: '转发与节点', commands: '安装与命令', operations: '设置与运维' };
|
||||
const PAGES = ['overview', 'servers', 'customers', 'chains', 'operations'];
|
||||
const TITLES = { overview: '系统状态', servers: '节点管理', customers: '用户订阅', chains: '入站&出站', operations: '设置' };
|
||||
function pageFromHash() { const page = location.hash.replace(/^#/, '').split('?')[0]; return PAGES.includes(page) ? page : 'overview'; }
|
||||
function setPage(page) {
|
||||
if (!PAGES.includes(page)) page = 'overview';
|
||||
@@ -339,7 +397,6 @@ function renderOverview() {
|
||||
const meanUp = history.length ? history.reduce((a, p) => a + (p.upBps || 0), 0) / history.length : 0;
|
||||
const meanDown = history.length ? history.reduce((a, p) => a + (p.downBps || 0), 0) / history.length : 0;
|
||||
const degraded = (state.overview && state.overview.degraded) || [];
|
||||
const events = ((state.overview && state.overview.activity) || []).map((event) => `<div class="event"><i class="event-dot"></i><div><b>${esc(auditText[event.action] || event.action)}</b> <small>${esc(event.actor)} · ${esc(event.target)}</small></div><time>${fmtDate(event.at)}</time></div>`).join('');
|
||||
return `<section class="stat-grid">${cards}</section>
|
||||
<section class="mid-grid">
|
||||
<article class="panel chart-card">
|
||||
@@ -370,8 +427,7 @@ function renderOverview() {
|
||||
<div class="seg"><div class="cap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.7 3.8 9S14.5 18.4 12 21c-2.5-2.6-3.8-5.7-3.8-9S9.5 5.6 12 3z"/></svg>IP 地址</div>
|
||||
<div class="kv"><div class="item"><span class="k">IPv4</span><span class="v mono">${esc((sys.ips && sys.ips.ipv4) || '—')}</span></div><div class="item"><span class="k">IPv6</span><span class="v mono">${esc((sys.ips && sys.ips.ipv6) || '—')}</span></div></div></div>
|
||||
</section>
|
||||
${degraded.length ? `<div class="notice">${degraded.length} 个节点异常:${degraded.map((n) => esc(n.name)).join('、')}。到「转发与节点」查看并修复。</div>` : ''}
|
||||
<section class="panel"><div class="panel-head"><h2>最近活动</h2></div><div class="activity-list">${events || '<div class="empty"><b>暂无活动</b></div>'}</div></section>`;
|
||||
${degraded.length ? `<div class="notice">${degraded.length} 个节点异常:${degraded.map((n) => esc(n.name)).join('、')}。到「入站&出站」查看并修复。</div>` : ''}`;
|
||||
}
|
||||
|
||||
/* ---------- 服务器 ---------- */
|
||||
@@ -401,7 +457,7 @@ function serverDetailRow(server) {
|
||||
const tls = server.tlsDomain ? `${esc(server.tlsDomain)} · ${server.engine && (server.engine.certificates || []).includes(server.tlsDomain) ? '已确认' : '待确认'}` : '未设置';
|
||||
const engine = server.role === 'exit' ? '—' : (server.engine ? (server.engine.singBoxInstalled ? '已安装' : '按需安装(ng-agent engine install)') : '未上报');
|
||||
const usage = server.usage ? `${fmtDate(server.usage.lastReportAt)} · ${server.usage.sampleCount} 项${server.usage.error ? ` · <span class="v err">异常:${esc(server.usage.error)}</span>` : ''}` : '尚无上报';
|
||||
return `<tr class="detail-row"><td colspan="15"><div class="detail-grid">
|
||||
return `<tr class="detail-row"><td colspan="14"><div class="detail-grid">
|
||||
<div><span class="k">资源余量</span><span class="v">${resources}</span></div>
|
||||
<div><span class="k">TLS 证书(${esc(server.tlsDomain || '—')})</span><span class="v">${tls}</span></div>
|
||||
<div><span class="k">引擎状态</span><span class="v">${engine}</span></div>
|
||||
@@ -417,6 +473,102 @@ function serverDetailRow(server) {
|
||||
<button class="btn small" data-action="agent-uninstall" data-id="${esc(server.id)}">SSH 卸载 Agent</button>
|
||||
</div></td></tr>`;
|
||||
}
|
||||
/* ---------- 列表批量操作(第 24 条) ----------
|
||||
每页一份选中集合(state.selection),行勾选框与表头全选维护它;
|
||||
选中 ≥1 时工具条下出现批量条。批量动作逐项调用既有单项接口,
|
||||
语义与守卫与单项完全一致:服务器 启停=PATCH enabled / 删除=DELETE
|
||||
(仍被节点引用等守卫由服务端拒绝);客户 启用=status active /
|
||||
停用=status suspended(与行内开关的 manual 口径一致)/ 删除=
|
||||
DELETE;入站 启用=deploy / 停用=remove / 删除=DELETE。
|
||||
选择状态口径:按页独立保存,重渲染与 45 秒自动刷新后按集合恢复
|
||||
勾选;翻页/筛选不清空(全选只作用于当前渲染的行);批量执行后
|
||||
清空本页集合。 */
|
||||
const BULK_DEFS = {
|
||||
servers: {
|
||||
get: (id) => serverById(id),
|
||||
label: (item) => item.name,
|
||||
isOn: (item) => item.enabled !== false,
|
||||
enable: (item) => api(`/api/servers/${item.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: true }) }),
|
||||
disable: (item) => api(`/api/servers/${item.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: false }) }),
|
||||
remove: (item) => api(`/api/servers/${item.id}`, { method: 'DELETE' }),
|
||||
deleteConfirm: (n) => `删除选中的 ${n} 台设备?将删除控制台登记并吊销其 Agent 密钥,此操作不可恢复。`,
|
||||
page: 'servers'
|
||||
},
|
||||
customers: {
|
||||
get: (id) => customerById(id),
|
||||
label: (item) => item.name,
|
||||
isOn: (item) => !(item.status === 'suspended' && item.suspendReason === 'manual'),
|
||||
enable: (item) => api(`/api/customers/${item.id}`, { method: 'PATCH', body: JSON.stringify({ status: 'active' }) }),
|
||||
disable: (item) => api(`/api/customers/${item.id}`, { method: 'PATCH', body: JSON.stringify({ status: 'suspended' }) }),
|
||||
remove: (item) => api(`/api/customers/${item.id}`, { method: 'DELETE' }),
|
||||
deleteConfirm: (n) => `删除选中的 ${n} 位客户及订阅地址?此操作不可恢复(仍有节点分配的客户会被逐项拒绝)。`,
|
||||
page: 'customers'
|
||||
},
|
||||
nodes: {
|
||||
get: (id) => nodeById(id),
|
||||
label: (item) => item.name,
|
||||
isOn: (item) => !['draft', 'removing'].includes(item.status),
|
||||
busy: (item) => ['deploying', 'removing'].includes(item.status),
|
||||
enable: (item) => api(`/api/nodes/${item.id}/deploy`, { method: 'POST', body: '{}' }),
|
||||
disable: (item) => api(`/api/nodes/${item.id}/remove`, { method: 'POST', body: '{}' }),
|
||||
remove: (item) => api(`/api/nodes/${item.id}`, { method: 'DELETE' }),
|
||||
deleteConfirm: (n) => `删除选中的 ${n} 个入站?会移除全部分配并从设备清理资源,此操作不可恢复。`,
|
||||
page: 'chains'
|
||||
}
|
||||
};
|
||||
|
||||
function bulkBarHTML(key) {
|
||||
const n = state.selection[key].size;
|
||||
if (!n) return '';
|
||||
return `<div class="bulk-bar"><span class="bulk-count">已选 <b>${n}</b> 项</span>
|
||||
<button class="btn small" data-action="bulk-enable" data-bulk-page="${key}">启用</button>
|
||||
<button class="btn small" data-action="bulk-disable" data-bulk-page="${key}">停用</button>
|
||||
<button class="btn small danger" data-action="bulk-delete" data-bulk-page="${key}">删除</button>
|
||||
<button class="btn small" data-action="bulk-clear" data-bulk-page="${key}">取消选择</button></div>`;
|
||||
}
|
||||
|
||||
async function runBulk(key, op) {
|
||||
if (state.bulkRunning) return;
|
||||
const def = BULK_DEFS[key]; if (!def) return;
|
||||
const ids = [...state.selection[key]];
|
||||
if (!ids.length) return;
|
||||
if (op === 'delete' && !await confirmModal(def.deleteConfirm(ids.length), '删除')) return;
|
||||
state.bulkRunning = true;
|
||||
const done = []; const failed = []; const skipped = [];
|
||||
try {
|
||||
for (const id of ids) {
|
||||
const item = def.get(id);
|
||||
if (!item) { skipped.push('已不存在'); continue; }
|
||||
const name = def.label(item) || id;
|
||||
if (op !== 'delete') {
|
||||
if (def.busy && def.busy(item)) { skipped.push(`${name}:正忙`); continue; }
|
||||
if ((op === 'enable') === def.isOn(item)) { skipped.push(`${name}:已是${op === 'enable' ? '启用' : '停用'}状态`); continue; }
|
||||
}
|
||||
try { await def[op === 'delete' ? 'remove' : op](item); done.push(name); }
|
||||
catch (error) { failed.push(`${name}:${error.message}`); }
|
||||
}
|
||||
} finally { state.bulkRunning = false; }
|
||||
state.selection[key].clear();
|
||||
const verb = op === 'delete' ? '删除' : op === 'enable' ? '启用' : '停用';
|
||||
const brief = (list) => list.slice(0, 3).join(';') + (list.length > 3 ? `;等 ${list.length} 项` : '');
|
||||
let msg = `批量${verb}:成功 ${done.length} 项`;
|
||||
if (skipped.length) msg += `,跳过 ${skipped.length} 项(${brief(skipped)})`;
|
||||
if (failed.length) msg += `,失败 ${failed.length} 项(${brief(failed)})`;
|
||||
toast(msg, failed.length > 0);
|
||||
await load(def.page);
|
||||
}
|
||||
|
||||
/* render 后同步表头全选框的三态(全选/半选/未选)。 */
|
||||
function syncCheckAll() {
|
||||
const all = $('[data-check-all]');
|
||||
if (!all) return;
|
||||
const boxes = $$('[data-row-check]');
|
||||
if (!boxes.length) { all.checked = false; all.indeterminate = false; return; }
|
||||
const n = boxes.filter((box) => box.checked).length;
|
||||
all.checked = n === boxes.length;
|
||||
all.indeterminate = n > 0 && n < boxes.length;
|
||||
}
|
||||
|
||||
function renderServers() {
|
||||
const q = state.search.toLowerCase();
|
||||
const onlineCount = state.servers.filter((s) => s.status === 'online').length;
|
||||
@@ -424,7 +576,6 @@ function renderServers() {
|
||||
<div class="stat"><div class="lab">设备总数</div><div class="val"><svg class="ic-dim" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>${state.servers.length}</div></div>
|
||||
<div class="stat"><div class="lab">在线</div><div class="val"><svg class="ic-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8.5 12.5l2.5 2.5 4.5-5.5"/></svg>${onlineCount}</div></div>
|
||||
<div class="stat"><div class="lab">离线</div><div class="val"><svg class="ic-red" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M9 9l6 6M15 9l-6 6"/></svg>${state.servers.length - onlineCount}</div></div>
|
||||
<div class="stat"><div class="lab">平均延迟</div><div class="val"><svg class="ic-bolt" viewBox="0 0 24 24" fill="currentColor"><path d="M13 2L4.5 13.5H11L9.5 22 19 10h-6.5L13 2z"/></svg>—</div></div>
|
||||
</section>`;
|
||||
const filtered = state.servers.filter((item) => [item.name, item.region, item.publicAddress, item.publicAddressV6, ...(item.labels || [])].join(' ').toLowerCase().includes(q));
|
||||
const { pageItems } = pageSlice(filtered, 'servers');
|
||||
@@ -437,8 +588,8 @@ function renderServers() {
|
||||
const addr = state.addrVisible ? esc(server.publicAddress) : esc(maskAddr(server.publicAddress));
|
||||
const row = `<tr>
|
||||
<td><button class="exp" type="button" data-action="expand-server" data-id="${esc(server.id)}">${expanded ? '−' : '+'}</button></td>
|
||||
<td><input type="checkbox" data-row-check></td>
|
||||
<td><div class="op-cell"><button class="icon-btn" title="编辑" data-action="edit-server" data-id="${esc(server.id)}">${ICONS.edit}</button><button class="icon-btn" title="复制注册命令" data-action="enroll-server" data-id="${esc(server.id)}">${ICONS.copy}</button><button class="icon-btn del" title="删除" data-action="delete-server" data-id="${esc(server.id)}">${ICONS.trash}</button></div></td>
|
||||
<td><input type="checkbox" data-row-check="servers" data-id="${esc(server.id)}"${state.selection.servers.has(server.id) ? ' checked' : ''}></td>
|
||||
<td><div class="op-cell"><button class="icon-btn" data-tip="编辑" data-action="edit-server" data-id="${esc(server.id)}">${ICONS.edit}</button><button class="icon-btn" data-tip="复制注册命令" data-action="enroll-server" data-id="${esc(server.id)}">${ICONS.copy}</button><button class="icon-btn del" data-tip="删除" data-action="delete-server" data-id="${esc(server.id)}">${ICONS.trash}</button></div></td>
|
||||
<td><label class="tgl"><input type="checkbox" data-toggle-server data-id="${esc(server.id)}" ${server.enabled !== false ? 'checked' : ''}><span class="tr"></span></label></td>
|
||||
<td><span class="dev-name">${esc(server.name)}</span></td>
|
||||
<td><span class="addr">${addr}</span></td>
|
||||
@@ -446,20 +597,19 @@ function renderServers() {
|
||||
<td><span class="num">${cpuPct == null ? '—' : `${cpuPct.toFixed(1)}%`}</span></td>
|
||||
<td><span class="num">${memPct == null ? '—' : `${memPct.toFixed(1)}%`}</span></td>
|
||||
<td><span class="ver-cell"><span class="ver">${esc(server.agentVersion || '—')}</span>${server.agentVersion && versionLessThan(server.agentVersion, state.version) ? '<span class="badge orange">有可用更新</span>' : ''}</span></td>
|
||||
<td><span class="ver">—</span></td>
|
||||
<td><span class="ver">${esc(server.engine && server.engine.singBoxVersion ? server.engine.singBoxVersion : '—')}</span></td>
|
||||
<td><span class="num">${system.uptimeSeconds ? fmtUptime(system.uptimeSeconds) : '—'}</span></td>
|
||||
<td><span class="clients-cell">${ICONS.users}${clients.total}<span class="on-n">${clients.online}</span></span></td>
|
||||
<td><span class="num">—</span></td>
|
||||
<td><span class="hb">${server.lastSeenAt ? relTime(server.lastSeenAt) : '等待注册'}</span></td></tr>`;
|
||||
return expanded ? row + serverDetailRow(server) : row;
|
||||
}).join('');
|
||||
return `${stats}
|
||||
<div class="page-tools"><button class="btn primary" data-action="add-server">+ 添加设备</button><input class="search" data-search placeholder="搜索设备名、地址" value="${esc(state.search)}"></div>
|
||||
<section class="panel"><div class="table-wrap"><table class="w-servers"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>操作</th><th>已启用</th><th>名称</th><th>地址<button class="icon-btn eye-btn" type="button" data-action="toggle-addr" title="显示 / 隐藏地址">${state.addrVisible ? ICONS.eyeOff : ICONS.eye}</button></th><th>状态</th><th>CPU</th><th>内存</th><th>Agent 版本</th><th>核心版本</th><th>运行时长</th><th>客户端</th><th>延迟</th><th>上次心跳</th></tr></thead>
|
||||
<tbody>${rows || emptyRow(15, '没有匹配设备')}</tbody></table></div>${pagerHTML('servers', filtered.length)}</section>`;
|
||||
${bulkBarHTML('servers')}<section class="panel"><div class="table-wrap"><table class="w-servers"><thead><tr><th></th><th><input type="checkbox" data-check-all="servers"></th><th>操作</th><th>已启用</th><th>名称</th><th>地址<button class="icon-btn eye-btn" type="button" data-action="toggle-addr" data-tip="显示 / 隐藏地址">${state.addrVisible ? ICONS.eyeOff : ICONS.eye}</button></th><th>状态</th><th>CPU</th><th>内存</th><th>Agent 版本</th><th>核心版本</th><th>运行时长</th><th>客户端</th><th>上次心跳</th></tr></thead>
|
||||
<tbody>${rows || emptyRow(14, '没有匹配设备')}</tbody></table></div>${pagerHTML('servers', filtered.length)}</section>`;
|
||||
}
|
||||
|
||||
/* ---------- 客户与订阅 ---------- */
|
||||
/* ---------- 用户订阅 ---------- */
|
||||
function protocolFamily(protocol) {
|
||||
const id = String(protocol || '');
|
||||
if (id.startsWith('vless')) return { cls: '', label: 'VLESS' };
|
||||
@@ -513,7 +663,7 @@ function renderCustomers() {
|
||||
const speedTotal = (item.speedUpBps || 0) + (item.speedDownBps || 0);
|
||||
const manualOff = item.status === 'suspended' && item.suspendReason === 'manual';
|
||||
return `<tr>
|
||||
<td><div class="op-cell"><input type="checkbox" data-row-check><button class="icon-btn" title="订阅二维码" data-action="customer-subscription" data-id="${esc(item.id)}">${ICONS.qr}</button><button class="icon-btn" title="详情" data-action="customer-access" data-id="${esc(item.id)}">${ICONS.info}</button><button class="icon-btn" title="重置流量" data-action="reset-usage" data-id="${esc(item.id)}">${ICONS.reset}</button><button class="icon-btn" title="编辑" data-action="edit-customer" data-id="${esc(item.id)}">${ICONS.edit}</button><button class="icon-btn del" title="删除" data-action="delete-customer" data-id="${esc(item.id)}">${ICONS.trash}</button></div></td>
|
||||
<td><div class="op-cell"><input type="checkbox" data-row-check="customers" data-id="${esc(item.id)}"${state.selection.customers.has(item.id) ? ' checked' : ''}><button class="icon-btn" data-tip="二维码" data-action="customer-subscription" data-id="${esc(item.id)}">${ICONS.qr}</button><button class="icon-btn" data-tip="详情" data-action="customer-access" data-id="${esc(item.id)}">${ICONS.info}</button><button class="icon-btn" data-tip="重置流量" data-action="reset-usage" data-id="${esc(item.id)}">${ICONS.reset}</button><button class="icon-btn" data-tip="编辑" data-action="edit-customer" data-id="${esc(item.id)}">${ICONS.edit}</button><button class="icon-btn del" data-tip="删除" data-action="delete-customer" data-id="${esc(item.id)}">${ICONS.trash}</button></div></td>
|
||||
<td><label class="tgl"><input type="checkbox" data-toggle-customer data-id="${esc(item.id)}" ${manualOff ? '' : 'checked'}><span class="tr"></span></label></td>
|
||||
<td><span class="pres ${item.online ? 'on' : 'off'}"><span class="dot ${item.online ? 'on' : 'off'}"></span>${item.online ? '在线' : '离线'}</span>${item.status === 'suspended' ? `<span class="paused-note">已暂停 · ${esc(suspendReasonText[item.suspendReason] || '待恢复')}</span>` : ''}</td>
|
||||
<td><span class="cust-name">${esc(item.name)}</span>${item.notes ? `<span class="cust-note">${esc(item.notes)}</span>` : '<span class="cust-note"></span>'}</td>
|
||||
@@ -523,12 +673,22 @@ function renderCustomers() {
|
||||
<td>${remainingPill(item)}</td>
|
||||
<td>${durationPill(item)}</td></tr>`;
|
||||
}).join('');
|
||||
return `<div class="page-tools"><button class="btn primary" data-action="add-customer">+ 添加客户</button>
|
||||
const sum = state.customerSummary;
|
||||
const statCell = (lab, cls, svg, val) => `<div class="stat"><div class="lab">${lab}</div><div class="val"><svg class="${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${svg}</svg>${val}</div></div>`;
|
||||
const custStats = sum ? `<section class="stats six">
|
||||
${statCell('客户端', 'ic-dim', '<circle cx="9" cy="8" r="3.5"/><path d="M3.5 19c.6-3 2.8-4.5 5.5-4.5s4.9 1.5 5.5 4.5"/><path d="M16 5.5a3.5 3.5 0 010 6.5M17.5 14.7c1.7.6 2.7 1.9 3 4.3"/>', sum.total)}
|
||||
${statCell('在线', 'ic-green', '<path d="M3 12h4l2.5-6 4 12L16 12h5"/>', sum.online)}
|
||||
${statCell('耗尽', 'ic-red', '<rect x="3" y="8" width="15" height="8" rx="2"/><path d="M21 11v2"/><path d="M6.5 11v2"/>', sum.depleted)}
|
||||
${statCell('即将耗尽', 'ic-bolt', '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', sum.depletingSoon)}
|
||||
${statCell('关闭', 'ic-red', '<path d="M12 3v8"/><path d="M6.3 6.5a8 8 0 1 0 11.4 0"/>', sum.disabled)}
|
||||
${statCell('启用', 'ic-green', '<circle cx="12" cy="12" r="9"/><path d="M8.5 12.5l2.5 2.5 4.5-5.5"/>', sum.enabled)}
|
||||
</section>` : '';
|
||||
return `${custStats}<div class="page-tools"><button class="btn primary" data-action="add-customer">+ 添加客户</button>
|
||||
<div class="toolbar"><input class="search" data-search placeholder="搜索客户、备注、分组或标签" value="${esc(state.search)}">
|
||||
<select class="search" data-group-filter><option value="">全部分组</option>${groups.map((g) => `<option value="${esc(g)}"${selected(g, state.groupFilter)}>${esc(g)}</option>`).join('')}</select>
|
||||
${filterNode ? `<span class="filter-chip">节点:${esc(filterNode.name)}<button type="button" data-action="clear-node-filter" title="清除">×</button></span>` : ''}
|
||||
${filterNode ? `<span class="filter-chip">节点:${esc(filterNode.name)}<button type="button" data-action="clear-node-filter" data-tip="清除">×</button></span>` : ''}
|
||||
<span class="badge blue">${filtered.length} 位客户</span></div></div>
|
||||
<section class="panel"><div class="table-wrap"><table class="w-customers"><thead><tr><th>操作</th><th>已启用</th><th>在线</th><th>客户端</th><th>关联入站</th><th>流量<span class="sarr">⇅</span></th><th class="ctr">速度</th><th>剩余<span class="sarr">⇅</span></th><th>时长<span class="sarr">⇅</span></th></tr></thead>
|
||||
${bulkBarHTML('customers')}<section class="panel"><div class="table-wrap"><table class="w-customers"><thead><tr><th><input type="checkbox" data-check-all="customers"> 操作</th><th>已启用</th><th>在线</th><th>客户端</th><th>关联入站</th><th>流量<span class="sarr">⇅</span></th><th class="ctr">速度</th><th>剩余<span class="sarr">⇅</span></th><th>时长<span class="sarr">⇅</span></th></tr></thead>
|
||||
<tbody>${rows || emptyRow(9, '还没有客户')}</tbody></table></div>${pagerHTML('customers', filtered.length)}</section>`;
|
||||
}
|
||||
|
||||
@@ -589,7 +749,7 @@ function refreshPicker() {
|
||||
|
||||
function credRowHTML(label, value) {
|
||||
return `<div class="cred-row"><span class="k">${esc(label)}</span><span class="v">${esc(value || '—')}</span>
|
||||
${value ? `<button type="button" class="icon-btn" title="复制" data-action="copy-text" data-value="${esc(value)}">${ICONS.copy}</button>` : ''}</div>`;
|
||||
${value ? `<button type="button" class="icon-btn" data-tip="复制" data-action="copy-text" data-value="${esc(value)}">${ICONS.copy}</button>` : ''}</div>`;
|
||||
}
|
||||
function renderCredentialsPane(customer) {
|
||||
const pane = $('#tab-credentials');
|
||||
@@ -597,19 +757,21 @@ function renderCredentialsPane(customer) {
|
||||
if (!customer) { pane.innerHTML = '<div class="empty"><b>创建客户后生成凭据</b></div>'; return; }
|
||||
const data = customerFormState.credentials;
|
||||
if (!data) { pane.innerHTML = '<div class="empty"><b>加载中…</b></div>'; return; }
|
||||
const groups = data.items.map((entry) => `<div class="cred-group">
|
||||
<div class="g-head">${esc(entry.nodeName)}<span class="badge ${entry.status === 'active' ? 'green' : 'gray'}">${esc(statusText[entry.status] || entry.status)}</span><span class="mut" style="font-weight:400;font-size:12px">${esc(entry.protocol)}</span>
|
||||
<button type="button" class="icon-btn" style="margin-left:auto" title="重新生成凭据" data-action="regen-credential" data-ncid="${esc(entry.nodeClientId)}" data-node="${esc(entry.nodeName)}">${ICONS.reset}</button></div>
|
||||
${credRowHTML('UUID', entry.clientId)}
|
||||
${credRowHTML('密码', entry.password)}
|
||||
${entry.shortId ? credRowHTML('Reality Short ID', entry.shortId) : ''}
|
||||
${entry.username ? credRowHTML('用户名', entry.username) : ''}
|
||||
</div>`).join('');
|
||||
const creds = data.credentials || {};
|
||||
// Round-3 item 15: the per-binding list (node name / status / protocol
|
||||
// / Reality public key) is gone — the tab shows only the subscription
|
||||
// token and the one shared credential set. Reality keys live on the
|
||||
// node edit form now (item 17); the socks username lives on the node
|
||||
// (item 21). Backend payload unchanged.
|
||||
pane.innerHTML = `<div class="cred-group"><div class="g-head">订阅</div>
|
||||
<div class="cred-row"><span class="k">订阅令牌</span><span class="v">${esc(data.subscriptionToken || '—')}</span>
|
||||
<button type="button" class="icon-btn" title="复制" data-action="copy-text" data-value="${esc(data.subscriptionToken || '')}">${ICONS.copy}</button>
|
||||
<button type="button" class="icon-btn" title="重置订阅令牌" data-action="rotate-subscription" data-id="${esc(customer.id)}">${ICONS.reset}</button></div></div>
|
||||
${groups || '<div class="empty"><b>尚未关联节点</b></div>'}`;
|
||||
<button type="button" class="icon-btn" data-tip="复制" data-action="copy-text" data-value="${esc(data.subscriptionToken || '')}">${ICONS.copy}</button>
|
||||
<button type="button" class="icon-btn" data-tip="重置订阅令牌" data-action="rotate-subscription" data-id="${esc(customer.id)}">${ICONS.reset}</button></div></div>
|
||||
<div class="cred-group"><div class="g-head">凭据(全部节点共用)<button type="button" class="icon-btn" style="margin-left:auto" data-tip="重新生成凭据" data-action="regen-credentials" data-id="${esc(customer.id)}">${ICONS.reset}</button></div>
|
||||
${credRowHTML('UUID', creds.clientId)}
|
||||
${credRowHTML('密码', creds.password)}
|
||||
${credRowHTML('Reality Short ID', creds.shortId)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function linkStatusLine(customer, source) {
|
||||
@@ -628,12 +790,12 @@ function renderLinksPane(customer) {
|
||||
<div class="row"><input type="text" data-link-uri placeholder="节点链接(vless:// / ss:// / hysteria2:// / anytls:// / socks5://)" value="${esc(link.uri || '')}">
|
||||
<input type="text" class="remark" data-link-remark placeholder="备注" value="${esc(link.remark || '')}">
|
||||
<label class="tgl"><input type="checkbox" data-link-enabled ${link.enabled !== false ? 'checked' : ''}><span class="tr"></span></label>
|
||||
<button type="button" class="icon-btn del" title="删除" data-action="remove-manual-link" data-index="${index}">${ICONS.trash}</button></div></div>`).join('');
|
||||
<button type="button" class="icon-btn del" data-tip="删除" data-action="remove-manual-link" data-index="${index}">${ICONS.trash}</button></div></div>`).join('');
|
||||
const sources = customerFormState.externalSources.map((source, index) => `<div class="link-item" data-source-item="${index}">
|
||||
<div class="row"><input type="text" data-source-url placeholder="订阅地址(https://…)" value="${esc(source.url || '')}">
|
||||
<input type="text" class="remark" data-source-remark placeholder="备注" value="${esc(source.remark || '')}">
|
||||
<label class="tgl"><input type="checkbox" data-source-enabled ${source.enabled !== false ? 'checked' : ''}><span class="tr"></span></label>
|
||||
<button type="button" class="icon-btn del" title="删除" data-action="remove-ext-source" data-index="${index}">${ICONS.trash}</button></div>
|
||||
<button type="button" class="icon-btn del" data-tip="删除" data-action="remove-ext-source" data-index="${index}">${ICONS.trash}</button></div>
|
||||
<div class="row"><input type="text" data-source-ua placeholder="User-Agent(可选)" value="${esc(source.userAgent || '')}">
|
||||
<span class="status-line" data-source-status="${esc(source.id || '')}">${linkStatusLine(customer, source)}</span>
|
||||
${source.id ? `<button type="button" class="btn small" data-action="refresh-ext-source" data-sid="${esc(source.id)}">立即刷新</button>` : ''}</div></div>`).join('');
|
||||
@@ -799,7 +961,7 @@ function uninstallModal() {
|
||||
<div class="form-actions"><button type="button" class="btn" data-close>关闭</button></div></div>`);
|
||||
}
|
||||
|
||||
/* ---------- 转发与节点 ---------- */
|
||||
/* ---------- 入站&出站 ---------- */
|
||||
function protoBadges(node) {
|
||||
const fam = (id) => {
|
||||
const v = String(id || '');
|
||||
@@ -837,10 +999,10 @@ function renderNodes() {
|
||||
const enabled = !['draft', 'removing'].includes(node.status);
|
||||
const busy = ['deploying', 'removing'].includes(node.status);
|
||||
return `<tr data-node-row="${esc(node.id)}">
|
||||
<td><span class="grip" title="拖动排序" data-grip>${ICONS.grip}</span></td>
|
||||
<td><input type="checkbox" data-row-check></td>
|
||||
<td><span class="grip" data-tip="拖动排序" data-grip>${ICONS.grip}</span></td>
|
||||
<td><input type="checkbox" data-row-check="nodes" data-id="${esc(node.id)}"${state.selection.nodes.has(node.id) ? ' checked' : ''}></td>
|
||||
<td class="num">${(node.sortIndex != null ? node.sortIndex : index) + 1}</td>
|
||||
<td class="menu-td"><button class="icon-btn" title="编辑" data-action="edit-node" data-id="${esc(node.id)}">${ICONS.edit}</button><button class="icon-btn" title="更多" data-action="node-menu" data-id="${esc(node.id)}">${ICONS.dots}</button></td>
|
||||
<td class="menu-td"><button class="icon-btn" data-tip="编辑" data-action="edit-node" data-id="${esc(node.id)}">${ICONS.edit}</button><button class="icon-btn" data-tip="更多" data-action="node-menu" data-id="${esc(node.id)}">${ICONS.dots}</button></td>
|
||||
<td><label class="tgl"><input type="checkbox" data-toggle-node data-id="${esc(node.id)}" ${enabled ? 'checked' : ''} ${busy ? 'disabled' : ''}><span class="tr"></span></label></td>
|
||||
<td><span class="node-name">${esc(node.name)}</span>${node.error ? `<div class="paused-note" title="${esc(node.error)}">${esc(String(node.error).slice(0, 40))}</div>` : ''}</td>
|
||||
<td><span class="srv-chip ${chipCls}">${esc(server ? server.name : '已删除')}</span></td>
|
||||
@@ -851,10 +1013,10 @@ function renderNodes() {
|
||||
<td>${speed > 0 ? `<span class="pill">↑ ${fmtSpeed(node.speedUpBps)} / ↓ ${fmtSpeed(node.speedDownBps)}</span>` : '<span class="pill mut">—</span>'}</td>
|
||||
<td><span class="pill">∞</span></td></tr>`;
|
||||
}).join('');
|
||||
return `<div class="page-tools"><button class="btn primary" data-action="add-node">+ 新建节点</button>
|
||||
return `<div class="page-tools"><button class="btn primary" data-action="add-node">+ 添加入站</button>
|
||||
<div class="toolbar"><input class="search" data-search placeholder="搜索节点、服务器或协议" value="${esc(state.search)}">
|
||||
<span class="badge blue">${state.nodes.length} 个节点</span><span class="badge green">在线 ${active}</span>${degraded ? `<span class="badge orange">降级 ${degraded}</span>` : ''}</div></div>
|
||||
<section class="panel"><div class="table-wrap visible"><table class="w-nodes"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>ID<span class="sarr">⇅</span></th><th>菜单</th><th>启用</th><th>备注</th><th>节点</th><th>端口<span class="sarr">⇅</span></th><th>协议</th><th>客户端</th><th>流量<span class="sarr">⇅</span></th><th>速度</th><th>到期时间</th></tr></thead>
|
||||
<span class="badge blue">${state.nodes.length} 个入站</span><span class="badge green">在线 ${active}</span>${degraded ? `<span class="badge orange">降级 ${degraded}</span>` : ''}</div></div>
|
||||
${bulkBarHTML('nodes')}<section class="panel"><div class="table-wrap visible"><table class="w-nodes"><thead><tr><th></th><th><input type="checkbox" data-check-all="nodes"></th><th>ID<span class="sarr">⇅</span></th><th>菜单</th><th>启用</th><th>备注</th><th>节点</th><th>端口<span class="sarr">⇅</span></th><th>协议</th><th>客户端</th><th>流量<span class="sarr">⇅</span></th><th>速度</th><th>到期时间</th></tr></thead>
|
||||
<tbody>${rows || emptyRow(13, '还没有节点')}</tbody></table></div>${pagerHTML('nodes', filtered.length)}</section>`;
|
||||
}
|
||||
|
||||
@@ -864,21 +1026,39 @@ function protocolOptions(role, current) {
|
||||
function nodeForm(item = null) {
|
||||
const node = item || { topology: 'forward', protocol: 'vless-reality-vision', exitProtocol: 'shadowsocks-2022-aes128', portMode: 'random', exitPortMode: 'random', networkMode: 'ipv4', realityServerName: 'www.tesla.com', realityDestPort: 443 };
|
||||
const preset = state.realityPresets.find((entry) => entry.serverName === node.realityServerName) || { id: 'custom' };
|
||||
openModal(item ? '编辑节点' : '新建节点', `<form id="node-form" class="form-grid"><input type="hidden" name="resourceId" value="${esc(item ? item.id : '')}">
|
||||
openModal(item ? '编辑节点' : '新建节点', `<form id="node-form" class="form-grid" data-exit-mode="${node.exitMode === 'manual' ? 'manual' : 'managed'}"><input type="hidden" name="resourceId" value="${esc(item ? item.id : '')}">
|
||||
<label class="wide">节点名称<input name="name" value="${esc(node.name || '')}" placeholder="新加坡入口 01" required></label>
|
||||
<label>服务器<select name="serverId" required>${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id, node.serverId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
|
||||
<label>网络栈<select name="networkMode"><option value="ipv4"${selected('ipv4', node.networkMode || 'ipv4')}>IPv4</option><option value="ipv6"${selected('ipv6', node.networkMode)}>IPv6</option><option value="dual"${selected('dual', node.networkMode)}>双栈监听</option></select></label>
|
||||
<label>入口协议<select name="protocol" data-node-sync>${protocolOptions('relay-ingress', node.protocol)}</select></label>
|
||||
<label>入口端口<div class="inline-fields"><select name="portMode" data-node-sync><option value="random"${selected('random', node.portMode)}>范围内随机</option><option value="fixed"${selected('fixed', node.portMode)}>固定端口</option></select><input name="port" type="number" value="${esc(node.port || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
|
||||
<label data-reality-only>Reality 目标预设<select name="realityPreset" data-reality-preset>${state.realityPresets.map((entry) => `<option value="${esc(entry.id)}" data-sni="${esc(entry.serverName)}" data-port="${entry.destPort}"${selected(entry.id, preset.id)}>${esc(entry.label)} · ${esc(entry.serverName)}</option>`).join('')}<option value="custom"${selected('custom', preset.id)}>自定义</option></select></label>
|
||||
<label data-reality-only>Reality 目标预设<div class="inline-fields"><select name="realityPreset" data-reality-preset>${state.realityPresets.map((entry) => `<option value="${esc(entry.id)}" data-sni="${esc(entry.serverName)}" data-port="${entry.destPort}"${selected(entry.id, preset.id)}>${esc(entry.label)} · ${esc(entry.serverName)}</option>`).join('')}<option value="custom"${selected('custom', preset.id)}>自定义</option></select><button type="button" class="btn" data-action="scan-reality">查找目标</button></div></label>
|
||||
<label data-reality-only>Reality SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(node.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(node.realityDestPort || 443)}" min="1" max="65535"></div></label>
|
||||
${item ? '<div class="wide cred-group" data-reality-only id="reality-keys-box"><div class="g-head">服务器 Reality 密钥对(主控托管)</div><div class="empty"><b>加载中…</b></div></div>' : ''}
|
||||
<label data-socks-only>SOCKS5 固定用户名<div class="inline-fields"><input name="socksUser" value="${esc(node.socksUser || '')}" placeholder="留空自动生成"><button type="button" class="icon-btn" data-tip="复制" data-action="copy-field" data-field="socksUser">${ICONS.copy}</button></div></label>
|
||||
<label data-socks-only>SOCKS5 固定密码<div class="inline-fields"><input name="socksPassword" value="${esc(node.socksPassword || '')}" placeholder="留空自动生成"><button type="button" class="icon-btn" data-tip="复制" data-action="copy-field" data-field="socksPassword">${ICONS.copy}</button></div></label>
|
||||
<label>拓扑<select name="topology" data-node-sync><option value="forward"${selected('forward', node.topology || 'forward')}>转发(入口 → 出口)</option><option value="direct"${selected('direct', node.topology)}>单机直连(无需出口)</option></select></label>
|
||||
<div class="section-title" data-forward-only>出口与转发传输</div>
|
||||
<label data-forward-only>出口 / 落地设备<select name="exitServerId">${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id, node.exitServerId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
|
||||
<div class="wide" data-forward-only><div class="segctl" id="exit-mode-seg"><button type="button" data-action="exit-mode" data-mode="managed">受管设备</button><button type="button" data-action="exit-mode" data-mode="manual">手动落地端点</button></div></div>
|
||||
<label class="wide" data-forward-only data-manual-only>从链接导入<input data-exit-link placeholder="粘贴 vless:// / ss:// / socks5:// 链接,自动填入下方字段"></label>
|
||||
<label data-forward-only data-managed-only>出口 / 落地设备<select name="exitServerId">${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id, node.exitServerId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
|
||||
<label data-forward-only data-manual-only>落地地址<input name="exitAddress" value="${esc(node.exitAddress || '')}" placeholder="IP 或域名"></label>
|
||||
<label data-forward-only>入口到出口协议<select name="exitProtocol" data-node-sync>${protocolOptions('exit-transport', node.exitProtocol)}</select></label>
|
||||
<label data-forward-only>出口端口<div class="inline-fields"><select name="exitPortMode" data-node-sync><option value="random"${selected('random', node.exitPortMode)}>范围内随机</option><option value="fixed"${selected('fixed', node.exitPortMode)}>固定端口</option></select><input name="exitPort" type="number" value="${esc(node.exitPort || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
|
||||
<label data-forward-only data-manual-only data-exit-cred="vless">VLESS 用户 ID<input name="exitClientId" placeholder="${item ? '留空保持不变' : '链接中的 UUID'}"></label>
|
||||
<label data-forward-only data-manual-only data-exit-cred="socksuser">SOCKS5 用户名<input name="exitSocksUser" placeholder="${item ? '留空保持不变' : ''}"></label>
|
||||
<label data-forward-only data-manual-only data-exit-cred="password">落地密码<input name="exitPassword" placeholder="${item ? '留空保持不变' : ''}"></label>
|
||||
<div class="form-actions wide"><button type="button" class="btn" data-close>取消</button><button class="btn primary" type="submit">${item ? '保存修改' : '创建节点'}</button></div></form>`, true);
|
||||
syncNodeForm();
|
||||
// Round-3 item 17: show the server's panel-hosted Reality keypair on
|
||||
// existing Reality nodes (display only — no rotation action here).
|
||||
if (item && ['vless-reality-vision', 'vless-reality'].includes(node.protocol)) {
|
||||
api(`/api/nodes/${item.id}/reality-keys`).then((data) => {
|
||||
const box = $('#reality-keys-box'); if (!box) return;
|
||||
box.innerHTML = `<div class="g-head">服务器 Reality 密钥对(主控托管,同机 Reality 节点共用)</div>
|
||||
${data.publicKey ? credRowHTML('公钥', data.publicKey) + credRowHTML('私钥', data.privateKey) : '<div class="empty"><b>尚未生成(首次部署时由主控生成)</b></div>'}`;
|
||||
}).catch(() => { const box = $('#reality-keys-box'); if (box) box.innerHTML = '<div class="empty"><b>密钥读取失败</b></div>'; });
|
||||
}
|
||||
}
|
||||
function syncNodeForm() {
|
||||
const form = $('#node-form'); if (!form) return;
|
||||
@@ -889,11 +1069,135 @@ function syncNodeForm() {
|
||||
const reality = ['vless-reality-vision', 'vless-reality'].includes(form.elements.protocol.value);
|
||||
$$('[data-reality-only]', form).forEach((element) => element.classList.toggle('hidden', !reality));
|
||||
form.elements.realityServerName.required = reality;
|
||||
const socks = form.elements.protocol.value === 'socks5-auth';
|
||||
$$('[data-socks-only]', form).forEach((element) => element.classList.toggle('hidden', !socks));
|
||||
for (const [mode, port] of [['portMode', 'port'], ['exitPortMode', 'exitPort']]) {
|
||||
const fixed = form.elements[mode].value === 'fixed' && (mode === 'portMode' || !direct);
|
||||
form.elements[port].disabled = !fixed;
|
||||
form.elements[port].required = fixed;
|
||||
}
|
||||
// Manual landing endpoint (round-2 item 8): swap the managed exit-server
|
||||
// picker for address + credential fields; the port is always explicit.
|
||||
const exitManual = !direct && (form.dataset.exitMode || 'managed') === 'manual';
|
||||
$$('[data-managed-only]', form).forEach((element) => element.classList.toggle('hidden', direct || exitManual));
|
||||
$$('[data-manual-only]', form).forEach((element) => element.classList.toggle('hidden', !exitManual));
|
||||
$$('#exit-mode-seg button', form).forEach((button) => button.classList.toggle('active', button.dataset.mode === (form.dataset.exitMode || 'managed')));
|
||||
form.elements.exitServerId.required = !direct && !exitManual;
|
||||
if (form.elements.exitAddress) form.elements.exitAddress.required = exitManual;
|
||||
const exitProtocol = form.elements.exitProtocol.value || '';
|
||||
const credFor = { vless: exitProtocol === 'vless-tcp', socksuser: exitProtocol === 'socks5-auth', password: exitProtocol === 'socks5-auth' || exitProtocol.startsWith('shadowsocks') };
|
||||
$$('[data-exit-cred]', form).forEach((element) => element.classList.toggle('hidden', !exitManual || !credFor[element.dataset.exitCred]));
|
||||
if (exitManual) {
|
||||
form.elements.exitPortMode.disabled = true;
|
||||
form.elements.exitPort.disabled = false;
|
||||
form.elements.exitPort.required = true;
|
||||
}
|
||||
}
|
||||
|
||||
const EXIT_LINK_METHODS = { '2022-blake3-aes-128-gcm': 'shadowsocks-2022-aes128', '2022-blake3-aes-256-gcm': 'shadowsocks-2022-aes256', 'aes-128-gcm': 'shadowsocks-aes128-gcm', 'aes-256-gcm': 'shadowsocks-aes256-gcm' };
|
||||
function parseExitLink(text) {
|
||||
const link = String(text || '').trim();
|
||||
if (!link) return null;
|
||||
try {
|
||||
if (link.startsWith('vless://')) {
|
||||
const url = new URL(link);
|
||||
if (!url.hostname || !url.port || !url.username) return null;
|
||||
return { protocol: 'vless-tcp', address: url.hostname, port: Number(url.port), clientId: decodeURIComponent(url.username) };
|
||||
}
|
||||
if (link.startsWith('ss://')) {
|
||||
const rest = link.slice(5).split('#')[0];
|
||||
const at = rest.lastIndexOf('@');
|
||||
if (at < 0) return null;
|
||||
let userinfo = rest.slice(0, at);
|
||||
const hostport = rest.slice(at + 1).split('?')[0].split('/')[0];
|
||||
if (!userinfo.includes(':')) {
|
||||
userinfo = atob(userinfo.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(userinfo.length / 4) * 4, '='));
|
||||
} else {
|
||||
userinfo = decodeURIComponent(userinfo);
|
||||
}
|
||||
const sep = userinfo.indexOf(':');
|
||||
if (sep < 0) return null;
|
||||
const protocol = EXIT_LINK_METHODS[userinfo.slice(0, sep)];
|
||||
if (!protocol) return null;
|
||||
const hostMatch = hostport.match(/^\[(.+)\]:(\d+)$/) || hostport.match(/^(.+):(\d+)$/);
|
||||
if (!hostMatch) return null;
|
||||
// The password is everything after "method:" — for SS2022 links that
|
||||
// is the combined serverKey:userKey string, which is exactly what the
|
||||
// entry outbound sends.
|
||||
return { protocol, address: hostMatch[1], port: Number(hostMatch[2]), password: userinfo.slice(sep + 1) };
|
||||
}
|
||||
if (link.startsWith('socks5://') || link.startsWith('socks://')) {
|
||||
const url = new URL(link.replace(/^socks:\/\//, 'socks5://'));
|
||||
if (!url.hostname || !url.port) return null;
|
||||
return { protocol: 'socks5-auth', address: url.hostname, port: Number(url.port), socksUser: decodeURIComponent(url.username), password: decodeURIComponent(url.password) };
|
||||
}
|
||||
} catch { return null; }
|
||||
return null;
|
||||
}
|
||||
function applyExitLink(input) {
|
||||
const form = $('#node-form'); if (!form) return;
|
||||
const parsed = parseExitLink(input.value);
|
||||
if (!parsed) { toast('无法识别该链接', true); return; }
|
||||
form.dataset.exitMode = 'manual';
|
||||
form.elements.exitAddress.value = parsed.address;
|
||||
form.elements.exitPort.value = parsed.port;
|
||||
form.elements.exitProtocol.value = parsed.protocol;
|
||||
if (parsed.clientId) form.elements.exitClientId.value = parsed.clientId;
|
||||
if (parsed.socksUser) form.elements.exitSocksUser.value = parsed.socksUser;
|
||||
if (parsed.password) form.elements.exitPassword.value = parsed.password;
|
||||
syncNodeForm();
|
||||
toast('已从链接填入落地端点');
|
||||
}
|
||||
|
||||
/* Reality 目标扫描器(第 16 条):叠加在节点表单之上的第二层弹窗,
|
||||
主控侧真实 TLS 握手探测;「使用」把目标回填到表单并关闭。 */
|
||||
function closeRealityScanner() { const overlay = $('#scan-overlay'); if (overlay) overlay.remove(); }
|
||||
function openRealityScanner() {
|
||||
closeRealityScanner();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'scan-overlay';
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.style.zIndex = '95';
|
||||
overlay.innerHTML = `<section class="modal panel scan-modal" role="dialog" aria-modal="true">
|
||||
<header class="modal-head"><h2>查找 Reality 目标</h2><button type="button" class="icon-btn" data-scan-close aria-label="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 6L6 18M6 6l12 12"/></svg></button></header>
|
||||
<div class="modal-body">
|
||||
<p class="links-note">从主控所在网络发起探测:可达性与延迟是主控视角,节点所在网络可能不同。</p>
|
||||
<div class="inline-fields"><input id="scan-input" placeholder="域名 / IP / CIDR,留空扫描常用候选池"><button type="button" class="btn primary" id="scan-run">扫描</button></div>
|
||||
<div id="scan-results" style="margin-top:12px"></div>
|
||||
</div></section>`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.addEventListener('click', async (event) => {
|
||||
if (event.target === overlay || event.target.closest('[data-scan-close]')) { closeRealityScanner(); return; }
|
||||
if (event.target.closest('#scan-run')) { await runRealityScan(); return; }
|
||||
const use = event.target.closest('[data-scan-use]');
|
||||
if (use) {
|
||||
const form = $('#node-form');
|
||||
if (form) {
|
||||
form.elements.realityServerName.value = use.dataset.host;
|
||||
form.elements.realityDestPort.value = use.dataset.port;
|
||||
if (form.elements.realityPreset) form.elements.realityPreset.value = 'custom';
|
||||
}
|
||||
closeRealityScanner();
|
||||
toast('已回填 Reality 目标');
|
||||
}
|
||||
});
|
||||
runRealityScan();
|
||||
}
|
||||
async function runRealityScan() {
|
||||
const box = $('#scan-results'); if (!box) return;
|
||||
box.innerHTML = '<div class="empty"><b>扫描中…</b></div>';
|
||||
try {
|
||||
const input = $('#scan-input') ? $('#scan-input').value.trim() : '';
|
||||
const data = await api('/api/reality/scan', { method: 'POST', body: JSON.stringify({ input }) });
|
||||
const rows = (data.results || []).map((r) => `<tr>
|
||||
<td class="scan-target" title="${esc(r.target)}">${esc(r.target)}${Number(r.port) !== 443 ? `:${esc(r.port)}` : ''}</td>
|
||||
<td>${r.ok ? '<span class="badge green">可用</span>' : '<span class="badge gray">不可用</span>'}</td>
|
||||
<td>${esc(r.tlsVersion || '—')}</td><td>${esc(r.alpn || '—')}</td>
|
||||
<td>${esc(r.keyExchange || '—')}</td><td class="scan-cert" title="${esc(r.certName || '')}">${esc(r.certName || '—')}</td>
|
||||
<td class="scan-reason">${r.ok ? `${esc(r.latencyMs)} ms` : esc(r.reason || '')}</td>
|
||||
<td>${r.ok ? `<button type="button" class="btn small" data-scan-use data-host="${esc(r.host)}" data-port="${esc(r.port)}">使用</button>` : ''}</td></tr>`).join('');
|
||||
box.innerHTML = `<div class="scan-wrap"><table class="scan-table"><thead><tr><th>目标</th><th>状态</th><th>TLS</th><th>ALPN</th><th>密钥交换</th><th>证书域名</th><th>延迟 / 原因</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="8">无结果</td></tr>'}</tbody></table></div>`;
|
||||
} catch (error) { box.innerHTML = `<div class="empty"><b>${esc(error.message)}</b></div>`; }
|
||||
}
|
||||
function nodeClientsModal(node) {
|
||||
const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id);
|
||||
@@ -917,8 +1221,8 @@ function nodeMenuHTML(node) {
|
||||
return items.join('');
|
||||
}
|
||||
|
||||
/* ---------- 安装与命令 ---------- */
|
||||
function renderCommands() {
|
||||
/* ---------- 设置(命令速查已合并至此页) ---------- */
|
||||
function commandsHTML() {
|
||||
const groups = [
|
||||
['面板机', [['更新控制面', 'ng update'], ['检查面板 HTTPS', 'ng cert'], ['备份面板', 'ng backup']]],
|
||||
['入口机 / 中转机', [['更新 Agent', 'ng-agent update'], ['申请或续签节点证书', 'ng-agent cert'], ['安装 sing-box 引擎', 'ng-agent engine install'], ['检查证书、引擎与双向计数', 'ng-agent doctor']]],
|
||||
@@ -927,7 +1231,6 @@ function renderCommands() {
|
||||
return `<div class="command-grid">${groups.map(([title, commands]) => `<section class="panel"><div class="panel-head"><h2>${esc(title)}</h2></div><div class="panel-body">${commands.map(([label, command]) => `<div class="command-item"><div><b>${esc(label)}</b><code>${esc(command)}</code></div><button type="button" class="btn small" data-action="copy-text" data-value="${esc(command)}">复制命令</button></div>`).join('')}</div></section>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
/* ---------- 设置与运维 ---------- */
|
||||
function renderOperations() {
|
||||
const rows = state.jobs.slice(0, 100).map((job) => `<tr><td><b>${esc(job.action === 'apply_resource' ? '应用资源' : '移除资源')}</b><div class="mut" style="font-size:12px">${esc(job.id)}</div></td><td>${esc((serverById(job.serverId) || {}).name || job.serverId)}</td><td>${esc(statusText[job.status] || job.status)}</td><td>${job.attempts}</td><td>${fmtDate(job.updatedAt)}</td><td><span class="mut">${esc(job.error || '')}</span></td></tr>`).join('');
|
||||
return `<section class="grid-3">
|
||||
@@ -946,12 +1249,14 @@ function renderOperations() {
|
||||
<div class="health-row"><div><b>Caddy HTTPS</b><span>证书自动申请与续签</span></div><span class="badge green">运行中</span></div>
|
||||
<div class="codebox" style="margin-top:10px">ng update\nng domain new.example.com\nng cert</div></div></article>
|
||||
</section>
|
||||
<section class="panel"><div class="panel-head"><h2>任务队列</h2></div><div class="table-wrap"><table><thead><tr><th>任务</th><th>设备</th><th>状态</th><th>尝试</th><th>更新时间</th><th>错误</th></tr></thead><tbody>${rows || emptyRow(6, '任务队列为空')}</tbody></table></div></section>`;
|
||||
${commandsHTML()}
|
||||
<section class="panel"><div class="panel-head"><h2>任务队列</h2></div><div class="table-wrap"><table class="w-jobs"><thead><tr><th>任务</th><th>设备</th><th>状态</th><th>尝试</th><th>更新时间</th><th>错误</th></tr></thead><tbody>${rows || emptyRow(6, '任务队列为空')}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, commands: renderCommands, operations: renderOperations };
|
||||
const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, operations: renderOperations };
|
||||
$('#content').innerHTML = views[state.page]();
|
||||
syncCheckAll();
|
||||
}
|
||||
|
||||
/* ---------- 表单提交 ---------- */
|
||||
@@ -995,9 +1300,17 @@ document.addEventListener('submit', async (event) => {
|
||||
closeModal(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers');
|
||||
} else if (form.id === 'node-form') {
|
||||
const resourceId = data.get('resourceId');
|
||||
const payload = { name: data.get('name'), serverId: data.get('serverId'), protocol: data.get('protocol'), portMode: data.get('portMode'), port: data.get('port') || null, networkMode: data.get('networkMode'), topology: data.get('topology'), exitServerId: data.get('exitServerId') || null, exitProtocol: data.get('exitProtocol') || null, exitPortMode: data.get('exitPortMode') || null, exitPort: data.get('exitPort') || null, realityServerName: data.get('realityServerName'), realityDestPort: Number(data.get('realityDestPort') || 443) };
|
||||
const payload = { name: data.get('name'), serverId: data.get('serverId'), protocol: data.get('protocol'), portMode: data.get('portMode'), port: data.get('port') || null, networkMode: data.get('networkMode'), topology: data.get('topology'), exitServerId: data.get('exitServerId') || null, exitProtocol: data.get('exitProtocol') || null, exitPortMode: data.get('exitPortMode') || null, exitPort: data.get('exitPort') || null, realityServerName: data.get('realityServerName'), realityDestPort: Number(data.get('realityDestPort') || 443), exitMode: form.dataset.exitMode || 'managed', exitAddress: data.get('exitAddress') || null, socksUser: data.get('socksUser') || null, socksPassword: data.get('socksPassword') || null };
|
||||
if (payload.topology !== 'direct' && payload.exitMode === 'manual') {
|
||||
const creds = {};
|
||||
if (data.get('exitClientId')) creds.clientId = data.get('exitClientId');
|
||||
if (data.get('exitPassword')) creds.password = data.get('exitPassword');
|
||||
if (data.get('exitSocksUser')) creds.socksUser = data.get('exitSocksUser');
|
||||
if (Object.keys(creds).length) payload.exitCredentials = creds;
|
||||
payload.exitPortMode = 'fixed';
|
||||
}
|
||||
await api(resourceId ? `/api/nodes/${resourceId}` : '/api/nodes', { method: resourceId ? 'PATCH' : 'POST', body: JSON.stringify(payload) });
|
||||
closeModal(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,请分配客户后部署'); await load('chains');
|
||||
closeModal(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,可直接部署,客户可随时增减绑定'); await load('chains');
|
||||
} else if (form.id === 'account-form') {
|
||||
if (data.get('newPassword') !== data.get('confirmPassword')) throw new Error('两次输入的新密码不一致');
|
||||
await api('/api/account', { method: 'PATCH', body: JSON.stringify({ username: data.get('username'), currentPassword: data.get('currentPassword'), newPassword: data.get('newPassword') }) });
|
||||
@@ -1056,6 +1369,8 @@ document.addEventListener('click', async (event) => {
|
||||
const action = button.dataset.action; const itemId = button.dataset.id;
|
||||
try {
|
||||
if (action === 'copy-text') { await copyText(button.dataset.value); toast('已复制到剪贴板'); }
|
||||
else if (action === 'scan-reality') openRealityScanner();
|
||||
else if (action === 'copy-field') { const input = button.form ? button.form.elements[button.dataset.field] : null; await copyText(input ? input.value : ''); toast('已复制到剪贴板'); }
|
||||
else if (action === 'add-server') serverForm();
|
||||
else if (action === 'edit-server') serverForm(serverById(itemId));
|
||||
else if (action === 'enroll-server') await enrollModal(itemId);
|
||||
@@ -1108,11 +1423,14 @@ document.addEventListener('click', async (event) => {
|
||||
api(`/api/customers/${itemId}/credentials`).then((data) => { customerFormState.credentials = data; renderCredentialsPane(result.customer); }).catch(() => {});
|
||||
} else if (modalOpen()) subscriptionModal(result.customer);
|
||||
}
|
||||
else if (action === 'regen-credential') {
|
||||
else if (action === 'exit-mode') {
|
||||
const form = $('#node-form');
|
||||
if (form) { form.dataset.exitMode = button.dataset.mode; syncNodeForm(); }
|
||||
}
|
||||
else if (action === 'regen-credentials') {
|
||||
const customerId = $('#customer-form').dataset.customerId;
|
||||
if (!await confirmModal(`重新生成「${button.dataset.node}」的凭据?该客户在此节点的现有连接将立即失效,需要重新导入订阅。`, '重新生成')) return;
|
||||
await api(`/api/customers/${customerId}/credentials/${button.dataset.ncid}/regenerate`, { method: 'POST', body: '{}' });
|
||||
const data = await api(`/api/customers/${customerId}/credentials`);
|
||||
if (!await confirmModal('重新生成该客户的凭据?全部节点同时更换,现有连接将立即失效,需要重新导入订阅。', '重新生成')) return;
|
||||
const data = await api(`/api/customers/${customerId}/credentials/regenerate`, { method: 'POST', body: '{}' });
|
||||
customerFormState.credentials = data;
|
||||
renderCredentialsPane(customerById(customerId));
|
||||
toast('凭据已重新生成');
|
||||
@@ -1165,6 +1483,10 @@ document.addEventListener('click', async (event) => {
|
||||
else if (action === 'repair-node') { hidePop(); if (!await confirmModal('重新下发该节点配置?', '修复')) return; await api(`/api/nodes/${itemId}/repair`, { method: 'POST', body: '{}' }); toast('修复任务已进入队列'); await load(); }
|
||||
else if (action === 'remove-node') { hidePop(); if (!await confirmModal('停用会从相关设备移除配置,节点保留为草稿,确认继续?', '停用')) return; await api(`/api/nodes/${itemId}/remove`, { method: 'POST', body: '{}' }); toast('停用任务已进入队列'); await load(); }
|
||||
else if (action === 'delete-node') { hidePop(); if (!await confirmModal('确定删除这个节点?会移除全部分配并从设备清理资源。', '删除')) return; await api(`/api/nodes/${itemId}`, { method: 'DELETE' }); toast('节点已删除,设备资源待 Agent 确认清理'); await load(); }
|
||||
else if (action === 'bulk-clear') { state.selection[button.dataset.bulkPage].clear(); render(); }
|
||||
else if (action === 'bulk-enable') await runBulk(button.dataset.bulkPage, 'enable');
|
||||
else if (action === 'bulk-disable') await runBulk(button.dataset.bulkPage, 'disable');
|
||||
else if (action === 'bulk-delete') await runBulk(button.dataset.bulkPage, 'delete');
|
||||
else if (action === 'download-backup') location.href = '/api/backup';
|
||||
else if (action === 'restore-backup') {
|
||||
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json';
|
||||
@@ -1204,9 +1526,20 @@ document.addEventListener('change', async (event) => {
|
||||
await load('chains');
|
||||
} else if (target.matches('[data-group-filter]')) {
|
||||
state.groupFilter = target.value; state.pages.customers = 1; render();
|
||||
} else if (target.matches('[data-row-check]')) {
|
||||
const set = state.selection[target.dataset.rowCheck];
|
||||
if (set) {
|
||||
if (target.checked) set.add(target.dataset.id); else set.delete(target.dataset.id);
|
||||
render();
|
||||
}
|
||||
} else if (target.matches('[data-check-all]')) {
|
||||
$$('[data-row-check]').forEach((box) => { box.checked = target.checked; });
|
||||
const set = state.selection[target.dataset.checkAll];
|
||||
if (set) {
|
||||
$$('[data-row-check]').forEach((box) => { if (target.checked) set.add(box.dataset.id); else set.delete(box.dataset.id); });
|
||||
render();
|
||||
}
|
||||
} else if (target.matches('[data-node-sync]')) syncNodeForm();
|
||||
else if (target.matches('[data-exit-link]')) applyExitLink(target);
|
||||
else if (target.matches('[data-reality-preset]')) {
|
||||
const option = target.selectedOptions[0]; const form = target.form;
|
||||
if (option && option.value !== 'custom') { form.elements.realityServerName.value = option.dataset.sni; form.elements.realityDestPort.value = option.dataset.port; }
|
||||
|
||||
+6
-9
@@ -28,23 +28,20 @@
|
||||
<div class="brand">amo</div>
|
||||
<nav class="menu" id="nav">
|
||||
<a class="mi" data-page="overview" href="#overview"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12l9-9 9 9"/><path d="M5 10v10h14V10"/></svg>系统状态</a>
|
||||
<a class="mi" data-page="servers" href="#servers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>服务器</a>
|
||||
<a class="mi" data-page="customers" href="#customers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.8-3.2 3.4-5 6.5-5s5.7 1.8 6.5 5"/><path d="M16 4.6a3.5 3.5 0 010 6.8M21.5 20c-.6-2.6-2.2-4.2-4.5-4.8"/></svg>客户与订阅</a>
|
||||
<a class="mi" data-page="chains" href="#chains"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="12" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.3 10.8l9.4-3.6M7.3 13.2l9.4 3.6"/></svg>转发与节点</a>
|
||||
<a class="mi" data-page="commands" href="#commands"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 17l6-5-6-5M12 19h8"/></svg>安装与命令</a>
|
||||
<a class="mi" data-page="operations" href="#operations"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 00-.1-1.2l2-1.5-2-3.4-2.3 1a7 7 0 00-2-1.2L14.2 3h-4l-.4 2.5a7 7 0 00-2 1.2l-2.3-1-2 3.4 2 1.5A7 7 0 005 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.4 2.3-1a7 7 0 002 1.2l.4 2.7h4l.4-2.5a7 7 0 002-1.2l2.3 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2z"/></svg>设置与运维</a>
|
||||
<a class="mi" data-page="servers" href="#servers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>节点管理</a>
|
||||
<a class="mi" data-page="customers" href="#customers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.8-3.2 3.4-5 6.5-5s5.7 1.8 6.5 5"/><path d="M16 4.6a3.5 3.5 0 010 6.8M21.5 20c-.6-2.6-2.2-4.2-4.5-4.8"/></svg>用户订阅</a>
|
||||
<a class="mi" data-page="chains" href="#chains"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="12" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.3 10.8l9.4-3.6M7.3 13.2l9.4 3.6"/></svg>入站&出站</a>
|
||||
<a class="mi" data-page="operations" href="#operations"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 00-.1-1.2l2-1.5-2-3.4-2.3 1a7 7 0 00-2-1.2L14.2 3h-4l-.4 2.5a7 7 0 00-2 1.2l-2.3-1-2 3.4 2 1.5A7 7 0 005 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.4 2.3-1a7 7 0 002 1.2l.4 2.7h4l.4-2.5a7 7 0 002-1.2l2.3 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2z"/></svg>设置</a>
|
||||
</nav>
|
||||
<div class="sider-foot"><span id="user-chip"></span><span class="foot-right"><span id="version-label"></span><button id="logout" class="icon-btn" type="button" title="退出登录"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg></button></span></div>
|
||||
<div class="sider-foot"><span id="user-chip"></span><span class="foot-right"><span id="version-label"></span><button id="logout" class="icon-btn" type="button" data-tip="退出登录"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg></button></span></div>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<span class="crumb">amo / <b id="page-title">系统状态</b></span>
|
||||
<span class="sp"></span>
|
||||
<input class="top-search" placeholder="搜索服务器、客户、节点…" disabled>
|
||||
<button class="theme-btn" id="refresh" type="button" title="刷新数据">刷新</button>
|
||||
<button class="theme-btn" type="button" data-theme-toggle>🌙 深色</button>
|
||||
<span class="avatar" id="avatar">管</span>
|
||||
</header>
|
||||
<main class="content" id="content"></main>
|
||||
</div>
|
||||
@@ -52,7 +49,7 @@
|
||||
|
||||
<div id="modal-overlay" class="modal-overlay hidden">
|
||||
<section class="modal panel" role="dialog" aria-modal="true">
|
||||
<header class="modal-head"><h2 id="modal-title"></h2><button type="button" class="icon-btn" data-close aria-label="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 6L6 18M6 6l12 12"/></svg></button></header>
|
||||
<header class="modal-head"><h2 id="modal-title"></h2><button type="button" class="icon-btn" data-close aria-label="关闭" data-tip="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 6L6 18M6 6l12 12"/></svg></button></header>
|
||||
<div id="modal-body" class="modal-body"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+31
-14
@@ -65,17 +65,17 @@ input,select,textarea{font-family:var(--font);color:var(--text)}
|
||||
.crumb{color:var(--text-3);font-size:13px;white-space:nowrap}
|
||||
.crumb b{color:var(--text);font-weight:600;font-size:15px}
|
||||
.topbar .sp{flex:1}
|
||||
.top-search{background:var(--card);border:1px solid var(--border);border-radius:6px;color:var(--text-2);padding:5px 12px;font-size:13px;width:230px}
|
||||
.theme-btn{border:1px solid var(--border);background:var(--card);color:var(--text-2);border-radius:6px;padding:5px 11px;font-size:12.5px;cursor:pointer;white-space:nowrap}
|
||||
.theme-btn:hover{border-color:var(--primary);color:var(--primary-text)}
|
||||
.avatar{width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,#1668dc,#13c2c2);display:inline-flex;align-items:center;justify-content:center;font-size:12px;color:#fff;font-weight:600;flex:none}
|
||||
.content{padding:16px 20px 28px;max-width:1460px;width:100%;margin:0 auto}
|
||||
.content{padding:24px;width:100%}
|
||||
.panel{background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:16px}
|
||||
.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;border-bottom:1px solid var(--border-soft)}
|
||||
.panel-head h2{font-size:14.5px;font-weight:600}
|
||||
.panel-body{padding:14px 16px}
|
||||
.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.page-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
|
||||
.bulk-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;padding:8px 12px;border:1px solid var(--primary);border-radius:8px;background:var(--card-hover)}
|
||||
.bulk-bar .bulk-count{font-size:13px;color:var(--text)}
|
||||
.search{background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 12px;font-size:13px;width:260px}
|
||||
select.search{width:auto}
|
||||
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--card-hover);color:var(--text);border-radius:6px;padding:5px 14px;font-size:13px;cursor:pointer;white-space:nowrap}
|
||||
@@ -138,13 +138,6 @@ select.search{width:auto}
|
||||
.stop-up-a{stop-color:var(--primary);stop-opacity:.26}
|
||||
.stop-up-b{stop-color:var(--primary);stop-opacity:0}
|
||||
.notice{border:1px solid var(--orange);background:var(--orange-soft);color:var(--orange-text);border-radius:6px;padding:8px 12px;font-size:13px;margin-bottom:16px}
|
||||
.activity-list{display:flex;flex-direction:column}
|
||||
.activity-list .event{display:flex;align-items:center;gap:10px;padding:7px 16px;border-bottom:1px solid var(--border-soft);font-size:13px}
|
||||
.activity-list .event:last-child{border-bottom:none}
|
||||
.activity-list .event b{font-weight:500}
|
||||
.activity-list .event small{color:var(--text-3)}
|
||||
.activity-list .event time{margin-left:auto;color:var(--text-3);font-size:12px;white-space:nowrap}
|
||||
.event-dot{width:7px;height:7px;border-radius:50%;background:var(--primary);flex:none}
|
||||
|
||||
/* ---------- 表格通用 ---------- */
|
||||
.table-wrap{overflow-x:auto}
|
||||
@@ -153,6 +146,9 @@ table{width:100%;border-collapse:collapse}
|
||||
table.w-servers{min-width:1390px}
|
||||
table.w-customers{min-width:1120px}
|
||||
table.w-nodes{min-width:1180px}
|
||||
table.w-jobs td{white-space:nowrap;padding-top:5px;padding-bottom:5px}
|
||||
table.w-jobs th:last-child{width:22%}
|
||||
table.w-jobs td:last-child{white-space:normal;min-width:120px;word-break:break-word}
|
||||
thead th{text-align:left;font-size:12.5px;font-weight:600;color:var(--text-2);letter-spacing:.3px;padding:7px 12px;border-bottom:1px solid var(--border-soft);background:var(--th-bg);white-space:nowrap}
|
||||
thead th .sarr{color:var(--text-3);font-size:10px;margin-left:3px}
|
||||
thead th.ctr,tbody td.ctr{text-align:center}
|
||||
@@ -181,6 +177,10 @@ tbody tr.drag-over td{border-top:2px solid var(--primary)}
|
||||
.icon-btn:hover{color:var(--primary-text);background:var(--hover-bg)}
|
||||
.icon-btn.del{color:var(--red-text)}
|
||||
.icon-btn.del:hover{background:var(--red-soft);color:var(--red-text)}
|
||||
/* 图标悬停提示气泡(3x-ui 式:黑底白字圆角 + 小尖角,挂 body 避免被表格滚动容器裁剪) */
|
||||
.tip-bubble{position:fixed;z-index:200;pointer-events:none;background:#141414;color:#fff;font-size:12px;line-height:1.25;padding:6px 9px;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity .12s;box-shadow:0 4px 14px rgba(0,0,0,.25)}
|
||||
.tip-bubble.show{opacity:1}
|
||||
.tip-bubble::after{content:"";position:absolute;top:100%;left:var(--tip-arrow-left,50%);transform:translateX(-50%);border:5px solid transparent;border-top-color:#141414;border-bottom:0}
|
||||
.tgl{position:relative;display:inline-block;width:38px;height:21px;vertical-align:middle;flex:none}
|
||||
.tgl input{opacity:0;width:0;height:0;position:absolute}
|
||||
.tgl .tr{position:absolute;inset:0;background:var(--tgl-off);border-radius:99px;transition:background .2s;cursor:pointer}
|
||||
@@ -206,7 +206,8 @@ tbody tr.drag-over td{border-top:2px solid var(--primary)}
|
||||
.empty b{display:block;color:var(--text-2);font-size:14px;margin-bottom:2px}
|
||||
|
||||
/* ---------- 服务器页 ---------- */
|
||||
.stats{display:grid;grid-template-columns:repeat(4,1fr);background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:14px}
|
||||
.stats{display:grid;grid-template-columns:repeat(3,1fr);background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:14px}
|
||||
.stats.six{grid-template-columns:repeat(6,1fr)}
|
||||
.stats .stat{padding:14px 22px;border:none;background:transparent}
|
||||
.stats .stat .lab{color:var(--text-2);font-size:12.5px;margin-bottom:3px}
|
||||
.stats .stat .val{display:flex;align-items:center;gap:8px;font-size:20px;font-weight:600;font-variant-numeric:tabular-nums}
|
||||
@@ -318,6 +319,9 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri
|
||||
.mono{font-family:var(--mono);font-size:12.5px;word-break:break-all}
|
||||
|
||||
/* 页签 */
|
||||
.segctl{display:inline-flex;border:1px solid var(--border);border-radius:8px;overflow:hidden}
|
||||
.segctl button{padding:6px 16px;background:var(--card);border:none;color:var(--text-2);cursor:pointer;font:inherit;font-size:13px}
|
||||
.segctl button.active{background:var(--primary);color:#fff}
|
||||
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border-soft);margin-bottom:14px}
|
||||
.tabs button{border:none;background:transparent;padding:7px 14px;font-size:13.5px;color:var(--text-2);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
|
||||
.tabs button.active{color:var(--primary-text);border-bottom-color:var(--primary);font-weight:500}
|
||||
@@ -372,7 +376,8 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri
|
||||
.warn-note{border:1px solid var(--orange);background:var(--orange-soft);color:var(--orange-text);border-radius:6px;padding:8px 12px;font-size:13px}
|
||||
|
||||
/* 命令页 / 运维页 */
|
||||
.command-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
|
||||
.command-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;margin-bottom:16px}
|
||||
.command-grid .panel{margin-bottom:0}
|
||||
.command-item{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border-soft)}
|
||||
.command-item:last-child{border-bottom:none}
|
||||
.command-item>div{flex:1;min-width:0}
|
||||
@@ -398,5 +403,17 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri
|
||||
.toast{background:var(--pop-bg);border:1px solid var(--border);border-left:3px solid var(--green);color:var(--text);border-radius:8px;box-shadow:var(--pop-shadow);padding:9px 14px;font-size:13px;max-width:360px}
|
||||
.toast.error{border-left-color:var(--red)}
|
||||
|
||||
@media (max-width:1100px){.stat-grid{grid-template-columns:repeat(2,1fr)}.mid-grid{grid-template-columns:1fr}.info-bar{flex-direction:column;gap:14px}.info-bar .seg{padding:0}.info-bar .seg+.seg{border-left:none}.stats{grid-template-columns:repeat(2,1fr)}}
|
||||
@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.top-search{display:none}}
|
||||
@media (max-width:1100px){.stat-grid{grid-template-columns:repeat(2,1fr)}.mid-grid{grid-template-columns:1fr}.info-bar{flex-direction:column;gap:14px}.info-bar .seg{padding:0}.info-bar .seg+.seg{border-left:none}.stats{grid-template-columns:repeat(2,1fr)}.stats.six{grid-template-columns:repeat(3,1fr)}}
|
||||
@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.content{padding:8px}}
|
||||
|
||||
/* Reality 目标扫描器结果表(第 16 条) */
|
||||
.scan-table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.scan-table th,.scan-table td{padding:6px 8px;text-align:left;border-bottom:1px solid var(--border-soft);white-space:nowrap}
|
||||
.scan-table th{color:var(--mut);font-weight:600}
|
||||
/* 扫描器浮层专属收口(真机修复:结果表曾冲出弹窗边框):弹窗加宽、结果容器
|
||||
横向滚动兜底、目标/证书列截断、失败原因列允许换行。全部 .scan- 专属,
|
||||
不影响节点编辑弹窗本体与其它弹窗/主页面表格的既有布局。 */
|
||||
.modal.scan-modal{width:1140px}
|
||||
.scan-wrap{overflow-x:auto}
|
||||
.scan-table td.scan-target,.scan-table td.scan-cert{max-width:230px;overflow:hidden;text-overflow:ellipsis}
|
||||
.scan-table td.scan-reason{white-space:normal;max-width:280px;overflow-wrap:anywhere}
|
||||
|
||||
@@ -27,7 +27,7 @@ elif [[ -x /usr/local/bin/nexusgate-sing-box ]]; then
|
||||
if [[ -f /etc/nexusgate/sing-box/config.json ]]; then
|
||||
/usr/local/bin/nexusgate-sing-box check -c /etc/nexusgate/sing-box/config.json 2>&1 | tail -n 12
|
||||
printf '入站流量统计(累计):\n'
|
||||
/usr/local/bin/nexusgate-sing-box-stats --server="127.0.0.1:${NG_SINGBOX_API_PORT:-10086}" 2>&1 | head -c 900
|
||||
/usr/local/bin/nexusgate-sing-box-stats --server="127.0.0.1:${NG_SINGBOX_API_PORT:-10089}" 2>&1 | head -c 900
|
||||
printf '\n'
|
||||
else
|
||||
printf '已安装,当前没有节点资源\n'
|
||||
|
||||
@@ -9,7 +9,16 @@ set -Eeuo pipefail
|
||||
version='v1.13.21'
|
||||
target='/usr/local/bin/nexusgate-sing-box'
|
||||
stats_target='/usr/local/bin/nexusgate-sing-box-stats'
|
||||
# The locally built sing-box carries no version stamp (`sing-box version`
|
||||
# prints "unknown"), so the agent cannot probe the core version from the
|
||||
# binary. Record the installed version (without the leading v) for the
|
||||
# agent's fallback probe — see agent-go installedSingBoxVersion.
|
||||
write_version_record() {
|
||||
install -d -m 0700 /etc/nexusgate/sing-box
|
||||
printf '%s\n' "${version#v}" > /etc/nexusgate/sing-box/version
|
||||
}
|
||||
if [[ -x "$target" && -x "$stats_target" && "${2:-}" != '--force' ]]; then
|
||||
write_version_record
|
||||
printf 'sing-box 已安装:%s(如需重装,运行 ng-agent-singbox install --force)\n' "$target"
|
||||
exit 0
|
||||
fi
|
||||
@@ -38,6 +47,7 @@ try_prebuilt() {
|
||||
install -m 0755 "$tmp_stats" "${stats_target}.next"
|
||||
mv -f "${target}.next" "$target"
|
||||
mv -f "${stats_target}.next" "$stats_target"
|
||||
write_version_record
|
||||
printf 'sing-box 预编译版安装完成:%s\n' "$target"
|
||||
return 0
|
||||
fi
|
||||
@@ -104,6 +114,7 @@ install -m 0755 "$build_dir/sing-box" "$target.next"
|
||||
install -m 0755 "$build_dir/stats-query" "$stats_target.next"
|
||||
mv -f -- "$target.next" "$target"
|
||||
mv -f -- "$stats_target.next" "$stats_target"
|
||||
write_version_record
|
||||
printf 'sing-box 统计版已安装:%s\n' "$target"
|
||||
if [[ -f /etc/nexusgate/sing-box/config.json ]]; then
|
||||
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then
|
||||
|
||||
Reference in New Issue
Block a user