Compare commits
14
Commits
main
...
backup-20261021
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5733928198 | ||
|
|
18bbd388ea | ||
|
|
25cb58245c | ||
|
|
0037116edc | ||
|
|
d316d0f8bb | ||
|
|
b49c134513 | ||
|
|
35c9bd2cc0 | ||
|
|
1b32b57377 | ||
|
|
3de4c4945f | ||
|
|
85763ebdb7 | ||
|
|
612035ac4e | ||
|
|
0a2889b4e4 | ||
|
|
d287bb9be9 | ||
|
|
c6f6b9dbea |
@@ -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,156 @@
|
||||
# NexusGate 客户流量按月自动重置 — 实施报告
|
||||
|
||||
日期:2026-10-20 | 基线:`f4ef3d9` | 范围:独立功能,不含任何数据迁移代码。
|
||||
|
||||
## 0. 开工前工作区处理
|
||||
|
||||
`git status` 检查结果:工作区与 HEAD (`f4ef3d9`) 一致,无已修改文件;上一条被中止的
|
||||
迁移工作流未留下任何残件。唯一未跟踪文件 `LINK-MIGRATION.md` 是更早的链接迁移说明
|
||||
文档(本功能之前就未入仓),与本功能无关 — 原样保留,未纳入本次提交。
|
||||
|
||||
## 1. 用量模型摸底(设计前提)
|
||||
|
||||
- 客户用量记在客户记录的三个字段:`usedBytes` / `usedUplinkBytes` / `usedDownlinkBytes`,
|
||||
由 orchestrator 按 agent 上报的 epoch 累计计数做增量累加(面板侧累计制)。
|
||||
- 额度判定全部读 `usedBytes`:`CustomerBlockReason`、housekeeping 的额度暂停、
|
||||
客户状态变更守卫、订阅发放前检查,四处同一口径。
|
||||
- 订阅输出(`Subscription-Userinfo` 头与订阅信息页)读同三字段,其中
|
||||
`historical = usedBytes - up - down`(≥0)是给"只灌总数"的导入数据兜底的派生量。
|
||||
- 模型没有独立的终身累计字段:三字段既是周期计数也是展示口径。已有手动
|
||||
`POST /api/customers/:id/reset-usage` 把三字段同时清零并在拦截消除后恢复客户 —
|
||||
本功能与其保持完全一致的清零语义。
|
||||
- **绝不能动**的是各 server/nodeClient 上的 epoch 基线计数器
|
||||
(`serverUsage[].usageCounters` 等):那是增量记账的基准,清了会重复计费。
|
||||
本功能只动客户三字段。
|
||||
|
||||
## 2. 设计
|
||||
|
||||
### 字段(客户级,共享 JSON schema,全部 omitempty)
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
|---|---|---|
|
||||
| `trafficResetDay` | int | 每月重置日 1–31;0/缺省 = 不重置 |
|
||||
| `trafficResetPeriod` | string | 幂等标记:上次已应用重置所属周期键 `YYYY-MM` |
|
||||
| `trafficResetAt` | string(ISO) | 上次实际重置时间(展示用) |
|
||||
|
||||
Node 版面板无副作用已核实:Node 的客户 PATCH 是逐字段改已加载对象、整对象落盘,
|
||||
未知字段原样保留;Node 只是不执行重置逻辑(生产主控是 Go 版)。
|
||||
|
||||
### 周期键与触发判定(纯函数,无内存状态)
|
||||
|
||||
- 某月的重置时刻 = `min(重置日, 当月天数)` 号 00:00(面板本地时间)— 31 号遇
|
||||
2 月在 28 号触发,闰年 29 号正常存在。
|
||||
- 周期键 = `now` 时刻"最近一个已到来的重置时刻"所在月份(`YYYY-MM`)。
|
||||
跨过重置时刻键才翻页。
|
||||
- 判定(housekeeping 每轮):`marker != 当前周期键` 且 marker 非空 ⇒ 到期,执行一次
|
||||
清零并把 marker 写为当前键。**面板停机跨过多个周期也只清一次**(落到当前周期),
|
||||
这就是重启补判:不依赖 ticker 记忆,第一轮 housekeeping(启动后 ≤60 秒,
|
||||
与 Node 版同节律,未额外加启动调用以保持差分行为对齐)即补触发。
|
||||
|
||||
### 布防(arm)语义 — 防误清
|
||||
|
||||
- marker 为空(老数据、或外部写入只写了 `trafficResetDay`)时,第一轮检查只写
|
||||
marker、**不清零**:本周期已累计用量保留,下个重置日正常触发。
|
||||
- API 创建客户带重置日、或 PATCH **改动**重置日时,当场布防(新排期从下个重置
|
||||
时刻生效,绝不在设置瞬间清零)。PATCH 回显**未改动**的值不重布防 — 否则管理员
|
||||
在"到期未触发"的窗口里编辑客户会把待触发的重置吞掉。
|
||||
|
||||
### 到期动作(镜像手动清零)
|
||||
|
||||
1. 三计数清零;marker ← 当前周期键;`trafficResetAt` / `updatedAt` ← 现在;
|
||||
2. 写审计 `reset_customer_usage_monthly`(actor `system:monthly_reset`,
|
||||
detail 含清零前用量、重置日、周期键);
|
||||
3. 若客户是 `traffic_limit` 暂停且清零后无其它拦截(未过期等),自动恢复
|
||||
active 并 `ResumeCustomerResources`(与手动清零同路径)。
|
||||
手动暂停、到期暂停的客户只清零、不恢复。
|
||||
|
||||
### 挂载点
|
||||
|
||||
`main.go` 的 60 秒 housekeeping 事务内、额度暂停评估**之前**调用
|
||||
`ApplyMonthlyTrafficResets`(`panel-go/reset.go`)— 同事务原子生效:清零后本轮
|
||||
额度判定即按新周期口径走。不新起 goroutine,内存零增量(几个时间计算/客户)。
|
||||
|
||||
### API / UI 配套(最小)
|
||||
|
||||
- `POST /api/customers` 与 `PATCH /api/customers/:id` 接受 `trafficResetDay`
|
||||
(0–31 整数,null = 清除;32 / 小数 / 负数 → 400)。
|
||||
- 前端客户表单新增"每月流量重置日"输入(0 不重置 + 说明),编辑时回显
|
||||
"上次重置 时间";提交 payload 带该字段。`public/app.js` 与
|
||||
`panel-go/embed_public/app.js`(构建期副本)已同步。
|
||||
|
||||
## 3. 改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `panel-go/types.go` | Customer 加 3 字段(+3 行) |
|
||||
| `panel-go/reset.go` | 新增:周期键/时刻计算、布防、到期应用、入参校验 |
|
||||
| `panel-go/main.go` | housekeeping 事务内挂载(+9 行) |
|
||||
| `panel-go/handlers.go` | 创建/PATCH 接受并布防 `trafficResetDay`(+22 行) |
|
||||
| `public/app.js` | 表单字段 + payload(+3/−1 行) |
|
||||
| `panel-go/reset_test.go` | 新增测试(14 个测试函数) |
|
||||
| `MONTHLY-RESET-REPORT.md` | 本报告 |
|
||||
|
||||
## 4. 测试清单与结果
|
||||
|
||||
**基线(改动前)**:panel-go `go test ./...` 全过;Node `node --test` 52/52。
|
||||
|
||||
**新增用例**(`panel-go/reset_test.go`,全部通过):
|
||||
|
||||
| 用例 | 覆盖点 |
|
||||
|---|---|
|
||||
| `TestTrafficResetPeriodKey` | 16 个历法断言:跨月、时刻整点边界、同日、年翻转(1/1→上年 12 月)、31→2/28 夹逼、30→2 月、29 闰/非闰年 |
|
||||
| `TestApplyMonthlyResetDueAndIdempotent` | 正常跨月触发清零 + marker/resetAt/审计落位;同周期二次检查不清零(新流量保留) |
|
||||
| `TestApplyMonthlyResetNotYetDue` | 本周期已重置者不动 |
|
||||
| `TestApplyMonthlyResetArmsOnFirstSight` | marker 空只布防不清零;布防后下个周期正常触发 |
|
||||
| `TestApplyMonthlyResetCatchUpMissedPeriods` | 停机跨 4 个周期(重启补判):只清一次、落当前周期、审计恰 1 条 |
|
||||
| `TestApplyMonthlyResetShortMonthClamp` | 31 号:2/27 不触发、2/28 00:01 触发且周期为 2 月 |
|
||||
| `TestApplyMonthlyResetIndependentDays` | 多客户不同重置日互不干扰(1 号已清、25 号未动) |
|
||||
| `TestApplyMonthlyResetUnsetOrInvalidUntouched` | 未设置 / 45 / −3 的客户完全不受影响 |
|
||||
| `TestApplyMonthlyResetResumesTrafficSuspended` | 流量暂停客户清零后自动恢复、拦截原因消除 |
|
||||
| `TestApplyMonthlyResetKeepsOtherSuspensions` | 手动暂停 / 到期暂停:只清零不恢复 |
|
||||
| `TestParseResetDay` | nil/0/1/31/json.Number 合法;32/2.5/−1 拒绝 |
|
||||
| `TestResetFieldsPersistAndOmitWhenUnset` | 落盘原文含三字段、重载不丢;未设置客户 JSON 不出现新键(Node 互通前提) |
|
||||
| `TestHousekeepingAppliesMonthlyReset` | 走真实 housekeeping 全链:到期清零、周期落位;第二轮不重复清 |
|
||||
| `TestCustomerAPIResetDayLifecycle` | 登录后 API 全程:创建带日(布防、无 resetAt)→ PATCH 32 被拒 → 改日重布防 → 清 0 后记录中字段消失 |
|
||||
|
||||
**改动后全套**:
|
||||
|
||||
- panel-go `go test ./...` 全过(另 `-count=1` 全量复跑过);`gofmt` / `go vet` 干净。
|
||||
- agent-go `go test ./...` 全过(未改动,回归确认)。
|
||||
- Node `node --test` 52/52;`npm run check`(server.js / agent / app.js 语法)通过。
|
||||
- **差分 E2E(difftest)正式模式 50/50 PASS**(Node vs Go 同种子同剧本,
|
||||
含客户 PATCH 与客户列表比对)— 新增字段未扰动任何既有响应形状。
|
||||
|
||||
## 5. 代码自审清单(边界专项)
|
||||
|
||||
| # | 发现 | 处置 |
|
||||
|---|---|---|
|
||||
| 1 | `TrafficResetPeriodKey` 初版混用时区:年月取 `now` 的 Location、时刻构造写死 `time.Local`,两者不一致必算错 | **已修**:全程 `now.Location()`;`daysInMonth` 改纯历法(UTC)计算;历法测试全程显式构造本地时间 |
|
||||
| 2 | epoch 基线计数器(serverUsage / nodeClient usageCounters)若被清会重复计费 | 已确认设计只动客户三字段,基线零接触(见 §1) |
|
||||
| 3 | 事务原子性:重置在 housekeeping 事务内,若 `ResumeCustomerResources` 出错整轮回滚 — marker 与计数同生同灭,下轮重试,不会出现"清了零但 marker 没落"或重复清 | 核实 `Store.Transaction` 为 clone-草稿语义后**接受**;代价:单个客户恢复出错会阻塞整轮 housekeeping,与既有 ip_limit 恢复同构 |
|
||||
| 4 | 跨界流量归属:重置时刻前后一个上报周期(≤60 秒)的增量整笔计入新周期 | 清零式重置的固有近似(3x-ui 同样),量级 ≤1 分钟流量,**接受并记录** |
|
||||
| 5 | PATCH 回显吞重置:前端每次编辑都回显该字段,若"提交即布防"会在到期窗口内吞掉待触发重置 | **已防**:仅值变化才重布防(§2),API 测试覆盖 |
|
||||
| 6 | PATCH 传 `trafficResetDay: null` 的语义 | 定为清除,与 `expiresAt` 的 null 清除口径一致 |
|
||||
| 7 | marker 被写成垃圾字符串(仅可能来自手工改库) | 按"到期"处理:清一次后写入合法键自愈收敛;前提是重置日本身合法,**接受** |
|
||||
| 8 | DST:00:00 在个别时区不存在(春跳日) | Go `time.Date` 自动顺延,周期键不受影响;生产时区 Asia/Shanghai 无 DST,**记录** |
|
||||
| 9 | 多名流量暂停客户同轮恢复 → 每人可能触发节点重建下发 | 与手动清零恢复同路径同量级,路由器客户规模小,**接受** |
|
||||
| 10 | 临时回滚到 Node 版当主控时重置停摆 | Node 不执行但不破坏字段;切回 Go 后按 marker 补判、不会重复清(幂等),**记录为运维须知** |
|
||||
| 11 | 补判延迟:启动后第一轮 housekeeping 在 ≤60 秒 | 满足"启动/下个检查点补触发";未加启动即时调用是为与 Node 节律保持差分对齐,**接受** |
|
||||
|
||||
自审未发现其它未处置的边界问题。
|
||||
|
||||
## 6. 与迁移的衔接点
|
||||
|
||||
- 转换器对 13 名按月重置客户**只需写 `trafficResetDay`(1–31)**,
|
||||
`trafficResetPeriod` 可不写:面板首轮自动布防、绝不误清迁移带入的期初用量,
|
||||
到下个重置日正常触发。若转换器想显式写 marker,写"转换时刻的当前周期键"即可
|
||||
(`TrafficResetPeriodKey(转换时刻, 重置日)`)。
|
||||
- 期初用量落位不变:`usedBytes = client_traffics 上+下`、方向两字段按原值 —
|
||||
其中属于重置客户的部分将在其首个重置日按新周期清零,符合 3x-ui 现行语义。
|
||||
|
||||
## 7. 未做 / 边界
|
||||
|
||||
- Node 版面板未实现重置执行逻辑(定版生产主控为 Go 版;Node 仅需字段无副作用,
|
||||
已满足)。如未来 Node 回主,需在 `server.js` housekeeping 补同语义实现。
|
||||
- 客户列表页未加"重置日"列(按最小配套要求只做了表单入口 + 上次重置显示)。
|
||||
- 本报告不含生产部署动作:新二进制构建/换装按既有流程另行执行。
|
||||
@@ -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 未改动、哈希不变。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 订阅聚合 · 小设计(待拍板)
|
||||
|
||||
> 目标:客户订阅在自有节点之外,可附加 **① 外部订阅源(面板定时拉取)② 手动节点链接**,合并成一份订阅输出,"一个订阅用全部入站"。
|
||||
> 现状锚点:订阅管线在 `lib/subscriptions.js`(Go:`panel-go/subscriptions.go`),入口 `formatSubscription / FormatSubscription`,条目为 `{nodeId, clientUri}`、名字挂在 URI 的 `#` 后,输出 raw/base64/clash/clash-smart/singbox/surge 五格式;Clash 等格式都经 `clashProxy` 从 URI 反解,**不支持的 scheme 会让整单生成抛错**——聚合的所有过滤必须在进管线前完成。
|
||||
|
||||
## 1. 数据模型(存进现有 JSON 库,跟随现状形态)
|
||||
|
||||
- 客户新增 `externalSources: [{id, url, remark, enabled, userAgent?}]`,上限 **5 个/客户**——防滥用且够用;字段风格与现有客户字段一致(camelCase,如 `trafficResetDay`)。
|
||||
- 客户新增 `manualLinks: [{id, uri, remark, enabled}]`,上限 **50 条/客户**;保存时即做 scheme 白名单与可解析校验,坏链接不许入库。
|
||||
- 拉取缓存放顶层对象 `subSourceCache: { [sourceId]: {fetchedAt, okAt, failCount, lastError, droppedCount, items:[{uri, name}]} }`,仿 `keyStore` 先例(对象形态、不入 COLLECTIONS 校验;Node 侧 `||= {}` 兜底、Go 侧 `Data` 加 map 字段)——缓存与客户配置分离,编辑客户不碰缓存,源被删时顺手清对应缓存键。
|
||||
|
||||
## 2. 拉取与缓存
|
||||
|
||||
- **刷新间隔**:全局设置 `settings.externalSubRefreshMinutes`,默认 **60 分钟**,可配 15–720;机场源普遍限频,60 分钟兼顾新鲜与不被风控。调度挂现有 **60s housekeeping**(Node `server.js housekeeping()` / Go `main.go housekeeping()`),按每源 `fetchedAt + 间隔` 判到期、加 ±10% 抖动防同刻齐发——与月重置同为"落盘标记判定、不靠内存计时器",面板重启不丢节奏。
|
||||
- **单次拉取上限**:超时 **10s**、响应体 **1 MiB**、解析后条目 **200 条/源**、重定向 ≤3 跳;超限即判失败走兜底。
|
||||
- **失败策略**:失败只累加 `failCount` 并退避(间隔 ×2ⁿ,上限 24h),**永远沿用上一次成功缓存输出**;`failCount ≥ 3` 时客户详情把该源标红提示。从未成功过的源输出为空、状态显示失败原因。**自有节点的生成路径与外源完全解耦**——外源拉取/解析出任何错,自有部分照常出(现状"无自有节点出 503"的判定改为"自有+手动+外源全空才 503")。
|
||||
- **触发入口**:新增/编辑源保存后立即异步拉一次;客户详情每源一个「立即刷新」按钮(`POST /api/customers/:id/external-sources/:sid/refresh`,同步返回条数/错误供回显);到期由 housekeeping 自动拉。housekeeping 内串行执行、每轮最多 3 个源,护路由器 CPU。
|
||||
- **缓存过期**:成功缓存**不设硬过期**——兜底优先于新鲜,源死了老节点继续发,总比订阅空掉强;新鲜度只由 `okAt` 在 UI 上展示。
|
||||
|
||||
## 3. 解析范围(首版只吃"链接列表")
|
||||
|
||||
- 输入形态按序判定:① 整体 base64 解码成功且解出含 scheme 的行 → 按行拆分;② 明文逐行(跳空行);③ 整个 body 就是一条链接(①②的自然子集)。覆盖 v2rayN 系与绝大多数机场的默认返回。
|
||||
- **协议白名单 = 现管线五种**:`vless://`、`ss://`、`hysteria2://`、`anytls://`、`socks5://`(正是 `buildClientUri` 产物、`clashProxy` 可反解的集合)。`vmess://`、`trojan://` 及未知 scheme **丢弃并计数**(`droppedCount` 落缓存、UI 可见"已忽略 N 条")——一条坏链接绝不能毁整单。
|
||||
- **Clash YAML / sing-box JSON 源首版不解析,放二期**:两者是完整配置语义,做 outbound→URI 逆向映射等于再造半个转换器,且与五格式输出管线重复;用下方 UA 策略拉到的基本都是链接列表,首版覆盖面已够。
|
||||
|
||||
## 4. 合并与输出
|
||||
|
||||
- **排序固定分段、不交错**:自有节点(按定版 `sortIndex` 序)→ 手动链接(列表序)→ 外部源(源添加序、源内保持原序)。自有是计费与可用性主体、排前面保证客户端默认落自有;分段固定使外源抖动不改变自有相对顺序,订阅内容 diff 稳定。
|
||||
- **命名**:外部项名 = `[源备注] 原名`(源备注为空则不加前缀);手动项名取备注 → URI 自带 `#名` → `手动节点 N` 兜底。合并后全局同名沿用现有后缀逻辑(`名 (2)`、Clash 侧 `(节点N)`),与自有重名处理完全一致。
|
||||
- **去重键**:`scheme + 主机(小写)+ 端口 + sha256(URI userinfo 原文) 前 16 位`;全局去重、优先级 自有 > 手动 > 外部——外部若与自有指向同一节点,保自有(凭据与流量统计都在自有侧)。
|
||||
- **流量口径不变**:`subscription-userinfo` 与额度判定只读自有三计数(`usedBytes` 等),外部/手动流量不计量、不参与暂停判定;按月重置只清自有计数,互不干扰。
|
||||
- **停用/暂停**:跟随现有订阅端点整单门禁(`handleSubscription` 已对非 active/过期/超额整单 403),**外部项不单开任何发放通道**。与"迁移无感"原则的关系:无感指凭据与端点不变,停用语义对客户只有一种——不出现"自有停了、外部还能用"的割裂状态;且刷新对暂停客户照常跑,恢复启用瞬间外源就是新鲜的。
|
||||
|
||||
## 5. 安全与边界
|
||||
|
||||
- **SSRF 默认禁内网**:拉取前解析主机名到 IP,命中环回/私网/链路本地段(含 169.254、::1、fc00::/8、fe80::/10)一律拒绝;重定向每一跳复检;连接时以校验过的 IP 直连并保留 Host/SNI(防 DNS rebinding)。面板在路由器上能摸到整个内网,这条是硬底线。设置 `settings.allowPrivateSubSources` 默认 **关**,用户有自托管订阅源时才手动打开。
|
||||
- 只接受 http/https、URL 长度 ≤ 2048;源 URL 在 UI、日志、activity 审计里一律脱敏(只显 scheme+host,query 打码,沿用现有 redact 思路);订阅 token 与源 URL 都不落明文日志。
|
||||
- **拉取 UA**:默认 `v2rayN/6.55`——机场对该 UA 普遍回 base64 链接列表,正中首版解析面;允许按源填 `userAgent` 覆盖。
|
||||
- 面板出站拉取走主控自身网络,不经任何节点转发;拉取结果只进缓存、不执行其中任何内容。
|
||||
|
||||
## 6. 双实现影响
|
||||
|
||||
- **Node**:新增 `lib/extsub.js`(拉取+解析+归一化);`lib/subscriptions.js` 在 `entries()` 后拼接聚合段;`server.js` housekeeping 挂到期扫描、客户 CRUD 校验新字段、加手动刷新端点。
|
||||
- **Go(生产主控,必须同步)**:新增 `panel-go/extsub.go` 对应物;`subscriptions.go` 的 `Entries/FormatSubscription` 同步拼接;`main.go` housekeeping 挂扫描;`handlers.go` 同步 API。字段同名同义、JSON 库双向兼容(两边都能读对方写的文件)。
|
||||
- **agent / agent-go:零改动**——聚合纯主控侧,agent 只管自有节点的部署与计数上报,协议不变。
|
||||
- 验收:`panel-go/difftest` 差分 E2E 增加聚合用例(同种子库 + 本地假外源 fixture,两实现五格式输出逐字节比对),过不了不算完。
|
||||
|
||||
## 7. 分期
|
||||
|
||||
- **首版(本设计全量)**:字段与缓存、链接列表解析、五格式合并输出、定时+手动刷新、失败兜底、SSRF 禁内网、双实现同步。
|
||||
- **二期**:Clash YAML / sing-box JSON 源解析;外部节点健康探测与失效自动剔除;按源建 Clash 分组;手动/外部与自有交错排序;按源流量估算展示(仅展示、不计费)。
|
||||
|
||||
---
|
||||
|
||||
**需拍板(默认即推荐值)**:刷新 60 分钟 / 内网源默认禁 / 外部项排自有之后 / 停用整单停发 / 首版不吃 YAML。
|
||||
@@ -0,0 +1,132 @@
|
||||
# NexusGate UI 全量套皮报告(NexusGate → amo)
|
||||
|
||||
- 日期:2026-10-21
|
||||
- 基线:`7c21742`(含按月重置,仅 panel-go 实现)
|
||||
- 范围:前端按 3x-ui 定版样张全量重写;后端 Node 参考实现与 panel-go 双实现对称新增节点排序、在线状态(presence)、近似速度、系统状态采样、客户凭据接口、订阅聚合管线、服务器启停门禁;agent / agent-go **零改动**;版本保持 `0.6.8`。
|
||||
- 结论:Node 测试 61/61、panel-go 测试全过、difftest 差分 E2E(selftest + Node vs Go 正式)各 65/65 全过;四页 + 客户表单三页签真机(Go 主控 + 种子库)截图自检通过;同种子同口径 RSS 新版未增(见 §7)。
|
||||
|
||||
## 1. 改动清单
|
||||
|
||||
### 1.1 前端(`public/`,双实现共用;panel-go 经 build.sh 同步进 embed)
|
||||
|
||||
- `index.html`:样张骨架(224px 侧边栏 + 顶栏 + 内容区);品牌纯文字 **amo**(无 logo/图标);`<title>amo</title>`;head 内联脚本按 localStorage `nexusgate-theme` 预置主题防闪烁;六个导航页:系统状态(原总览)、服务器、客户与订阅、转发与节点、安装与命令、设置与运维。
|
||||
- `styles.css`:四样张 CSS 合并;浅色默认 + 深色变量双套;字体为 3x-ui 同款系统字体栈(`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif`),**零 webfont/外链**;紧凑密度;弹窗/表单/页签/入站选择器/浮层/toast/登录/展开行样式齐备。
|
||||
- `app.js`:全量重写(保留 api/CSRF/toast/弹窗/45s 自动刷新/哈希路由骨架)。全站无说明性文字(page-intro 类已绝迹,grep 验证);原生 confirm/prompt 全部换成样式化弹窗;列表默认 50 条/页(客户/服务器/节点)。
|
||||
- 四页按定版列落地:
|
||||
- **系统状态**:四小卡(主控 CPU/内存/存储 + 今日全网流量,带迷你走势线;存储卡显示剩余)、整体速度双线图 + 三格统计(已发送/已接收/窗口均值)、连接数卡、底部信息条(运行时间:面板/系统|面板:内存/在线节点/客户|IP 地址 IPv4/IPv6)。
|
||||
- **服务器**:统计条(设备总数/在线/离线/平均延迟)+ 15 列(展开 +|复选|操作|已启用开关|名称|地址打码 + 表头 👁 显隐|状态|CPU|内存|Agent 版本 + 「有可用更新」徽标|核心版本|运行时长|客户端总数/在线数|延迟|上次心跳);展开行承载旧详情与全部旧动作。
|
||||
- **客户与订阅**:定版列(操作图标栏:二维码/详情/重置流量/编辑/红删除|已启用开关|在线药丸 + 已暂停红字|客户端名 + 备注两行|关联入站协议分色 chip + 「+N」弹层|流量药丸 + 迷你条|速度|剩余药丸|时长药丸)。
|
||||
- **转发与节点**:定版列(拖动手柄|复选|ID=排序序号|铅笔 + ⋮ 菜单|启用开关|备注|服务器 chip 三态色|端口|协议徽标组单行|客户端总数 + 绿色在线数 + 悬停名单浮层(挂 body)|流量药丸|速度药丸|到期时间药丸)。
|
||||
|
||||
### 1.2 后端 Node(`lib/`、`server.js`)
|
||||
|
||||
- 新增 `lib/presence.js`(在线/速度推导)、`lib/sysstat.js`(主控采样 + 环形历史 + 全局 IP 挑选)、`lib/extsub.js`(外部订阅拉取/解析/SSRF/退避/去重命名)、`lib/reset.js`(**按月重置从 panel-go 1:1 移植补齐 parity**,见 §5)。
|
||||
- `lib/orchestrator.js`:绑定数据层抽出 `assignCustomerData/unassignCustomerData` + 新增 `alignCustomerNodes`(表单全量对齐);部署校验加设备停用门禁;用量上报写 presence/速度(含节点 `lastSampleAt` 落盘,与 Go 对齐)。
|
||||
- `lib/subscriptions.js`:entries 按节点 sortIndex 排序;新增聚合管线 `aggregateEntries`(自有 → 手动 → 外部合并/命名/去重)。
|
||||
- `server.js`:`GET /api/system`、客户凭据 GET/regenerate、外部源手动刷新、节点 reorder 路由;客户 POST/PATCH 支持 `nodeIds` 全量对齐与聚合字段;housekeeping 接月重置 + 采样 + 到期源刷新;启动时 sortIndex 迁移。
|
||||
- `lib/store.js`:默认 `brandName` 改为 `amo`。
|
||||
|
||||
### 1.3 后端 Go(`panel-go/`,与 Node 逐项对称)
|
||||
|
||||
- 新增 `presence.go`、`sysstat.go`、`extsub.go`;`types.go` 增字段(`Server.Enabled`、`Node.SortIndex` + 速度/活跃指针字段、`UsageSlot` 速度字段、`Customer.ExternalSources/ManualLinks`、顶层 `SubSourceCache`、设置项);`orchestrator.go`(绑定对齐、停用门禁、用量写 presence/速度);`subscriptions.go`(sortIndex 排序 + 聚合);`handlers.go`(/api/system、凭据、刷新、reorder 路由与列表附加字段);`main.go`(sortIndex 迁移、housekeeping 采样/刷新);默认品牌名 `amo`。
|
||||
|
||||
### 1.4 测试
|
||||
|
||||
- 新增 `test/uireskin.test.js`(Node,9 个)与 `panel-go/uireskin_test.go`(Go:聚合解析、SSRF 拦截、排序/去重、presence 窗口等)。
|
||||
- difftest 扩展 phase-3(见 §6),`ID_PREFIXES` 补 `src`/`lnk`。
|
||||
|
||||
## 2. 旧功能对照表(逐项核对旧前端动作入口)
|
||||
|
||||
旧前端共 22 个 `data-action`,新前端 38 个。逐项结论:
|
||||
|
||||
| 旧功能 | 新入口 | 结论 |
|
||||
|---|---|---|
|
||||
| 新建/编辑/删除服务器、复制注册命令 | 服务器页操作列同名动作 | ✅ 保留 |
|
||||
| 服务器详情(资源余量、TLS 证书、引擎状态、流量统计状态、旧版 agent 警告、重试清理、遗忘离线、SSH 卸载 Agent) | 服务器展开行 | ✅ 保留(样张 15 列放不下的收进展开行) |
|
||||
| 新建/编辑/删除客户、启停、重置流量、重置令牌 | 客户页操作列 + 表单左下「重置流量」+ 订阅弹窗重置令牌 | ✅ 保留 |
|
||||
| 客户详情(IP 观测、使用限制、订阅访问日志、清空日志) | 操作列详情 ⓘ 弹窗 | ✅ 保留(列表不显示 IP/设备上限,详情与表单仍在) |
|
||||
| 订阅链接(9 种格式 + 信息页 + 各节点入站链接 + 二维码) | 订阅弹窗,顶部 auto 格式二维码 + 逐行复制/二维码 | ✅ 保留 |
|
||||
| 新建/编辑节点、部署、修复、删除 | 节点页菜单(铅笔/⋮) | ✅ 保留 |
|
||||
| 节点行「停用/启用」按钮 | **去除**(用户定版:与行首开关重复,只留开关) | ⛔ 按定版删除 |
|
||||
| 节点页「分配客户」表单 + 客户端弹窗内解绑 | **去除**;绑定写入口收敛到客户表单「关联入站」(用户定版,防两处不一致);`/assign`、`/unassign` API 保留不动;节点客户端弹窗改只读名单 + 可复制链接 | ⛔ 按定版收敛 |
|
||||
| `copy-uri` 复制动作 | 改名统一为 `copy-text`(订阅行、命令、入站链接同一处理器) | ✅ 改名保留 |
|
||||
| 安装与命令页全部命令复制 | 命令页换皮、删除每条命令的说明小字 | ✅ 保留 |
|
||||
| 设置与运维(改密、备份下载/恢复等) | 运维页换皮、删除说明段(备份卡保留一句凭据保管警告) | ✅ 保留 |
|
||||
| 旧前端死代码 `renderNodeLinks` | 未迁移(无入口调用) | ⛔ 死代码不迁移 |
|
||||
|
||||
## 3. 客户表单三页签(基本 / 凭据 / 链接)
|
||||
|
||||
- **基本**:名称、分组、流量上限 GB、快速到期 + 到期日期/时间、IP 限制、设备(HWID)限制、**流量重置成对控件**(「流量重置」下拉:不重置/每月 + 「每月重置日」数字输入并排;直接绑定既有 `trafficResetDay`,非「每月」时重置日输入禁用,截图与 DOM 实测验证)、标签、备注、启用开关(编辑态绑定状态)、左下「重置流量」(仅编辑态)。
|
||||
- **关联入站**(基本页签内):点击输入框弹下拉面板列全部节点(按 sortIndex 序、可滚动、已选高亮 + ✓、点击切换),右侧 🔍 按节点名搜索过滤;已选项以 chips 呈现可 × 单删;「全选 / 全部清除」按钮。保存 = 按勾选全量对齐 `nodeClients`(走现有部署/回收链路与凭据生成,不另起数据)。
|
||||
- 3x-ui 独有而模型没有的字段(自动续期/Telegram ID/反向标签等)**未加**。
|
||||
- **凭据**:订阅令牌行(复制 + ↻ 重置,沿用既有 rotate-subscription);每条节点绑定一行:节点名/协议/状态、UUID(`credentials.clientId`)、密码(`credentials.relayPassword`,SS/HY2/AnyTLS/SOCKS 共用)、Reality Short ID(`credentials.shortId`)、SOCKS 时用户名(`ng:<customerId>`);逐项只读 + 复制 + ↻ 重新生成(**二次确认**)。
|
||||
- **凭据对应关系(按代码模型点名)**:凭据挂在「客户 × 节点」绑定(nodeClients)上、每条绑定一套,不是客户级一套——同一客户在不同节点上的 UUID/密码/Short ID 各自独立;订阅令牌(subId)才是客户级的。新接口:`GET /api/customers/:id/credentials`、`POST /api/customers/:id/credentials/:ncid/regenerate`(重生成后对已部署节点自动 rebuildNode 下发;打审计 `regenerate_credentials`);生成规则沿用既有 `newNodeClientCredentials(protocol)`,未新造格式。
|
||||
- **链接**:顶部一句短文案(「添加第三方分享链接和远程订阅地址,将其包含在该客户的订阅中」);「+ 添加外部链接」+ 手动链接列表(空态「暂无外部链接」;每条:链接本体 + 备注 + 启用开关 + 删除);「+ 添加外部订阅」+ 订阅源列表(空态「暂无外部订阅」;每条:地址 + 备注 + 可选 UA + 启用开关 + 删除 + 状态行(条数/上次成功/失败原因/已忽略 N 条)+「立即刷新」)。
|
||||
|
||||
## 4. 订阅聚合(按 SUB-AGGREGATION-DESIGN.md 全文)
|
||||
|
||||
- 客户新增 `externalSources`(≤5)与 `manualLinks`(≤50);拉取缓存独立顶层 `subSourceCache`(与客户配置分离,删源/删客户顺手清键)。
|
||||
- 拉取:超时 10s、体上限 1MiB、每源 ≤200 条、重定向 ≤3 跳逐跳复检;SSRF:解析全部地址、任一非全局即拒绝(`settings.allowPrivateSubSources` 默认关时才放行内网)、连接钉死校验过的 IP 并保留 Host/SNI;UA 默认 `v2rayN/6.55` 可按源覆盖;解析只吃链接列表(整体 base64 或明文逐行),协议白名单 `vless/ss/hysteria2/anytls/socks5`,其余丢弃并计数 `droppedCount`。
|
||||
- 刷新:全局间隔 `settings.externalSubRefreshMinutes` 默认 60(钳 15–720);失败指数退避 + 确定性 jitter(双实现一致);housekeeping 每轮串行最多 3 个到期源;保存新增/改 URL 后台立即拉一次;手动刷新端点同步返回结果;**失败永远用上次成功缓存兜底**。
|
||||
- 合并:顺序自有(sortIndex)→ 手动(列表序)→ 外部(源序/缓存序);外部项命名加 `[源备注]` 前缀,手动项取备注 → URI 自带名 → 「手动节点 N」;跨段重名追加 ` (2)`;去重键 = 协议 + 主机(小写)+ 端口 + 凭据指纹(解码后 userinfo 的 sha256 前 16 hex),自有优先;外部项不计量;客户停用仍整单 403;**自有-only 客户输出与改造前逐字节一致**(difftest 金标准守)。
|
||||
- 注:面板没有 `/api/settings` 路由(双实现都没有,旧前端也无此调用),`externalSubRefreshMinutes`/`allowPrivateSubSources` 只能改数据文件——属既有形态,本轮未新造设置 UI。
|
||||
|
||||
## 5. 口径取舍专节(被迫近似与信号缺口,逐项点名)
|
||||
|
||||
1. **presence 粒度近似(B2)**:agent 用量样本只有 `{kind:'user', id:customerId}`(按服务器聚合,无节点线索)与 `{kind:'inbound', id:nodeId}`;observations 只有 `{customerId, ip}`。故客户×节点在线的推导为:客户在其绑定节点的服务器上有新鲜活跃槽(180s 窗口)⇒ 该客户在该服务器的**全部**绑定节点上同时点亮。同机多节点无法区分——这是上报粒度的上限,agent 协议未动。`customer.lastOnlineAt` 此前双实现从未写入,本轮在用量 delta>0 与 observations 接受时补写。
|
||||
2. **速度为近似值(B3)**:相邻两次已处理样本 delta/时间差(dt≥5s 才更新,epoch 变更跳过),有效期 150s;无历史显「—」。difftest 里速度值依赖墙钟,只比形状(正/零)不比数值。
|
||||
3. **连接数替代呈现(B4)**:agent 上报**没有连接数字段**,未编造通道——系统状态「连接数」卡与 /api/system 的 `connections` 用**在线客户数**替代呈现,前端卡内如实标注「在线客户」及在线节点/客户明细。「今日流量」基线为纯内存(面板启动时刻的节点累计量),面板重启后从启动时重新累计;历史环形缓冲 240 条纯内存(主控指标 + 全网速度/连接数),量级百 KB 内。
|
||||
4. **服务器延迟无数据源**:agent 心跳不上报延迟——服务器页统计条与延迟列恒显「—」,未编造。
|
||||
5. **核心版本(sing-box 版本)无数据源**:engine 上报只有 `singBoxInstalled` 布尔——核心版本列恒显「—」。CPU% 由 load1/cpuCount 换算,内存% 由 memoryTotal/memoryAvailable 算。
|
||||
6. **节点到期时间恒 ∞**:节点模型无到期字段,到期时间药丸恒显 ∞,未造字段。
|
||||
7. **IP 地址口径(B5)**:从主机网卡挑全局地址(排除 RFC1918/CGNAT/链路本地/ULA/回环/组播),取不到显「—」;不调用任何外部 IP 查询服务。沙箱实测显示 198.19.0.2(VM 出口的基准测试段地址,属网卡地址列表口径内;真实硬件上为实际网卡全局地址)。
|
||||
8. **系统状态页活动区**:样张底部无活动区;为保「旧功能零丢失」,在信息条下方补回异常节点提示条(有才显)+ 最近活动面板——相对样张的唯一版式增补,特此点名。
|
||||
9. **存储卡走势线**:无历史数据源时以当前占用率画平线(真实采样累积后即为真实曲线)。
|
||||
10. **服务器 enabled(新实现)**:server 加 `enabled`(默认 true):停用后禁止其上节点部署(409「设备 X 已停用」)、reconcile 跳过该设备;与状态(在线/离线)正交。
|
||||
11. **按月重置 Node parity 补齐**:该功能原只有 panel-go 实现(7c21742);本轮按 reset.go 语义 1:1 移植到 Node 并接进 create/PATCH/housekeeping——这是 parity 补齐,不是行为变更,Go 侧行为不变。
|
||||
12. **种⼦环境假象(验收时确认非缺陷)**:种子库节点在面板重启后被既有 reconcile 置为 deploying(无真实 agent 轮询完成),故截图里「在线节点」按 active 计数为 0、节点开关呈启用态——与生产(agent 数秒内轮询完成)表现不同,非套皮问题。
|
||||
|
||||
## 6. 改名清单(NexusGate → amo,仅展示层)
|
||||
|
||||
改了的可见点:
|
||||
- 侧边栏品牌、登录页品牌:纯文字「amo」加粗,无 logo。
|
||||
- 页面 `<title>amo</title>`、顶栏面包屑前缀、订阅输出可见名相关默认值。
|
||||
- Node 与 Go 的默认设置 `brandName`:`NexusGate` → `amo`(新库默认;`brandName` 当前无渲染消费点,老库里的存量值不会出现在任何用户可见输出中)。
|
||||
- 面板对外文案中的产品名(前端静态文案)。
|
||||
|
||||
刻意保留的内部标识(未改):
|
||||
- 仓库目录、二进制名(`nexusgate-panel` 等)、数据路径(`/etc/nexusgate`、`nexusgate.json`)、环境变量(`NG_*`)、agent 协议与字段名。
|
||||
- 安装/卸载命令里的仓库 URL(`git.881218.xyz/rogue/NexusGate`)。
|
||||
- 启动日志行与数据文件损坏错误文案中的 "NexusGate"(运维耦合的日志/错误前缀,按定版保留)。
|
||||
|
||||
## 7. 测试与验收证据
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| Node 测试 `npm test` | **61/61 全过**(含新增 uireskin 9 个) |
|
||||
| Go 测试 `go test ./...` | **全过**(含新增 uireskin_test.go) |
|
||||
| difftest selftest(Node vs Node) | **65/65** |
|
||||
| difftest 正式(Node vs Go 二进制) | **65/65** |
|
||||
| 真跑截图自检 | Go 主控 + 混合种子库(3 服务器/6 客户/3 节点/6 绑定,含在线/离线/额度暂停/手动停用/过期/聚合客户),四页 + 表单三页签 + 深色抽查,见下 |
|
||||
| RSS 对比 | 同一种子、同样预热请求、无 GOMEMLIMIT 的同口径:**基线(7c21742)VmRSS 45,528–45,596 kB;新版 43,164–43,224 kB**——新版未增(差值在 Go 运行时波动量级内);新增历史环形缓冲纯内存 240 条封顶 |
|
||||
|
||||
difftest phase-3 新增覆盖(双实现逐项比对):节点 reorder 后列表序与订阅输出序、客户 PATCH `nodeIds` 全量对齐(加/清绑定)、凭据 GET 形状与 regenerate 后凭据变化、两段用量上报后的 presence 投影(在线布尔/速度正性)与速度字段形状归一、`/api/system` 键结构形状、聚合全链路(本地 fixture 源:手动刷新返回 count=2/dropped=1、externalStatus 状态、Dave 全格式订阅逐项比对)。时间依赖值(速度数值、/api/system 数值)按设计只比形状——Normalizer 对 `speedUpBps/speedDownBps` 做正/零归一并注明原因。既有约束保持:Alice 订阅与 seed 金标准逐字节比对、DB 互换(swap)后互通、phase-2 全部步骤不变。
|
||||
|
||||
截图(浅色默认,深色抽查客户页):
|
||||
- `~/workspace/nexusgate-ui-reskin-shots/01-overview-light.png` 系统状态
|
||||
- `~/workspace/nexusgate-ui-reskin-shots/02-servers-light.png` 服务器
|
||||
- `~/workspace/nexusgate-ui-reskin-shots/03-customers-light.png` 客户与订阅(在线/离线/额度暂停/手动停用/已过期五态齐全;无备注客户第二行留空、无分组回退)
|
||||
- `~/workspace/nexusgate-ui-reskin-shots/04-nodes-light.png` 转发与节点
|
||||
- `~/workspace/nexusgate-ui-reskin-shots/05-customer-form-basic.png` 客户表单·基本(流量重置成对控件、关联入站 chips、左下重置流量)
|
||||
- `~/workspace/nexusgate-ui-reskin-shots/06-customer-form-credentials.png` 客户表单·凭据(Dave:令牌 + N1 绑定 UUID/密码/Short ID)
|
||||
- `~/workspace/nexusgate-ui-reskin-shots/07-customer-form-links.png` 客户表单·链接(手动链接 + 外部源 + 状态行 + 立即刷新)
|
||||
- `~/workspace/nexusgate-ui-reskin-shots/08-customers-dark.png` 客户页深色
|
||||
|
||||
自检中发现并处置:① difftest interop 暴露 Node 侧节点 `lastSampleAt` 未落盘(速度计算借道 lastUsageAt)——已按设计对齐落盘并复跑全绿;② 截图种子一度用了模型不存在的 `disabled` 状态——模型手动停用为 `suspended+manual`,前端推导与后端一致,种子已修正(非产品缺陷)。
|
||||
|
||||
## 8. 提交
|
||||
|
||||
本地提交(不 push):
|
||||
1. `feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity)`(含 SUB-AGGREGATION-DESIGN.md、difftest phase-3)
|
||||
2. `feat(web): 3x-ui style reskin — amo branding, light/dark themes, four pages + tabbed customer form`
|
||||
3. `docs: UI reskin report`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
'use strict';
|
||||
|
||||
// External subscription aggregation (panel-go/extsub.go mirrors this
|
||||
// file; design: SUB-AGGREGATION-DESIGN.md). The panel fetches a
|
||||
// customer's external subscription sources, parses link lists, caches
|
||||
// the result in data.subSourceCache and merges manual + external items
|
||||
// into the subscription output behind the customer's own nodes.
|
||||
//
|
||||
// Safety: http/https only, SSRF guard rejects non-global IPs unless
|
||||
// settings.allowPrivateSubSources is on, every redirect hop is
|
||||
// re-validated, the connection is pinned to the validated IP (no DNS
|
||||
// rebinding window), 10s timeout, 1MiB body cap, 200 items per source.
|
||||
|
||||
const http = require('node:http');
|
||||
const https = require('node:https');
|
||||
const dns = require('node:dns/promises');
|
||||
const net = require('node:net');
|
||||
const crypto = require('node:crypto');
|
||||
const { isGlobalIPv4, isGlobalIPv6 } = require('./sysstat');
|
||||
|
||||
const LINK_SCHEMES = ['vless:', 'ss:', 'hysteria2:', 'anytls:', 'socks5:'];
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
const MAX_ITEMS = 200;
|
||||
const FETCH_TIMEOUT_MS = 10000;
|
||||
const MAX_REDIRECTS = 3;
|
||||
const DEFAULT_UA = 'v2rayN/6.55';
|
||||
|
||||
function isGlobalIP(ip) {
|
||||
const family = net.isIP(ip);
|
||||
if (family === 4) return isGlobalIPv4(ip);
|
||||
if (family === 6) return isGlobalIPv6(ip.replace(/^\[|\]$/g, ''));
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateLinkUri(uri) {
|
||||
if (typeof uri !== 'string' || !uri || uri.length > 2048) return false;
|
||||
let url;
|
||||
try { url = new URL(uri); } catch { return false; }
|
||||
return LINK_SCHEMES.includes(url.protocol) && Boolean(url.hostname);
|
||||
}
|
||||
|
||||
// Parse a fetched body into a normalized link list. Accepts a base64
|
||||
// blob or plaintext lines; unsupported schemes and unparseable lines are
|
||||
// dropped and counted, never fatal.
|
||||
function parseLinkList(body) {
|
||||
let text = String(body == null ? '' : body);
|
||||
const compact = text.replace(/\s+/g, '');
|
||||
if (compact.length >= 16 && /^[A-Za-z0-9+/=_-]+$/.test(compact)) {
|
||||
try {
|
||||
const normalized = compact.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const decoded = Buffer.from(normalized, 'base64').toString('utf8');
|
||||
if (decoded.includes('://')) text = decoded;
|
||||
} catch { /* keep plaintext */ }
|
||||
}
|
||||
const items = [];
|
||||
let dropped = 0;
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (items.length >= MAX_ITEMS) { dropped += 1; continue; }
|
||||
if (!validateLinkUri(line)) { dropped += 1; continue; }
|
||||
let name = '';
|
||||
const hash = line.lastIndexOf('#');
|
||||
if (hash >= 0) {
|
||||
try { name = decodeURIComponent(line.slice(hash + 1)); } catch { name = line.slice(hash + 1); }
|
||||
}
|
||||
items.push({ uri: line, name: name.slice(0, 120) });
|
||||
}
|
||||
return { items, dropped };
|
||||
}
|
||||
|
||||
async function resolveChecked(hostname, allowPrivate) {
|
||||
let addresses;
|
||||
if (net.isIP(hostname)) {
|
||||
addresses = [hostname];
|
||||
} else {
|
||||
const records = await dns.lookup(hostname, { all: true });
|
||||
addresses = records.map((record) => record.address);
|
||||
}
|
||||
if (!addresses.length) throw new Error('域名解析无结果');
|
||||
if (!allowPrivate && addresses.some((ip) => !isGlobalIP(ip))) {
|
||||
throw new Error('订阅源指向非公网地址,已拒绝');
|
||||
}
|
||||
return addresses[0];
|
||||
}
|
||||
|
||||
function fetchOnce(urlString, { userAgent, allowPrivate, deadline }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
(async () => {
|
||||
const url = new URL(urlString);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('仅支持 http/https 订阅源');
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, '');
|
||||
const ip = await resolveChecked(hostname, allowPrivate);
|
||||
const isTls = url.protocol === 'https:';
|
||||
const port = Number(url.port || (isTls ? 443 : 80));
|
||||
const hostHeader = url.port ? `${hostname}:${url.port}` : hostname;
|
||||
const options = {
|
||||
hostname: ip, port, path: `${url.pathname}${url.search}`, method: 'GET',
|
||||
headers: { host: hostHeader, 'user-agent': userAgent || DEFAULT_UA, accept: '*/*' },
|
||||
timeout: Math.max(1000, deadline - Date.now()),
|
||||
};
|
||||
if (isTls) options.servername = hostname;
|
||||
const transport = isTls ? https : http;
|
||||
const req = transport.request(options, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
resolve({ redirect: new URL(res.headers.location, urlString).toString() });
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
reject(new Error(`订阅源返回 HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
res.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
req.destroy(new Error('订阅源响应超过 1MiB'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => resolve({ body: Buffer.concat(chunks).toString('utf8') }));
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('timeout', () => req.destroy(new Error('订阅源请求超时')));
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
})().catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSourceBody(source, settings) {
|
||||
const allowPrivate = Boolean(settings && settings.allowPrivateSubSources);
|
||||
const deadline = Date.now() + FETCH_TIMEOUT_MS;
|
||||
let current = source.url;
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
||||
if (Date.now() > deadline) throw new Error('订阅源请求超时');
|
||||
const result = await fetchOnce(current, { userAgent: source.userAgent, allowPrivate, deadline });
|
||||
if (!result.redirect) return result.body;
|
||||
if (hop === MAX_REDIRECTS) throw new Error('订阅源重定向次数过多');
|
||||
current = result.redirect;
|
||||
}
|
||||
throw new Error('订阅源请求失败');
|
||||
}
|
||||
|
||||
// Fetch + parse one source. Never throws: returns { ok, items, dropped, error }.
|
||||
async function fetchExternalSource(source, settings) {
|
||||
try {
|
||||
const body = await fetchSourceBody(source, settings || {});
|
||||
const { items, dropped } = parseLinkList(body);
|
||||
return { ok: true, items, dropped, error: null };
|
||||
} catch (error) {
|
||||
return { ok: false, items: [], dropped: 0, error: String(error && error.message || error).slice(0, 240) };
|
||||
}
|
||||
}
|
||||
|
||||
function refreshMinutes(settings) {
|
||||
const value = Number(settings && settings.externalSubRefreshMinutes);
|
||||
if (!Number.isFinite(value) || value <= 0) return 60;
|
||||
return Math.min(720, Math.max(15, value));
|
||||
}
|
||||
|
||||
// Deterministic ±10% jitter from the source id so both implementations
|
||||
// and restarts agree, and sources do not all fire in the same pass.
|
||||
function jitterFactor(sourceId) {
|
||||
const hash = crypto.createHash('sha256').update(String(sourceId)).digest();
|
||||
return 1 + ((hash[0] % 21) - 10) / 100;
|
||||
}
|
||||
|
||||
// Collect sources due for a refresh pass: [{ customerId, source }].
|
||||
function dueSources(data, nowMs, limit = 3) {
|
||||
const out = [];
|
||||
const cache = data.subSourceCache || {};
|
||||
for (const customer of data.customers) {
|
||||
for (const source of customer.externalSources || []) {
|
||||
if (!source.enabled) continue;
|
||||
const entry = cache[source.id];
|
||||
if (!entry || !entry.fetchedAt) { out.push({ customerId: customer.id, source }); continue; }
|
||||
const base = refreshMinutes(data.settings) * 60000;
|
||||
const backoff = Math.min(Math.pow(2, Math.min(entry.failCount || 0, 10)) * base, 24 * 3600 * 1000);
|
||||
const due = Date.parse(entry.fetchedAt) + backoff * jitterFactor(source.id);
|
||||
if (nowMs >= due) out.push({ customerId: customer.id, source });
|
||||
if (out.length >= limit) return out;
|
||||
}
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out.slice(0, limit);
|
||||
}
|
||||
|
||||
// Apply one fetch result to the cache inside a store transaction draft.
|
||||
function applyFetchResult(data, sourceId, result, nowIso) {
|
||||
data.subSourceCache ||= {};
|
||||
const previous = data.subSourceCache[sourceId] || { fetchedAt: null, okAt: null, failCount: 0, lastError: null, droppedCount: 0, items: [] };
|
||||
const entry = { ...previous, fetchedAt: nowIso };
|
||||
if (result.ok) {
|
||||
entry.okAt = nowIso;
|
||||
entry.failCount = 0;
|
||||
entry.lastError = null;
|
||||
entry.droppedCount = result.dropped;
|
||||
entry.items = result.items;
|
||||
} else {
|
||||
entry.failCount = (previous.failCount || 0) + 1;
|
||||
entry.lastError = result.error;
|
||||
}
|
||||
data.subSourceCache[sourceId] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ---------- save-time normalization (shared by create/PATCH) ----------
|
||||
|
||||
function cleanTextLocal(value, max) {
|
||||
return String(value == null ? '' : value).trim().slice(0, max);
|
||||
}
|
||||
|
||||
function normalizeExternalSources(value, idFn) {
|
||||
if (value == null) return undefined;
|
||||
if (!Array.isArray(value)) { const e = new Error('外部订阅格式无效'); e.statusCode = 400; throw e; }
|
||||
if (value.length > 5) { const e = new Error('每个客户最多 5 个外部订阅'); e.statusCode = 400; throw e; }
|
||||
return value.map((raw) => {
|
||||
const item = raw && typeof raw === 'object' ? raw : {};
|
||||
const url = cleanTextLocal(item.url, 2048);
|
||||
let parsed;
|
||||
try { parsed = new URL(url); } catch { parsed = null; }
|
||||
if (!parsed || !['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
|
||||
const e = new Error('外部订阅地址无效'); e.statusCode = 400; throw e;
|
||||
}
|
||||
const next = {
|
||||
id: typeof item.id === 'string' && item.id ? item.id.slice(0, 80) : idFn('src'),
|
||||
url, remark: cleanTextLocal(item.remark, 80), enabled: item.enabled !== false,
|
||||
};
|
||||
const ua = cleanTextLocal(item.userAgent, 120);
|
||||
if (ua) next.userAgent = ua;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeManualLinks(value, idFn) {
|
||||
if (value == null) return undefined;
|
||||
if (!Array.isArray(value)) { const e = new Error('外部链接格式无效'); e.statusCode = 400; throw e; }
|
||||
if (value.length > 50) { const e = new Error('每个客户最多 50 条外部链接'); e.statusCode = 400; throw e; }
|
||||
return value.map((raw) => {
|
||||
const item = raw && typeof raw === 'object' ? raw : {};
|
||||
const uri = cleanTextLocal(item.uri, 2048);
|
||||
if (!validateLinkUri(uri)) { const e = new Error('外部链接无效或协议不受支持'); e.statusCode = 400; throw e; }
|
||||
return {
|
||||
id: typeof item.id === 'string' && item.id ? item.id.slice(0, 80) : idFn('lnk'),
|
||||
uri, remark: cleanTextLocal(item.remark, 80), enabled: item.enabled !== false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- merge helpers (used by lib/subscriptions.js) ----------
|
||||
|
||||
function dedupKey(uri) {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
let userinfo = '';
|
||||
try { userinfo = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`; }
|
||||
catch { userinfo = `${url.username}:${url.password}`; }
|
||||
const hash = crypto.createHash('sha256').update(userinfo).digest('hex').slice(0, 16);
|
||||
return `${url.protocol}//${url.hostname.toLowerCase()}:${url.port}#${hash}`;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function withName(uri, name) {
|
||||
const separator = uri.lastIndexOf('#');
|
||||
const base = separator >= 0 ? uri.slice(0, separator) : uri;
|
||||
return `${base}#${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
function nameFromUri(uri) {
|
||||
const hash = uri.lastIndexOf('#');
|
||||
if (hash < 0) return '';
|
||||
try { return decodeURIComponent(uri.slice(hash + 1)); } catch { return uri.slice(hash + 1); }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LINK_SCHEMES, DEFAULT_UA, validateLinkUri, parseLinkList, fetchExternalSource,
|
||||
refreshMinutes, dueSources, applyFetchResult, normalizeExternalSources, normalizeManualLinks,
|
||||
dedupKey, withName, nameFromUri,
|
||||
};
|
||||
+125
-39
@@ -240,6 +240,9 @@ function validateNodeDeploy(data, node) {
|
||||
else validateProtocolPair(node.protocol, node.exitProtocol);
|
||||
const server = assertFound(data.servers.find((s) => s.id === node.serverId), '服务器');
|
||||
const exitServer = direct ? null : assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器');
|
||||
for (const target of direct ? [server] : [server, exitServer]) {
|
||||
if (target.enabled === false) { const error = new Error(`设备 ${target.name} 已停用`); error.statusCode = 409; throw error; }
|
||||
}
|
||||
if (!activeNodeClients(data, node).length) { const error = new Error('请先给节点分配至少一个客户再部署'); error.statusCode = 400; throw error; }
|
||||
if (['vless-ws-tls', 'hysteria2', 'anytls'].includes(node.protocol)) {
|
||||
if (!server.tlsDomain) { const error = new Error(`${node.protocol} 需要先填写设备 TLS 域名并在节点上安装证书`); error.statusCode = 400; throw error; }
|
||||
@@ -281,6 +284,8 @@ function validateNodeDeploy(data, node) {
|
||||
// gates on "no in-flight jobs" instead and passes failInFlight: false.
|
||||
async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null, failInFlight = true } = {}) {
|
||||
const server = assertFound(data.servers.find((item) => item.id === serverId), '服务器');
|
||||
// A disabled device receives no new deployments, including reconciles.
|
||||
if (server.enabled === false) return 0;
|
||||
if (failInFlight) {
|
||||
for (const job of data.jobs.filter((item) => item.serverId === serverId && ['queued', 'running'].includes(item.status))) {
|
||||
job.status = 'failed'; job.error = 'Agent 重新注册,旧任务已由对账任务替代'; job.updatedAt = nowIso();
|
||||
@@ -314,6 +319,90 @@ async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null,
|
||||
return count;
|
||||
}
|
||||
|
||||
// Data-level assign/unassign (the transaction-free cores of the
|
||||
// Orchestrator methods) so the customer form's node alignment can run
|
||||
// inside the customer create/PATCH transaction.
|
||||
async function assignCustomerData(data, nodeId, customerId, actor = 'admin') {
|
||||
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
|
||||
const customer = assertFound(data.customers.find((c) => c.id === customerId), '客户');
|
||||
if (customer.status !== 'active') { const error = new Error('只能给状态正常的客户分配节点'); error.statusCode = 409; throw error; }
|
||||
let nc = data.nodeClients.find((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
|
||||
if (nc && nc.status === 'active') { const error = new Error('该客户已分配到此节点'); error.statusCode = 409; throw error; }
|
||||
if (nc) {
|
||||
nc.status = 'active'; nc.updatedAt = nowIso();
|
||||
} else {
|
||||
nc = {
|
||||
id: id('ncl'), nodeId, customerId, credentials: newNodeClientCredentials(node.protocol),
|
||||
status: 'active', createdAt: nowIso(), updatedAt: nowIso(),
|
||||
};
|
||||
data.nodeClients.push(nc);
|
||||
}
|
||||
// A manually undeployed node stays draft until explicitly deployed.
|
||||
// Automatic teardowns (suspend) are revived when clients return.
|
||||
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) {
|
||||
// record the assignment only
|
||||
} else {
|
||||
if (node.status === 'removing' && node.autoRemoving) {
|
||||
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
|
||||
node.autoRemoving = false;
|
||||
}
|
||||
await rebuildNode(data, node);
|
||||
}
|
||||
audit(data, actor, 'assign_customer', node.id, { customerId, customer: customer.name });
|
||||
return structuredClone(nc);
|
||||
}
|
||||
|
||||
async function unassignCustomerData(data, nodeId, customerId, actor = 'admin') {
|
||||
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
|
||||
const index = data.nodeClients.findIndex((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
|
||||
if (index < 0) { const error = new Error('该客户未分配到此节点'); error.statusCode = 404; throw error; }
|
||||
data.nodeClients.splice(index, 1);
|
||||
if (node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node);
|
||||
audit(data, actor, 'unassign_customer', node.id, { customerId });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Full-set alignment used by the customer form's 关联入站 multi-select:
|
||||
// bindings not in desiredIds are removed, missing ones are created.
|
||||
// Suspended customers get suspended bindings (mirroring their resource
|
||||
// state) without touching node resources.
|
||||
async function alignCustomerNodes(data, customer, desiredIds, actor = 'admin') {
|
||||
const desired = [...new Set((desiredIds || []).map(String).filter(Boolean))];
|
||||
for (const nodeId of desired) {
|
||||
const node = data.nodes.find((n) => n.id === nodeId && !n.pendingDelete);
|
||||
if (!node) { const error = new Error('节点不存在'); error.statusCode = 400; throw error; }
|
||||
}
|
||||
const current = data.nodeClients.filter((nc) => nc.customerId === customer.id);
|
||||
const rebuild = new Set();
|
||||
for (const nc of [...current]) {
|
||||
if (desired.includes(nc.nodeId)) continue;
|
||||
data.nodeClients.splice(data.nodeClients.indexOf(nc), 1);
|
||||
audit(data, actor, 'unassign_customer', nc.nodeId, { customerId: customer.id });
|
||||
rebuild.add(nc.nodeId);
|
||||
}
|
||||
for (const nodeId of desired) {
|
||||
if (current.some((nc) => nc.nodeId === nodeId)) continue;
|
||||
const node = data.nodes.find((n) => n.id === nodeId);
|
||||
const nc = {
|
||||
id: id('ncl'), nodeId, customerId: customer.id, credentials: newNodeClientCredentials(node.protocol),
|
||||
status: customer.status === 'active' ? 'active' : 'suspended', createdAt: nowIso(), updatedAt: nowIso(),
|
||||
};
|
||||
data.nodeClients.push(nc);
|
||||
audit(data, actor, 'assign_customer', node.id, { customerId: customer.id, customer: customer.name });
|
||||
if (nc.status === 'active') rebuild.add(nodeId);
|
||||
}
|
||||
for (const nodeId of rebuild) {
|
||||
const node = data.nodes.find((n) => n.id === nodeId);
|
||||
if (!node) continue;
|
||||
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) continue;
|
||||
if (node.status === 'removing' && node.autoRemoving) {
|
||||
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
|
||||
node.autoRemoving = false;
|
||||
}
|
||||
await rebuildNode(data, node);
|
||||
}
|
||||
}
|
||||
|
||||
class Orchestrator {
|
||||
constructor(store) {
|
||||
this.store = store;
|
||||
@@ -365,47 +454,11 @@ class Orchestrator {
|
||||
}
|
||||
|
||||
async assignCustomer(nodeId, customerId, actor = 'admin') {
|
||||
return this.store.transaction(async (data) => {
|
||||
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
|
||||
const customer = assertFound(data.customers.find((c) => c.id === customerId), '客户');
|
||||
if (customer.status !== 'active') { const error = new Error('只能给状态正常的客户分配节点'); error.statusCode = 409; throw error; }
|
||||
let nc = data.nodeClients.find((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
|
||||
if (nc && nc.status === 'active') { const error = new Error('该客户已分配到此节点'); error.statusCode = 409; throw error; }
|
||||
if (nc) {
|
||||
nc.status = 'active'; nc.updatedAt = nowIso();
|
||||
} else {
|
||||
nc = {
|
||||
id: id('ncl'), nodeId, customerId, credentials: newNodeClientCredentials(node.protocol),
|
||||
status: 'active', createdAt: nowIso(), updatedAt: nowIso(),
|
||||
};
|
||||
data.nodeClients.push(nc);
|
||||
}
|
||||
// A manually undeployed node stays draft until explicitly deployed.
|
||||
// Automatic teardowns (suspend) are revived when clients return.
|
||||
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) {
|
||||
// record the assignment only
|
||||
} else {
|
||||
if (node.status === 'removing' && node.autoRemoving) {
|
||||
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
|
||||
node.autoRemoving = false;
|
||||
}
|
||||
await rebuildNode(data, node);
|
||||
}
|
||||
audit(data, actor, 'assign_customer', node.id, { customerId, customer: customer.name });
|
||||
return structuredClone(nc);
|
||||
});
|
||||
return this.store.transaction(async (data) => assignCustomerData(data, nodeId, customerId, actor));
|
||||
}
|
||||
|
||||
async unassignCustomer(nodeId, customerId, actor = 'admin') {
|
||||
return this.store.transaction(async (data) => {
|
||||
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
|
||||
const index = data.nodeClients.findIndex((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
|
||||
if (index < 0) { const error = new Error('该客户未分配到此节点'); error.statusCode = 404; throw error; }
|
||||
data.nodeClients.splice(index, 1);
|
||||
if (node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node);
|
||||
audit(data, actor, 'unassign_customer', node.id, { customerId });
|
||||
return true;
|
||||
});
|
||||
return this.store.transaction(async (data) => unassignCustomerData(data, nodeId, customerId, actor));
|
||||
}
|
||||
|
||||
async reconcileServer(serverId, actor = 'system', options = {}) {
|
||||
@@ -516,8 +569,27 @@ class Orchestrator {
|
||||
const key = `srv:${serverId}`;
|
||||
customer.serverUsage = customer.serverUsage || {};
|
||||
const slot = customer.serverUsage[key] || (customer.serverUsage[key] = {});
|
||||
const prevCounters = slot.usageCounters;
|
||||
const prevSampleAt = slot.lastSampleAt;
|
||||
const delta = applyDelta(slot, uplink, downlink, sampleEpoch);
|
||||
if (!delta) continue;
|
||||
slot.lastSampleAt = reportedAt;
|
||||
// Presence + approximate speed from the same delta (no extra
|
||||
// sampling channel): a positive delta is activity; speed is the
|
||||
// delta over the gap since the previous processed sample.
|
||||
const restarted = Boolean(sampleEpoch && prevCounters && prevCounters.epoch && sampleEpoch !== prevCounters.epoch);
|
||||
if (delta.upDelta + delta.downDelta > 0) {
|
||||
customer.lastOnlineAt = reportedAt;
|
||||
slot.lastActiveAt = reportedAt;
|
||||
}
|
||||
if (prevSampleAt && !restarted) {
|
||||
const dt = (Date.parse(reportedAt) - Date.parse(prevSampleAt)) / 1000;
|
||||
if (dt >= 5) {
|
||||
slot.speedUpBps = Math.round(delta.upDelta / dt);
|
||||
slot.speedDownBps = Math.round(delta.downDelta / dt);
|
||||
slot.speedAt = reportedAt;
|
||||
}
|
||||
}
|
||||
customer.usedUplinkBytes = (customer.usedUplinkBytes || 0) + delta.upDelta;
|
||||
customer.usedDownlinkBytes = (customer.usedDownlinkBytes || 0) + delta.downDelta;
|
||||
customer.usedBytes = (customer.usedBytes || 0) + delta.upDelta + delta.downDelta;
|
||||
@@ -528,8 +600,21 @@ class Orchestrator {
|
||||
// transport and is never double counted (same rule as before).
|
||||
const node = data.nodes.find((item) => item.id === sample.id && item.serverId === serverId);
|
||||
if (!node) continue;
|
||||
const prevCounters = node.usageCounters;
|
||||
const prevSampleAt = node.lastSampleAt;
|
||||
const delta = applyDelta(node, uplink, downlink, sampleEpoch);
|
||||
if (!delta) continue;
|
||||
node.lastSampleAt = reportedAt;
|
||||
const restarted = Boolean(sampleEpoch && prevCounters && prevCounters.epoch && sampleEpoch !== prevCounters.epoch);
|
||||
if (delta.upDelta + delta.downDelta > 0) node.lastActiveAt = reportedAt;
|
||||
if (prevSampleAt && !restarted) {
|
||||
const dt = (Date.parse(reportedAt) - Date.parse(prevSampleAt)) / 1000;
|
||||
if (dt >= 5) {
|
||||
node.speedUpBps = Math.round(delta.upDelta / dt);
|
||||
node.speedDownBps = Math.round(delta.downDelta / dt);
|
||||
node.speedAt = reportedAt;
|
||||
}
|
||||
}
|
||||
const metered = node.meteredTraffic || { uplink: 0, downlink: 0 };
|
||||
node.meteredTraffic = { uplink: metered.uplink + delta.upDelta, downlink: metered.downlink + delta.downDelta };
|
||||
node.lastUsageAt = reportedAt;
|
||||
@@ -541,4 +626,5 @@ class Orchestrator {
|
||||
|
||||
module.exports = { Orchestrator, allocatePort, selectPort, nodePortInUse, queueJob, audit, id, nowIso, cancelQueuedDeletes, resourceForNode,
|
||||
resourceForNodeExit, rebuildNode, applyDelta, forwardNode, activeNodeClients,
|
||||
suspendCustomerResources, resumeCustomerResources, HEARTBEAT_RECONCILE_INTERVAL_MS };
|
||||
suspendCustomerResources, resumeCustomerResources, HEARTBEAT_RECONCILE_INTERVAL_MS,
|
||||
assignCustomerData, unassignCustomerData, alignCustomerNodes };
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
// Presence & speed derivation (panel-go/presence.go mirrors this file).
|
||||
//
|
||||
// Signals (agent protocol unchanged):
|
||||
// - Agent usage reports carry cumulative per-user counters aggregated per
|
||||
// server (NOT per node) and cumulative per-inbound (per node) counters.
|
||||
// A processed sample whose delta is > 0 marks activity: per-server for
|
||||
// the customer (serverUsage slot lastActiveAt + customer.lastOnlineAt),
|
||||
// per-node for the inbound (node.lastActiveAt).
|
||||
// - AnyTLS observations confirm customer activity and also stamp
|
||||
// customer.lastOnlineAt at ingest time.
|
||||
// A customer counts as "online on node N" when they hold an active
|
||||
// binding on N and their last activity on N's server is inside the
|
||||
// window. With several nodes on one server this attributes the activity
|
||||
// to each of them — the reports cannot separate nodes on a server.
|
||||
|
||||
const PRESENCE_WINDOW_MS = 180000; // 3 minutes
|
||||
const SPEED_STALE_MS = 150000;
|
||||
|
||||
function parseMs(value) {
|
||||
if (!value) return 0;
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms) ? ms : 0;
|
||||
}
|
||||
|
||||
function customerLastActiveAt(customer) {
|
||||
return customer.lastOnlineAt || null;
|
||||
}
|
||||
|
||||
function customerOnline(customer, nowMs) {
|
||||
const ms = parseMs(customer.lastOnlineAt);
|
||||
return ms > 0 && nowMs - ms <= PRESENCE_WINDOW_MS;
|
||||
}
|
||||
|
||||
// Sum of the customer's per-server speeds that are still fresh.
|
||||
function customerSpeedBps(customer, nowMs) {
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
for (const slot of Object.values(customer.serverUsage || {})) {
|
||||
if (!slot || !slot.speedAt) continue;
|
||||
if (nowMs - parseMs(slot.speedAt) > SPEED_STALE_MS) continue;
|
||||
up += Number(slot.speedUpBps || 0);
|
||||
down += Number(slot.speedDownBps || 0);
|
||||
}
|
||||
return { up, down };
|
||||
}
|
||||
|
||||
function nodeSpeedBps(node, nowMs) {
|
||||
if (!node.speedAt || nowMs - parseMs(node.speedAt) > SPEED_STALE_MS) return { up: 0, down: 0 };
|
||||
return { up: Number(node.speedUpBps || 0), down: Number(node.speedDownBps || 0) };
|
||||
}
|
||||
|
||||
// Customers (ids) with an active binding on this node whose last
|
||||
// activity on the node's server is inside the presence window. The
|
||||
// per-server usage slot is authoritative; when the customer has never
|
||||
// reported usage from this server, the customer-level lastOnlineAt is
|
||||
// used as a fallback (AnyTLS observations stamp it without creating a
|
||||
// usage slot, and they are validated against this server at ingest).
|
||||
function nodeOnlineCustomerIds(data, node, nowMs) {
|
||||
const out = [];
|
||||
for (const nc of data.nodeClients) {
|
||||
if (nc.nodeId !== node.id || nc.status !== 'active') continue;
|
||||
const customer = data.customers.find((item) => item.id === nc.customerId);
|
||||
if (!customer) continue;
|
||||
const slot = (customer.serverUsage || {})[`srv:${node.serverId}`];
|
||||
const ms = slot && slot.lastActiveAt ? parseMs(slot.lastActiveAt) : parseMs(customer.lastOnlineAt);
|
||||
if (ms > 0 && nowMs - ms <= PRESENCE_WINDOW_MS) out.push(customer.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function countOnlineCustomers(data, nowMs) {
|
||||
return data.customers.filter((customer) => customerOnline(customer, nowMs)).length;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PRESENCE_WINDOW_MS, SPEED_STALE_MS, parseMs,
|
||||
customerLastActiveAt, customerOnline, customerSpeedBps, nodeSpeedBps,
|
||||
nodeOnlineCustomerIds, countOnlineCustomers,
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
'use strict';
|
||||
|
||||
// Monthly traffic reset — Node port of panel-go/reset.go (the two panel
|
||||
// implementations must stay behavior-identical). A customer with
|
||||
// trafficResetDay (1-31) gets the cycle counters zeroed once per monthly
|
||||
// period. Due-ness derives only from the persisted trafficResetPeriod
|
||||
// marker, so a reset missed while the panel was down fires at the first
|
||||
// housekeeping pass after it is back. All dates are panel-local.
|
||||
|
||||
function daysInMonth(year, monthIndex) {
|
||||
return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
function resetInstant(year, monthIndex, day) {
|
||||
const dim = daysInMonth(year, monthIndex);
|
||||
return new Date(year, monthIndex, Math.min(day, dim), 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function periodKeyOf(date) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
|
||||
}
|
||||
|
||||
// The "YYYY-MM" of the most recent scheduled reset instant at or before now.
|
||||
function trafficResetPeriodKey(now, day) {
|
||||
let year = now.getFullYear();
|
||||
let month = now.getMonth();
|
||||
let cand = resetInstant(year, month, day);
|
||||
if (now.getTime() < cand.getTime()) {
|
||||
month -= 1;
|
||||
if (month < 0) { month = 11; year -= 1; }
|
||||
cand = resetInstant(year, month, day);
|
||||
}
|
||||
return periodKeyOf(cand);
|
||||
}
|
||||
|
||||
// (Re)arm a customer's period marker for its current reset day WITHOUT
|
||||
// zeroing usage: the schedule takes effect from the next reset instant.
|
||||
function armTrafficReset(customer, now = new Date()) {
|
||||
const day = Number(customer.trafficResetDay || 0);
|
||||
if (!Number.isInteger(day) || day < 1 || day > 31) {
|
||||
delete customer.trafficResetPeriod;
|
||||
return;
|
||||
}
|
||||
customer.trafficResetPeriod = trafficResetPeriodKey(now, day);
|
||||
}
|
||||
|
||||
// Validate a trafficResetDay request value: 0 disables, 1-31 is the day of
|
||||
// month. null/absent means 0. Mirrors panel-go ParseResetDay.
|
||||
function parseResetDay(value) {
|
||||
if (value == null) return 0;
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number < 0 || !Number.isInteger(number)) {
|
||||
const error = new Error('每月重置日必须是非负整数');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
if (number > 31) {
|
||||
const error = new Error('每月重置日须在 0–31 之间(0 表示不重置)');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
// Perform due monthly resets for all customers in data as of now. Returns
|
||||
// the list of customers whose counters were zeroed (the caller mirrors the
|
||||
// manual reset-usage semantics: a traffic_limit suspension may lift).
|
||||
// auditFn(data, customer, previousUsed, day, period) records the event.
|
||||
function applyMonthlyTrafficResets(data, now, auditFn) {
|
||||
const reset = [];
|
||||
for (const customer of data.customers) {
|
||||
const day = Number(customer.trafficResetDay || 0);
|
||||
if (!Number.isInteger(day) || day < 1 || day > 31) continue;
|
||||
const key = trafficResetPeriodKey(now, day);
|
||||
if (!customer.trafficResetPeriod) {
|
||||
// First sight: arm only — usage already accrued in the current
|
||||
// period must survive until the next reset day.
|
||||
customer.trafficResetPeriod = key;
|
||||
continue;
|
||||
}
|
||||
if (customer.trafficResetPeriod === key) continue;
|
||||
const previousUsed = customer.usedBytes || 0;
|
||||
customer.usedBytes = 0;
|
||||
customer.usedUplinkBytes = 0;
|
||||
customer.usedDownlinkBytes = 0;
|
||||
customer.trafficResetPeriod = key;
|
||||
customer.trafficResetAt = now.toISOString();
|
||||
customer.updatedAt = now.toISOString();
|
||||
if (auditFn) auditFn(data, customer, previousUsed, day, key);
|
||||
reset.push(customer);
|
||||
}
|
||||
return reset;
|
||||
}
|
||||
|
||||
module.exports = { trafficResetPeriodKey, armTrafficReset, parseResetDay, applyMonthlyTrafficResets, daysInMonth, resetInstant };
|
||||
+1
-1
@@ -13,7 +13,7 @@ function emptyData() {
|
||||
schemaVersion: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
settings: {
|
||||
brandName: 'NexusGate',
|
||||
brandName: 'amo',
|
||||
sessionHours: 12,
|
||||
observationTtlMinutes: 10,
|
||||
activityRetentionDays: 30,
|
||||
|
||||
+54
-3
@@ -1,6 +1,12 @@
|
||||
'use strict';
|
||||
|
||||
const { buildClientUri, nodeEmail } = require('./protocols');
|
||||
const { dedupKey, withName, nameFromUri } = require('./extsub');
|
||||
|
||||
function nodeSortIndex(data, nodeId) {
|
||||
const node = data.nodes.find((item) => item.id === nodeId);
|
||||
return node && Number.isFinite(node.sortIndex) ? node.sortIndex : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
// Client URLs are only issued for confirmed, running nodes. Each link is built
|
||||
// on the fly from the node's current config and the customer's own credentials
|
||||
@@ -22,7 +28,11 @@ function nodeClientUri(data, nodeClient) {
|
||||
function entries(data, customerId) {
|
||||
const seen = new Map();
|
||||
const items = [];
|
||||
for (const nodeClient of data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active')) {
|
||||
// Own nodes follow the panel-wide custom order (sortIndex); the sort is
|
||||
// stable, so equal/unset indexes keep the binding creation order.
|
||||
const bindings = data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active');
|
||||
bindings.sort((a, b) => nodeSortIndex(data, a.nodeId) - nodeSortIndex(data, b.nodeId));
|
||||
for (const nodeClient of bindings) {
|
||||
const uri = nodeClientUri(data, nodeClient);
|
||||
if (!uri) continue;
|
||||
const node = data.nodes.find((item) => item.id === nodeClient.nodeId);
|
||||
@@ -38,6 +48,47 @@ function entries(data, customerId) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// Full subscription item list: own nodes (sortIndex order), then manual
|
||||
// links (list order), then external sources (source order, cached order).
|
||||
// Global dedup by scheme+host+port+credential fingerprint, own first.
|
||||
// Name collisions across segments get the " (n)" suffix treatment.
|
||||
function aggregateEntries(data, customer) {
|
||||
const items = entries(data, customer.id);
|
||||
const usedKeys = new Set();
|
||||
const usedNames = new Set();
|
||||
for (const item of items) {
|
||||
const key = dedupKey(item.clientUri);
|
||||
if (key) usedKeys.add(key);
|
||||
usedNames.add(nameFromUri(item.clientUri));
|
||||
}
|
||||
const append = (uri, rawName) => {
|
||||
const key = dedupKey(uri);
|
||||
if (key && usedKeys.has(key)) return;
|
||||
let name = rawName || '未命名节点';
|
||||
if (usedNames.has(name)) {
|
||||
let index = 2;
|
||||
while (usedNames.has(`${name} (${index})`)) index += 1;
|
||||
name = `${name} (${index})`;
|
||||
}
|
||||
if (key) usedKeys.add(key);
|
||||
usedNames.add(name);
|
||||
items.push({ nodeId: '', clientUri: withName(uri, name) });
|
||||
};
|
||||
(customer.manualLinks || []).forEach((link, index) => {
|
||||
if (!link.enabled) return;
|
||||
append(link.uri, link.remark || nameFromUri(link.uri) || `手动节点 ${index + 1}`);
|
||||
});
|
||||
for (const source of customer.externalSources || []) {
|
||||
if (!source.enabled) continue;
|
||||
const cache = (data.subSourceCache || {})[source.id];
|
||||
if (!cache || !Array.isArray(cache.items)) continue;
|
||||
for (const cached of cache.items) {
|
||||
append(cached.uri, `${source.remark ? `[${source.remark}] ` : ''}${cached.name || '未命名节点'}`);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function q(value) { return JSON.stringify(String(value)); }
|
||||
|
||||
function clashProxy(item) {
|
||||
@@ -149,7 +200,7 @@ function makeSingBox(items) {
|
||||
}
|
||||
|
||||
function formatSubscription(data, customer, format, userAgent = '') {
|
||||
const items = entries(data, customer.id);
|
||||
const items = aggregateEntries(data, customer);
|
||||
if (!items.length) return null;
|
||||
const selected = format === 'auto' ? (/clash|mihomo/i.test(userAgent) ? 'clash' : 'base64') : format;
|
||||
if (selected === 'raw') return { body:items.map((item) => item.clientUri).join('\n') + '\n', contentType:'text/plain; charset=utf-8' };
|
||||
@@ -160,4 +211,4 @@ function formatSubscription(data, customer, format, userAgent = '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
module.exports = { entries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };
|
||||
module.exports = { entries, aggregateEntries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
'use strict';
|
||||
|
||||
// Host/system sampling for the system-status page (panel-go/sysstat.go
|
||||
// mirrors this file). Samples are taken at most every ~30s (housekeeping
|
||||
// cadence and on-demand from /api/system when stale) and kept in a
|
||||
// memory-only ring buffer (240 entries ≈ 4h at 60s cadence, tens of KB).
|
||||
// Nothing here writes to the data file: the "today" traffic baseline is
|
||||
// also memory-only, so a panel restart restarts the day counter from
|
||||
// the traffic seen since boot (named trade-off, see UI-RESKIN-REPORT).
|
||||
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const { customerOnline } = require('./presence');
|
||||
|
||||
const HISTORY_CAP = 240;
|
||||
const SAMPLE_MIN_INTERVAL_MS = 25000;
|
||||
|
||||
function readCpuTimes() {
|
||||
try {
|
||||
const line = fs.readFileSync('/proc/stat', 'utf8').split('\n')[0];
|
||||
const parts = line.trim().split(/\s+/).slice(1).map(Number);
|
||||
if (parts.length < 5 || parts.some((n) => !Number.isFinite(n))) return null;
|
||||
const idle = parts[3] + (parts[4] || 0);
|
||||
const total = parts.reduce((a, b) => a + b, 0);
|
||||
return { idle, total };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function readMem() {
|
||||
try {
|
||||
const text = fs.readFileSync('/proc/meminfo', 'utf8');
|
||||
const grab = (key) => {
|
||||
const match = new RegExp(`^${key}:\\s+(\\d+)`, 'm').exec(text);
|
||||
return match ? Number(match[1]) * 1024 : 0;
|
||||
};
|
||||
const total = grab('MemTotal');
|
||||
const available = grab('MemAvailable');
|
||||
return { total, used: total > 0 ? total - available : 0 };
|
||||
} catch { return { total: 0, used: 0 }; }
|
||||
}
|
||||
|
||||
function isGlobalIPv4(ip) {
|
||||
const parts = String(ip).split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
|
||||
const [a, b] = parts;
|
||||
if (a === 0 || a === 10 || a === 127) return false;
|
||||
if (a === 172 && b >= 16 && b <= 31) return false;
|
||||
if (a === 192 && b === 168) return false;
|
||||
if (a === 100 && b >= 64 && b <= 127) return false; // CGNAT
|
||||
if (a === 169 && b === 254) return false; // link-local
|
||||
if (a >= 224) return false; // multicast / reserved
|
||||
return true;
|
||||
}
|
||||
|
||||
function isGlobalIPv6(ip) {
|
||||
const lower = String(ip).toLowerCase();
|
||||
if (lower === '::1' || lower === '::') return false;
|
||||
if (/^f[cd]/.test(lower)) return false; // ULA fc00::/7
|
||||
if (/^fe[89ab]/.test(lower)) return false; // link-local fe80::/10
|
||||
if (lower.startsWith('ff')) return false; // multicast
|
||||
return lower.includes(':');
|
||||
}
|
||||
|
||||
// Pick the host's global addresses from the interface list (3x-ui
|
||||
// 口径: local interface addresses, no external lookup of any kind).
|
||||
function hostIPs() {
|
||||
let ipv4 = null;
|
||||
let ipv6 = null;
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const list of Object.values(interfaces)) {
|
||||
for (const info of list || []) {
|
||||
if (info.internal) continue;
|
||||
if (info.family === 'IPv4' && !ipv4 && isGlobalIPv4(info.address)) ipv4 = info.address;
|
||||
if (info.family === 'IPv6' && !ipv6 && isGlobalIPv6(info.address)) ipv6 = info.address;
|
||||
}
|
||||
}
|
||||
return { ipv4, ipv6 };
|
||||
}
|
||||
|
||||
class SystemMonitor {
|
||||
constructor(dataDir) {
|
||||
this.dataDir = dataDir;
|
||||
this.startedAt = Date.now();
|
||||
this.prevCpu = null;
|
||||
this.cpuPercent = null;
|
||||
this.history = [];
|
||||
this.lastSampleAt = 0;
|
||||
this.dayKey = null;
|
||||
this.dayBaseUp = 0;
|
||||
this.dayBaseDown = 0;
|
||||
this.todayUp = 0;
|
||||
this.todayDown = 0;
|
||||
this.current = null;
|
||||
}
|
||||
|
||||
sample(data, force = false) {
|
||||
const now = Date.now();
|
||||
if (!force && now - this.lastSampleAt < SAMPLE_MIN_INTERVAL_MS) return this.current;
|
||||
this.lastSampleAt = now;
|
||||
const cpu = readCpuTimes();
|
||||
if (cpu && this.prevCpu) {
|
||||
const totalDelta = cpu.total - this.prevCpu.total;
|
||||
const idleDelta = cpu.idle - this.prevCpu.idle;
|
||||
if (totalDelta > 0) this.cpuPercent = Math.max(0, Math.min(100, (1 - idleDelta / totalDelta) * 100));
|
||||
}
|
||||
if (cpu) this.prevCpu = cpu;
|
||||
const mem = readMem();
|
||||
let disk = { total: 0, used: 0 };
|
||||
try {
|
||||
const stat = fs.statfsSync(this.dataDir);
|
||||
disk = { total: stat.blocks * stat.bsize, used: (stat.blocks - stat.bfree) * stat.bsize };
|
||||
} catch { /* keep zeros */ }
|
||||
let totalUp = 0;
|
||||
let totalDown = 0;
|
||||
let upBps = 0;
|
||||
let downBps = 0;
|
||||
for (const node of data.nodes || []) {
|
||||
if (node.meteredTraffic) {
|
||||
totalUp += Number(node.meteredTraffic.uplink || 0);
|
||||
totalDown += Number(node.meteredTraffic.downlink || 0);
|
||||
}
|
||||
if (node.speedAt && now - Date.parse(node.speedAt) <= 150000) {
|
||||
upBps += Number(node.speedUpBps || 0);
|
||||
downBps += Number(node.speedDownBps || 0);
|
||||
}
|
||||
}
|
||||
const dayKey = new Date(now).toLocaleDateString('en-CA');
|
||||
if (this.dayKey !== dayKey) {
|
||||
this.dayKey = dayKey;
|
||||
this.dayBaseUp = totalUp;
|
||||
this.dayBaseDown = totalDown;
|
||||
}
|
||||
this.todayUp = Math.max(0, totalUp - this.dayBaseUp);
|
||||
this.todayDown = Math.max(0, totalDown - this.dayBaseDown);
|
||||
const connections = (data.customers || []).filter((c) => customerOnline(c, now)).length;
|
||||
const entry = {
|
||||
at: new Date(now).toISOString(),
|
||||
cpu: this.cpuPercent == null ? null : Math.round(this.cpuPercent * 10) / 10,
|
||||
mem: mem.total > 0 ? Math.round((mem.used / mem.total) * 1000) / 10 : null,
|
||||
upBps, downBps, connections,
|
||||
};
|
||||
this.history.push(entry);
|
||||
if (this.history.length > HISTORY_CAP) this.history.splice(0, this.history.length - HISTORY_CAP);
|
||||
this.current = {
|
||||
at: entry.at, cpuPercent: entry.cpu, mem, disk,
|
||||
upBps, downBps, connections, totalUp, totalDown,
|
||||
};
|
||||
return this.current;
|
||||
}
|
||||
|
||||
status(data, version) {
|
||||
const current = this.sample(data) || {
|
||||
cpuPercent: null, mem: readMem(), disk: { total: 0, used: 0 },
|
||||
upBps: 0, downBps: 0, connections: 0, totalUp: 0, totalDown: 0,
|
||||
};
|
||||
const mem = current.mem;
|
||||
const disk = current.disk;
|
||||
const onlineNodes = (data.nodes || []).filter((n) => n.status === 'active').length;
|
||||
return {
|
||||
version,
|
||||
startedAt: new Date(this.startedAt).toISOString(),
|
||||
panelUptimeSec: Math.floor((Date.now() - this.startedAt) / 1000),
|
||||
hostUptimeSec: Math.floor(os.uptime()),
|
||||
panelMemBytes: process.memoryUsage().rss,
|
||||
host: {
|
||||
cpuPercent: current.cpuPercent,
|
||||
cpuCount: os.cpus().length,
|
||||
load1: os.loadavg()[0],
|
||||
memTotal: mem.total, memUsed: mem.used,
|
||||
memPercent: mem.total > 0 ? Math.round((mem.used / mem.total) * 1000) / 10 : null,
|
||||
diskTotal: disk.total, diskUsed: disk.used,
|
||||
diskPercent: disk.total > 0 ? Math.round((disk.used / disk.total) * 1000) / 10 : null,
|
||||
},
|
||||
network: {
|
||||
upBps: current.upBps, downBps: current.downBps,
|
||||
connections: current.connections,
|
||||
onlineCustomers: current.connections,
|
||||
onlineNodes, nodesTotal: (data.nodes || []).length,
|
||||
customersTotal: (data.customers || []).length,
|
||||
},
|
||||
today: { upBytes: this.todayUp, downBytes: this.todayDown },
|
||||
totals: { upBytes: current.totalUp, downBytes: current.totalDown },
|
||||
ips: hostIPs(),
|
||||
history: this.history,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SystemMonitor, hostIPs, isGlobalIPv4, isGlobalIPv6, readCpuTimes, readMem };
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -280,7 +280,7 @@ export async function fetchAllSubscriptions(client, token) {
|
||||
// Learning normalizer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc'];
|
||||
const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc', 'src', 'lnk'];
|
||||
const PREFIXED_ID_RE = new RegExp(`\\b(${ID_PREFIXES.join('|')})_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`, 'g');
|
||||
const BARE_UUID_RE = /(?<![0-9A-Za-z_-])[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![0-9A-Za-z_-])/g;
|
||||
const ISO_TS_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})/g;
|
||||
@@ -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);
|
||||
@@ -424,6 +443,10 @@ export class Normalizer {
|
||||
return out;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
// Speeds are derived from adjacent usage reports over wall-clock
|
||||
// time (dt differs per side by construction), so only their shape
|
||||
// is comparable: positive vs zero. See UI-RESKIN-REPORT.md.
|
||||
if (keyName === 'speedUpBps' || keyName === 'speedDownBps') return value > 0 ? '<speed+>' : 0;
|
||||
if (keyName && PORT_FIELDS.has(keyName) && this.ports.has(value)) return this.ports.get(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
// executed and the first differing JSON path is reported per step.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
import {
|
||||
ADMIN_USERNAME, DEFAULT_GO_BIN, DIFFTEST_DIR, Normalizer, PanelClient,
|
||||
Reporter, UA_DEFAULT, completeJob, fetchAllSubscriptions, firstDiff,
|
||||
@@ -284,6 +287,201 @@ async function runPhase2(runner, { seedLog, seedExpect }) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3: UI-reskin features — node reorder, presence/speed, /api/system,
|
||||
// customer-form node alignment, credentials, subscription aggregation.
|
||||
// Time-derived values (speeds, /api/system numbers) are compared by shape
|
||||
// only; everything else is compared exactly like phase 2.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AGG_FIXTURE_BODY = Buffer.from([
|
||||
'ss://YWVzLTI1Ni1nY206ZXh0LXB3@ext.example.com:8388#ExtOne',
|
||||
'vless://22222222-2222-2222-2222-222222222222@ext2.example.com:443?security=none#ExtTwo',
|
||||
'trojan://x@bad.example.com:443#Dropped',
|
||||
'',
|
||||
].join('\n')).toString('base64');
|
||||
const AGG_MANUAL_LINK = 'ss://YWVzLTI1Ni1nY206bWFudWFsLXB3@manual.example.com:8388#ManualOne';
|
||||
|
||||
async function runPhase3(runner) {
|
||||
// --- node reorder + subscription order ----------------------------------
|
||||
await runner.step('p3/reorder-nodes', async (side) => {
|
||||
const res = await side.client.admin('POST', '/api/nodes/reorder', { ids: [side.ids.n2, side.ids.n1] });
|
||||
const list = await side.client.admin('GET', '/api/nodes');
|
||||
return {
|
||||
reorder: view(res),
|
||||
order: list.json.nodes.map((n) => n.name),
|
||||
sortIndexes: list.json.nodes.map((n) => n.sortIndex),
|
||||
};
|
||||
});
|
||||
await runner.step('p3/alice-sub-order-after-reorder', async (side) => {
|
||||
const res = await side.client.getSubscription(side.subTokens.alice, 'raw', UA_DEFAULT);
|
||||
const names = res.body.split('\n').filter(Boolean).map((line) => {
|
||||
const idx = line.lastIndexOf('#');
|
||||
return idx >= 0 ? decodeURIComponent(line.slice(idx + 1)) : line;
|
||||
});
|
||||
return { status: res.status, names };
|
||||
});
|
||||
|
||||
// --- presence & speed: two usage reports ~6s apart -----------------------
|
||||
await runner.step('p3/usage-report-1', async (side) =>
|
||||
view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
|
||||
epoch: 'diff-phase2-epoch-1',
|
||||
samples: [
|
||||
{ kind: 'user', id: side.ids.alice, uplink: 1500, downlink: 2100 },
|
||||
{ kind: 'inbound', id: side.ids.n1, uplink: 5000, downlink: 6000 },
|
||||
],
|
||||
})));
|
||||
await runner.step('p3/usage-report-2-after-6s', async (side) => {
|
||||
await sleep(6000);
|
||||
return view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
|
||||
epoch: 'diff-phase2-epoch-1',
|
||||
samples: [
|
||||
{ kind: 'user', id: side.ids.alice, uplink: 4500, downlink: 6300 },
|
||||
{ kind: 'inbound', id: side.ids.n1, uplink: 8000, downlink: 9000 },
|
||||
],
|
||||
}));
|
||||
});
|
||||
await runner.step('p3/presence-projection', async (side) => {
|
||||
const customers = await side.client.admin('GET', '/api/customers');
|
||||
const nodes = await side.client.admin('GET', '/api/nodes');
|
||||
const nameOf = (id) => { const c = customers.json.customers.find((x) => x.id === id); return c ? c.name : '?'; };
|
||||
return {
|
||||
customers: customers.json.customers.map((c) => ({
|
||||
name: c.name, online: c.online,
|
||||
speedPositive: (c.speedUpBps || 0) > 0 && (c.speedDownBps || 0) > 0,
|
||||
hasLastActiveAt: Boolean(c.lastActiveAt),
|
||||
})),
|
||||
nodes: nodes.json.nodes.map((n) => ({
|
||||
name: n.name, clientCount: n.clientCount, onlineCount: n.onlineCount,
|
||||
onlineNames: (n.onlineCustomerIds || []).map(nameOf),
|
||||
speedPositive: (n.speedUpBps || 0) > 0,
|
||||
})),
|
||||
};
|
||||
});
|
||||
await runner.step('p3/system-shape', async (side) => {
|
||||
const res = await side.client.admin('GET', '/api/system');
|
||||
const s = res.json || {};
|
||||
return {
|
||||
status: res.status, version: s.version,
|
||||
hostKeys: s.host ? Object.keys(s.host).sort() : null,
|
||||
networkKeys: s.network ? Object.keys(s.network).sort() : null,
|
||||
todayKeys: s.today ? Object.keys(s.today).sort() : null,
|
||||
ipsKeys: s.ips ? Object.keys(s.ips).sort() : null,
|
||||
historyIsArray: Array.isArray(s.history),
|
||||
connectionsIsNumber: Boolean(s.network) && typeof s.network.connections === 'number',
|
||||
panelMemPositive: s.panelMemBytes > 0,
|
||||
startedAtPresent: typeof s.startedAt === 'string',
|
||||
};
|
||||
});
|
||||
|
||||
// --- customer-form node alignment (on Carol: no golden subscriptions) ----
|
||||
await runner.step('p3/align-carol-add-n1', async (side) => {
|
||||
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [side.ids.n1] });
|
||||
const nodes = await side.client.admin('GET', '/api/nodes');
|
||||
const mine = nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol);
|
||||
if (mine.length) side.ids.carolNc = mine[0].id;
|
||||
return {
|
||||
status: res.status, count: mine.length,
|
||||
nodeNames: mine.map((nc) => { const n = nodes.json.nodes.find((x) => x.id === nc.nodeId); return n ? n.name : '?'; }),
|
||||
statuses: mine.map((nc) => nc.status),
|
||||
};
|
||||
});
|
||||
|
||||
// --- credentials ----------------------------------------------------------
|
||||
await runner.step('p3/credentials-get', async (side) => {
|
||||
const res = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
|
||||
return {
|
||||
status: res.status,
|
||||
hasToken: typeof res.json.subscriptionToken === 'string' && res.json.subscriptionToken.length > 10,
|
||||
items: (res.json.items || []).map((entry) => ({
|
||||
nodeName: entry.nodeName, protocol: entry.protocol, status: entry.status,
|
||||
hasClientId: Boolean(entry.clientId), hasPassword: Boolean(entry.password),
|
||||
hasShortId: Boolean(entry.shortId), username: entry.username,
|
||||
})),
|
||||
};
|
||||
});
|
||||
await runner.step('p3/credentials-regenerate', async (side) => {
|
||||
const before = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
|
||||
const regen = await side.client.admin('POST', `/api/customers/${side.ids.carol}/credentials/${side.ids.carolNc}/regenerate`, {});
|
||||
const after = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
|
||||
const b = (before.json.items || [])[0] || {};
|
||||
const a = (after.json.items || [])[0] || {};
|
||||
return {
|
||||
status: regen.status,
|
||||
changed: b.clientId !== a.clientId || b.password !== a.password,
|
||||
itemNodeName: regen.json && regen.json.item ? regen.json.item.nodeName : null,
|
||||
itemHasClientId: Boolean(regen.json && regen.json.item && regen.json.item.clientId),
|
||||
};
|
||||
});
|
||||
await runner.step('p3/align-carol-remove-all', async (side) => {
|
||||
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [] });
|
||||
const nodes = await side.client.admin('GET', '/api/nodes');
|
||||
return {
|
||||
status: res.status,
|
||||
bindingsForCarol: nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol).length,
|
||||
};
|
||||
});
|
||||
|
||||
// --- subscription aggregation ---------------------------------------------
|
||||
// Both panels must allow private subscription sources to reach the
|
||||
// loopback fixture: flip the setting in each side's data file and
|
||||
// restart (there is deliberately no settings API).
|
||||
const fixture = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end(AGG_FIXTURE_BODY);
|
||||
});
|
||||
await new Promise((resolve) => fixture.listen(0, '127.0.0.1', resolve));
|
||||
const fixtureUrl = `http://127.0.0.1:${fixture.address().port}/sub`;
|
||||
try {
|
||||
for (const side of [runner.sideA, runner.sideB]) {
|
||||
await stopPanel(side.proc);
|
||||
const db = JSON.parse(fs.readFileSync(side.dataFile, 'utf8'));
|
||||
db.settings = db.settings || {};
|
||||
db.settings.allowPrivateSubSources = true;
|
||||
fs.writeFileSync(side.dataFile, JSON.stringify(db));
|
||||
await restartSide(side);
|
||||
}
|
||||
await runner.step('p3/agg-relogin', async (side) => view(await side.client.login()));
|
||||
await runner.step('p3/agg-create-dave', async (side) => {
|
||||
const res = await side.client.admin('POST', '/api/customers', {
|
||||
name: 'Dave',
|
||||
manualLinks: [{ uri: AGG_MANUAL_LINK, remark: '手动一号', enabled: true }],
|
||||
externalSources: [{ url: fixtureUrl, remark: '机场', enabled: true }],
|
||||
});
|
||||
if (res.json && res.json.customer) {
|
||||
side.ids.dave = res.json.customer.id;
|
||||
side.subTokens.dave = res.json.customer.subscriptionToken;
|
||||
const source = (res.json.customer.externalSources || [])[0];
|
||||
if (source) side.ids.daveSrc = source.id;
|
||||
}
|
||||
return {
|
||||
status: res.status,
|
||||
manualCount: res.json && res.json.customer ? (res.json.customer.manualLinks || []).length : null,
|
||||
sourceCount: res.json && res.json.customer ? (res.json.customer.externalSources || []).length : null,
|
||||
};
|
||||
});
|
||||
await runner.step('p3/agg-refresh', async (side) =>
|
||||
view(await side.client.admin('POST', `/api/customers/${side.ids.dave}/external-sources/${side.ids.daveSrc}/refresh`, {})));
|
||||
await runner.step('p3/agg-customer-status', async (side) => {
|
||||
const res = await side.client.admin('GET', '/api/customers');
|
||||
const dave = res.json.customers.find((c) => c.id === side.ids.dave);
|
||||
const status = dave && dave.externalStatus ? Object.values(dave.externalStatus)[0] : null;
|
||||
return {
|
||||
hasExternalStatus: Boolean(dave && dave.externalStatus),
|
||||
count: status ? status.count : null,
|
||||
dropped: status ? status.droppedCount : null,
|
||||
failCount: status ? status.failCount : null,
|
||||
hasOkAt: Boolean(status && status.okAt),
|
||||
lastError: status ? status.lastError : 'missing',
|
||||
};
|
||||
});
|
||||
await runner.step('p3/agg-subscriptions', async (side) =>
|
||||
fetchAllSubscriptions(side.client, side.subTokens.dave));
|
||||
} finally {
|
||||
fixture.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB interoperability: swap the two panels' DB files and keep working.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -360,6 +558,7 @@ async function runPair({ reporter, kindA, kindB, bin, seedPath, seedLog, seedExp
|
||||
try {
|
||||
const runner = new StepRunner(reporter, sideA, sideB);
|
||||
await runPhase2(runner, { seedLog, seedExpect });
|
||||
await runPhase3(runner);
|
||||
await runInterop(reporter, sideA, sideB, { seedExpect });
|
||||
} finally {
|
||||
await destroySide(sideA, keep);
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
package main
|
||||
|
||||
// External subscription aggregation — mirrors lib/extsub.js (design:
|
||||
// SUB-AGGREGATION-DESIGN.md). SSRF guard, pinned-IP dialing, redirect
|
||||
// re-validation, 10s timeout, 1MiB cap, 200 items per source.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var LinkSchemes = map[string]bool{
|
||||
"vless:": true, "ss:": true, "hysteria2:": true, "anytls:": true, "socks5:": true,
|
||||
}
|
||||
|
||||
const maxSubBodyBytes = 1024 * 1024
|
||||
const maxSubItems = 200
|
||||
const fetchTimeout = 10 * time.Second
|
||||
const defaultSubUA = "v2rayN/6.55"
|
||||
|
||||
func ValidateLinkUri(uri string) bool {
|
||||
if uri == "" || len(uri) > 2048 {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return LinkSchemes[parsed.Scheme+":"] && parsed.Hostname() != ""
|
||||
}
|
||||
|
||||
var base64CharsetRe = regexp.MustCompile(`^[A-Za-z0-9+/=_-]+$`)
|
||||
|
||||
// ParseLinkList mirrors parseLinkList in lib/extsub.js.
|
||||
func ParseLinkList(body string) ([]SubCacheItem, int) {
|
||||
text := body
|
||||
compact := strings.Join(strings.Fields(text), "")
|
||||
if len(compact) >= 16 && base64CharsetRe.MatchString(compact) {
|
||||
normalized := strings.NewReplacer("-", "+", "_", "/").Replace(compact)
|
||||
if pad := len(normalized) % 4; pad != 0 {
|
||||
normalized += strings.Repeat("=", 4-pad)
|
||||
}
|
||||
if decoded, err := base64.StdEncoding.DecodeString(normalized); err == nil && strings.Contains(string(decoded), "://") {
|
||||
text = string(decoded)
|
||||
}
|
||||
}
|
||||
items := []SubCacheItem{}
|
||||
dropped := 0
|
||||
for _, rawLine := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if len(items) >= maxSubItems {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
if !ValidateLinkUri(line) {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
name := ""
|
||||
if idx := strings.LastIndex(line, "#"); idx >= 0 {
|
||||
rawName := line[idx+1:]
|
||||
if decoded, err := url.PathUnescape(rawName); err == nil {
|
||||
name = decoded
|
||||
} else {
|
||||
name = rawName
|
||||
}
|
||||
}
|
||||
items = append(items, SubCacheItem{URI: line, Name: truncateRunes(name, 120)})
|
||||
}
|
||||
return items, dropped
|
||||
}
|
||||
|
||||
func isGlobalIPString(ip string) bool {
|
||||
parsed := net.ParseIP(strings.Trim(ip, "[]"))
|
||||
if parsed == nil {
|
||||
return false
|
||||
}
|
||||
if parsed.To4() != nil {
|
||||
return isGlobalIPv4(parsed)
|
||||
}
|
||||
return isGlobalIPv6(parsed)
|
||||
}
|
||||
|
||||
func resolveCheckedIP(ctx context.Context, hostname string, allowPrivate bool) (string, error) {
|
||||
var addresses []string
|
||||
if ip := net.ParseIP(hostname); ip != nil {
|
||||
addresses = []string{hostname}
|
||||
} else {
|
||||
records, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
|
||||
if err != nil {
|
||||
return "", errors.New("域名解析无结果")
|
||||
}
|
||||
for _, record := range records {
|
||||
addresses = append(addresses, record.IP.String())
|
||||
}
|
||||
}
|
||||
if len(addresses) == 0 {
|
||||
return "", errors.New("域名解析无结果")
|
||||
}
|
||||
if !allowPrivate {
|
||||
for _, ip := range addresses {
|
||||
if !isGlobalIPString(ip) {
|
||||
return "", errors.New("订阅源指向非公网地址,已拒绝")
|
||||
}
|
||||
}
|
||||
}
|
||||
return addresses[0], nil
|
||||
}
|
||||
|
||||
func fetchSourceBody(source ExtSource, settings *Settings) (string, error) {
|
||||
parsed, err := url.Parse(source.URL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return "", errors.New("仅支持 http/https 订阅源")
|
||||
}
|
||||
allowPrivate := settings != nil && settings.AllowPrivateSubSources
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
pin := func(ctx context.Context, addr string) (string, string, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ip, err := resolveCheckedIP(ctx, host, allowPrivate)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return ip, port, nil
|
||||
}
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
ip, port, err := pin(ctx, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort(ip, port))
|
||||
},
|
||||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
ip, port, err := pin(ctx, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsDialer := &tls.Dialer{NetDialer: dialer, Config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}}
|
||||
return tlsDialer.DialContext(ctx, network, net.JoinHostPort(ip, port))
|
||||
},
|
||||
ResponseHeaderTimeout: fetchTimeout,
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: fetchTimeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return errors.New("订阅源重定向次数过多")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ua := source.UserAgent
|
||||
if ua == "" {
|
||||
ua = defaultSubUA
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, source.URL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", ua)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "", errors.New("订阅源请求超时")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("订阅源返回 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSubBodyBytes+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(body) > maxSubBodyBytes {
|
||||
return "", errors.New("订阅源响应超过 1MiB")
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
type FetchResult struct {
|
||||
OK bool
|
||||
Items []SubCacheItem
|
||||
Dropped int
|
||||
Error string
|
||||
}
|
||||
|
||||
// FetchExternalSource mirrors fetchExternalSource: never fails hard.
|
||||
func FetchExternalSource(source ExtSource, settings *Settings) FetchResult {
|
||||
body, err := fetchSourceBody(source, settings)
|
||||
if err != nil {
|
||||
message := err.Error()
|
||||
if len([]rune(message)) > 240 {
|
||||
message = truncateRunes(message, 240)
|
||||
}
|
||||
return FetchResult{OK: false, Items: []SubCacheItem{}, Error: message}
|
||||
}
|
||||
items, dropped := ParseLinkList(body)
|
||||
return FetchResult{OK: true, Items: items, Dropped: dropped}
|
||||
}
|
||||
|
||||
func RefreshMinutes(settings *Settings) float64 {
|
||||
if settings == nil || settings.ExternalSubRefreshMinutes <= 0 {
|
||||
return 60
|
||||
}
|
||||
return math.Min(720, math.Max(15, settings.ExternalSubRefreshMinutes))
|
||||
}
|
||||
|
||||
func jitterFactor(sourceID string) float64 {
|
||||
hash := sha256.Sum256([]byte(sourceID))
|
||||
return 1 + float64(int(hash[0]%21)-10)/100
|
||||
}
|
||||
|
||||
type dueSource struct {
|
||||
CustomerID string
|
||||
Source ExtSource
|
||||
}
|
||||
|
||||
// DueSources mirrors dueSources in lib/extsub.js.
|
||||
func DueSources(d *Data, nowMs int64, limit int) []dueSource {
|
||||
out := []dueSource{}
|
||||
for _, customer := range d.Customers {
|
||||
for _, source := range customer.ExternalSources {
|
||||
if !source.Enabled {
|
||||
continue
|
||||
}
|
||||
entry := d.SubSourceCache[source.ID]
|
||||
if entry == nil || entry.FetchedAt == nil {
|
||||
out = append(out, dueSource{customer.ID, source})
|
||||
continue
|
||||
}
|
||||
base := RefreshMinutes(&d.Settings) * 60000
|
||||
failCount := entry.FailCount
|
||||
if failCount > 10 {
|
||||
failCount = 10
|
||||
}
|
||||
backoff := math.Min(math.Pow(2, float64(failCount))*base, 24*3600*1000)
|
||||
fetchedMs, _ := ParseISOMs(*entry.FetchedAt)
|
||||
if float64(nowMs) >= float64(fetchedMs)+backoff*jitterFactor(source.ID) {
|
||||
out = append(out, dueSource{customer.ID, source})
|
||||
}
|
||||
if len(out) >= limit {
|
||||
return out
|
||||
}
|
||||
}
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyFetchResult mirrors applyFetchResult in lib/extsub.js.
|
||||
func ApplyFetchResult(d *Data, sourceID string, result FetchResult, nowISO string) *SubSourceCacheEntry {
|
||||
if d.SubSourceCache == nil {
|
||||
d.SubSourceCache = map[string]*SubSourceCacheEntry{}
|
||||
}
|
||||
entry := d.SubSourceCache[sourceID]
|
||||
if entry == nil {
|
||||
entry = &SubSourceCacheEntry{Items: []SubCacheItem{}}
|
||||
}
|
||||
entry.FetchedAt = StrPtr(nowISO)
|
||||
if result.OK {
|
||||
entry.OkAt = StrPtr(nowISO)
|
||||
entry.FailCount = 0
|
||||
entry.LastError = nil
|
||||
entry.DroppedCount = result.Dropped
|
||||
entry.Items = result.Items
|
||||
if entry.Items == nil {
|
||||
entry.Items = []SubCacheItem{}
|
||||
}
|
||||
} else {
|
||||
entry.FailCount++
|
||||
entry.LastError = StrPtr(result.Error)
|
||||
}
|
||||
d.SubSourceCache[sourceID] = entry
|
||||
return entry
|
||||
}
|
||||
|
||||
// ---------- save-time normalization ----------
|
||||
|
||||
func NormalizeExternalSources(value any) ([]ExtSource, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
arr, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, httpErr(400, "外部订阅格式无效")
|
||||
}
|
||||
if len(arr) > 5 {
|
||||
return nil, httpErr(400, "每个客户最多 5 个外部订阅")
|
||||
}
|
||||
out := []ExtSource{}
|
||||
for _, raw := range arr {
|
||||
item, _ := raw.(map[string]any)
|
||||
rawURL := CleanText(item["url"], 2048)
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
|
||||
return nil, httpErr(400, "外部订阅地址无效")
|
||||
}
|
||||
idValue := ""
|
||||
if s, isStr := item["id"].(string); isStr && s != "" {
|
||||
idValue = truncateRunes(s, 80)
|
||||
} else {
|
||||
idValue = NewID("src")
|
||||
}
|
||||
next := ExtSource{
|
||||
ID: idValue,
|
||||
URL: rawURL,
|
||||
Remark: CleanText(item["remark"], 80),
|
||||
Enabled: true,
|
||||
}
|
||||
if enabled, isBool := item["enabled"].(bool); isBool && !enabled {
|
||||
next.Enabled = false
|
||||
}
|
||||
if ua := CleanText(item["userAgent"], 120); ua != "" {
|
||||
next.UserAgent = ua
|
||||
}
|
||||
out = append(out, next)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func NormalizeManualLinks(value any) ([]ManualLink, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
arr, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, httpErr(400, "外部链接格式无效")
|
||||
}
|
||||
if len(arr) > 50 {
|
||||
return nil, httpErr(400, "每个客户最多 50 条外部链接")
|
||||
}
|
||||
out := []ManualLink{}
|
||||
for _, raw := range arr {
|
||||
item, _ := raw.(map[string]any)
|
||||
uri := CleanText(item["uri"], 2048)
|
||||
if !ValidateLinkUri(uri) {
|
||||
return nil, httpErr(400, "外部链接无效或协议不受支持")
|
||||
}
|
||||
idValue := ""
|
||||
if s, isStr := item["id"].(string); isStr && s != "" {
|
||||
idValue = truncateRunes(s, 80)
|
||||
} else {
|
||||
idValue = NewID("lnk")
|
||||
}
|
||||
next := ManualLink{
|
||||
ID: idValue,
|
||||
URI: uri,
|
||||
Remark: CleanText(item["remark"], 80),
|
||||
Enabled: true,
|
||||
}
|
||||
if enabled, isBool := item["enabled"].(bool); isBool && !enabled {
|
||||
next.Enabled = false
|
||||
}
|
||||
out = append(out, next)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------- merge helpers (used by subscriptions.go) ----------
|
||||
|
||||
func DedupKey(uri string) string {
|
||||
parsed, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
username, password := "", ""
|
||||
if parsed.User != nil {
|
||||
username = parsed.User.Username()
|
||||
password, _ = parsed.User.Password()
|
||||
}
|
||||
hash := sha256.Sum256([]byte(username + ":" + password))
|
||||
return fmt.Sprintf("%s//%s:%s#%s", parsed.Scheme+":", strings.ToLower(parsed.Hostname()), parsed.Port(), hex.EncodeToString(hash[:])[:16])
|
||||
}
|
||||
|
||||
func WithName(uri, name string) string {
|
||||
base := uri
|
||||
if idx := strings.LastIndex(uri, "#"); idx >= 0 {
|
||||
base = uri[:idx]
|
||||
}
|
||||
return base + "#" + encodeURIComponent(name)
|
||||
}
|
||||
|
||||
func NameFromURI(uri string) string {
|
||||
idx := strings.LastIndex(uri, "#")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
raw := uri[idx+1:]
|
||||
if decoded, err := url.PathUnescape(raw); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func strOrNil(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------- App-level refresh machinery ----------
|
||||
|
||||
// RefreshOneSource fetches one source now and persists the outcome.
|
||||
func (a *App) RefreshOneSource(customerID, sourceID string) *FetchResult {
|
||||
data := a.store.Snapshot()
|
||||
customer := findCustomer(data, customerID)
|
||||
if customer == nil {
|
||||
return nil
|
||||
}
|
||||
var source *ExtSource
|
||||
for i := range customer.ExternalSources {
|
||||
if customer.ExternalSources[i].ID == sourceID {
|
||||
source = &customer.ExternalSources[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
settings := data.Settings
|
||||
result := FetchExternalSource(*source, &settings)
|
||||
_, err := a.store.Transaction(func(d *Data) (any, error) {
|
||||
ApplyFetchResult(d, sourceID, result, NowISO())
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &result
|
||||
}
|
||||
|
||||
func (a *App) RefreshSourcesInBackground(customerID string, sourceIDs []string) {
|
||||
for _, sourceID := range sourceIDs {
|
||||
go func(id string) {
|
||||
_ = a.RefreshOneSource(customerID, id)
|
||||
}(sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) RefreshDueSources() {
|
||||
data := a.store.Snapshot()
|
||||
for _, due := range DueSources(data, time.Now().UnixMilli(), 3) {
|
||||
_ = a.RefreshOneSource(due.CustomerID, due.Source.ID)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+643
-22
@@ -7,6 +7,7 @@ package main
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -36,6 +38,25 @@ type App struct {
|
||||
|
||||
loginMu sync.Mutex
|
||||
loginAttempts map[string]*loginAttempt
|
||||
|
||||
sysmonMu sync.Mutex
|
||||
sysmon *SystemMonitor
|
||||
}
|
||||
|
||||
// monitor lazily creates the system monitor (data dir = store file dir).
|
||||
func (a *App) monitor() *SystemMonitor {
|
||||
a.sysmonMu.Lock()
|
||||
defer a.sysmonMu.Unlock()
|
||||
if a.sysmon == nil {
|
||||
dir := "."
|
||||
if a.store != nil && a.store.File != "" {
|
||||
if idx := strings.LastIndex(a.store.File, "/"); idx >= 0 {
|
||||
dir = a.store.File[:idx]
|
||||
}
|
||||
}
|
||||
a.sysmon = NewSystemMonitor(dir)
|
||||
}
|
||||
return a.sysmon
|
||||
}
|
||||
|
||||
func NewApp(store *Store, sessions *SessionManager, orch *Orchestrator) *App {
|
||||
@@ -158,6 +179,16 @@ func NormalizeServer(body map[string]any, current *Server) (*Server, error) {
|
||||
if next.TLSDomain != "" && !validTLSDomain(next.TLSDomain) {
|
||||
return nil, httpErr(400, "TLS 域名必须是有效的公网域名")
|
||||
}
|
||||
if _, ok := body["enabled"]; ok {
|
||||
enabled := true
|
||||
if b, isBool := body["enabled"].(bool); isBool && !b {
|
||||
enabled = false
|
||||
}
|
||||
next.Enabled = &enabled
|
||||
} else if next.Enabled == nil {
|
||||
enabled := true
|
||||
next.Enabled = &enabled
|
||||
}
|
||||
return &next, nil
|
||||
}
|
||||
|
||||
@@ -178,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 {
|
||||
@@ -202,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)
|
||||
@@ -213,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) {
|
||||
@@ -236,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 {
|
||||
@@ -290,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"]
|
||||
@@ -303,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" {
|
||||
@@ -334,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"]
|
||||
@@ -347,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
|
||||
@@ -361,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 不能为空")
|
||||
}
|
||||
@@ -413,6 +621,7 @@ func PublicServer(item *Server, data *Data) map[string]any {
|
||||
}
|
||||
}
|
||||
out["pendingCleanup"] = pending
|
||||
out["enabled"] = item.Enabled == nil || *item.Enabled
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -421,9 +630,155 @@ func PublicCustomer(item *Customer, data *Data) map[string]any {
|
||||
out := recordToMap(item)
|
||||
out["observedIpCount"] = len(ObservedIPs(data, item.ID, time.Now()))
|
||||
out["subscriptionClientCount"] = len(ActiveClients(data, item.ID, time.Now()))
|
||||
if len(item.ExternalSources) > 0 {
|
||||
status := map[string]any{}
|
||||
for _, source := range item.ExternalSources {
|
||||
entry := data.SubSourceCache[source.ID]
|
||||
summary := map[string]any{
|
||||
"fetchedAt": nil, "okAt": nil, "failCount": 0,
|
||||
"lastError": nil, "droppedCount": 0, "count": 0,
|
||||
}
|
||||
if entry != nil {
|
||||
if entry.FetchedAt != nil {
|
||||
summary["fetchedAt"] = *entry.FetchedAt
|
||||
}
|
||||
if entry.OkAt != nil {
|
||||
summary["okAt"] = *entry.OkAt
|
||||
}
|
||||
summary["failCount"] = entry.FailCount
|
||||
if entry.LastError != nil {
|
||||
summary["lastError"] = *entry.LastError
|
||||
}
|
||||
summary["droppedCount"] = entry.DroppedCount
|
||||
summary["count"] = len(entry.Items)
|
||||
}
|
||||
status[source.ID] = summary
|
||||
}
|
||||
out["externalStatus"] = status
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
node := findNode(d, nc.NodeID)
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
username := ""
|
||||
if node.Protocol == "socks5-auth" {
|
||||
username = NodeEmail(customer.ID)
|
||||
}
|
||||
var publicKey any
|
||||
if key := realityPublicKeyOf(node); key != "" {
|
||||
publicKey = key
|
||||
}
|
||||
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 map[string]any{
|
||||
"customerId": customer.ID,
|
||||
"subscriptionToken": customer.SubscriptionToken,
|
||||
"credentials": creds,
|
||||
"bindings": bindings,
|
||||
}
|
||||
}
|
||||
|
||||
// CustomerBlockReason is customerBlockReason: "expired" / "traffic_limit" /
|
||||
// "ip_limit", or "" for none (JS null).
|
||||
func CustomerBlockReason(data *Data, customer *Customer) string {
|
||||
@@ -806,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,
|
||||
}
|
||||
}
|
||||
@@ -1074,6 +1430,9 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
|
||||
LastSeenAt: now,
|
||||
})
|
||||
}
|
||||
if customer := findCustomer(d, customerID); customer != nil {
|
||||
customer.LastOnlineAt = StrPtr(now)
|
||||
}
|
||||
if !affectedSet[customerID] {
|
||||
affectedSet[customerID] = true
|
||||
affected = append(affected, customerID)
|
||||
@@ -1241,6 +1600,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet && pathname == "/api/system" {
|
||||
data := a.store.Snapshot()
|
||||
SendJSON(w, 200, a.monitor().Status(data, VERSION), nil)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet && pathname == "/api/protocols" {
|
||||
SendJSON(w, 200, map[string]any{"profiles": ProfileCatalog, "realityPresets": RealityPresets}, nil)
|
||||
return true, nil
|
||||
@@ -1558,11 +1923,23 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
|
||||
if r.Method == http.MethodGet && pathname == "/api/customers" {
|
||||
data := a.store.Snapshot()
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
customers := []map[string]any{}
|
||||
for _, item := range data.Customers {
|
||||
customers = append(customers, PublicCustomer(item, data))
|
||||
out := PublicCustomer(item, data)
|
||||
out["online"] = CustomerOnline(item, nowMs)
|
||||
if item.LastOnlineAt != nil {
|
||||
out["lastActiveAt"] = *item.LastOnlineAt
|
||||
} else {
|
||||
out["lastActiveAt"] = nil
|
||||
}
|
||||
up, down := CustomerSpeedBps(item, nowMs)
|
||||
out["speedUpBps"] = up
|
||||
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 {
|
||||
@@ -1631,6 +2008,16 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
externalSources, err := NormalizeExternalSources(body["externalSources"])
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
manualLinks, err := NormalizeManualLinks(body["manualLinks"])
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
var createdID string
|
||||
var refreshIDs []string
|
||||
customerAny, err := a.store.Transaction(func(d *Data) (any, error) {
|
||||
name, err := RequiredText(body["name"], "客户名称", 120)
|
||||
if err != nil {
|
||||
@@ -1652,6 +2039,10 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resetDay, err := ParseResetDay(body["trafficResetDay"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := &Customer{
|
||||
ID: NewID("cus"),
|
||||
SubscriptionToken: RandomToken(32),
|
||||
@@ -1665,18 +2056,41 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
ExpiresAt: expires,
|
||||
IPLimit: int64(ipLimit),
|
||||
DeviceLimit: int64(deviceLimit),
|
||||
TrafficResetDay: resetDay,
|
||||
Tags: sliceStrings(asIds(body["tags"]), 20),
|
||||
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.
|
||||
ArmTrafficReset(next, time.Now())
|
||||
if len(externalSources) > 0 {
|
||||
next.ExternalSources = externalSources
|
||||
}
|
||||
if len(manualLinks) > 0 {
|
||||
next.ManualLinks = manualLinks
|
||||
}
|
||||
d.Customers = append(d.Customers, next)
|
||||
if body["nodeIds"] != nil {
|
||||
if err := AlignCustomerNodes(d, next, asIds(body["nodeIds"]), actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "create_customer", next.ID, map[string]any{"name": next.Name})
|
||||
createdID = next.ID
|
||||
for _, source := range externalSources {
|
||||
refreshIDs = append(refreshIDs, source.ID)
|
||||
}
|
||||
return cloneJSONValue(next), nil
|
||||
})
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if len(refreshIDs) > 0 {
|
||||
a.RefreshSourcesInBackground(createdID, refreshIDs)
|
||||
}
|
||||
SendJSON(w, 201, map[string]any{"customer": customerAny}, nil)
|
||||
return true, nil
|
||||
}
|
||||
@@ -1685,6 +2099,21 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
var patchSources []ExtSource
|
||||
if body["externalSources"] != nil {
|
||||
patchSources, err = NormalizeExternalSources(body["externalSources"])
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
var patchLinks []ManualLink
|
||||
if body["manualLinks"] != nil {
|
||||
patchLinks, err = NormalizeManualLinks(body["manualLinks"])
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
var patchRefreshIDs []string
|
||||
customerAny, err := a.store.Transaction(func(d *Data) (any, error) {
|
||||
item := findCustomer(d, params["id"])
|
||||
if item == nil {
|
||||
@@ -1692,6 +2121,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
}
|
||||
wasActive := item.Status == "active"
|
||||
previousReason := strVal(item.SuspendReason)
|
||||
previousSources := append([]ExtSource{}, item.ExternalSources...)
|
||||
if _, ok := body["name"]; ok {
|
||||
name, err := RequiredText(body["name"], "客户名称", 120)
|
||||
if err != nil {
|
||||
@@ -1736,6 +2166,20 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
if _, ok := body["tags"]; ok {
|
||||
item.Tags = sliceStrings(asIds(body["tags"]), 20)
|
||||
}
|
||||
if _, ok := body["trafficResetDay"]; ok {
|
||||
v, err := ParseResetDay(body["trafficResetDay"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v != item.TrafficResetDay {
|
||||
item.TrafficResetDay = v
|
||||
// A changed schedule is re-armed rather than fired:
|
||||
// it takes effect from the next reset instant. An
|
||||
// unchanged value (the UI echoes it on every edit)
|
||||
// leaves the marker — and any pending reset — alone.
|
||||
ArmTrafficReset(item, time.Now())
|
||||
}
|
||||
}
|
||||
reason := CustomerBlockReason(d, item)
|
||||
statusWanted := JSString(body["status"])
|
||||
if statusWanted == "active" && reason != "" {
|
||||
@@ -1756,6 +2200,41 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
item.SuspendReason = nil
|
||||
item.SuspendReasonPresent = true
|
||||
}
|
||||
if body["externalSources"] != nil {
|
||||
if len(patchSources) > 0 {
|
||||
item.ExternalSources = patchSources
|
||||
} else {
|
||||
item.ExternalSources = nil
|
||||
}
|
||||
keptIDs := map[string]bool{}
|
||||
for _, source := range patchSources {
|
||||
keptIDs[source.ID] = true
|
||||
}
|
||||
for _, old := range previousSources {
|
||||
if !keptIDs[old.ID] && d.SubSourceCache != nil {
|
||||
delete(d.SubSourceCache, old.ID)
|
||||
}
|
||||
}
|
||||
for _, source := range patchSources {
|
||||
found := false
|
||||
for _, old := range previousSources {
|
||||
if old.ID == source.ID && old.URL == source.URL {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
patchRefreshIDs = append(patchRefreshIDs, source.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if body["manualLinks"] != nil {
|
||||
if len(patchLinks) > 0 {
|
||||
item.ManualLinks = patchLinks
|
||||
} else {
|
||||
item.ManualLinks = nil
|
||||
}
|
||||
}
|
||||
if wasActive && item.Status == "suspended" {
|
||||
_, _ = SuspendCustomerResources(d, item, actor)
|
||||
}
|
||||
@@ -1764,6 +2243,11 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if body["nodeIds"] != nil {
|
||||
if err := AlignCustomerNodes(d, item, asIds(body["nodeIds"]), actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
item.UpdatedAt = NowISO()
|
||||
Audit(d, actor, "update_customer", item.ID, nil)
|
||||
return cloneJSONValue(item), nil
|
||||
@@ -1771,9 +2255,68 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if len(patchRefreshIDs) > 0 {
|
||||
a.RefreshSourcesInBackground(params["id"], patchRefreshIDs)
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{"customer": customerAny}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if params, ok := RoutePattern("/api/customers/:id/credentials", pathname); ok && r.Method == http.MethodGet {
|
||||
data := a.store.Snapshot()
|
||||
customer := findCustomer(data, params["id"])
|
||||
if customer == nil {
|
||||
return true, httpErr(404, "客户不存在")
|
||||
}
|
||||
SendJSON(w, 200, customerCredentialsView(data, customer), nil)
|
||||
return true, nil
|
||||
}
|
||||
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, "客户不存在")
|
||||
}
|
||||
// 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.CustomerID != customer.ID || nc.Status != "active" {
|
||||
continue
|
||||
}
|
||||
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{"nodesRebuilt": len(rebuilt)})
|
||||
view = customerCredentialsView(d, customer)
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
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 {
|
||||
result := a.RefreshOneSource(params["id"], params["sid"])
|
||||
if result == nil {
|
||||
return true, httpErr(404, "订阅源不存在")
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{
|
||||
"ok": result.OK, "count": len(result.Items), "dropped": result.Dropped, "error": strOrNil(result.Error),
|
||||
}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if params, ok := RoutePattern("/api/customers/:id/reset-usage", pathname); ok && r.Method == http.MethodPost {
|
||||
_, err := a.store.Transaction(func(d *Data) (any, error) {
|
||||
item := findCustomer(d, params["id"])
|
||||
@@ -1838,6 +2381,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
return nil, httpErr(409, "客户仍有节点分配。请先移除全部分配")
|
||||
}
|
||||
}
|
||||
removedCustomer := d.Customers[index]
|
||||
for _, source := range removedCustomer.ExternalSources {
|
||||
if d.SubSourceCache != nil {
|
||||
delete(d.SubSourceCache, source.ID)
|
||||
}
|
||||
}
|
||||
d.Customers = append(d.Customers[:index], d.Customers[index+1:]...)
|
||||
access := d.SubscriptionAccess[:0]
|
||||
for _, item := range d.SubscriptionAccess {
|
||||
@@ -1872,14 +2421,31 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
|
||||
if r.Method == http.MethodGet && pathname == "/api/nodes" {
|
||||
data := a.store.Snapshot()
|
||||
nowMs := time.Now().UnixMilli()
|
||||
sortedNodes := append([]*Node{}, data.Nodes...)
|
||||
sort.SliceStable(sortedNodes, func(i, j int) bool { return sortedNodes[i].SortIndex < sortedNodes[j].SortIndex })
|
||||
nodes := []map[string]any{}
|
||||
visibleIDs := map[string]bool{}
|
||||
for _, item := range data.Nodes {
|
||||
for _, item := range sortedNodes {
|
||||
if item.PendingDelete {
|
||||
continue
|
||||
}
|
||||
visibleIDs[item.ID] = true
|
||||
nodes = append(nodes, PublicNode(item))
|
||||
out := PublicNode(item)
|
||||
clientCount := 0
|
||||
for _, nc := range data.NodeClients {
|
||||
if nc.NodeID == item.ID {
|
||||
clientCount++
|
||||
}
|
||||
}
|
||||
onlineIDs := NodeOnlineCustomerIds(data, item, nowMs)
|
||||
out["clientCount"] = clientCount
|
||||
out["onlineCount"] = len(onlineIDs)
|
||||
out["onlineCustomerIds"] = onlineIDs
|
||||
up, down := NodeSpeedBps(item, nowMs)
|
||||
out["speedUpBps"] = up
|
||||
out["speedDownBps"] = down
|
||||
nodes = append(nodes, out)
|
||||
}
|
||||
nodeClients := []map[string]any{}
|
||||
for _, nc := range data.NodeClients {
|
||||
@@ -1899,6 +2465,44 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
SendJSON(w, 200, map[string]any{"nodes": nodes, "nodeClients": nodeClients}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if r.Method == http.MethodPost && pathname == "/api/nodes/reorder" {
|
||||
body, err := ReadJSON(r, defaultMaxBody)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
_, err = a.store.Transaction(func(d *Data) (any, error) {
|
||||
ids := asIds(body["ids"])
|
||||
byID := map[string]*Node{}
|
||||
for _, item := range d.Nodes {
|
||||
byID[item.ID] = item
|
||||
}
|
||||
ordered := []*Node{}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
if node := byID[id]; node != nil && !seen[id] {
|
||||
seen[id] = true
|
||||
ordered = append(ordered, node)
|
||||
}
|
||||
}
|
||||
rest := append([]*Node{}, d.Nodes...)
|
||||
sort.SliceStable(rest, func(i, j int) bool { return rest[i].SortIndex < rest[j].SortIndex })
|
||||
for _, item := range rest {
|
||||
if !seen[item.ID] {
|
||||
ordered = append(ordered, item)
|
||||
}
|
||||
}
|
||||
for index, item := range ordered {
|
||||
item.SortIndex = index
|
||||
}
|
||||
Audit(d, actor, "reorder_nodes", "nodes", map[string]any{"count": len(ordered)})
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{"ok": true}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if r.Method == http.MethodPost && pathname == "/api/nodes" {
|
||||
body, err := ReadJSON(r, defaultMaxBody)
|
||||
if err != nil {
|
||||
@@ -1916,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
|
||||
@@ -1926,6 +2536,13 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
next.CreatedAt = NowISO()
|
||||
next.UpdatedAt = NowISO()
|
||||
EnsureNodeSecrets(next)
|
||||
maxIndex := -1
|
||||
for _, item := range d.Nodes {
|
||||
if item.SortIndex > maxIndex {
|
||||
maxIndex = item.SortIndex
|
||||
}
|
||||
}
|
||||
next.SortIndex = maxIndex + 1
|
||||
d.Nodes = append(d.Nodes, next)
|
||||
Audit(d, actor, "create_node", next.ID, map[string]any{"name": next.Name})
|
||||
return cloneJSONValue(next), nil
|
||||
@@ -1951,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" {
|
||||
@@ -1980,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
|
||||
|
||||
@@ -194,6 +194,15 @@ func (a *App) housekeeping() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Monthly traffic resets run before the quota evaluation below,
|
||||
// so a customer whose new period just started is judged on the
|
||||
// zeroed counters (and a reset can lift a traffic suspension in
|
||||
// the same pass). Due-ness derives from the persisted period
|
||||
// marker, so this also catches up resets missed while the panel
|
||||
// was down — at the first pass after it is back (<=60s).
|
||||
if err := ApplyMonthlyTrafficResets(d, time.Now()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, customer := range d.Customers {
|
||||
expired := false
|
||||
if customer.ExpiresAt != nil {
|
||||
@@ -263,6 +272,10 @@ func (a *App) housekeeping() {
|
||||
return
|
||||
}
|
||||
a.getSessions().Prune()
|
||||
// System-status sampling + due external-source refreshes (outside the
|
||||
// transaction; fetches must not hold the store lock).
|
||||
a.monitor().Sample(a.store.Snapshot(), false)
|
||||
a.RefreshDueSources()
|
||||
}
|
||||
|
||||
// ---------- main ----------
|
||||
@@ -352,6 +365,32 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Node sort order migration (idempotent): unless the stored
|
||||
// sortIndex values are exactly the permutation 0..n-1, renumber by
|
||||
// storage order. Mirrors the Node implementation's boot migration.
|
||||
{
|
||||
nodes := store.Snapshot().Nodes
|
||||
seen := map[int]bool{}
|
||||
valid := true
|
||||
for _, node := range nodes {
|
||||
if node.SortIndex < 0 || node.SortIndex >= len(nodes) || seen[node.SortIndex] {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
seen[node.SortIndex] = true
|
||||
}
|
||||
if !valid {
|
||||
if _, err := store.Transaction(func(d *Data) (any, error) {
|
||||
for index, node := range d.Nodes {
|
||||
node.SortIndex = index
|
||||
}
|
||||
return nil, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app := NewApp(store, nil, NewOrchestrator(store))
|
||||
app.replaceSessions(NewSessionManager(app.sessionHours()))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+265
-77
@@ -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,12 +642,21 @@ 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 exitServer != nil {
|
||||
targets = append(targets, exitServer)
|
||||
}
|
||||
for _, target := range targets {
|
||||
if target.Enabled != nil && !*target.Enabled {
|
||||
return httpErr(409, "设备 %s 已停用", target.Name)
|
||||
}
|
||||
}
|
||||
if len(activeNodeClients(d, node)) == 0 {
|
||||
return httpErr(400, "请先给节点分配至少一个客户再部署")
|
||||
}
|
||||
@@ -657,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()
|
||||
@@ -702,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
|
||||
@@ -720,6 +765,10 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
|
||||
if server == nil {
|
||||
return 0, orchNotFound("服务器")
|
||||
}
|
||||
// A disabled device receives no new deployments, including reconciles.
|
||||
if server.Enabled != nil && !*server.Enabled {
|
||||
return 0, nil
|
||||
}
|
||||
failInFlight := true
|
||||
if opts.FailInFlight != nil {
|
||||
failInFlight = *opts.FailInFlight
|
||||
@@ -935,61 +984,178 @@ func (o *Orchestrator) RepairNode(nodeID, actor string) (bool, error) {
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// assignCustomerData is the transaction-free core of AssignCustomer,
|
||||
// shared with the customer form's node alignment (AlignCustomerNodes).
|
||||
func assignCustomerData(d *Data, nodeID, customerID, actor string) (*NodeClient, error) {
|
||||
node := orchFindNode(d, nodeID)
|
||||
if node == nil {
|
||||
return nil, orchNotFound("节点")
|
||||
}
|
||||
customer := orchFindCustomer(d, customerID)
|
||||
if customer == nil {
|
||||
return nil, orchNotFound("客户")
|
||||
}
|
||||
if customer.Status != "active" {
|
||||
return nil, httpErr(409, "只能给状态正常的客户分配节点")
|
||||
}
|
||||
var nc *NodeClient
|
||||
for _, entry := range d.NodeClients {
|
||||
if entry.NodeID == nodeID && entry.CustomerID == customerID {
|
||||
nc = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if nc != nil && nc.Status == "active" {
|
||||
return nil, httpErr(409, "该客户已分配到此节点")
|
||||
}
|
||||
if nc != nil {
|
||||
nc.Status = "active"
|
||||
nc.UpdatedAt = NowISO()
|
||||
} else {
|
||||
now := NowISO()
|
||||
ensureCustomerCredentials(d, customerID)
|
||||
nc = &NodeClient{
|
||||
ID: NewID("ncl"),
|
||||
NodeID: nodeID,
|
||||
CustomerID: customerID,
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
d.NodeClients = append(d.NodeClients, nc)
|
||||
}
|
||||
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
|
||||
// Record the assignment only.
|
||||
} else {
|
||||
if node.Status == "removing" && boolVal(node.AutoRemoving) {
|
||||
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
||||
node.AutoRemoving = BoolPtr(false)
|
||||
}
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
|
||||
return nc, nil
|
||||
}
|
||||
|
||||
func unassignCustomerData(d *Data, nodeID, customerID, actor string) error {
|
||||
node := orchFindNode(d, nodeID)
|
||||
if node == nil {
|
||||
return orchNotFound("节点")
|
||||
}
|
||||
index := -1
|
||||
for i, entry := range d.NodeClients {
|
||||
if entry.NodeID == nodeID && entry.CustomerID == customerID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return httpErr(404, "该客户未分配到此节点")
|
||||
}
|
||||
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
|
||||
if node.Status != "draft" && node.Status != "removing" {
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// AlignCustomerNodes mirrors alignCustomerNodes in lib/orchestrator.js:
|
||||
// full-set alignment of one customer's node bindings (customer form's
|
||||
// 关联入站 multi-select). Suspended customers get suspended bindings.
|
||||
func AlignCustomerNodes(d *Data, customer *Customer, desiredIds []string, actor string) error {
|
||||
desired := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, nodeID := range desiredIds {
|
||||
if nodeID == "" || seen[nodeID] {
|
||||
continue
|
||||
}
|
||||
seen[nodeID] = true
|
||||
node := orchFindNode(d, nodeID)
|
||||
if node == nil || node.PendingDelete {
|
||||
return httpErr(400, "节点不存在")
|
||||
}
|
||||
desired = append(desired, nodeID)
|
||||
}
|
||||
rebuild := map[string]bool{}
|
||||
kept := []*NodeClient{}
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.CustomerID != customer.ID {
|
||||
kept = append(kept, nc)
|
||||
continue
|
||||
}
|
||||
if seen[nc.NodeID] {
|
||||
kept = append(kept, nc)
|
||||
continue
|
||||
}
|
||||
Audit(d, actor, "unassign_customer", nc.NodeID, map[string]any{"customerId": customer.ID})
|
||||
rebuild[nc.NodeID] = true
|
||||
}
|
||||
d.NodeClients = kept
|
||||
for _, nodeID := range desired {
|
||||
exists := false
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.CustomerID == customer.ID && nc.NodeID == nodeID {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
node := orchFindNode(d, nodeID)
|
||||
status := "suspended"
|
||||
if customer.Status == "active" {
|
||||
status = "active"
|
||||
}
|
||||
now := NowISO()
|
||||
ensureCustomerCredentials(d, customer.ID)
|
||||
d.NodeClients = append(d.NodeClients, &NodeClient{
|
||||
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" {
|
||||
rebuild[nodeID] = true
|
||||
}
|
||||
}
|
||||
for nodeID := range rebuild {
|
||||
node := orchFindNode(d, nodeID)
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
|
||||
continue
|
||||
}
|
||||
if node.Status == "removing" && boolVal(node.AutoRemoving) {
|
||||
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
||||
node.AutoRemoving = BoolPtr(false)
|
||||
}
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) AssignCustomer(nodeID, customerID, actor string) (*NodeClient, error) {
|
||||
if actor == "" {
|
||||
actor = "admin"
|
||||
}
|
||||
var result *NodeClient
|
||||
err := o.tx(func(d *Data) error {
|
||||
node := orchFindNode(d, nodeID)
|
||||
if node == nil {
|
||||
return orchNotFound("节点")
|
||||
nc, err := assignCustomerData(d, nodeID, customerID, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
customer := orchFindCustomer(d, customerID)
|
||||
if customer == nil {
|
||||
return orchNotFound("客户")
|
||||
}
|
||||
if customer.Status != "active" {
|
||||
return httpErr(409, "只能给状态正常的客户分配节点")
|
||||
}
|
||||
var nc *NodeClient
|
||||
for _, entry := range d.NodeClients {
|
||||
if entry.NodeID == nodeID && entry.CustomerID == customerID {
|
||||
nc = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if nc != nil && nc.Status == "active" {
|
||||
return httpErr(409, "该客户已分配到此节点")
|
||||
}
|
||||
if nc != nil {
|
||||
nc.Status = "active"
|
||||
nc.UpdatedAt = NowISO()
|
||||
} else {
|
||||
now := NowISO()
|
||||
nc = &NodeClient{
|
||||
ID: NewID("ncl"),
|
||||
NodeID: nodeID,
|
||||
CustomerID: customerID,
|
||||
Credentials: NewNodeClientCredentials(node.Protocol),
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
d.NodeClients = append(d.NodeClients, nc)
|
||||
}
|
||||
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
|
||||
// Record the assignment only.
|
||||
} else {
|
||||
if node.Status == "removing" && boolVal(node.AutoRemoving) {
|
||||
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
||||
node.AutoRemoving = BoolPtr(false)
|
||||
}
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
|
||||
result = cloneNodeClientRecord(nc)
|
||||
return nil
|
||||
})
|
||||
@@ -1001,28 +1167,7 @@ func (o *Orchestrator) UnassignCustomer(nodeID, customerID, actor string) (bool,
|
||||
actor = "admin"
|
||||
}
|
||||
err := o.tx(func(d *Data) error {
|
||||
node := orchFindNode(d, nodeID)
|
||||
if node == nil {
|
||||
return orchNotFound("节点")
|
||||
}
|
||||
index := -1
|
||||
for i, entry := range d.NodeClients {
|
||||
if entry.NodeID == nodeID && entry.CustomerID == customerID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return httpErr(404, "该客户未分配到此节点")
|
||||
}
|
||||
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
|
||||
if node.Status != "draft" && node.Status != "removing" {
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
|
||||
return nil
|
||||
return unassignCustomerData(d, nodeID, customerID, actor)
|
||||
})
|
||||
return err == nil, err
|
||||
}
|
||||
@@ -1298,6 +1443,9 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
|
||||
slot = &UsageSlot{}
|
||||
customer.ServerUsage[key] = slot
|
||||
}
|
||||
prevCounters := slot.UsageCounters
|
||||
prevSampleISO := slot.LastSampleAt
|
||||
restarted := prevCounters != nil && prevCounters.Epoch != nil && sampleEpoch != nil && *sampleEpoch != *prevCounters.Epoch
|
||||
delta := ApplyDelta(SlotHolder(slot), uplink, downlink, sampleEpoch)
|
||||
if delta == nil {
|
||||
continue
|
||||
@@ -1307,11 +1455,33 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
|
||||
customer.UsedBytes += delta.UpDelta + delta.DownDelta
|
||||
customer.LastUsageAt = StrPtr(reportedAt)
|
||||
customer.UpdatedAt = reportedAt
|
||||
slot.LastSampleAt = StrPtr(reportedAt)
|
||||
if delta.UpDelta > 0 || delta.DownDelta > 0 {
|
||||
slot.LastActiveAt = StrPtr(reportedAt)
|
||||
customer.LastOnlineAt = StrPtr(reportedAt)
|
||||
}
|
||||
if !restarted && prevCounters != nil && prevSampleISO != nil {
|
||||
if prevMs, ok1 := ParseISOMs(*prevSampleISO); ok1 {
|
||||
if nowMs, ok2 := ParseISOMs(reportedAt); ok2 {
|
||||
dt := float64(nowMs-prevMs) / 1000
|
||||
if dt >= 5 {
|
||||
up := float64(int64(math.Round(float64(uplink-prevCounters.Uplink) / dt)))
|
||||
down := float64(int64(math.Round(float64(downlink-prevCounters.Downlink) / dt)))
|
||||
slot.SpeedUpBps = &up
|
||||
slot.SpeedDownBps = &down
|
||||
slot.SpeedAt = StrPtr(reportedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "inbound":
|
||||
node := orchFindNode(d, sample.ID)
|
||||
if node == nil || node.ServerID != serverID {
|
||||
continue
|
||||
}
|
||||
prevCounters := node.UsageCounters
|
||||
prevSampleISO := node.LastSampleAt
|
||||
restarted := prevCounters != nil && prevCounters.Epoch != nil && sampleEpoch != nil && *sampleEpoch != *prevCounters.Epoch
|
||||
delta := ApplyDelta(NodeHolder(node), uplink, downlink, sampleEpoch)
|
||||
if delta == nil {
|
||||
continue
|
||||
@@ -1323,6 +1493,24 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
|
||||
}
|
||||
node.MeteredTraffic = &MeteredTraffic{Uplink: mu + delta.UpDelta, Downlink: md + delta.DownDelta}
|
||||
node.LastUsageAt = StrPtr(reportedAt)
|
||||
node.LastSampleAt = StrPtr(reportedAt)
|
||||
if delta.UpDelta > 0 || delta.DownDelta > 0 {
|
||||
node.LastActiveAt = StrPtr(reportedAt)
|
||||
}
|
||||
if !restarted && prevCounters != nil && prevSampleISO != nil {
|
||||
if prevMs, ok1 := ParseISOMs(*prevSampleISO); ok1 {
|
||||
if nowMs, ok2 := ParseISOMs(reportedAt); ok2 {
|
||||
dt := float64(nowMs-prevMs) / 1000
|
||||
if dt >= 5 {
|
||||
up := float64(int64(math.Round(float64(uplink-prevCounters.Uplink) / dt)))
|
||||
down := float64(int64(math.Round(float64(downlink-prevCounters.Downlink) / dt)))
|
||||
node.SpeedUpBps = &up
|
||||
node.SpeedDownBps = &down
|
||||
node.SpeedAt = StrPtr(reportedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
// Presence & speed derivation — mirrors lib/presence.js. See that file
|
||||
// for the signal model (per-server user counters, per-node inbound
|
||||
// counters, AnyTLS observations; 3-minute window; server-level activity
|
||||
// attributed to the customer's nodes on that server).
|
||||
|
||||
import "time"
|
||||
|
||||
const PresenceWindowMs = 180000
|
||||
const SpeedStaleMs = 150000
|
||||
|
||||
func parseISOMsOr0(value *string) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
ms, ok := ParseISOMs(*value)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
func CustomerOnline(customer *Customer, nowMs int64) bool {
|
||||
ms := parseISOMsOr0(customer.LastOnlineAt)
|
||||
return ms > 0 && nowMs-ms <= PresenceWindowMs
|
||||
}
|
||||
|
||||
func CustomerSpeedBps(customer *Customer, nowMs int64) (float64, float64) {
|
||||
var up, down float64
|
||||
for _, slot := range customer.ServerUsage {
|
||||
if slot == nil || slot.SpeedAt == nil {
|
||||
continue
|
||||
}
|
||||
if nowMs-parseISOMsOr0(slot.SpeedAt) > SpeedStaleMs {
|
||||
continue
|
||||
}
|
||||
if slot.SpeedUpBps != nil {
|
||||
up += *slot.SpeedUpBps
|
||||
}
|
||||
if slot.SpeedDownBps != nil {
|
||||
down += *slot.SpeedDownBps
|
||||
}
|
||||
}
|
||||
return up, down
|
||||
}
|
||||
|
||||
func NodeSpeedBps(node *Node, nowMs int64) (float64, float64) {
|
||||
if node.SpeedAt == nil || nowMs-parseISOMsOr0(node.SpeedAt) > SpeedStaleMs {
|
||||
return 0, 0
|
||||
}
|
||||
var up, down float64
|
||||
if node.SpeedUpBps != nil {
|
||||
up = *node.SpeedUpBps
|
||||
}
|
||||
if node.SpeedDownBps != nil {
|
||||
down = *node.SpeedDownBps
|
||||
}
|
||||
return up, down
|
||||
}
|
||||
|
||||
// NodeOnlineCustomerIds mirrors nodeOnlineCustomerIds in lib/presence.js.
|
||||
func NodeOnlineCustomerIds(d *Data, node *Node, nowMs int64) []string {
|
||||
out := []string{}
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.NodeID != node.ID || nc.Status != "active" {
|
||||
continue
|
||||
}
|
||||
customer := findCustomer(d, nc.CustomerID)
|
||||
if customer == nil {
|
||||
continue
|
||||
}
|
||||
var ms int64
|
||||
if slot := customer.ServerUsage["srv:"+node.ServerID]; slot != nil && slot.LastActiveAt != nil {
|
||||
ms = parseISOMsOr0(slot.LastActiveAt)
|
||||
} else {
|
||||
ms = parseISOMsOr0(customer.LastOnlineAt)
|
||||
}
|
||||
if ms > 0 && nowMs-ms <= PresenceWindowMs {
|
||||
out = append(out, customer.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func CountOnlineCustomers(d *Data, now time.Time) int {
|
||||
count := 0
|
||||
for _, customer := range d.Customers {
|
||||
if CustomerOnline(customer, now.UnixMilli()) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
+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"}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
// Monthly traffic reset. A customer with trafficResetDay (1-31) gets the
|
||||
// cycle counters (usedBytes / usedUplinkBytes / usedDownlinkBytes — the
|
||||
// same fields the quota checks and the subscription userinfo read) zeroed
|
||||
// once per monthly period, from the regular 60s housekeeping pass.
|
||||
//
|
||||
// The design is deliberately stateless and restart-safe: whether a reset
|
||||
// is due is derived only from the persisted trafficResetPeriod marker, so
|
||||
// a reset instant missed while the panel was down fires at the first
|
||||
// housekeeping pass after it comes back — no in-memory schedule to lose.
|
||||
// All dates are panel-local, matching the rest of housekeeping.
|
||||
|
||||
import "time"
|
||||
|
||||
// daysInMonth returns the number of days in the given month (pure
|
||||
// calendar math, location-independent).
|
||||
func daysInMonth(year int, month time.Month) int {
|
||||
// Day 0 of the next month is the last day of this one.
|
||||
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
}
|
||||
|
||||
// resetInstant returns the scheduled reset instant inside the given
|
||||
// month: the reset day clamped to the month's length (the 31st fires on
|
||||
// Feb 28), at 00:00 in loc.
|
||||
func resetInstant(year int, month time.Month, day int, loc *time.Location) time.Time {
|
||||
if dim := daysInMonth(year, month); day > dim {
|
||||
day = dim
|
||||
}
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
func prevMonth(year int, month time.Month) (int, time.Month) {
|
||||
if month == time.January {
|
||||
return year - 1, time.December
|
||||
}
|
||||
return year, month - 1
|
||||
}
|
||||
|
||||
// TrafficResetPeriodKey names the monthly period `now` belongs to for the
|
||||
// given reset day: the "2006-01" of the most recent scheduled reset
|
||||
// instant at or before now. Crossing a reset instant flips the key.
|
||||
func TrafficResetPeriodKey(now time.Time, day int) string {
|
||||
loc := now.Location()
|
||||
year, month, _ := now.Date()
|
||||
cand := resetInstant(year, month, day, loc)
|
||||
if now.Before(cand) {
|
||||
// This month's reset instant is still ahead: the current period
|
||||
// began at last month's reset instant.
|
||||
year, month = prevMonth(year, month)
|
||||
cand = resetInstant(year, month, day, loc)
|
||||
}
|
||||
return cand.Format("2006-01")
|
||||
}
|
||||
|
||||
// ArmTrafficReset (re)arms a customer's period marker for its current
|
||||
// reset day WITHOUT zeroing usage: the schedule takes effect from the next
|
||||
// reset instant. Used when the reset day is set or changed through the
|
||||
// API, and for customers whose marker is empty (data written before this
|
||||
// feature existed, or externally — e.g. by the future 3x-ui converter,
|
||||
// which only has to write trafficResetDay).
|
||||
func ArmTrafficReset(customer *Customer, now time.Time) {
|
||||
if customer.TrafficResetDay < 1 || customer.TrafficResetDay > 31 {
|
||||
customer.TrafficResetPeriod = ""
|
||||
return
|
||||
}
|
||||
customer.TrafficResetPeriod = TrafficResetPeriodKey(now, customer.TrafficResetDay)
|
||||
}
|
||||
|
||||
// ApplyMonthlyTrafficResets performs the due monthly resets for all
|
||||
// customers in d as of now. Idempotent per period via the persisted
|
||||
// marker; safe to call on every housekeeping pass.
|
||||
func ApplyMonthlyTrafficResets(d *Data, now time.Time) error {
|
||||
for _, customer := range d.Customers {
|
||||
day := customer.TrafficResetDay
|
||||
if day < 1 || day > 31 {
|
||||
continue
|
||||
}
|
||||
key := TrafficResetPeriodKey(now, day)
|
||||
if customer.TrafficResetPeriod == "" {
|
||||
// First sight: arm the marker only — usage already accrued in
|
||||
// the current period must survive until the next reset day.
|
||||
customer.TrafficResetPeriod = key
|
||||
continue
|
||||
}
|
||||
if customer.TrafficResetPeriod == key {
|
||||
continue
|
||||
}
|
||||
// A new period has begun since the last applied reset. Zero the
|
||||
// cycle counters exactly like the manual reset-usage action (the
|
||||
// model has no separate lifetime counter: these three fields are
|
||||
// also what subscriptions report as upload/download).
|
||||
previousUsed := customer.UsedBytes
|
||||
customer.UsedBytes = 0
|
||||
customer.UsedUplinkBytes = 0
|
||||
customer.UsedDownlinkBytes = 0
|
||||
customer.TrafficResetPeriod = key
|
||||
resetAt := ISOFromTime(now)
|
||||
customer.TrafficResetAt = &resetAt
|
||||
customer.UpdatedAt = resetAt
|
||||
Audit(d, "system:monthly_reset", "reset_customer_usage_monthly", customer.ID, map[string]any{
|
||||
"previousUsedBytes": previousUsed,
|
||||
"resetDay": day,
|
||||
"period": key,
|
||||
})
|
||||
// Mirror the manual reset: a customer suspended only for the
|
||||
// traffic limit comes back once nothing blocks it any more.
|
||||
if customer.Status == "suspended" && strVal(customer.SuspendReason) == "traffic_limit" &&
|
||||
CustomerBlockReason(d, customer) == "" {
|
||||
customer.Status = "active"
|
||||
customer.SuspendReason = nil
|
||||
customer.SuspendReasonPresent = true
|
||||
if _, err := ResumeCustomerResources(d, customer, "system:monthly_reset"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseResetDay validates a trafficResetDay request value: 0 disables
|
||||
// the schedule, 1-31 is the day of month. JSON null/absent means 0.
|
||||
func ParseResetDay(v any) (int, error) {
|
||||
n, err := NonNegativeNumber(v, "每月重置日", true)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n > 31 {
|
||||
return 0, httpErr(400, "每月重置日须在 0–31 之间(0 表示不重置)")
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package main
|
||||
|
||||
// Tests for the monthly traffic reset: period-key calendar math (incl.
|
||||
// short-month clamping), the housekeeping apply pass (due / arming /
|
||||
// idempotency / missed-period catch-up / suspension interplay), API
|
||||
// surface and persistence of the new customer fields.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func local(y int, m time.Month, d, hh, mm int) time.Time {
|
||||
return time.Date(y, m, d, hh, mm, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
func TestTrafficResetPeriodKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
now time.Time
|
||||
day int
|
||||
want string
|
||||
}{
|
||||
{local(2026, 10, 20, 10, 0), 15, "2026-10"}, // mid-period
|
||||
{local(2026, 10, 15, 0, 0), 15, "2026-10"}, // exactly at the instant: new period
|
||||
{local(2026, 10, 14, 23, 59), 15, "2026-09"},
|
||||
{local(2026, 10, 20, 0, 0), 20, "2026-10"}, // reset day == today
|
||||
{local(2026, 10, 1, 0, 0), 1, "2026-10"}, // day 1 at month start
|
||||
{local(2027, 1, 1, 0, 0), 31, "2026-12"}, // year rollover, before Jan 31
|
||||
{local(2026, 12, 31, 0, 0), 31, "2026-12"}, // at Dec 31 instant
|
||||
{local(2027, 2, 27, 12, 0), 31, "2027-01"}, // Feb has no 31st yet
|
||||
{local(2027, 2, 28, 0, 0), 31, "2027-02"}, // clamped instant: Feb 28
|
||||
{local(2027, 3, 1, 0, 0), 31, "2027-02"}, // still the Feb period
|
||||
{local(2027, 2, 28, 12, 0), 30, "2027-02"}, // day 30 clamps to Feb 28 too
|
||||
{local(2027, 2, 27, 12, 0), 30, "2027-01"},
|
||||
{local(2028, 2, 29, 0, 0), 29, "2028-02"}, // leap year: Feb 29 exists
|
||||
{local(2028, 3, 1, 0, 0), 29, "2028-02"},
|
||||
{local(2027, 3, 1, 0, 0), 29, "2027-02"}, // non-leap: day 29 clamps to Feb 28
|
||||
{local(2027, 2, 28, 0, 0), 29, "2027-02"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := TrafficResetPeriodKey(c.now, c.day); got != c.want {
|
||||
t.Errorf("key(%v, %d) = %q, want %q", c.now, c.day, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetAudits(d *Data, id string) int {
|
||||
n := 0
|
||||
for _, e := range d.Activity {
|
||||
if e.Action == "reset_customer_usage_monthly" && e.Target == id {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetDueAndIdempotent(t *testing.T) {
|
||||
c := &Customer{ID: "cus_a", Status: "active", TrafficLimitBytes: 1000,
|
||||
UsedBytes: 500, UsedUplinkBytes: 200, UsedDownlinkBytes: 300,
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
|
||||
d := &Data{Customers: []*Customer{c}}
|
||||
now := local(2026, 10, 20, 8, 0)
|
||||
if err := ApplyMonthlyTrafficResets(d, now); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if c.UsedBytes != 0 || c.UsedUplinkBytes != 0 || c.UsedDownlinkBytes != 0 {
|
||||
t.Fatalf("counters not zeroed: %+v", c)
|
||||
}
|
||||
if c.TrafficResetPeriod != "2026-10" {
|
||||
t.Fatalf("period = %q", c.TrafficResetPeriod)
|
||||
}
|
||||
if c.TrafficResetAt == nil || *c.TrafficResetAt != ISOFromTime(now) {
|
||||
t.Fatalf("resetAt = %v", c.TrafficResetAt)
|
||||
}
|
||||
if n := resetAudits(d, "cus_a"); n != 1 {
|
||||
t.Fatalf("audits = %d", n)
|
||||
}
|
||||
// Same period, fresh usage accrues: a second pass must not zero again.
|
||||
c.UsedBytes, c.UsedUplinkBytes, c.UsedDownlinkBytes = 50, 20, 30
|
||||
if err := ApplyMonthlyTrafficResets(d, now.Add(24*time.Hour)); err != nil {
|
||||
t.Fatalf("apply2: %v", err)
|
||||
}
|
||||
if c.UsedBytes != 50 {
|
||||
t.Fatalf("second pass zeroed within the same period: %d", c.UsedBytes)
|
||||
}
|
||||
if n := resetAudits(d, "cus_a"); n != 1 {
|
||||
t.Fatalf("audits after second pass = %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetNotYetDue(t *testing.T) {
|
||||
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 500,
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-10"}
|
||||
d := &Data{Customers: []*Customer{c}}
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 500 || c.TrafficResetAt != nil {
|
||||
t.Fatalf("not-due customer touched: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetArmsOnFirstSight(t *testing.T) {
|
||||
// Marker empty (legacy / converter-written data): arm only, the usage
|
||||
// accrued in the current period must survive.
|
||||
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 500,
|
||||
TrafficResetDay: 15}
|
||||
d := &Data{Customers: []*Customer{c}}
|
||||
now := local(2026, 10, 20, 0, 0)
|
||||
if err := ApplyMonthlyTrafficResets(d, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 500 {
|
||||
t.Fatalf("first sight zeroed usage: %d", c.UsedBytes)
|
||||
}
|
||||
if c.TrafficResetPeriod != "2026-10" || c.TrafficResetAt != nil {
|
||||
t.Fatalf("arming wrong: period=%q at=%v", c.TrafficResetPeriod, c.TrafficResetAt)
|
||||
}
|
||||
// And the armed schedule really fires at the next reset instant.
|
||||
c.UsedBytes = 700
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 11, 15, 0, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 0 || c.TrafficResetPeriod != "2026-11" {
|
||||
t.Fatalf("armed schedule did not fire: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetCatchUpMissedPeriods(t *testing.T) {
|
||||
// Panel down across several reset days (restart catch-up): exactly
|
||||
// one reset, landing in the current period.
|
||||
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 900,
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-06"}
|
||||
d := &Data{Customers: []*Customer{c}}
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 0 || c.TrafficResetPeriod != "2026-10" {
|
||||
t.Fatalf("catch-up wrong: %+v", c)
|
||||
}
|
||||
if n := resetAudits(d, "cus_a"); n != 1 {
|
||||
t.Fatalf("catch-up audits = %d, want exactly 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetShortMonthClamp(t *testing.T) {
|
||||
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 100,
|
||||
TrafficResetDay: 31, TrafficResetPeriod: "2027-01"}
|
||||
d := &Data{Customers: []*Customer{c}}
|
||||
// Feb 27: the clamped instant (Feb 28) has not arrived — no fire.
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2027, 2, 27, 23, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 100 {
|
||||
t.Fatalf("fired before the clamped instant")
|
||||
}
|
||||
// Feb 28 00:01: fires, period is February's.
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2027, 2, 28, 0, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 0 || c.TrafficResetPeriod != "2027-02" {
|
||||
t.Fatalf("clamp fire wrong: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetIndependentDays(t *testing.T) {
|
||||
a := &Customer{ID: "cus_a", Status: "active", UsedBytes: 100,
|
||||
TrafficResetDay: 1, TrafficResetPeriod: "2026-09"}
|
||||
b := &Customer{ID: "cus_b", Status: "active", UsedBytes: 200,
|
||||
TrafficResetDay: 25, TrafficResetPeriod: "2026-09"}
|
||||
d := &Data{Customers: []*Customer{a, b}}
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.UsedBytes != 0 {
|
||||
t.Errorf("day-1 customer not reset")
|
||||
}
|
||||
if b.UsedBytes != 200 || b.TrafficResetAt != nil {
|
||||
t.Errorf("day-25 customer wrongly reset: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetUnsetOrInvalidUntouched(t *testing.T) {
|
||||
off := &Customer{ID: "cus_off", Status: "active", UsedBytes: 100}
|
||||
bad := &Customer{ID: "cus_bad", Status: "active", UsedBytes: 100,
|
||||
TrafficResetDay: 45, TrafficResetPeriod: "2020-01"}
|
||||
neg := &Customer{ID: "cus_neg", Status: "active", UsedBytes: 100,
|
||||
TrafficResetDay: -3, TrafficResetPeriod: "2020-01"}
|
||||
d := &Data{Customers: []*Customer{off, bad, neg}}
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range d.Customers {
|
||||
if c.UsedBytes != 100 || c.TrafficResetAt != nil {
|
||||
t.Errorf("customer %s touched: %+v", c.ID, c)
|
||||
}
|
||||
}
|
||||
if off.TrafficResetPeriod != "" {
|
||||
t.Errorf("unset customer got a marker: %q", off.TrafficResetPeriod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetResumesTrafficSuspended(t *testing.T) {
|
||||
reason := "traffic_limit"
|
||||
c := &Customer{ID: "cus_a", Status: "suspended", SuspendReason: &reason,
|
||||
SuspendReasonPresent: true, TrafficLimitBytes: 1000,
|
||||
UsedBytes: 1500, UsedUplinkBytes: 500, UsedDownlinkBytes: 1000,
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
|
||||
d := &Data{Customers: []*Customer{c}}
|
||||
if got := CustomerBlockReason(d, c); got != "traffic_limit" {
|
||||
t.Fatalf("precondition block reason = %q", got)
|
||||
}
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 0 || c.Status != "active" || c.SuspendReason != nil {
|
||||
t.Fatalf("not resumed after reset: %+v", c)
|
||||
}
|
||||
if got := CustomerBlockReason(d, c); got != "" {
|
||||
t.Fatalf("block reason after reset = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetKeepsOtherSuspensions(t *testing.T) {
|
||||
manual := "manual"
|
||||
expired := "expired"
|
||||
past := "2020-01-01T00:00:00.000Z"
|
||||
a := &Customer{ID: "cus_m", Status: "suspended", SuspendReason: &manual,
|
||||
SuspendReasonPresent: true, UsedBytes: 100,
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
|
||||
b := &Customer{ID: "cus_e", Status: "suspended", SuspendReason: &expired,
|
||||
SuspendReasonPresent: true, UsedBytes: 100, ExpiresAt: &past,
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
|
||||
d := &Data{Customers: []*Customer{a, b}}
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Counters reset (a fresh cycle awaits renewal), status untouched.
|
||||
if a.UsedBytes != 0 || b.UsedBytes != 0 {
|
||||
t.Fatalf("counters not reset: %d %d", a.UsedBytes, b.UsedBytes)
|
||||
}
|
||||
if a.Status != "suspended" || b.Status != "suspended" {
|
||||
t.Fatalf("suspension lifted wrongly: %s %s", a.Status, b.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResetDay(t *testing.T) {
|
||||
for in, want := range map[any]int{nil: 0, float64(0): 0, float64(1): 1, float64(31): 31, json.Number("15"): 15} {
|
||||
got, err := ParseResetDay(in)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("ParseResetDay(%v) = %d, %v; want %d", in, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []any{float64(32), float64(2.5), float64(-1)} {
|
||||
if _, err := ParseResetDay(in); err == nil {
|
||||
t.Errorf("ParseResetDay(%v) accepted", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetFieldsPersistAndOmitWhenUnset(t *testing.T) {
|
||||
s, file := newTestStore(t)
|
||||
at := "2026-10-15T00:00:00.000Z"
|
||||
_, err := s.Transaction(func(d *Data) (any, error) {
|
||||
d.Customers = append(d.Customers,
|
||||
&Customer{ID: "cus_set", Name: "set", Status: "active",
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-10", TrafficResetAt: &at},
|
||||
&Customer{ID: "cus_unset", Name: "unset", Status: "active"})
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"trafficResetDay":15`) ||
|
||||
!strings.Contains(string(raw), `"trafficResetPeriod":"2026-10"`) ||
|
||||
!strings.Contains(string(raw), `"trafficResetAt":"2026-10-15T00:00:00.000Z"`) {
|
||||
t.Fatalf("reset fields not persisted: %s", raw)
|
||||
}
|
||||
// Reload through a fresh store: fields survive the round trip.
|
||||
s2 := NewStore(file)
|
||||
if err := s2.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var set, unset *Customer
|
||||
for _, c := range s2.Data.Customers {
|
||||
if c.ID == "cus_set" {
|
||||
set = c
|
||||
} else {
|
||||
unset = c
|
||||
}
|
||||
}
|
||||
if set == nil || set.TrafficResetDay != 15 || set.TrafficResetPeriod != "2026-10" ||
|
||||
set.TrafficResetAt == nil || *set.TrafficResetAt != at {
|
||||
t.Fatalf("round trip lost fields: %+v", set)
|
||||
}
|
||||
// Unset customer: none of the new keys may appear in its JSON.
|
||||
out, err := json.Marshal(unset)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(out), "trafficReset") {
|
||||
t.Fatalf("unset customer gained reset keys: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHousekeepingAppliesMonthlyReset(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.Now()
|
||||
day := now.Day()
|
||||
_, err := s.Transaction(func(d *Data) (any, error) {
|
||||
d.Customers = append(d.Customers, &Customer{ID: "cus_a", Name: "a", Status: "active",
|
||||
UsedBytes: 123, TrafficResetDay: day, TrafficResetPeriod: "2000-01"})
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp(s, NewSessionManager(12), NewOrchestrator(s))
|
||||
app.housekeeping()
|
||||
c := s.Snapshot().Customers[0]
|
||||
if c.UsedBytes != 0 {
|
||||
t.Fatalf("housekeeping did not reset: %d", c.UsedBytes)
|
||||
}
|
||||
if want := TrafficResetPeriodKey(now, day); c.TrafficResetPeriod != want {
|
||||
t.Fatalf("period = %q, want %q", c.TrafficResetPeriod, want)
|
||||
}
|
||||
// Second pass with fresh usage: no double reset through the real loop.
|
||||
_, err = s.Transaction(func(d *Data) (any, error) {
|
||||
d.Customers[0].UsedBytes = 7
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.housekeeping()
|
||||
if got := s.Snapshot().Customers[0].UsedBytes; got != 7 {
|
||||
t.Fatalf("second housekeeping pass zeroed again: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func loginHeaders(t *testing.T, app *App) map[string]string {
|
||||
t.Helper()
|
||||
rec := doRequest(app, "POST", "/api/auth/login", `{"username":"admin","password":"password12345"}`, nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeBody(t, rec)
|
||||
csrf, _ := body["csrf"].(string)
|
||||
cookie := ""
|
||||
for _, h := range rec.Header().Values("Set-Cookie") {
|
||||
if strings.HasPrefix(h, "ng_session=") {
|
||||
cookie = strings.Split(h, ";")[0]
|
||||
}
|
||||
}
|
||||
if csrf == "" || cookie == "" {
|
||||
t.Fatalf("login artifacts missing: csrf=%q cookie=%q", csrf, cookie)
|
||||
}
|
||||
return map[string]string{"Cookie": cookie, "X-CSRF-Token": csrf}
|
||||
}
|
||||
|
||||
func TestCustomerAPIResetDayLifecycle(t *testing.T) {
|
||||
app := newSmokeApp(t)
|
||||
headers := loginHeaders(t, app)
|
||||
|
||||
// Create with a reset day: armed marker for the current period, no
|
||||
// reset timestamp yet.
|
||||
rec := doRequest(app, "POST", "/api/customers",
|
||||
`{"name":"monthly","trafficResetDay":15}`, headers)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
customer, _ := decodeBody(t, rec)["customer"].(map[string]any)
|
||||
if customer == nil || customer["trafficResetDay"] != float64(15) {
|
||||
t.Fatalf("create shape %v", customer)
|
||||
}
|
||||
if want := TrafficResetPeriodKey(time.Now(), 15); customer["trafficResetPeriod"] != want {
|
||||
t.Fatalf("armed period = %v, want %q", customer["trafficResetPeriod"], want)
|
||||
}
|
||||
if _, ok := customer["trafficResetAt"]; ok {
|
||||
t.Fatalf("fresh customer has resetAt: %v", customer)
|
||||
}
|
||||
id, _ := customer["id"].(string)
|
||||
|
||||
// Out-of-range day is rejected.
|
||||
rec = doRequest(app, "PATCH", "/api/customers/"+id, `{"trafficResetDay":32}`, headers)
|
||||
if rec.Code != 400 {
|
||||
t.Fatalf("patch 32 status %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Changing the day re-arms for the new schedule.
|
||||
rec = doRequest(app, "PATCH", "/api/customers/"+id, `{"trafficResetDay":20}`, headers)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("patch 20 status %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
customer, _ = decodeBody(t, rec)["customer"].(map[string]any)
|
||||
if customer["trafficResetDay"] != float64(20) ||
|
||||
customer["trafficResetPeriod"] != TrafficResetPeriodKey(time.Now(), 20) {
|
||||
t.Fatalf("re-armed shape %v", customer)
|
||||
}
|
||||
|
||||
// Clearing the day removes the schedule from the record entirely.
|
||||
rec = doRequest(app, "PATCH", "/api/customers/"+id, `{"trafficResetDay":0}`, headers)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("patch 0 status %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
customer, _ = decodeBody(t, rec)["customer"].(map[string]any)
|
||||
if _, ok := customer["trafficResetDay"]; ok {
|
||||
t.Fatalf("cleared customer still carries resetDay: %v", customer)
|
||||
}
|
||||
if _, ok := customer["trafficResetPeriod"]; ok {
|
||||
t.Fatalf("cleared customer still carries resetPeriod: %v", customer)
|
||||
}
|
||||
}
|
||||
+62
-1
@@ -6,6 +6,7 @@ package main
|
||||
// the same serialization here.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -43,7 +44,7 @@ func EmptyData() *Data {
|
||||
SchemaVersion: 1,
|
||||
CreatedAt: NowISO(),
|
||||
Settings: Settings{
|
||||
BrandName: "NexusGate",
|
||||
BrandName: "amo",
|
||||
SessionHours: 12,
|
||||
ObservationTtlMinutes: 10,
|
||||
ActivityRetentionDays: 30,
|
||||
@@ -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()
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestInitCreatesMissingFile(t *testing.T) {
|
||||
if s.Data.SchemaVersion != 1 {
|
||||
t.Errorf("SchemaVersion = %d", s.Data.SchemaVersion)
|
||||
}
|
||||
if s.Data.Settings.BrandName != "NexusGate" || s.Data.Settings.SessionHours != 12 ||
|
||||
if s.Data.Settings.BrandName != "amo" || s.Data.Settings.SessionHours != 12 ||
|
||||
s.Data.Settings.ObservationTtlMinutes != 10 || s.Data.Settings.ActivityRetentionDays != 30 ||
|
||||
s.Data.Settings.CompletedJobRetentionDays != 7 {
|
||||
t.Errorf("settings defaults = %+v", s.Data.Settings)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -107,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,
|
||||
@@ -129,13 +134,29 @@ func NodeClientURI(d *Data, nc *NodeClient) string {
|
||||
return uri
|
||||
}
|
||||
|
||||
// sortIndexOf mirrors sortIndexOf in lib/subscriptions.js.
|
||||
func sortIndexOf(d *Data, nodeID string) int {
|
||||
for _, item := range d.Nodes {
|
||||
if item.ID == nodeID {
|
||||
return item.SortIndex
|
||||
}
|
||||
}
|
||||
return 1 << 30
|
||||
}
|
||||
|
||||
func Entries(d *Data, customerID string) []SubEntry {
|
||||
seen := map[string]int{}
|
||||
items := []SubEntry{}
|
||||
bindings := []*NodeClient{}
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.CustomerID != customerID || nc.Status != "active" {
|
||||
continue
|
||||
if nc.CustomerID == customerID && nc.Status == "active" {
|
||||
bindings = append(bindings, nc)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(bindings, func(i, j int) bool {
|
||||
return sortIndexOf(d, bindings[i].NodeID) < sortIndexOf(d, bindings[j].NodeID)
|
||||
})
|
||||
for _, nc := range bindings {
|
||||
uri := NodeClientURI(d, nc)
|
||||
if uri == "" {
|
||||
continue
|
||||
@@ -663,8 +684,78 @@ func MakeSingBox(items []SubEntry) string {
|
||||
|
||||
var clashUARe = regexp.MustCompile(`(?i)clash|mihomo`)
|
||||
|
||||
// AggregateEntries mirrors aggregateEntries in lib/subscriptions.js:
|
||||
// own entries (sortIndex order) → manual links → external sources,
|
||||
// deduped by scheme+host+port+credential fingerprint, first wins.
|
||||
func AggregateEntries(d *Data, customer *Customer) []SubEntry {
|
||||
own := Entries(d, customer.ID)
|
||||
out := []SubEntry{}
|
||||
seenKeys := map[string]bool{}
|
||||
seenNames := map[string]int{}
|
||||
push := func(uri, name string) {
|
||||
if uri == "" {
|
||||
return
|
||||
}
|
||||
key := DedupKey(uri)
|
||||
if key != "" && seenKeys[key] {
|
||||
return
|
||||
}
|
||||
if key != "" {
|
||||
seenKeys[key] = true
|
||||
}
|
||||
index := seenNames[name] + 1
|
||||
seenNames[name] = index
|
||||
finalName := name
|
||||
if index != 1 {
|
||||
finalName = fmt.Sprintf("%s (%d)", name, index)
|
||||
}
|
||||
out = append(out, SubEntry{ClientURI: WithName(uri, finalName)})
|
||||
}
|
||||
for _, entry := range own {
|
||||
key := DedupKey(entry.ClientURI)
|
||||
if key != "" {
|
||||
seenKeys[key] = true
|
||||
}
|
||||
seenNames[NameFromURI(entry.ClientURI)]++
|
||||
out = append(out, entry)
|
||||
}
|
||||
for i, link := range customer.ManualLinks {
|
||||
if !link.Enabled {
|
||||
continue
|
||||
}
|
||||
name := link.Remark
|
||||
if name == "" {
|
||||
name = NameFromURI(link.URI)
|
||||
}
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("手动节点 %d", i+1)
|
||||
}
|
||||
push(link.URI, name)
|
||||
}
|
||||
for _, source := range customer.ExternalSources {
|
||||
if !source.Enabled {
|
||||
continue
|
||||
}
|
||||
entry := d.SubSourceCache[source.ID]
|
||||
if entry == nil || entry.OkAt == nil {
|
||||
continue
|
||||
}
|
||||
for _, item := range entry.Items {
|
||||
name := item.Name
|
||||
if name == "" {
|
||||
name = "未命名节点"
|
||||
}
|
||||
if source.Remark != "" {
|
||||
name = "[" + source.Remark + "] " + name
|
||||
}
|
||||
push(item.URI, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func FormatSubscription(d *Data, customer *Customer, format, userAgent string) (*SubResult, bool) {
|
||||
items := Entries(d, customer.ID)
|
||||
items := AggregateEntries(d, customer)
|
||||
if len(items) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package main
|
||||
|
||||
// Host/system sampling for the system-status page — mirrors
|
||||
// lib/sysstat.js. Memory-only ring history (240 samples), ~30s minimum
|
||||
// sampling interval, "today" traffic baseline kept in memory (a panel
|
||||
// restart restarts the day counter from traffic seen since boot).
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const historyCap = 240
|
||||
const sampleMinIntervalMs = 25000
|
||||
|
||||
type sysSample struct {
|
||||
At string `json:"at"`
|
||||
CPU *float64 `json:"cpu"`
|
||||
Mem *float64 `json:"mem"`
|
||||
UpBps float64 `json:"upBps"`
|
||||
DownBps float64 `json:"downBps"`
|
||||
Connections int `json:"connections"`
|
||||
}
|
||||
|
||||
type sysCurrent struct {
|
||||
CPUPct *float64
|
||||
MemTotal int64
|
||||
MemUsed int64
|
||||
DiskTotal int64
|
||||
DiskUsed int64
|
||||
UpBps float64
|
||||
DownBps float64
|
||||
Conns int
|
||||
TotalUp int64
|
||||
TotalDown int64
|
||||
}
|
||||
|
||||
type SystemMonitor struct {
|
||||
mu sync.Mutex
|
||||
dataDir string
|
||||
startedAt time.Time
|
||||
prevIdle int64
|
||||
prevTotal int64
|
||||
hasPrev bool
|
||||
cpuPct *float64
|
||||
history []sysSample
|
||||
lastSample time.Time
|
||||
dayKey string
|
||||
dayBaseUp int64
|
||||
dayBaseDown int64
|
||||
todayUp int64
|
||||
todayDown int64
|
||||
current *sysCurrent
|
||||
}
|
||||
|
||||
func NewSystemMonitor(dataDir string) *SystemMonitor {
|
||||
return &SystemMonitor{dataDir: dataDir, startedAt: time.Now()}
|
||||
}
|
||||
|
||||
func readCPUTimes() (idle, total int64, ok bool) {
|
||||
raw, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
line := strings.SplitN(string(raw), "\n", 2)[0]
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 6 {
|
||||
return 0, 0, false
|
||||
}
|
||||
var values []int64
|
||||
for _, f := range fields[1:] {
|
||||
n, err := strconv.ParseInt(f, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
values = append(values, n)
|
||||
}
|
||||
idle = values[3]
|
||||
if len(values) > 4 {
|
||||
idle += values[4]
|
||||
}
|
||||
for _, v := range values {
|
||||
total += v
|
||||
}
|
||||
return idle, total, true
|
||||
}
|
||||
|
||||
func readMemInfo() (total, used int64) {
|
||||
raw, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
grab := func(key string) int64 {
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if strings.HasPrefix(line, key+":") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
if n, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
|
||||
return n * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
total = grab("MemTotal")
|
||||
available := grab("MemAvailable")
|
||||
if total > 0 {
|
||||
used = total - available
|
||||
}
|
||||
return total, used
|
||||
}
|
||||
|
||||
func isGlobalIPv4(ip net.IP) bool {
|
||||
v4 := ip.To4()
|
||||
if v4 == nil {
|
||||
return false
|
||||
}
|
||||
a, b := v4[0], v4[1]
|
||||
switch {
|
||||
case a == 0 || a == 10 || a == 127:
|
||||
return false
|
||||
case a == 172 && b >= 16 && b <= 31:
|
||||
return false
|
||||
case a == 192 && b == 168:
|
||||
return false
|
||||
case a == 100 && b >= 64 && b <= 127:
|
||||
return false
|
||||
case a == 169 && b == 254:
|
||||
return false
|
||||
case a >= 224:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isGlobalIPv6(ip net.IP) bool {
|
||||
if ip.To4() != nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() {
|
||||
return false
|
||||
}
|
||||
b := ip.To16()
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
if b[0] == 0xfc || b[0] == 0xfd { // ULA fc00::/7
|
||||
return false
|
||||
}
|
||||
if b[0] == 0xfe && b[1]&0xc0 == 0x80 { // link-local fe80::/10
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// HostIPs picks the host's global interface addresses (no external
|
||||
// lookup of any kind), mirroring hostIPs in lib/sysstat.js.
|
||||
func HostIPs() (ipv4, ipv6 *string) {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch a := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = a.IP
|
||||
case *net.IPAddr:
|
||||
ip = a.IP
|
||||
}
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if ipv4 == nil && isGlobalIPv4(ip) {
|
||||
s := ip.String()
|
||||
ipv4 = &s
|
||||
}
|
||||
if ipv6 == nil && isGlobalIPv6(ip) {
|
||||
s := ip.String()
|
||||
ipv6 = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ipv4, ipv6
|
||||
}
|
||||
|
||||
func round1(v float64) float64 {
|
||||
return float64(int64(v*10+0.5)) / 10
|
||||
}
|
||||
|
||||
func (m *SystemMonitor) Sample(d *Data, force bool) *sysCurrent {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
now := time.Now()
|
||||
if !force && m.current != nil && now.Sub(m.lastSample).Milliseconds() < sampleMinIntervalMs {
|
||||
return m.current
|
||||
}
|
||||
m.lastSample = now
|
||||
if idle, total, ok := readCPUTimes(); ok {
|
||||
if m.hasPrev {
|
||||
totalDelta := total - m.prevTotal
|
||||
idleDelta := idle - m.prevIdle
|
||||
if totalDelta > 0 {
|
||||
pct := (1 - float64(idleDelta)/float64(totalDelta)) * 100
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
}
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
m.cpuPct = &pct
|
||||
}
|
||||
}
|
||||
m.prevIdle, m.prevTotal, m.hasPrev = idle, total, true
|
||||
}
|
||||
memTotal, memUsed := readMemInfo()
|
||||
var diskTotal, diskUsed int64
|
||||
var statfs syscall.Statfs_t
|
||||
if err := syscall.Statfs(m.dataDir, &statfs); err == nil {
|
||||
diskTotal = int64(statfs.Blocks) * int64(statfs.Bsize)
|
||||
diskUsed = (int64(statfs.Blocks) - int64(statfs.Bfree)) * int64(statfs.Bsize)
|
||||
}
|
||||
var totalUp, totalDown int64
|
||||
var upBps, downBps float64
|
||||
nowMs := now.UnixMilli()
|
||||
for _, node := range d.Nodes {
|
||||
if node.MeteredTraffic != nil {
|
||||
totalUp += node.MeteredTraffic.Uplink
|
||||
totalDown += node.MeteredTraffic.Downlink
|
||||
}
|
||||
up, down := NodeSpeedBps(node, nowMs)
|
||||
upBps += up
|
||||
downBps += down
|
||||
}
|
||||
dayKey := now.Format("2006-01-02")
|
||||
if m.dayKey != dayKey {
|
||||
m.dayKey = dayKey
|
||||
m.dayBaseUp = totalUp
|
||||
m.dayBaseDown = totalDown
|
||||
}
|
||||
m.todayUp = totalUp - m.dayBaseUp
|
||||
if m.todayUp < 0 {
|
||||
m.todayUp = 0
|
||||
}
|
||||
m.todayDown = totalDown - m.dayBaseDown
|
||||
if m.todayDown < 0 {
|
||||
m.todayDown = 0
|
||||
}
|
||||
conns := CountOnlineCustomers(d, now)
|
||||
var cpuVal, memVal *float64
|
||||
if m.cpuPct != nil {
|
||||
v := round1(*m.cpuPct)
|
||||
cpuVal = &v
|
||||
}
|
||||
if memTotal > 0 {
|
||||
v := round1(float64(memUsed) / float64(memTotal) * 100)
|
||||
memVal = &v
|
||||
}
|
||||
m.history = append(m.history, sysSample{
|
||||
At: ISOFromTime(now), CPU: cpuVal, Mem: memVal,
|
||||
UpBps: upBps, DownBps: downBps, Connections: conns,
|
||||
})
|
||||
if len(m.history) > historyCap {
|
||||
m.history = m.history[len(m.history)-historyCap:]
|
||||
}
|
||||
m.current = &sysCurrent{
|
||||
CPUPct: cpuVal, MemTotal: memTotal, MemUsed: memUsed,
|
||||
DiskTotal: diskTotal, DiskUsed: diskUsed,
|
||||
UpBps: upBps, DownBps: downBps, Conns: conns,
|
||||
TotalUp: totalUp, TotalDown: totalDown,
|
||||
}
|
||||
return m.current
|
||||
}
|
||||
|
||||
func panelMemBytes() int64 {
|
||||
raw, err := os.ReadFile("/proc/self/status")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if strings.HasPrefix(line, "VmRSS:") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
if n, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
|
||||
return n * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func hostUptimeSec() int64 {
|
||||
raw, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(raw))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
if v, err := strconv.ParseFloat(fields[0], 64); err == nil {
|
||||
return int64(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *SystemMonitor) Status(d *Data, version string) map[string]any {
|
||||
current := m.Sample(d, false)
|
||||
m.mu.Lock()
|
||||
history := make([]sysSample, len(m.history))
|
||||
copy(history, m.history)
|
||||
todayUp, todayDown := m.todayUp, m.todayDown
|
||||
startedAt := m.startedAt
|
||||
m.mu.Unlock()
|
||||
if current == nil {
|
||||
current = &sysCurrent{}
|
||||
}
|
||||
memTotal, memUsed := current.MemTotal, current.MemUsed
|
||||
var memPct, diskPct *float64
|
||||
if memTotal > 0 {
|
||||
v := round1(float64(memUsed) / float64(memTotal) * 100)
|
||||
memPct = &v
|
||||
}
|
||||
if current.DiskTotal > 0 {
|
||||
v := round1(float64(current.DiskUsed) / float64(current.DiskTotal) * 100)
|
||||
diskPct = &v
|
||||
}
|
||||
onlineNodes := 0
|
||||
for _, node := range d.Nodes {
|
||||
if node.Status == "active" {
|
||||
onlineNodes++
|
||||
}
|
||||
}
|
||||
ipv4, ipv6 := HostIPs()
|
||||
load1 := float64(0)
|
||||
// load average from /proc/loadavg
|
||||
if raw, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
fields := strings.Fields(string(raw))
|
||||
if len(fields) > 0 {
|
||||
load1, _ = strconv.ParseFloat(fields[0], 64)
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"version": version,
|
||||
"startedAt": ISOFromTime(startedAt),
|
||||
"panelUptimeSec": int64(time.Since(startedAt).Seconds()),
|
||||
"hostUptimeSec": hostUptimeSec(),
|
||||
"panelMemBytes": panelMemBytes(),
|
||||
"host": map[string]any{
|
||||
"cpuPercent": current.CPUPct,
|
||||
"cpuCount": runtime.NumCPU(),
|
||||
"load1": load1,
|
||||
"memTotal": memTotal, "memUsed": memUsed, "memPercent": memPct,
|
||||
"diskTotal": current.DiskTotal, "diskUsed": current.DiskUsed, "diskPercent": diskPct,
|
||||
},
|
||||
"network": map[string]any{
|
||||
"upBps": current.UpBps, "downBps": current.DownBps,
|
||||
"connections": current.Conns, "onlineCustomers": current.Conns,
|
||||
"onlineNodes": onlineNodes, "nodesTotal": len(d.Nodes), "customersTotal": len(d.Customers),
|
||||
},
|
||||
"today": map[string]any{"upBytes": todayUp, "downBytes": todayDown},
|
||||
"totals": map[string]any{"upBytes": current.TotalUp, "downBytes": current.TotalDown},
|
||||
"ips": map[string]any{"ipv4": ipv4, "ipv6": ipv6},
|
||||
"history": history,
|
||||
}
|
||||
}
|
||||
+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"
|
||||
}
|
||||
}
|
||||
|
||||
+115
-39
@@ -217,23 +217,24 @@ func recordJSON[T any](raw []byte, dst *T, extra *extraFields) error {
|
||||
// ---------- database ----------
|
||||
|
||||
type Data struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Settings Settings `json:"settings"`
|
||||
Users []*User `json:"users"`
|
||||
Servers []*Server `json:"servers"`
|
||||
Agents []*Agent `json:"agents"`
|
||||
EnrollmentTokens []*EnrollmentToken `json:"enrollmentTokens"`
|
||||
Customers []*Customer `json:"customers"`
|
||||
Nodes []*Node `json:"nodes"`
|
||||
NodeClients []*NodeClient `json:"nodeClients"`
|
||||
Jobs []*Job `json:"jobs"`
|
||||
Activity []*Activity `json:"activity"`
|
||||
Observations []*Observation `json:"observations"`
|
||||
SubscriptionAccess []*SubscriptionAccess `json:"subscriptionAccess"`
|
||||
SubscriptionClients []*SubscriptionClient `json:"subscriptionClients"`
|
||||
KeyStore map[string]*KeyEntry `json:"keyStore"`
|
||||
Extra extraFields `json:"-"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Settings Settings `json:"settings"`
|
||||
Users []*User `json:"users"`
|
||||
Servers []*Server `json:"servers"`
|
||||
Agents []*Agent `json:"agents"`
|
||||
EnrollmentTokens []*EnrollmentToken `json:"enrollmentTokens"`
|
||||
Customers []*Customer `json:"customers"`
|
||||
Nodes []*Node `json:"nodes"`
|
||||
NodeClients []*NodeClient `json:"nodeClients"`
|
||||
Jobs []*Job `json:"jobs"`
|
||||
Activity []*Activity `json:"activity"`
|
||||
Observations []*Observation `json:"observations"`
|
||||
SubscriptionAccess []*SubscriptionAccess `json:"subscriptionAccess"`
|
||||
SubscriptionClients []*SubscriptionClient `json:"subscriptionClients"`
|
||||
KeyStore map[string]*KeyEntry `json:"keyStore"`
|
||||
SubSourceCache map[string]*SubSourceCacheEntry `json:"subSourceCache,omitempty"`
|
||||
Extra extraFields `json:"-"`
|
||||
}
|
||||
|
||||
func (d *Data) UnmarshalJSON(raw []byte) error {
|
||||
@@ -267,12 +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"`
|
||||
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 {
|
||||
@@ -324,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:"-"`
|
||||
}
|
||||
@@ -379,6 +388,7 @@ type Server struct {
|
||||
PortRangeEnd int `json:"portRangeEnd"`
|
||||
Labels []string `json:"labels"`
|
||||
Status string `json:"status"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
LastSeenAt *string `json:"lastSeenAt,omitempty"`
|
||||
@@ -501,6 +511,11 @@ func (c UsageCounters) MarshalJSON() ([]byte, error) {
|
||||
type UsageSlot struct {
|
||||
UsageCounters *UsageCounters `json:"usageCounters,omitempty"`
|
||||
RetiredUsageEpochs []string `json:"retiredUsageEpochs,omitempty"`
|
||||
LastSampleAt *string `json:"lastSampleAt,omitempty"`
|
||||
LastActiveAt *string `json:"lastActiveAt,omitempty"`
|
||||
SpeedUpBps *float64 `json:"speedUpBps,omitempty"`
|
||||
SpeedDownBps *float64 `json:"speedDownBps,omitempty"`
|
||||
SpeedAt *string `json:"speedAt,omitempty"`
|
||||
Extra extraFields `json:"-"`
|
||||
}
|
||||
|
||||
@@ -532,6 +547,9 @@ type Customer struct {
|
||||
UsedBytes int64 `json:"usedBytes"`
|
||||
UsedUplinkBytes int64 `json:"usedUplinkBytes"`
|
||||
UsedDownlinkBytes int64 `json:"usedDownlinkBytes"`
|
||||
TrafficResetDay int `json:"trafficResetDay,omitempty"`
|
||||
TrafficResetPeriod string `json:"trafficResetPeriod,omitempty"`
|
||||
TrafficResetAt *string `json:"trafficResetAt,omitempty"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
IPLimit int64 `json:"ipLimit"`
|
||||
DeviceLimit int64 `json:"deviceLimit"`
|
||||
@@ -542,7 +560,48 @@ type Customer struct {
|
||||
ServerUsage map[string]*UsageSlot `json:"serverUsage,omitempty"`
|
||||
LastUsageAt *string `json:"lastUsageAt,omitempty"`
|
||||
LastOnlineAt *string `json:"lastOnlineAt,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
|
||||
// (SUB-AGGREGATION-DESIGN.md). UserAgent is optional.
|
||||
type ExtSource struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Remark string `json:"remark"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
}
|
||||
|
||||
// ManualLink is one hand-added node link on a customer.
|
||||
type ManualLink struct {
|
||||
ID string `json:"id"`
|
||||
URI string `json:"uri"`
|
||||
Remark string `json:"remark"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// SubCacheItem is one parsed entry of a fetched external source.
|
||||
type SubCacheItem struct {
|
||||
URI string `json:"uri"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// SubSourceCacheEntry is the fetch cache for one external source,
|
||||
// stored top-level in data.subSourceCache keyed by source id.
|
||||
type SubSourceCacheEntry struct {
|
||||
FetchedAt *string `json:"fetchedAt"`
|
||||
OkAt *string `json:"okAt"`
|
||||
FailCount int `json:"failCount"`
|
||||
LastError *string `json:"lastError"`
|
||||
DroppedCount int `json:"droppedCount"`
|
||||
Items []SubCacheItem `json:"items"`
|
||||
}
|
||||
|
||||
func (c *Customer) UnmarshalJSON(raw []byte) error {
|
||||
@@ -644,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"`
|
||||
@@ -675,6 +741,12 @@ type Node struct {
|
||||
RetiredUsageEpochs []string `json:"retiredUsageEpochs"`
|
||||
MeteredTraffic *MeteredTraffic `json:"meteredTraffic"`
|
||||
LastUsageAt *string `json:"lastUsageAt"`
|
||||
SortIndex int `json:"sortIndex"`
|
||||
SpeedUpBps *float64 `json:"speedUpBps,omitempty"`
|
||||
SpeedDownBps *float64 `json:"speedDownBps,omitempty"`
|
||||
SpeedAt *string `json:"speedAt,omitempty"`
|
||||
LastActiveAt *string `json:"lastActiveAt,omitempty"`
|
||||
LastSampleAt *string `json:"lastSampleAt,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Extra extraFields `json:"-"`
|
||||
@@ -720,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 {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseLinkListBase64AndPlain(t *testing.T) {
|
||||
plain := "vless://11111111-1111-1111-1111-111111111111@a.example.com:443?security=reality#One\n" +
|
||||
"trojan://x@b.example.com:443#Bad\n" +
|
||||
"ss://YWVzLTI1Ni1nY206cGFzcw@b.example.com:8388#Two\n"
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
|
||||
items, dropped := ParseLinkList(encoded)
|
||||
if len(items) != 2 || dropped != 1 {
|
||||
t.Fatalf("base64 list: items=%d dropped=%d", len(items), dropped)
|
||||
}
|
||||
if items[0].Name != "One" || items[1].Name != "Two" {
|
||||
t.Fatalf("names: %+v", items)
|
||||
}
|
||||
items2, dropped2 := ParseLinkList(plain)
|
||||
if len(items2) != 2 || dropped2 != 1 {
|
||||
t.Fatalf("plain list: items=%d dropped=%d", len(items2), dropped2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchExternalSourceGuards(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("vless://11111111-1111-1111-1111-111111111111@a.example.com:443#X\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
source := ExtSource{ID: "src_t", URL: server.URL, Enabled: true}
|
||||
settings := &Settings{}
|
||||
blocked := FetchExternalSource(source, settings)
|
||||
if blocked.OK {
|
||||
t.Fatal("loopback source must be rejected by default")
|
||||
}
|
||||
settings.AllowPrivateSubSources = true
|
||||
allowed := FetchExternalSource(source, settings)
|
||||
if !allowed.OK || len(allowed.Items) != 1 {
|
||||
t.Fatalf("allowPrivate fetch: %+v", allowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateEntriesOrderAndDedup(t *testing.T) {
|
||||
d := &Data{
|
||||
Nodes: []*Node{
|
||||
{ID: "nod_b", Name: "B", SortIndex: 1, ServerID: "srv_1", Protocol: "shadowsocks-aes256-gcm", Status: "active", Port: 20003},
|
||||
{ID: "nod_a", Name: "A", SortIndex: 0, ServerID: "srv_1", Protocol: "shadowsocks-aes256-gcm", Status: "active", Port: 20001},
|
||||
},
|
||||
Servers: []*Server{{ID: "srv_1", Name: "S", PublicAddress: "s.example.com"}},
|
||||
NodeClients: []*NodeClient{
|
||||
{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"}}},
|
||||
},
|
||||
}
|
||||
okAt := NowISO()
|
||||
d.SubSourceCache["src_1"].OkAt = &okAt
|
||||
customer := &Customer{
|
||||
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 {
|
||||
names = append(names, NameFromURI(entry.ClientURI))
|
||||
}
|
||||
// Own entries first in sortIndex order (A before B although B's
|
||||
// binding is stored first); the manual link follows; the external
|
||||
// item duplicates the manual link (same host/port/credential) and
|
||||
// is dropped.
|
||||
joined := strings.Join(names, ",")
|
||||
if joined != "A,B,手动一号" {
|
||||
t.Fatalf("aggregate order/dedup: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenceWindow(t *testing.T) {
|
||||
now := time.Now()
|
||||
recent := ISOFromTime(now.Add(-60 * time.Second))
|
||||
customer := &Customer{LastOnlineAt: &recent}
|
||||
if !CustomerOnline(customer, now.UnixMilli()) {
|
||||
t.Fatal("customer active 60s ago should be online")
|
||||
}
|
||||
old := ISOFromTime(now.Add(-10 * time.Minute))
|
||||
customer.LastOnlineAt = &old
|
||||
if CustomerOnline(customer, now.UnixMilli()) {
|
||||
t.Fatal("customer active 10m ago should be offline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostIPsNoPanic(t *testing.T) {
|
||||
HostIPs() // just must not panic on any host
|
||||
}
|
||||
+1276
-475
File diff suppressed because it is too large
Load Diff
+38
-45
@@ -3,66 +3,59 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="theme-color" content="#f5f7fb">
|
||||
<title>NexusGate · 枢门控制台</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=0.3.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#f0f2f5">
|
||||
<title>amo</title>
|
||||
<script>(function(){try{var t=localStorage.getItem('nexusgate-theme');if(t==='dark'){document.documentElement.setAttribute('data-theme','dark');document.querySelector('meta[name="theme-color"]').content='#1a1b1f';}}catch(e){}})();</script>
|
||||
<link rel="stylesheet" href="/styles.css?v=0.7.0">
|
||||
</head>
|
||||
<body>
|
||||
<div id="login" class="login-shell hidden">
|
||||
<section class="login-main">
|
||||
<button class="theme-button login-theme" type="button" data-theme-toggle aria-label="切换主题">浅色</button>
|
||||
<div class="login-card">
|
||||
<div class="brand login-brand"><span class="brand-mark">N</span><span><b>NexusGate</b><small>枢门控制台</small></span></div>
|
||||
<div class="login-copy">
|
||||
<h1>欢迎回来</h1>
|
||||
</div>
|
||||
<form id="login-form" class="stack login-form">
|
||||
<label>登录账号<input name="username" autocomplete="username" placeholder="请输入账号" required></label>
|
||||
<label>登录密码<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="primary full large" type="submit">登录控制台</button>
|
||||
</form>
|
||||
<p id="login-error" class="form-error"></p>
|
||||
</div>
|
||||
<section class="login-card panel">
|
||||
<div class="login-brand">amo</div>
|
||||
<form id="login-form" class="stack">
|
||||
<label>登录账号<input name="username" autocomplete="username" required></label>
|
||||
<label>登录密码<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="btn primary full" type="submit">登录</button>
|
||||
</form>
|
||||
<p id="login-error" class="form-error"></p>
|
||||
<button class="theme-btn login-theme" type="button" data-theme-toggle>🌙 深色</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="app" class="app-shell hidden">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="brand sidebar-brand"><span class="brand-mark small">N</span><span><b>NexusGate</b><small>枢门控制台</small></span></div>
|
||||
<p class="nav-caption">工作台</p>
|
||||
<nav id="nav">
|
||||
<button data-page="overview" class="active"><span class="nav-icon">▦</span><span class="nav-label">概览</span></button>
|
||||
<p class="nav-caption nav-group">资源管理</p>
|
||||
<button data-page="servers"><span class="nav-icon">◫</span><span class="nav-label">服务器</span></button>
|
||||
<button data-page="customers"><span class="nav-icon">◎</span><span class="nav-label">客户与订阅</span></button>
|
||||
<button data-page="chains"><span class="nav-icon">⇄</span><span class="nav-label">转发与节点</span></button>
|
||||
<button data-page="commands"><span class="nav-icon">⌘</span><span class="nav-label">安装与命令</span></button>
|
||||
<p class="nav-caption nav-group">安全与系统</p>
|
||||
<button data-page="operations"><span class="nav-icon">⚙</span><span class="nav-label">设置与运维</span></button>
|
||||
<div id="app" class="layout hidden">
|
||||
<aside class="sider">
|
||||
<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="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="sidebar-foot"><div><span class="live-dot"></span><span id="connection-label">控制面正常</span></div><button id="logout" class="icon-button" title="退出登录">↗</button></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>
|
||||
|
||||
<main class="main-shell">
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<button id="menu-toggle" class="icon-button mobile-only" type="button" aria-label="展开菜单">☰</button>
|
||||
<div class="title-block"><p id="breadcrumb" class="eyebrow">NEXUSGATE / 运行总览</p><h1 id="page-title">运行总览</h1></div>
|
||||
<div class="top-actions"><button id="refresh" class="ghost" type="button">刷新数据</button><button class="theme-button" type="button" data-theme-toggle aria-label="切换主题">浅色</button><span id="user-chip" class="user-chip"></span></div>
|
||||
<span class="crumb">amo / <b id="page-title">系统状态</b></span>
|
||||
<span class="sp"></span>
|
||||
<button class="theme-btn" id="refresh" type="button" title="刷新数据">刷新</button>
|
||||
<button class="theme-btn" type="button" data-theme-toggle>🌙 深色</button>
|
||||
</header>
|
||||
<div id="content" class="content"></div>
|
||||
</main>
|
||||
<button id="sidebar-scrim" class="sidebar-scrim" type="button" aria-label="关闭菜单"></button>
|
||||
<main class="content" id="content"></main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="modal">
|
||||
<section class="dialog-shell">
|
||||
<header><div><p id="modal-kicker" class="eyebrow"></p><h2 id="modal-title"></h2></div><button type="button" data-close class="icon-button" aria-label="关闭">×</button></header>
|
||||
<div id="modal-body"></div>
|
||||
<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="关闭" 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>
|
||||
</dialog>
|
||||
</div>
|
||||
<div id="float-pop" class="float-pop hidden"></div>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
<script src="/vendor/qrcode.js?v=0.4.0" defer></script>
|
||||
<script src="/app.js?v=0.4.0" defer></script>
|
||||
<script src="/app.js?v=0.7.0" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+399
-105
@@ -1,111 +1,405 @@
|
||||
:root {
|
||||
--bg: #f4f7fb; --surface: #ffffff; --surface-soft: #f8faff; --surface-hover: #f1f5ff;
|
||||
--text: #172033; --muted: #6f7b91; --line: #e4e9f2; --line-strong: #d6ddea;
|
||||
--primary: #4f6ef7; --primary-dark: #3c57d9; --primary-soft: #eef1ff;
|
||||
--success: #159f70; --success-soft: #e9f8f2; --warning: #bc7a12; --warning-soft: #fff6df;
|
||||
--danger: #d84d61; --danger-soft: #fff0f2; --shadow: 0 12px 36px rgba(34,51,84,.08); --shadow-lg: 0 28px 80px rgba(23,32,51,.16);
|
||||
--radius: 16px; font-family: Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; color-scheme: light;
|
||||
/* amo 控制台样式 — 按 3x-ui 风格样张落地。浅色默认,[data-theme="dark"] 深色。
|
||||
字体为系统字体栈,零外部依赖(路由器可离线部署)。 */
|
||||
:root{
|
||||
--bg:#f0f2f5; --sider:#ffffff; --card:#ffffff; --card-hover:#fafafa;
|
||||
--border:#d9dce1; --border-soft:#e8eaed;
|
||||
--text:#20242a; --text-2:#5a6068; --text-3:#8a8f98;
|
||||
--primary:#1668dc; --primary-hover:#3c89e8; --primary-soft:rgba(22,104,220,.10); --primary-text:#0958d9;
|
||||
--green:#52c41a; --green-text:#237804; --green-soft:rgba(82,196,26,.13);
|
||||
--red:#ff4d4f; --red-text:#cf1322; --red-soft:rgba(255,77,79,.08);
|
||||
--orange:#faad14; --orange-text:#975a00; --orange-soft:rgba(250,173,20,.14);
|
||||
--purple:#9254de; --purple-text:#722ed1; --purple-soft:rgba(146,84,222,.10);
|
||||
--cyan:#13c2c2; --cyan-text:#096f74; --cyan-soft:rgba(19,194,194,.10);
|
||||
--pink-text:#c41d7f; --pink-soft:rgba(235,47,150,.09);
|
||||
--sky-text:#0958d9; --sky-soft:rgba(22,104,220,.09);
|
||||
--gray-soft:rgba(0,0,0,.05); --pill-bg:rgba(0,0,0,.045);
|
||||
--hover-bg:rgba(0,0,0,.04); --row-hover:rgba(0,0,0,.02); --th-bg:#fafafa;
|
||||
--dot-off:#c2c6cc; --tgl-off:#c9ccd3; --track:rgba(0,0,0,.08);
|
||||
--pop-bg:#ffffff; --pop-shadow:0 12px 32px rgba(0,0,0,.16);
|
||||
--trans-bg:rgba(82,196,26,.14); --trans-text:#237804;
|
||||
--chart-2:#6f757f;
|
||||
--radius:8px;
|
||||
--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
|
||||
--mono:"SF Mono",SFMono-Regular,Consolas,"Liberation Mono",monospace;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
--bg:#0e1421; --surface:#161e2e; --surface-soft:#111827; --surface-hover:#202b40; --text:#edf2fb; --muted:#93a0b6;
|
||||
--line:#273248; --line-strong:#344159; --primary:#7790ff; --primary-dark:#91a4ff; --primary-soft:#222d50;
|
||||
--success:#42cea0; --success-soft:#16382f; --warning:#f0bd5f; --warning-soft:#3b301b; --danger:#ff788a; --danger-soft:#45232b;
|
||||
--shadow:0 16px 42px rgba(0,0,0,.2); --shadow-lg:0 30px 90px rgba(0,0,0,.45); color-scheme:dark;
|
||||
[data-theme="dark"]{
|
||||
--bg:#1a1b1f; --sider:#1e2024; --card:#23252b; --card-hover:#282a31;
|
||||
--border:#33363e; --border-soft:#2b2d34;
|
||||
--text:#e9eaee; --text-2:#a2a7b1; --text-3:#6f757f;
|
||||
--primary-soft:rgba(22,104,220,.16); --primary-text:#69a7ff;
|
||||
--green-text:#73d13d;
|
||||
--red-text:#ff7875; --red-soft:rgba(255,77,79,.13);
|
||||
--orange-text:#ffc53d; --orange-soft:rgba(250,173,20,.13);
|
||||
--purple-text:#b37feb; --purple-soft:rgba(146,84,222,.17);
|
||||
--cyan-text:#36cfc9; --cyan-soft:rgba(19,194,194,.13);
|
||||
--pink-text:#ff85c0; --pink-soft:rgba(235,47,150,.16);
|
||||
--sky-text:#8dbdff; --sky-soft:rgba(105,167,255,.15);
|
||||
--gray-soft:rgba(255,255,255,.07); --pill-bg:rgba(255,255,255,.06);
|
||||
--hover-bg:rgba(255,255,255,.05); --row-hover:rgba(255,255,255,.025); --th-bg:rgba(255,255,255,.02);
|
||||
--dot-off:#5a5e66; --tgl-off:#3c404a; --track:rgba(255,255,255,.09);
|
||||
--pop-bg:#2e3038; --pop-shadow:0 12px 32px rgba(0,0,0,.55);
|
||||
--trans-bg:rgba(82,196,26,.12); --trans-text:#95de64;
|
||||
--chart-2:#8a8f98;
|
||||
}
|
||||
*{box-sizing:border-box} html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text)} body{min-height:100vh}
|
||||
button,input,select,textarea{font:inherit} button{cursor:pointer}.hidden{display:none!important}.muted{color:var(--muted)}
|
||||
.eyebrow{margin:0 0 7px;color:var(--primary);font-size:11px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}
|
||||
.brand{display:flex;align-items:center;gap:12px}.brand>span:last-child{display:flex;flex-direction:column;line-height:1.15}.brand b{font-size:17px;letter-spacing:-.02em}.brand small{margin-top:4px;color:var(--muted);font-size:10px;letter-spacing:.12em}
|
||||
.brand-mark{width:44px;height:44px;display:grid;place-items:center;color:white;border-radius:14px;background:linear-gradient(145deg,#617cfb,#4b5ed9);box-shadow:0 10px 24px rgba(79,110,247,.3);font-weight:900;font-size:21px}.brand-mark.small{width:38px;height:38px;border-radius:12px;font-size:18px}
|
||||
.login-shell{min-height:100vh;display:grid;grid-template-columns:minmax(420px,47%) 1fr;background:var(--surface)}.login-main{position:relative;display:grid;place-items:center;padding:60px 8vw}.login-card{width:min(100%,430px)}.login-brand{margin-bottom:70px}.login-copy h1{margin:0 0 12px;font-size:clamp(32px,4vw,44px);letter-spacing:-.04em}.login-copy>p:last-child{margin:0;font-size:15px}.login-form{margin-top:38px}.login-footnote{margin:30px 0 0;color:var(--muted);font-size:12px;text-align:center}.login-theme{position:absolute;top:28px;right:30px}
|
||||
.login-visual{position:relative;overflow:hidden;margin:18px;padding:clamp(48px,7vw,110px);border-radius:28px;color:white;background:linear-gradient(140deg,#637df8 0%,#4961da 54%,#334bbf 100%);display:flex;flex-direction:column;justify-content:center}.login-visual::after{content:"";position:absolute;inset:0;opacity:.13;background-image:radial-gradient(circle at 1px 1px,#fff 1px,transparent 0);background-size:28px 28px}.visual-copy,.visual-card{position:relative;z-index:1}.visual-copy{max-width:650px}.visual-copy h2{margin:24px 0 18px;font-size:clamp(36px,4.5vw,62px);line-height:1.13;letter-spacing:-.045em}.visual-copy p{max-width:560px;margin:0;color:rgba(255,255,255,.78);font-size:17px;line-height:1.8}.visual-badge{display:inline-flex;padding:9px 14px;border:1px solid rgba(255,255,255,.28);border-radius:999px;background:rgba(255,255,255,.1);font-size:12px;font-weight:750;letter-spacing:.08em}.visual-card{display:flex;align-items:center;gap:15px;width:fit-content;margin-top:70px;padding:18px 22px;border:1px solid rgba(255,255,255,.22);border-radius:15px;background:rgba(255,255,255,.12);backdrop-filter:blur(16px)}.visual-card div{display:flex;flex-direction:column;gap:5px}.visual-card small{color:rgba(255,255,255,.7)}.orb{position:absolute;border-radius:50%;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.06)}.orb-one{width:440px;height:440px;right:-100px;top:-110px}.orb-two{width:260px;height:260px;left:-90px;bottom:-80px}
|
||||
.app-shell{min-height:100vh;display:grid;grid-template-columns:246px minmax(0,1fr)}.sidebar{position:sticky;top:0;z-index:30;height:100vh;display:flex;flex-direction:column;padding:24px 15px 18px;border-right:1px solid var(--line);background:var(--surface)}.sidebar-brand{padding:0 8px 27px}.nav-caption{margin:5px 12px 10px;color:var(--muted);font-size:10px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}#nav{display:flex;flex-direction:column;gap:4px}#nav button{width:100%;display:flex;align-items:center;gap:12px;padding:11px 13px;border:0;border-radius:10px;color:var(--muted);background:transparent;font-weight:650;text-align:left;transition:.16s ease}#nav button:hover{color:var(--text);background:var(--surface-hover)}#nav button.active{color:var(--primary);background:var(--primary-soft)}.nav-icon{width:23px;height:23px;display:grid;place-items:center;border-radius:7px;font-size:17px}#nav button.active .nav-icon{color:white;background:var(--primary);box-shadow:0 5px 12px rgba(79,110,247,.25)}.sidebar-foot{margin-top:auto;display:flex;align-items:center;justify-content:space-between;padding:14px 10px 0;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.sidebar-foot>div{display:flex;align-items:center;gap:8px}.live-dot{flex:0 0 auto;width:8px;height:8px;display:inline-block;border-radius:50%;background:#35c493;box-shadow:0 0 0 4px rgba(53,196,147,.15)}
|
||||
.main-shell{min-width:0}.topbar{height:88px;position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:0 34px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:blur(18px)}.title-block h1{margin:0;font-size:24px;letter-spacing:-.025em}.top-actions{display:flex;align-items:center;gap:10px}.user-chip{padding:9px 13px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--muted);font-size:12px;font-weight:700}.content{max-width:1620px;margin:0 auto;padding:28px 34px 60px}
|
||||
button,.button{border:1px solid var(--line-strong);border-radius:9px;padding:8px 12px;color:var(--text);background:var(--surface);font-size:12px;font-weight:700;transition:border-color .15s,background .15s,transform .15s}button:hover{border-color:var(--primary)}button:active{transform:translateY(1px)}button.primary{border-color:var(--primary);color:white;background:var(--primary);box-shadow:0 6px 16px rgba(79,110,247,.2)}button.primary:hover{background:var(--primary-dark)}button.danger{border-color:transparent;color:var(--danger);background:var(--danger-soft)}button.ghost{background:transparent}button.full{width:100%}button.large{padding:13px 16px;font-size:14px;border-radius:11px}button:disabled{cursor:not-allowed;opacity:.48;transform:none}.icon-button,.theme-button{width:38px;height:38px;display:grid;place-items:center;padding:0;border-radius:10px;font-size:18px}.theme-button{color:var(--primary);background:var(--primary-soft);border-color:transparent}.mobile-only,.sidebar-scrim{display:none}
|
||||
.page-intro{display:flex;align-items:center;justify-content:space-between;gap:22px;margin-bottom:18px}.page-intro p{max-width:820px;margin:0;color:var(--muted);font-size:13px;line-height:1.75}.metrics{display:grid;grid-template-columns:repeat(5,minmax(150px,1fr));gap:14px;margin-bottom:18px}.metric{position:relative;overflow:hidden;padding:21px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:var(--shadow)}.metric::before{content:"";position:absolute;width:5px;height:46px;left:0;top:22px;border-radius:0 5px 5px 0;background:var(--accent)}.metric span,.metric small{display:block;color:var(--muted);font-size:12px}.metric strong{display:block;margin:7px 0 4px;font-size:29px;letter-spacing:-.04em}.grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.panel{overflow:hidden;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:var(--shadow)}.panel-head{min-height:68px;display:flex;align-items:center;justify-content:space-between;gap:15px;padding:16px 20px;border-bottom:1px solid var(--line)}.panel-head h2{margin:0 0 4px;font-size:15px}.panel-head p{margin:0;color:var(--muted);font-size:11px}.panel-body{padding:20px}.health-list,.timeline,.stack{display:flex;flex-direction:column;gap:13px}.health-row{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 0;border-bottom:1px solid var(--line)}.health-row:last-child{border:0}.health-row>div{min-width:0;display:flex;flex-direction:column;gap:4px}.health-row span,.health-row small{color:var(--muted);font-size:11px}.event{display:grid;grid-template-columns:10px minmax(0,1fr) auto;align-items:center;gap:12px;padding:9px 0}.event-dot{width:8px;height:8px;border:2px solid var(--primary);border-radius:50%}.event div{display:flex;flex-direction:column;gap:3px}.event small,.event time{color:var(--muted);font-size:10px}
|
||||
.toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:9px;width:100%}.search{width:min(410px,100%)}.tag{display:inline-flex;align-items:center;margin:2px 4px 2px 0;padding:4px 8px;border-radius:999px;color:var(--primary);background:var(--primary-soft);font-size:10px;font-weight:750}.status{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:999px;color:var(--muted);background:var(--surface-soft);font-size:10px;font-weight:800;white-space:nowrap}.status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.status.online,.status.active,.status.completed{color:var(--success);background:var(--success-soft)}.status.failed,.status.degraded,.status.suspended{color:var(--danger);background:var(--danger-soft)}.status.deploying,.status.queued,.status.running,.status.removing,.status.redeploying,.status.redeploy_pending,.status.changes_pending{color:var(--warning);background:var(--warning-soft)}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12px}th{padding:11px 16px;color:var(--muted);background:var(--surface-soft);font-size:10px;text-align:left;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}td{padding:14px 16px;border-top:1px solid var(--line);vertical-align:middle}tbody tr:hover{background:var(--surface-hover)}td strong,td small{display:block}td strong{font-size:12px}td small{max-width:360px;margin-top:4px;color:var(--muted);line-height:1.45}.actions{display:flex;justify-content:flex-end;flex-wrap:wrap;gap:6px}.progress{width:130px;height:4px;margin-top:8px;overflow:hidden;border-radius:5px;background:var(--line)}.progress i{height:100%;display:block;border-radius:inherit;background:var(--primary)}.empty{min-height:120px;display:grid;place-items:center;align-content:center;gap:7px;color:var(--muted);text-align:center}.empty b{color:var(--text)}
|
||||
label{display:flex;flex-direction:column;gap:7px;color:var(--text);font-size:12px;font-weight:700}input,select,textarea{width:100%;min-height:42px;padding:9px 11px;outline:0;border:1px solid var(--line-strong);border-radius:9px;color:var(--text);background:var(--surface);transition:border-color .15s,box-shadow .15s}input:focus,select:focus,textarea:focus{border-color:var(--primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--primary) 14%,transparent)}textarea{min-height:88px;resize:vertical}label small,.field-note{color:var(--muted);font-size:10px;font-weight:500;line-height:1.5}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px}.form-grid .wide{grid-column:1/-1}.form-actions{grid-column:1/-1;display:flex;justify-content:flex-end;gap:9px;padding-top:8px}.inline-fields{display:grid;grid-template-columns:1fr 1fr;gap:8px}.check-grid{max-height:215px;overflow:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:4px}.check-card{display:flex;flex-direction:row;align-items:center;gap:10px;padding:10px;border:1px solid var(--line);border-radius:9px;background:var(--surface-soft)}.check-card input{width:15px;min-height:auto;accent-color:var(--primary)}.check-card span{display:flex;flex-direction:column;gap:3px}.check-card small{color:var(--muted)}.notice{padding:12px 14px;border:1px solid color-mix(in srgb,var(--primary) 25%,var(--line));border-radius:10px;color:var(--muted);background:var(--primary-soft);font-size:11px;line-height:1.65}.notice.warning{border-color:color-mix(in srgb,var(--warning) 30%,var(--line));background:var(--warning-soft)}.codebox{overflow:auto;padding:14px;border:1px solid var(--line);border-radius:10px;color:var(--text);background:var(--surface-soft);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.7;white-space:pre-wrap;word-break:break-all}.form-error{min-height:20px;color:var(--danger);font-size:12px}.section-title{margin:8px 0 -3px;grid-column:1/-1;display:flex;align-items:center;gap:10px;color:var(--muted);font-size:10px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.section-title::after{content:"";flex:1;height:1px;background:var(--line)}
|
||||
dialog{width:min(760px,calc(100% - 32px));max-height:calc(100vh - 40px);padding:0;overflow:auto;border:0;border-radius:18px;color:var(--text);background:var(--surface);box-shadow:var(--shadow-lg)}dialog::backdrop{background:rgba(18,25,39,.48);backdrop-filter:blur(3px)}.dialog-shell>header{position:sticky;top:0;z-index:2;display:flex;align-items:center;justify-content:space-between;padding:20px 22px;border-bottom:1px solid var(--line);background:var(--surface)}.dialog-shell h2{margin:0;font-size:20px}#modal-body{padding:22px}#toasts{position:fixed;z-index:80;right:22px;bottom:22px;display:flex;flex-direction:column;gap:9px}.toast{max-width:390px;padding:12px 15px;border:1px solid color-mix(in srgb,var(--success) 25%,var(--line));border-radius:10px;color:var(--success);background:var(--surface);box-shadow:var(--shadow-lg);font-size:12px;font-weight:700}.toast.error{border-color:color-mix(in srgb,var(--danger) 30%,var(--line));color:var(--danger)}
|
||||
@media(max-width:1120px){.metrics{grid-template-columns:repeat(3,1fr)}.login-shell{grid-template-columns:1fr 1fr}.login-main{padding:50px 7vw}.login-visual{padding:55px}}
|
||||
@media(max-width:860px){.login-shell{display:block;background:var(--surface)}.login-main{min-height:100vh;padding:50px 24px}.login-visual{display:none}.login-brand{margin-bottom:48px}.app-shell{display:block}.sidebar{position:fixed;left:0;transform:translateX(-105%);width:246px;transition:transform .2s ease;box-shadow:var(--shadow-lg)}.app-shell.sidebar-open .sidebar{transform:translateX(0)}.sidebar-scrim{position:fixed;z-index:25;inset:0;border:0;border-radius:0;background:rgba(15,22,34,.42)}.app-shell.sidebar-open .sidebar-scrim{display:block}.mobile-only{display:grid}.topbar{padding:0 18px}.content{padding:22px 18px 50px}.grid-2,.grid-3{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.user-chip{display:none}}
|
||||
@media(max-width:570px){.topbar{height:76px}.title-block h1{font-size:19px}.title-block .eyebrow{display:none}#refresh{display:none}.page-intro{align-items:stretch;flex-direction:column}.metrics{grid-template-columns:1fr}.form-grid{grid-template-columns:1fr}.form-grid .wide,.form-actions,.section-title{grid-column:1}.check-grid{grid-template-columns:1fr}.inline-fields{grid-template-columns:1fr}.login-copy h1{font-size:34px}dialog{width:calc(100% - 18px);max-height:calc(100vh - 18px)}#modal-body{padding:18px}}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
|
||||
a{color:inherit;text-decoration:none}
|
||||
.hidden{display:none !important}
|
||||
button{font-family:var(--font)}
|
||||
input,select,textarea{font-family:var(--font);color:var(--text)}
|
||||
:focus-visible{outline:2px solid var(--primary-hover);outline-offset:1px}
|
||||
|
||||
/* Compact control-room layout: stable navigation width and readable actions in either theme. */
|
||||
:root{--bg:#f5f7fc;--surface-soft:#f8faff;--primary:#3478ee;--primary-dark:#2468dc;--primary-soft:#eaf2ff;--radius:14px;--shadow:0 4px 18px rgba(26,44,80,.04)}
|
||||
html[data-theme="dark"]{--bg:#0e1420;--surface-soft:#1b2638;--primary:#79a9ff;--primary-dark:#9bbfff;--primary-soft:#213656;--shadow:0 4px 20px rgba(0,0,0,.12)}
|
||||
.login-shell{display:grid;grid-template-columns:1fr;background:var(--bg)}
|
||||
.login-main{min-height:100vh;padding:28px;display:grid;place-items:center}
|
||||
.login-card{width:min(100%,420px);padding:38px;border:1px solid var(--line);border-radius:18px;background:var(--surface);box-shadow:var(--shadow)}
|
||||
.login-brand{margin-bottom:32px}.login-copy h1{margin:0;font-size:29px}.login-form{margin-top:28px}
|
||||
.login-theme{top:24px;right:28px}.login-visual{display:none}
|
||||
.app-shell{grid-template-columns:218px minmax(0,1fr)}
|
||||
.sidebar{width:218px;min-width:218px;padding:24px 12px 18px;color:#e7edf9;border-right:0;background:#151e2d}
|
||||
.sidebar .brand b{color:#fff}.sidebar .brand small,.sidebar .nav-caption{color:#8190a6}
|
||||
.sidebar-brand{padding:0 10px 28px}.sidebar .brand-mark{background:#3478ee;box-shadow:none}
|
||||
#nav{min-width:0}#nav button{min-width:0;width:100%;display:flex;flex-flow:row nowrap;align-items:center;gap:12px;white-space:nowrap;line-height:1.3;color:#a9b7cb;padding:11px 12px;font-size:13px}
|
||||
#nav button .nav-label{display:block;flex:0 0 auto;white-space:nowrap;word-break:keep-all;writing-mode:horizontal-tb}
|
||||
#nav button:hover{color:#fff;background:#222f44}#nav button.active{color:#fff;background:#2a3b58}
|
||||
#nav button.active .nav-icon{color:#79a9ff;background:transparent;box-shadow:none}
|
||||
.nav-icon{flex:0 0 22px;width:22px;height:22px;font-size:16px}.nav-group{margin-top:18px;margin-bottom:6px}
|
||||
.sidebar-foot{border-color:#354256;color:#91a1b9}.sidebar-foot .icon-button{color:#e7edf9;background:transparent;border-color:#354256}
|
||||
.topbar{height:74px;padding:0 30px;background:var(--surface);backdrop-filter:none}.title-block h1{font-size:20px}.title-block .eyebrow{margin-bottom:3px;font-size:10px;color:var(--muted)}
|
||||
.content{max-width:1460px;padding:28px 30px 60px}.theme-button{width:auto;min-width:68px;height:36px;padding:0 11px;font-size:12px;white-space:nowrap}
|
||||
.page-intro{margin-bottom:18px}.page-intro p{max-width:900px}.panel{box-shadow:var(--shadow)}
|
||||
.actions{min-width:235px;flex-wrap:wrap}.actions button{white-space:nowrap}
|
||||
.table-wrap table{min-width:850px}.table-wrap td{line-height:1.5}.table-wrap td:last-child{width:1%;white-space:nowrap}
|
||||
.protocol-note{grid-column:1/-1;line-height:1.7}.chain-section{grid-column:1/-1;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--surface-soft)}
|
||||
.chain-section h3{margin:0 0 14px;font-size:14px}.chain-section .form-grid{gap:14px}.field-hint{color:var(--muted);font-size:11px;line-height:1.6}
|
||||
.row-actions{display:flex;gap:7px;align-items:center}.danger-link{color:var(--danger)}
|
||||
@media(max-width:860px){.sidebar{width:218px;min-width:218px}.topbar{padding:0 18px}.content{padding:20px 18px 50px}}
|
||||
@media(max-width:570px){.login-card{padding:28px 22px}.top-actions{gap:5px}.theme-button{min-width:56px}.content{padding:18px 12px 40px}}
|
||||
/* ---------- 布局 ---------- */
|
||||
.layout{display:flex;min-height:100vh}
|
||||
.sider{flex:0 0 224px;width:224px;background:var(--sider);border-right:1px solid var(--border-soft);position:sticky;top:0;height:100vh;display:flex;flex-direction:column;z-index:30}
|
||||
.brand{display:flex;align-items:center;height:58px;padding:0 20px;border-bottom:1px solid var(--border-soft);font-size:19px;font-weight:700;letter-spacing:.4px}
|
||||
.menu{padding:12px 10px;flex:1;overflow-y:auto}
|
||||
.mi{display:flex;align-items:center;gap:10px;padding:8px 10px;margin:2px 0;border-radius:6px;color:var(--text-2);cursor:pointer}
|
||||
.mi svg{width:16px;height:16px;flex:none}
|
||||
.mi:hover{background:var(--hover-bg);color:var(--text)}
|
||||
.mi.active{background:var(--primary-soft);color:var(--primary-text);font-weight:500}
|
||||
.sider-foot{padding:12px 20px;border-top:1px solid var(--border-soft);color:var(--text-2);font-size:12.5px;display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.sider-foot .foot-right{display:inline-flex;align-items:center;gap:6px;color:var(--text-3)}
|
||||
.main{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||
.topbar{height:58px;display:flex;align-items:center;gap:12px;padding:0 24px;border-bottom:1px solid var(--border-soft);background:var(--bg);position:sticky;top:0;z-index:20}
|
||||
.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}
|
||||
.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)}
|
||||
.content{padding:24px;width:100%}
|
||||
.panel{background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:16px}
|
||||
.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;border-bottom:1px solid var(--border-soft)}
|
||||
.panel-head h2{font-size:14.5px;font-weight:600}
|
||||
.panel-body{padding:14px 16px}
|
||||
.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.page-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
|
||||
.search{background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 12px;font-size:13px;width:260px}
|
||||
select.search{width:auto}
|
||||
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--card-hover);color:var(--text);border-radius:6px;padding:5px 14px;font-size:13px;cursor:pointer;white-space:nowrap}
|
||||
.btn:hover{border-color:var(--primary);color:var(--primary-text)}
|
||||
.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
|
||||
.btn.primary:hover{background:var(--primary-hover);color:#fff}
|
||||
.btn.danger{color:var(--red-text)}
|
||||
.btn.danger:hover{border-color:var(--red);color:var(--red-text);background:var(--red-soft)}
|
||||
.btn.small{padding:2px 10px;font-size:12px}
|
||||
.btn.full{width:100%;justify-content:center;padding:8px 14px}
|
||||
.btn:disabled{opacity:.55;cursor:default}
|
||||
|
||||
/* Long device and customer lists stay inside a searchable, keyboard-friendly picker. */
|
||||
.picker-field{display:flex;flex-direction:column;gap:7px;font-size:12px;font-weight:700}
|
||||
.picker-field>small,.picker-content>small{color:var(--muted);font-size:11px;font-weight:500}
|
||||
.multi-picker{border:1px solid var(--line-strong);border-radius:10px;background:var(--surface)}
|
||||
.multi-picker>summary{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 12px;cursor:pointer;list-style:none}
|
||||
.multi-picker>summary::-webkit-details-marker{display:none}
|
||||
.multi-picker[open]>summary{border-bottom:1px solid var(--line);color:var(--primary)}
|
||||
.picker-content{padding:10px;display:grid;gap:9px}.picker-options{max-height:225px;overflow:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
|
||||
.picker-option{display:flex;flex-direction:row;align-items:center;min-width:0;gap:10px;padding:9px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface-soft);cursor:pointer}
|
||||
.picker-option[hidden]{display:none}.picker-option:hover{border-color:var(--primary)}.picker-option input{flex:0 0 16px;width:16px;min-height:16px;accent-color:var(--primary)}.picker-option span{min-width:0;display:grid;gap:3px}.picker-option b,.picker-option small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.picker-option small{color:var(--muted);font-size:10px;font-weight:500}
|
||||
.subscription-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft)}
|
||||
.subscription-row>div{min-width:0;display:grid;gap:5px}.subscription-row code{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-size:11px}.subscription-row button{flex:0 0 auto}
|
||||
@media(max-width:570px){.picker-options{grid-template-columns:1fr}.subscription-row{align-items:stretch;flex-direction:column}.subscription-row button{align-self:flex-end}}
|
||||
/* ---------- 系统状态 ---------- */
|
||||
.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:16px}
|
||||
.stat{background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);padding:14px 16px 10px;overflow:hidden}
|
||||
.stat .hd{display:flex;align-items:center;gap:7px;color:var(--text-2);font-size:13px}
|
||||
.stat .hd svg{width:15px;height:15px;color:var(--primary);flex:none}
|
||||
.stat .hd .scope{margin-left:auto;color:var(--text-3);font-size:11.5px}
|
||||
.stat .big{font-size:30px;font-weight:600;line-height:1.25;margin-top:4px;font-variant-numeric:tabular-nums}
|
||||
.stat .big small{font-size:15px;font-weight:500;color:var(--text-2);margin-left:2px}
|
||||
.stat .det{color:var(--text-2);font-size:12.5px;font-variant-numeric:tabular-nums}
|
||||
.stat .mm{display:flex;justify-content:space-between;color:var(--text-3);font-size:12px;margin-top:8px;font-variant-numeric:tabular-nums}
|
||||
.spark{display:block;width:100%;height:44px;margin-top:4px}
|
||||
.mid-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px;margin-bottom:16px;align-items:stretch}
|
||||
.chart-card{padding:14px 18px 12px;display:flex;flex-direction:column;margin-bottom:0}
|
||||
.chart-hd{display:flex;align-items:flex-start;gap:12px}
|
||||
.chart-hd h2{font-size:14.5px;font-weight:600}
|
||||
.chart-hd .legend{margin-left:auto;display:flex;gap:18px;font-size:13px;white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
.legend .up{color:var(--primary-text);font-weight:600}
|
||||
.legend .dn{color:var(--text-2);font-weight:600}
|
||||
.legend .lb{color:var(--text-3);font-weight:400;font-size:12px;margin-right:3px}
|
||||
.chart{flex:1;width:100%;margin-top:8px}
|
||||
.stat3{display:flex;border-top:1px solid var(--border-soft);margin-top:10px;padding-top:11px}
|
||||
.stat3>div{flex:1;padding:0 14px}
|
||||
.stat3>div:first-child{padding-left:0}
|
||||
.stat3>div+div{border-left:1px solid var(--border-soft)}
|
||||
.stat3 .k{color:var(--text-3);font-size:12px}
|
||||
.stat3 .v{font-size:16.5px;font-weight:600;margin-top:1px;font-variant-numeric:tabular-nums}
|
||||
.conn .big{font-size:34px;font-weight:600;line-height:1.2;font-variant-numeric:tabular-nums}
|
||||
.conn .big span{font-size:13px;font-weight:400;color:var(--text-3);margin-left:6px}
|
||||
.conn .proto-row{display:flex;gap:26px;margin-top:8px;font-size:13px;font-variant-numeric:tabular-nums}
|
||||
.conn .proto-row b{font-weight:600;margin-left:5px}
|
||||
.swatch{display:inline-block;width:16px;height:0;border-top:2.5px solid var(--primary);border-radius:2px;vertical-align:middle}
|
||||
.swatch.g{border-top-color:var(--chart-2)}
|
||||
.info-bar{display:flex;gap:0;padding:14px 18px}
|
||||
.info-bar .seg{flex:1;padding:0 18px;min-width:0}
|
||||
.info-bar .seg:first-child{padding-left:0}
|
||||
.info-bar .seg+.seg{border-left:1px solid var(--border-soft)}
|
||||
.info-bar .cap{color:var(--text-3);font-size:12.5px;display:flex;align-items:center;gap:6px;margin-bottom:7px}
|
||||
.info-bar .cap svg{width:14px;height:14px}
|
||||
.kv{display:flex;gap:22px;flex-wrap:wrap}
|
||||
.kv .item .k{display:block;color:var(--text-3);font-size:11px;letter-spacing:.6px}
|
||||
.kv .item .v{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums}
|
||||
.kv .item .v.mono{font-family:var(--mono);font-size:13px;font-weight:500;word-break:break-all}
|
||||
.ln-up{stroke:var(--primary);fill:none;stroke-width:1.8;stroke-linejoin:round;stroke-linecap:round}
|
||||
.ln-dn{stroke:var(--chart-2);fill:none;stroke-width:1.8;stroke-linejoin:round;stroke-linecap:round}
|
||||
.mean{stroke:var(--primary);stroke-width:1;stroke-dasharray:4 4;opacity:.55;fill:none}
|
||||
.mean-g{stroke:var(--chart-2);stroke-width:1;stroke-dasharray:4 4;opacity:.6;fill:none}
|
||||
.grid-ln{stroke:var(--border-soft);stroke-width:1;stroke-dasharray:3 5;fill:none}
|
||||
.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}
|
||||
|
||||
/* Readable defaults on wide admin screens without inflating the navigation. */
|
||||
table{font-size:13px}th{font-size:11px;letter-spacing:.02em}td strong{font-size:13px}td small{font-size:11px}
|
||||
.panel-head p,.health-row span,.health-row small,.event small,.event time{font-size:12px}
|
||||
.status,.tag{font-size:11px}.page-intro p{font-size:13px}.actions{min-width:0}
|
||||
/* ---------- 表格通用 ---------- */
|
||||
.table-wrap{overflow-x:auto}
|
||||
.table-wrap.visible{overflow:visible}
|
||||
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}
|
||||
tbody td{padding:6px 12px;border-bottom:1px solid var(--border-soft);vertical-align:middle;font-size:13.5px}
|
||||
tbody tr:last-child td{border-bottom:none}
|
||||
tbody tr:hover{background:var(--row-hover)}
|
||||
tbody tr.dragging{opacity:.4}
|
||||
tbody tr.drag-over td{border-top:2px solid var(--primary)}
|
||||
.badge{display:inline-flex;align-items:center;gap:5px;padding:1px 8px;border-radius:5px;font-size:12px;line-height:20px;white-space:nowrap}
|
||||
.badge.green{background:var(--green-soft);color:var(--green-text)}
|
||||
.badge.red{background:var(--red-soft);color:var(--red-text)}
|
||||
.badge.orange{background:var(--orange-soft);color:var(--orange-text)}
|
||||
.badge.blue{background:var(--primary-soft);color:var(--primary-text)}
|
||||
.badge.gray{background:var(--gray-soft);color:var(--text-2)}
|
||||
.num{font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.mut{color:var(--text-3)}
|
||||
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex:none}
|
||||
.dot.on{background:var(--green);animation:pulse 2s infinite}
|
||||
.dot.off{background:var(--dot-off)}
|
||||
.dot.bad{background:var(--red)}
|
||||
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(82,196,26,.45)}70%{box-shadow:0 0 0 6px rgba(82,196,26,0)}100%{box-shadow:0 0 0 0 rgba(82,196,26,0)}}
|
||||
.op-cell{display:flex;align-items:center;gap:1px;white-space:nowrap}
|
||||
.op-cell input[type="checkbox"]{margin-right:7px}
|
||||
.icon-btn{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border:none;background:transparent;color:var(--text-2);border-radius:6px;cursor:pointer;padding:0;vertical-align:middle}
|
||||
.icon-btn svg{width:17px;height:17px;display:block}
|
||||
.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}
|
||||
.tgl .tr::before{content:"";position:absolute;width:17px;height:17px;border-radius:50%;background:#fff;top:2px;left:2px;transition:transform .2s;box-shadow:0 1px 3px rgba(0,0,0,.35)}
|
||||
.tgl input:checked + .tr{background:var(--primary)}
|
||||
.tgl input:checked + .tr::before{transform:translateX(17px)}
|
||||
.tgl input:focus-visible + .tr{outline:2px solid var(--primary-hover);outline-offset:2px}
|
||||
.tgl input:disabled + .tr{opacity:.5;cursor:default}
|
||||
.pill{display:inline-flex;align-items:center;gap:7px;background:var(--pill-bg);color:var(--text);border-radius:999px;padding:3px 11px;font-size:12.5px;white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
.pill.green{background:var(--green-soft);color:var(--green-text);font-weight:600}
|
||||
.pill.red{background:var(--red-soft);color:var(--red-text);font-weight:600}
|
||||
.pill.gray{background:var(--gray-soft);color:var(--text-2)}
|
||||
.pill.mut{color:var(--text-3)}
|
||||
.mini-bar{width:62px;height:6px;border-radius:99px;background:var(--track);overflow:hidden;display:inline-block;flex:none}
|
||||
.mini-bar i{display:block;height:100%;background:var(--primary);border-radius:99px}
|
||||
.pill.warn .mini-bar i{background:var(--orange)}
|
||||
.pill.danger .mini-bar i{background:var(--red)}
|
||||
.pager{display:flex;justify-content:flex-end;align-items:center;gap:6px;padding:9px 16px;border-top:1px solid var(--border-soft);color:var(--text-2);font-size:13px}
|
||||
.pg-btn{min-width:28px;height:28px;padding:0 7px;border:1px solid var(--border);background:var(--card);border-radius:6px;color:var(--text-2);cursor:pointer;font-size:13px}
|
||||
.pg-btn.active{border-color:var(--primary);color:var(--primary-text)}
|
||||
.pg-btn:disabled{opacity:.45;cursor:default}
|
||||
.empty{padding:28px 16px;text-align:center;color:var(--text-3);font-size:13px}
|
||||
.empty b{display:block;color:var(--text-2);font-size:14px;margin-bottom:2px}
|
||||
|
||||
/* Keep dense management tables at one row per item, even at narrow widths. */
|
||||
.table-wrap table{min-width:1040px;table-layout:auto}
|
||||
.table-wrap td{padding:9px 13px;line-height:1.35}
|
||||
.table-wrap th{padding:10px 13px}
|
||||
.table-wrap td:last-child{min-width:200px}
|
||||
.table-wrap td strong{font-weight:650}
|
||||
.table-wrap td small{font-weight:400;margin-top:3px}
|
||||
.actions.compact-actions{min-width:0;justify-content:flex-end;flex-wrap:nowrap;align-items:center;gap:4px}
|
||||
.compact-actions button{padding:6px 8px;font-size:11px;font-weight:600}
|
||||
.compact-actions select{width:92px;min-height:30px;padding:4px 7px;font-size:11px;font-weight:500;cursor:pointer}
|
||||
.error-detail{max-width:230px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--danger)}
|
||||
.subscription-row{flex-wrap:wrap;background:var(--surface)}
|
||||
.subscription-row>div:first-child{flex:1 1 310px}
|
||||
.access-table{max-height:280px;overflow:auto;margin-top:10px;border:1px solid var(--line);border-radius:10px}
|
||||
.access-table table{min-width:610px}
|
||||
.access-table td{padding:9px 12px}
|
||||
.access-table td small{max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.subscription-actions{display:flex;gap:5px;flex:0 0 auto}
|
||||
.subscription-qr{flex:1 0 100%;text-align:center;padding:14px;border-top:1px solid var(--line)}
|
||||
.subscription-qr[hidden]{display:none}
|
||||
.subscription-qr svg{max-width:min(100%,250px);height:auto;background:#fff;border:9px solid #fff;border-radius:8px}
|
||||
.status.suspending,.status.partially_suspended{color:var(--warning);background:var(--warning-soft)}
|
||||
.command-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr));gap:16px;align-items:start}
|
||||
.command-item{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 0;border-bottom:1px solid var(--line)}
|
||||
.command-item:last-child{border-bottom:0}.command-item>div{min-width:0;display:grid;gap:5px}
|
||||
.command-item small{color:var(--muted);font-size:11px;line-height:1.55}.command-item code{font-size:12px;color:var(--primary)}
|
||||
.command-item button{flex:0 0 auto}
|
||||
.node-link-list{display:grid;gap:8px;margin-top:8px}
|
||||
.node-link-customer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;background:var(--surface);border:1px solid var(--line);border-radius:8px}
|
||||
.node-link-customer .actions{margin:0}
|
||||
details summary{cursor:pointer;font-weight:600}
|
||||
/* ---------- 服务器页 ---------- */
|
||||
.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}
|
||||
.stats .stat .val svg{width:19px;height:19px;flex:none}
|
||||
.ic-dim{color:var(--text-2)} .ic-green{color:var(--green)} .ic-red{color:var(--red)} .ic-bolt{color:var(--orange)}
|
||||
.exp{display:inline-flex;width:20px;height:20px;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:5px;color:var(--text-2);font-size:14px;line-height:1;background:var(--card);cursor:pointer;padding:0}
|
||||
.eye-btn{width:24px;height:24px;vertical-align:-6px;margin-left:4px}
|
||||
.eye-btn svg{width:15px;height:15px}
|
||||
.dev-name{font-weight:600;font-size:13.5px;white-space:nowrap}
|
||||
.addr{font-family:var(--mono);font-size:12.5px;color:var(--text-2);white-space:nowrap}
|
||||
.status{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font-weight:500}
|
||||
.status.on{color:var(--green-text)}
|
||||
.status.off{color:var(--red-text)}
|
||||
.warn-ic{width:15px;height:15px;color:var(--orange);flex:none;vertical-align:-2px}
|
||||
.ver-cell{display:inline-flex;align-items:center;gap:7px;white-space:nowrap}
|
||||
.ver{font-family:var(--mono);font-size:12.5px}
|
||||
.clients-cell{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
.clients-cell svg{width:16px;height:16px;color:var(--text-3);flex:none}
|
||||
.clients-cell .on-n{color:var(--green-text);font-weight:600}
|
||||
.hb{white-space:nowrap;color:var(--text-2)}
|
||||
.detail-row td{background:var(--th-bg);padding:12px 16px}
|
||||
.detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:8px 22px;font-size:13px}
|
||||
.detail-grid .k{color:var(--text-3);font-size:11.5px;display:block}
|
||||
.detail-grid .v{font-weight:500}
|
||||
.detail-grid .v.err{color:var(--red-text)}
|
||||
.detail-actions{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
|
||||
|
||||
/* ---------- 客户页 ---------- */
|
||||
.pres{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:2px 11px;font-size:12.5px;font-weight:600;white-space:nowrap}
|
||||
.pres.on{background:var(--green-soft);color:var(--green-text)}
|
||||
.pres.off{background:var(--gray-soft);color:var(--text-3);font-weight:400}
|
||||
.paused-note{display:block;color:var(--red-text);font-size:12px;font-weight:500;margin-top:4px;white-space:nowrap}
|
||||
.cust-name{font-weight:600;font-size:13.5px;white-space:nowrap}
|
||||
.cust-note{display:block;color:var(--text-3);font-size:12px;margin-top:1px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.in-chip{display:inline-flex;padding:2px 9px;border-radius:6px;background:var(--primary-soft);color:var(--primary-text);font-size:12.5px;font-weight:500;line-height:20px;white-space:nowrap}
|
||||
.in-chip.more{background:var(--gray-soft);color:var(--text-2);margin-left:5px;cursor:pointer;border:none;font-family:var(--font)}
|
||||
.in-chip.more:hover{color:var(--primary-text);background:var(--primary-soft)}
|
||||
.in-chip.ss{background:var(--pink-soft);color:var(--pink-text)}
|
||||
.in-chip.mixed{background:var(--orange-soft);color:var(--orange-text)}
|
||||
.speed{font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.speed.mut{color:var(--text-3)}
|
||||
.filter-chip{display:inline-flex;align-items:center;gap:6px;background:var(--primary-soft);color:var(--primary-text);border-radius:999px;padding:3px 6px 3px 11px;font-size:12.5px}
|
||||
.filter-chip button{border:none;background:transparent;color:inherit;cursor:pointer;font-size:14px;line-height:1;padding:2px}
|
||||
|
||||
/* ---------- 节点页 ---------- */
|
||||
.grip{display:inline-flex;padding:3px 2px;border-radius:4px;color:var(--text-3);cursor:grab;vertical-align:middle}
|
||||
.grip svg{width:13px;height:13px;display:block}
|
||||
.grip:hover{color:var(--text);background:var(--hover-bg)}
|
||||
.grip:active{cursor:grabbing}
|
||||
td.menu-td{white-space:nowrap}
|
||||
.node-name{font-weight:600;font-size:13.5px;white-space:nowrap}
|
||||
.srv-chip{display:inline-flex;padding:2px 10px;border-radius:6px;font-size:12.5px;font-weight:500;line-height:20px;white-space:nowrap}
|
||||
.srv-chip.online{background:var(--primary-soft);color:var(--primary-text)}
|
||||
.srv-chip.offline{background:var(--red-soft);color:var(--red-text)}
|
||||
.srv-chip.degraded{background:var(--orange-soft);color:var(--orange-text)}
|
||||
.port{font-variant-numeric:tabular-nums}
|
||||
.proto{display:inline-flex;padding:0 6px;border-radius:5px;font-size:11.5px;font-weight:600;line-height:18px;white-space:nowrap}
|
||||
.proto.vless{background:var(--purple-soft);color:var(--purple-text)}
|
||||
.proto.ss{background:var(--cyan-soft);color:var(--cyan-text)}
|
||||
.proto.mixed{background:var(--orange-soft);color:var(--orange-text)}
|
||||
.proto.other{background:var(--gray-soft);color:var(--text-2)}
|
||||
.trans{display:inline-flex;padding:0 6px;border-radius:5px;font-size:11.5px;line-height:18px;white-space:nowrap;background:var(--trans-bg);color:var(--trans-text)}
|
||||
.sec{display:inline-flex;padding:0 6px;border-radius:5px;font-size:11.5px;line-height:18px;white-space:nowrap;background:var(--sky-soft);color:var(--sky-text)}
|
||||
.sec.plain{background:var(--gray-soft);color:var(--text-2)}
|
||||
.badge-stack{display:inline-flex;gap:4px;flex-wrap:nowrap;align-items:center}
|
||||
.clients{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;cursor:pointer}
|
||||
.clients>svg{width:17px;height:17px;color:var(--text-3);flex:none}
|
||||
.online-num{color:var(--green-text);font-weight:600;padding:1px 2px}
|
||||
.online-num.zero{color:var(--text-3);font-weight:400}
|
||||
|
||||
/* ---------- 浮层(挂 body) ---------- */
|
||||
.float-pop{position:fixed;z-index:90;background:var(--pop-bg);border:1px solid var(--border);border-radius:8px;box-shadow:var(--pop-shadow);min-width:176px;max-width:320px;padding:6px 0}
|
||||
.float-pop .pop-head{display:flex;justify-content:space-between;align-items:center;padding:6px 12px;border-bottom:1px solid var(--border-soft);font-size:12px;color:var(--text-2)}
|
||||
.float-pop .pop-head b{color:var(--text);font-size:13px}
|
||||
.float-pop .pop-item{display:flex;align-items:center;gap:8px;padding:5px 12px;color:var(--text);font-size:13px}
|
||||
.float-pop .pop-item .in-chip{margin:2px 0}
|
||||
.float-pop .pop-foot{padding:6px 12px;border-top:1px solid var(--border-soft);font-size:11px;color:var(--text-3)}
|
||||
.float-pop .menu-item{display:flex;width:100%;align-items:center;gap:8px;padding:7px 12px;border:none;background:transparent;color:var(--text);font-size:13px;cursor:pointer;text-align:left}
|
||||
.float-pop .menu-item:hover{background:var(--hover-bg)}
|
||||
.float-pop .menu-item.danger{color:var(--red-text)}
|
||||
.float-pop .chip-list{padding:8px 12px;display:flex;flex-direction:column;gap:6px;align-items:flex-start}
|
||||
|
||||
/* ---------- 弹窗与表单 ---------- */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(10,12,16,.45);z-index:80;display:flex;align-items:flex-start;justify-content:center;overflow-y:auto;padding:36px 16px}
|
||||
.modal{width:640px;max-width:100%;margin:0}
|
||||
.modal.wide{width:820px}
|
||||
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:13px 18px;border-bottom:1px solid var(--border-soft)}
|
||||
.modal-head h2{font-size:15.5px;font-weight:600}
|
||||
.modal-body{padding:16px 18px}
|
||||
.stack{display:flex;flex-direction:column;gap:12px}
|
||||
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 16px}
|
||||
.form-grid .wide{grid-column:1 / -1}
|
||||
label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:var(--text-2)}
|
||||
label>input,label>select,label>textarea,.field>input{border:1px solid var(--border);background:var(--card);border-radius:6px;padding:6px 10px;font-size:13.5px;color:var(--text)}
|
||||
label>textarea{min-height:56px;resize:vertical}
|
||||
label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--primary);outline:none}
|
||||
.form-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
|
||||
.form-actions .left{margin-right:auto}
|
||||
.inline-fields{display:flex;gap:8px}
|
||||
.inline-fields>*{flex:1;min-width:0}
|
||||
.pair-fields{display:flex;gap:10px;align-items:flex-end}
|
||||
.pair-fields label{flex:1}
|
||||
.form-error{color:var(--red-text);font-size:13px;min-height:18px}
|
||||
.codebox{background:var(--bg);border:1px solid var(--border-soft);border-radius:6px;padding:10px 12px;font-family:var(--mono);font-size:12.5px;white-space:pre-wrap;word-break:break-all;color:var(--text)}
|
||||
.section-title{grid-column:1 / -1;font-size:13px;font-weight:600;color:var(--text);border-top:1px solid var(--border-soft);padding-top:12px}
|
||||
.kv-table{width:100%;border-collapse:collapse}
|
||||
.kv-table td{padding:5px 8px;border-bottom:1px solid var(--border-soft);font-size:13px;vertical-align:top}
|
||||
.kv-table td.k{color:var(--text-3);width:130px;white-space:nowrap}
|
||||
.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}
|
||||
.tab-pane.hidden{display:none}
|
||||
|
||||
/* 凭据行 */
|
||||
.cred-row{display:flex;align-items:center;gap:8px;padding:7px 0;border-bottom:1px solid var(--border-soft)}
|
||||
.cred-row:last-child{border-bottom:none}
|
||||
.cred-row .k{width:110px;flex:none;color:var(--text-3);font-size:12.5px}
|
||||
.cred-row .v{flex:1;min-width:0;font-family:var(--mono);font-size:12.5px;word-break:break-all}
|
||||
.cred-group{margin-bottom:14px}
|
||||
.cred-group .g-head{display:flex;align-items:center;gap:8px;font-weight:600;font-size:13.5px;margin-bottom:2px}
|
||||
|
||||
/* 链接页签条目 */
|
||||
.link-item{border:1px solid var(--border-soft);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}
|
||||
.link-item .row{display:flex;gap:8px;align-items:center}
|
||||
.link-item input[type="text"],.link-item input:not([type]){flex:1;border:1px solid var(--border);background:var(--card);border-radius:6px;padding:6px 10px;font-size:13px;min-width:0}
|
||||
.link-item .remark{flex:0 0 150px}
|
||||
.link-item .status-line{font-size:12px;color:var(--text-3)}
|
||||
.link-item .status-line .err{color:var(--red-text)}
|
||||
.links-head{display:flex;align-items:center;justify-content:space-between;margin:14px 0 8px}
|
||||
.links-head h3{font-size:13.5px;font-weight:600}
|
||||
.links-note{font-size:13px;color:var(--text-2)}
|
||||
.links-empty{color:var(--text-3);font-size:13px;padding:6px 0}
|
||||
|
||||
/* 关联入站选择器 */
|
||||
.picker{position:relative}
|
||||
.picker-box{display:flex;flex-wrap:wrap;gap:6px;align-items:center;border:1px solid var(--border);border-radius:6px;padding:5px 8px;min-height:34px;cursor:text;background:var(--card)}
|
||||
.picker-box .chip{display:inline-flex;align-items:center;gap:5px;background:var(--primary-soft);color:var(--primary-text);border-radius:5px;padding:1px 4px 1px 8px;font-size:12.5px}
|
||||
.picker-box .chip button{border:none;background:transparent;color:inherit;cursor:pointer;font-size:13px;line-height:1;padding:2px}
|
||||
.picker-box .ph{color:var(--text-3);font-size:13px}
|
||||
.picker-box .search-ic{margin-left:auto;color:var(--text-3);flex:none}
|
||||
.picker-panel{position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:40;background:var(--pop-bg);border:1px solid var(--border);border-radius:8px;box-shadow:var(--pop-shadow);padding:6px}
|
||||
.picker-tools{display:flex;gap:8px;align-items:center;padding:2px 4px 6px}
|
||||
.picker-tools input{flex:1;border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:13px;background:var(--card)}
|
||||
.picker-list{max-height:220px;overflow-y:auto}
|
||||
.picker-opt{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;font-size:13.5px;color:var(--text)}
|
||||
.picker-opt:hover{background:var(--hover-bg)}
|
||||
.picker-opt.sel{background:var(--primary-soft)}
|
||||
.picker-opt .ck{margin-left:auto;color:var(--primary-text);font-weight:700;visibility:hidden}
|
||||
.picker-opt.sel .ck{visibility:visible}
|
||||
.picker-opt small{color:var(--text-3)}
|
||||
|
||||
/* 订阅弹窗 */
|
||||
.subscription-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border-soft)}
|
||||
.subscription-row>div:first-child{flex:1;min-width:0}
|
||||
.subscription-row b{display:block;font-size:13.5px}
|
||||
.subscription-row code{font-family:var(--mono);font-size:11.5px;color:var(--text-2);word-break:break-all}
|
||||
.subscription-actions{display:flex;gap:6px;flex:none}
|
||||
.subscription-qr{padding:10px 0;text-align:center}
|
||||
.subscription-qr svg{max-width:180px;height:auto}
|
||||
.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;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}
|
||||
.command-item b{display:block;font-size:13.5px}
|
||||
.command-item code{font-family:var(--mono);font-size:12.5px;color:var(--primary-text)}
|
||||
.grid-3{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
|
||||
.grid-3 .panel{margin-bottom:0}
|
||||
.health-row{display:flex;align-items:center;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--border-soft);font-size:13.5px}
|
||||
.health-row:last-child{border-bottom:none}
|
||||
.health-row span{color:var(--text-3);font-size:12.5px;display:block}
|
||||
.tag{display:inline-flex;padding:1px 8px;border-radius:5px;background:var(--gray-soft);color:var(--text-2);font-size:12px;line-height:20px}
|
||||
|
||||
/* 登录 */
|
||||
.login-shell{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
.login-card{width:360px;max-width:100%;padding:26px 26px 18px;position:relative;margin:0}
|
||||
.login-brand{font-size:26px;font-weight:700;letter-spacing:.4px;margin-bottom:18px}
|
||||
.login-theme{position:absolute;top:14px;right:14px}
|
||||
.login-card label{margin-bottom:12px}
|
||||
.login-card label>input{width:100%}
|
||||
|
||||
/* toast */
|
||||
#toasts{position:fixed;right:18px;bottom:18px;z-index:120;display:flex;flex-direction:column;gap:8px}
|
||||
.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)}.stats.six{grid-template-columns:repeat(3,1fr)}}
|
||||
@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.content{padding:8px}}
|
||||
|
||||
@@ -7,11 +7,15 @@ const net = require('node:net');
|
||||
const { Store } = require('./lib/store');
|
||||
const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth');
|
||||
const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http');
|
||||
const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, dropRealityKey } = require('./lib/protocols');
|
||||
const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator');
|
||||
const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, dropRealityKey, nodeEmail } = require('./lib/protocols');
|
||||
const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources, alignCustomerNodes } = require('./lib/orchestrator');
|
||||
const { formatSubscription, nodeClientUri } = require('./lib/subscriptions');
|
||||
const { redactSecrets } = require('./lib/redact');
|
||||
const { clientIdentity, activeClients, observedIps, pruneAccess } = require('./lib/access');
|
||||
const { parseResetDay, armTrafficReset, applyMonthlyTrafficResets } = require('./lib/reset');
|
||||
const { customerOnline, customerSpeedBps, nodeSpeedBps, nodeOnlineCustomerIds } = require('./lib/presence');
|
||||
const { SystemMonitor } = require('./lib/sysstat');
|
||||
const { fetchExternalSource, dueSources, applyFetchResult, normalizeExternalSources, normalizeManualLinks } = require('./lib/extsub');
|
||||
|
||||
const APP_ROOT = __dirname;
|
||||
const PUBLIC_DIR = path.join(APP_ROOT, 'public');
|
||||
@@ -24,6 +28,7 @@ const VERSION = '0.6.8';
|
||||
const store = new Store(DATA_FILE);
|
||||
let sessions;
|
||||
let orchestrator;
|
||||
let systemMonitor = null;
|
||||
const loginAttempts = new Map();
|
||||
|
||||
function cleanText(value, max = 120) {
|
||||
@@ -70,6 +75,7 @@ function normalizeServer(body, current = {}) {
|
||||
publicAddress: 'publicAddress' in body ? requiredText(body.publicAddress, '公网地址', 255) : current.publicAddress,
|
||||
publicAddressV6: 'publicAddressV6' in body ? cleanText(body.publicAddressV6, 255) : (current.publicAddressV6 || ''),
|
||||
tlsDomain: 'tlsDomain' in body ? cleanText(body.tlsDomain, 253).toLowerCase() : (current.tlsDomain || ''),
|
||||
enabled: 'enabled' in body ? body.enabled !== false : (current.enabled !== undefined ? current.enabled : true),
|
||||
portRangeStart: 'portRangeStart' in body ? Number(body.portRangeStart) : Number(current.portRangeStart || 20000),
|
||||
portRangeEnd: 'portRangeEnd' in body ? Number(body.portRangeEnd) : Number(current.portRangeEnd || 50000),
|
||||
labels: 'labels' in body ? asIds(body.labels).slice(0, 20) : (current.labels || [])
|
||||
@@ -134,14 +140,47 @@ function publicNode(item) {
|
||||
|
||||
function publicServer(item) {
|
||||
return { ...item, ...(item.engine ? { engine: { ...item.engine, detail: redactSecrets(item.engine.detail) } } : {}),
|
||||
enabled: item.enabled !== false,
|
||||
agentVersion: store.data.agents.find((agent) => agent.serverId === item.id && agent.status === 'active')?.version || null,
|
||||
pendingCleanup: store.data.nodes.filter((entry) => (entry.serverId === item.id || entry.exitServerId === item.id) && entry.status === 'removing').length };
|
||||
}
|
||||
|
||||
function publicCustomer(item) {
|
||||
return { ...item,
|
||||
const now = Date.now();
|
||||
const speed = customerSpeedBps(item, now);
|
||||
const out = { ...item,
|
||||
observedIpCount: observedIps(store.data, item.id).length,
|
||||
subscriptionClientCount: activeClients(store.data, item.id).length };
|
||||
subscriptionClientCount: activeClients(store.data, item.id).length,
|
||||
online: customerOnline(item, now),
|
||||
lastActiveAt: item.lastOnlineAt || null,
|
||||
speedUpBps: speed.up, speedDownBps: speed.down };
|
||||
if ((item.externalSources || []).length) {
|
||||
out.externalStatus = {};
|
||||
for (const source of item.externalSources) {
|
||||
const cache = (store.data.subSourceCache || {})[source.id];
|
||||
out.externalStatus[source.id] = cache
|
||||
? { fetchedAt: cache.fetchedAt || null, okAt: cache.okAt || null, failCount: cache.failCount || 0,
|
||||
lastError: cache.lastError || null, droppedCount: cache.droppedCount || 0, count: (cache.items || []).length }
|
||||
: { fetchedAt: null, okAt: null, failCount: 0, lastError: null, droppedCount: 0, count: 0 };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// One customer's credential rows (凭据 tab): the subscription token plus
|
||||
// one row per node binding, holding that binding's own credentials.
|
||||
function credentialItems(data, customer) {
|
||||
return data.nodeClients.filter((nc) => nc.customerId === customer.id).map((nc) => {
|
||||
const node = data.nodes.find((item) => item.id === nc.nodeId);
|
||||
return {
|
||||
nodeClientId: nc.id, nodeId: nc.nodeId, nodeName: node ? node.name : nc.nodeId,
|
||||
protocol: node ? node.protocol : '', status: nc.status,
|
||||
clientId: nc.credentials ? nc.credentials.clientId || '' : '',
|
||||
password: nc.credentials ? nc.credentials.relayPassword || '' : '',
|
||||
shortId: nc.credentials ? nc.credentials.shortId || '' : '',
|
||||
username: node && node.protocol === 'socks5-auth' ? nodeEmail(customer.id) : '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function customerBlockReason(data, customer) {
|
||||
@@ -398,6 +437,10 @@ async function handleAgent(req, res, pathname) {
|
||||
const existing = data.observations.find((item) => item.customerId === customerId && item.ip === ip);
|
||||
if (existing) existing.lastSeenAt = nowIso();
|
||||
else data.observations.push({ id: id('obs'), customerId, ip, firstSeenAt: nowIso(), lastSeenAt: nowIso() });
|
||||
// An accepted observation is a live connection on this server:
|
||||
// it is the customer's freshest presence signal.
|
||||
const observedCustomer = data.customers.find((item) => item.id === customerId);
|
||||
if (observedCustomer) observedCustomer.lastOnlineAt = nowIso();
|
||||
affected.add(customerId);
|
||||
}
|
||||
for (const customerId of affected) {
|
||||
@@ -418,6 +461,33 @@ async function handleAgent(req, res, pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetch one external source now and persist the outcome. Used by the
|
||||
// manual refresh endpoint (awaited) and by save/housekeeping triggers.
|
||||
async function refreshOneSource(customerId, sourceId) {
|
||||
const customer = store.data.customers.find((item) => item.id === customerId);
|
||||
const source = customer && (customer.externalSources || []).find((item) => item.id === sourceId);
|
||||
if (!source) return null;
|
||||
const result = await fetchExternalSource(source, store.data.settings);
|
||||
await store.transaction((data) => { applyFetchResult(data, sourceId, result, nowIso()); });
|
||||
return result;
|
||||
}
|
||||
|
||||
function refreshSourcesInBackground(customerId, sourceIds) {
|
||||
for (const sourceId of sourceIds || []) {
|
||||
refreshOneSource(customerId, sourceId).catch((error) => console.error('External subscription refresh failed:', error));
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDueSources() {
|
||||
try {
|
||||
for (const { customerId, source } of dueSources(store.data, Date.now(), 3)) {
|
||||
await refreshOneSource(customerId, source.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('External subscription refresh failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdminApi(req, res, pathname) {
|
||||
if (!pathname.startsWith('/api/')) return false;
|
||||
const changing = !['GET', 'HEAD'].includes(req.method);
|
||||
@@ -440,6 +510,11 @@ async function handleAdminApi(req, res, pathname) {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/api/system') {
|
||||
if (!systemMonitor) systemMonitor = new SystemMonitor(path.dirname(DATA_FILE));
|
||||
sendJson(res, 200, systemMonitor.status(store.data, VERSION));
|
||||
return true;
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/api/protocols') {
|
||||
sendJson(res, 200, { profiles: PROFILE_CATALOG, realityPresets: REALITY_PRESETS });
|
||||
return true;
|
||||
@@ -602,7 +677,7 @@ async function handleAdminApi(req, res, pathname) {
|
||||
}
|
||||
if (req.method === 'POST' && pathname === '/api/customers') {
|
||||
const body = await readJson(req);
|
||||
const customer = await store.transaction((data) => {
|
||||
const customer = await store.transaction(async (data) => {
|
||||
const next = {
|
||||
id: id('cus'), subscriptionToken: randomToken(32), name: requiredText(body.name, '客户名称'), group: cleanText(body.group, 80),
|
||||
status: 'active', trafficLimitBytes: nonNegativeNumber(body.trafficLimitBytes, '流量上限'), usedBytes: 0, usedUplinkBytes: 0, usedDownlinkBytes: 0,
|
||||
@@ -611,16 +686,25 @@ async function handleAdminApi(req, res, pathname) {
|
||||
tags: asIds(body.tags).slice(0, 20), notes: cleanText(body.notes, 1000),
|
||||
createdAt: nowIso(), updatedAt: nowIso()
|
||||
};
|
||||
const resetDay = parseResetDay(body.trafficResetDay);
|
||||
if (resetDay > 0) { next.trafficResetDay = resetDay; armTrafficReset(next); }
|
||||
const sources = normalizeExternalSources(body.externalSources, id);
|
||||
if (sources && sources.length) next.externalSources = sources;
|
||||
const links = normalizeManualLinks(body.manualLinks, id);
|
||||
if (links && links.length) next.manualLinks = links;
|
||||
data.customers.push(next);
|
||||
if (Array.isArray(body.nodeIds)) await alignCustomerNodes(data, next, body.nodeIds, actor);
|
||||
audit(data, actor, 'create_customer', next.id, { name: next.name });
|
||||
return structuredClone(next);
|
||||
});
|
||||
refreshSourcesInBackground(customer.id, (customer.externalSources || []).map((source) => source.id));
|
||||
sendJson(res, 201, { customer });
|
||||
return true;
|
||||
}
|
||||
const customerItem = route('/api/customers/:id', pathname);
|
||||
if (customerItem && req.method === 'PATCH') {
|
||||
const body = await readJson(req);
|
||||
let changedSourceIds = [];
|
||||
const customer = await store.transaction(async (data) => {
|
||||
const item = data.customers.find((entry) => entry.id === customerItem.id);
|
||||
if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
|
||||
@@ -634,6 +718,32 @@ async function handleAdminApi(req, res, pathname) {
|
||||
if ('deviceLimit' in body) item.deviceLimit = nonNegativeNumber(body.deviceLimit, '设备上限', true);
|
||||
if ('expiresAt' in body) item.expiresAt = optionalIso(body.expiresAt, '到期日期');
|
||||
if ('tags' in body) item.tags = asIds(body.tags).slice(0, 20);
|
||||
if ('trafficResetDay' in body) {
|
||||
const resetDay = parseResetDay(body.trafficResetDay);
|
||||
if (resetDay !== (item.trafficResetDay || 0)) {
|
||||
// A changed schedule is re-armed rather than fired (mirrors
|
||||
// panel-go); an unchanged echo leaves the marker alone.
|
||||
if (resetDay > 0) item.trafficResetDay = resetDay; else delete item.trafficResetDay;
|
||||
armTrafficReset(item);
|
||||
}
|
||||
}
|
||||
if ('externalSources' in body) {
|
||||
const previous = new Map((item.externalSources || []).map((source) => [source.id, source]));
|
||||
const sources = normalizeExternalSources(body.externalSources, id) || [];
|
||||
if (sources.length) item.externalSources = sources; else delete item.externalSources;
|
||||
const kept = new Set(sources.map((source) => source.id));
|
||||
if (data.subSourceCache) {
|
||||
for (const oldId of previous.keys()) if (!kept.has(oldId)) delete data.subSourceCache[oldId];
|
||||
}
|
||||
changedSourceIds = sources.filter((source) => {
|
||||
const old = previous.get(source.id);
|
||||
return !old || old.url !== source.url || (old.userAgent || '') !== (source.userAgent || '');
|
||||
}).map((source) => source.id);
|
||||
}
|
||||
if ('manualLinks' in body) {
|
||||
const links = normalizeManualLinks(body.manualLinks, id) || [];
|
||||
if (links.length) item.manualLinks = links; else delete item.manualLinks;
|
||||
}
|
||||
const reason = customerBlockReason(data, item);
|
||||
if (body.status === 'active' && reason) {
|
||||
const error = new Error(`客户仍受${{ expired:'到期时间', traffic_limit:'流量额度', ip_limit:'节点 IP 上限' }[reason]}限制,请先修改限制再启用`);
|
||||
@@ -646,10 +756,12 @@ async function handleAdminApi(req, res, pathname) {
|
||||
}
|
||||
if (wasActive && item.status === 'suspended') suspendCustomerResources(data, item, actor);
|
||||
if (!wasActive && item.status === 'active') await resumeCustomerResources(data, item, actor);
|
||||
if (Array.isArray(body.nodeIds)) await alignCustomerNodes(data, item, body.nodeIds, actor);
|
||||
item.updatedAt = nowIso();
|
||||
audit(data, actor, 'update_customer', item.id);
|
||||
return structuredClone(item);
|
||||
});
|
||||
refreshSourcesInBackground(customer.id, changedSourceIds);
|
||||
sendJson(res, 200, { customer });
|
||||
return true;
|
||||
}
|
||||
@@ -686,24 +798,98 @@ async function handleAdminApi(req, res, pathname) {
|
||||
data.nodes.some((node) => node.id === item.nodeId && !node.pendingDelete))) {
|
||||
throw Object.assign(new Error('客户仍有节点分配。请先移除全部分配'), { statusCode: 409 });
|
||||
}
|
||||
const removed = data.customers[index];
|
||||
data.customers.splice(index, 1);
|
||||
data.subscriptionAccess = data.subscriptionAccess.filter((item) => item.customerId !== customerItem.id);
|
||||
data.subscriptionClients = data.subscriptionClients.filter((item) => item.customerId !== customerItem.id);
|
||||
data.observations = data.observations.filter((item) => item.customerId !== customerItem.id);
|
||||
if (data.subSourceCache) {
|
||||
for (const source of removed.externalSources || []) delete data.subSourceCache[source.id];
|
||||
}
|
||||
audit(data, actor, 'delete_customer', customerItem.id);
|
||||
});
|
||||
sendJson(res, 200, { ok: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
const credentialsRoute = route('/api/customers/:id/credentials', pathname);
|
||||
if (credentialsRoute && req.method === 'GET') {
|
||||
const customer = store.data.customers.find((item) => item.id === credentialsRoute.id);
|
||||
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
|
||||
sendJson(res, 200, { customerId: customer.id, subscriptionToken: customer.subscriptionToken,
|
||||
items: credentialItems(store.data, customer) });
|
||||
return true;
|
||||
}
|
||||
const regenerateRoute = route('/api/customers/:id/credentials/:ncid/regenerate', pathname);
|
||||
if (regenerateRoute && req.method === 'POST') {
|
||||
const item = await store.transaction(async (data) => {
|
||||
const customer = data.customers.find((entry) => entry.id === regenerateRoute.id);
|
||||
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
|
||||
const nc = data.nodeClients.find((entry) => entry.id === regenerateRoute.ncid && entry.customerId === customer.id);
|
||||
if (!nc) throw Object.assign(new Error('凭据不存在'), { statusCode: 404 });
|
||||
const node = data.nodes.find((entry) => entry.id === nc.nodeId);
|
||||
if (!node) throw Object.assign(new Error('节点不存在'), { statusCode: 404 });
|
||||
// Same generation rules as a fresh assignment; the node resource is
|
||||
// rebuilt so the old credential stops working on the wire.
|
||||
nc.credentials = newNodeClientCredentials(node.protocol);
|
||||
nc.updatedAt = nowIso();
|
||||
if (['active', 'deploying', 'failed', 'degraded'].includes(node.status)) await rebuildNode(data, node);
|
||||
audit(data, actor, 'regenerate_credentials', customer.id, { nodeId: node.id });
|
||||
return credentialItems(data, customer).find((entry) => entry.nodeClientId === nc.id);
|
||||
});
|
||||
sendJson(res, 200, { item });
|
||||
return true;
|
||||
}
|
||||
const sourceRefresh = route('/api/customers/:id/external-sources/:sid/refresh', pathname);
|
||||
if (sourceRefresh && req.method === 'POST') {
|
||||
const customer = store.data.customers.find((item) => item.id === sourceRefresh.id);
|
||||
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
|
||||
if (!(customer.externalSources || []).some((item) => item.id === sourceRefresh.sid)) {
|
||||
throw Object.assign(new Error('外部订阅不存在'), { statusCode: 404 });
|
||||
}
|
||||
const result = await refreshOneSource(customer.id, sourceRefresh.sid);
|
||||
sendJson(res, 200, { ok: Boolean(result && result.ok), count: result ? result.items.length : 0,
|
||||
dropped: result ? result.dropped : 0, error: result ? result.error : '刷新失败' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/nodes') {
|
||||
const visibleNodes = store.data.nodes.filter((item) => !item.pendingDelete);
|
||||
const now = Date.now();
|
||||
const visibleNodes = store.data.nodes.filter((item) => !item.pendingDelete)
|
||||
.sort((a, b) => (Number.isFinite(a.sortIndex) ? a.sortIndex : Number.MAX_SAFE_INTEGER) - (Number.isFinite(b.sortIndex) ? b.sortIndex : Number.MAX_SAFE_INTEGER));
|
||||
const visibleIds = new Set(visibleNodes.map((item) => item.id));
|
||||
const nodeClients = store.data.nodeClients.filter((nc) => visibleIds.has(nc.nodeId)).map((nc) => {
|
||||
const { credentials, ...safe } = nc;
|
||||
return { ...safe, clientUri: nodeClientUri(store.data, nc) };
|
||||
});
|
||||
sendJson(res, 200, { nodes: visibleNodes.map(publicNode), nodeClients });
|
||||
const nodes = visibleNodes.map((node) => {
|
||||
const onlineCustomerIds = nodeOnlineCustomerIds(store.data, node, now);
|
||||
const speed = nodeSpeedBps(node, now);
|
||||
return { ...publicNode(node),
|
||||
clientCount: store.data.nodeClients.filter((nc) => nc.nodeId === node.id && nc.status === 'active').length,
|
||||
onlineCount: onlineCustomerIds.length, onlineCustomerIds,
|
||||
speedUpBps: speed.up, speedDownBps: speed.down };
|
||||
});
|
||||
sendJson(res, 200, { nodes, nodeClients });
|
||||
return true;
|
||||
}
|
||||
if (req.method === 'POST' && pathname === '/api/nodes/reorder') {
|
||||
const body = await readJson(req);
|
||||
const ids = asIds(body.ids);
|
||||
if (!ids.length) throw Object.assign(new Error('排序列表不能为空'), { statusCode: 400 });
|
||||
await store.transaction((data) => {
|
||||
const byId = new Map(data.nodes.map((node) => [node.id, node]));
|
||||
for (const nodeId of ids) {
|
||||
if (!byId.has(nodeId)) throw Object.assign(new Error('节点不存在'), { statusCode: 400 });
|
||||
}
|
||||
const listed = new Set(ids);
|
||||
const rest = data.nodes.filter((node) => !listed.has(node.id))
|
||||
.sort((a, b) => (Number.isFinite(a.sortIndex) ? a.sortIndex : Number.MAX_SAFE_INTEGER) - (Number.isFinite(b.sortIndex) ? b.sortIndex : Number.MAX_SAFE_INTEGER));
|
||||
[...ids.map((nodeId) => byId.get(nodeId)), ...rest].forEach((node, index) => { node.sortIndex = index; });
|
||||
audit(data, actor, 'reorder_nodes', 'nodes', { count: ids.length });
|
||||
return null;
|
||||
});
|
||||
sendJson(res, 200, { ok: true });
|
||||
return true;
|
||||
}
|
||||
if (req.method === 'POST' && pathname === '/api/nodes') {
|
||||
@@ -713,6 +899,7 @@ async function handleAdminApi(req, res, pathname) {
|
||||
const next = {
|
||||
id: id('nod'), status: 'draft', entryReady: false, exitReady: false,
|
||||
resourceId: id('res'), exitResourceId: id('res'),
|
||||
sortIndex: data.nodes.reduce((max, node) => Math.max(max, Number.isFinite(node.sortIndex) ? node.sortIndex : -1), -1) + 1,
|
||||
...normalized,
|
||||
artifacts: {}, error: null, pendingDelete: false,
|
||||
usageCounters: null, retiredUsageEpochs: [], meteredTraffic: null, lastUsageAt: null,
|
||||
@@ -1066,6 +1253,16 @@ async function housekeeping() {
|
||||
try {
|
||||
await store.transaction(async (data) => {
|
||||
const now = Date.now();
|
||||
// Monthly traffic resets run before the quota evaluation below,
|
||||
// mirroring panel-go housekeeping.
|
||||
const resetDone = applyMonthlyTrafficResets(data, new Date(), (draft, customer, previousUsed, day, period) =>
|
||||
audit(draft, 'system:monthly_reset', 'reset_customer_usage_monthly', customer.id, { previousUsedBytes: previousUsed, resetDay: day, period }));
|
||||
for (const customer of resetDone) {
|
||||
if (customer.status === 'suspended' && customer.suspendReason === 'traffic_limit' && !customerBlockReason(data, customer)) {
|
||||
customer.status = 'active'; customer.suspendReason = null; customer.updatedAt = nowIso();
|
||||
await resumeCustomerResources(data, customer, 'system:monthly_reset');
|
||||
}
|
||||
}
|
||||
for (const server of data.servers) {
|
||||
if (server.lastSeenAt && now - new Date(server.lastSeenAt).getTime() > 180000) server.status = 'offline';
|
||||
}
|
||||
@@ -1103,6 +1300,9 @@ async function housekeeping() {
|
||||
return null;
|
||||
});
|
||||
sessions.prune();
|
||||
if (!systemMonitor) systemMonitor = new SystemMonitor(path.dirname(DATA_FILE));
|
||||
systemMonitor.sample(store.data);
|
||||
await refreshDueSources();
|
||||
} catch (error) {
|
||||
console.error('Housekeeping failed:', error);
|
||||
}
|
||||
@@ -1126,6 +1326,14 @@ async function main() {
|
||||
if (store.data.customers.some((item) => !item.subscriptionToken)) {
|
||||
await store.transaction((data) => { for (const customer of data.customers) if (!customer.subscriptionToken) customer.subscriptionToken = randomToken(32); });
|
||||
}
|
||||
// Node custom order: legacy records carry no sortIndex. Renumber by the
|
||||
// stored array order whenever the values are not exactly 0..n-1.
|
||||
const sortValues = store.data.nodes.map((node) => node.sortIndex);
|
||||
const sortBroken = !sortValues.every((value) => Number.isFinite(value)) ||
|
||||
[...sortValues].sort((a, b) => a - b).some((value, index) => value !== index);
|
||||
if (store.data.nodes.length && sortBroken) {
|
||||
await store.transaction((data) => { data.nodes.forEach((node, index) => { node.sortIndex = index; }); });
|
||||
}
|
||||
if (!store.data.users.length) {
|
||||
const password = process.env.NG_ADMIN_PASSWORD || randomToken(15);
|
||||
await store.transaction((data) => {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const net = require('node:net');
|
||||
const http = require('node:http');
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
const { trafficResetPeriodKey, armTrafficReset, applyMonthlyTrafficResets, parseResetDay } = require('../lib/reset');
|
||||
const { parseLinkList, validateLinkUri, fetchExternalSource } = require('../lib/extsub');
|
||||
const { aggregateEntries } = require('../lib/subscriptions');
|
||||
const { customerOnline, customerSpeedBps, nodeOnlineCustomerIds } = require('../lib/presence');
|
||||
|
||||
// ---------- monthly reset (Node port of panel-go/reset.go) ----------
|
||||
|
||||
test('reset period key clamps to month end and flips at the reset instant', () => {
|
||||
// Day 31 in February fires on Feb 28.
|
||||
assert.equal(trafficResetPeriodKey(new Date(2026, 1, 27, 23, 59), 31), '2026-01');
|
||||
assert.equal(trafficResetPeriodKey(new Date(2026, 1, 28, 0, 0), 31), '2026-02');
|
||||
assert.equal(trafficResetPeriodKey(new Date(2026, 0, 31, 0, 0), 31), '2026-01');
|
||||
assert.equal(trafficResetPeriodKey(new Date(2026, 0, 30, 23, 59), 31), '2025-12');
|
||||
assert.equal(trafficResetPeriodKey(new Date(2026, 4, 15, 0, 0), 15), '2026-05');
|
||||
assert.equal(trafficResetPeriodKey(new Date(2026, 4, 14, 23, 59), 15), '2026-04');
|
||||
});
|
||||
|
||||
test('monthly reset arms on first sight, fires once per period, catches up after downtime', () => {
|
||||
const data = { customers: [{ id: 'c1', trafficResetDay: 15, usedBytes: 500, usedUplinkBytes: 200, usedDownlinkBytes: 300 }] };
|
||||
const customer = data.customers[0];
|
||||
// First sight only arms.
|
||||
let done = applyMonthlyTrafficResets(data, new Date(2026, 4, 20));
|
||||
assert.equal(done.length, 0);
|
||||
assert.equal(customer.usedBytes, 500);
|
||||
assert.equal(customer.trafficResetPeriod, '2026-05');
|
||||
// Same period: nothing.
|
||||
done = applyMonthlyTrafficResets(data, new Date(2026, 5, 14));
|
||||
assert.equal(done.length, 0);
|
||||
// Crossing the instant fires exactly once (even when the panel was down
|
||||
// across two boundaries, only the current period applies).
|
||||
done = applyMonthlyTrafficResets(data, new Date(2026, 6, 20));
|
||||
assert.equal(done.length, 1);
|
||||
assert.equal(customer.usedBytes, 0);
|
||||
assert.equal(customer.trafficResetPeriod, '2026-07');
|
||||
done = applyMonthlyTrafficResets(data, new Date(2026, 6, 21));
|
||||
assert.equal(done.length, 0);
|
||||
});
|
||||
|
||||
test('parseResetDay validates like the Go implementation', () => {
|
||||
assert.equal(parseResetDay(undefined), 0);
|
||||
assert.equal(parseResetDay(null), 0);
|
||||
assert.equal(parseResetDay(0), 0);
|
||||
assert.equal(parseResetDay(31), 31);
|
||||
assert.throws(() => parseResetDay(32), /0–31/);
|
||||
assert.throws(() => parseResetDay(-1), /非负整数/);
|
||||
assert.throws(() => parseResetDay(1.5), /非负整数/);
|
||||
const customer = { trafficResetDay: 10 };
|
||||
armTrafficReset(customer, new Date(2026, 4, 20));
|
||||
assert.equal(customer.trafficResetPeriod, '2026-05');
|
||||
});
|
||||
|
||||
// ---------- external subscription parsing ----------
|
||||
|
||||
test('parseLinkList accepts base64 and plaintext, drops unsupported schemes', () => {
|
||||
const plain = [
|
||||
'vless://11111111-1111-1111-1111-111111111111@a.example.com:443?security=reality#Node%20A',
|
||||
'ss://YWVzLTEyOC1nY206cGFzcw@b.example.com:8388#NodeB',
|
||||
'vmess://eyJ2IjoiMiJ9',
|
||||
'trojan://pw@c.example.com:443#T',
|
||||
'not a link',
|
||||
'hysteria2://pw@d.example.com:443?sni=d.example.com#NodeD',
|
||||
].join('\n');
|
||||
const fromPlain = parseLinkList(plain);
|
||||
assert.equal(fromPlain.items.length, 3);
|
||||
assert.equal(fromPlain.dropped, 3);
|
||||
assert.equal(fromPlain.items[0].name, 'Node A');
|
||||
const fromB64 = parseLinkList(Buffer.from(plain, 'utf8').toString('base64'));
|
||||
assert.deepEqual(fromB64, fromPlain);
|
||||
assert.equal(validateLinkUri('socks5://u:p@h:1080'), true);
|
||||
assert.equal(validateLinkUri('http://example.com/sub'), false);
|
||||
});
|
||||
|
||||
test('fetchExternalSource follows the SSRF guard and parses a local fixture when allowed', async (t) => {
|
||||
const body = Buffer.from('vless://11111111-1111-1111-1111-111111111111@a.example.com:443#A\n', 'utf8').toString('base64');
|
||||
const server = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end(body); });
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => server.close());
|
||||
const url = `http://127.0.0.1:${server.address().port}/sub`;
|
||||
const blocked = await fetchExternalSource({ url }, {});
|
||||
assert.equal(blocked.ok, false);
|
||||
assert.match(blocked.error, /非公网/);
|
||||
const allowed = await fetchExternalSource({ url }, { allowPrivateSubSources: true });
|
||||
assert.equal(allowed.ok, true);
|
||||
assert.equal(allowed.items.length, 1);
|
||||
assert.equal(allowed.items[0].name, 'A');
|
||||
});
|
||||
|
||||
// ---------- subscription aggregation merge ----------
|
||||
|
||||
function aggData() {
|
||||
return {
|
||||
servers: [{ id: 's1', publicAddress: 'a.example.com', tlsDomain: '' }],
|
||||
nodes: [
|
||||
{ id: 'n2', name: '第二', serverId: 's1', protocol: 'socks5-auth', status: 'active', port: 1080, sortIndex: 1, networkMode: 'ipv4' },
|
||||
{ id: 'n1', name: '第一', serverId: 's1', protocol: 'socks5-auth', status: 'active', port: 1081, sortIndex: 0, networkMode: 'ipv4' },
|
||||
],
|
||||
nodeClients: [
|
||||
{ id: 'nc2', nodeId: 'n2', customerId: 'c1', status: 'active', credentials: { clientId: 'u2', relayPassword: 'p2', shortId: 'x' } },
|
||||
{ id: 'nc1', nodeId: 'n1', customerId: 'c1', status: 'active', credentials: { clientId: 'u1', relayPassword: 'p1', shortId: 'x' } },
|
||||
],
|
||||
subSourceCache: {
|
||||
src1: { fetchedAt: 'x', okAt: 'x', failCount: 0, lastError: null, droppedCount: 0, items: [
|
||||
{ uri: 'vless://11111111-1111-1111-1111-111111111111@ext.example.com:443#ExtNode', name: 'ExtNode' },
|
||||
{ uri: 'socks5://ng%3Ac1:p1@a.example.com:1081#dup', name: 'dup' },
|
||||
] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('aggregateEntries orders own by sortIndex, then manual, then external, with dedup', () => {
|
||||
const data = aggData();
|
||||
const customer = {
|
||||
id: 'c1',
|
||||
manualLinks: [{ id: 'lnk1', uri: 'hysteria2://pw@m.example.com:443?sni=m.example.com#Manual', remark: '', enabled: true }],
|
||||
externalSources: [{ id: 'src1', url: 'https://src.example.com/sub', remark: '机场', enabled: true }],
|
||||
};
|
||||
const items = aggregateEntries(data, customer);
|
||||
const names = items.map((item) => decodeURIComponent(item.clientUri.slice(item.clientUri.lastIndexOf('#') + 1)));
|
||||
// The external socks5 entry duplicates own node n1 (same host/port/credentials) and is dropped.
|
||||
assert.deepEqual(names, ['第一', '第二', 'Manual', '[机场] ExtNode']);
|
||||
});
|
||||
|
||||
test('aggregateEntries with no aggregation fields matches own entries only', () => {
|
||||
const data = aggData();
|
||||
const items = aggregateEntries(data, { id: 'c1' });
|
||||
assert.equal(items.length, 2);
|
||||
});
|
||||
|
||||
// ---------- presence ----------
|
||||
|
||||
test('presence window and speed freshness', () => {
|
||||
const now = Date.parse('2026-10-21T00:00:00.000Z');
|
||||
const recent = new Date(now - 60000).toISOString();
|
||||
const stale = new Date(now - 600000).toISOString();
|
||||
assert.equal(customerOnline({ lastOnlineAt: recent }, now), true);
|
||||
assert.equal(customerOnline({ lastOnlineAt: stale }, now), false);
|
||||
assert.equal(customerOnline({}, now), false);
|
||||
const customer = { serverUsage: { 'srv:s1': { speedUpBps: 100, speedDownBps: 200, speedAt: recent, lastActiveAt: recent } } };
|
||||
assert.deepEqual(customerSpeedBps(customer, now), { up: 100, down: 200 });
|
||||
assert.deepEqual(customerSpeedBps({ serverUsage: { 'srv:s1': { speedUpBps: 100, speedDownBps: 200, speedAt: stale } } }, now), { up: 0, down: 0 });
|
||||
const data = {
|
||||
customers: [{ id: 'c1', lastOnlineAt: stale, serverUsage: { 'srv:s1': { lastActiveAt: recent } } }],
|
||||
nodeClients: [{ nodeId: 'n1', customerId: 'c1', status: 'active' }],
|
||||
observations: [],
|
||||
};
|
||||
assert.deepEqual(nodeOnlineCustomerIds(data, { id: 'n1', serverId: 's1' }, now), ['c1']);
|
||||
assert.deepEqual(nodeOnlineCustomerIds(data, { id: 'n1', serverId: 's2' }, now), []);
|
||||
});
|
||||
|
||||
// ---------- API integration: sort / reorder / credentials / alignment ----------
|
||||
|
||||
async function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); });
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(url) {
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
try { const response = await fetch(url); if (response.ok) return; } catch { /* retry */ }
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error('test server did not start');
|
||||
}
|
||||
|
||||
test('api: node sortIndex, reorder, credentials, customer node alignment, presence', async (t) => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nexusgate-ui-'));
|
||||
const port = await freePort();
|
||||
const child = spawn(process.execPath, ['server.js'], {
|
||||
cwd: path.resolve(__dirname, '..'),
|
||||
env: { ...process.env, NG_HOST: '127.0.0.1', NG_PORT: String(port), NG_DATA_FILE: path.join(dir, 'data.json'), NG_ADMIN_PASSWORD: 'test-password', NG_COOKIE_SECURE: 'false' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
t.after(async () => { child.kill('SIGTERM'); await fs.promises.rm(dir, { recursive: true, force: true }); });
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
await waitFor(`${base}/healthz`);
|
||||
const login = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'test-password' }) });
|
||||
const session = await login.json();
|
||||
const cookie = login.headers.get('set-cookie').split(';')[0];
|
||||
const request = async (pathname, method = 'GET', body) => {
|
||||
const response = await fetch(`${base}${pathname}`, { method, headers: { cookie, 'x-csrf-token': session.csrf, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
|
||||
const json = await response.json();
|
||||
assert.ok(response.ok, `${method} ${pathname}: ${JSON.stringify(json)}`);
|
||||
return json;
|
||||
};
|
||||
|
||||
const server = (await request('/api/servers', 'POST', { name: 'S1', role: 'hybrid', publicAddress: 's1.example.com' })).server;
|
||||
assert.equal(server.enabled, true);
|
||||
const n1 = (await request('/api/nodes', 'POST', { name: 'N1', serverId: server.id, topology: 'direct', protocol: 'socks5-auth' })).node;
|
||||
const n2 = (await request('/api/nodes', 'POST', { name: 'N2', serverId: server.id, topology: 'direct', protocol: 'socks5-auth' })).node;
|
||||
assert.equal(n1.sortIndex, 0);
|
||||
assert.equal(n2.sortIndex, 1);
|
||||
|
||||
// Reorder flips list order.
|
||||
await request('/api/nodes/reorder', 'POST', { ids: [n2.id, n1.id] });
|
||||
let listed = (await request('/api/nodes')).nodes;
|
||||
assert.deepEqual(listed.map((node) => node.name), ['N2', 'N1']);
|
||||
assert.deepEqual(listed.map((node) => node.sortIndex), [0, 1]);
|
||||
|
||||
// Create with nodeIds alignment + monthly reset + aggregation fields.
|
||||
const customer = (await request('/api/customers', 'POST', {
|
||||
name: '对齐客户', trafficResetDay: 15, nodeIds: [n1.id],
|
||||
manualLinks: [{ uri: 'vless://11111111-1111-1111-1111-111111111111@x.example.com:443#M', remark: '手动一' }],
|
||||
})).customer;
|
||||
assert.equal(customer.trafficResetDay, 15);
|
||||
assert.equal(customer.trafficResetPeriod, trafficResetPeriodKey(new Date(), 15));
|
||||
assert.equal(customer.manualLinks.length, 1);
|
||||
let nodesView = await request('/api/nodes');
|
||||
assert.equal(nodesView.nodeClients.filter((nc) => nc.customerId === customer.id).length, 1);
|
||||
|
||||
// PATCH alignment swaps the binding set.
|
||||
await request(`/api/customers/${customer.id}`, 'PATCH', { nodeIds: [n2.id] });
|
||||
nodesView = await request('/api/nodes');
|
||||
const bindings = nodesView.nodeClients.filter((nc) => nc.customerId === customer.id);
|
||||
assert.equal(bindings.length, 1);
|
||||
assert.equal(bindings[0].nodeId, n2.id);
|
||||
|
||||
// Credentials view + regeneration rotates the binding credential.
|
||||
const before = await request(`/api/customers/${customer.id}/credentials`);
|
||||
assert.equal(before.items.length, 1);
|
||||
assert.ok(before.items[0].clientId);
|
||||
assert.equal(before.subscriptionToken, customer.subscriptionToken);
|
||||
const regenerated = (await request(`/api/customers/${customer.id}/credentials/${before.items[0].nodeClientId}/regenerate`, 'POST', {})).item;
|
||||
assert.notEqual(regenerated.clientId, before.items[0].clientId);
|
||||
assert.notEqual(regenerated.password, before.items[0].password);
|
||||
|
||||
// Presence: enroll an agent and report usage twice; the customer turns
|
||||
// online after the first positive delta.
|
||||
const ticket = await request(`/api/servers/${server.id}/enrollment-token`, 'POST', {});
|
||||
const enrolled = await fetch(`${base}/api/agent/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: ticket.token, hostname: 'h' }) });
|
||||
const agentKey = (await enrolled.json()).agentKey;
|
||||
const usage = (samples) => fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ samples, epoch: 'e1' }) });
|
||||
await usage([{ kind: 'user', id: customer.id, uplink: 1000, downlink: 2000 }]);
|
||||
const customers = (await request('/api/customers')).customers;
|
||||
const view = customers.find((item) => item.id === customer.id);
|
||||
assert.equal(view.online, true);
|
||||
assert.ok(view.lastActiveAt);
|
||||
|
||||
// System status endpoint shape.
|
||||
const system = await request('/api/system');
|
||||
assert.ok('host' in system && 'network' in system && 'history' in system && 'ips' in system);
|
||||
assert.equal(system.network.customersTotal, 1);
|
||||
});
|
||||
Reference in New Issue
Block a user