10 Commits
Author SHA1 Message Date
aaaamoooo 5733928198 docs: report addendum for items 10-13
CI / anytls-smoke (push) Canceled after 0s
CI / test (push) Canceled after 0s
2026-10-21 11:18:00 +08:00
aaaamoooo 18bbd388ea feat(web): topbar/nav cleanup batch (items 10-13)
Item 10: remove the disabled decorative topbar search input (each page
has its own working search); CSS rule and media-query reference removed.
Item 11: menu/title renames — 服务器 -> 节点管理, 客户与订阅 -> 用户订阅,
转发与节点 -> 入站&出站, 设置与运维 -> 设置 (nav, TITLES/breadcrumb and
cross-references synced); the chains page add button is now 添加入站 and
its count reads N 个入站; the 安装与命令 page is merged into 设置
(commandsHTML block between the settings grid and the jobs table) and
the page itself is removed from nav, PAGES, TITLES, load() and the view
map — an old #commands hash now lands on the SPA's standard overview
fallback.
Item 12: remove the system page's 最近活动 panel (rendering and its
audit label map plus orphaned CSS); backend audit records untouched.
Item 13: the settings jobs table gets the w-jobs treatment — ID, status
and time cells never wrap, compact row padding, and the error column is
capped at 22% width so an empty error column no longer balloons.

Also fixes a class collision from the item-8 batch: the exit-mode
segmented control is renamed .seg -> .segctl so it no longer draws a
border box around the system page info-bar segments.
Golden re-captured: the only diff is the served index HTML inside
errors.badSubscription, audited character by character as exactly these
renames and removals.
2026-10-21 11:18:00 +08:00
aaaamoooo 25cb58245c docs: round-2 (v2) nine-item change report 2026-10-21 11:18:00 +08:00
aaaamoooo 0037116edc feat: manual landing endpoints for forward nodes (item 8)
A forward node's exit target is now a choice: a managed panel server
(existing behavior) or a manual landing endpoint — address + port +
protocol (VLESS / SS2022 / SOCKS5) + operator-supplied credentials,
including third-party nodes the panel does not run. The panel only
builds the entry-side sing-box outbound and route rule; there is no
exit resource, no exit job, and the node is exit-ready by definition.

- model: Node.ExitMode/ExitAddress; manual exits force fixed port mode
  and skip the exit-server port table entirely
- validation: address/port required; per-protocol credential presence;
  SS2022 passwords must be base64 key material of the method's key
  length (one PSK segment or serverKey:userKey) — the exact form
  sing-box accepts, rejected up front instead of failing at deploy
- orchestration: resource building, deploy validation and rebuilds all
  branch on manualExit; switching managed->manual queues removal of the
  old exit resource, manual->managed mints a fresh exit resource id
  (deploy validation previously dereferenced the nil exit server —
  regression test locks the fix)
- UI: exit target segmented control, link paste box that parses
  vless:// / ss:// / socks5:// links into the form, per-protocol
  credential fields (blank keeps the stored value on edit)
- golden: new manualCreate/manualAssign/manualDeploy/manualJobs/
  manualNodes sections — exactly one entry-side job whose outbound
  targets the manual address; all prior sections unchanged
- the generated combined sing-box config passes the real sing-box
  1.13.21 check
2026-10-21 11:18:00 +08:00
aaaamoooo d316d0f8bb feat: customer-level credentials shared across all nodes (item 2)
One credential set per customer (UUID + 32-byte root relay key + shortId)
lives on the customer record; node bindings no longer store credentials.
SS2022 user keys derive deterministically from the root key by method
length (16/24/32-byte prefix), so a single password serves every protocol,
matching 3x-ui's per-client credential model. Reality public keys stay a
node property and are shown per binding in the credentials pane.

- store: load-time convergence — first binding's set wins; a converged
  password that is not a 32-byte root key is regenerated (uuid/shortId
  preserved); bindings are stripped; idempotent, covered by tests
