Compare commits
10
Commits
612035ac4e
...
5733928198
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5733928198 | ||
|
|
18bbd388ea | ||
|
|
25cb58245c | ||
|
|
0037116edc | ||
|
|
d316d0f8bb | ||
|
|
b49c134513 | ||
|
|
35c9bd2cc0 | ||
|
|
1b32b57377 | ||
|
|
3de4c4945f | ||
|
|
85763ebdb7 |
@@ -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,92 @@
|
||||
# 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 04:56,用户附截图)
|
||||
|
||||
首页(系统状态页)底部的「最近活动」面板整个删除(该面板是改版时为保旧功能补回的,用户现明确不要)。只删展示模块,后端活动/审计记录本身保留不动。
|
||||
|
||||
## 13. 设置页「任务队列」表格排紧凑(2026-10-21 04:58,用户附截图)
|
||||
|
||||
现状毛病:列宽分配失衡——任务 ID 被挤到换行两行、状态"已完成"竖排三行、时间两行,而错误列大片空白。
|
||||
要求:重配列宽与换行——ID/状态/时间等短内容不换行、单行呈现;空白列不占大宽度;整表紧凑。合并进设置页后保持此排版。
|
||||
@@ -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 未改动、哈希不变。
|
||||
@@ -199,6 +199,57 @@ 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
|
||||
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 ""
|
||||
}
|
||||
|
||||
func singBoxVersion() string {
|
||||
info, err := os.Stat(cfg.SingBoxBin)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
singBoxVersionMu.Lock()
|
||||
defer singBoxVersionMu.Unlock()
|
||||
c := &singBoxVersionCache
|
||||
if c.probed && c.path == cfg.SingBoxBin && c.mtimeNs == info.ModTime().UnixNano() && c.size == info.Size() {
|
||||
return c.version
|
||||
}
|
||||
version := ""
|
||||
if out, _, code := runCapture(5*time.Second, cfg.SingBoxBin, "version"); code == 0 {
|
||||
version = parseSingBoxVersion(out)
|
||||
}
|
||||
c.path, c.mtimeNs, c.size, c.version, c.probed = cfg.SingBoxBin, info.ModTime().UnixNano(), info.Size(), version, true
|
||||
return version
|
||||
}
|
||||
|
||||
// engineHealth mirrors agent.js engineHealth(). Callers must hold stateMu
|
||||
@@ -214,6 +265,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"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+332
-73
@@ -7,6 +7,7 @@ package main
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -208,6 +209,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 +244,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 +255,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 +278,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 +352,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 +378,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 +453,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 +475,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 +511,64 @@ 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 密钥长度与加密方法不符")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if IsRealityProtocol(protocol) && next.RealityServerName == "" {
|
||||
return nil, httpErr(400, "Reality SNI 不能为空")
|
||||
}
|
||||
@@ -481,9 +659,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
|
||||
@@ -496,30 +757,26 @@ func credentialItems(d *Data, customer *Customer) []map[string]any {
|
||||
if node.Protocol == "socks5-auth" {
|
||||
username = NodeEmail(customer.ID)
|
||||
}
|
||||
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" /
|
||||
@@ -904,6 +1161,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,
|
||||
}
|
||||
}
|
||||
@@ -1681,7 +1939,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 +2061,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 +2267,44 @@ 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" {
|
||||
node := findNode(d, nc.NodeID)
|
||||
if node == nil || node.Status == "draft" || node.Status == "removing" || rebuilt[node.ID] {
|
||||
continue
|
||||
}
|
||||
rebuilt[node.ID] = true
|
||||
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
|
||||
}
|
||||
}
|
||||
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 +2520,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
|
||||
@@ -2313,28 +2568,29 @@ 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" {
|
||||
@@ -2342,6 +2598,9 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
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,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)
|
||||
}
|
||||
}
|
||||
+58
-22
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -331,7 +367,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 +387,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"
|
||||
}
|
||||
@@ -606,14 +642,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 {
|
||||
@@ -666,7 +702,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 +747,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
|
||||
@@ -977,14 +1013,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)
|
||||
}
|
||||
@@ -1077,14 +1113,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" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+55
-18
@@ -196,18 +196,47 @@ 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
|
||||
}
|
||||
|
||||
func EnsureNodeSecrets(node *Node) {
|
||||
if node.WSPath == nil || *node.WSPath == "" {
|
||||
node.WSPath = StrPtr("/" + hex.EncodeToString(xbRandBytes(9)))
|
||||
@@ -352,12 +381,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 +413,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 +436,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,
|
||||
@@ -417,7 +447,7 @@ 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})
|
||||
users = append(users, map[string]any{"username": NodeEmail(nc.CustomerID), "password": clientCredentials(nc).RelayPassword})
|
||||
}
|
||||
return map[string]any{"type": "socks", "tag": tag, "listen": listen, "listen_port": port, "users": users}, nil
|
||||
}
|
||||
@@ -428,7 +458,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 +476,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 +499,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 +525,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 +719,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"}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package main
|
||||
// the same serialization here.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -133,10 +134,70 @@ func (s *Store) Init() error {
|
||||
if err := validateData(&data); err != nil {
|
||||
return err
|
||||
}
|
||||
if MigrateCustomerCredentials(&data) {
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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,13 +108,17 @@ 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
|
||||
}
|
||||
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: creds.RelayPassword,
|
||||
ShortID: creds.ShortID,
|
||||
WSPath: xbDeref(node.WSPath),
|
||||
SocksUser: NodeEmail(nc.CustomerID),
|
||||
Reality: node.Reality,
|
||||
|
||||
@@ -36,26 +36,25 @@ func goldenSubData() *Data {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+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"
|
||||
}
|
||||
}
|
||||
|
||||
+47
-26
@@ -268,14 +268,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 +332,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:"-"`
|
||||
}
|
||||
@@ -554,9 +560,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,13 +703,20 @@ 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"`
|
||||
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"`
|
||||
@@ -775,14 +792,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 {
|
||||
|
||||
+182
-61
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
/* amo 控制台前端。六个页面:系统状态 / 服务器 / 客户与订阅 / 转发与节点 /
|
||||
安装与命令 / 设置与运维。hash 路由,45 秒自动刷新(弹窗打开时暂停)。 */
|
||||
/* amo 控制台前端。五个页面:系统状态 / 节点管理 / 用户订阅 / 入站&出站 /
|
||||
设置。hash 路由,45 秒自动刷新(弹窗打开时暂停)。 */
|
||||
|
||||
const state = {
|
||||
session: null, page: 'overview', overview: null, system: null,
|
||||
@@ -62,14 +62,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 = {
|
||||
@@ -144,7 +137,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 +189,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));
|
||||
@@ -221,20 +245,19 @@ async function load(page = state.page) {
|
||||
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;
|
||||
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
$('#version-label').textContent = `v${state.version}`;
|
||||
} 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;
|
||||
} 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;
|
||||
state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
} 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.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || [];
|
||||
} else if (page === 'commands') { /* 静态页 */ }
|
||||
else if (page === 'operations') {
|
||||
} 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}`;
|
||||
@@ -243,8 +266,8 @@ async function load(page = state.page) {
|
||||
} 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 +362,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 +392,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 +422,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>
|
||||
@@ -424,7 +445,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');
|
||||
@@ -438,7 +458,7 @@ function renderServers() {
|
||||
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><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 +466,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>`;
|
||||
<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" 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 +532,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><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,10 +542,20 @@ 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>
|
||||
<tbody>${rows || emptyRow(9, '还没有客户')}</tbody></table></div>${pagerHTML('customers', filtered.length)}</section>`;
|
||||
@@ -589,7 +618,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 +626,22 @@ 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) : ''}
|
||||
const creds = data.credentials || {};
|
||||
const bindings = (data.bindings || []).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></div>
|
||||
${entry.username ? credRowHTML('用户名', entry.username) : ''}
|
||||
${entry.realityPublicKey ? credRowHTML('Reality 公钥', entry.realityPublicKey) : ''}
|
||||
</div>`).join('');
|
||||
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>
|
||||
${bindings || '<div class="empty"><b>尚未关联节点</b></div>'}`;
|
||||
}
|
||||
|
||||
function linkStatusLine(customer, source) {
|
||||
@@ -628,12 +660,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 +831,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 +869,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><span class="grip" data-tip="拖动排序" data-grip>${ICONS.grip}</span></td>
|
||||
<td><input type="checkbox" data-row-check></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,9 +883,9 @@ 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>
|
||||
<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>
|
||||
<tbody>${rows || emptyRow(13, '还没有节点')}</tbody></table></div>${pagerHTML('nodes', filtered.length)}</section>`;
|
||||
}
|
||||
@@ -864,7 +896,7 @@ 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>
|
||||
@@ -874,9 +906,15 @@ function nodeForm(item = null) {
|
||||
<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>
|
||||
<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();
|
||||
}
|
||||
@@ -894,6 +932,77 @@ function syncNodeForm() {
|
||||
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('已从链接填入落地端点');
|
||||
}
|
||||
function nodeClientsModal(node) {
|
||||
const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id);
|
||||
@@ -917,8 +1026,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 +1036,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,11 +1054,12 @@ 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]();
|
||||
}
|
||||
|
||||
@@ -995,7 +1104,15 @@ 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 };
|
||||
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');
|
||||
} else if (form.id === 'account-form') {
|
||||
@@ -1108,11 +1225,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('凭据已重新生成');
|
||||
@@ -1207,6 +1327,7 @@ document.addEventListener('change', async (event) => {
|
||||
} else if (target.matches('[data-check-all]')) {
|
||||
$$('[data-row-check]').forEach((box) => { box.checked = target.checked; });
|
||||
} 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>
|
||||
|
||||
+17
-14
@@ -65,11 +65,9 @@ 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}
|
||||
@@ -138,13 +136,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 +144,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 +175,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 +204,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 +317,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 +374,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 +401,5 @@ 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}}
|
||||
|
||||
Reference in New Issue
Block a user