- deploy: the orchestrator stamps the customer's set onto a build copy;
  subscriptions resolve the same set; the derivation applies in both the
  inbound builder and the URI builder (verified live: Alice's N1 link is
  byte-identical to the pre-change seed expectation, her N2 user key is
  exactly the root key's 16-byte prefix)
- API: GET /api/customers/:id/credentials returns {credentials, bindings};
  regenerate is now POST /api/customers/:id/credentials/regenerate and
  rotates the whole set, rebuilding every deployed node of the customer
- UI: credentials pane shows the single shared set plus per-binding
  node/protocol/Reality-public-key rows
- golden subscriptions fixture regenerated under one shared set (RF and
  SS entries byte-identical to before; five entries audited by hand)
- golden baseline re-captured for the new shapes; difftest normalizer now
  treats a base64 root key's prefix-truncation encodings and the Surge
  '='-mangled variants as the same secret
2026-10-21 11:18:00 +08:00
aaaamoooo b49c134513 feat(agent+panel): sing-box core version probe and display (round-2 #4)
agent-go: engineReport gains singBoxVersion. The probe runs
`sing-box version` once, parses the version token from its first line
('unknown' from unstamped builds reports empty), and caches it keyed by
the binary's mtime+size — re-probed only when an engine install/update
replaces the file. Steady-state heartbeats pay one stat, no subprocess.
Agent version stays 0.6.8.

panel-go: EngineState.singBoxVersion persisted from heartbeats (old
agents without the field keep showing '-'), servers table core-version
column now renders it. Golden gains heartbeatWithEngine +
serversAfterEngineHeartbeat sections locking the full chain, plus a
listen-address (<hostip>) normalization for the sandbox bind address.
singboxversion_test.go covers parsing, cache hits, and re-probe on
binary replacement.
2026-10-21 11:18:00 +08:00
aaaamoooo 35c9bd2cc0 feat(panel): customers stats strip with 3x-ui depletion semantics (round-2 #1)
GET /api/customers gains a summary object (total/online/depleted/
depletingSoon/disabled/enabled) computed server-side; the customers page
renders it as a six-cell stats strip above the toolbar.

Predicates aligned verbatim with 3x-ui client_paging.go:
- depleted = (limit>0 && used>=limit) || (expiry set && expiry<=now),
  counted regardless of status;
- depletingSoon = active && !depleted && (expiry within expireDiff days
  || remaining traffic < trafficDiff GB), thresholds from new settings
  expireDiff/trafficDiff with 3x-ui's factory default 0 (nobody counts
  until configured);
- enabled = status active; disabled = manual suspensions only.
Online reuses the existing 3-minute presence projection.

Golden diff confined to the additive summary keys; summary_test.go
covers every bucket with thresholds off (soon=0) and on (soon=2).
2026-10-21 11:18:00 +08:00
aaaamoooo 1b32b57377 feat(web): UI batch — content width, latency removal, icon tooltips, avatar removal (round-2 #3/#5/#6/#9)
#3 .content: drop max-width:1460px centering, padding 24px full width
   (3x-ui content-area parity), 8px under the 760px breakpoint.
#5 servers page: remove latency column + average-latency stat cell
   (stats grid 4->3, table 15->14 cols incl. detail colspan/empty row).
#6 icon buttons: native title replaced by data-tip with a 3x-ui style
   bubble (black/white, rounded, arrow, body-level fixed layer so table
   scroll containers cannot clip it); customer QR tip renamed to 二维码;
   covers customers/servers/nodes rows, credentials pane, links pane,
   address eye toggle, drag grip, filter chip clear, logout, modal close.
#9 remove decorative topbar avatar span#avatar, its showApp assignment
   and the dead .avatar CSS rule. Refresh button untouched.

Golden re-captured: sole diff was errors.badSubscription body (the SPA
index.html served there) reflecting the avatar/data-tip changes.
2026-10-21 11:18:00 +08:00
aaaamoooo 3de4c4945f fix(panel-go): random ports roll once — sticky across edits (round-2 #7)
NormalizeNode re-selected ports whenever the request merely CONTAINED
port/serverId keys; the browser form always submits them (port=null in
random mode), so every save re-rolled both entry and exit ports. Now an
assigned port only moves on an explicit addressing change: mode switch to
random, a different fixed port value, or a server move where the port is
taken (fixed mode surfaces the 409). random->fixed pins the current port.

Regression: portlock_test.go (UI-shape payload saved twice, rename-only,
mode switches, fixed change/conflict, server move free/taken).
Golden re-captured: diff confined to nodePatchUiStyle/nodePatchJobs/
nodesAfterPatch/backup port leaves returning to the seed ports.
2026-10-21 11:17:59 +08:00
aaaamoooo 85763ebdb7 test(panel-go): freeze Go golden behavior baseline (Node retired, difftest superseded)
Single-source Go from this round on: golden.mjs replays a fixed API script
against a Go panel seeded from the difftest seed DB and diffs every response
(learning-normalized) against golden.json. 25 sections: lists, credentials,
all subscription formats, deploy job payloads, usage/presence, UI-style node
PATCH, /api/system shape, backup, error cases.
2026-10-21 11:17:59 +08:00
26 changed files with 6100 additions and 281 deletions
+72
View File
@@ -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,不受影响)。
+92
View File
@@ -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/状态/时间等短内容不换行、单行呈现;空白列不占大宽度;整表紧凑。合并进设置页后保持此排版。
+77
View File
@@ -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 未改动、哈希不变。
+52
View File
@@ -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"
+81
View File
@@ -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)
}
}
+124
View File
@@ -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")
}
}
+23 -4
View File
@@ -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);
+33
View File
@@ -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
+278
View File
@@ -0,0 +1,278 @@
// NexusGate Go panel golden-behavior harness.
//
// Freezes the CURRENT Go panel behavior as a regression baseline now that the
// Node implementation is retired (2026-10-21 decision): a single Go panel is
// started on a copy of the difftest seed DB, driven through a fixed script of
// API calls, and every response is passed through the difftest learning
// Normalizer (volatile ids / secrets / ports / timestamps become placeholders)
// before being stored in golden.json. `verify` replays the same script and
// diffs against the frozen file; any diff must be either an intended change
// (re-capture and note it in the commit) or a regression.
//
// Usage:
// node golden.mjs capture [--bin /path/to/nexusgate-panel]
// node golden.mjs verify [--bin /path/to/nexusgate-panel] [--golden golden.json]
//
// The binary defaults to ../nexusgate-panel-linux-amd64 (same resolution as
// the difftest runner). The repo data/ directory is never touched.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
startPanel, waitHealth, stopPanel, makeTempDir, freePort, PanelClient,
Normalizer, firstDiff, fetchAllSubscriptions, drainAgentJobs, pollOnce,
completeJob, DEFAULT_GO_BIN, DIFFTEST_DIR,
} from '../difftest/lib.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SEED = path.join(DIFFTEST_DIR, 'seed.json');
const SEED_LOG = path.join(DIFFTEST_DIR, 'seed-log.json');
function parseArgs(argv) {
const args = { mode: argv[2], bin: process.env.GO_PANEL_BIN || DEFAULT_GO_BIN, golden: path.join(HERE, 'golden.json') };
for (let i = 3; i < argv.length; i += 1) {
if (argv[i] === '--bin') args.bin = argv[++i];
else if (argv[i] === '--golden') args.golden = argv[++i];
else throw new Error(`unknown argument: ${argv[i]}`);
}
if (!['capture', 'verify'].includes(args.mode)) throw new Error('mode must be capture or verify');
return args;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// View of an admin/agent HTTP response, mirroring difftest's view().
function view(res) {
return { status: res.status, body: res.json !== null && res.json !== undefined ? res.json : res.text };
}
// Keys whose numeric values are wall-clock derived and therefore not part of
// the behavior contract; squashed wherever they appear.
const VOLATILE_NUM_KEYS = /uptime|serverTimeMs|nowMs/i;
function squashVolatile(value, keyName = null) {
// A resource's inbound `listen` is the panel host's own interface address
// (bind resolution) — environment-specific, not behavior.
if (keyName === 'listen' && typeof value === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(value)) return '<hostip>';
if (Array.isArray(value)) return value.map((v) => squashVolatile(v, keyName));
if (value && typeof value === 'object') {
const out = {};
for (const [k, v] of Object.entries(value)) {
out[k] = typeof v === 'number' && VOLATILE_NUM_KEYS.test(k) ? '<num>' : squashVolatile(v, k);
}
return out;
}
return value;
}
// /api/system: almost every number is a live sample. Only structural counts
// that follow deterministically from the script are kept verbatim.
const SYSTEM_KEEP_NUM = new Set(['nodesTotal', 'customersTotal', 'onlineNodes', 'onlineCustomers', 'connections']);
function squashSystem(value, keyName = null) {
if (typeof value === 'number') return keyName && SYSTEM_KEEP_NUM.has(keyName) ? value : '<num>';
if (typeof value === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(value)) return '<ip>';
if (Array.isArray(value)) return value.map((v) => squashSystem(v, keyName));
if (value && typeof value === 'object') {
const out = {};
for (const k of Object.keys(value).sort()) out[k] = squashSystem(value[k], k);
return out;
}
return value;
}
async function runScript(bin) {
const seedLog = JSON.parse(fs.readFileSync(SEED_LOG, 'utf8'));
const seedIds = seedLog.meta.ids;
const tmp = makeTempDir('nexusgate-golden-');
const dataFile = path.join(tmp, 'nexusgate.json');
fs.copyFileSync(SEED, dataFile);
const port = await freePort();
const proc = startPanel({ kind: 'go', bin, dataFile, port });
const N = new Normalizer();
const sections = {};
const cap = (name, value) => { sections[name] = N.process(squashVolatile(structuredClone(value))); };
try {
await waitHealth(proc);
const client = new PanelClient(proc.base);
const ids = { entryServer: seedIds.entryServer, exitServer: seedIds.exitServer, n1: seedIds.n1, n2: seedIds.n2, alice: seedIds.alice, bob: seedIds.bob };
const agentKeys = { entry: seedLog.meta.agentKeys.entry, exit: seedLog.meta.agentKeys.exit };
const subTokens = {};
cap('login', view(await client.login()));
cap('session', view(await client.admin('GET', '/api/session')));
// Old-shape heartbeats (no engine object at all): the panel must keep
// accepting them and simply have no engine state to show.
cap('heartbeatSeedAgents', {
entry: view(await client.agentPost('/api/agent/heartbeat', agentKeys.entry, { version: '0.6.8', system: {} })),
exit: view(await client.agentPost('/api/agent/heartbeat', agentKeys.exit, { version: '0.6.8', system: {} })),
});
const customersRes = await client.admin('GET', '/api/customers');
for (const c of customersRes.json.customers) subTokens[c.name.toLowerCase()] = c.subscriptionToken;
const nodesRes = await client.admin('GET', '/api/nodes');
cap('lists', {
overview: view(await client.admin('GET', '/api/overview')),
servers: view(await client.admin('GET', '/api/servers')),
customers: view(customersRes),
nodes: view(nodesRes),
jobs: view(await client.admin('GET', '/api/jobs')),
});
// New-shape heartbeat carrying the core version (round-2 item 4): the
// panel must persist it on the server's engine state and expose it in
// the servers list. The seed-shape heartbeats above (no engine object)
// must keep working and leave engine state absent.
cap('heartbeatWithEngine', view(await client.agentPost('/api/agent/heartbeat', agentKeys.entry, {
version: '0.6.8', system: {},
engine: { status: 'ready', detail: '', singBoxInstalled: true, certificates: [], singBoxStatus: 'ready', singBoxVersion: '1.13.21' },
})));
cap('serversAfterEngineHeartbeat', view(await client.admin('GET', '/api/servers')));
cap('credentials', {
alice: view(await client.admin('GET', `/api/customers/${ids.alice}/credentials`)),
bob: view(await client.admin('GET', `/api/customers/${ids.bob}/credentials`)),
});
cap('subscriptionsSeed', {
alice: await fetchAllSubscriptions(client, subTokens.alice),
bob: await fetchAllSubscriptions(client, subTokens.bob),
});
// New server + agent enrolment.
const s3res = await client.admin('POST', '/api/servers', {
name: 'S3', role: 'relay', region: '', publicAddress: 's3.example.com',
publicAddressV6: '', tlsDomain: '', portRangeStart: 20000, portRangeEnd: 50000,
});
ids.s3 = s3res.json.server.id;
cap('createServerS3', view(s3res));
const ticket = await client.admin('POST', `/api/servers/${ids.s3}/enrollment-token`, {});
const enrolled = await client.request('POST', '/api/agent/enroll', {
body: { token: ticket.json.token, hostname: 'golden-agent-s3', version: '0.6.8', system: {} },
cookie: null,
});
agentKeys.s3 = enrolled.json.agentKey;
cap('enrollS3', { ticket: view(ticket), enroll: view(enrolled) });
const carolRes = await client.admin('POST', '/api/customers', { name: 'Carol' });
ids.carol = carolRes.json.customer.id;
subTokens.carol = carolRes.json.customer.subscriptionToken;
cap('createCarol', view(carolRes));
const n3res = await client.admin('POST', '/api/nodes', {
name: 'N3', serverId: ids.s3, topology: 'direct',
protocol: 'vless-reality', realityServerName: 'www.amd.com',
});
ids.n3 = n3res.json.node.id;
cap('createNodeN3', view(n3res));
cap('assignN3', view(await client.admin('POST', `/api/nodes/${ids.n3}/assign`, { customerId: ids.carol })));
cap('deployN3', view(await client.admin('POST', `/api/nodes/${ids.n3}/deploy`, {})));
const drainedS3 = await drainAgentJobs(client, agentKeys.s3);
cap('deployN3Jobs', drainedS3.map(({ job, complete }) => ({ job, complete })));
cap('nodesAfterDeploy', view(await client.admin('GET', '/api/nodes')));
cap('carolSubscriptions', await fetchAllSubscriptions(client, subTokens.carol));
// Usage + presence/speed projection (two cumulative reports 6s apart).
const usageBody = (u1, d1, u2, d2) => ({
epoch: 'golden-epoch-1',
samples: [
{ kind: 'user', id: ids.alice, uplink: u1, downlink: d1 },
{ kind: 'inbound', id: ids.n1, uplink: u2, downlink: d2 },
],
});
cap('usageReport1', view(await client.agentPost('/api/agent/usage', agentKeys.entry, usageBody(1500, 2100, 5000, 6000))));
await sleep(6000);
cap('usageReport2', view(await client.agentPost('/api/agent/usage', agentKeys.entry, usageBody(4500, 6300, 8000, 9000))));
const customers2 = await client.admin('GET', '/api/customers');
const nodes2 = await client.admin('GET', '/api/nodes');
const nameOf = (id) => { const c = customers2.json.customers.find((x) => x.id === id); return c ? c.name : '?'; };
sections.presenceProjection = {
customers: customers2.json.customers.map((c) => ({
name: c.name, online: c.online,
speedPositive: (c.speedUpBps || 0) > 0 && (c.speedDownBps || 0) > 0,
hasLastActiveAt: Boolean(c.lastActiveAt),
})),
nodes: nodes2.json.nodes.map((n) => ({
name: n.name, clientCount: n.clientCount, onlineCount: n.onlineCount,
onlineNames: (n.onlineCustomerIds || []).map(nameOf),
speedPositive: (n.speedUpBps || 0) > 0,
})),
};
cap('customersAfterUsage', view(customers2));
// UI-style full-payload PATCH of the seed forward node N1 (name change
// only). The browser form always submits portMode + port(null in random
// mode) + serverId; this is the exact traffic shape behind the port
// re-roll bug, frozen here so the fix shows up as a deliberate diff.
const n1 = nodes2.json.nodes.find((n) => n.id === ids.n1);
const uiPatch = {
name: 'N1-renamed', serverId: n1.serverId, protocol: n1.protocol,
portMode: n1.portMode, port: n1.portMode === 'fixed' ? n1.port : null,
networkMode: n1.networkMode, topology: n1.topology,
exitServerId: n1.exitServerId, exitProtocol: n1.exitProtocol,
exitPortMode: n1.exitPortMode, exitPort: n1.exitPortMode === 'fixed' ? n1.exitPort : null,
realityServerName: n1.realityServerName || '', realityDestPort: n1.realityDestPort || null,
};
cap('nodePatchUiStyle', view(await client.admin('PATCH', `/api/nodes/${ids.n1}`, uiPatch)));
const drainedEntry = await drainAgentJobs(client, agentKeys.entry);
const drainedExit = await drainAgentJobs(client, agentKeys.exit);
cap('nodePatchJobs', {
entry: drainedEntry.map(({ job, complete }) => ({ job, complete })),
exit: drainedExit.map(({ job, complete }) => ({ job, complete })),
});
cap('nodesAfterPatch', view(await client.admin('GET', '/api/nodes')));
const systemRes = await client.admin('GET', '/api/system');
sections.systemShape = { status: systemRes.status, body: squashSystem(N.process(structuredClone(systemRes.json))) };
cap('backup', view(await client.admin('GET', '/api/backup')));
const anon = new PanelClient(proc.base);
cap('errors', {
unauthenticated: view(await anon.request('GET', '/api/customers')),
loginFailure: view(await anon.request('POST', '/api/auth/login', { body: { username: 'admin', password: 'wrong-password' }, cookie: null })),
missingNode: view(await client.admin('GET', '/api/nodes/nod_00000000-0000-0000-0000-000000000000')),
badSubscription: await (async () => {
const r = await client.getSubscription('nosuchtoken000000', 'raw', 'NexusGate-Golden/1.0');
return { status: r.status, body: r.body };
})(),
});
// Round-2 item 8: manual landing endpoint. Creation is exit-ready with
// no exit server; deploying queues only the entry-side job, whose
// outbound targets the manual address with the operator's credentials.
const manualCreate = await client.admin('POST', '/api/nodes', {
name: 'N4-Manual', serverId: ids.entryServer, protocol: 'vless-reality-vision', topology: 'forward',
exitMode: 'manual', exitAddress: '203.0.113.9', exitProtocol: 'shadowsocks-2022-aes128',
exitPort: 23464, exitPortMode: 'fixed', exitCredentials: { password: 'FRYXGBkaGxwdHh8gISIjJA==:FRYXGBkaGxwdHh8gISIjJA==' },
portMode: 'fixed', port: 23466, networkMode: 'ipv4', realityServerName: 'www.amd.com', realityDestPort: 443,
});
cap('manualCreate', view(manualCreate));
const manualNodeId = manualCreate.json.node.id;
cap('manualAssign', view(await client.admin('POST', `/api/nodes/${manualNodeId}/assign`, { customerId: ids.carol })));
cap('manualDeploy', view(await client.admin('POST', `/api/nodes/${manualNodeId}/deploy`, {})));
const drainedManual = await drainAgentJobs(client, agentKeys.entry);
cap('manualJobs', drainedManual.map(({ job, complete }) => ({ job, complete })));
cap('manualNodes', view(await client.admin('GET', '/api/nodes')));
return sections;
} finally {
await stopPanel(proc);
fs.rmSync(tmp, { recursive: true, force: true });
}
}
const args = parseArgs(process.argv);
if (!fs.existsSync(args.bin)) { console.error(`Go panel binary not found: ${args.bin}`); process.exit(2); }
const sections = await runScript(args.bin);
if (args.mode === 'capture') {
const payload = { meta: { capturedAt: new Date().toISOString(), note: 'Go panel golden baseline; see README.md in this directory.' }, sections };
fs.writeFileSync(args.golden, `${JSON.stringify(payload, null, 2)}\n`);
console.log(`CAPTURED ${Object.keys(sections).length} sections -> ${args.golden}`);
} else {
const golden = JSON.parse(fs.readFileSync(args.golden, 'utf8'));
const diff = firstDiff(golden.sections, sections);
if (!diff) { console.log('GOLDEN PASS — behavior identical to frozen baseline'); process.exit(0); }
console.log(`GOLDEN DIFF at ${diff.at}\n golden: ${diff.a}\n actual: ${diff.b}`);
process.exit(1);
}
+332 -73
View File
@@ -7,6 +7,7 @@ package main
import (
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"math"
@@ -208,6 +209,17 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
topology = "forward"
}
direct := topology == "direct"
// Exit target selection (round-2 item 8): "manual" addresses an
// unmanaged landing endpoint; anything else keeps the managed server.
exitMode := next.ExitMode
if _, ok := body["exitMode"]; ok {
if s, _ := body["exitMode"].(string); s == "manual" {
exitMode = "manual"
} else {
exitMode = "managed"
}
}
manual := !direct && exitMode == "manual"
protocol := next.Protocol
if _, ok := body["protocol"]; ok {
@@ -232,7 +244,7 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
return nil, httpErr(404, "服务器不存在")
}
var exitServerID *string
if direct {
if direct || manual {
exitServerID = nil
} else if _, ok := body["exitServerId"]; ok {
id, err := RequiredText(body["exitServerId"], "出口服务器 ID", 120)
@@ -243,7 +255,7 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
} else {
exitServerID = next.ExitServerID
}
if !direct {
if !direct && !manual {
found := false
for _, item := range data.Servers {
if item.ID == strVal(exitServerID) {
@@ -266,6 +278,26 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
next.Protocol = protocol
next.Topology = topology
next.ExitServerID = exitServerID
next.ExitMode = ""
next.ExitAddress = ""
if manual {
next.ExitMode = "manual"
address := ""
if current != nil {
address = current.ExitAddress
}
if _, ok := body["exitAddress"]; ok {
s, err := RequiredText(body["exitAddress"], "落地端点地址", 255)
if err != nil {
return nil, err
}
address = s
}
if address == "" || strings.ContainsAny(address, " \t\r\n/") || strings.Contains(address, "://") {
return nil, httpErr(400, "手动落地端点地址无效")
}
next.ExitAddress = address
}
if direct {
next.ExitProtocol = nil
} else if _, ok := body["exitProtocol"]; ok {
@@ -320,8 +352,21 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
_, portModeInBody := body["portMode"]
_, portInBody := body["port"]
portModeChanged := portModeInBody && portMode != currentPortMode
_, serverIDInBody := body["serverId"]
if portModeChanged || portInBody || serverIDInBody || currentPort == 0 {
// An already-assigned port is sticky. The browser form always submits
// portMode/port/serverId keys (port is null in random mode), so key
// presence alone must never trigger a re-selection — a random port is
// rolled exactly once, at creation. On edit the port only moves for an
// explicit addressing change: switching the mode to random, supplying a
// different fixed port, or moving to a server where the port is taken.
portValueChanged := false
if portInBody && !jsFalsy(body["port"]) {
if f, valid := anyToJSNumber(body["port"]); valid && int(f) != currentPort {
portValueChanged = true
}
}
serverChanged := current != nil && serverID != current.ServerID
switch {
case current == nil || currentPort == 0:
var requested any
if portInBody {
requested = body["port"]
@@ -333,8 +378,52 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
return nil, err
}
next.Port = port
case portModeChanged && portMode == "random":
// Explicit switch into random mode: roll a fresh port.
port, err := SelectPort(data, server, portMode, nil, currentID)
if err != nil {
return nil, err
}
next.Port = port
case portValueChanged && portMode == "fixed":
port, err := SelectPort(data, server, portMode, body["port"], currentID)
if err != nil {
return nil, err
}
next.Port = port
case serverChanged && NodePortInUse(data, serverID, currentPort, currentID):
// The port is taken on the target server: fixed mode surfaces the
// conflict, random mode rolls a replacement. (When it is free the
// switch falls through and the port is kept verbatim.)
var requested any
if portMode == "fixed" {
requested = json.Number(fmt.Sprint(currentPort))
}
port, err := SelectPort(data, server, portMode, requested, currentID)
if err != nil {
return nil, err
}
next.Port = port
}
if !direct {
if manual {
// A manual endpoint's port belongs to the remote service: it is
// always explicit (never rolled, never checked against a panel
// server's port table).
fixed := "fixed"
next.ExitPortMode = &fixed
port := 0
if v, ok := body["exitPort"]; ok && !jsFalsy(v) {
if f, valid := anyToJSNumber(v); valid {
port = int(f)
}
} else if current != nil && current.ExitPort != nil {
port = *current.ExitPort
}
if port < 1 || port > 65535 {
return nil, httpErr(400, "手动落地端点端口无效")
}
next.ExitPort = &port
} else if !direct {
exitPortMode := strVal(next.ExitPortMode)
if _, ok := body["exitPortMode"]; ok {
if s, _ := body["exitPortMode"].(string); s == "fixed" {
@@ -364,8 +453,17 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
_, exitPortModeInBody := body["exitPortMode"]
_, exitPortInBody := body["exitPort"]
exitPortModeChanged := exitPortModeInBody && exitPortMode != currentExitPortMode
_, exitServerIDInBody := body["exitServerId"]
if exitPortModeChanged || exitPortInBody || exitServerIDInBody || currentExitPort == nil {
// Same stickiness rule as the entry port: an assigned exit port is
// only re-selected on creation or an explicit addressing change.
exitPortValueChanged := false
if exitPortInBody && !jsFalsy(body["exitPort"]) && currentExitPort != nil {
if f, valid := anyToJSNumber(body["exitPort"]); valid && int(f) != *currentExitPort {
exitPortValueChanged = true
}
}
exitServerChanged := current != nil && strVal(exitServerID) != strVal(current.ExitServerID)
switch {
case current == nil || currentExitPort == nil:
var requested any
if exitPortInBody {
requested = body["exitPort"]
@@ -377,6 +475,28 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
return nil, err
}
next.ExitPort = &port
case exitPortModeChanged && exitPortMode == "random":
port, err := SelectPort(data, exitServer, exitPortMode, nil, currentID)
if err != nil {
return nil, err
}
next.ExitPort = &port
case exitPortValueChanged && exitPortMode == "fixed":
port, err := SelectPort(data, exitServer, exitPortMode, body["exitPort"], currentID)
if err != nil {
return nil, err
}
next.ExitPort = &port
case exitServerChanged && NodePortInUse(data, strVal(exitServerID), *currentExitPort, currentID):
var requested any
if exitPortMode == "fixed" {
requested = json.Number(fmt.Sprint(*currentExitPort))
}
port, err := SelectPort(data, exitServer, exitPortMode, requested, currentID)
if err != nil {
return nil, err
}
next.ExitPort = &port
}
} else {
next.ExitPortMode = nil
@@ -391,6 +511,64 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
return nil, herr
}
}
if manual {
// Manual endpoint credentials describe a third-party service, so
// they come from the operator (form fields or a pasted share link),
// never generated. Fields merge over the stored set so an edit that
// leaves a field blank keeps the previous value.
if rawCreds, ok := body["exitCredentials"].(map[string]any); ok {
creds := ExitCredentials{}
if next.ExitCredentials != nil {
creds = *next.ExitCredentials
}
if v, ok := rawCreds["clientId"]; ok {
creds.ClientID = CleanText(v, 128)
}
if v, ok := rawCreds["password"]; ok {
creds.Password = CleanText(v, 512)
}
if v, ok := rawCreds["socksUser"]; ok {
creds.SocksUser = CleanText(v, 128)
}
next.ExitCredentials = &creds
}
creds := next.ExitCredentials
exitProtocol := strVal(next.ExitProtocol)
switch {
case creds == nil:
return nil, httpErr(400, "手动落地端点缺少凭据")
case exitProtocol == "vless-tcp" && creds.ClientID == "":
return nil, httpErr(400, "手动落地端点缺少 VLESS 用户 ID")
case exitProtocol == "socks5-auth" && (creds.SocksUser == "" || creds.Password == ""):
return nil, httpErr(400, "手动落地端点缺少 SOCKS5 用户名或密码")
default:
if cipher, isSS := ShadowsocksMethods[exitProtocol]; isSS {
if creds.Password == "" {
return nil, httpErr(400, "手动落地端点缺少 SS 密码")
}
// SS2022 keys are base64 key material (one PSK segment, or
// serverKey:userKey); sing-box rejects anything else when
// the config loads, so reject it here with a clear error.
if strings.HasPrefix(cipher.Method, "2022-blake3-") {
segments := strings.Split(creds.Password, ":")
if len(segments) > 2 {
return nil, httpErr(400, "手动落地端点 SS 密码格式无效")
}
for _, segment := range segments {
raw, err := base64.StdEncoding.DecodeString(segment)
if err != nil {
if raw, err = base64.RawStdEncoding.DecodeString(segment); err != nil {
return nil, httpErr(400, "手动落地端点 SS 密码必须是 base64 密钥")
}
}
if len(raw) != cipher.KeyBytes {
return nil, httpErr(400, "手动落地端点 SS 密钥长度与加密方法不符")
}
}
}
}
}
}
if IsRealityProtocol(protocol) && next.RealityServerName == "" {
return nil, httpErr(400, "Reality SNI 不能为空")
}
@@ -481,9 +659,92 @@ func PublicCustomer(item *Customer, data *Data) map[string]any {
return out
}
// credentialItems mirrors credentialItems in server.js.
func credentialItems(d *Data, customer *Customer) []map[string]any {
items := []map[string]any{}
// CustomerSummary computes the customers-page stats strip, mirroring
// 3x-ui's client summary semantics (client_paging.go): total / online /
// depleted / depletingSoon / disabled / enabled. "depleted" is 3x-ui's
// depletedExpr — traffic exhausted (limit > 0 and used >= limit) OR expiry
// reached — counted regardless of status. "depletingSoon" is its
// expiringExpr — status active, not depleted, and either the expiry is
// less than settings.expireDiff days away or the remaining traffic is
// less than settings.trafficDiff GB. Both thresholds default to 0 (the
// 3x-ui factory default), under which nobody counts as depleting soon.
// "disabled" counts manual suspensions only; auto-blocks (expired /
// traffic_limit / ip_limit) surface through depleted instead.
func CustomerSummary(data *Data, nowMs int64) map[string]any {
expireDiffMs := data.Settings.ExpireDiff * 86400000
trafficDiffBytes := data.Settings.TrafficDiff * 1073741824
total, online, depleted, depletingSoon, disabled, enabled := len(data.Customers), 0, 0, 0, 0, 0
for _, c := range data.Customers {
if CustomerOnline(c, nowMs) {
online++
}
if c.Status == "active" {
enabled++
}
if c.Status == "suspended" && strVal(c.SuspendReason) == "manual" {
disabled++
}
var expiryMs int64 = -1
if c.ExpiresAt != nil {
if ms, ok := ParseISOMs(*c.ExpiresAt); ok {
expiryMs = ms
}
}
isDepleted := (c.TrafficLimitBytes > 0 && float64(c.UsedBytes) >= c.TrafficLimitBytes) ||
(expiryMs >= 0 && expiryMs <= nowMs)
if isDepleted {
depleted++
continue
}
if c.Status != "active" {
continue
}
nearExpiry := expiryMs >= 0 && float64(expiryMs-nowMs) < expireDiffMs
nearLimit := c.TrafficLimitBytes > 0 && c.TrafficLimitBytes-float64(c.UsedBytes) < trafficDiffBytes
if nearExpiry || nearLimit {
depletingSoon++
}
}
return map[string]any{
"total": total, "online": online, "depleted": depleted,
"depletingSoon": depletingSoon, "disabled": disabled, "enabled": enabled,
}
}
// realityPublicKeyOf returns the node's panel-hosted Reality public key as
// reported back by the agent ("" when unknown). The keypair is the server's
// identity — it stays per-node even though customer credentials are shared.
func realityPublicKeyOf(node *Node) string {
if node == nil {
return ""
}
if raw, ok := node.Artifacts["realityPublicKey"]; ok && len(raw) > 0 {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
}
return ""
}
// customerCredentialsView is the credentials-tab payload (round-2 item 2):
// the customer's ONE credential set, plus per-binding node facts. The
// Reality public key is shown per node because it belongs to the server,
// not to the customer.
func customerCredentialsView(d *Data, customer *Customer) map[string]any {
creds := map[string]any{"clientId": nil, "password": nil, "shortId": nil}
if customer.Credentials != nil {
if customer.Credentials.ClientID != "" {
creds["clientId"] = customer.Credentials.ClientID
}
if customer.Credentials.RelayPassword != "" {
creds["password"] = customer.Credentials.RelayPassword
}
if customer.Credentials.ShortID != "" {
creds["shortId"] = customer.Credentials.ShortID
}
}
bindings := []map[string]any{}
for _, nc := range d.NodeClients {
if nc.CustomerID != customer.ID {
continue
@@ -496,30 +757,26 @@ func credentialItems(d *Data, customer *Customer) []map[string]any {
if node.Protocol == "socks5-auth" {
username = NodeEmail(customer.ID)
}
creds := nc.Credentials
var clientID, password, shortID any
if creds.ClientID != "" {
clientID = creds.ClientID
var publicKey any
if key := realityPublicKeyOf(node); key != "" {
publicKey = key
}
if creds.RelayPassword != "" {
password = creds.RelayPassword
}
if creds.ShortID != "" {
shortID = creds.ShortID
}
items = append(items, map[string]any{
"nodeClientId": nc.ID,
"nodeId": node.ID,
"nodeName": node.Name,
"protocol": node.Protocol,
"status": nc.Status,
"clientId": clientID,
"password": password,
"shortId": shortID,
"username": username,
bindings = append(bindings, map[string]any{
"nodeClientId": nc.ID,
"nodeId": node.ID,
"nodeName": node.Name,
"protocol": node.Protocol,
"status": nc.Status,
"username": username,
"realityPublicKey": publicKey,
})
}
return items
return map[string]any{
"customerId": customer.ID,
"subscriptionToken": customer.SubscriptionToken,
"credentials": creds,
"bindings": bindings,
}
}
// CustomerBlockReason is customerBlockReason: "expired" / "traffic_limit" /
@@ -904,6 +1161,7 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
SingBoxInstalled: installed,
Certificates: certs,
SingBoxStatus: singBoxStatus,
SingBoxVersion: CleanText(eng["singBoxVersion"], 32),
At: now,
}
}
@@ -1681,7 +1939,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
out["speedDownBps"] = down
customers = append(customers, out)
}
SendJSON(w, 200, map[string]any{"customers": customers}, nil)
SendJSON(w, 200, map[string]any{"customers": customers, "summary": CustomerSummary(data, nowMs)}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/access", pathname); ok && r.Method == http.MethodGet {
@@ -1803,6 +2061,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
Notes: CleanText(body["notes"], 1000),
CreatedAt: NowISO(),
UpdatedAt: NowISO(),
Credentials: func() *Credentials { c := NewCustomerCredentials(); return &c }(),
}
// Arm the monthly-reset schedule: it starts with the next
// reset instant, it never fires retroactively at creation.
@@ -2008,54 +2267,44 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if customer == nil {
return true, httpErr(404, "客户不存在")
}
SendJSON(w, 200, map[string]any{
"customerId": customer.ID,
"subscriptionToken": customer.SubscriptionToken,
"items": credentialItems(data, customer),
}, nil)
SendJSON(w, 200, customerCredentialsView(data, customer), nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/credentials/:ncid/regenerate", pathname); ok && r.Method == http.MethodPost {
var responseItem map[string]any
if params, ok := RoutePattern("/api/customers/:id/credentials/regenerate", pathname); ok && r.Method == http.MethodPost {
var view map[string]any
_, err := a.store.Transaction(func(d *Data) (any, error) {
customer := findCustomer(d, params["id"])
if customer == nil {
return nil, httpErr(404, "客户不存在")
}
var binding *NodeClient
// One set for the whole customer: rotating it rotates every
// node at once, and every deployed node is rebuilt so the new
// users land on the agents.
creds := NewCustomerCredentials()
customer.Credentials = &creds
customer.UpdatedAt = NowISO()
rebuilt := map[string]bool{}
for _, nc := range d.NodeClients {
if nc.ID == params["ncid"] && nc.CustomerID == customer.ID {
binding = nc
break
if nc.CustomerID != customer.ID || nc.Status != "active" {
continue
}
}
if binding == nil {
return nil, httpErr(404, "凭据不存在")
}
node := findNode(d, binding.NodeID)
if node == nil {
return nil, httpErr(404, "节点不存在")
}
binding.Credentials = NewNodeClientCredentials(node.Protocol)
binding.UpdatedAt = NowISO()
if node.Status != "draft" && node.Status != "removing" {
node := findNode(d, nc.NodeID)
if node == nil || node.Status == "draft" || node.Status == "removing" || rebuilt[node.ID] {
continue
}
rebuilt[node.ID] = true
if err := RebuildNode(d, node, false); err != nil {
return nil, err
}
}
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodeId": node.ID, "node": node.Name})
for _, entry := range credentialItems(d, customer) {
if entry["nodeClientId"] == binding.ID {
responseItem = entry
break
}
}
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodesRebuilt": len(rebuilt)})
view = customerCredentialsView(d, customer)
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"item": responseItem}, nil)
SendJSON(w, 200, view, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/external-sources/:sid/refresh", pathname); ok && r.Method == http.MethodPost {
@@ -2271,6 +2520,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
next.ExitReady = false
next.ResourceID = NewID("res")
next.ExitResourceID = NewID("res")
if manualExit(next) {
// Nothing is deployed on a manual landing endpoint, so the
// exit side is ready by definition and owns no resource.
next.ExitReady = true
next.ExitResourceID = ""
}
next.Artifacts = map[string]json.RawMessage{}
next.Error = nil
next.PendingDelete = false
@@ -2313,28 +2568,29 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
}
oldServerID := item.ServerID
oldExitServerID := strVal(item.ExitServerID)
oldExitResourceID := item.ExitResourceID
oldForward := forwardNode(item)
oldProtocol := item.Protocol
next, err := NormalizeNode(body, item, d)
if err != nil {
return nil, err
}
*item = *next
EnsureNodeSecrets(item)
if item.Protocol != oldProtocol {
for _, nc := range d.NodeClients {
if nc.NodeID == item.ID {
nc.Credentials = NewNodeClientCredentials(item.Protocol)
nc.UpdatedAt = NowISO()
}
}
}
if item.ServerID != oldServerID {
QueueJob(d, oldServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ResourceID, Kind: "node"})
item.EntryReady = false
}
if oldForward && (!forwardNode(item) || strVal(item.ExitServerID) != oldExitServerID) {
QueueJob(d, oldExitServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ExitResourceID, Kind: "node-exit"})
if oldForward && oldExitResourceID != "" && (!forwardNode(item) || manualExit(item) || strVal(item.ExitServerID) != oldExitServerID) {
QueueJob(d, oldExitServerID, item.ID, "delete_resource", &JobPayload{ResourceID: oldExitResourceID, Kind: "node-exit"})
item.ExitReady = false
}
if manualExit(item) {
item.ExitReady = true
item.ExitResourceID = ""
} else if forwardNode(item) && item.ExitResourceID == "" {
// Switching from a manual endpoint back to a managed exit
// server needs a fresh exit resource identity.
item.ExitResourceID = NewID("res")
item.ExitReady = false
}
if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" {
@@ -2342,6 +2598,9 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
return nil, err
}
}
if manualExit(item) {
item.ExitReady = true
}
item.UpdatedAt = NowISO()
Audit(d, actor, "update_node", item.ID, map[string]any{"name": item.Name})
return cloneJSONValue(item), nil
+141
View File
@@ -0,0 +1,141 @@
package main
// Tests for manual landing endpoints (round-2 item 8): NormalizeNode
// validation, entry-side outbound generation, and the no-exit-job rule.
import (
"encoding/json"
"testing"
)
func manualData() *Data {
return &Data{
Servers: []*Server{{ID: "srv_entry", Name: "Entry", PublicAddress: "entry.example.com"}},
}
}
func manualBody() map[string]any {
return map[string]any{
"name": "Manual", "serverId": "srv_entry", "protocol": "vless-reality-vision",
"topology": "forward", "exitMode": "manual", "exitAddress": "203.0.113.9",
"exitProtocol": "shadowsocks-2022-aes128", "exitPort": json.Number("23464"),
"exitCredentials": map[string]any{"password": "FRYXGBkaGxwdHh8gISIjJA==:FRYXGBkaGxwdHh8gISIjJA=="},
"portMode": "fixed", "port": json.Number("23465"),
}
}
func TestNormalizeManualExit(t *testing.T) {
node, err := NormalizeNode(manualBody(), nil, manualData())
if err != nil {
t.Fatalf("normalize: %v", err)
}
if node.ExitMode != "manual" || node.ExitAddress != "203.0.113.9" {
t.Fatalf("mode/address not stored: %q %q", node.ExitMode, node.ExitAddress)
}
if node.ExitServerID != nil {
t.Fatalf("manual node must not reference an exit server")
}
if node.ExitPort == nil || *node.ExitPort != 23464 {
t.Fatalf("manual port not kept: %v", node.ExitPort)
}
if node.ExitPortMode == nil || *node.ExitPortMode != "fixed" {
t.Fatalf("manual port mode must be fixed")
}
if node.ExitCredentials == nil || node.ExitCredentials.Password != "FRYXGBkaGxwdHh8gISIjJA==:FRYXGBkaGxwdHh8gISIjJA==" {
t.Fatalf("manual credentials not stored")
}
}
func TestNormalizeManualExitValidation(t *testing.T) {
cases := []struct {
name string
mutate func(map[string]any)
}{
{"missing address", func(b map[string]any) { delete(b, "exitAddress") }},
{"address with scheme", func(b map[string]any) { b["exitAddress"] = "https://x.example.com" }},
{"missing port", func(b map[string]any) { delete(b, "exitPort") }},
{"missing ss password", func(b map[string]any) { b["exitCredentials"] = map[string]any{} }},
{"ss2022 password not base64", func(b map[string]any) {
b["exitCredentials"] = map[string]any{"password": "not-a-base64-key"}
}},
{"ss2022 key wrong length", func(b map[string]any) {
// A 16-byte key against the aes256 method (needs 32).
b["exitCredentials"] = map[string]any{"password": "AAAAAAAAAAAAAAAAAAAAAA=="}
b["exitProtocol"] = "shadowsocks-2022-aes256"
}},
{"vless without uuid", func(b map[string]any) {
b["exitProtocol"] = "vless-tcp"
b["exitCredentials"] = map[string]any{"password": "x"}
}},
{"socks without user", func(b map[string]any) {
b["exitProtocol"] = "socks5-auth"
b["exitCredentials"] = map[string]any{"password": "x"}
}},
}
for _, tc := range cases {
body := manualBody()
tc.mutate(body)
if _, err := NormalizeNode(body, nil, manualData()); err == nil {
t.Fatalf("%s: expected an error", tc.name)
}
}
}
func TestManualExitResourceAndJobs(t *testing.T) {
node, err := NormalizeNode(manualBody(), nil, manualData())
if err != nil {
t.Fatalf("normalize: %v", err)
}
node.ID = "nod_manual"
node.ResourceID = "res_manual"
node.ExitResourceID = ""
node.ExitReady = true
node.Status = "active"
customer := &Customer{ID: "cus_1", Name: "C", Credentials: &Credentials{
ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "pw", ShortID: "abcd0001ef01",
}}
d := manualData()
d.Nodes = []*Node{node}
d.Customers = []*Customer{customer}
d.NodeClients = []*NodeClient{{ID: "ncl_1", NodeID: node.ID, CustomerID: "cus_1", Status: "active"}}
resource, err := resourceForNode(d, node, nil)
if err != nil {
t.Fatalf("resourceForNode: %v", err)
}
outbounds := resource["outbounds"].([]any)
outbound := outbounds[0].(map[string]any)
if outbound["server"] != "203.0.113.9" || outbound["type"] != "shadowsocks" {
t.Fatalf("outbound does not target the manual endpoint: %v", outbound)
}
if outbound["password"] != "FRYXGBkaGxwdHh8gISIjJA==:FRYXGBkaGxwdHh8gISIjJA==" {
t.Fatalf("outbound password wrong: %v", outbound["password"])
}
rules := resource["routingRules"].([]any)
if rules[0].(map[string]any)["outbound"] != outbound["tag"] {
t.Fatalf("routing rule does not point at the exit outbound")
}
jobsBefore := len(d.Jobs)
if err := RebuildNode(d, node, true); err != nil {
t.Fatalf("RebuildNode: %v", err)
}
for _, job := range d.Jobs[jobsBefore:] {
if job.Payload != nil && job.Payload.Kind == "node-exit" {
t.Fatalf("manual node must not queue exit jobs")
}
if job.ServerID != "srv_entry" {
t.Fatalf("job queued for a non-entry server: %s", job.ServerID)
}
}
if !node.ExitReady {
t.Fatalf("manual node must stay exit-ready")
}
// Deploy validation must not look up an exit server for manual nodes
// (regression: this dereferenced a nil exit server and panicked).
d.Servers[0].Status = "online"
if err := ValidateNodeDeploy(d, node); err != nil {
t.Fatalf("ValidateNodeDeploy for a manual node: %v", err)
}
}
+58 -22
View File
@@ -59,6 +59,13 @@ func forwardNode(node *Node) bool {
return topology == "forward"
}
// manualExit reports whether a forward node's exit is an unmanaged landing
// endpoint (round-2 item 8): no exit server, no exit resource, nothing to
// deploy on the far side.
func manualExit(node *Node) bool {
return forwardNode(node) && node.ExitMode == "manual"
}
func tagForNode(node *Node) string {
var sb strings.Builder
for _, r := range node.ID {
@@ -239,6 +246,16 @@ func payloadWithResource(resource map[string]any, kind string, keyMaterial map[s
return payload
}
// ensureCustomerCredentials fills a missing customer-level credential set
// (defensive: the store-load migration and customer creation provide one).
func ensureCustomerCredentials(d *Data, customerID string) {
customer := findCustomer(d, customerID)
if customer != nil && (customer.Credentials == nil || customer.Credentials.ClientID == "") {
creds := NewCustomerCredentials()
customer.Credentials = &creds
}
}
func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any, error) {
server := orchFindServer(d, node.ServerID)
if server == nil {
@@ -253,7 +270,10 @@ func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any
return nil, err
}
var exitServer *Server
if forwardNode(node) {
exitAddress := ""
if manualExit(node) {
exitAddress = node.ExitAddress
} else if forwardNode(node) {
exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
if exitServer == nil {
return nil, orchNotFound("出口服务器")
@@ -262,14 +282,30 @@ func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any
if clients == nil {
clients = activeNodeClients(d, node)
}
// Credentials live on the customer (round-2 item 2); stored bindings
// carry none. Stamp each customer's set onto throwaway copies for the
// resource builder so the stored records stay clean.
resolved := make([]*NodeClient, len(clients))
for i, nc := range clients {
ensureCustomerCredentials(d, nc.CustomerID)
cp := *nc
if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil {
creds := *customer.Credentials
cp.Credentials = &creds
} else {
cp.Credentials = nil
}
resolved[i] = &cp
}
return BuildNodeResource(BuildNodeResourceParams{
ResourceID: node.ResourceID,
TagPrefix: tagForNode(node),
Node: node,
NodeClients: clients,
NodeClients: resolved,
Server: server,
BindAddress: bindAddress,
ExitServer: exitServer,
ExitAddress: exitAddress,
})
}
@@ -331,7 +367,7 @@ func RebuildNode(d *Data, node *Node, includeExit bool) error {
}
payload := payloadWithResource(resource, "node", KeyMaterialForNode(d, node))
QueueJob(d, node.ServerID, node.ID, "apply_resource", payload)
if forwardNode(node) && includeExit {
if forwardNode(node) && includeExit && !manualExit(node) {
exitResource, err := resourceForNodeExit(d, node)
if err != nil {
return err
@@ -351,7 +387,7 @@ func DeriveNodeStatus(node *Node) {
}
return
}
if forwardNode(node) && !node.ExitReady {
if forwardNode(node) && !node.ExitReady && !manualExit(node) {
if node.Status != "degraded" {
node.Status = "deploying"
}
@@ -606,14 +642,14 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
return orchNotFound("服务器")
}
var exitServer *Server
if !direct {
if !direct && !manualExit(node) {
exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
if exitServer == nil {
return orchNotFound("出口服务器")
}
}
targets := []*Server{server}
if !direct {
if exitServer != nil {
targets = append(targets, exitServer)
}
for _, target := range targets {
@@ -666,7 +702,7 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
}
}
servers := []*Server{server}
if !direct {
if exitServer != nil {
servers = []*Server{server, exitServer}
}
nowMs := time.Now().UnixMilli()
@@ -711,7 +747,7 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
if NodePortInUse(d, node.ServerID, node.Port, node.ID) {
return httpErr(409, "端口 %d 已被该服务器上的其他节点占用", node.Port)
}
if !direct && node.ExitPort != nil && NodePortInUse(d, xbDeref(node.ExitServerID), *node.ExitPort, node.ID) {
if !direct && !manualExit(node) && node.ExitPort != nil && NodePortInUse(d, xbDeref(node.ExitServerID), *node.ExitPort, node.ID) {
return httpErr(409, "出口端口 %d 已被该服务器上的其他节点占用", *node.ExitPort)
}
return nil
@@ -977,14 +1013,14 @@ func assignCustomerData(d *Data, nodeID, customerID, actor string) (*NodeClient,
nc.UpdatedAt = NowISO()
} else {
now := NowISO()
ensureCustomerCredentials(d, customerID)
nc = &NodeClient{
ID: NewID("ncl"),
NodeID: nodeID,
CustomerID: customerID,
Credentials: NewNodeClientCredentials(node.Protocol),
Status: "active",
CreatedAt: now,
UpdatedAt: now,
ID: NewID("ncl"),
NodeID: nodeID,
CustomerID: customerID,
Status: "active",
CreatedAt: now,
UpdatedAt: now,
}
d.NodeClients = append(d.NodeClients, nc)
}
@@ -1077,14 +1113,14 @@ func AlignCustomerNodes(d *Data, customer *Customer, desiredIds []string, actor
status = "active"
}
now := NowISO()
ensureCustomerCredentials(d, customer.ID)
d.NodeClients = append(d.NodeClients, &NodeClient{
ID: NewID("ncl"),
NodeID: nodeID,
CustomerID: customer.ID,
Credentials: NewNodeClientCredentials(node.Protocol),
Status: status,
CreatedAt: now,
UpdatedAt: now,
ID: NewID("ncl"),
NodeID: nodeID,
CustomerID: customer.ID,
Status: status,
CreatedAt: now,
UpdatedAt: now,
})
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customer.ID, "customer": customer.Name})
if status == "active" {
+156
View File
@@ -0,0 +1,156 @@
package main
// Regression tests for the port re-roll bug (round-2 item 7): a node whose
// entry/exit ports were assigned in "random" mode must keep them verbatim
// across later edits. The browser form always submits portMode + port (null
// in random mode) + serverId, so NormalizeNode must treat an edit as
// addressing-neutral unless the user explicitly changes the mode, supplies
// a different fixed port, or moves the node to a server where the port is
// already taken.
import (
"encoding/json"
"testing"
)
func portLockFixture() (*Data, *Node) {
exitID := "srv_exit"
exitProto := "shadowsocks-2022-aes256"
exitMode := "random"
exitPort := 31001
node := &Node{
ID: "nod_lock", Name: "N1", ServerID: "srv_entry", Topology: "forward",
Protocol: "vless-reality-vision", PortMode: "random", Port: 26522,
ExitServerID: &exitID, ExitProtocol: &exitProto,
ExitPortMode: &exitMode, ExitPort: &exitPort,
NetworkMode: "ipv4", RealityServerName: "www.tesla.com", RealityDestPort: 443,
}
data := &Data{
Servers: []*Server{
{ID: "srv_entry", Name: "Entry", PortRangeStart: 20000, PortRangeEnd: 50000},
{ID: "srv_exit", Name: "Exit", PortRangeStart: 20000, PortRangeEnd: 50000},
{ID: "srv_other", Name: "Other", PortRangeStart: 20000, PortRangeEnd: 50000},
},
Nodes: []*Node{node},
}
return data, node
}
// The exact payload shape the browser node form submits on save.
func uiNodeBody(name string) map[string]any {
return map[string]any{
"name": name, "serverId": "srv_entry", "protocol": "vless-reality-vision",
"portMode": "random", "port": nil, "networkMode": "ipv4", "topology": "forward",
"exitServerId": "srv_exit", "exitProtocol": "shadowsocks-2022-aes256",
"exitPortMode": "random", "exitPort": nil,
"realityServerName": "www.tesla.com", "realityDestPort": json.Number("443"),
}
}
func TestPortLockUIEditKeepsPortsVerbatim(t *testing.T) {
data, node := portLockFixture()
current := node
for i := 0; i < 2; i++ {
next, err := NormalizeNode(uiNodeBody("N1-renamed"), current, data)
if err != nil {
t.Fatalf("edit %d: %v", i, err)
}
if next.Port != 26522 {
t.Fatalf("edit %d: entry port re-rolled: got %d want 26522", i, next.Port)
}
if next.ExitPort == nil || *next.ExitPort != 31001 {
t.Fatalf("edit %d: exit port re-rolled: got %v want 31001", i, next.ExitPort)
}
current = next
data.Nodes[0] = next
}
}
func TestPortLockRenameOnlyKeepsPorts(t *testing.T) {
data, node := portLockFixture()
next, err := NormalizeNode(map[string]any{"name": "Other name"}, node, data)
if err != nil {
t.Fatalf("rename: %v", err)
}
if next.Port != 26522 || next.ExitPort == nil || *next.ExitPort != 31001 {
t.Fatalf("rename moved ports: port=%d exit=%v", next.Port, next.ExitPort)
}
}
func TestPortLockRandomToFixedPinsCurrentPort(t *testing.T) {
data, node := portLockFixture()
body := uiNodeBody("N1")
body["portMode"] = "fixed" // port key stays null, as the disabled input submits nothing
next, err := NormalizeNode(body, node, data)
if err != nil {
t.Fatalf("mode switch: %v", err)
}
if next.PortMode != "fixed" || next.Port != 26522 {
t.Fatalf("random->fixed should pin current port: mode=%s port=%d", next.PortMode, next.Port)
}
}
func TestPortLockFixedExplicitPortChangeAndConflict(t *testing.T) {
data, node := portLockFixture()
node.PortMode = "fixed"
body := uiNodeBody("N1")
body["portMode"] = "fixed"
body["port"] = json.Number("27000")
next, err := NormalizeNode(body, node, data)
if err != nil {
t.Fatalf("fixed change: %v", err)
}
if next.Port != 27000 {
t.Fatalf("fixed explicit port not applied: got %d", next.Port)
}
// Occupied port on the same server must 409.
other := &Node{ID: "nod_other", ServerID: "srv_entry", Port: 28000}
data.Nodes = append(data.Nodes, other)
body["port"] = json.Number("28000")
if _, err := NormalizeNode(body, next, data); err == nil {
t.Fatalf("expected conflict error for occupied fixed port")
}
}
func TestPortLockFixedToRandomRerolls(t *testing.T) {
data, node := portLockFixture()
node.PortMode = "fixed"
body := uiNodeBody("N1") // portMode random in body
next, err := NormalizeNode(body, node, data)
if err != nil {
t.Fatalf("fixed->random: %v", err)
}
if next.Port < 20000 || next.Port > 50000 {
t.Fatalf("re-rolled port out of range: %d", next.Port)
}
}
func TestPortLockServerMoveKeepsFreePortAndRerollsTaken(t *testing.T) {
data, node := portLockFixture()
body := uiNodeBody("N1")
body["serverId"] = "srv_other"
next, err := NormalizeNode(body, node, data)
if err != nil {
t.Fatalf("server move (free): %v", err)
}
if next.Port != 26522 {
t.Fatalf("server move should keep free port: got %d", next.Port)
}
// Occupy 26522 on the target server: random mode must roll a replacement.
blocker := &Node{ID: "nod_blocker", ServerID: "srv_other", Port: 26522}
data.Nodes = append(data.Nodes, blocker)
next2, err := NormalizeNode(body, node, data)
if err != nil {
t.Fatalf("server move (taken): %v", err)
}
if next2.Port == 26522 {
t.Fatalf("server move kept a taken port")
}
// Fixed mode must surface the conflict instead of silently moving.
node.PortMode = "fixed"
body["portMode"] = "fixed"
body["port"] = json.Number("26522")
if _, err := NormalizeNode(body, node, data); err == nil {
t.Fatalf("expected conflict error when moving fixed port onto a taken port")
}
}
+55 -18
View File
@@ -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
+3 -3
View File
@@ -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"}
+61
View File
@@ -6,6 +6,7 @@ package main
// the same serialization here.
import (
"encoding/base64"
"errors"
"fmt"
"os"
@@ -133,10 +134,70 @@ func (s *Store) Init() error {
if err := validateData(&data); err != nil {
return err
}
if MigrateCustomerCredentials(&data) {
// Persist the converged credential model immediately, so a crash
// before the next transaction cannot resurrect per-binding sets.
if err := s.persist(&data); err != nil {
return err
}
}
s.Data = &data
return nil
}
// MigrateCustomerCredentials converges legacy per-binding credentials into
// each customer's single set (round-2 item 2; the 3x-ui model of one
// credential row per client). Deterministic rules:
// - A customer with a complete stored set keeps it.
// - Otherwise the set is copied from the customer's first binding (in
// stored order) that carries credentials; a customer with no usable
// binding credentials gets a fresh set.
// - The relay password must be a base64 32-byte root key, because SS2022
// user keys derive from it by prefix truncation (EffectiveRelayPassword).
// If the converged password does not decode to exactly 32 bytes, only
// the password is regenerated — clientId and shortId are preserved.
// - Every binding's stored credentials are then cleared, so bindings stop
// carrying credentials at all.
//
// Returns true when anything changed.
func MigrateCustomerCredentials(d *Data) bool {
changed := false
for _, customer := range d.Customers {
var creds *Credentials
if customer.Credentials != nil && customer.Credentials.ClientID != "" {
copied := *customer.Credentials
creds = &copied
} else {
for _, nc := range d.NodeClients {
if nc.CustomerID == customer.ID && nc.Credentials != nil && nc.Credentials.ClientID != "" {
copied := *nc.Credentials
creds = &copied
break
}
}
}
if creds == nil {
fresh := NewCustomerCredentials()
creds = &fresh
}
if raw, err := base64.StdEncoding.DecodeString(creds.RelayPassword); err != nil || len(raw) != 32 {
creds.RelayPassword = b64(32)
}
old := customer.Credentials
if old == nil || old.ClientID != creds.ClientID || old.RelayPassword != creds.RelayPassword || old.ShortID != creds.ShortID {
customer.Credentials = creds
changed = true
}
}
for _, nc := range d.NodeClients {
if nc.Credentials != nil {
nc.Credentials = nil
changed = true
}
}
return changed
}
// Snapshot returns a deep copy of the current data (JS structuredClone).
func (s *Store) Snapshot() *Data {
s.mu.Lock()
+7 -3
View File
@@ -108,13 +108,17 @@ func NodeClientURI(d *Data, nc *NodeClient) string {
if networkMode == "" {
networkMode = "ipv4"
}
creds := Credentials{}
if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil {
creds = *customer.Credentials
}
uri, ok := BuildClientURI(BuildClientURIParams{
Protocol: node.Protocol,
RelayServer: server,
RelayPort: node.Port,
ClientID: nc.Credentials.ClientID,
RelayPassword: nc.Credentials.RelayPassword,
ShortID: nc.Credentials.ShortID,
ClientID: creds.ClientID,
RelayPassword: creds.RelayPassword,
ShortID: creds.ShortID,
WSPath: xbDeref(node.WSPath),
SocksUser: NodeEmail(nc.CustomerID),
Reality: node.Reality,
+12 -13
View File
@@ -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",
}}},
}
}
+65
View File
@@ -0,0 +1,65 @@
package main
// Tests for CustomerSummary (round-2 item 1): the six counts on the
// customers page, with depleted/depletingSoon predicates aligned to
// 3x-ui's depletedExpr/expiringExpr (client_paging.go) — including its
// panel-configurable expireDiff (days) / trafficDiff (GB) thresholds,
// which default to 0 and then count nobody as depleting soon.
import (
"testing"
"time"
)
func summaryFixture(now time.Time) *Data {
iso := func(t time.Time) *string { s := t.UTC().Format(time.RFC3339); return &s }
manual := "manual"
expiredReason := "expired"
gb := float64(1073741824)
return &Data{Customers: []*Customer{
// active + online + plain
{ID: "cus_a", Name: "A", Status: "active", LastOnlineAt: iso(now)},
// active, traffic exhausted
{ID: "cus_b", Name: "B", Status: "active", TrafficLimitBytes: 1000, UsedBytes: 1000},
// active, 0.5GB remaining of 2GB
{ID: "cus_c", Name: "C", Status: "active", TrafficLimitBytes: 2 * gb, UsedBytes: int64(1.5 * gb)},
// active, expires in 2 days
{ID: "cus_d", Name: "D", Status: "active", ExpiresAt: iso(now.Add(48 * time.Hour))},
// active, expires in 10 days
{ID: "cus_e", Name: "E", Status: "active", ExpiresAt: iso(now.Add(240 * time.Hour))},
// manually disabled
{ID: "cus_f", Name: "F", Status: "suspended", SuspendReason: &manual, SuspendReasonPresent: true},
// expired (auto-suspended): depleted, not "disabled"
{ID: "cus_g", Name: "G", Status: "suspended", SuspendReason: &expiredReason, SuspendReasonPresent: true, ExpiresAt: iso(now.Add(-time.Hour))},
// active, unlimited with heavy usage: never depleted
{ID: "cus_h", Name: "H", Status: "active", UsedBytes: int64(9 * gb)},
}}
}
func TestCustomerSummaryDefaultThresholds(t *testing.T) {
now := time.Now()
d := summaryFixture(now)
s := CustomerSummary(d, now.UnixMilli())
want := map[string]int{"total": 8, "online": 1, "depleted": 2, "depletingSoon": 0, "disabled": 1, "enabled": 6}
for k, v := range want {
if s[k] != v {
t.Fatalf("summary[%s] = %v, want %d (full: %v)", k, s[k], v, s)
}
}
}
func TestCustomerSummaryWithThresholds(t *testing.T) {
now := time.Now()
d := summaryFixture(now)
d.Settings.ExpireDiff = 3 // days
d.Settings.TrafficDiff = 1 // GB
s := CustomerSummary(d, now.UnixMilli())
// C (0.5GB remaining < 1GB) and D (expiry in 2d < 3d) become depleting soon;
// E (10d) does not. Nothing else moves.
if s["depletingSoon"] != 2 {
t.Fatalf("depletingSoon = %v, want 2 (full: %v)", s["depletingSoon"], s)
}
if s["depleted"] != 2 || s["enabled"] != 6 || s["disabled"] != 1 {
t.Fatalf("other counts moved: %v", s)
}
}
+32 -32
View File
@@ -2,7 +2,7 @@
"entries": [
{
"nodeId": "nod_rf",
"clientUri": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23456?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu&sid=abcd0001ef01&type=tcp&headerType=none&flow=xtls-rprx-vision#RF-Reality"
"clientUri": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23456?encryption=none\u0026security=reality\u0026sni=www.amd.com\u0026fp=chrome\u0026pbk=FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\u0026sid=abcd0001ef01\u0026type=tcp\u0026headerType=none\u0026flow=xtls-rprx-vision#RF-Reality"
},
{
"nodeId": "nod_ss",
@@ -10,56 +10,56 @@
},
{
"nodeId": "nod_hy",
"clientUri": "hysteria2://HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz@entry.example.com:23459?sni=tls.example.com&alpn=h3#HY2"
"clientUri": "hysteria2://FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23459?sni=tls.example.com\u0026alpn=h3#HY2"
},
{
"nodeId": "nod_any",
"clientUri": "anytls://IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6@entry.example.com:23460?sni=tls.example.com#AnyTLS"
"clientUri": "anytls://FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23460?sni=tls.example.com#AnyTLS"
},
{
"nodeId": "nod_ws",
"clientUri": "vless://11111111-2222-3333-4444-000000000006@entry.example.com:23461?encryption=none&security=tls&type=ws&path=%2Fwspath123&sni=tls.example.com#WS-TLS"
"clientUri": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23461?encryption=none\u0026security=tls\u0026type=ws\u0026path=%2Fwspath123\u0026sni=tls.example.com#WS-TLS"
},
{
"nodeId": "nod_sk",
"clientUri": "socks5://ng%3Acus_1:MTIzNDU2Nzg5Ojs8PT4%2FQEFCQ0RFRkdI@entry.example.com:23462#Socks"
"clientUri": "socks5://ng%3Acus_1:FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23462#Socks"
},
{
"nodeId": "nod_rv",
"clientUri": "vless://11111111-2222-3333-4444-000000000008@v6.example.com:23463?encryption=none&security=reality&sni=www.apple.com&fp=chrome&pbk=FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu&sid=abcd0008ef01&type=tcp&headerType=none#RV-Plain"
"clientUri": "vless://11111111-2222-3333-4444-000000000001@v6.example.com:23463?encryption=none\u0026security=reality\u0026sni=www.apple.com\u0026fp=chrome\u0026pbk=FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\u0026sid=abcd0001ef01\u0026type=tcp\u0026headerType=none#RV-Plain"
}
],
"formats": {
"raw": {
"body": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23456?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu&sid=abcd0001ef01&type=tcp&headerType=none&flow=xtls-rprx-vision#RF-Reality\nss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206Q3d3TkRnOFFFUklURkJVV0Z4Z1pHZz09OkZSWVhHQmthR3h3ZEhoOGdJU0lqSkE9PQ@entry.example.com:23458#SS-Direct\nhysteria2://HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz@entry.example.com:23459?sni=tls.example.com&alpn=h3#HY2\nanytls://IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6@entry.example.com:23460?sni=tls.example.com#AnyTLS\nvless://11111111-2222-3333-4444-000000000006@entry.example.com:23461?encryption=none&security=tls&type=ws&path=%2Fwspath123&sni=tls.example.com#WS-TLS\nsocks5://ng%3Acus_1:MTIzNDU2Nzg5Ojs8PT4%2FQEFCQ0RFRkdI@entry.example.com:23462#Socks\nvless://11111111-2222-3333-4444-000000000008@v6.example.com:23463?encryption=none&security=reality&sni=www.apple.com&fp=chrome&pbk=FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu&sid=abcd0008ef01&type=tcp&headerType=none#RV-Plain\n",
"contentType": "text/plain; charset=utf-8"
},
"base64": {
"body": "dmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NTY/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy5hbWQuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSZktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDAxZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUmZmxvdz14dGxzLXJwcngtdmlzaW9uI1JGLVJlYWxpdHkKc3M6Ly9NakF5TWkxaWJHRnJaVE10WVdWekxURXlPQzFuWTIwNlEzZDNUa1JuT0ZGRlVrbFVSa0pWVjBaNFoxcEhaejA5T2taU1dWaEhRbXRoUjNoM1pFaG9PR2RKVTBscVNrRTlQUUBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OCNTUy1EaXJlY3QKaHlzdGVyaWEyOi8vSEIwZUh5QWhJaU1rSlNZbktDa3FLeXd0TGk4d01USXpAZW50cnkuZXhhbXBsZS5jb206MjM0NTk/c25pPXRscy5leGFtcGxlLmNvbSZhbHBuPWgzI0hZMgphbnl0bHM6Ly9JeVFsSmljb0tTb3JMQzB1THpBeE1qTTBOVFkzT0RrNkBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MD9zbmk9dGxzLmV4YW1wbGUuY29tI0FueVRMUwp2bGVzczovLzExMTExMTExLTIyMjItMzMzMy00NDQ0LTAwMDAwMDAwMDAwNkBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MT9lbmNyeXB0aW9uPW5vbmUmc2VjdXJpdHk9dGxzJnR5cGU9d3MmcGF0aD0lMkZ3c3BhdGgxMjMmc25pPXRscy5leGFtcGxlLmNvbSNXUy1UTFMKc29ja3M1Oi8vbmclM0FjdXNfMTpNVEl6TkRVMk56ZzVPanM4UFQ0JTJGUUVGQ1EwUkZSa2RJQGVudHJ5LmV4YW1wbGUuY29tOjIzNDYyI1NvY2tzCnZsZXNzOi8vMTExMTExMTEtMjIyMi0zMzMzLTQ0NDQtMDAwMDAwMDAwMDA4QHY2LmV4YW1wbGUuY29tOjIzNDYzP2VuY3J5cHRpb249bm9uZSZzZWN1cml0eT1yZWFsaXR5JnNuaT13d3cuYXBwbGUuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSdktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDA4ZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUjUlYtUGxhaW4K",
"contentType": "text/plain; charset=utf-8"
},
"clash": {
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000006\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000008\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0008ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"MATCH,节点选择\"\n",
"contentType": "text/yaml; charset=utf-8"
},
"clash-smart": {
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000006\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000008\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0008ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - \"DIRECT\"\n - name: \"自动选择\"\n type: url-test\n url: \"https://www.gstatic.com/generate_204\"\n interval: 300\n tolerance: 80\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"流媒体\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"AI 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"Meta 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"GEOSITE,category-ads-all,REJECT\"\n - \"GEOSITE,private,DIRECT\"\n - \"GEOSITE,openai,AI 服务\"\n - \"GEOSITE,netflix,流媒体\"\n - \"GEOSITE,facebook,Meta 服务\"\n - \"DOMAIN-SUFFIX,fbcdn.net,Meta 服务\"\n - \"DOMAIN-SUFFIX,instagram.com,Meta 服务\"\n - \"DOMAIN-SUFFIX,whatsapp.net,Meta 服务\"\n - \"GEOSITE,geolocation-!cn,节点选择\"\n - \"GEOSITE,cn,DIRECT\"\n - \"GEOIP,CN,DIRECT\"\n - \"MATCH,节点选择\"\n",
"contentType": "text/yaml; charset=utf-8"
},
"singbox": {
"body": "{\n \"log\": {\n \"level\": \"warn\"\n },\n \"inbounds\": [\n {\n \"type\": \"mixed\",\n \"tag\": \"local\",\n \"listen\": \"127.0.0.1\",\n \"listen_port\": 2080\n }\n ],\n \"outbounds\": [\n {\n \"type\": \"selector\",\n \"tag\": \"proxy\",\n \"outbounds\": [\n \"node-1\",\n \"node-2\",\n \"node-3\",\n \"node-4\",\n \"node-5\",\n \"node-6\",\n \"node-7\"\n ]\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-1\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23456,\n \"uuid\": \"11111111-2222-3333-4444-000000000001\",\n \"flow\": \"xtls-rprx-vision\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"www.amd.com\",\n \"reality\": {\n \"enabled\": true,\n \"public_key\": \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\",\n \"short_id\": \"abcd0001ef01\"\n }\n }\n },\n {\n \"type\": \"shadowsocks\",\n \"tag\": \"node-2\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23458,\n \"method\": \"2022-blake3-aes-128-gcm\",\n \"password\": \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n },\n {\n \"type\": \"hysteria2\",\n \"tag\": \"node-3\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23459,\n \"password\": \"HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\",\n \"alpn\": [\n \"h3\"\n ]\n }\n },\n {\n \"type\": \"anytls\",\n \"tag\": \"node-4\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23460,\n \"password\": \"IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\"\n }\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-5\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23461,\n \"uuid\": \"11111111-2222-3333-4444-000000000006\",\n \"transport\": {\n \"type\": \"ws\",\n \"path\": \"/wspath123\"\n },\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\"\n }\n },\n {\n \"type\": \"socks\",\n \"tag\": \"node-6\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23462,\n \"version\": \"5\",\n \"username\": \"ng:cus_1\",\n \"password\": \"MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\"\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-7\",\n \"server\": \"v6.example.com\",\n \"server_port\": 23463,\n \"uuid\": \"11111111-2222-3333-4444-000000000008\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"www.apple.com\",\n \"reality\": {\n \"enabled\": true,\n \"public_key\": \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\",\n \"short_id\": \"abcd0008ef01\"\n }\n }\n },\n {\n \"type\": \"direct\",\n \"tag\": \"direct\"\n }\n ],\n \"route\": {\n \"final\": \"proxy\"\n }\n}\n",
"contentType": "application/json; charset=utf-8"
},
"surge": {
"body": "[General]\nloglevel = notify\n[Proxy]\nSS-Direct = ss, entry.example.com, 23458, encrypt-method=2022-blake3-aes-128-gcm, password=CwwNDg8QERITFBUWFxgZGg__:FRYXGBkaGxwdHh8gISIjJA__\nSocks = socks5, entry.example.com, 23462, ng:cus_1, MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\n[Proxy Group]\n节点选择 = select, SS-Direct, Socks, DIRECT\n自动选择 = url-test, SS-Direct, Socks, url=http://www.gstatic.com/generate_204, interval=600\n[Rule]\nGEOIP,CN,DIRECT\nFINAL,节点选择\n",
"contentType": "text/plain; charset=utf-8"
},
"auto-clash-ua": {
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000006\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000008\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0008ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"MATCH,节点选择\"\n",
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"MATCH,节点选择\"\n",
"contentType": "text/yaml; charset=utf-8"
},
"auto-plain-ua": {
"body": "dmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NTY/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy5hbWQuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSZktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDAxZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUmZmxvdz14dGxzLXJwcngtdmlzaW9uI1JGLVJlYWxpdHkKc3M6Ly9NakF5TWkxaWJHRnJaVE10WVdWekxURXlPQzFuWTIwNlEzZDNUa1JuT0ZGRlVrbFVSa0pWVjBaNFoxcEhaejA5T2taU1dWaEhRbXRoUjNoM1pFaG9PR2RKVTBscVNrRTlQUUBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OCNTUy1EaXJlY3QKaHlzdGVyaWEyOi8vSEIwZUh5QWhJaU1rSlNZbktDa3FLeXd0TGk4d01USXpAZW50cnkuZXhhbXBsZS5jb206MjM0NTk/c25pPXRscy5leGFtcGxlLmNvbSZhbHBuPWgzI0hZMgphbnl0bHM6Ly9JeVFsSmljb0tTb3JMQzB1THpBeE1qTTBOVFkzT0RrNkBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MD9zbmk9dGxzLmV4YW1wbGUuY29tI0FueVRMUwp2bGVzczovLzExMTExMTExLTIyMjItMzMzMy00NDQ0LTAwMDAwMDAwMDAwNkBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MT9lbmNyeXB0aW9uPW5vbmUmc2VjdXJpdHk9dGxzJnR5cGU9d3MmcGF0aD0lMkZ3c3BhdGgxMjMmc25pPXRscy5leGFtcGxlLmNvbSNXUy1UTFMKc29ja3M1Oi8vbmclM0FjdXNfMTpNVEl6TkRVMk56ZzVPanM4UFQ0JTJGUUVGQ1EwUkZSa2RJQGVudHJ5LmV4YW1wbGUuY29tOjIzNDYyI1NvY2tzCnZsZXNzOi8vMTExMTExMTEtMjIyMi0zMzMzLTQ0NDQtMDAwMDAwMDAwMDA4QHY2LmV4YW1wbGUuY29tOjIzNDYzP2VuY3J5cHRpb249bm9uZSZzZWN1cml0eT1yZWFsaXR5JnNuaT13d3cuYXBwbGUuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSdktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDA4ZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUjUlYtUGxhaW4K",
"body": "dmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NTY/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy5hbWQuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSZktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDAxZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUmZmxvdz14dGxzLXJwcngtdmlzaW9uI1JGLVJlYWxpdHkKc3M6Ly9NakF5TWkxaWJHRnJaVE10WVdWekxURXlPQzFuWTIwNlEzZDNUa1JuT0ZGRlVrbFVSa0pWVjBaNFoxcEhaejA5T2taU1dWaEhRbXRoUjNoM1pFaG9PR2RKVTBscVNrRTlQUUBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OCNTUy1EaXJlY3QKaHlzdGVyaWEyOi8vRlJZWEdCa2FHeHdkSGg4Z0lTSWpKQSUzRCUzREBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OT9zbmk9dGxzLmV4YW1wbGUuY29tJmFscG49aDMjSFkyCmFueXRsczovL0ZSWVhHQmthR3h3ZEhoOGdJU0lqSkElM0QlM0RAZW50cnkuZXhhbXBsZS5jb206MjM0NjA/c25pPXRscy5leGFtcGxlLmNvbSNBbnlUTFMKdmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NjE/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXRscyZ0eXBlPXdzJnBhdGg9JTJGd3NwYXRoMTIzJnNuaT10bHMuZXhhbXBsZS5jb20jV1MtVExTCnNvY2tzNTovL25nJTNBY3VzXzE6RlJZWEdCa2FHeHdkSGg4Z0lTSWpKQSUzRCUzREBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MiNTb2Nrcwp2bGVzczovLzExMTExMTExLTIyMjItMzMzMy00NDQ0LTAwMDAwMDAwMDAwMUB2Ni5leGFtcGxlLmNvbToyMzQ2Mz9lbmNyeXB0aW9uPW5vbmUmc2VjdXJpdHk9cmVhbGl0eSZzbmk9d3d3LmFwcGxlLmNvbSZmcD1jaHJvbWUmcGJrPUZJWEVEUEJLUnZLZXkwMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFyc3R1JnNpZD1hYmNkMDAwMWVmMDEmdHlwZT10Y3AmaGVhZGVyVHlwZT1ub25lI1JWLVBsYWluCg==",
"contentType": "text/plain; charset=utf-8"
},
"base64": {
"body": "dmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NTY/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy5hbWQuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSZktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDAxZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUmZmxvdz14dGxzLXJwcngtdmlzaW9uI1JGLVJlYWxpdHkKc3M6Ly9NakF5TWkxaWJHRnJaVE10WVdWekxURXlPQzFuWTIwNlEzZDNUa1JuT0ZGRlVrbFVSa0pWVjBaNFoxcEhaejA5T2taU1dWaEhRbXRoUjNoM1pFaG9PR2RKVTBscVNrRTlQUUBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OCNTUy1EaXJlY3QKaHlzdGVyaWEyOi8vRlJZWEdCa2FHeHdkSGg4Z0lTSWpKQSUzRCUzREBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OT9zbmk9dGxzLmV4YW1wbGUuY29tJmFscG49aDMjSFkyCmFueXRsczovL0ZSWVhHQmthR3h3ZEhoOGdJU0lqSkElM0QlM0RAZW50cnkuZXhhbXBsZS5jb206MjM0NjA/c25pPXRscy5leGFtcGxlLmNvbSNBbnlUTFMKdmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NjE/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXRscyZ0eXBlPXdzJnBhdGg9JTJGd3NwYXRoMTIzJnNuaT10bHMuZXhhbXBsZS5jb20jV1MtVExTCnNvY2tzNTovL25nJTNBY3VzXzE6RlJZWEdCa2FHeHdkSGg4Z0lTSWpKQSUzRCUzREBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MiNTb2Nrcwp2bGVzczovLzExMTExMTExLTIyMjItMzMzMy00NDQ0LTAwMDAwMDAwMDAwMUB2Ni5leGFtcGxlLmNvbToyMzQ2Mz9lbmNyeXB0aW9uPW5vbmUmc2VjdXJpdHk9cmVhbGl0eSZzbmk9d3d3LmFwcGxlLmNvbSZmcD1jaHJvbWUmcGJrPUZJWEVEUEJLUnZLZXkwMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFyc3R1JnNpZD1hYmNkMDAwMWVmMDEmdHlwZT10Y3AmaGVhZGVyVHlwZT1ub25lI1JWLVBsYWluCg==",
"contentType": "text/plain; charset=utf-8"
},
"clash": {
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"MATCH,节点选择\"\n",
"contentType": "text/yaml; charset=utf-8"
},
"clash-smart": {
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - \"DIRECT\"\n - name: \"自动选择\"\n type: url-test\n url: \"https://www.gstatic.com/generate_204\"\n interval: 300\n tolerance: 80\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"流媒体\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"AI 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"Meta 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"GEOSITE,category-ads-all,REJECT\"\n - \"GEOSITE,private,DIRECT\"\n - \"GEOSITE,openai,AI 服务\"\n - \"GEOSITE,netflix,流媒体\"\n - \"GEOSITE,facebook,Meta 服务\"\n - \"DOMAIN-SUFFIX,fbcdn.net,Meta 服务\"\n - \"DOMAIN-SUFFIX,instagram.com,Meta 服务\"\n - \"DOMAIN-SUFFIX,whatsapp.net,Meta 服务\"\n - \"GEOSITE,geolocation-!cn,节点选择\"\n - \"GEOSITE,cn,DIRECT\"\n - \"GEOIP,CN,DIRECT\"\n - \"MATCH,节点选择\"\n",
"contentType": "text/yaml; charset=utf-8"
},
"raw": {
"body": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23456?encryption=none\u0026security=reality\u0026sni=www.amd.com\u0026fp=chrome\u0026pbk=FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\u0026sid=abcd0001ef01\u0026type=tcp\u0026headerType=none\u0026flow=xtls-rprx-vision#RF-Reality\nss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206Q3d3TkRnOFFFUklURkJVV0Z4Z1pHZz09OkZSWVhHQmthR3h3ZEhoOGdJU0lqSkE9PQ@entry.example.com:23458#SS-Direct\nhysteria2://FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23459?sni=tls.example.com\u0026alpn=h3#HY2\nanytls://FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23460?sni=tls.example.com#AnyTLS\nvless://11111111-2222-3333-4444-000000000001@entry.example.com:23461?encryption=none\u0026security=tls\u0026type=ws\u0026path=%2Fwspath123\u0026sni=tls.example.com#WS-TLS\nsocks5://ng%3Acus_1:FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23462#Socks\nvless://11111111-2222-3333-4444-000000000001@v6.example.com:23463?encryption=none\u0026security=reality\u0026sni=www.apple.com\u0026fp=chrome\u0026pbk=FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\u0026sid=abcd0001ef01\u0026type=tcp\u0026headerType=none#RV-Plain\n",
"contentType": "text/plain; charset=utf-8"
},
"singbox": {
"body": "{\n \"log\": {\n \"level\": \"warn\"\n },\n \"inbounds\": [\n {\n \"type\": \"mixed\",\n \"tag\": \"local\",\n \"listen\": \"127.0.0.1\",\n \"listen_port\": 2080\n }\n ],\n \"outbounds\": [\n {\n \"type\": \"selector\",\n \"tag\": \"proxy\",\n \"outbounds\": [\n \"node-1\",\n \"node-2\",\n \"node-3\",\n \"node-4\",\n \"node-5\",\n \"node-6\",\n \"node-7\"\n ]\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-1\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23456,\n \"uuid\": \"11111111-2222-3333-4444-000000000001\",\n \"flow\": \"xtls-rprx-vision\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"www.amd.com\",\n \"reality\": {\n \"enabled\": true,\n \"public_key\": \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\",\n \"short_id\": \"abcd0001ef01\"\n }\n }\n },\n {\n \"type\": \"shadowsocks\",\n \"tag\": \"node-2\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23458,\n \"method\": \"2022-blake3-aes-128-gcm\",\n \"password\": \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n },\n {\n \"type\": \"hysteria2\",\n \"tag\": \"node-3\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23459,\n \"password\": \"FRYXGBkaGxwdHh8gISIjJA==\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\",\n \"alpn\": [\n \"h3\"\n ]\n }\n },\n {\n \"type\": \"anytls\",\n \"tag\": \"node-4\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23460,\n \"password\": \"FRYXGBkaGxwdHh8gISIjJA==\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\"\n }\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-5\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23461,\n \"uuid\": \"11111111-2222-3333-4444-000000000001\",\n \"transport\": {\n \"type\": \"ws\",\n \"path\": \"/wspath123\"\n },\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\"\n }\n },\n {\n \"type\": \"socks\",\n \"tag\": \"node-6\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23462,\n \"version\": \"5\",\n \"username\": \"ng:cus_1\",\n \"password\": \"FRYXGBkaGxwdHh8gISIjJA==\"\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-7\",\n \"server\": \"v6.example.com\",\n \"server_port\": 23463,\n \"uuid\": \"11111111-2222-3333-4444-000000000001\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"www.apple.com\",\n \"reality\": {\n \"enabled\": true,\n \"public_key\": \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\",\n \"short_id\": \"abcd0001ef01\"\n }\n }\n },\n {\n \"type\": \"direct\",\n \"tag\": \"direct\"\n }\n ],\n \"route\": {\n \"final\": \"proxy\"\n }\n}\n",
"contentType": "application/json; charset=utf-8"
},
"surge": {
"body": "[General]\nloglevel = notify\n[Proxy]\nSS-Direct = ss, entry.example.com, 23458, encrypt-method=2022-blake3-aes-128-gcm, password=CwwNDg8QERITFBUWFxgZGg__:FRYXGBkaGxwdHh8gISIjJA__\nSocks = socks5, entry.example.com, 23462, ng:cus_1, FRYXGBkaGxwdHh8gISIjJA__\n[Proxy Group]\n节点选择 = select, SS-Direct, Socks, DIRECT\n自动选择 = url-test, SS-Direct, Socks, url=http://www.gstatic.com/generate_204, interval=600\n[Rule]\nGEOIP,CN,DIRECT\nFINAL,节点选择\n",
"contentType": "text/plain; charset=utf-8"
}
}
+47 -26
View File
@@ -268,14 +268,19 @@ func (d *Data) Clone() *Data {
}
type Settings struct {
BrandName string `json:"brandName"`
SessionHours float64 `json:"sessionHours"`
ObservationTtlMinutes float64 `json:"observationTtlMinutes"`
ActivityRetentionDays float64 `json:"activityRetentionDays"`
CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"`
ExternalSubRefreshMinutes float64 `json:"externalSubRefreshMinutes,omitempty"`
AllowPrivateSubSources bool `json:"allowPrivateSubSources,omitempty"`
Extra extraFields `json:"-"`
BrandName string `json:"brandName"`
SessionHours float64 `json:"sessionHours"`
ObservationTtlMinutes float64 `json:"observationTtlMinutes"`
ActivityRetentionDays float64 `json:"activityRetentionDays"`
CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"`
ExternalSubRefreshMinutes float64 `json:"externalSubRefreshMinutes,omitempty"`
AllowPrivateSubSources bool `json:"allowPrivateSubSources,omitempty"`
// Depleting-soon thresholds for the customers summary, aligned with
// 3x-ui's expireDiff/trafficDiff settings (same names, units and
// factory default of 0 = nobody counts as depleting soon).
ExpireDiff float64 `json:"expireDiff,omitempty"`
TrafficDiff float64 `json:"trafficDiff,omitempty"`
Extra extraFields `json:"-"`
}
func (s *Settings) UnmarshalJSON(raw []byte) error {
@@ -327,6 +332,7 @@ type EngineState struct {
SingBoxInstalled bool `json:"singBoxInstalled"`
Certificates []string `json:"certificates"`
SingBoxStatus string `json:"singBoxStatus"`
SingBoxVersion string `json:"singBoxVersion,omitempty"`
At string `json:"at"`
Extra extraFields `json:"-"`
}
@@ -554,9 +560,13 @@ type Customer struct {
ServerUsage map[string]*UsageSlot `json:"serverUsage,omitempty"`
LastUsageAt *string `json:"lastUsageAt,omitempty"`
LastOnlineAt *string `json:"lastOnlineAt,omitempty"`
ExternalSources []ExtSource `json:"externalSources,omitempty"`
ManualLinks []ManualLink `json:"manualLinks,omitempty"`
Extra extraFields `json:"-"`
// The customer's single credential set, shared by all of their node
// bindings (round-2 item 2). Populated by the store-load migration for
// legacy records and at creation for new ones.
Credentials *Credentials `json:"credentials,omitempty"`
ExternalSources []ExtSource `json:"externalSources,omitempty"`
ManualLinks []ManualLink `json:"manualLinks,omitempty"`
Extra extraFields `json:"-"`
}
// ExtSource is one external subscription source on a customer
@@ -693,13 +703,20 @@ func (m MeteredTraffic) MarshalJSON() ([]byte, error) {
}
type Node struct {
ID string `json:"id"`
Name string `json:"name"`
ServerID string `json:"serverId"`
ExitServerID *string `json:"exitServerId"`
Protocol string `json:"protocol"`
Topology string `json:"topology"`
ExitProtocol *string `json:"exitProtocol"`
ID string `json:"id"`
Name string `json:"name"`
ServerID string `json:"serverId"`
ExitServerID *string `json:"exitServerId"`
Protocol string `json:"protocol"`
Topology string `json:"topology"`
ExitProtocol *string `json:"exitProtocol"`
// ExitMode selects the forward exit target: "" / "managed" is a panel
// server (ExitServerID); "manual" is an unmanaged landing endpoint
// described by ExitAddress + ExitPort + ExitProtocol + ExitCredentials
// (round-2 item 8). Manual exits have no exit resource and are always
// ExitReady — the panel only builds the entry-side outbound.
ExitMode string `json:"exitMode,omitempty"`
ExitAddress string `json:"exitAddress,omitempty"`
NetworkMode string `json:"networkMode"`
RealityServerName string `json:"realityServerName"`
RealityDestPort int `json:"realityDestPort"`
@@ -775,14 +792,18 @@ func (c Credentials) MarshalJSON() ([]byte, error) {
}
type NodeClient struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
CustomerID string `json:"customerId"`
Credentials Credentials `json:"credentials"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Extra extraFields `json:"-"`
ID string `json:"id"`
NodeID string `json:"nodeId"`
CustomerID string `json:"customerId"`
// Deprecated: credentials live on the Customer (round-2 item 2). This
// field is only a migration input — the store-load migration converges
// it into Customer.Credentials and clears it, so it disappears from
// disk on the next save. Builders receive resolved copies instead.
Credentials *Credentials `json:"credentials,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Extra extraFields `json:"-"`
}
func (n *NodeClient) UnmarshalJSON(raw []byte) error {
+4 -3
View File
@@ -53,8 +53,8 @@ func TestAggregateEntriesOrderAndDedup(t *testing.T) {
},
Servers: []*Server{{ID: "srv_1", Name: "S", PublicAddress: "s.example.com"}},
NodeClients: []*NodeClient{
{ID: "ncl_b", NodeID: "nod_b", CustomerID: "cus_1", Status: "active", Credentials: Credentials{RelayPassword: "pw-b"}},
{ID: "ncl_a", NodeID: "nod_a", CustomerID: "cus_1", Status: "active", Credentials: Credentials{RelayPassword: "pw-a"}},
{ID: "ncl_b", NodeID: "nod_b", CustomerID: "cus_1", Status: "active"},
{ID: "ncl_a", NodeID: "nod_a", CustomerID: "cus_1", Status: "active"},
},
SubSourceCache: map[string]*SubSourceCacheEntry{
"src_1": {Items: []SubCacheItem{{URI: "ss://YWVzLTI1Ni1nY206cHctYQ@s.example.com:20002#Dup", Name: "Dup"}}},
@@ -63,12 +63,13 @@ func TestAggregateEntriesOrderAndDedup(t *testing.T) {
okAt := NowISO()
d.SubSourceCache["src_1"].OkAt = &okAt
customer := &Customer{
ID: "cus_1",
ID: "cus_1", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "pw-a", ShortID: "abcd0001ef01"},
ManualLinks: []ManualLink{
{ID: "lnk_1", URI: "ss://YWVzLTI1Ni1nY206cHctYQ@s.example.com:20002#Whatever", Remark: "手动一号", Enabled: true},
},
ExternalSources: []ExtSource{{ID: "src_1", URL: "https://example.com/sub", Remark: "机场", Enabled: true}},
}
d.Customers = []*Customer{customer}
entries := AggregateEntries(d, customer)
names := []string{}
for _, entry := range entries {
+182 -61
View File
@@ -1,7 +1,7 @@
'use strict';
/* amo 控制台前端。六个页面:系统状态 / 服务器 / 客户与订阅 / 转发与节点 /
安装与命令 / 设置与运维。hash 路由,45 秒自动刷新(弹窗打开时暂停)。 */
/* amo 控制台前端。五个页面:系统状态 / 节点管理 / 用户订阅 / 入站&出站 /
设置。hash 路由,45 秒自动刷新(弹窗打开时暂停)。 */
const state = {
session: null, page: 'overview', overview: null, system: null,
@@ -62,14 +62,7 @@ const statusText = {
};
const roleText = { relay: '入口 / 转发', exit: '出口 / 落地', hybrid: '综合节点' };
const topologyText = { forward: '转发线路', direct: '单机直连' };
const auditText = {
create_server: '添加设备', update_server: '编辑设备', delete_server: '删除设备', create_enrollment: '生成注册令牌', enroll_agent: 'Agent 注册',
reconcile_server: '设备配置对账', set_tls_domain: '节点证书同步', create_customer: '添加客户', update_customer: '编辑客户', reset_customer_usage: '重置客户流量', delete_customer: '删除客户',
create_node: '创建节点', update_node: '编辑节点', deploy_node: '部署节点', repair_node: '修复节点', remove_node: '停用节点', reorder_nodes: '节点排序',
delete_node: '删除节点', assign_customer: '分配客户', unassign_customer: '移除分配', suspend_customer_resources: '暂停客户资源', resume_customer_resources: '恢复客户资源',
retry_cleanup: '重试遗留清理', force_forget_server: '强制遗忘离线设备', update_account: '修改管理员账号', suspend_ip_limit: 'IP 超限停用', rotate_subscription: '重置订阅链接',
regenerate_credentials: '重新生成凭据', monthly_reset: '按月重置流量'
};
const suspendReasonText = { traffic_limit: '额度耗尽', expired: '已到期', ip_limit: '节点 IP 超限', manual: '手动停用' };
const ICONS = {
@@ -144,7 +137,6 @@ function showLogin() {
function showApp() {
$('#login').classList.add('hidden'); $('#app').classList.remove('hidden');
$('#user-chip').textContent = state.session.user.username;
$('#avatar').textContent = (state.session.user.username || '管').slice(0, 1).toUpperCase();
$('#version-label').textContent = `v${state.version}`;
}
@@ -197,6 +189,38 @@ function showPop(anchor, html) {
}
function hidePop() { const pop = $('#float-pop'); pop.classList.add('hidden'); pop.innerHTML = ''; pop._anchor = null; }
/* ---------- 图标悬停提示(data-tip,3x-ui 式气泡,挂 body 防裁剪) ---------- */
let tipBubble = null;
let tipTarget = null;
function showTip(el) {
const text = el.getAttribute('data-tip');
if (!text) return;
if (!tipBubble) {
tipBubble = document.createElement('div');
tipBubble.className = 'tip-bubble';
document.body.appendChild(tipBubble);
}
tipTarget = el;
tipBubble.textContent = text;
tipBubble.classList.add('show');
const rect = el.getBoundingClientRect();
const bw = tipBubble.offsetWidth;
const bh = tipBubble.offsetHeight;
let left = rect.left + rect.width / 2 - bw / 2;
left = Math.max(6, Math.min(left, window.innerWidth - bw - 6));
tipBubble.style.left = `${left}px`;
tipBubble.style.top = `${Math.max(4, rect.top - bh - 9)}px`;
tipBubble.style.setProperty('--tip-arrow-left', `${rect.left + rect.width / 2 - left}px`);
}
function hideTip() { tipTarget = null; if (tipBubble) tipBubble.classList.remove('show'); }
document.addEventListener('mouseover', (event) => {
const el = event.target && event.target.closest ? event.target.closest('[data-tip]') : null;
if (el === tipTarget) return;
if (el) showTip(el); else hideTip();
});
document.addEventListener('mousedown', hideTip);
window.addEventListener('scroll', hideTip, true);
/* ---------- 分页 ---------- */
function pageSlice(items, key) {
const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
@@ -221,20 +245,19 @@ async function load(page = state.page) {
if (page === 'overview') {
const [overview, system, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/system'), api('/api/servers'), api('/api/customers'), api('/api/nodes')]);
state.overview = overview; state.system = system; state.version = overview.version || state.version;
state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
$('#version-label').textContent = `v${state.version}`;
} else if (page === 'servers') {
const [servers, routes] = await Promise.all([api('/api/servers'), api('/api/nodes')]);
state.servers = servers.servers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
} else if (page === 'customers') {
const [customers, routes] = await Promise.all([api('/api/customers'), api('/api/nodes')]);
state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
} else if (page === 'chains') {
const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/nodes'), api('/api/protocols')]);
state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || [];
} else if (page === 'commands') { /* 静态页 */ }
else if (page === 'operations') {
} else if (page === 'operations') {
const [jobs, overview] = await Promise.all([api('/api/jobs'), api('/api/overview')]);
state.jobs = jobs.jobs; state.overview = overview; state.version = overview.version || state.version;
$('#version-label').textContent = `v${state.version}`;
@@ -243,8 +266,8 @@ async function load(page = state.page) {
} catch (error) { toast(error.message, true); }
}
const PAGES = ['overview', 'servers', 'customers', 'chains', 'commands', 'operations'];
const TITLES = { overview: '系统状态', servers: '服务器', customers: '客户与订阅', chains: '转发与节点', commands: '安装与命令', operations: '设置与运维' };
const PAGES = ['overview', 'servers', 'customers', 'chains', 'operations'];
const TITLES = { overview: '系统状态', servers: '节点管理', customers: '用户订阅', chains: '入站&出站', operations: '设置' };
function pageFromHash() { const page = location.hash.replace(/^#/, '').split('?')[0]; return PAGES.includes(page) ? page : 'overview'; }
function setPage(page) {
if (!PAGES.includes(page)) page = 'overview';
@@ -339,7 +362,6 @@ function renderOverview() {
const meanUp = history.length ? history.reduce((a, p) => a + (p.upBps || 0), 0) / history.length : 0;
const meanDown = history.length ? history.reduce((a, p) => a + (p.downBps || 0), 0) / history.length : 0;
const degraded = (state.overview && state.overview.degraded) || [];
const events = ((state.overview && state.overview.activity) || []).map((event) => `<div class="event"><i class="event-dot"></i><div><b>${esc(auditText[event.action] || event.action)}</b> <small>${esc(event.actor)} · ${esc(event.target)}</small></div><time>${fmtDate(event.at)}</time></div>`).join('');
return `<section class="stat-grid">${cards}</section>
<section class="mid-grid">
<article class="panel chart-card">
@@ -370,8 +392,7 @@ function renderOverview() {
<div class="seg"><div class="cap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.7 3.8 9S14.5 18.4 12 21c-2.5-2.6-3.8-5.7-3.8-9S9.5 5.6 12 3z"/></svg>IP 地址</div>
<div class="kv"><div class="item"><span class="k">IPv4</span><span class="v mono">${esc((sys.ips && sys.ips.ipv4) || '—')}</span></div><div class="item"><span class="k">IPv6</span><span class="v mono">${esc((sys.ips && sys.ips.ipv6) || '—')}</span></div></div></div>
</section>
${degraded.length ? `<div class="notice">${degraded.length} 个节点异常:${degraded.map((n) => esc(n.name)).join('、')}。到「转发与节点」查看并修复。</div>` : ''}
<section class="panel"><div class="panel-head"><h2>最近活动</h2></div><div class="activity-list">${events || '<div class="empty"><b>暂无活动</b></div>'}</div></section>`;
${degraded.length ? `<div class="notice">${degraded.length} 个节点异常:${degraded.map((n) => esc(n.name)).join('、')}。到「入站&出站」查看并修复。</div>` : ''}`;
}
/* ---------- 服务器 ---------- */
@@ -401,7 +422,7 @@ function serverDetailRow(server) {
const tls = server.tlsDomain ? `${esc(server.tlsDomain)} · ${server.engine && (server.engine.certificates || []).includes(server.tlsDomain) ? '已确认' : '待确认'}` : '未设置';
const engine = server.role === 'exit' ? '—' : (server.engine ? (server.engine.singBoxInstalled ? '已安装' : '按需安装(ng-agent engine install)') : '未上报');
const usage = server.usage ? `${fmtDate(server.usage.lastReportAt)} · ${server.usage.sampleCount} 项${server.usage.error ? ` · <span class="v err">异常:${esc(server.usage.error)}</span>` : ''}` : '尚无上报';
return `<tr class="detail-row"><td colspan="15"><div class="detail-grid">
return `<tr class="detail-row"><td colspan="14"><div class="detail-grid">
<div><span class="k">资源余量</span><span class="v">${resources}</span></div>
<div><span class="k">TLS 证书(${esc(server.tlsDomain || '—')})</span><span class="v">${tls}</span></div>
<div><span class="k">引擎状态</span><span class="v">${engine}</span></div>
@@ -424,7 +445,6 @@ function renderServers() {
<div class="stat"><div class="lab">设备总数</div><div class="val"><svg class="ic-dim" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>${state.servers.length}</div></div>
<div class="stat"><div class="lab">在线</div><div class="val"><svg class="ic-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8.5 12.5l2.5 2.5 4.5-5.5"/></svg>${onlineCount}</div></div>
<div class="stat"><div class="lab">离线</div><div class="val"><svg class="ic-red" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M9 9l6 6M15 9l-6 6"/></svg>${state.servers.length - onlineCount}</div></div>
<div class="stat"><div class="lab">平均延迟</div><div class="val"><svg class="ic-bolt" viewBox="0 0 24 24" fill="currentColor"><path d="M13 2L4.5 13.5H11L9.5 22 19 10h-6.5L13 2z"/></svg>—</div></div>
</section>`;
const filtered = state.servers.filter((item) => [item.name, item.region, item.publicAddress, item.publicAddressV6, ...(item.labels || [])].join(' ').toLowerCase().includes(q));
const { pageItems } = pageSlice(filtered, 'servers');
@@ -438,7 +458,7 @@ function renderServers() {
const row = `<tr>
<td><button class="exp" type="button" data-action="expand-server" data-id="${esc(server.id)}">${expanded ? '−' : '+'}</button></td>
<td><input type="checkbox" data-row-check></td>
<td><div class="op-cell"><button class="icon-btn" title="编辑" data-action="edit-server" data-id="${esc(server.id)}">${ICONS.edit}</button><button class="icon-btn" title="复制注册命令" data-action="enroll-server" data-id="${esc(server.id)}">${ICONS.copy}</button><button class="icon-btn del" title="删除" data-action="delete-server" data-id="${esc(server.id)}">${ICONS.trash}</button></div></td>
<td><div class="op-cell"><button class="icon-btn" data-tip="编辑" data-action="edit-server" data-id="${esc(server.id)}">${ICONS.edit}</button><button class="icon-btn" data-tip="复制注册命令" data-action="enroll-server" data-id="${esc(server.id)}">${ICONS.copy}</button><button class="icon-btn del" data-tip="删除" data-action="delete-server" data-id="${esc(server.id)}">${ICONS.trash}</button></div></td>
<td><label class="tgl"><input type="checkbox" data-toggle-server data-id="${esc(server.id)}" ${server.enabled !== false ? 'checked' : ''}><span class="tr"></span></label></td>
<td><span class="dev-name">${esc(server.name)}</span></td>
<td><span class="addr">${addr}</span></td>
@@ -446,20 +466,19 @@ function renderServers() {
<td><span class="num">${cpuPct == null ? '—' : `${cpuPct.toFixed(1)}%`}</span></td>
<td><span class="num">${memPct == null ? '—' : `${memPct.toFixed(1)}%`}</span></td>
<td><span class="ver-cell"><span class="ver">${esc(server.agentVersion || '—')}</span>${server.agentVersion && versionLessThan(server.agentVersion, state.version) ? '<span class="badge orange">有可用更新</span>' : ''}</span></td>
<td><span class="ver">—</span></td>
<td><span class="ver">${esc(server.engine && server.engine.singBoxVersion ? server.engine.singBoxVersion : '—')}</span></td>
<td><span class="num">${system.uptimeSeconds ? fmtUptime(system.uptimeSeconds) : '—'}</span></td>
<td><span class="clients-cell">${ICONS.users}${clients.total}<span class="on-n">${clients.online}</span></span></td>
<td><span class="num">—</span></td>
<td><span class="hb">${server.lastSeenAt ? relTime(server.lastSeenAt) : '等待注册'}</span></td></tr>`;
return expanded ? row + serverDetailRow(server) : row;
}).join('');
return `${stats}
<div class="page-tools"><button class="btn primary" data-action="add-server">+ 添加设备</button><input class="search" data-search placeholder="搜索设备名、地址" value="${esc(state.search)}"></div>
<section class="panel"><div class="table-wrap"><table class="w-servers"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>操作</th><th>已启用</th><th>名称</th><th>地址<button class="icon-btn eye-btn" type="button" data-action="toggle-addr" title="显示 / 隐藏地址">${state.addrVisible ? ICONS.eyeOff : ICONS.eye}</button></th><th>状态</th><th>CPU</th><th>内存</th><th>Agent 版本</th><th>核心版本</th><th>运行时长</th><th>客户端</th><th>延迟</th><th>上次心跳</th></tr></thead>
<tbody>${rows || emptyRow(15, '没有匹配设备')}</tbody></table></div>${pagerHTML('servers', filtered.length)}</section>`;
<section class="panel"><div class="table-wrap"><table class="w-servers"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>操作</th><th>已启用</th><th>名称</th><th>地址<button class="icon-btn eye-btn" type="button" data-action="toggle-addr" data-tip="显示 / 隐藏地址">${state.addrVisible ? ICONS.eyeOff : ICONS.eye}</button></th><th>状态</th><th>CPU</th><th>内存</th><th>Agent 版本</th><th>核心版本</th><th>运行时长</th><th>客户端</th><th>上次心跳</th></tr></thead>
<tbody>${rows || emptyRow(14, '没有匹配设备')}</tbody></table></div>${pagerHTML('servers', filtered.length)}</section>`;
}
/* ---------- 客户与订阅 ---------- */
/* ---------- 用户订阅 ---------- */
function protocolFamily(protocol) {
const id = String(protocol || '');
if (id.startsWith('vless')) return { cls: '', label: 'VLESS' };
@@ -513,7 +532,7 @@ function renderCustomers() {
const speedTotal = (item.speedUpBps || 0) + (item.speedDownBps || 0);
const manualOff = item.status === 'suspended' && item.suspendReason === 'manual';
return `<tr>
<td><div class="op-cell"><input type="checkbox" data-row-check><button class="icon-btn" title="订阅二维码" data-action="customer-subscription" data-id="${esc(item.id)}">${ICONS.qr}</button><button class="icon-btn" title="详情" data-action="customer-access" data-id="${esc(item.id)}">${ICONS.info}</button><button class="icon-btn" title="重置流量" data-action="reset-usage" data-id="${esc(item.id)}">${ICONS.reset}</button><button class="icon-btn" title="编辑" data-action="edit-customer" data-id="${esc(item.id)}">${ICONS.edit}</button><button class="icon-btn del" title="删除" data-action="delete-customer" data-id="${esc(item.id)}">${ICONS.trash}</button></div></td>
<td><div class="op-cell"><input type="checkbox" data-row-check><button class="icon-btn" data-tip="二维码" data-action="customer-subscription" data-id="${esc(item.id)}">${ICONS.qr}</button><button class="icon-btn" data-tip="详情" data-action="customer-access" data-id="${esc(item.id)}">${ICONS.info}</button><button class="icon-btn" data-tip="重置流量" data-action="reset-usage" data-id="${esc(item.id)}">${ICONS.reset}</button><button class="icon-btn" data-tip="编辑" data-action="edit-customer" data-id="${esc(item.id)}">${ICONS.edit}</button><button class="icon-btn del" data-tip="删除" data-action="delete-customer" data-id="${esc(item.id)}">${ICONS.trash}</button></div></td>
<td><label class="tgl"><input type="checkbox" data-toggle-customer data-id="${esc(item.id)}" ${manualOff ? '' : 'checked'}><span class="tr"></span></label></td>
<td><span class="pres ${item.online ? 'on' : 'off'}"><span class="dot ${item.online ? 'on' : 'off'}"></span>${item.online ? '在线' : '离线'}</span>${item.status === 'suspended' ? `<span class="paused-note">已暂停 · ${esc(suspendReasonText[item.suspendReason] || '待恢复')}</span>` : ''}</td>
<td><span class="cust-name">${esc(item.name)}</span>${item.notes ? `<span class="cust-note">${esc(item.notes)}</span>` : '<span class="cust-note"></span>'}</td>
@@ -523,10 +542,20 @@ function renderCustomers() {
<td>${remainingPill(item)}</td>
<td>${durationPill(item)}</td></tr>`;
}).join('');
return `<div class="page-tools"><button class="btn primary" data-action="add-customer">+ 添加客户</button>
const sum = state.customerSummary;
const statCell = (lab, cls, svg, val) => `<div class="stat"><div class="lab">${lab}</div><div class="val"><svg class="${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${svg}</svg>${val}</div></div>`;
const custStats = sum ? `<section class="stats six">
${statCell('客户端', 'ic-dim', '<circle cx="9" cy="8" r="3.5"/><path d="M3.5 19c.6-3 2.8-4.5 5.5-4.5s4.9 1.5 5.5 4.5"/><path d="M16 5.5a3.5 3.5 0 010 6.5M17.5 14.7c1.7.6 2.7 1.9 3 4.3"/>', sum.total)}
${statCell('在线', 'ic-green', '<path d="M3 12h4l2.5-6 4 12L16 12h5"/>', sum.online)}
${statCell('耗尽', 'ic-red', '<rect x="3" y="8" width="15" height="8" rx="2"/><path d="M21 11v2"/><path d="M6.5 11v2"/>', sum.depleted)}
${statCell('即将耗尽', 'ic-bolt', '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', sum.depletingSoon)}
${statCell('关闭', 'ic-red', '<path d="M12 3v8"/><path d="M6.3 6.5a8 8 0 1 0 11.4 0"/>', sum.disabled)}
${statCell('启用', 'ic-green', '<circle cx="12" cy="12" r="9"/><path d="M8.5 12.5l2.5 2.5 4.5-5.5"/>', sum.enabled)}
</section>` : '';
return `${custStats}<div class="page-tools"><button class="btn primary" data-action="add-customer">+ 添加客户</button>
<div class="toolbar"><input class="search" data-search placeholder="搜索客户、备注、分组或标签" value="${esc(state.search)}">
<select class="search" data-group-filter><option value="">全部分组</option>${groups.map((g) => `<option value="${esc(g)}"${selected(g, state.groupFilter)}>${esc(g)}</option>`).join('')}</select>
${filterNode ? `<span class="filter-chip">节点:${esc(filterNode.name)}<button type="button" data-action="clear-node-filter" title="清除">×</button></span>` : ''}
${filterNode ? `<span class="filter-chip">节点:${esc(filterNode.name)}<button type="button" data-action="clear-node-filter" data-tip="清除">×</button></span>` : ''}
<span class="badge blue">${filtered.length} 位客户</span></div></div>
<section class="panel"><div class="table-wrap"><table class="w-customers"><thead><tr><th>操作</th><th>已启用</th><th>在线</th><th>客户端</th><th>关联入站</th><th>流量<span class="sarr">⇅</span></th><th class="ctr">速度</th><th>剩余<span class="sarr">⇅</span></th><th>时长<span class="sarr">⇅</span></th></tr></thead>
<tbody>${rows || emptyRow(9, '还没有客户')}</tbody></table></div>${pagerHTML('customers', filtered.length)}</section>`;
@@ -589,7 +618,7 @@ function refreshPicker() {
function credRowHTML(label, value) {
return `<div class="cred-row"><span class="k">${esc(label)}</span><span class="v">${esc(value || '—')}</span>
${value ? `<button type="button" class="icon-btn" title="复制" data-action="copy-text" data-value="${esc(value)}">${ICONS.copy}</button>` : ''}</div>`;
${value ? `<button type="button" class="icon-btn" data-tip="复制" data-action="copy-text" data-value="${esc(value)}">${ICONS.copy}</button>` : ''}</div>`;
}
function renderCredentialsPane(customer) {
const pane = $('#tab-credentials');
@@ -597,19 +626,22 @@ function renderCredentialsPane(customer) {
if (!customer) { pane.innerHTML = '<div class="empty"><b>创建客户后生成凭据</b></div>'; return; }
const data = customerFormState.credentials;
if (!data) { pane.innerHTML = '<div class="empty"><b>加载中…</b></div>'; return; }
const groups = data.items.map((entry) => `<div class="cred-group">
<div class="g-head">${esc(entry.nodeName)}<span class="badge ${entry.status === 'active' ? 'green' : 'gray'}">${esc(statusText[entry.status] || entry.status)}</span><span class="mut" style="font-weight:400;font-size:12px">${esc(entry.protocol)}</span>
<button type="button" class="icon-btn" style="margin-left:auto" title="重新生成凭据" data-action="regen-credential" data-ncid="${esc(entry.nodeClientId)}" data-node="${esc(entry.nodeName)}">${ICONS.reset}</button></div>
${credRowHTML('UUID', entry.clientId)}
${credRowHTML('密码', entry.password)}
${entry.shortId ? credRowHTML('Reality Short ID', entry.shortId) : ''}
const creds = data.credentials || {};
const bindings = (data.bindings || []).map((entry) => `<div class="cred-group">
<div class="g-head">${esc(entry.nodeName)}<span class="badge ${entry.status === 'active' ? 'green' : 'gray'}">${esc(statusText[entry.status] || entry.status)}</span><span class="mut" style="font-weight:400;font-size:12px">${esc(entry.protocol)}</span></div>
${entry.username ? credRowHTML('用户名', entry.username) : ''}
${entry.realityPublicKey ? credRowHTML('Reality 公钥', entry.realityPublicKey) : ''}
</div>`).join('');
pane.innerHTML = `<div class="cred-group"><div class="g-head">订阅</div>
<div class="cred-row"><span class="k">订阅令牌</span><span class="v">${esc(data.subscriptionToken || '—')}</span>
<button type="button" class="icon-btn" title="复制" data-action="copy-text" data-value="${esc(data.subscriptionToken || '')}">${ICONS.copy}</button>
<button type="button" class="icon-btn" title="重置订阅令牌" data-action="rotate-subscription" data-id="${esc(customer.id)}">${ICONS.reset}</button></div></div>
${groups || '<div class="empty"><b>尚未关联节点</b></div>'}`;
<button type="button" class="icon-btn" data-tip="复制" data-action="copy-text" data-value="${esc(data.subscriptionToken || '')}">${ICONS.copy}</button>
<button type="button" class="icon-btn" data-tip="重置订阅令牌" data-action="rotate-subscription" data-id="${esc(customer.id)}">${ICONS.reset}</button></div></div>
<div class="cred-group"><div class="g-head">凭据(全部节点共用)<button type="button" class="icon-btn" style="margin-left:auto" data-tip="重新生成凭据" data-action="regen-credentials" data-id="${esc(customer.id)}">${ICONS.reset}</button></div>
${credRowHTML('UUID', creds.clientId)}
${credRowHTML('密码', creds.password)}
${credRowHTML('Reality Short ID', creds.shortId)}
</div>
${bindings || '<div class="empty"><b>尚未关联节点</b></div>'}`;
}
function linkStatusLine(customer, source) {
@@ -628,12 +660,12 @@ function renderLinksPane(customer) {
<div class="row"><input type="text" data-link-uri placeholder="节点链接(vless:// / ss:// / hysteria2:// / anytls:// / socks5://)" value="${esc(link.uri || '')}">
<input type="text" class="remark" data-link-remark placeholder="备注" value="${esc(link.remark || '')}">
<label class="tgl"><input type="checkbox" data-link-enabled ${link.enabled !== false ? 'checked' : ''}><span class="tr"></span></label>
<button type="button" class="icon-btn del" title="删除" data-action="remove-manual-link" data-index="${index}">${ICONS.trash}</button></div></div>`).join('');
<button type="button" class="icon-btn del" data-tip="删除" data-action="remove-manual-link" data-index="${index}">${ICONS.trash}</button></div></div>`).join('');
const sources = customerFormState.externalSources.map((source, index) => `<div class="link-item" data-source-item="${index}">
<div class="row"><input type="text" data-source-url placeholder="订阅地址(https://…)" value="${esc(source.url || '')}">
<input type="text" class="remark" data-source-remark placeholder="备注" value="${esc(source.remark || '')}">
<label class="tgl"><input type="checkbox" data-source-enabled ${source.enabled !== false ? 'checked' : ''}><span class="tr"></span></label>
<button type="button" class="icon-btn del" title="删除" data-action="remove-ext-source" data-index="${index}">${ICONS.trash}</button></div>
<button type="button" class="icon-btn del" data-tip="删除" data-action="remove-ext-source" data-index="${index}">${ICONS.trash}</button></div>
<div class="row"><input type="text" data-source-ua placeholder="User-Agent(可选)" value="${esc(source.userAgent || '')}">
<span class="status-line" data-source-status="${esc(source.id || '')}">${linkStatusLine(customer, source)}</span>
${source.id ? `<button type="button" class="btn small" data-action="refresh-ext-source" data-sid="${esc(source.id)}">立即刷新</button>` : ''}</div></div>`).join('');
@@ -799,7 +831,7 @@ function uninstallModal() {
<div class="form-actions"><button type="button" class="btn" data-close>关闭</button></div></div>`);
}
/* ---------- 转发与节点 ---------- */
/* ---------- 入站&出站 ---------- */
function protoBadges(node) {
const fam = (id) => {
const v = String(id || '');
@@ -837,10 +869,10 @@ function renderNodes() {
const enabled = !['draft', 'removing'].includes(node.status);
const busy = ['deploying', 'removing'].includes(node.status);
return `<tr data-node-row="${esc(node.id)}">
<td><span class="grip" title="拖动排序" data-grip>${ICONS.grip}</span></td>
<td><span class="grip" data-tip="拖动排序" data-grip>${ICONS.grip}</span></td>
<td><input type="checkbox" data-row-check></td>
<td class="num">${(node.sortIndex != null ? node.sortIndex : index) + 1}</td>
<td class="menu-td"><button class="icon-btn" title="编辑" data-action="edit-node" data-id="${esc(node.id)}">${ICONS.edit}</button><button class="icon-btn" title="更多" data-action="node-menu" data-id="${esc(node.id)}">${ICONS.dots}</button></td>
<td class="menu-td"><button class="icon-btn" data-tip="编辑" data-action="edit-node" data-id="${esc(node.id)}">${ICONS.edit}</button><button class="icon-btn" data-tip="更多" data-action="node-menu" data-id="${esc(node.id)}">${ICONS.dots}</button></td>
<td><label class="tgl"><input type="checkbox" data-toggle-node data-id="${esc(node.id)}" ${enabled ? 'checked' : ''} ${busy ? 'disabled' : ''}><span class="tr"></span></label></td>
<td><span class="node-name">${esc(node.name)}</span>${node.error ? `<div class="paused-note" title="${esc(node.error)}">${esc(String(node.error).slice(0, 40))}</div>` : ''}</td>
<td><span class="srv-chip ${chipCls}">${esc(server ? server.name : '已删除')}</span></td>
@@ -851,9 +883,9 @@ function renderNodes() {
<td>${speed > 0 ? `<span class="pill">↑ ${fmtSpeed(node.speedUpBps)} / ↓ ${fmtSpeed(node.speedDownBps)}</span>` : '<span class="pill mut">—</span>'}</td>
<td><span class="pill">∞</span></td></tr>`;
}).join('');
return `<div class="page-tools"><button class="btn primary" data-action="add-node">+ 新建节点</button>
return `<div class="page-tools"><button class="btn primary" data-action="add-node">+ 添加入站</button>
<div class="toolbar"><input class="search" data-search placeholder="搜索节点、服务器或协议" value="${esc(state.search)}">
<span class="badge blue">${state.nodes.length} 个节点</span><span class="badge green">在线 ${active}</span>${degraded ? `<span class="badge orange">降级 ${degraded}</span>` : ''}</div></div>
<span class="badge blue">${state.nodes.length} 个入站</span><span class="badge green">在线 ${active}</span>${degraded ? `<span class="badge orange">降级 ${degraded}</span>` : ''}</div></div>
<section class="panel"><div class="table-wrap visible"><table class="w-nodes"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>ID<span class="sarr">⇅</span></th><th>菜单</th><th>启用</th><th>备注</th><th>节点</th><th>端口<span class="sarr">⇅</span></th><th>协议</th><th>客户端</th><th>流量<span class="sarr">⇅</span></th><th>速度</th><th>到期时间</th></tr></thead>
<tbody>${rows || emptyRow(13, '还没有节点')}</tbody></table></div>${pagerHTML('nodes', filtered.length)}</section>`;
}
@@ -864,7 +896,7 @@ function protocolOptions(role, current) {
function nodeForm(item = null) {
const node = item || { topology: 'forward', protocol: 'vless-reality-vision', exitProtocol: 'shadowsocks-2022-aes128', portMode: 'random', exitPortMode: 'random', networkMode: 'ipv4', realityServerName: 'www.tesla.com', realityDestPort: 443 };
const preset = state.realityPresets.find((entry) => entry.serverName === node.realityServerName) || { id: 'custom' };
openModal(item ? '编辑节点' : '新建节点', `<form id="node-form" class="form-grid"><input type="hidden" name="resourceId" value="${esc(item ? item.id : '')}">
openModal(item ? '编辑节点' : '新建节点', `<form id="node-form" class="form-grid" data-exit-mode="${node.exitMode === 'manual' ? 'manual' : 'managed'}"><input type="hidden" name="resourceId" value="${esc(item ? item.id : '')}">
<label class="wide">节点名称<input name="name" value="${esc(node.name || '')}" placeholder="新加坡入口 01" required></label>
<label>服务器<select name="serverId" required>${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id, node.serverId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
<label>网络栈<select name="networkMode"><option value="ipv4"${selected('ipv4', node.networkMode || 'ipv4')}>IPv4</option><option value="ipv6"${selected('ipv6', node.networkMode)}>IPv6</option><option value="dual"${selected('dual', node.networkMode)}>双栈监听</option></select></label>
@@ -874,9 +906,15 @@ function nodeForm(item = null) {
<label data-reality-only>Reality SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(node.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(node.realityDestPort || 443)}" min="1" max="65535"></div></label>
<label>拓扑<select name="topology" data-node-sync><option value="forward"${selected('forward', node.topology || 'forward')}>转发(入口 → 出口)</option><option value="direct"${selected('direct', node.topology)}>单机直连(无需出口)</option></select></label>
<div class="section-title" data-forward-only>出口与转发传输</div>
<label data-forward-only>出口 / 落地设备<select name="exitServerId">${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id, node.exitServerId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
<div class="wide" data-forward-only><div class="segctl" id="exit-mode-seg"><button type="button" data-action="exit-mode" data-mode="managed">受管设备</button><button type="button" data-action="exit-mode" data-mode="manual">手动落地端点</button></div></div>
<label class="wide" data-forward-only data-manual-only>从链接导入<input data-exit-link placeholder="粘贴 vless:// / ss:// / socks5:// 链接,自动填入下方字段"></label>
<label data-forward-only data-managed-only>出口 / 落地设备<select name="exitServerId">${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id, node.exitServerId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
<label data-forward-only data-manual-only>落地地址<input name="exitAddress" value="${esc(node.exitAddress || '')}" placeholder="IP 或域名"></label>
<label data-forward-only>入口到出口协议<select name="exitProtocol" data-node-sync>${protocolOptions('exit-transport', node.exitProtocol)}</select></label>
<label data-forward-only>出口端口<div class="inline-fields"><select name="exitPortMode" data-node-sync><option value="random"${selected('random', node.exitPortMode)}>范围内随机</option><option value="fixed"${selected('fixed', node.exitPortMode)}>固定端口</option></select><input name="exitPort" type="number" value="${esc(node.exitPort || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
<label data-forward-only data-manual-only data-exit-cred="vless">VLESS 用户 ID<input name="exitClientId" placeholder="${item ? '留空保持不变' : '链接中的 UUID'}"></label>
<label data-forward-only data-manual-only data-exit-cred="socksuser">SOCKS5 用户名<input name="exitSocksUser" placeholder="${item ? '留空保持不变' : ''}"></label>
<label data-forward-only data-manual-only data-exit-cred="password">落地密码<input name="exitPassword" placeholder="${item ? '留空保持不变' : ''}"></label>
<div class="form-actions wide"><button type="button" class="btn" data-close>取消</button><button class="btn primary" type="submit">${item ? '保存修改' : '创建节点'}</button></div></form>`, true);
syncNodeForm();
}
@@ -894,6 +932,77 @@ function syncNodeForm() {
form.elements[port].disabled = !fixed;
form.elements[port].required = fixed;
}
// Manual landing endpoint (round-2 item 8): swap the managed exit-server
// picker for address + credential fields; the port is always explicit.
const exitManual = !direct && (form.dataset.exitMode || 'managed') === 'manual';
$$('[data-managed-only]', form).forEach((element) => element.classList.toggle('hidden', direct || exitManual));
$$('[data-manual-only]', form).forEach((element) => element.classList.toggle('hidden', !exitManual));
$$('#exit-mode-seg button', form).forEach((button) => button.classList.toggle('active', button.dataset.mode === (form.dataset.exitMode || 'managed')));
form.elements.exitServerId.required = !direct && !exitManual;
if (form.elements.exitAddress) form.elements.exitAddress.required = exitManual;
const exitProtocol = form.elements.exitProtocol.value || '';
const credFor = { vless: exitProtocol === 'vless-tcp', socksuser: exitProtocol === 'socks5-auth', password: exitProtocol === 'socks5-auth' || exitProtocol.startsWith('shadowsocks') };
$$('[data-exit-cred]', form).forEach((element) => element.classList.toggle('hidden', !exitManual || !credFor[element.dataset.exitCred]));
if (exitManual) {
form.elements.exitPortMode.disabled = true;
form.elements.exitPort.disabled = false;
form.elements.exitPort.required = true;
}
}
const EXIT_LINK_METHODS = { '2022-blake3-aes-128-gcm': 'shadowsocks-2022-aes128', '2022-blake3-aes-256-gcm': 'shadowsocks-2022-aes256', 'aes-128-gcm': 'shadowsocks-aes128-gcm', 'aes-256-gcm': 'shadowsocks-aes256-gcm' };
function parseExitLink(text) {
const link = String(text || '').trim();
if (!link) return null;
try {
if (link.startsWith('vless://')) {
const url = new URL(link);
if (!url.hostname || !url.port || !url.username) return null;
return { protocol: 'vless-tcp', address: url.hostname, port: Number(url.port), clientId: decodeURIComponent(url.username) };
}
if (link.startsWith('ss://')) {
const rest = link.slice(5).split('#')[0];
const at = rest.lastIndexOf('@');
if (at < 0) return null;
let userinfo = rest.slice(0, at);
const hostport = rest.slice(at + 1).split('?')[0].split('/')[0];
if (!userinfo.includes(':')) {
userinfo = atob(userinfo.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(userinfo.length / 4) * 4, '='));
} else {
userinfo = decodeURIComponent(userinfo);
}
const sep = userinfo.indexOf(':');
if (sep < 0) return null;
const protocol = EXIT_LINK_METHODS[userinfo.slice(0, sep)];
if (!protocol) return null;
const hostMatch = hostport.match(/^\[(.+)\]:(\d+)$/) || hostport.match(/^(.+):(\d+)$/);
if (!hostMatch) return null;
// The password is everything after "method:" — for SS2022 links that
// is the combined serverKey:userKey string, which is exactly what the
// entry outbound sends.
return { protocol, address: hostMatch[1], port: Number(hostMatch[2]), password: userinfo.slice(sep + 1) };
}
if (link.startsWith('socks5://') || link.startsWith('socks://')) {
const url = new URL(link.replace(/^socks:\/\//, 'socks5://'));
if (!url.hostname || !url.port) return null;
return { protocol: 'socks5-auth', address: url.hostname, port: Number(url.port), socksUser: decodeURIComponent(url.username), password: decodeURIComponent(url.password) };
}
} catch { return null; }
return null;
}
function applyExitLink(input) {
const form = $('#node-form'); if (!form) return;
const parsed = parseExitLink(input.value);
if (!parsed) { toast('无法识别该链接', true); return; }
form.dataset.exitMode = 'manual';
form.elements.exitAddress.value = parsed.address;
form.elements.exitPort.value = parsed.port;
form.elements.exitProtocol.value = parsed.protocol;
if (parsed.clientId) form.elements.exitClientId.value = parsed.clientId;
if (parsed.socksUser) form.elements.exitSocksUser.value = parsed.socksUser;
if (parsed.password) form.elements.exitPassword.value = parsed.password;
syncNodeForm();
toast('已从链接填入落地端点');
}
function nodeClientsModal(node) {
const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id);
@@ -917,8 +1026,8 @@ function nodeMenuHTML(node) {
return items.join('');
}
/* ---------- 安装与命令 ---------- */
function renderCommands() {
/* ---------- 设置(命令速查已合并至此页) ---------- */
function commandsHTML() {
const groups = [
['面板机', [['更新控制面', 'ng update'], ['检查面板 HTTPS', 'ng cert'], ['备份面板', 'ng backup']]],
['入口机 / 中转机', [['更新 Agent', 'ng-agent update'], ['申请或续签节点证书', 'ng-agent cert'], ['安装 sing-box 引擎', 'ng-agent engine install'], ['检查证书、引擎与双向计数', 'ng-agent doctor']]],
@@ -927,7 +1036,6 @@ function renderCommands() {
return `<div class="command-grid">${groups.map(([title, commands]) => `<section class="panel"><div class="panel-head"><h2>${esc(title)}</h2></div><div class="panel-body">${commands.map(([label, command]) => `<div class="command-item"><div><b>${esc(label)}</b><code>${esc(command)}</code></div><button type="button" class="btn small" data-action="copy-text" data-value="${esc(command)}">复制命令</button></div>`).join('')}</div></section>`).join('')}</div>`;
}
/* ---------- 设置与运维 ---------- */
function renderOperations() {
const rows = state.jobs.slice(0, 100).map((job) => `<tr><td><b>${esc(job.action === 'apply_resource' ? '应用资源' : '移除资源')}</b><div class="mut" style="font-size:12px">${esc(job.id)}</div></td><td>${esc((serverById(job.serverId) || {}).name || job.serverId)}</td><td>${esc(statusText[job.status] || job.status)}</td><td>${job.attempts}</td><td>${fmtDate(job.updatedAt)}</td><td><span class="mut">${esc(job.error || '')}</span></td></tr>`).join('');
return `<section class="grid-3">
@@ -946,11 +1054,12 @@ function renderOperations() {
<div class="health-row"><div><b>Caddy HTTPS</b><span>证书自动申请与续签</span></div><span class="badge green">运行中</span></div>
<div class="codebox" style="margin-top:10px">ng update\nng domain new.example.com\nng cert</div></div></article>
</section>
<section class="panel"><div class="panel-head"><h2>任务队列</h2></div><div class="table-wrap"><table><thead><tr><th>任务</th><th>设备</th><th>状态</th><th>尝试</th><th>更新时间</th><th>错误</th></tr></thead><tbody>${rows || emptyRow(6, '任务队列为空')}</tbody></table></div></section>`;
${commandsHTML()}
<section class="panel"><div class="panel-head"><h2>任务队列</h2></div><div class="table-wrap"><table class="w-jobs"><thead><tr><th>任务</th><th>设备</th><th>状态</th><th>尝试</th><th>更新时间</th><th>错误</th></tr></thead><tbody>${rows || emptyRow(6, '任务队列为空')}</tbody></table></div></section>`;
}
function render() {
const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, commands: renderCommands, operations: renderOperations };
const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, operations: renderOperations };
$('#content').innerHTML = views[state.page]();
}
@@ -995,7 +1104,15 @@ document.addEventListener('submit', async (event) => {
closeModal(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers');
} else if (form.id === 'node-form') {
const resourceId = data.get('resourceId');
const payload = { name: data.get('name'), serverId: data.get('serverId'), protocol: data.get('protocol'), portMode: data.get('portMode'), port: data.get('port') || null, networkMode: data.get('networkMode'), topology: data.get('topology'), exitServerId: data.get('exitServerId') || null, exitProtocol: data.get('exitProtocol') || null, exitPortMode: data.get('exitPortMode') || null, exitPort: data.get('exitPort') || null, realityServerName: data.get('realityServerName'), realityDestPort: Number(data.get('realityDestPort') || 443) };
const payload = { name: data.get('name'), serverId: data.get('serverId'), protocol: data.get('protocol'), portMode: data.get('portMode'), port: data.get('port') || null, networkMode: data.get('networkMode'), topology: data.get('topology'), exitServerId: data.get('exitServerId') || null, exitProtocol: data.get('exitProtocol') || null, exitPortMode: data.get('exitPortMode') || null, exitPort: data.get('exitPort') || null, realityServerName: data.get('realityServerName'), realityDestPort: Number(data.get('realityDestPort') || 443), exitMode: form.dataset.exitMode || 'managed', exitAddress: data.get('exitAddress') || null };
if (payload.topology !== 'direct' && payload.exitMode === 'manual') {
const creds = {};
if (data.get('exitClientId')) creds.clientId = data.get('exitClientId');
if (data.get('exitPassword')) creds.password = data.get('exitPassword');
if (data.get('exitSocksUser')) creds.socksUser = data.get('exitSocksUser');
if (Object.keys(creds).length) payload.exitCredentials = creds;
payload.exitPortMode = 'fixed';
}
await api(resourceId ? `/api/nodes/${resourceId}` : '/api/nodes', { method: resourceId ? 'PATCH' : 'POST', body: JSON.stringify(payload) });
closeModal(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,请分配客户后部署'); await load('chains');
} else if (form.id === 'account-form') {
@@ -1108,11 +1225,14 @@ document.addEventListener('click', async (event) => {
api(`/api/customers/${itemId}/credentials`).then((data) => { customerFormState.credentials = data; renderCredentialsPane(result.customer); }).catch(() => {});
} else if (modalOpen()) subscriptionModal(result.customer);
}
else if (action === 'regen-credential') {
else if (action === 'exit-mode') {
const form = $('#node-form');
if (form) { form.dataset.exitMode = button.dataset.mode; syncNodeForm(); }
}
else if (action === 'regen-credentials') {
const customerId = $('#customer-form').dataset.customerId;
if (!await confirmModal(`重新生成「${button.dataset.node}」的凭据?该客户在此节点的现有连接将立即失效,需要重新导入订阅。`, '重新生成')) return;
await api(`/api/customers/${customerId}/credentials/${button.dataset.ncid}/regenerate`, { method: 'POST', body: '{}' });
const data = await api(`/api/customers/${customerId}/credentials`);
if (!await confirmModal('重新生成该客户的凭据?全部节点同时更换,现有连接将立即失效,需要重新导入订阅。', '重新生成')) return;
const data = await api(`/api/customers/${customerId}/credentials/regenerate`, { method: 'POST', body: '{}' });
customerFormState.credentials = data;
renderCredentialsPane(customerById(customerId));
toast('凭据已重新生成');
@@ -1207,6 +1327,7 @@ document.addEventListener('change', async (event) => {
} else if (target.matches('[data-check-all]')) {
$$('[data-row-check]').forEach((box) => { box.checked = target.checked; });
} else if (target.matches('[data-node-sync]')) syncNodeForm();
else if (target.matches('[data-exit-link]')) applyExitLink(target);
else if (target.matches('[data-reality-preset]')) {
const option = target.selectedOptions[0]; const form = target.form;
if (option && option.value !== 'custom') { form.elements.realityServerName.value = option.dataset.sni; form.elements.realityDestPort.value = option.dataset.port; }
+6 -9
View File
@@ -28,23 +28,20 @@
<div class="brand">amo</div>
<nav class="menu" id="nav">
<a class="mi" data-page="overview" href="#overview"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12l9-9 9 9"/><path d="M5 10v10h14V10"/></svg>系统状态</a>
<a class="mi" data-page="servers" href="#servers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>服务器</a>
<a class="mi" data-page="customers" href="#customers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.8-3.2 3.4-5 6.5-5s5.7 1.8 6.5 5"/><path d="M16 4.6a3.5 3.5 0 010 6.8M21.5 20c-.6-2.6-2.2-4.2-4.5-4.8"/></svg>客户与订阅</a>
<a class="mi" data-page="chains" href="#chains"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="12" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.3 10.8l9.4-3.6M7.3 13.2l9.4 3.6"/></svg>转发与节点</a>
<a class="mi" data-page="commands" href="#commands"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 17l6-5-6-5M12 19h8"/></svg>安装与命令</a>
<a class="mi" data-page="operations" href="#operations"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 00-.1-1.2l2-1.5-2-3.4-2.3 1a7 7 0 00-2-1.2L14.2 3h-4l-.4 2.5a7 7 0 00-2 1.2l-2.3-1-2 3.4 2 1.5A7 7 0 005 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.4 2.3-1a7 7 0 002 1.2l.4 2.7h4l.4-2.5a7 7 0 002-1.2l2.3 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2z"/></svg>设置与运维</a>
<a class="mi" data-page="servers" href="#servers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>节点管理</a>
<a class="mi" data-page="customers" href="#customers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.8-3.2 3.4-5 6.5-5s5.7 1.8 6.5 5"/><path d="M16 4.6a3.5 3.5 0 010 6.8M21.5 20c-.6-2.6-2.2-4.2-4.5-4.8"/></svg>用户订阅</a>
<a class="mi" data-page="chains" href="#chains"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="12" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.3 10.8l9.4-3.6M7.3 13.2l9.4 3.6"/></svg>入站&amp;出站</a>
<a class="mi" data-page="operations" href="#operations"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 00-.1-1.2l2-1.5-2-3.4-2.3 1a7 7 0 00-2-1.2L14.2 3h-4l-.4 2.5a7 7 0 00-2 1.2l-2.3-1-2 3.4 2 1.5A7 7 0 005 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.4 2.3-1a7 7 0 002 1.2l.4 2.7h4l.4-2.5a7 7 0 002-1.2l2.3 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2z"/></svg>设置</a>
</nav>
<div class="sider-foot"><span id="user-chip"></span><span class="foot-right"><span id="version-label"></span><button id="logout" class="icon-btn" type="button" title="退出登录"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg></button></span></div>
<div class="sider-foot"><span id="user-chip"></span><span class="foot-right"><span id="version-label"></span><button id="logout" class="icon-btn" type="button" data-tip="退出登录"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg></button></span></div>
</aside>
<div class="main">
<header class="topbar">
<span class="crumb">amo / <b id="page-title">系统状态</b></span>
<span class="sp"></span>
<input class="top-search" placeholder="搜索服务器、客户、节点…" disabled>
<button class="theme-btn" id="refresh" type="button" title="刷新数据">刷新</button>
<button class="theme-btn" type="button" data-theme-toggle>🌙 深色</button>
<span class="avatar" id="avatar">管</span>
</header>
<main class="content" id="content"></main>
</div>
@@ -52,7 +49,7 @@
<div id="modal-overlay" class="modal-overlay hidden">
<section class="modal panel" role="dialog" aria-modal="true">
<header class="modal-head"><h2 id="modal-title"></h2><button type="button" class="icon-btn" data-close aria-label="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 6L6 18M6 6l12 12"/></svg></button></header>
<header class="modal-head"><h2 id="modal-title"></h2><button type="button" class="icon-btn" data-close aria-label="关闭" data-tip="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 6L6 18M6 6l12 12"/></svg></button></header>
<div id="modal-body" class="modal-body"></div>
</section>
</div>
+17 -14
View File
@@ -65,11 +65,9 @@ input,select,textarea{font-family:var(--font);color:var(--text)}
.crumb{color:var(--text-3);font-size:13px;white-space:nowrap}
.crumb b{color:var(--text);font-weight:600;font-size:15px}
.topbar .sp{flex:1}
.top-search{background:var(--card);border:1px solid var(--border);border-radius:6px;color:var(--text-2);padding:5px 12px;font-size:13px;width:230px}
.theme-btn{border:1px solid var(--border);background:var(--card);color:var(--text-2);border-radius:6px;padding:5px 11px;font-size:12.5px;cursor:pointer;white-space:nowrap}
.theme-btn:hover{border-color:var(--primary);color:var(--primary-text)}
.avatar{width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,#1668dc,#13c2c2);display:inline-flex;align-items:center;justify-content:center;font-size:12px;color:#fff;font-weight:600;flex:none}
.content{padding:16px 20px 28px;max-width:1460px;width:100%;margin:0 auto}
.content{padding:24px;width:100%}
.panel{background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:16px}
.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;border-bottom:1px solid var(--border-soft)}
.panel-head h2{font-size:14.5px;font-weight:600}
@@ -138,13 +136,6 @@ select.search{width:auto}
.stop-up-a{stop-color:var(--primary);stop-opacity:.26}
.stop-up-b{stop-color:var(--primary);stop-opacity:0}
.notice{border:1px solid var(--orange);background:var(--orange-soft);color:var(--orange-text);border-radius:6px;padding:8px 12px;font-size:13px;margin-bottom:16px}
.activity-list{display:flex;flex-direction:column}
.activity-list .event{display:flex;align-items:center;gap:10px;padding:7px 16px;border-bottom:1px solid var(--border-soft);font-size:13px}
.activity-list .event:last-child{border-bottom:none}
.activity-list .event b{font-weight:500}
.activity-list .event small{color:var(--text-3)}
.activity-list .event time{margin-left:auto;color:var(--text-3);font-size:12px;white-space:nowrap}
.event-dot{width:7px;height:7px;border-radius:50%;background:var(--primary);flex:none}
/* ---------- 表格通用 ---------- */
.table-wrap{overflow-x:auto}
@@ -153,6 +144,9 @@ table{width:100%;border-collapse:collapse}
table.w-servers{min-width:1390px}
table.w-customers{min-width:1120px}
table.w-nodes{min-width:1180px}
table.w-jobs td{white-space:nowrap;padding-top:5px;padding-bottom:5px}
table.w-jobs th:last-child{width:22%}
table.w-jobs td:last-child{white-space:normal;min-width:120px;word-break:break-word}
thead th{text-align:left;font-size:12.5px;font-weight:600;color:var(--text-2);letter-spacing:.3px;padding:7px 12px;border-bottom:1px solid var(--border-soft);background:var(--th-bg);white-space:nowrap}
thead th .sarr{color:var(--text-3);font-size:10px;margin-left:3px}
thead th.ctr,tbody td.ctr{text-align:center}
@@ -181,6 +175,10 @@ tbody tr.drag-over td{border-top:2px solid var(--primary)}
.icon-btn:hover{color:var(--primary-text);background:var(--hover-bg)}
.icon-btn.del{color:var(--red-text)}
.icon-btn.del:hover{background:var(--red-soft);color:var(--red-text)}
/* 图标悬停提示气泡(3x-ui 式:黑底白字圆角 + 小尖角,挂 body 避免被表格滚动容器裁剪) */
.tip-bubble{position:fixed;z-index:200;pointer-events:none;background:#141414;color:#fff;font-size:12px;line-height:1.25;padding:6px 9px;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity .12s;box-shadow:0 4px 14px rgba(0,0,0,.25)}
.tip-bubble.show{opacity:1}
.tip-bubble::after{content:"";position:absolute;top:100%;left:var(--tip-arrow-left,50%);transform:translateX(-50%);border:5px solid transparent;border-top-color:#141414;border-bottom:0}
.tgl{position:relative;display:inline-block;width:38px;height:21px;vertical-align:middle;flex:none}
.tgl input{opacity:0;width:0;height:0;position:absolute}
.tgl .tr{position:absolute;inset:0;background:var(--tgl-off);border-radius:99px;transition:background .2s;cursor:pointer}
@@ -206,7 +204,8 @@ tbody tr.drag-over td{border-top:2px solid var(--primary)}
.empty b{display:block;color:var(--text-2);font-size:14px;margin-bottom:2px}
/* ---------- 服务器页 ---------- */
.stats{display:grid;grid-template-columns:repeat(4,1fr);background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:14px}
.stats{display:grid;grid-template-columns:repeat(3,1fr);background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:14px}
.stats.six{grid-template-columns:repeat(6,1fr)}
.stats .stat{padding:14px 22px;border:none;background:transparent}
.stats .stat .lab{color:var(--text-2);font-size:12.5px;margin-bottom:3px}
.stats .stat .val{display:flex;align-items:center;gap:8px;font-size:20px;font-weight:600;font-variant-numeric:tabular-nums}
@@ -318,6 +317,9 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri
.mono{font-family:var(--mono);font-size:12.5px;word-break:break-all}
/* 页签 */
.segctl{display:inline-flex;border:1px solid var(--border);border-radius:8px;overflow:hidden}
.segctl button{padding:6px 16px;background:var(--card);border:none;color:var(--text-2);cursor:pointer;font:inherit;font-size:13px}
.segctl button.active{background:var(--primary);color:#fff}
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border-soft);margin-bottom:14px}
.tabs button{border:none;background:transparent;padding:7px 14px;font-size:13.5px;color:var(--text-2);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
.tabs button.active{color:var(--primary-text);border-bottom-color:var(--primary);font-weight:500}
@@ -372,7 +374,8 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri
.warn-note{border:1px solid var(--orange);background:var(--orange-soft);color:var(--orange-text);border-radius:6px;padding:8px 12px;font-size:13px}
/* 命令页 / 运维页 */
.command-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
.command-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;margin-bottom:16px}
.command-grid .panel{margin-bottom:0}
.command-item{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border-soft)}
.command-item:last-child{border-bottom:none}
.command-item>div{flex:1;min-width:0}
@@ -398,5 +401,5 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri
.toast{background:var(--pop-bg);border:1px solid var(--border);border-left:3px solid var(--green);color:var(--text);border-radius:8px;box-shadow:var(--pop-shadow);padding:9px 14px;font-size:13px;max-width:360px}
.toast.error{border-left-color:var(--red)}
@media (max-width:1100px){.stat-grid{grid-template-columns:repeat(2,1fr)}.mid-grid{grid-template-columns:1fr}.info-bar{flex-direction:column;gap:14px}.info-bar .seg{padding:0}.info-bar .seg+.seg{border-left:none}.stats{grid-template-columns:repeat(2,1fr)}}
@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.top-search{display:none}}
@media (max-width:1100px){.stat-grid{grid-template-columns:repeat(2,1fr)}.mid-grid{grid-template-columns:1fr}.info-bar{flex-direction:column;gap:14px}.info-bar .seg{padding:0}.info-bar .seg+.seg{border-left:none}.stats{grid-template-columns:repeat(2,1fr)}.stats.six{grid-template-columns:repeat(3,1fr)}}
@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.content{padding:8px}}