Author SHA1 Message Date
aaaamoooo 5733928198 docs: report addendum for items 10-13
CI / test (push) Canceled after 0s
CI / anytls-smoke (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
aaaamoooo 612035ac4e docs: UI reskin report
CI / test (push) Canceled after 0s
CI / anytls-smoke (push) Canceled after 0s
2026-10-21 03:16:31 +08:00
aaaamoooo 0a2889b4e4 feat(web): 3x-ui style reskin — amo branding, light/dark themes, four pages + tabbed customer form 2026-10-21 03:16:30 +08:00
aaaamoooo d287bb9be9 feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity) 2026-10-21 03:16:30 +08:00
aaaamoooo c6f6b9dbea feat(panel-go): monthly traffic reset for customers
- trafficResetDay/Period/At on the shared customer record (omitempty,
  inert for the Node panel, which preserves unknown fields)
- housekeeping applies due resets before quota evaluation: period-key
  idempotency, short-month clamping (31st fires Feb 28), missed-period
  catch-up after panel downtime, traffic suspensions auto-resume
- first sight of a schedule only arms the period marker, never zeroes;
  API create/PATCH accept trafficResetDay (0=off) and re-arm on change
- UI: reset-day input + last-reset display in the customer form
- tests: calendar math, apply pass, API lifecycle, persistence;
  full Go/Node suites green, difftest formal 50/50
2026-10-21 03:16:30 +08:00
aaaamoooo f4ef3d96a6 chore: migrate self-distribution links from GitHub to self-hosted Gitea
CI / test (push) Canceled after 0s
CI / anytls-smoke (push) Canceled after 0s
GitHub accounts suspended; all first-party distribution now served from
git.881218.xyz/rogue/NexusGate (Gitea):
- raw:  /rogue/NexusGate/raw/branch/<branch>/<path>
- release assets and source archives under the same repo path
- scripts gain a GIT_BASE variable built from NG_REPO (default
  rogue/NexusGate); NG_REPO/NG_BRANCH/--repo/--branch semantics unchanged
- panel enrollment/uninstall commands in public/app.js point at Gitea raw
- install.sh/nexusgate.sh/agent-singbox.sh stats source previously
  defaulted to the upstream repo (a2899882); defaults now this repo
Upstream dependency references (sagernet/sing-box Go module) untouched.
2026-10-20 11:05:55 +00:00
53 changed files with 11810 additions and 918 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,不受影响)。
+156
View File
@@ -0,0 +1,156 @@
# NexusGate 客户流量按月自动重置 — 实施报告
日期:2026-10-20 | 基线:`f4ef3d9` | 范围:独立功能,不含任何数据迁移代码。
## 0. 开工前工作区处理
`git status` 检查结果:工作区与 HEAD (`f4ef3d9`) 一致,无已修改文件;上一条被中止的
迁移工作流未留下任何残件。唯一未跟踪文件 `LINK-MIGRATION.md` 是更早的链接迁移说明
文档(本功能之前就未入仓),与本功能无关 — 原样保留,未纳入本次提交。
## 1. 用量模型摸底(设计前提)
- 客户用量记在客户记录的三个字段:`usedBytes` / `usedUplinkBytes` / `usedDownlinkBytes`,
由 orchestrator 按 agent 上报的 epoch 累计计数做增量累加(面板侧累计制)。
- 额度判定全部读 `usedBytes`:`CustomerBlockReason`、housekeeping 的额度暂停、
客户状态变更守卫、订阅发放前检查,四处同一口径。
- 订阅输出(`Subscription-Userinfo` 头与订阅信息页)读同三字段,其中
`historical = usedBytes - up - down`(≥0)是给"只灌总数"的导入数据兜底的派生量。
- 模型没有独立的终身累计字段:三字段既是周期计数也是展示口径。已有手动
`POST /api/customers/:id/reset-usage` 把三字段同时清零并在拦截消除后恢复客户 —
本功能与其保持完全一致的清零语义。
- **绝不能动**的是各 server/nodeClient 上的 epoch 基线计数器
(`serverUsage[].usageCounters` 等):那是增量记账的基准,清了会重复计费。
本功能只动客户三字段。
## 2. 设计
### 字段(客户级,共享 JSON schema,全部 omitempty)
| 字段 | 类型 | 含义 |
|---|---|---|
| `trafficResetDay` | int | 每月重置日 1–31;0/缺省 = 不重置 |
| `trafficResetPeriod` | string | 幂等标记:上次已应用重置所属周期键 `YYYY-MM` |
| `trafficResetAt` | string(ISO) | 上次实际重置时间(展示用) |
Node 版面板无副作用已核实:Node 的客户 PATCH 是逐字段改已加载对象、整对象落盘,
未知字段原样保留;Node 只是不执行重置逻辑(生产主控是 Go 版)。
### 周期键与触发判定(纯函数,无内存状态)
- 某月的重置时刻 = `min(重置日, 当月天数)` 号 00:00(面板本地时间)— 31 号遇
2 月在 28 号触发,闰年 29 号正常存在。
- 周期键 = `now` 时刻"最近一个已到来的重置时刻"所在月份(`YYYY-MM`)。
跨过重置时刻键才翻页。
- 判定(housekeeping 每轮):`marker != 当前周期键` 且 marker 非空 ⇒ 到期,执行一次
清零并把 marker 写为当前键。**面板停机跨过多个周期也只清一次**(落到当前周期),
这就是重启补判:不依赖 ticker 记忆,第一轮 housekeeping(启动后 ≤60 秒,
与 Node 版同节律,未额外加启动调用以保持差分行为对齐)即补触发。
### 布防(arm)语义 — 防误清
- marker 为空(老数据、或外部写入只写了 `trafficResetDay`)时,第一轮检查只写
marker、**不清零**:本周期已累计用量保留,下个重置日正常触发。
- API 创建客户带重置日、或 PATCH **改动**重置日时,当场布防(新排期从下个重置
时刻生效,绝不在设置瞬间清零)。PATCH 回显**未改动**的值不重布防 — 否则管理员
在"到期未触发"的窗口里编辑客户会把待触发的重置吞掉。
### 到期动作(镜像手动清零)
1. 三计数清零;marker ← 当前周期键;`trafficResetAt` / `updatedAt` ← 现在;
2. 写审计 `reset_customer_usage_monthly`(actor `system:monthly_reset`,
detail 含清零前用量、重置日、周期键);
3. 若客户是 `traffic_limit` 暂停且清零后无其它拦截(未过期等),自动恢复
active 并 `ResumeCustomerResources`(与手动清零同路径)。
手动暂停、到期暂停的客户只清零、不恢复。
### 挂载点
`main.go` 的 60 秒 housekeeping 事务内、额度暂停评估**之前**调用
`ApplyMonthlyTrafficResets`(`panel-go/reset.go`)— 同事务原子生效:清零后本轮
额度判定即按新周期口径走。不新起 goroutine,内存零增量(几个时间计算/客户)。
### API / UI 配套(最小)
- `POST /api/customers` 与 `PATCH /api/customers/:id` 接受 `trafficResetDay`
(0–31 整数,null = 清除;32 / 小数 / 负数 → 400)。
- 前端客户表单新增"每月流量重置日"输入(0 不重置 + 说明),编辑时回显
"上次重置 时间";提交 payload 带该字段。`public/app.js` 与
`panel-go/embed_public/app.js`(构建期副本)已同步。
## 3. 改动清单
| 文件 | 改动 |
|---|---|
| `panel-go/types.go` | Customer 加 3 字段(+3 行) |
| `panel-go/reset.go` | 新增:周期键/时刻计算、布防、到期应用、入参校验 |
| `panel-go/main.go` | housekeeping 事务内挂载(+9 行) |
| `panel-go/handlers.go` | 创建/PATCH 接受并布防 `trafficResetDay`(+22 行) |
| `public/app.js` | 表单字段 + payload(+3/−1 行) |
| `panel-go/reset_test.go` | 新增测试(14 个测试函数) |
| `MONTHLY-RESET-REPORT.md` | 本报告 |
## 4. 测试清单与结果
**基线(改动前)**:panel-go `go test ./...` 全过;Node `node --test` 52/52。
**新增用例**(`panel-go/reset_test.go`,全部通过):
| 用例 | 覆盖点 |
|---|---|
| `TestTrafficResetPeriodKey` | 16 个历法断言:跨月、时刻整点边界、同日、年翻转(1/1→上年 12 月)、31→2/28 夹逼、30→2 月、29 闰/非闰年 |
| `TestApplyMonthlyResetDueAndIdempotent` | 正常跨月触发清零 + marker/resetAt/审计落位;同周期二次检查不清零(新流量保留) |
| `TestApplyMonthlyResetNotYetDue` | 本周期已重置者不动 |
| `TestApplyMonthlyResetArmsOnFirstSight` | marker 空只布防不清零;布防后下个周期正常触发 |
| `TestApplyMonthlyResetCatchUpMissedPeriods` | 停机跨 4 个周期(重启补判):只清一次、落当前周期、审计恰 1 条 |
| `TestApplyMonthlyResetShortMonthClamp` | 31 号:2/27 不触发、2/28 00:01 触发且周期为 2 月 |
| `TestApplyMonthlyResetIndependentDays` | 多客户不同重置日互不干扰(1 号已清、25 号未动) |
| `TestApplyMonthlyResetUnsetOrInvalidUntouched` | 未设置 / 45 / −3 的客户完全不受影响 |
| `TestApplyMonthlyResetResumesTrafficSuspended` | 流量暂停客户清零后自动恢复、拦截原因消除 |
| `TestApplyMonthlyResetKeepsOtherSuspensions` | 手动暂停 / 到期暂停:只清零不恢复 |
| `TestParseResetDay` | nil/0/1/31/json.Number 合法;32/2.5/−1 拒绝 |
| `TestResetFieldsPersistAndOmitWhenUnset` | 落盘原文含三字段、重载不丢;未设置客户 JSON 不出现新键(Node 互通前提) |
| `TestHousekeepingAppliesMonthlyReset` | 走真实 housekeeping 全链:到期清零、周期落位;第二轮不重复清 |
| `TestCustomerAPIResetDayLifecycle` | 登录后 API 全程:创建带日(布防、无 resetAt)→ PATCH 32 被拒 → 改日重布防 → 清 0 后记录中字段消失 |
**改动后全套**:
- panel-go `go test ./...` 全过(另 `-count=1` 全量复跑过);`gofmt` / `go vet` 干净。
- agent-go `go test ./...` 全过(未改动,回归确认)。
- Node `node --test` 52/52;`npm run check`(server.js / agent / app.js 语法)通过。
- **差分 E2E(difftest)正式模式 50/50 PASS**(Node vs Go 同种子同剧本,
含客户 PATCH 与客户列表比对)— 新增字段未扰动任何既有响应形状。
## 5. 代码自审清单(边界专项)
| # | 发现 | 处置 |
|---|---|---|
| 1 | `TrafficResetPeriodKey` 初版混用时区:年月取 `now` 的 Location、时刻构造写死 `time.Local`,两者不一致必算错 | **已修**:全程 `now.Location()`;`daysInMonth` 改纯历法(UTC)计算;历法测试全程显式构造本地时间 |
| 2 | epoch 基线计数器(serverUsage / nodeClient usageCounters)若被清会重复计费 | 已确认设计只动客户三字段,基线零接触(见 §1) |
| 3 | 事务原子性:重置在 housekeeping 事务内,若 `ResumeCustomerResources` 出错整轮回滚 — marker 与计数同生同灭,下轮重试,不会出现"清了零但 marker 没落"或重复清 | 核实 `Store.Transaction` 为 clone-草稿语义后**接受**;代价:单个客户恢复出错会阻塞整轮 housekeeping,与既有 ip_limit 恢复同构 |
| 4 | 跨界流量归属:重置时刻前后一个上报周期(≤60 秒)的增量整笔计入新周期 | 清零式重置的固有近似(3x-ui 同样),量级 ≤1 分钟流量,**接受并记录** |
| 5 | PATCH 回显吞重置:前端每次编辑都回显该字段,若"提交即布防"会在到期窗口内吞掉待触发重置 | **已防**:仅值变化才重布防(§2),API 测试覆盖 |
| 6 | PATCH 传 `trafficResetDay: null` 的语义 | 定为清除,与 `expiresAt` 的 null 清除口径一致 |
| 7 | marker 被写成垃圾字符串(仅可能来自手工改库) | 按"到期"处理:清一次后写入合法键自愈收敛;前提是重置日本身合法,**接受** |
| 8 | DST:00:00 在个别时区不存在(春跳日) | Go `time.Date` 自动顺延,周期键不受影响;生产时区 Asia/Shanghai 无 DST,**记录** |
| 9 | 多名流量暂停客户同轮恢复 → 每人可能触发节点重建下发 | 与手动清零恢复同路径同量级,路由器客户规模小,**接受** |
| 10 | 临时回滚到 Node 版当主控时重置停摆 | Node 不执行但不破坏字段;切回 Go 后按 marker 补判、不会重复清(幂等),**记录为运维须知** |
| 11 | 补判延迟:启动后第一轮 housekeeping 在 ≤60 秒 | 满足"启动/下个检查点补触发";未加启动即时调用是为与 Node 节律保持差分对齐,**接受** |
自审未发现其它未处置的边界问题。
## 6. 与迁移的衔接点
- 转换器对 13 名按月重置客户**只需写 `trafficResetDay`(1–31)**,
`trafficResetPeriod` 可不写:面板首轮自动布防、绝不误清迁移带入的期初用量,
到下个重置日正常触发。若转换器想显式写 marker,写"转换时刻的当前周期键"即可
(`TrafficResetPeriodKey(转换时刻, 重置日)`)。
- 期初用量落位不变:`usedBytes = client_traffics 上+下`、方向两字段按原值 —
其中属于重置客户的部分将在其首个重置日按新周期清零,符合 3x-ui 现行语义。
## 7. 未做 / 边界
- Node 版面板未实现重置执行逻辑(定版生产主控为 Go 版;Node 仅需字段无副作用,
已满足)。如未来 Node 回主,需在 `server.js` housekeeping 补同语义实现。
- 客户列表页未加"重置日"列(按最小配套要求只做了表单入口 + 上次重置显示)。
- 本报告不含生产部署动作:新二进制构建/换装按既有流程另行执行。
+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 未改动、哈希不变。
+3 -3
View File
@@ -21,7 +21,7 @@
```bash ```bash
# 克隆仓库 # 克隆仓库
git clone https://github.com/aaaamoooo/NexusGate.git /opt/nexusgate git clone https://git.881218.xyz/rogue/NexusGate.git /opt/nexusgate
cd /opt/nexusgate cd /opt/nexusgate
# 安装依赖并启动 # 安装依赖并启动
@@ -39,10 +39,10 @@ OpenWrt 上用 procd 常驻,见 `scripts/` 目录。
3. 在节点机上以 root 运行该命令 3. 在节点机上以 root 运行该命令
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/aaaamoooo/NexusGate/main/scripts/agent-install.sh | bash -s -- --server https://主控域名 --token 你的TOKEN curl -fsSL https://git.881218.xyz/rogue/NexusGate/raw/branch/main/scripts/agent-install.sh | bash -s -- --server https://主控域名 --token 你的TOKEN
``` ```
Agent 是 Go 静态二进制,安装时从 GitHub Release 下载,节点机不再需要安装 Node.js。(如需回退旧版 Node Agent,安装时加环境变量 `NG_AGENT_IMPL=node`。) Agent 是 Go 静态二进制,安装时从 Gitea Release 下载,节点机不再需要安装 Node.js。(如需回退旧版 Node Agent,安装时加环境变量 `NG_AGENT_IMPL=node`。)
## 支持的协议 ## 支持的协议
+55
View File
@@ -0,0 +1,55 @@
# 订阅聚合 · 小设计(待拍板)
> 目标:客户订阅在自有节点之外,可附加 **① 外部订阅源(面板定时拉取)② 手动节点链接**,合并成一份订阅输出,"一个订阅用全部入站"。
> 现状锚点:订阅管线在 `lib/subscriptions.js`(Go:`panel-go/subscriptions.go`),入口 `formatSubscription / FormatSubscription`,条目为 `{nodeId, clientUri}`、名字挂在 URI 的 `#` 后,输出 raw/base64/clash/clash-smart/singbox/surge 五格式;Clash 等格式都经 `clashProxy` 从 URI 反解,**不支持的 scheme 会让整单生成抛错**——聚合的所有过滤必须在进管线前完成。
## 1. 数据模型(存进现有 JSON 库,跟随现状形态)
- 客户新增 `externalSources: [{id, url, remark, enabled, userAgent?}]`,上限 **5 个/客户**——防滥用且够用;字段风格与现有客户字段一致(camelCase,如 `trafficResetDay`)。
- 客户新增 `manualLinks: [{id, uri, remark, enabled}]`,上限 **50 条/客户**;保存时即做 scheme 白名单与可解析校验,坏链接不许入库。
- 拉取缓存放顶层对象 `subSourceCache: { [sourceId]: {fetchedAt, okAt, failCount, lastError, droppedCount, items:[{uri, name}]} }`,仿 `keyStore` 先例(对象形态、不入 COLLECTIONS 校验;Node 侧 `||= {}` 兜底、Go 侧 `Data` 加 map 字段)——缓存与客户配置分离,编辑客户不碰缓存,源被删时顺手清对应缓存键。
## 2. 拉取与缓存
- **刷新间隔**:全局设置 `settings.externalSubRefreshMinutes`,默认 **60 分钟**,可配 15–720;机场源普遍限频,60 分钟兼顾新鲜与不被风控。调度挂现有 **60s housekeeping**(Node `server.js housekeeping()` / Go `main.go housekeeping()`),按每源 `fetchedAt + 间隔` 判到期、加 ±10% 抖动防同刻齐发——与月重置同为"落盘标记判定、不靠内存计时器",面板重启不丢节奏。
- **单次拉取上限**:超时 **10s**、响应体 **1 MiB**、解析后条目 **200 条/源**、重定向 ≤3 跳;超限即判失败走兜底。
- **失败策略**:失败只累加 `failCount` 并退避(间隔 ×2ⁿ,上限 24h),**永远沿用上一次成功缓存输出**;`failCount ≥ 3` 时客户详情把该源标红提示。从未成功过的源输出为空、状态显示失败原因。**自有节点的生成路径与外源完全解耦**——外源拉取/解析出任何错,自有部分照常出(现状"无自有节点出 503"的判定改为"自有+手动+外源全空才 503")。
- **触发入口**:新增/编辑源保存后立即异步拉一次;客户详情每源一个「立即刷新」按钮(`POST /api/customers/:id/external-sources/:sid/refresh`,同步返回条数/错误供回显);到期由 housekeeping 自动拉。housekeeping 内串行执行、每轮最多 3 个源,护路由器 CPU。
- **缓存过期**:成功缓存**不设硬过期**——兜底优先于新鲜,源死了老节点继续发,总比订阅空掉强;新鲜度只由 `okAt` 在 UI 上展示。
## 3. 解析范围(首版只吃"链接列表")
- 输入形态按序判定:① 整体 base64 解码成功且解出含 scheme 的行 → 按行拆分;② 明文逐行(跳空行);③ 整个 body 就是一条链接(①②的自然子集)。覆盖 v2rayN 系与绝大多数机场的默认返回。
- **协议白名单 = 现管线五种**:`vless://`、`ss://`、`hysteria2://`、`anytls://`、`socks5://`(正是 `buildClientUri` 产物、`clashProxy` 可反解的集合)。`vmess://`、`trojan://` 及未知 scheme **丢弃并计数**(`droppedCount` 落缓存、UI 可见"已忽略 N 条")——一条坏链接绝不能毁整单。
- **Clash YAML / sing-box JSON 源首版不解析,放二期**:两者是完整配置语义,做 outbound→URI 逆向映射等于再造半个转换器,且与五格式输出管线重复;用下方 UA 策略拉到的基本都是链接列表,首版覆盖面已够。
## 4. 合并与输出
- **排序固定分段、不交错**:自有节点(按定版 `sortIndex` 序)→ 手动链接(列表序)→ 外部源(源添加序、源内保持原序)。自有是计费与可用性主体、排前面保证客户端默认落自有;分段固定使外源抖动不改变自有相对顺序,订阅内容 diff 稳定。
- **命名**:外部项名 = `[源备注] 原名`(源备注为空则不加前缀);手动项名取备注 → URI 自带 `#名` → `手动节点 N` 兜底。合并后全局同名沿用现有后缀逻辑(`名 (2)`、Clash 侧 `(节点N)`),与自有重名处理完全一致。
- **去重键**:`scheme + 主机(小写)+ 端口 + sha256(URI userinfo 原文) 前 16 位`;全局去重、优先级 自有 > 手动 > 外部——外部若与自有指向同一节点,保自有(凭据与流量统计都在自有侧)。
- **流量口径不变**:`subscription-userinfo` 与额度判定只读自有三计数(`usedBytes` 等),外部/手动流量不计量、不参与暂停判定;按月重置只清自有计数,互不干扰。
- **停用/暂停**:跟随现有订阅端点整单门禁(`handleSubscription` 已对非 active/过期/超额整单 403),**外部项不单开任何发放通道**。与"迁移无感"原则的关系:无感指凭据与端点不变,停用语义对客户只有一种——不出现"自有停了、外部还能用"的割裂状态;且刷新对暂停客户照常跑,恢复启用瞬间外源就是新鲜的。
## 5. 安全与边界
- **SSRF 默认禁内网**:拉取前解析主机名到 IP,命中环回/私网/链路本地段(含 169.254、::1、fc00::/8、fe80::/10)一律拒绝;重定向每一跳复检;连接时以校验过的 IP 直连并保留 Host/SNI(防 DNS rebinding)。面板在路由器上能摸到整个内网,这条是硬底线。设置 `settings.allowPrivateSubSources` 默认 **关**,用户有自托管订阅源时才手动打开。
- 只接受 http/https、URL 长度 ≤ 2048;源 URL 在 UI、日志、activity 审计里一律脱敏(只显 scheme+host,query 打码,沿用现有 redact 思路);订阅 token 与源 URL 都不落明文日志。
- **拉取 UA**:默认 `v2rayN/6.55`——机场对该 UA 普遍回 base64 链接列表,正中首版解析面;允许按源填 `userAgent` 覆盖。
- 面板出站拉取走主控自身网络,不经任何节点转发;拉取结果只进缓存、不执行其中任何内容。
## 6. 双实现影响
- **Node**:新增 `lib/extsub.js`(拉取+解析+归一化);`lib/subscriptions.js` 在 `entries()` 后拼接聚合段;`server.js` housekeeping 挂到期扫描、客户 CRUD 校验新字段、加手动刷新端点。
- **Go(生产主控,必须同步)**:新增 `panel-go/extsub.go` 对应物;`subscriptions.go` 的 `Entries/FormatSubscription` 同步拼接;`main.go` housekeeping 挂扫描;`handlers.go` 同步 API。字段同名同义、JSON 库双向兼容(两边都能读对方写的文件)。
- **agent / agent-go:零改动**——聚合纯主控侧,agent 只管自有节点的部署与计数上报,协议不变。
- 验收:`panel-go/difftest` 差分 E2E 增加聚合用例(同种子库 + 本地假外源 fixture,两实现五格式输出逐字节比对),过不了不算完。
## 7. 分期
- **首版(本设计全量)**:字段与缓存、链接列表解析、五格式合并输出、定时+手动刷新、失败兜底、SSRF 禁内网、双实现同步。
- **二期**:Clash YAML / sing-box JSON 源解析;外部节点健康探测与失效自动剔除;按源建 Clash 分组;手动/外部与自有交错排序;按源流量估算展示(仅展示、不计费)。
---
**需拍板(默认即推荐值)**:刷新 60 分钟 / 内网源默认禁 / 外部项排自有之后 / 停用整单停发 / 首版不吃 YAML。
+132
View File
@@ -0,0 +1,132 @@
# NexusGate UI 全量套皮报告(NexusGate → amo)
- 日期:2026-10-21
- 基线:`7c21742`(含按月重置,仅 panel-go 实现)
- 范围:前端按 3x-ui 定版样张全量重写;后端 Node 参考实现与 panel-go 双实现对称新增节点排序、在线状态(presence)、近似速度、系统状态采样、客户凭据接口、订阅聚合管线、服务器启停门禁;agent / agent-go **零改动**;版本保持 `0.6.8`。
- 结论:Node 测试 61/61、panel-go 测试全过、difftest 差分 E2E(selftest + Node vs Go 正式)各 65/65 全过;四页 + 客户表单三页签真机(Go 主控 + 种子库)截图自检通过;同种子同口径 RSS 新版未增(见 §7)。
## 1. 改动清单
### 1.1 前端(`public/`,双实现共用;panel-go 经 build.sh 同步进 embed)
- `index.html`:样张骨架(224px 侧边栏 + 顶栏 + 内容区);品牌纯文字 **amo**(无 logo/图标);`<title>amo</title>`;head 内联脚本按 localStorage `nexusgate-theme` 预置主题防闪烁;六个导航页:系统状态(原总览)、服务器、客户与订阅、转发与节点、安装与命令、设置与运维。
- `styles.css`:四样张 CSS 合并;浅色默认 + 深色变量双套;字体为 3x-ui 同款系统字体栈(`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif`),**零 webfont/外链**;紧凑密度;弹窗/表单/页签/入站选择器/浮层/toast/登录/展开行样式齐备。
- `app.js`:全量重写(保留 api/CSRF/toast/弹窗/45s 自动刷新/哈希路由骨架)。全站无说明性文字(page-intro 类已绝迹,grep 验证);原生 confirm/prompt 全部换成样式化弹窗;列表默认 50 条/页(客户/服务器/节点)。
- 四页按定版列落地:
- **系统状态**:四小卡(主控 CPU/内存/存储 + 今日全网流量,带迷你走势线;存储卡显示剩余)、整体速度双线图 + 三格统计(已发送/已接收/窗口均值)、连接数卡、底部信息条(运行时间:面板/系统|面板:内存/在线节点/客户|IP 地址 IPv4/IPv6)。
- **服务器**:统计条(设备总数/在线/离线/平均延迟)+ 15 列(展开 +|复选|操作|已启用开关|名称|地址打码 + 表头 👁 显隐|状态|CPU|内存|Agent 版本 + 「有可用更新」徽标|核心版本|运行时长|客户端总数/在线数|延迟|上次心跳);展开行承载旧详情与全部旧动作。
- **客户与订阅**:定版列(操作图标栏:二维码/详情/重置流量/编辑/红删除|已启用开关|在线药丸 + 已暂停红字|客户端名 + 备注两行|关联入站协议分色 chip + 「+N」弹层|流量药丸 + 迷你条|速度|剩余药丸|时长药丸)。
- **转发与节点**:定版列(拖动手柄|复选|ID=排序序号|铅笔 + ⋮ 菜单|启用开关|备注|服务器 chip 三态色|端口|协议徽标组单行|客户端总数 + 绿色在线数 + 悬停名单浮层(挂 body)|流量药丸|速度药丸|到期时间药丸)。
### 1.2 后端 Node(`lib/`、`server.js`)
- 新增 `lib/presence.js`(在线/速度推导)、`lib/sysstat.js`(主控采样 + 环形历史 + 全局 IP 挑选)、`lib/extsub.js`(外部订阅拉取/解析/SSRF/退避/去重命名)、`lib/reset.js`(**按月重置从 panel-go 1:1 移植补齐 parity**,见 §5)。
- `lib/orchestrator.js`:绑定数据层抽出 `assignCustomerData/unassignCustomerData` + 新增 `alignCustomerNodes`(表单全量对齐);部署校验加设备停用门禁;用量上报写 presence/速度(含节点 `lastSampleAt` 落盘,与 Go 对齐)。
- `lib/subscriptions.js`:entries 按节点 sortIndex 排序;新增聚合管线 `aggregateEntries`(自有 → 手动 → 外部合并/命名/去重)。
- `server.js`:`GET /api/system`、客户凭据 GET/regenerate、外部源手动刷新、节点 reorder 路由;客户 POST/PATCH 支持 `nodeIds` 全量对齐与聚合字段;housekeeping 接月重置 + 采样 + 到期源刷新;启动时 sortIndex 迁移。
- `lib/store.js`:默认 `brandName` 改为 `amo`。
### 1.3 后端 Go(`panel-go/`,与 Node 逐项对称)
- 新增 `presence.go`、`sysstat.go`、`extsub.go`;`types.go` 增字段(`Server.Enabled`、`Node.SortIndex` + 速度/活跃指针字段、`UsageSlot` 速度字段、`Customer.ExternalSources/ManualLinks`、顶层 `SubSourceCache`、设置项);`orchestrator.go`(绑定对齐、停用门禁、用量写 presence/速度);`subscriptions.go`(sortIndex 排序 + 聚合);`handlers.go`(/api/system、凭据、刷新、reorder 路由与列表附加字段);`main.go`(sortIndex 迁移、housekeeping 采样/刷新);默认品牌名 `amo`。
### 1.4 测试
- 新增 `test/uireskin.test.js`(Node,9 个)与 `panel-go/uireskin_test.go`(Go:聚合解析、SSRF 拦截、排序/去重、presence 窗口等)。
- difftest 扩展 phase-3(见 §6),`ID_PREFIXES` 补 `src`/`lnk`。
## 2. 旧功能对照表(逐项核对旧前端动作入口)
旧前端共 22 个 `data-action`,新前端 38 个。逐项结论:
| 旧功能 | 新入口 | 结论 |
|---|---|---|
| 新建/编辑/删除服务器、复制注册命令 | 服务器页操作列同名动作 | ✅ 保留 |
| 服务器详情(资源余量、TLS 证书、引擎状态、流量统计状态、旧版 agent 警告、重试清理、遗忘离线、SSH 卸载 Agent) | 服务器展开行 | ✅ 保留(样张 15 列放不下的收进展开行) |
| 新建/编辑/删除客户、启停、重置流量、重置令牌 | 客户页操作列 + 表单左下「重置流量」+ 订阅弹窗重置令牌 | ✅ 保留 |
| 客户详情(IP 观测、使用限制、订阅访问日志、清空日志) | 操作列详情 ⓘ 弹窗 | ✅ 保留(列表不显示 IP/设备上限,详情与表单仍在) |
| 订阅链接(9 种格式 + 信息页 + 各节点入站链接 + 二维码) | 订阅弹窗,顶部 auto 格式二维码 + 逐行复制/二维码 | ✅ 保留 |
| 新建/编辑节点、部署、修复、删除 | 节点页菜单(铅笔/⋮) | ✅ 保留 |
| 节点行「停用/启用」按钮 | **去除**(用户定版:与行首开关重复,只留开关) | ⛔ 按定版删除 |
| 节点页「分配客户」表单 + 客户端弹窗内解绑 | **去除**;绑定写入口收敛到客户表单「关联入站」(用户定版,防两处不一致);`/assign`、`/unassign` API 保留不动;节点客户端弹窗改只读名单 + 可复制链接 | ⛔ 按定版收敛 |
| `copy-uri` 复制动作 | 改名统一为 `copy-text`(订阅行、命令、入站链接同一处理器) | ✅ 改名保留 |
| 安装与命令页全部命令复制 | 命令页换皮、删除每条命令的说明小字 | ✅ 保留 |
| 设置与运维(改密、备份下载/恢复等) | 运维页换皮、删除说明段(备份卡保留一句凭据保管警告) | ✅ 保留 |
| 旧前端死代码 `renderNodeLinks` | 未迁移(无入口调用) | ⛔ 死代码不迁移 |
## 3. 客户表单三页签(基本 / 凭据 / 链接)
- **基本**:名称、分组、流量上限 GB、快速到期 + 到期日期/时间、IP 限制、设备(HWID)限制、**流量重置成对控件**(「流量重置」下拉:不重置/每月 + 「每月重置日」数字输入并排;直接绑定既有 `trafficResetDay`,非「每月」时重置日输入禁用,截图与 DOM 实测验证)、标签、备注、启用开关(编辑态绑定状态)、左下「重置流量」(仅编辑态)。
- **关联入站**(基本页签内):点击输入框弹下拉面板列全部节点(按 sortIndex 序、可滚动、已选高亮 + ✓、点击切换),右侧 🔍 按节点名搜索过滤;已选项以 chips 呈现可 × 单删;「全选 / 全部清除」按钮。保存 = 按勾选全量对齐 `nodeClients`(走现有部署/回收链路与凭据生成,不另起数据)。
- 3x-ui 独有而模型没有的字段(自动续期/Telegram ID/反向标签等)**未加**。
- **凭据**:订阅令牌行(复制 + ↻ 重置,沿用既有 rotate-subscription);每条节点绑定一行:节点名/协议/状态、UUID(`credentials.clientId`)、密码(`credentials.relayPassword`,SS/HY2/AnyTLS/SOCKS 共用)、Reality Short ID(`credentials.shortId`)、SOCKS 时用户名(`ng:<customerId>`);逐项只读 + 复制 + ↻ 重新生成(**二次确认**)。
- **凭据对应关系(按代码模型点名)**:凭据挂在「客户 × 节点」绑定(nodeClients)上、每条绑定一套,不是客户级一套——同一客户在不同节点上的 UUID/密码/Short ID 各自独立;订阅令牌(subId)才是客户级的。新接口:`GET /api/customers/:id/credentials`、`POST /api/customers/:id/credentials/:ncid/regenerate`(重生成后对已部署节点自动 rebuildNode 下发;打审计 `regenerate_credentials`);生成规则沿用既有 `newNodeClientCredentials(protocol)`,未新造格式。
- **链接**:顶部一句短文案(「添加第三方分享链接和远程订阅地址,将其包含在该客户的订阅中」);「+ 添加外部链接」+ 手动链接列表(空态「暂无外部链接」;每条:链接本体 + 备注 + 启用开关 + 删除);「+ 添加外部订阅」+ 订阅源列表(空态「暂无外部订阅」;每条:地址 + 备注 + 可选 UA + 启用开关 + 删除 + 状态行(条数/上次成功/失败原因/已忽略 N 条)+「立即刷新」)。
## 4. 订阅聚合(按 SUB-AGGREGATION-DESIGN.md 全文)
- 客户新增 `externalSources`(≤5)与 `manualLinks`(≤50);拉取缓存独立顶层 `subSourceCache`(与客户配置分离,删源/删客户顺手清键)。
- 拉取:超时 10s、体上限 1MiB、每源 ≤200 条、重定向 ≤3 跳逐跳复检;SSRF:解析全部地址、任一非全局即拒绝(`settings.allowPrivateSubSources` 默认关时才放行内网)、连接钉死校验过的 IP 并保留 Host/SNI;UA 默认 `v2rayN/6.55` 可按源覆盖;解析只吃链接列表(整体 base64 或明文逐行),协议白名单 `vless/ss/hysteria2/anytls/socks5`,其余丢弃并计数 `droppedCount`。
- 刷新:全局间隔 `settings.externalSubRefreshMinutes` 默认 60(钳 15–720);失败指数退避 + 确定性 jitter(双实现一致);housekeeping 每轮串行最多 3 个到期源;保存新增/改 URL 后台立即拉一次;手动刷新端点同步返回结果;**失败永远用上次成功缓存兜底**。
- 合并:顺序自有(sortIndex)→ 手动(列表序)→ 外部(源序/缓存序);外部项命名加 `[源备注]` 前缀,手动项取备注 → URI 自带名 → 「手动节点 N」;跨段重名追加 ` (2)`;去重键 = 协议 + 主机(小写)+ 端口 + 凭据指纹(解码后 userinfo 的 sha256 前 16 hex),自有优先;外部项不计量;客户停用仍整单 403;**自有-only 客户输出与改造前逐字节一致**(difftest 金标准守)。
- 注:面板没有 `/api/settings` 路由(双实现都没有,旧前端也无此调用),`externalSubRefreshMinutes`/`allowPrivateSubSources` 只能改数据文件——属既有形态,本轮未新造设置 UI。
## 5. 口径取舍专节(被迫近似与信号缺口,逐项点名)
1. **presence 粒度近似(B2)**:agent 用量样本只有 `{kind:'user', id:customerId}`(按服务器聚合,无节点线索)与 `{kind:'inbound', id:nodeId}`;observations 只有 `{customerId, ip}`。故客户×节点在线的推导为:客户在其绑定节点的服务器上有新鲜活跃槽(180s 窗口)⇒ 该客户在该服务器的**全部**绑定节点上同时点亮。同机多节点无法区分——这是上报粒度的上限,agent 协议未动。`customer.lastOnlineAt` 此前双实现从未写入,本轮在用量 delta>0 与 observations 接受时补写。
2. **速度为近似值(B3)**:相邻两次已处理样本 delta/时间差(dt≥5s 才更新,epoch 变更跳过),有效期 150s;无历史显「—」。difftest 里速度值依赖墙钟,只比形状(正/零)不比数值。
3. **连接数替代呈现(B4)**:agent 上报**没有连接数字段**,未编造通道——系统状态「连接数」卡与 /api/system 的 `connections` 用**在线客户数**替代呈现,前端卡内如实标注「在线客户」及在线节点/客户明细。「今日流量」基线为纯内存(面板启动时刻的节点累计量),面板重启后从启动时重新累计;历史环形缓冲 240 条纯内存(主控指标 + 全网速度/连接数),量级百 KB 内。
4. **服务器延迟无数据源**:agent 心跳不上报延迟——服务器页统计条与延迟列恒显「—」,未编造。
5. **核心版本(sing-box 版本)无数据源**:engine 上报只有 `singBoxInstalled` 布尔——核心版本列恒显「—」。CPU% 由 load1/cpuCount 换算,内存% 由 memoryTotal/memoryAvailable 算。
6. **节点到期时间恒 ∞**:节点模型无到期字段,到期时间药丸恒显 ∞,未造字段。
7. **IP 地址口径(B5)**:从主机网卡挑全局地址(排除 RFC1918/CGNAT/链路本地/ULA/回环/组播),取不到显「—」;不调用任何外部 IP 查询服务。沙箱实测显示 198.19.0.2(VM 出口的基准测试段地址,属网卡地址列表口径内;真实硬件上为实际网卡全局地址)。
8. **系统状态页活动区**:样张底部无活动区;为保「旧功能零丢失」,在信息条下方补回异常节点提示条(有才显)+ 最近活动面板——相对样张的唯一版式增补,特此点名。
9. **存储卡走势线**:无历史数据源时以当前占用率画平线(真实采样累积后即为真实曲线)。
10. **服务器 enabled(新实现)**:server 加 `enabled`(默认 true):停用后禁止其上节点部署(409「设备 X 已停用」)、reconcile 跳过该设备;与状态(在线/离线)正交。
11. **按月重置 Node parity 补齐**:该功能原只有 panel-go 实现(7c21742);本轮按 reset.go 语义 1:1 移植到 Node 并接进 create/PATCH/housekeeping——这是 parity 补齐,不是行为变更,Go 侧行为不变。
12. **种⼦环境假象(验收时确认非缺陷)**:种子库节点在面板重启后被既有 reconcile 置为 deploying(无真实 agent 轮询完成),故截图里「在线节点」按 active 计数为 0、节点开关呈启用态——与生产(agent 数秒内轮询完成)表现不同,非套皮问题。
## 6. 改名清单(NexusGate → amo,仅展示层)
改了的可见点:
- 侧边栏品牌、登录页品牌:纯文字「amo」加粗,无 logo。
- 页面 `<title>amo</title>`、顶栏面包屑前缀、订阅输出可见名相关默认值。
- Node 与 Go 的默认设置 `brandName`:`NexusGate` → `amo`(新库默认;`brandName` 当前无渲染消费点,老库里的存量值不会出现在任何用户可见输出中)。
- 面板对外文案中的产品名(前端静态文案)。
刻意保留的内部标识(未改):
- 仓库目录、二进制名(`nexusgate-panel` 等)、数据路径(`/etc/nexusgate`、`nexusgate.json`)、环境变量(`NG_*`)、agent 协议与字段名。
- 安装/卸载命令里的仓库 URL(`git.881218.xyz/rogue/NexusGate`)。
- 启动日志行与数据文件损坏错误文案中的 "NexusGate"(运维耦合的日志/错误前缀,按定版保留)。
## 7. 测试与验收证据
| 项目 | 结果 |
|---|---|
| Node 测试 `npm test` | **61/61 全过**(含新增 uireskin 9 个) |
| Go 测试 `go test ./...` | **全过**(含新增 uireskin_test.go) |
| difftest selftest(Node vs Node) | **65/65** |
| difftest 正式(Node vs Go 二进制) | **65/65** |
| 真跑截图自检 | Go 主控 + 混合种子库(3 服务器/6 客户/3 节点/6 绑定,含在线/离线/额度暂停/手动停用/过期/聚合客户),四页 + 表单三页签 + 深色抽查,见下 |
| RSS 对比 | 同一种子、同样预热请求、无 GOMEMLIMIT 的同口径:**基线(7c21742)VmRSS 45,528–45,596 kB;新版 43,164–43,224 kB**——新版未增(差值在 Go 运行时波动量级内);新增历史环形缓冲纯内存 240 条封顶 |
difftest phase-3 新增覆盖(双实现逐项比对):节点 reorder 后列表序与订阅输出序、客户 PATCH `nodeIds` 全量对齐(加/清绑定)、凭据 GET 形状与 regenerate 后凭据变化、两段用量上报后的 presence 投影(在线布尔/速度正性)与速度字段形状归一、`/api/system` 键结构形状、聚合全链路(本地 fixture 源:手动刷新返回 count=2/dropped=1、externalStatus 状态、Dave 全格式订阅逐项比对)。时间依赖值(速度数值、/api/system 数值)按设计只比形状——Normalizer 对 `speedUpBps/speedDownBps` 做正/零归一并注明原因。既有约束保持:Alice 订阅与 seed 金标准逐字节比对、DB 互换(swap)后互通、phase-2 全部步骤不变。
截图(浅色默认,深色抽查客户页):
- `~/workspace/nexusgate-ui-reskin-shots/01-overview-light.png` 系统状态
- `~/workspace/nexusgate-ui-reskin-shots/02-servers-light.png` 服务器
- `~/workspace/nexusgate-ui-reskin-shots/03-customers-light.png` 客户与订阅(在线/离线/额度暂停/手动停用/已过期五态齐全;无备注客户第二行留空、无分组回退)
- `~/workspace/nexusgate-ui-reskin-shots/04-nodes-light.png` 转发与节点
- `~/workspace/nexusgate-ui-reskin-shots/05-customer-form-basic.png` 客户表单·基本(流量重置成对控件、关联入站 chips、左下重置流量)
- `~/workspace/nexusgate-ui-reskin-shots/06-customer-form-credentials.png` 客户表单·凭据(Dave:令牌 + N1 绑定 UUID/密码/Short ID)
- `~/workspace/nexusgate-ui-reskin-shots/07-customer-form-links.png` 客户表单·链接(手动链接 + 外部源 + 状态行 + 立即刷新)
- `~/workspace/nexusgate-ui-reskin-shots/08-customers-dark.png` 客户页深色
自检中发现并处置:① difftest interop 暴露 Node 侧节点 `lastSampleAt` 未落盘(速度计算借道 lastUsageAt)——已按设计对齐落盘并复跑全绿;② 截图种子一度用了模型不存在的 `disabled` 状态——模型手动停用为 `suspended+manual`,前端推导与后端一致,种子已修正(非产品缺陷)。
## 8. 提交
本地提交(不 push):
1. `feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity)`(含 SUB-AGGREGATION-DESIGN.md、difftest phase-3)
2. `feat(web): 3x-ui style reskin — amo branding, light/dark themes, four pages + tabbed customer form`
3. `docs: UI reskin report`
+2 -2
View File
@@ -7,8 +7,8 @@ Agent 已从 Node.js(`agent/agent.js`)切换为 Go 静态二进制
## 新装(scripts/agent-install.sh) ## 新装(scripts/agent-install.sh)
- 默认从 GitHub Release 下载二进制: - 默认从 Gitea Release 下载二进制:
`https://github.com/aaaamoooo/NexusGate/releases/download/agent-v0.6.8/nexusgate-agent-linux-<arch>` `https://git.881218.xyz/rogue/NexusGate/releases/download/agent-v0.6.8/nexusgate-agent-linux-<arch>`
(`<arch>` 由 `uname -m` 决定:x86_64→amd64,aarch64→arm64) (`<arch>` 由 `uname -m` 决定:x86_64→amd64,aarch64→arm64)
- 安装到 `/opt/nexusgate-agent/nexusgate-agent`(0755)。 - 安装到 `/opt/nexusgate-agent/nexusgate-agent`(0755)。
- 服务名仍为 `nexusgate-agent`: - 服务名仍为 `nexusgate-agent`:
+52
View File
@@ -199,6 +199,57 @@ type engineReport struct {
SingBoxInstalled bool `json:"singBoxInstalled"` SingBoxInstalled bool `json:"singBoxInstalled"`
Certificates []string `json:"certificates"` Certificates []string `json:"certificates"`
SingBoxStatus string `json:"singBoxStatus"` 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 // engineHealth mirrors agent.js engineHealth(). Callers must hold stateMu
@@ -214,6 +265,7 @@ func engineHealthLocked(resources []map[string]interface{}) engineReport {
if installed { if installed {
result.Status = service.Status result.Status = service.Status
result.Detail = service.Detail result.Detail = service.Detail
result.SingBoxVersion = singBoxVersion()
} else { } else {
result.Status = "error" result.Status = "error"
result.Detail = "sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install" 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)
}
}
+284
View File
@@ -0,0 +1,284 @@
'use strict';
// External subscription aggregation (panel-go/extsub.go mirrors this
// file; design: SUB-AGGREGATION-DESIGN.md). The panel fetches a
// customer's external subscription sources, parses link lists, caches
// the result in data.subSourceCache and merges manual + external items
// into the subscription output behind the customer's own nodes.
//
// Safety: http/https only, SSRF guard rejects non-global IPs unless
// settings.allowPrivateSubSources is on, every redirect hop is
// re-validated, the connection is pinned to the validated IP (no DNS
// rebinding window), 10s timeout, 1MiB body cap, 200 items per source.
const http = require('node:http');
const https = require('node:https');
const dns = require('node:dns/promises');
const net = require('node:net');
const crypto = require('node:crypto');
const { isGlobalIPv4, isGlobalIPv6 } = require('./sysstat');
const LINK_SCHEMES = ['vless:', 'ss:', 'hysteria2:', 'anytls:', 'socks5:'];
const MAX_BODY_BYTES = 1024 * 1024;
const MAX_ITEMS = 200;
const FETCH_TIMEOUT_MS = 10000;
const MAX_REDIRECTS = 3;
const DEFAULT_UA = 'v2rayN/6.55';
function isGlobalIP(ip) {
const family = net.isIP(ip);
if (family === 4) return isGlobalIPv4(ip);
if (family === 6) return isGlobalIPv6(ip.replace(/^\[|\]$/g, ''));
return false;
}
function validateLinkUri(uri) {
if (typeof uri !== 'string' || !uri || uri.length > 2048) return false;
let url;
try { url = new URL(uri); } catch { return false; }
return LINK_SCHEMES.includes(url.protocol) && Boolean(url.hostname);
}
// Parse a fetched body into a normalized link list. Accepts a base64
// blob or plaintext lines; unsupported schemes and unparseable lines are
// dropped and counted, never fatal.
function parseLinkList(body) {
let text = String(body == null ? '' : body);
const compact = text.replace(/\s+/g, '');
if (compact.length >= 16 && /^[A-Za-z0-9+/=_-]+$/.test(compact)) {
try {
const normalized = compact.replace(/-/g, '+').replace(/_/g, '/');
const decoded = Buffer.from(normalized, 'base64').toString('utf8');
if (decoded.includes('://')) text = decoded;
} catch { /* keep plaintext */ }
}
const items = [];
let dropped = 0;
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
if (items.length >= MAX_ITEMS) { dropped += 1; continue; }
if (!validateLinkUri(line)) { dropped += 1; continue; }
let name = '';
const hash = line.lastIndexOf('#');
if (hash >= 0) {
try { name = decodeURIComponent(line.slice(hash + 1)); } catch { name = line.slice(hash + 1); }
}
items.push({ uri: line, name: name.slice(0, 120) });
}
return { items, dropped };
}
async function resolveChecked(hostname, allowPrivate) {
let addresses;
if (net.isIP(hostname)) {
addresses = [hostname];
} else {
const records = await dns.lookup(hostname, { all: true });
addresses = records.map((record) => record.address);
}
if (!addresses.length) throw new Error('域名解析无结果');
if (!allowPrivate && addresses.some((ip) => !isGlobalIP(ip))) {
throw new Error('订阅源指向非公网地址,已拒绝');
}
return addresses[0];
}
function fetchOnce(urlString, { userAgent, allowPrivate, deadline }) {
return new Promise((resolve, reject) => {
(async () => {
const url = new URL(urlString);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('仅支持 http/https 订阅源');
const hostname = url.hostname.replace(/^\[|\]$/g, '');
const ip = await resolveChecked(hostname, allowPrivate);
const isTls = url.protocol === 'https:';
const port = Number(url.port || (isTls ? 443 : 80));
const hostHeader = url.port ? `${hostname}:${url.port}` : hostname;
const options = {
hostname: ip, port, path: `${url.pathname}${url.search}`, method: 'GET',
headers: { host: hostHeader, 'user-agent': userAgent || DEFAULT_UA, accept: '*/*' },
timeout: Math.max(1000, deadline - Date.now()),
};
if (isTls) options.servername = hostname;
const transport = isTls ? https : http;
const req = transport.request(options, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
resolve({ redirect: new URL(res.headers.location, urlString).toString() });
return;
}
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`订阅源返回 HTTP ${res.statusCode}`));
return;
}
const chunks = [];
let size = 0;
res.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
req.destroy(new Error('订阅源响应超过 1MiB'));
return;
}
chunks.push(chunk);
});
res.on('end', () => resolve({ body: Buffer.concat(chunks).toString('utf8') }));
res.on('error', reject);
});
req.on('timeout', () => req.destroy(new Error('订阅源请求超时')));
req.on('error', reject);
req.end();
})().catch(reject);
});
}
async function fetchSourceBody(source, settings) {
const allowPrivate = Boolean(settings && settings.allowPrivateSubSources);
const deadline = Date.now() + FETCH_TIMEOUT_MS;
let current = source.url;
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
if (Date.now() > deadline) throw new Error('订阅源请求超时');
const result = await fetchOnce(current, { userAgent: source.userAgent, allowPrivate, deadline });
if (!result.redirect) return result.body;
if (hop === MAX_REDIRECTS) throw new Error('订阅源重定向次数过多');
current = result.redirect;
}
throw new Error('订阅源请求失败');
}
// Fetch + parse one source. Never throws: returns { ok, items, dropped, error }.
async function fetchExternalSource(source, settings) {
try {
const body = await fetchSourceBody(source, settings || {});
const { items, dropped } = parseLinkList(body);
return { ok: true, items, dropped, error: null };
} catch (error) {
return { ok: false, items: [], dropped: 0, error: String(error && error.message || error).slice(0, 240) };
}
}
function refreshMinutes(settings) {
const value = Number(settings && settings.externalSubRefreshMinutes);
if (!Number.isFinite(value) || value <= 0) return 60;
return Math.min(720, Math.max(15, value));
}
// Deterministic ±10% jitter from the source id so both implementations
// and restarts agree, and sources do not all fire in the same pass.
function jitterFactor(sourceId) {
const hash = crypto.createHash('sha256').update(String(sourceId)).digest();
return 1 + ((hash[0] % 21) - 10) / 100;
}
// Collect sources due for a refresh pass: [{ customerId, source }].
function dueSources(data, nowMs, limit = 3) {
const out = [];
const cache = data.subSourceCache || {};
for (const customer of data.customers) {
for (const source of customer.externalSources || []) {
if (!source.enabled) continue;
const entry = cache[source.id];
if (!entry || !entry.fetchedAt) { out.push({ customerId: customer.id, source }); continue; }
const base = refreshMinutes(data.settings) * 60000;
const backoff = Math.min(Math.pow(2, Math.min(entry.failCount || 0, 10)) * base, 24 * 3600 * 1000);
const due = Date.parse(entry.fetchedAt) + backoff * jitterFactor(source.id);
if (nowMs >= due) out.push({ customerId: customer.id, source });
if (out.length >= limit) return out;
}
if (out.length >= limit) break;
}
return out.slice(0, limit);
}
// Apply one fetch result to the cache inside a store transaction draft.
function applyFetchResult(data, sourceId, result, nowIso) {
data.subSourceCache ||= {};
const previous = data.subSourceCache[sourceId] || { fetchedAt: null, okAt: null, failCount: 0, lastError: null, droppedCount: 0, items: [] };
const entry = { ...previous, fetchedAt: nowIso };
if (result.ok) {
entry.okAt = nowIso;
entry.failCount = 0;
entry.lastError = null;
entry.droppedCount = result.dropped;
entry.items = result.items;
} else {
entry.failCount = (previous.failCount || 0) + 1;
entry.lastError = result.error;
}
data.subSourceCache[sourceId] = entry;
return entry;
}
// ---------- save-time normalization (shared by create/PATCH) ----------
function cleanTextLocal(value, max) {
return String(value == null ? '' : value).trim().slice(0, max);
}
function normalizeExternalSources(value, idFn) {
if (value == null) return undefined;
if (!Array.isArray(value)) { const e = new Error('外部订阅格式无效'); e.statusCode = 400; throw e; }
if (value.length > 5) { const e = new Error('每个客户最多 5 个外部订阅'); e.statusCode = 400; throw e; }
return value.map((raw) => {
const item = raw && typeof raw === 'object' ? raw : {};
const url = cleanTextLocal(item.url, 2048);
let parsed;
try { parsed = new URL(url); } catch { parsed = null; }
if (!parsed || !['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
const e = new Error('外部订阅地址无效'); e.statusCode = 400; throw e;
}
const next = {
id: typeof item.id === 'string' && item.id ? item.id.slice(0, 80) : idFn('src'),
url, remark: cleanTextLocal(item.remark, 80), enabled: item.enabled !== false,
};
const ua = cleanTextLocal(item.userAgent, 120);
if (ua) next.userAgent = ua;
return next;
});
}
function normalizeManualLinks(value, idFn) {
if (value == null) return undefined;
if (!Array.isArray(value)) { const e = new Error('外部链接格式无效'); e.statusCode = 400; throw e; }
if (value.length > 50) { const e = new Error('每个客户最多 50 条外部链接'); e.statusCode = 400; throw e; }
return value.map((raw) => {
const item = raw && typeof raw === 'object' ? raw : {};
const uri = cleanTextLocal(item.uri, 2048);
if (!validateLinkUri(uri)) { const e = new Error('外部链接无效或协议不受支持'); e.statusCode = 400; throw e; }
return {
id: typeof item.id === 'string' && item.id ? item.id.slice(0, 80) : idFn('lnk'),
uri, remark: cleanTextLocal(item.remark, 80), enabled: item.enabled !== false,
};
});
}
// ---------- merge helpers (used by lib/subscriptions.js) ----------
function dedupKey(uri) {
try {
const url = new URL(uri);
let userinfo = '';
try { userinfo = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`; }
catch { userinfo = `${url.username}:${url.password}`; }
const hash = crypto.createHash('sha256').update(userinfo).digest('hex').slice(0, 16);
return `${url.protocol}//${url.hostname.toLowerCase()}:${url.port}#${hash}`;
} catch { return null; }
}
function withName(uri, name) {
const separator = uri.lastIndexOf('#');
const base = separator >= 0 ? uri.slice(0, separator) : uri;
return `${base}#${encodeURIComponent(name)}`;
}
function nameFromUri(uri) {
const hash = uri.lastIndexOf('#');
if (hash < 0) return '';
try { return decodeURIComponent(uri.slice(hash + 1)); } catch { return uri.slice(hash + 1); }
}
module.exports = {
LINK_SCHEMES, DEFAULT_UA, validateLinkUri, parseLinkList, fetchExternalSource,
refreshMinutes, dueSources, applyFetchResult, normalizeExternalSources, normalizeManualLinks,
dedupKey, withName, nameFromUri,
};
+125 -39
View File
@@ -240,6 +240,9 @@ function validateNodeDeploy(data, node) {
else validateProtocolPair(node.protocol, node.exitProtocol); else validateProtocolPair(node.protocol, node.exitProtocol);
const server = assertFound(data.servers.find((s) => s.id === node.serverId), '服务器'); const server = assertFound(data.servers.find((s) => s.id === node.serverId), '服务器');
const exitServer = direct ? null : assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器'); const exitServer = direct ? null : assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器');
for (const target of direct ? [server] : [server, exitServer]) {
if (target.enabled === false) { const error = new Error(`设备 ${target.name} 已停用`); error.statusCode = 409; throw error; }
}
if (!activeNodeClients(data, node).length) { const error = new Error('请先给节点分配至少一个客户再部署'); error.statusCode = 400; throw error; } if (!activeNodeClients(data, node).length) { const error = new Error('请先给节点分配至少一个客户再部署'); error.statusCode = 400; throw error; }
if (['vless-ws-tls', 'hysteria2', 'anytls'].includes(node.protocol)) { if (['vless-ws-tls', 'hysteria2', 'anytls'].includes(node.protocol)) {
if (!server.tlsDomain) { const error = new Error(`${node.protocol} 需要先填写设备 TLS 域名并在节点上安装证书`); error.statusCode = 400; throw error; } if (!server.tlsDomain) { const error = new Error(`${node.protocol} 需要先填写设备 TLS 域名并在节点上安装证书`); error.statusCode = 400; throw error; }
@@ -281,6 +284,8 @@ function validateNodeDeploy(data, node) {
// gates on "no in-flight jobs" instead and passes failInFlight: false. // gates on "no in-flight jobs" instead and passes failInFlight: false.
async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null, failInFlight = true } = {}) { async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null, failInFlight = true } = {}) {
const server = assertFound(data.servers.find((item) => item.id === serverId), '服务器'); const server = assertFound(data.servers.find((item) => item.id === serverId), '服务器');
// A disabled device receives no new deployments, including reconciles.
if (server.enabled === false) return 0;
if (failInFlight) { if (failInFlight) {
for (const job of data.jobs.filter((item) => item.serverId === serverId && ['queued', 'running'].includes(item.status))) { for (const job of data.jobs.filter((item) => item.serverId === serverId && ['queued', 'running'].includes(item.status))) {
job.status = 'failed'; job.error = 'Agent 重新注册,旧任务已由对账任务替代'; job.updatedAt = nowIso(); job.status = 'failed'; job.error = 'Agent 重新注册,旧任务已由对账任务替代'; job.updatedAt = nowIso();
@@ -314,6 +319,90 @@ async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null,
return count; return count;
} }
// Data-level assign/unassign (the transaction-free cores of the
// Orchestrator methods) so the customer form's node alignment can run
// inside the customer create/PATCH transaction.
async function assignCustomerData(data, nodeId, customerId, actor = 'admin') {
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const customer = assertFound(data.customers.find((c) => c.id === customerId), '客户');
if (customer.status !== 'active') { const error = new Error('只能给状态正常的客户分配节点'); error.statusCode = 409; throw error; }
let nc = data.nodeClients.find((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (nc && nc.status === 'active') { const error = new Error('该客户已分配到此节点'); error.statusCode = 409; throw error; }
if (nc) {
nc.status = 'active'; nc.updatedAt = nowIso();
} else {
nc = {
id: id('ncl'), nodeId, customerId, credentials: newNodeClientCredentials(node.protocol),
status: 'active', createdAt: nowIso(), updatedAt: nowIso(),
};
data.nodeClients.push(nc);
}
// A manually undeployed node stays draft until explicitly deployed.
// Automatic teardowns (suspend) are revived when clients return.
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) {
// record the assignment only
} else {
if (node.status === 'removing' && node.autoRemoving) {
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
node.autoRemoving = false;
}
await rebuildNode(data, node);
}
audit(data, actor, 'assign_customer', node.id, { customerId, customer: customer.name });
return structuredClone(nc);
}
async function unassignCustomerData(data, nodeId, customerId, actor = 'admin') {
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const index = data.nodeClients.findIndex((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (index < 0) { const error = new Error('该客户未分配到此节点'); error.statusCode = 404; throw error; }
data.nodeClients.splice(index, 1);
if (node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node);
audit(data, actor, 'unassign_customer', node.id, { customerId });
return true;
}
// Full-set alignment used by the customer form's 关联入站 multi-select:
// bindings not in desiredIds are removed, missing ones are created.
// Suspended customers get suspended bindings (mirroring their resource
// state) without touching node resources.
async function alignCustomerNodes(data, customer, desiredIds, actor = 'admin') {
const desired = [...new Set((desiredIds || []).map(String).filter(Boolean))];
for (const nodeId of desired) {
const node = data.nodes.find((n) => n.id === nodeId && !n.pendingDelete);
if (!node) { const error = new Error('节点不存在'); error.statusCode = 400; throw error; }
}
const current = data.nodeClients.filter((nc) => nc.customerId === customer.id);
const rebuild = new Set();
for (const nc of [...current]) {
if (desired.includes(nc.nodeId)) continue;
data.nodeClients.splice(data.nodeClients.indexOf(nc), 1);
audit(data, actor, 'unassign_customer', nc.nodeId, { customerId: customer.id });
rebuild.add(nc.nodeId);
}
for (const nodeId of desired) {
if (current.some((nc) => nc.nodeId === nodeId)) continue;
const node = data.nodes.find((n) => n.id === nodeId);
const nc = {
id: id('ncl'), nodeId, customerId: customer.id, credentials: newNodeClientCredentials(node.protocol),
status: customer.status === 'active' ? 'active' : 'suspended', createdAt: nowIso(), updatedAt: nowIso(),
};
data.nodeClients.push(nc);
audit(data, actor, 'assign_customer', node.id, { customerId: customer.id, customer: customer.name });
if (nc.status === 'active') rebuild.add(nodeId);
}
for (const nodeId of rebuild) {
const node = data.nodes.find((n) => n.id === nodeId);
if (!node) continue;
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) continue;
if (node.status === 'removing' && node.autoRemoving) {
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
node.autoRemoving = false;
}
await rebuildNode(data, node);
}
}
class Orchestrator { class Orchestrator {
constructor(store) { constructor(store) {
this.store = store; this.store = store;
@@ -365,47 +454,11 @@ class Orchestrator {
} }
async assignCustomer(nodeId, customerId, actor = 'admin') { async assignCustomer(nodeId, customerId, actor = 'admin') {
return this.store.transaction(async (data) => { return this.store.transaction(async (data) => assignCustomerData(data, nodeId, customerId, actor));
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const customer = assertFound(data.customers.find((c) => c.id === customerId), '客户');
if (customer.status !== 'active') { const error = new Error('只能给状态正常的客户分配节点'); error.statusCode = 409; throw error; }
let nc = data.nodeClients.find((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (nc && nc.status === 'active') { const error = new Error('该客户已分配到此节点'); error.statusCode = 409; throw error; }
if (nc) {
nc.status = 'active'; nc.updatedAt = nowIso();
} else {
nc = {
id: id('ncl'), nodeId, customerId, credentials: newNodeClientCredentials(node.protocol),
status: 'active', createdAt: nowIso(), updatedAt: nowIso(),
};
data.nodeClients.push(nc);
}
// A manually undeployed node stays draft until explicitly deployed.
// Automatic teardowns (suspend) are revived when clients return.
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) {
// record the assignment only
} else {
if (node.status === 'removing' && node.autoRemoving) {
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
node.autoRemoving = false;
}
await rebuildNode(data, node);
}
audit(data, actor, 'assign_customer', node.id, { customerId, customer: customer.name });
return structuredClone(nc);
});
} }
async unassignCustomer(nodeId, customerId, actor = 'admin') { async unassignCustomer(nodeId, customerId, actor = 'admin') {
return this.store.transaction(async (data) => { return this.store.transaction(async (data) => unassignCustomerData(data, nodeId, customerId, actor));
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const index = data.nodeClients.findIndex((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (index < 0) { const error = new Error('该客户未分配到此节点'); error.statusCode = 404; throw error; }
data.nodeClients.splice(index, 1);
if (node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node);
audit(data, actor, 'unassign_customer', node.id, { customerId });
return true;
});
} }
async reconcileServer(serverId, actor = 'system', options = {}) { async reconcileServer(serverId, actor = 'system', options = {}) {
@@ -516,8 +569,27 @@ class Orchestrator {
const key = `srv:${serverId}`; const key = `srv:${serverId}`;
customer.serverUsage = customer.serverUsage || {}; customer.serverUsage = customer.serverUsage || {};
const slot = customer.serverUsage[key] || (customer.serverUsage[key] = {}); const slot = customer.serverUsage[key] || (customer.serverUsage[key] = {});
const prevCounters = slot.usageCounters;
const prevSampleAt = slot.lastSampleAt;
const delta = applyDelta(slot, uplink, downlink, sampleEpoch); const delta = applyDelta(slot, uplink, downlink, sampleEpoch);
if (!delta) continue; if (!delta) continue;
slot.lastSampleAt = reportedAt;
// Presence + approximate speed from the same delta (no extra
// sampling channel): a positive delta is activity; speed is the
// delta over the gap since the previous processed sample.
const restarted = Boolean(sampleEpoch && prevCounters && prevCounters.epoch && sampleEpoch !== prevCounters.epoch);
if (delta.upDelta + delta.downDelta > 0) {
customer.lastOnlineAt = reportedAt;
slot.lastActiveAt = reportedAt;
}
if (prevSampleAt && !restarted) {
const dt = (Date.parse(reportedAt) - Date.parse(prevSampleAt)) / 1000;
if (dt >= 5) {
slot.speedUpBps = Math.round(delta.upDelta / dt);
slot.speedDownBps = Math.round(delta.downDelta / dt);
slot.speedAt = reportedAt;
}
}
customer.usedUplinkBytes = (customer.usedUplinkBytes || 0) + delta.upDelta; customer.usedUplinkBytes = (customer.usedUplinkBytes || 0) + delta.upDelta;
customer.usedDownlinkBytes = (customer.usedDownlinkBytes || 0) + delta.downDelta; customer.usedDownlinkBytes = (customer.usedDownlinkBytes || 0) + delta.downDelta;
customer.usedBytes = (customer.usedBytes || 0) + delta.upDelta + delta.downDelta; customer.usedBytes = (customer.usedBytes || 0) + delta.upDelta + delta.downDelta;
@@ -528,8 +600,21 @@ class Orchestrator {
// transport and is never double counted (same rule as before). // transport and is never double counted (same rule as before).
const node = data.nodes.find((item) => item.id === sample.id && item.serverId === serverId); const node = data.nodes.find((item) => item.id === sample.id && item.serverId === serverId);
if (!node) continue; if (!node) continue;
const prevCounters = node.usageCounters;
const prevSampleAt = node.lastSampleAt;
const delta = applyDelta(node, uplink, downlink, sampleEpoch); const delta = applyDelta(node, uplink, downlink, sampleEpoch);
if (!delta) continue; if (!delta) continue;
node.lastSampleAt = reportedAt;
const restarted = Boolean(sampleEpoch && prevCounters && prevCounters.epoch && sampleEpoch !== prevCounters.epoch);
if (delta.upDelta + delta.downDelta > 0) node.lastActiveAt = reportedAt;
if (prevSampleAt && !restarted) {
const dt = (Date.parse(reportedAt) - Date.parse(prevSampleAt)) / 1000;
if (dt >= 5) {
node.speedUpBps = Math.round(delta.upDelta / dt);
node.speedDownBps = Math.round(delta.downDelta / dt);
node.speedAt = reportedAt;
}
}
const metered = node.meteredTraffic || { uplink: 0, downlink: 0 }; const metered = node.meteredTraffic || { uplink: 0, downlink: 0 };
node.meteredTraffic = { uplink: metered.uplink + delta.upDelta, downlink: metered.downlink + delta.downDelta }; node.meteredTraffic = { uplink: metered.uplink + delta.upDelta, downlink: metered.downlink + delta.downDelta };
node.lastUsageAt = reportedAt; node.lastUsageAt = reportedAt;
@@ -541,4 +626,5 @@ class Orchestrator {
module.exports = { Orchestrator, allocatePort, selectPort, nodePortInUse, queueJob, audit, id, nowIso, cancelQueuedDeletes, resourceForNode, module.exports = { Orchestrator, allocatePort, selectPort, nodePortInUse, queueJob, audit, id, nowIso, cancelQueuedDeletes, resourceForNode,
resourceForNodeExit, rebuildNode, applyDelta, forwardNode, activeNodeClients, resourceForNodeExit, rebuildNode, applyDelta, forwardNode, activeNodeClients,
suspendCustomerResources, resumeCustomerResources, HEARTBEAT_RECONCILE_INTERVAL_MS }; suspendCustomerResources, resumeCustomerResources, HEARTBEAT_RECONCILE_INTERVAL_MS,
assignCustomerData, unassignCustomerData, alignCustomerNodes };
+81
View File
@@ -0,0 +1,81 @@
'use strict';
// Presence & speed derivation (panel-go/presence.go mirrors this file).
//
// Signals (agent protocol unchanged):
// - Agent usage reports carry cumulative per-user counters aggregated per
// server (NOT per node) and cumulative per-inbound (per node) counters.
// A processed sample whose delta is > 0 marks activity: per-server for
// the customer (serverUsage slot lastActiveAt + customer.lastOnlineAt),
// per-node for the inbound (node.lastActiveAt).
// - AnyTLS observations confirm customer activity and also stamp
// customer.lastOnlineAt at ingest time.
// A customer counts as "online on node N" when they hold an active
// binding on N and their last activity on N's server is inside the
// window. With several nodes on one server this attributes the activity
// to each of them — the reports cannot separate nodes on a server.
const PRESENCE_WINDOW_MS = 180000; // 3 minutes
const SPEED_STALE_MS = 150000;
function parseMs(value) {
if (!value) return 0;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : 0;
}
function customerLastActiveAt(customer) {
return customer.lastOnlineAt || null;
}
function customerOnline(customer, nowMs) {
const ms = parseMs(customer.lastOnlineAt);
return ms > 0 && nowMs - ms <= PRESENCE_WINDOW_MS;
}
// Sum of the customer's per-server speeds that are still fresh.
function customerSpeedBps(customer, nowMs) {
let up = 0;
let down = 0;
for (const slot of Object.values(customer.serverUsage || {})) {
if (!slot || !slot.speedAt) continue;
if (nowMs - parseMs(slot.speedAt) > SPEED_STALE_MS) continue;
up += Number(slot.speedUpBps || 0);
down += Number(slot.speedDownBps || 0);
}
return { up, down };
}
function nodeSpeedBps(node, nowMs) {
if (!node.speedAt || nowMs - parseMs(node.speedAt) > SPEED_STALE_MS) return { up: 0, down: 0 };
return { up: Number(node.speedUpBps || 0), down: Number(node.speedDownBps || 0) };
}
// Customers (ids) with an active binding on this node whose last
// activity on the node's server is inside the presence window. The
// per-server usage slot is authoritative; when the customer has never
// reported usage from this server, the customer-level lastOnlineAt is
// used as a fallback (AnyTLS observations stamp it without creating a
// usage slot, and they are validated against this server at ingest).
function nodeOnlineCustomerIds(data, node, nowMs) {
const out = [];
for (const nc of data.nodeClients) {
if (nc.nodeId !== node.id || nc.status !== 'active') continue;
const customer = data.customers.find((item) => item.id === nc.customerId);
if (!customer) continue;
const slot = (customer.serverUsage || {})[`srv:${node.serverId}`];
const ms = slot && slot.lastActiveAt ? parseMs(slot.lastActiveAt) : parseMs(customer.lastOnlineAt);
if (ms > 0 && nowMs - ms <= PRESENCE_WINDOW_MS) out.push(customer.id);
}
return out;
}
function countOnlineCustomers(data, nowMs) {
return data.customers.filter((customer) => customerOnline(customer, nowMs)).length;
}
module.exports = {
PRESENCE_WINDOW_MS, SPEED_STALE_MS, parseMs,
customerLastActiveAt, customerOnline, customerSpeedBps, nodeSpeedBps,
nodeOnlineCustomerIds, countOnlineCustomers,
};
+96
View File
@@ -0,0 +1,96 @@
'use strict';
// Monthly traffic reset — Node port of panel-go/reset.go (the two panel
// implementations must stay behavior-identical). A customer with
// trafficResetDay (1-31) gets the cycle counters zeroed once per monthly
// period. Due-ness derives only from the persisted trafficResetPeriod
// marker, so a reset missed while the panel was down fires at the first
// housekeeping pass after it is back. All dates are panel-local.
function daysInMonth(year, monthIndex) {
return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
}
function resetInstant(year, monthIndex, day) {
const dim = daysInMonth(year, monthIndex);
return new Date(year, monthIndex, Math.min(day, dim), 0, 0, 0, 0);
}
function periodKeyOf(date) {
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
}
// The "YYYY-MM" of the most recent scheduled reset instant at or before now.
function trafficResetPeriodKey(now, day) {
let year = now.getFullYear();
let month = now.getMonth();
let cand = resetInstant(year, month, day);
if (now.getTime() < cand.getTime()) {
month -= 1;
if (month < 0) { month = 11; year -= 1; }
cand = resetInstant(year, month, day);
}
return periodKeyOf(cand);
}
// (Re)arm a customer's period marker for its current reset day WITHOUT
// zeroing usage: the schedule takes effect from the next reset instant.
function armTrafficReset(customer, now = new Date()) {
const day = Number(customer.trafficResetDay || 0);
if (!Number.isInteger(day) || day < 1 || day > 31) {
delete customer.trafficResetPeriod;
return;
}
customer.trafficResetPeriod = trafficResetPeriodKey(now, day);
}
// Validate a trafficResetDay request value: 0 disables, 1-31 is the day of
// month. null/absent means 0. Mirrors panel-go ParseResetDay.
function parseResetDay(value) {
if (value == null) return 0;
const number = Number(value);
if (!Number.isFinite(number) || number < 0 || !Number.isInteger(number)) {
const error = new Error('每月重置日必须是非负整数');
error.statusCode = 400;
throw error;
}
if (number > 31) {
const error = new Error('每月重置日须在 0–31 之间(0 表示不重置)');
error.statusCode = 400;
throw error;
}
return number;
}
// Perform due monthly resets for all customers in data as of now. Returns
// the list of customers whose counters were zeroed (the caller mirrors the
// manual reset-usage semantics: a traffic_limit suspension may lift).
// auditFn(data, customer, previousUsed, day, period) records the event.
function applyMonthlyTrafficResets(data, now, auditFn) {
const reset = [];
for (const customer of data.customers) {
const day = Number(customer.trafficResetDay || 0);
if (!Number.isInteger(day) || day < 1 || day > 31) continue;
const key = trafficResetPeriodKey(now, day);
if (!customer.trafficResetPeriod) {
// First sight: arm only — usage already accrued in the current
// period must survive until the next reset day.
customer.trafficResetPeriod = key;
continue;
}
if (customer.trafficResetPeriod === key) continue;
const previousUsed = customer.usedBytes || 0;
customer.usedBytes = 0;
customer.usedUplinkBytes = 0;
customer.usedDownlinkBytes = 0;
customer.trafficResetPeriod = key;
customer.trafficResetAt = now.toISOString();
customer.updatedAt = now.toISOString();
if (auditFn) auditFn(data, customer, previousUsed, day, key);
reset.push(customer);
}
return reset;
}
module.exports = { trafficResetPeriodKey, armTrafficReset, parseResetDay, applyMonthlyTrafficResets, daysInMonth, resetInstant };
+1 -1
View File
@@ -13,7 +13,7 @@ function emptyData() {
schemaVersion: 1, schemaVersion: 1,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
settings: { settings: {
brandName: 'NexusGate', brandName: 'amo',
sessionHours: 12, sessionHours: 12,
observationTtlMinutes: 10, observationTtlMinutes: 10,
activityRetentionDays: 30, activityRetentionDays: 30,
+54 -3
View File
@@ -1,6 +1,12 @@
'use strict'; 'use strict';
const { buildClientUri, nodeEmail } = require('./protocols'); const { buildClientUri, nodeEmail } = require('./protocols');
const { dedupKey, withName, nameFromUri } = require('./extsub');
function nodeSortIndex(data, nodeId) {
const node = data.nodes.find((item) => item.id === nodeId);
return node && Number.isFinite(node.sortIndex) ? node.sortIndex : Number.MAX_SAFE_INTEGER;
}
// Client URLs are only issued for confirmed, running nodes. Each link is built // Client URLs are only issued for confirmed, running nodes. Each link is built
// on the fly from the node's current config and the customer's own credentials // on the fly from the node's current config and the customer's own credentials
@@ -22,7 +28,11 @@ function nodeClientUri(data, nodeClient) {
function entries(data, customerId) { function entries(data, customerId) {
const seen = new Map(); const seen = new Map();
const items = []; const items = [];
for (const nodeClient of data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active')) { // Own nodes follow the panel-wide custom order (sortIndex); the sort is
// stable, so equal/unset indexes keep the binding creation order.
const bindings = data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active');
bindings.sort((a, b) => nodeSortIndex(data, a.nodeId) - nodeSortIndex(data, b.nodeId));
for (const nodeClient of bindings) {
const uri = nodeClientUri(data, nodeClient); const uri = nodeClientUri(data, nodeClient);
if (!uri) continue; if (!uri) continue;
const node = data.nodes.find((item) => item.id === nodeClient.nodeId); const node = data.nodes.find((item) => item.id === nodeClient.nodeId);
@@ -38,6 +48,47 @@ function entries(data, customerId) {
return items; return items;
} }
// Full subscription item list: own nodes (sortIndex order), then manual
// links (list order), then external sources (source order, cached order).
// Global dedup by scheme+host+port+credential fingerprint, own first.
// Name collisions across segments get the " (n)" suffix treatment.
function aggregateEntries(data, customer) {
const items = entries(data, customer.id);
const usedKeys = new Set();
const usedNames = new Set();
for (const item of items) {
const key = dedupKey(item.clientUri);
if (key) usedKeys.add(key);
usedNames.add(nameFromUri(item.clientUri));
}
const append = (uri, rawName) => {
const key = dedupKey(uri);
if (key && usedKeys.has(key)) return;
let name = rawName || '未命名节点';
if (usedNames.has(name)) {
let index = 2;
while (usedNames.has(`${name} (${index})`)) index += 1;
name = `${name} (${index})`;
}
if (key) usedKeys.add(key);
usedNames.add(name);
items.push({ nodeId: '', clientUri: withName(uri, name) });
};
(customer.manualLinks || []).forEach((link, index) => {
if (!link.enabled) return;
append(link.uri, link.remark || nameFromUri(link.uri) || `手动节点 ${index + 1}`);
});
for (const source of customer.externalSources || []) {
if (!source.enabled) continue;
const cache = (data.subSourceCache || {})[source.id];
if (!cache || !Array.isArray(cache.items)) continue;
for (const cached of cache.items) {
append(cached.uri, `${source.remark ? `[${source.remark}] ` : ''}${cached.name || '未命名节点'}`);
}
}
return items;
}
function q(value) { return JSON.stringify(String(value)); } function q(value) { return JSON.stringify(String(value)); }
function clashProxy(item) { function clashProxy(item) {
@@ -149,7 +200,7 @@ function makeSingBox(items) {
} }
function formatSubscription(data, customer, format, userAgent = '') { function formatSubscription(data, customer, format, userAgent = '') {
const items = entries(data, customer.id); const items = aggregateEntries(data, customer);
if (!items.length) return null; if (!items.length) return null;
const selected = format === 'auto' ? (/clash|mihomo/i.test(userAgent) ? 'clash' : 'base64') : format; const selected = format === 'auto' ? (/clash|mihomo/i.test(userAgent) ? 'clash' : 'base64') : format;
if (selected === 'raw') return { body:items.map((item) => item.clientUri).join('\n') + '\n', contentType:'text/plain; charset=utf-8' }; if (selected === 'raw') return { body:items.map((item) => item.clientUri).join('\n') + '\n', contentType:'text/plain; charset=utf-8' };
@@ -160,4 +211,4 @@ function formatSubscription(data, customer, format, userAgent = '') {
return undefined; return undefined;
} }
module.exports = { entries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription }; module.exports = { entries, aggregateEntries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };
+189
View File
@@ -0,0 +1,189 @@
'use strict';
// Host/system sampling for the system-status page (panel-go/sysstat.go
// mirrors this file). Samples are taken at most every ~30s (housekeeping
// cadence and on-demand from /api/system when stale) and kept in a
// memory-only ring buffer (240 entries ≈ 4h at 60s cadence, tens of KB).
// Nothing here writes to the data file: the "today" traffic baseline is
// also memory-only, so a panel restart restarts the day counter from
// the traffic seen since boot (named trade-off, see UI-RESKIN-REPORT).
const fs = require('node:fs');
const os = require('node:os');
const { customerOnline } = require('./presence');
const HISTORY_CAP = 240;
const SAMPLE_MIN_INTERVAL_MS = 25000;
function readCpuTimes() {
try {
const line = fs.readFileSync('/proc/stat', 'utf8').split('\n')[0];
const parts = line.trim().split(/\s+/).slice(1).map(Number);
if (parts.length < 5 || parts.some((n) => !Number.isFinite(n))) return null;
const idle = parts[3] + (parts[4] || 0);
const total = parts.reduce((a, b) => a + b, 0);
return { idle, total };
} catch { return null; }
}
function readMem() {
try {
const text = fs.readFileSync('/proc/meminfo', 'utf8');
const grab = (key) => {
const match = new RegExp(`^${key}:\\s+(\\d+)`, 'm').exec(text);
return match ? Number(match[1]) * 1024 : 0;
};
const total = grab('MemTotal');
const available = grab('MemAvailable');
return { total, used: total > 0 ? total - available : 0 };
} catch { return { total: 0, used: 0 }; }
}
function isGlobalIPv4(ip) {
const parts = String(ip).split('.').map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
const [a, b] = parts;
if (a === 0 || a === 10 || a === 127) return false;
if (a === 172 && b >= 16 && b <= 31) return false;
if (a === 192 && b === 168) return false;
if (a === 100 && b >= 64 && b <= 127) return false; // CGNAT
if (a === 169 && b === 254) return false; // link-local
if (a >= 224) return false; // multicast / reserved
return true;
}
function isGlobalIPv6(ip) {
const lower = String(ip).toLowerCase();
if (lower === '::1' || lower === '::') return false;
if (/^f[cd]/.test(lower)) return false; // ULA fc00::/7
if (/^fe[89ab]/.test(lower)) return false; // link-local fe80::/10
if (lower.startsWith('ff')) return false; // multicast
return lower.includes(':');
}
// Pick the host's global addresses from the interface list (3x-ui
// 口径: local interface addresses, no external lookup of any kind).
function hostIPs() {
let ipv4 = null;
let ipv6 = null;
const interfaces = os.networkInterfaces();
for (const list of Object.values(interfaces)) {
for (const info of list || []) {
if (info.internal) continue;
if (info.family === 'IPv4' && !ipv4 && isGlobalIPv4(info.address)) ipv4 = info.address;
if (info.family === 'IPv6' && !ipv6 && isGlobalIPv6(info.address)) ipv6 = info.address;
}
}
return { ipv4, ipv6 };
}
class SystemMonitor {
constructor(dataDir) {
this.dataDir = dataDir;
this.startedAt = Date.now();
this.prevCpu = null;
this.cpuPercent = null;
this.history = [];
this.lastSampleAt = 0;
this.dayKey = null;
this.dayBaseUp = 0;
this.dayBaseDown = 0;
this.todayUp = 0;
this.todayDown = 0;
this.current = null;
}
sample(data, force = false) {
const now = Date.now();
if (!force && now - this.lastSampleAt < SAMPLE_MIN_INTERVAL_MS) return this.current;
this.lastSampleAt = now;
const cpu = readCpuTimes();
if (cpu && this.prevCpu) {
const totalDelta = cpu.total - this.prevCpu.total;
const idleDelta = cpu.idle - this.prevCpu.idle;
if (totalDelta > 0) this.cpuPercent = Math.max(0, Math.min(100, (1 - idleDelta / totalDelta) * 100));
}
if (cpu) this.prevCpu = cpu;
const mem = readMem();
let disk = { total: 0, used: 0 };
try {
const stat = fs.statfsSync(this.dataDir);
disk = { total: stat.blocks * stat.bsize, used: (stat.blocks - stat.bfree) * stat.bsize };
} catch { /* keep zeros */ }
let totalUp = 0;
let totalDown = 0;
let upBps = 0;
let downBps = 0;
for (const node of data.nodes || []) {
if (node.meteredTraffic) {
totalUp += Number(node.meteredTraffic.uplink || 0);
totalDown += Number(node.meteredTraffic.downlink || 0);
}
if (node.speedAt && now - Date.parse(node.speedAt) <= 150000) {
upBps += Number(node.speedUpBps || 0);
downBps += Number(node.speedDownBps || 0);
}
}
const dayKey = new Date(now).toLocaleDateString('en-CA');
if (this.dayKey !== dayKey) {
this.dayKey = dayKey;
this.dayBaseUp = totalUp;
this.dayBaseDown = totalDown;
}
this.todayUp = Math.max(0, totalUp - this.dayBaseUp);
this.todayDown = Math.max(0, totalDown - this.dayBaseDown);
const connections = (data.customers || []).filter((c) => customerOnline(c, now)).length;
const entry = {
at: new Date(now).toISOString(),
cpu: this.cpuPercent == null ? null : Math.round(this.cpuPercent * 10) / 10,
mem: mem.total > 0 ? Math.round((mem.used / mem.total) * 1000) / 10 : null,
upBps, downBps, connections,
};
this.history.push(entry);
if (this.history.length > HISTORY_CAP) this.history.splice(0, this.history.length - HISTORY_CAP);
this.current = {
at: entry.at, cpuPercent: entry.cpu, mem, disk,
upBps, downBps, connections, totalUp, totalDown,
};
return this.current;
}
status(data, version) {
const current = this.sample(data) || {
cpuPercent: null, mem: readMem(), disk: { total: 0, used: 0 },
upBps: 0, downBps: 0, connections: 0, totalUp: 0, totalDown: 0,
};
const mem = current.mem;
const disk = current.disk;
const onlineNodes = (data.nodes || []).filter((n) => n.status === 'active').length;
return {
version,
startedAt: new Date(this.startedAt).toISOString(),
panelUptimeSec: Math.floor((Date.now() - this.startedAt) / 1000),
hostUptimeSec: Math.floor(os.uptime()),
panelMemBytes: process.memoryUsage().rss,
host: {
cpuPercent: current.cpuPercent,
cpuCount: os.cpus().length,
load1: os.loadavg()[0],
memTotal: mem.total, memUsed: mem.used,
memPercent: mem.total > 0 ? Math.round((mem.used / mem.total) * 1000) / 10 : null,
diskTotal: disk.total, diskUsed: disk.used,
diskPercent: disk.total > 0 ? Math.round((disk.used / disk.total) * 1000) / 10 : null,
},
network: {
upBps: current.upBps, downBps: current.downBps,
connections: current.connections,
onlineCustomers: current.connections,
onlineNodes, nodesTotal: (data.nodes || []).length,
customersTotal: (data.customers || []).length,
},
today: { upBytes: this.todayUp, downBytes: this.todayDown },
totals: { upBytes: current.totalUp, downBytes: current.totalDown },
ips: hostIPs(),
history: this.history,
};
}
}
module.exports = { SystemMonitor, hostIPs, isGlobalIPv4, isGlobalIPv6, readCpuTimes, readMem };
+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")
}
}
+28 -5
View File
@@ -280,7 +280,7 @@ export async function fetchAllSubscriptions(client, token) {
// Learning normalizer // Learning normalizer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc']; const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc', 'src', 'lnk'];
const PREFIXED_ID_RE = new RegExp(`\\b(${ID_PREFIXES.join('|')})_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`, 'g'); const PREFIXED_ID_RE = new RegExp(`\\b(${ID_PREFIXES.join('|')})_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`, 'g');
const BARE_UUID_RE = /(?<![0-9A-Za-z_-])[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![0-9A-Za-z_-])/g; const BARE_UUID_RE = /(?<![0-9A-Za-z_-])[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![0-9A-Za-z_-])/g;
const ISO_TS_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})/g; const ISO_TS_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})/g;
@@ -392,11 +392,30 @@ export class Normalizer {
if (this.panelOrigin) out = out.split(this.panelOrigin).join('<panel>'); if (this.panelOrigin) out = out.split(this.panelOrigin).join('<panel>');
// Learned secrets first (longest raw first so a full token wins over its // 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. // 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); // A base64 secret's prefix truncations (the SS2022 user keys derived
for (const [raw, placeholder] of secrets) { // from a customer's 32-byte root key) are the same logical secret, so
if (raw.length >= 8) out = out.split(raw).join(placeholder); // their encodings are replaced with the same placeholder.
if (raw.length > 16) out = out.split(raw.slice(0, 16)).join(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); 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)); 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); for (const [fragment, placeholder] of this.fragments) out = out.split(fragment).join(placeholder);
@@ -424,6 +443,10 @@ export class Normalizer {
return out; return out;
} }
if (typeof value === 'number') { if (typeof value === 'number') {
// Speeds are derived from adjacent usage reports over wall-clock
// time (dt differs per side by construction), so only their shape
// is comparable: positive vs zero. See UI-RESKIN-REPORT.md.
if (keyName === 'speedUpBps' || keyName === 'speedDownBps') return value > 0 ? '<speed+>' : 0;
if (keyName && PORT_FIELDS.has(keyName) && this.ports.has(value)) return this.ports.get(value); if (keyName && PORT_FIELDS.has(keyName) && this.ports.has(value)) return this.ports.get(value);
return value; return value;
} }
+199
View File
@@ -21,8 +21,11 @@
// executed and the first differing JSON path is reported per step. // executed and the first differing JSON path is reported per step.
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
import { import {
ADMIN_USERNAME, DEFAULT_GO_BIN, DIFFTEST_DIR, Normalizer, PanelClient, ADMIN_USERNAME, DEFAULT_GO_BIN, DIFFTEST_DIR, Normalizer, PanelClient,
Reporter, UA_DEFAULT, completeJob, fetchAllSubscriptions, firstDiff, Reporter, UA_DEFAULT, completeJob, fetchAllSubscriptions, firstDiff,
@@ -284,6 +287,201 @@ async function runPhase2(runner, { seedLog, seedExpect }) {
}); });
} }
// ---------------------------------------------------------------------------
// Phase 3: UI-reskin features — node reorder, presence/speed, /api/system,
// customer-form node alignment, credentials, subscription aggregation.
// Time-derived values (speeds, /api/system numbers) are compared by shape
// only; everything else is compared exactly like phase 2.
// ---------------------------------------------------------------------------
const AGG_FIXTURE_BODY = Buffer.from([
'ss://YWVzLTI1Ni1nY206ZXh0LXB3@ext.example.com:8388#ExtOne',
'vless://22222222-2222-2222-2222-222222222222@ext2.example.com:443?security=none#ExtTwo',
'trojan://x@bad.example.com:443#Dropped',
'',
].join('\n')).toString('base64');
const AGG_MANUAL_LINK = 'ss://YWVzLTI1Ni1nY206bWFudWFsLXB3@manual.example.com:8388#ManualOne';
async function runPhase3(runner) {
// --- node reorder + subscription order ----------------------------------
await runner.step('p3/reorder-nodes', async (side) => {
const res = await side.client.admin('POST', '/api/nodes/reorder', { ids: [side.ids.n2, side.ids.n1] });
const list = await side.client.admin('GET', '/api/nodes');
return {
reorder: view(res),
order: list.json.nodes.map((n) => n.name),
sortIndexes: list.json.nodes.map((n) => n.sortIndex),
};
});
await runner.step('p3/alice-sub-order-after-reorder', async (side) => {
const res = await side.client.getSubscription(side.subTokens.alice, 'raw', UA_DEFAULT);
const names = res.body.split('\n').filter(Boolean).map((line) => {
const idx = line.lastIndexOf('#');
return idx >= 0 ? decodeURIComponent(line.slice(idx + 1)) : line;
});
return { status: res.status, names };
});
// --- presence & speed: two usage reports ~6s apart -----------------------
await runner.step('p3/usage-report-1', async (side) =>
view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
epoch: 'diff-phase2-epoch-1',
samples: [
{ kind: 'user', id: side.ids.alice, uplink: 1500, downlink: 2100 },
{ kind: 'inbound', id: side.ids.n1, uplink: 5000, downlink: 6000 },
],
})));
await runner.step('p3/usage-report-2-after-6s', async (side) => {
await sleep(6000);
return view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
epoch: 'diff-phase2-epoch-1',
samples: [
{ kind: 'user', id: side.ids.alice, uplink: 4500, downlink: 6300 },
{ kind: 'inbound', id: side.ids.n1, uplink: 8000, downlink: 9000 },
],
}));
});
await runner.step('p3/presence-projection', async (side) => {
const customers = await side.client.admin('GET', '/api/customers');
const nodes = await side.client.admin('GET', '/api/nodes');
const nameOf = (id) => { const c = customers.json.customers.find((x) => x.id === id); return c ? c.name : '?'; };
return {
customers: customers.json.customers.map((c) => ({
name: c.name, online: c.online,
speedPositive: (c.speedUpBps || 0) > 0 && (c.speedDownBps || 0) > 0,
hasLastActiveAt: Boolean(c.lastActiveAt),
})),
nodes: nodes.json.nodes.map((n) => ({
name: n.name, clientCount: n.clientCount, onlineCount: n.onlineCount,
onlineNames: (n.onlineCustomerIds || []).map(nameOf),
speedPositive: (n.speedUpBps || 0) > 0,
})),
};
});
await runner.step('p3/system-shape', async (side) => {
const res = await side.client.admin('GET', '/api/system');
const s = res.json || {};
return {
status: res.status, version: s.version,
hostKeys: s.host ? Object.keys(s.host).sort() : null,
networkKeys: s.network ? Object.keys(s.network).sort() : null,
todayKeys: s.today ? Object.keys(s.today).sort() : null,
ipsKeys: s.ips ? Object.keys(s.ips).sort() : null,
historyIsArray: Array.isArray(s.history),
connectionsIsNumber: Boolean(s.network) && typeof s.network.connections === 'number',
panelMemPositive: s.panelMemBytes > 0,
startedAtPresent: typeof s.startedAt === 'string',
};
});
// --- customer-form node alignment (on Carol: no golden subscriptions) ----
await runner.step('p3/align-carol-add-n1', async (side) => {
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [side.ids.n1] });
const nodes = await side.client.admin('GET', '/api/nodes');
const mine = nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol);
if (mine.length) side.ids.carolNc = mine[0].id;
return {
status: res.status, count: mine.length,
nodeNames: mine.map((nc) => { const n = nodes.json.nodes.find((x) => x.id === nc.nodeId); return n ? n.name : '?'; }),
statuses: mine.map((nc) => nc.status),
};
});
// --- credentials ----------------------------------------------------------
await runner.step('p3/credentials-get', async (side) => {
const res = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
return {
status: res.status,
hasToken: typeof res.json.subscriptionToken === 'string' && res.json.subscriptionToken.length > 10,
items: (res.json.items || []).map((entry) => ({
nodeName: entry.nodeName, protocol: entry.protocol, status: entry.status,
hasClientId: Boolean(entry.clientId), hasPassword: Boolean(entry.password),
hasShortId: Boolean(entry.shortId), username: entry.username,
})),
};
});
await runner.step('p3/credentials-regenerate', async (side) => {
const before = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
const regen = await side.client.admin('POST', `/api/customers/${side.ids.carol}/credentials/${side.ids.carolNc}/regenerate`, {});
const after = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
const b = (before.json.items || [])[0] || {};
const a = (after.json.items || [])[0] || {};
return {
status: regen.status,
changed: b.clientId !== a.clientId || b.password !== a.password,
itemNodeName: regen.json && regen.json.item ? regen.json.item.nodeName : null,
itemHasClientId: Boolean(regen.json && regen.json.item && regen.json.item.clientId),
};
});
await runner.step('p3/align-carol-remove-all', async (side) => {
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [] });
const nodes = await side.client.admin('GET', '/api/nodes');
return {
status: res.status,
bindingsForCarol: nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol).length,
};
});
// --- subscription aggregation ---------------------------------------------
// Both panels must allow private subscription sources to reach the
// loopback fixture: flip the setting in each side's data file and
// restart (there is deliberately no settings API).
const fixture = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(AGG_FIXTURE_BODY);
});
await new Promise((resolve) => fixture.listen(0, '127.0.0.1', resolve));
const fixtureUrl = `http://127.0.0.1:${fixture.address().port}/sub`;
try {
for (const side of [runner.sideA, runner.sideB]) {
await stopPanel(side.proc);
const db = JSON.parse(fs.readFileSync(side.dataFile, 'utf8'));
db.settings = db.settings || {};
db.settings.allowPrivateSubSources = true;
fs.writeFileSync(side.dataFile, JSON.stringify(db));
await restartSide(side);
}
await runner.step('p3/agg-relogin', async (side) => view(await side.client.login()));
await runner.step('p3/agg-create-dave', async (side) => {
const res = await side.client.admin('POST', '/api/customers', {
name: 'Dave',
manualLinks: [{ uri: AGG_MANUAL_LINK, remark: '手动一号', enabled: true }],
externalSources: [{ url: fixtureUrl, remark: '机场', enabled: true }],
});
if (res.json && res.json.customer) {
side.ids.dave = res.json.customer.id;
side.subTokens.dave = res.json.customer.subscriptionToken;
const source = (res.json.customer.externalSources || [])[0];
if (source) side.ids.daveSrc = source.id;
}
return {
status: res.status,
manualCount: res.json && res.json.customer ? (res.json.customer.manualLinks || []).length : null,
sourceCount: res.json && res.json.customer ? (res.json.customer.externalSources || []).length : null,
};
});
await runner.step('p3/agg-refresh', async (side) =>
view(await side.client.admin('POST', `/api/customers/${side.ids.dave}/external-sources/${side.ids.daveSrc}/refresh`, {})));
await runner.step('p3/agg-customer-status', async (side) => {
const res = await side.client.admin('GET', '/api/customers');
const dave = res.json.customers.find((c) => c.id === side.ids.dave);
const status = dave && dave.externalStatus ? Object.values(dave.externalStatus)[0] : null;
return {
hasExternalStatus: Boolean(dave && dave.externalStatus),
count: status ? status.count : null,
dropped: status ? status.droppedCount : null,
failCount: status ? status.failCount : null,
hasOkAt: Boolean(status && status.okAt),
lastError: status ? status.lastError : 'missing',
};
});
await runner.step('p3/agg-subscriptions', async (side) =>
fetchAllSubscriptions(side.client, side.subTokens.dave));
} finally {
fixture.close();
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// DB interoperability: swap the two panels' DB files and keep working. // DB interoperability: swap the two panels' DB files and keep working.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -360,6 +558,7 @@ async function runPair({ reporter, kindA, kindB, bin, seedPath, seedLog, seedExp
try { try {
const runner = new StepRunner(reporter, sideA, sideB); const runner = new StepRunner(reporter, sideA, sideB);
await runPhase2(runner, { seedLog, seedExpect }); await runPhase2(runner, { seedLog, seedExpect });
await runPhase3(runner);
await runInterop(reporter, sideA, sideB, { seedExpect }); await runInterop(reporter, sideA, sideB, { seedExpect });
} finally { } finally {
await destroySide(sideA, keep); await destroySide(sideA, keep);
+475
View File
@@ -0,0 +1,475 @@
package main
// External subscription aggregation — mirrors lib/extsub.js (design:
// SUB-AGGREGATION-DESIGN.md). SSRF guard, pinned-IP dialing, redirect
// re-validation, 10s timeout, 1MiB cap, 200 items per source.
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
var LinkSchemes = map[string]bool{
"vless:": true, "ss:": true, "hysteria2:": true, "anytls:": true, "socks5:": true,
}
const maxSubBodyBytes = 1024 * 1024
const maxSubItems = 200
const fetchTimeout = 10 * time.Second
const defaultSubUA = "v2rayN/6.55"
func ValidateLinkUri(uri string) bool {
if uri == "" || len(uri) > 2048 {
return false
}
parsed, err := url.Parse(uri)
if err != nil {
return false
}
return LinkSchemes[parsed.Scheme+":"] && parsed.Hostname() != ""
}
var base64CharsetRe = regexp.MustCompile(`^[A-Za-z0-9+/=_-]+$`)
// ParseLinkList mirrors parseLinkList in lib/extsub.js.
func ParseLinkList(body string) ([]SubCacheItem, int) {
text := body
compact := strings.Join(strings.Fields(text), "")
if len(compact) >= 16 && base64CharsetRe.MatchString(compact) {
normalized := strings.NewReplacer("-", "+", "_", "/").Replace(compact)
if pad := len(normalized) % 4; pad != 0 {
normalized += strings.Repeat("=", 4-pad)
}
if decoded, err := base64.StdEncoding.DecodeString(normalized); err == nil && strings.Contains(string(decoded), "://") {
text = string(decoded)
}
}
items := []SubCacheItem{}
dropped := 0
for _, rawLine := range strings.Split(text, "\n") {
line := strings.TrimSpace(rawLine)
if line == "" {
continue
}
if len(items) >= maxSubItems {
dropped++
continue
}
if !ValidateLinkUri(line) {
dropped++
continue
}
name := ""
if idx := strings.LastIndex(line, "#"); idx >= 0 {
rawName := line[idx+1:]
if decoded, err := url.PathUnescape(rawName); err == nil {
name = decoded
} else {
name = rawName
}
}
items = append(items, SubCacheItem{URI: line, Name: truncateRunes(name, 120)})
}
return items, dropped
}
func isGlobalIPString(ip string) bool {
parsed := net.ParseIP(strings.Trim(ip, "[]"))
if parsed == nil {
return false
}
if parsed.To4() != nil {
return isGlobalIPv4(parsed)
}
return isGlobalIPv6(parsed)
}
func resolveCheckedIP(ctx context.Context, hostname string, allowPrivate bool) (string, error) {
var addresses []string
if ip := net.ParseIP(hostname); ip != nil {
addresses = []string{hostname}
} else {
records, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
if err != nil {
return "", errors.New("域名解析无结果")
}
for _, record := range records {
addresses = append(addresses, record.IP.String())
}
}
if len(addresses) == 0 {
return "", errors.New("域名解析无结果")
}
if !allowPrivate {
for _, ip := range addresses {
if !isGlobalIPString(ip) {
return "", errors.New("订阅源指向非公网地址,已拒绝")
}
}
}
return addresses[0], nil
}
func fetchSourceBody(source ExtSource, settings *Settings) (string, error) {
parsed, err := url.Parse(source.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "", errors.New("仅支持 http/https 订阅源")
}
allowPrivate := settings != nil && settings.AllowPrivateSubSources
dialer := &net.Dialer{Timeout: 5 * time.Second}
pin := func(ctx context.Context, addr string) (string, string, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return "", "", err
}
ip, err := resolveCheckedIP(ctx, host, allowPrivate)
if err != nil {
return "", "", err
}
return ip, port, nil
}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
ip, port, err := pin(ctx, addr)
if err != nil {
return nil, err
}
return dialer.DialContext(ctx, network, net.JoinHostPort(ip, port))
},
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, _ := net.SplitHostPort(addr)
ip, port, err := pin(ctx, addr)
if err != nil {
return nil, err
}
tlsDialer := &tls.Dialer{NetDialer: dialer, Config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}}
return tlsDialer.DialContext(ctx, network, net.JoinHostPort(ip, port))
},
ResponseHeaderTimeout: fetchTimeout,
}
client := &http.Client{
Transport: transport,
Timeout: fetchTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("订阅源重定向次数过多")
}
return nil
},
}
ua := source.UserAgent
if ua == "" {
ua = defaultSubUA
}
req, err := http.NewRequest(http.MethodGet, source.URL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", ua)
req.Header.Set("Accept", "*/*")
resp, err := client.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "", errors.New("订阅源请求超时")
}
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("订阅源返回 HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSubBodyBytes+1))
if err != nil {
return "", err
}
if len(body) > maxSubBodyBytes {
return "", errors.New("订阅源响应超过 1MiB")
}
return string(body), nil
}
type FetchResult struct {
OK bool
Items []SubCacheItem
Dropped int
Error string
}
// FetchExternalSource mirrors fetchExternalSource: never fails hard.
func FetchExternalSource(source ExtSource, settings *Settings) FetchResult {
body, err := fetchSourceBody(source, settings)
if err != nil {
message := err.Error()
if len([]rune(message)) > 240 {
message = truncateRunes(message, 240)
}
return FetchResult{OK: false, Items: []SubCacheItem{}, Error: message}
}
items, dropped := ParseLinkList(body)
return FetchResult{OK: true, Items: items, Dropped: dropped}
}
func RefreshMinutes(settings *Settings) float64 {
if settings == nil || settings.ExternalSubRefreshMinutes <= 0 {
return 60
}
return math.Min(720, math.Max(15, settings.ExternalSubRefreshMinutes))
}
func jitterFactor(sourceID string) float64 {
hash := sha256.Sum256([]byte(sourceID))
return 1 + float64(int(hash[0]%21)-10)/100
}
type dueSource struct {
CustomerID string
Source ExtSource
}
// DueSources mirrors dueSources in lib/extsub.js.
func DueSources(d *Data, nowMs int64, limit int) []dueSource {
out := []dueSource{}
for _, customer := range d.Customers {
for _, source := range customer.ExternalSources {
if !source.Enabled {
continue
}
entry := d.SubSourceCache[source.ID]
if entry == nil || entry.FetchedAt == nil {
out = append(out, dueSource{customer.ID, source})
continue
}
base := RefreshMinutes(&d.Settings) * 60000
failCount := entry.FailCount
if failCount > 10 {
failCount = 10
}
backoff := math.Min(math.Pow(2, float64(failCount))*base, 24*3600*1000)
fetchedMs, _ := ParseISOMs(*entry.FetchedAt)
if float64(nowMs) >= float64(fetchedMs)+backoff*jitterFactor(source.ID) {
out = append(out, dueSource{customer.ID, source})
}
if len(out) >= limit {
return out
}
}
if len(out) >= limit {
break
}
}
if len(out) > limit {
out = out[:limit]
}
return out
}
// ApplyFetchResult mirrors applyFetchResult in lib/extsub.js.
func ApplyFetchResult(d *Data, sourceID string, result FetchResult, nowISO string) *SubSourceCacheEntry {
if d.SubSourceCache == nil {
d.SubSourceCache = map[string]*SubSourceCacheEntry{}
}
entry := d.SubSourceCache[sourceID]
if entry == nil {
entry = &SubSourceCacheEntry{Items: []SubCacheItem{}}
}
entry.FetchedAt = StrPtr(nowISO)
if result.OK {
entry.OkAt = StrPtr(nowISO)
entry.FailCount = 0
entry.LastError = nil
entry.DroppedCount = result.Dropped
entry.Items = result.Items
if entry.Items == nil {
entry.Items = []SubCacheItem{}
}
} else {
entry.FailCount++
entry.LastError = StrPtr(result.Error)
}
d.SubSourceCache[sourceID] = entry
return entry
}
// ---------- save-time normalization ----------
func NormalizeExternalSources(value any) ([]ExtSource, error) {
if value == nil {
return nil, nil
}
arr, ok := value.([]any)
if !ok {
return nil, httpErr(400, "外部订阅格式无效")
}
if len(arr) > 5 {
return nil, httpErr(400, "每个客户最多 5 个外部订阅")
}
out := []ExtSource{}
for _, raw := range arr {
item, _ := raw.(map[string]any)
rawURL := CleanText(item["url"], 2048)
parsed, err := url.Parse(rawURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
return nil, httpErr(400, "外部订阅地址无效")
}
idValue := ""
if s, isStr := item["id"].(string); isStr && s != "" {
idValue = truncateRunes(s, 80)
} else {
idValue = NewID("src")
}
next := ExtSource{
ID: idValue,
URL: rawURL,
Remark: CleanText(item["remark"], 80),
Enabled: true,
}
if enabled, isBool := item["enabled"].(bool); isBool && !enabled {
next.Enabled = false
}
if ua := CleanText(item["userAgent"], 120); ua != "" {
next.UserAgent = ua
}
out = append(out, next)
}
return out, nil
}
func NormalizeManualLinks(value any) ([]ManualLink, error) {
if value == nil {
return nil, nil
}
arr, ok := value.([]any)
if !ok {
return nil, httpErr(400, "外部链接格式无效")
}
if len(arr) > 50 {
return nil, httpErr(400, "每个客户最多 50 条外部链接")
}
out := []ManualLink{}
for _, raw := range arr {
item, _ := raw.(map[string]any)
uri := CleanText(item["uri"], 2048)
if !ValidateLinkUri(uri) {
return nil, httpErr(400, "外部链接无效或协议不受支持")
}
idValue := ""
if s, isStr := item["id"].(string); isStr && s != "" {
idValue = truncateRunes(s, 80)
} else {
idValue = NewID("lnk")
}
next := ManualLink{
ID: idValue,
URI: uri,
Remark: CleanText(item["remark"], 80),
Enabled: true,
}
if enabled, isBool := item["enabled"].(bool); isBool && !enabled {
next.Enabled = false
}
out = append(out, next)
}
return out, nil
}
// ---------- merge helpers (used by subscriptions.go) ----------
func DedupKey(uri string) string {
parsed, err := url.Parse(uri)
if err != nil {
return ""
}
username, password := "", ""
if parsed.User != nil {
username = parsed.User.Username()
password, _ = parsed.User.Password()
}
hash := sha256.Sum256([]byte(username + ":" + password))
return fmt.Sprintf("%s//%s:%s#%s", parsed.Scheme+":", strings.ToLower(parsed.Hostname()), parsed.Port(), hex.EncodeToString(hash[:])[:16])
}
func WithName(uri, name string) string {
base := uri
if idx := strings.LastIndex(uri, "#"); idx >= 0 {
base = uri[:idx]
}
return base + "#" + encodeURIComponent(name)
}
func NameFromURI(uri string) string {
idx := strings.LastIndex(uri, "#")
if idx < 0 {
return ""
}
raw := uri[idx+1:]
if decoded, err := url.PathUnescape(raw); err == nil {
return decoded
}
return raw
}
func strOrNil(s string) any {
if s == "" {
return nil
}
return s
}
// ---------- App-level refresh machinery ----------
// RefreshOneSource fetches one source now and persists the outcome.
func (a *App) RefreshOneSource(customerID, sourceID string) *FetchResult {
data := a.store.Snapshot()
customer := findCustomer(data, customerID)
if customer == nil {
return nil
}
var source *ExtSource
for i := range customer.ExternalSources {
if customer.ExternalSources[i].ID == sourceID {
source = &customer.ExternalSources[i]
break
}
}
if source == nil {
return nil
}
settings := data.Settings
result := FetchExternalSource(*source, &settings)
_, err := a.store.Transaction(func(d *Data) (any, error) {
ApplyFetchResult(d, sourceID, result, NowISO())
return nil, nil
})
if err != nil {
return nil
}
return &result
}
func (a *App) RefreshSourcesInBackground(customerID string, sourceIDs []string) {
for _, sourceID := range sourceIDs {
go func(id string) {
_ = a.RefreshOneSource(customerID, id)
}(sourceID)
}
}
func (a *App) RefreshDueSources() {
data := a.store.Snapshot()
for _, due := range DueSources(data, time.Now().UnixMilli(), 3) {
_ = a.RefreshOneSource(due.CustomerID, due.Source.ID)
}
}
+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);
}
+643 -22
View File
@@ -7,6 +7,7 @@ package main
import ( import (
"crypto/subtle" "crypto/subtle"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
@@ -14,6 +15,7 @@ import (
"net/http" "net/http"
"os" "os"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -36,6 +38,25 @@ type App struct {
loginMu sync.Mutex loginMu sync.Mutex
loginAttempts map[string]*loginAttempt loginAttempts map[string]*loginAttempt
sysmonMu sync.Mutex
sysmon *SystemMonitor
}
// monitor lazily creates the system monitor (data dir = store file dir).
func (a *App) monitor() *SystemMonitor {
a.sysmonMu.Lock()
defer a.sysmonMu.Unlock()
if a.sysmon == nil {
dir := "."
if a.store != nil && a.store.File != "" {
if idx := strings.LastIndex(a.store.File, "/"); idx >= 0 {
dir = a.store.File[:idx]
}
}
a.sysmon = NewSystemMonitor(dir)
}
return a.sysmon
} }
func NewApp(store *Store, sessions *SessionManager, orch *Orchestrator) *App { func NewApp(store *Store, sessions *SessionManager, orch *Orchestrator) *App {
@@ -158,6 +179,16 @@ func NormalizeServer(body map[string]any, current *Server) (*Server, error) {
if next.TLSDomain != "" && !validTLSDomain(next.TLSDomain) { if next.TLSDomain != "" && !validTLSDomain(next.TLSDomain) {
return nil, httpErr(400, "TLS 域名必须是有效的公网域名") return nil, httpErr(400, "TLS 域名必须是有效的公网域名")
} }
if _, ok := body["enabled"]; ok {
enabled := true
if b, isBool := body["enabled"].(bool); isBool && !b {
enabled = false
}
next.Enabled = &enabled
} else if next.Enabled == nil {
enabled := true
next.Enabled = &enabled
}
return &next, nil return &next, nil
} }
@@ -178,6 +209,17 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
topology = "forward" topology = "forward"
} }
direct := topology == "direct" 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 protocol := next.Protocol
if _, ok := body["protocol"]; ok { if _, ok := body["protocol"]; ok {
@@ -202,7 +244,7 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
return nil, httpErr(404, "服务器不存在") return nil, httpErr(404, "服务器不存在")
} }
var exitServerID *string var exitServerID *string
if direct { if direct || manual {
exitServerID = nil exitServerID = nil
} else if _, ok := body["exitServerId"]; ok { } else if _, ok := body["exitServerId"]; ok {
id, err := RequiredText(body["exitServerId"], "出口服务器 ID", 120) id, err := RequiredText(body["exitServerId"], "出口服务器 ID", 120)
@@ -213,7 +255,7 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
} else { } else {
exitServerID = next.ExitServerID exitServerID = next.ExitServerID
} }
if !direct { if !direct && !manual {
found := false found := false
for _, item := range data.Servers { for _, item := range data.Servers {
if item.ID == strVal(exitServerID) { if item.ID == strVal(exitServerID) {
@@ -236,6 +278,26 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
next.Protocol = protocol next.Protocol = protocol
next.Topology = topology next.Topology = topology
next.ExitServerID = exitServerID 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 { if direct {
next.ExitProtocol = nil next.ExitProtocol = nil
} else if _, ok := body["exitProtocol"]; ok { } else if _, ok := body["exitProtocol"]; ok {
@@ -290,8 +352,21 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
_, portModeInBody := body["portMode"] _, portModeInBody := body["portMode"]
_, portInBody := body["port"] _, portInBody := body["port"]
portModeChanged := portModeInBody && portMode != currentPortMode portModeChanged := portModeInBody && portMode != currentPortMode
_, serverIDInBody := body["serverId"] // An already-assigned port is sticky. The browser form always submits
if portModeChanged || portInBody || serverIDInBody || currentPort == 0 { // 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 var requested any
if portInBody { if portInBody {
requested = body["port"] requested = body["port"]
@@ -303,8 +378,52 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
return nil, err return nil, err
} }
next.Port = port 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) exitPortMode := strVal(next.ExitPortMode)
if _, ok := body["exitPortMode"]; ok { if _, ok := body["exitPortMode"]; ok {
if s, _ := body["exitPortMode"].(string); s == "fixed" { if s, _ := body["exitPortMode"].(string); s == "fixed" {
@@ -334,8 +453,17 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
_, exitPortModeInBody := body["exitPortMode"] _, exitPortModeInBody := body["exitPortMode"]
_, exitPortInBody := body["exitPort"] _, exitPortInBody := body["exitPort"]
exitPortModeChanged := exitPortModeInBody && exitPortMode != currentExitPortMode exitPortModeChanged := exitPortModeInBody && exitPortMode != currentExitPortMode
_, exitServerIDInBody := body["exitServerId"] // Same stickiness rule as the entry port: an assigned exit port is
if exitPortModeChanged || exitPortInBody || exitServerIDInBody || currentExitPort == nil { // 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 var requested any
if exitPortInBody { if exitPortInBody {
requested = body["exitPort"] requested = body["exitPort"]
@@ -347,6 +475,28 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
return nil, err return nil, err
} }
next.ExitPort = &port 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 { } else {
next.ExitPortMode = nil next.ExitPortMode = nil
@@ -361,6 +511,64 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
return nil, herr 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 == "" { if IsRealityProtocol(protocol) && next.RealityServerName == "" {
return nil, httpErr(400, "Reality SNI 不能为空") return nil, httpErr(400, "Reality SNI 不能为空")
} }
@@ -413,6 +621,7 @@ func PublicServer(item *Server, data *Data) map[string]any {
} }
} }
out["pendingCleanup"] = pending out["pendingCleanup"] = pending
out["enabled"] = item.Enabled == nil || *item.Enabled
return out return out
} }
@@ -421,9 +630,155 @@ func PublicCustomer(item *Customer, data *Data) map[string]any {
out := recordToMap(item) out := recordToMap(item)
out["observedIpCount"] = len(ObservedIPs(data, item.ID, time.Now())) out["observedIpCount"] = len(ObservedIPs(data, item.ID, time.Now()))
out["subscriptionClientCount"] = len(ActiveClients(data, item.ID, time.Now())) out["subscriptionClientCount"] = len(ActiveClients(data, item.ID, time.Now()))
if len(item.ExternalSources) > 0 {
status := map[string]any{}
for _, source := range item.ExternalSources {
entry := data.SubSourceCache[source.ID]
summary := map[string]any{
"fetchedAt": nil, "okAt": nil, "failCount": 0,
"lastError": nil, "droppedCount": 0, "count": 0,
}
if entry != nil {
if entry.FetchedAt != nil {
summary["fetchedAt"] = *entry.FetchedAt
}
if entry.OkAt != nil {
summary["okAt"] = *entry.OkAt
}
summary["failCount"] = entry.FailCount
if entry.LastError != nil {
summary["lastError"] = *entry.LastError
}
summary["droppedCount"] = entry.DroppedCount
summary["count"] = len(entry.Items)
}
status[source.ID] = summary
}
out["externalStatus"] = status
}
return out return out
} }
// CustomerSummary computes the customers-page stats strip, mirroring
// 3x-ui's client summary semantics (client_paging.go): total / online /
// depleted / depletingSoon / disabled / enabled. "depleted" is 3x-ui's
// depletedExpr — traffic exhausted (limit > 0 and used >= limit) OR expiry
// reached — counted regardless of status. "depletingSoon" is its
// expiringExpr — status active, not depleted, and either the expiry is
// less than settings.expireDiff days away or the remaining traffic is
// less than settings.trafficDiff GB. Both thresholds default to 0 (the
// 3x-ui factory default), under which nobody counts as depleting soon.
// "disabled" counts manual suspensions only; auto-blocks (expired /
// traffic_limit / ip_limit) surface through depleted instead.
func CustomerSummary(data *Data, nowMs int64) map[string]any {
expireDiffMs := data.Settings.ExpireDiff * 86400000
trafficDiffBytes := data.Settings.TrafficDiff * 1073741824
total, online, depleted, depletingSoon, disabled, enabled := len(data.Customers), 0, 0, 0, 0, 0
for _, c := range data.Customers {
if CustomerOnline(c, nowMs) {
online++
}
if c.Status == "active" {
enabled++
}
if c.Status == "suspended" && strVal(c.SuspendReason) == "manual" {
disabled++
}
var expiryMs int64 = -1
if c.ExpiresAt != nil {
if ms, ok := ParseISOMs(*c.ExpiresAt); ok {
expiryMs = ms
}
}
isDepleted := (c.TrafficLimitBytes > 0 && float64(c.UsedBytes) >= c.TrafficLimitBytes) ||
(expiryMs >= 0 && expiryMs <= nowMs)
if isDepleted {
depleted++
continue
}
if c.Status != "active" {
continue
}
nearExpiry := expiryMs >= 0 && float64(expiryMs-nowMs) < expireDiffMs
nearLimit := c.TrafficLimitBytes > 0 && c.TrafficLimitBytes-float64(c.UsedBytes) < trafficDiffBytes
if nearExpiry || nearLimit {
depletingSoon++
}
}
return map[string]any{
"total": total, "online": online, "depleted": depleted,
"depletingSoon": depletingSoon, "disabled": disabled, "enabled": enabled,
}
}
// realityPublicKeyOf returns the node's panel-hosted Reality public key as
// reported back by the agent ("" when unknown). The keypair is the server's
// identity — it stays per-node even though customer credentials are shared.
func realityPublicKeyOf(node *Node) string {
if node == nil {
return ""
}
if raw, ok := node.Artifacts["realityPublicKey"]; ok && len(raw) > 0 {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
}
return ""
}
// customerCredentialsView is the credentials-tab payload (round-2 item 2):
// the customer's ONE credential set, plus per-binding node facts. The
// Reality public key is shown per node because it belongs to the server,
// not to the customer.
func customerCredentialsView(d *Data, customer *Customer) map[string]any {
creds := map[string]any{"clientId": nil, "password": nil, "shortId": nil}
if customer.Credentials != nil {
if customer.Credentials.ClientID != "" {
creds["clientId"] = customer.Credentials.ClientID
}
if customer.Credentials.RelayPassword != "" {
creds["password"] = customer.Credentials.RelayPassword
}
if customer.Credentials.ShortID != "" {
creds["shortId"] = customer.Credentials.ShortID
}
}
bindings := []map[string]any{}
for _, nc := range d.NodeClients {
if nc.CustomerID != customer.ID {
continue
}
node := findNode(d, nc.NodeID)
if node == nil {
continue
}
username := ""
if node.Protocol == "socks5-auth" {
username = NodeEmail(customer.ID)
}
var publicKey any
if key := realityPublicKeyOf(node); key != "" {
publicKey = key
}
bindings = append(bindings, map[string]any{
"nodeClientId": nc.ID,
"nodeId": node.ID,
"nodeName": node.Name,
"protocol": node.Protocol,
"status": nc.Status,
"username": username,
"realityPublicKey": publicKey,
})
}
return map[string]any{
"customerId": customer.ID,
"subscriptionToken": customer.SubscriptionToken,
"credentials": creds,
"bindings": bindings,
}
}
// CustomerBlockReason is customerBlockReason: "expired" / "traffic_limit" / // CustomerBlockReason is customerBlockReason: "expired" / "traffic_limit" /
// "ip_limit", or "" for none (JS null). // "ip_limit", or "" for none (JS null).
func CustomerBlockReason(data *Data, customer *Customer) string { func CustomerBlockReason(data *Data, customer *Customer) string {
@@ -806,6 +1161,7 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
SingBoxInstalled: installed, SingBoxInstalled: installed,
Certificates: certs, Certificates: certs,
SingBoxStatus: singBoxStatus, SingBoxStatus: singBoxStatus,
SingBoxVersion: CleanText(eng["singBoxVersion"], 32),
At: now, At: now,
} }
} }
@@ -1074,6 +1430,9 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
LastSeenAt: now, LastSeenAt: now,
}) })
} }
if customer := findCustomer(d, customerID); customer != nil {
customer.LastOnlineAt = StrPtr(now)
}
if !affectedSet[customerID] { if !affectedSet[customerID] {
affectedSet[customerID] = true affectedSet[customerID] = true
affected = append(affected, customerID) affected = append(affected, customerID)
@@ -1241,6 +1600,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
return true, nil return true, nil
} }
if r.Method == http.MethodGet && pathname == "/api/system" {
data := a.store.Snapshot()
SendJSON(w, 200, a.monitor().Status(data, VERSION), nil)
return true, nil
}
if r.Method == http.MethodGet && pathname == "/api/protocols" { if r.Method == http.MethodGet && pathname == "/api/protocols" {
SendJSON(w, 200, map[string]any{"profiles": ProfileCatalog, "realityPresets": RealityPresets}, nil) SendJSON(w, 200, map[string]any{"profiles": ProfileCatalog, "realityPresets": RealityPresets}, nil)
return true, nil return true, nil
@@ -1558,11 +1923,23 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if r.Method == http.MethodGet && pathname == "/api/customers" { if r.Method == http.MethodGet && pathname == "/api/customers" {
data := a.store.Snapshot() data := a.store.Snapshot()
now := time.Now()
nowMs := now.UnixMilli()
customers := []map[string]any{} customers := []map[string]any{}
for _, item := range data.Customers { for _, item := range data.Customers {
customers = append(customers, PublicCustomer(item, data)) out := PublicCustomer(item, data)
out["online"] = CustomerOnline(item, nowMs)
if item.LastOnlineAt != nil {
out["lastActiveAt"] = *item.LastOnlineAt
} else {
out["lastActiveAt"] = nil
}
up, down := CustomerSpeedBps(item, nowMs)
out["speedUpBps"] = up
out["speedDownBps"] = down
customers = append(customers, out)
} }
SendJSON(w, 200, map[string]any{"customers": customers}, nil) SendJSON(w, 200, map[string]any{"customers": customers, "summary": CustomerSummary(data, nowMs)}, nil)
return true, nil return true, nil
} }
if params, ok := RoutePattern("/api/customers/:id/access", pathname); ok && r.Method == http.MethodGet { if params, ok := RoutePattern("/api/customers/:id/access", pathname); ok && r.Method == http.MethodGet {
@@ -1631,6 +2008,16 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if err != nil { if err != nil {
return true, err return true, err
} }
externalSources, err := NormalizeExternalSources(body["externalSources"])
if err != nil {
return true, err
}
manualLinks, err := NormalizeManualLinks(body["manualLinks"])
if err != nil {
return true, err
}
var createdID string
var refreshIDs []string
customerAny, err := a.store.Transaction(func(d *Data) (any, error) { customerAny, err := a.store.Transaction(func(d *Data) (any, error) {
name, err := RequiredText(body["name"], "客户名称", 120) name, err := RequiredText(body["name"], "客户名称", 120)
if err != nil { if err != nil {
@@ -1652,6 +2039,10 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if err != nil { if err != nil {
return nil, err return nil, err
} }
resetDay, err := ParseResetDay(body["trafficResetDay"])
if err != nil {
return nil, err
}
next := &Customer{ next := &Customer{
ID: NewID("cus"), ID: NewID("cus"),
SubscriptionToken: RandomToken(32), SubscriptionToken: RandomToken(32),
@@ -1665,18 +2056,41 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
ExpiresAt: expires, ExpiresAt: expires,
IPLimit: int64(ipLimit), IPLimit: int64(ipLimit),
DeviceLimit: int64(deviceLimit), DeviceLimit: int64(deviceLimit),
TrafficResetDay: resetDay,
Tags: sliceStrings(asIds(body["tags"]), 20), Tags: sliceStrings(asIds(body["tags"]), 20),
Notes: CleanText(body["notes"], 1000), Notes: CleanText(body["notes"], 1000),
CreatedAt: NowISO(), CreatedAt: NowISO(),
UpdatedAt: NowISO(), UpdatedAt: NowISO(),
Credentials: func() *Credentials { c := NewCustomerCredentials(); return &c }(),
}
// Arm the monthly-reset schedule: it starts with the next
// reset instant, it never fires retroactively at creation.
ArmTrafficReset(next, time.Now())
if len(externalSources) > 0 {
next.ExternalSources = externalSources
}
if len(manualLinks) > 0 {
next.ManualLinks = manualLinks
} }
d.Customers = append(d.Customers, next) d.Customers = append(d.Customers, next)
if body["nodeIds"] != nil {
if err := AlignCustomerNodes(d, next, asIds(body["nodeIds"]), actor); err != nil {
return nil, err
}
}
Audit(d, actor, "create_customer", next.ID, map[string]any{"name": next.Name}) Audit(d, actor, "create_customer", next.ID, map[string]any{"name": next.Name})
createdID = next.ID
for _, source := range externalSources {
refreshIDs = append(refreshIDs, source.ID)
}
return cloneJSONValue(next), nil return cloneJSONValue(next), nil
}) })
if err != nil { if err != nil {
return true, err return true, err
} }
if len(refreshIDs) > 0 {
a.RefreshSourcesInBackground(createdID, refreshIDs)
}
SendJSON(w, 201, map[string]any{"customer": customerAny}, nil) SendJSON(w, 201, map[string]any{"customer": customerAny}, nil)
return true, nil return true, nil
} }
@@ -1685,6 +2099,21 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if err != nil { if err != nil {
return true, err return true, err
} }
var patchSources []ExtSource
if body["externalSources"] != nil {
patchSources, err = NormalizeExternalSources(body["externalSources"])
if err != nil {
return true, err
}
}
var patchLinks []ManualLink
if body["manualLinks"] != nil {
patchLinks, err = NormalizeManualLinks(body["manualLinks"])
if err != nil {
return true, err
}
}
var patchRefreshIDs []string
customerAny, err := a.store.Transaction(func(d *Data) (any, error) { customerAny, err := a.store.Transaction(func(d *Data) (any, error) {
item := findCustomer(d, params["id"]) item := findCustomer(d, params["id"])
if item == nil { if item == nil {
@@ -1692,6 +2121,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
} }
wasActive := item.Status == "active" wasActive := item.Status == "active"
previousReason := strVal(item.SuspendReason) previousReason := strVal(item.SuspendReason)
previousSources := append([]ExtSource{}, item.ExternalSources...)
if _, ok := body["name"]; ok { if _, ok := body["name"]; ok {
name, err := RequiredText(body["name"], "客户名称", 120) name, err := RequiredText(body["name"], "客户名称", 120)
if err != nil { if err != nil {
@@ -1736,6 +2166,20 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if _, ok := body["tags"]; ok { if _, ok := body["tags"]; ok {
item.Tags = sliceStrings(asIds(body["tags"]), 20) item.Tags = sliceStrings(asIds(body["tags"]), 20)
} }
if _, ok := body["trafficResetDay"]; ok {
v, err := ParseResetDay(body["trafficResetDay"])
if err != nil {
return nil, err
}
if v != item.TrafficResetDay {
item.TrafficResetDay = v
// A changed schedule is re-armed rather than fired:
// it takes effect from the next reset instant. An
// unchanged value (the UI echoes it on every edit)
// leaves the marker — and any pending reset — alone.
ArmTrafficReset(item, time.Now())
}
}
reason := CustomerBlockReason(d, item) reason := CustomerBlockReason(d, item)
statusWanted := JSString(body["status"]) statusWanted := JSString(body["status"])
if statusWanted == "active" && reason != "" { if statusWanted == "active" && reason != "" {
@@ -1756,6 +2200,41 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
item.SuspendReason = nil item.SuspendReason = nil
item.SuspendReasonPresent = true item.SuspendReasonPresent = true
} }
if body["externalSources"] != nil {
if len(patchSources) > 0 {
item.ExternalSources = patchSources
} else {
item.ExternalSources = nil
}
keptIDs := map[string]bool{}
for _, source := range patchSources {
keptIDs[source.ID] = true
}
for _, old := range previousSources {
if !keptIDs[old.ID] && d.SubSourceCache != nil {
delete(d.SubSourceCache, old.ID)
}
}
for _, source := range patchSources {
found := false
for _, old := range previousSources {
if old.ID == source.ID && old.URL == source.URL {
found = true
break
}
}
if !found {
patchRefreshIDs = append(patchRefreshIDs, source.ID)
}
}
}
if body["manualLinks"] != nil {
if len(patchLinks) > 0 {
item.ManualLinks = patchLinks
} else {
item.ManualLinks = nil
}
}
if wasActive && item.Status == "suspended" { if wasActive && item.Status == "suspended" {
_, _ = SuspendCustomerResources(d, item, actor) _, _ = SuspendCustomerResources(d, item, actor)
} }
@@ -1764,6 +2243,11 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
return nil, err return nil, err
} }
} }
if body["nodeIds"] != nil {
if err := AlignCustomerNodes(d, item, asIds(body["nodeIds"]), actor); err != nil {
return nil, err
}
}
item.UpdatedAt = NowISO() item.UpdatedAt = NowISO()
Audit(d, actor, "update_customer", item.ID, nil) Audit(d, actor, "update_customer", item.ID, nil)
return cloneJSONValue(item), nil return cloneJSONValue(item), nil
@@ -1771,9 +2255,68 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if err != nil { if err != nil {
return true, err return true, err
} }
if len(patchRefreshIDs) > 0 {
a.RefreshSourcesInBackground(params["id"], patchRefreshIDs)
}
SendJSON(w, 200, map[string]any{"customer": customerAny}, nil) SendJSON(w, 200, map[string]any{"customer": customerAny}, nil)
return true, nil return true, nil
} }
if params, ok := RoutePattern("/api/customers/:id/credentials", pathname); ok && r.Method == http.MethodGet {
data := a.store.Snapshot()
customer := findCustomer(data, params["id"])
if customer == nil {
return true, httpErr(404, "客户不存在")
}
SendJSON(w, 200, customerCredentialsView(data, customer), nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/credentials/regenerate", pathname); ok && r.Method == http.MethodPost {
var view map[string]any
_, err := a.store.Transaction(func(d *Data) (any, error) {
customer := findCustomer(d, params["id"])
if customer == nil {
return nil, httpErr(404, "客户不存在")
}
// One set for the whole customer: rotating it rotates every
// node at once, and every deployed node is rebuilt so the new
// users land on the agents.
creds := NewCustomerCredentials()
customer.Credentials = &creds
customer.UpdatedAt = NowISO()
rebuilt := map[string]bool{}
for _, nc := range d.NodeClients {
if nc.CustomerID != customer.ID || nc.Status != "active" {
continue
}
node := findNode(d, nc.NodeID)
if node == nil || node.Status == "draft" || node.Status == "removing" || rebuilt[node.ID] {
continue
}
rebuilt[node.ID] = true
if err := RebuildNode(d, node, false); err != nil {
return nil, err
}
}
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodesRebuilt": len(rebuilt)})
view = customerCredentialsView(d, customer)
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, view, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/external-sources/:sid/refresh", pathname); ok && r.Method == http.MethodPost {
result := a.RefreshOneSource(params["id"], params["sid"])
if result == nil {
return true, httpErr(404, "订阅源不存在")
}
SendJSON(w, 200, map[string]any{
"ok": result.OK, "count": len(result.Items), "dropped": result.Dropped, "error": strOrNil(result.Error),
}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/reset-usage", pathname); ok && r.Method == http.MethodPost { if params, ok := RoutePattern("/api/customers/:id/reset-usage", pathname); ok && r.Method == http.MethodPost {
_, err := a.store.Transaction(func(d *Data) (any, error) { _, err := a.store.Transaction(func(d *Data) (any, error) {
item := findCustomer(d, params["id"]) item := findCustomer(d, params["id"])
@@ -1838,6 +2381,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
return nil, httpErr(409, "客户仍有节点分配。请先移除全部分配") return nil, httpErr(409, "客户仍有节点分配。请先移除全部分配")
} }
} }
removedCustomer := d.Customers[index]
for _, source := range removedCustomer.ExternalSources {
if d.SubSourceCache != nil {
delete(d.SubSourceCache, source.ID)
}
}
d.Customers = append(d.Customers[:index], d.Customers[index+1:]...) d.Customers = append(d.Customers[:index], d.Customers[index+1:]...)
access := d.SubscriptionAccess[:0] access := d.SubscriptionAccess[:0]
for _, item := range d.SubscriptionAccess { for _, item := range d.SubscriptionAccess {
@@ -1872,14 +2421,31 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if r.Method == http.MethodGet && pathname == "/api/nodes" { if r.Method == http.MethodGet && pathname == "/api/nodes" {
data := a.store.Snapshot() data := a.store.Snapshot()
nowMs := time.Now().UnixMilli()
sortedNodes := append([]*Node{}, data.Nodes...)
sort.SliceStable(sortedNodes, func(i, j int) bool { return sortedNodes[i].SortIndex < sortedNodes[j].SortIndex })
nodes := []map[string]any{} nodes := []map[string]any{}
visibleIDs := map[string]bool{} visibleIDs := map[string]bool{}
for _, item := range data.Nodes { for _, item := range sortedNodes {
if item.PendingDelete { if item.PendingDelete {
continue continue
} }
visibleIDs[item.ID] = true visibleIDs[item.ID] = true
nodes = append(nodes, PublicNode(item)) out := PublicNode(item)
clientCount := 0
for _, nc := range data.NodeClients {
if nc.NodeID == item.ID {
clientCount++
}
}
onlineIDs := NodeOnlineCustomerIds(data, item, nowMs)
out["clientCount"] = clientCount
out["onlineCount"] = len(onlineIDs)
out["onlineCustomerIds"] = onlineIDs
up, down := NodeSpeedBps(item, nowMs)
out["speedUpBps"] = up
out["speedDownBps"] = down
nodes = append(nodes, out)
} }
nodeClients := []map[string]any{} nodeClients := []map[string]any{}
for _, nc := range data.NodeClients { for _, nc := range data.NodeClients {
@@ -1899,6 +2465,44 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
SendJSON(w, 200, map[string]any{"nodes": nodes, "nodeClients": nodeClients}, nil) SendJSON(w, 200, map[string]any{"nodes": nodes, "nodeClients": nodeClients}, nil)
return true, nil return true, nil
} }
if r.Method == http.MethodPost && pathname == "/api/nodes/reorder" {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
_, err = a.store.Transaction(func(d *Data) (any, error) {
ids := asIds(body["ids"])
byID := map[string]*Node{}
for _, item := range d.Nodes {
byID[item.ID] = item
}
ordered := []*Node{}
seen := map[string]bool{}
for _, id := range ids {
if node := byID[id]; node != nil && !seen[id] {
seen[id] = true
ordered = append(ordered, node)
}
}
rest := append([]*Node{}, d.Nodes...)
sort.SliceStable(rest, func(i, j int) bool { return rest[i].SortIndex < rest[j].SortIndex })
for _, item := range rest {
if !seen[item.ID] {
ordered = append(ordered, item)
}
}
for index, item := range ordered {
item.SortIndex = index
}
Audit(d, actor, "reorder_nodes", "nodes", map[string]any{"count": len(ordered)})
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"ok": true}, nil)
return true, nil
}
if r.Method == http.MethodPost && pathname == "/api/nodes" { if r.Method == http.MethodPost && pathname == "/api/nodes" {
body, err := ReadJSON(r, defaultMaxBody) body, err := ReadJSON(r, defaultMaxBody)
if err != nil { if err != nil {
@@ -1916,6 +2520,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
next.ExitReady = false next.ExitReady = false
next.ResourceID = NewID("res") next.ResourceID = NewID("res")
next.ExitResourceID = 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.Artifacts = map[string]json.RawMessage{}
next.Error = nil next.Error = nil
next.PendingDelete = false next.PendingDelete = false
@@ -1926,6 +2536,13 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
next.CreatedAt = NowISO() next.CreatedAt = NowISO()
next.UpdatedAt = NowISO() next.UpdatedAt = NowISO()
EnsureNodeSecrets(next) EnsureNodeSecrets(next)
maxIndex := -1
for _, item := range d.Nodes {
if item.SortIndex > maxIndex {
maxIndex = item.SortIndex
}
}
next.SortIndex = maxIndex + 1
d.Nodes = append(d.Nodes, next) d.Nodes = append(d.Nodes, next)
Audit(d, actor, "create_node", next.ID, map[string]any{"name": next.Name}) Audit(d, actor, "create_node", next.ID, map[string]any{"name": next.Name})
return cloneJSONValue(next), nil return cloneJSONValue(next), nil
@@ -1951,28 +2568,29 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
} }
oldServerID := item.ServerID oldServerID := item.ServerID
oldExitServerID := strVal(item.ExitServerID) oldExitServerID := strVal(item.ExitServerID)
oldExitResourceID := item.ExitResourceID
oldForward := forwardNode(item) oldForward := forwardNode(item)
oldProtocol := item.Protocol
next, err := NormalizeNode(body, item, d) next, err := NormalizeNode(body, item, d)
if err != nil { if err != nil {
return nil, err return nil, err
} }
*item = *next *item = *next
EnsureNodeSecrets(item) 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 { if item.ServerID != oldServerID {
QueueJob(d, oldServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ResourceID, Kind: "node"}) QueueJob(d, oldServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ResourceID, Kind: "node"})
item.EntryReady = false item.EntryReady = false
} }
if oldForward && (!forwardNode(item) || strVal(item.ExitServerID) != oldExitServerID) { if oldForward && oldExitResourceID != "" && (!forwardNode(item) || manualExit(item) || strVal(item.ExitServerID) != oldExitServerID) {
QueueJob(d, oldExitServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ExitResourceID, Kind: "node-exit"}) 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 item.ExitReady = false
} }
if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" { if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" {
@@ -1980,6 +2598,9 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
return nil, err return nil, err
} }
} }
if manualExit(item) {
item.ExitReady = true
}
item.UpdatedAt = NowISO() item.UpdatedAt = NowISO()
Audit(d, actor, "update_node", item.ID, map[string]any{"name": item.Name}) Audit(d, actor, "update_node", item.ID, map[string]any{"name": item.Name})
return cloneJSONValue(item), nil return cloneJSONValue(item), nil
+39
View File
@@ -194,6 +194,15 @@ func (a *App) housekeeping() {
} }
} }
} }
// Monthly traffic resets run before the quota evaluation below,
// so a customer whose new period just started is judged on the
// zeroed counters (and a reset can lift a traffic suspension in
// the same pass). Due-ness derives from the persisted period
// marker, so this also catches up resets missed while the panel
// was down — at the first pass after it is back (<=60s).
if err := ApplyMonthlyTrafficResets(d, time.Now()); err != nil {
return nil, err
}
for _, customer := range d.Customers { for _, customer := range d.Customers {
expired := false expired := false
if customer.ExpiresAt != nil { if customer.ExpiresAt != nil {
@@ -263,6 +272,10 @@ func (a *App) housekeeping() {
return return
} }
a.getSessions().Prune() a.getSessions().Prune()
// System-status sampling + due external-source refreshes (outside the
// transaction; fetches must not hold the store lock).
a.monitor().Sample(a.store.Snapshot(), false)
a.RefreshDueSources()
} }
// ---------- main ---------- // ---------- main ----------
@@ -352,6 +365,32 @@ func run() error {
} }
} }
// Node sort order migration (idempotent): unless the stored
// sortIndex values are exactly the permutation 0..n-1, renumber by
// storage order. Mirrors the Node implementation's boot migration.
{
nodes := store.Snapshot().Nodes
seen := map[int]bool{}
valid := true
for _, node := range nodes {
if node.SortIndex < 0 || node.SortIndex >= len(nodes) || seen[node.SortIndex] {
valid = false
break
}
seen[node.SortIndex] = true
}
if !valid {
if _, err := store.Transaction(func(d *Data) (any, error) {
for index, node := range d.Nodes {
node.SortIndex = index
}
return nil, nil
}); err != nil {
return err
}
}
}
app := NewApp(store, nil, NewOrchestrator(store)) app := NewApp(store, nil, NewOrchestrator(store))
app.replaceSessions(NewSessionManager(app.sessionHours())) app.replaceSessions(NewSessionManager(app.sessionHours()))
+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)
}
}
+265 -77
View File
@@ -59,6 +59,13 @@ func forwardNode(node *Node) bool {
return topology == "forward" 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 { func tagForNode(node *Node) string {
var sb strings.Builder var sb strings.Builder
for _, r := range node.ID { for _, r := range node.ID {
@@ -239,6 +246,16 @@ func payloadWithResource(resource map[string]any, kind string, keyMaterial map[s
return payload 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) { func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any, error) {
server := orchFindServer(d, node.ServerID) server := orchFindServer(d, node.ServerID)
if server == nil { if server == nil {
@@ -253,7 +270,10 @@ func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any
return nil, err return nil, err
} }
var exitServer *Server var exitServer *Server
if forwardNode(node) { exitAddress := ""
if manualExit(node) {
exitAddress = node.ExitAddress
} else if forwardNode(node) {
exitServer = orchFindServer(d, xbDeref(node.ExitServerID)) exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
if exitServer == nil { if exitServer == nil {
return nil, orchNotFound("出口服务器") return nil, orchNotFound("出口服务器")
@@ -262,14 +282,30 @@ func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any
if clients == nil { if clients == nil {
clients = activeNodeClients(d, node) 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{ return BuildNodeResource(BuildNodeResourceParams{
ResourceID: node.ResourceID, ResourceID: node.ResourceID,
TagPrefix: tagForNode(node), TagPrefix: tagForNode(node),
Node: node, Node: node,
NodeClients: clients, NodeClients: resolved,
Server: server, Server: server,
BindAddress: bindAddress, BindAddress: bindAddress,
ExitServer: exitServer, ExitServer: exitServer,
ExitAddress: exitAddress,
}) })
} }
@@ -331,7 +367,7 @@ func RebuildNode(d *Data, node *Node, includeExit bool) error {
} }
payload := payloadWithResource(resource, "node", KeyMaterialForNode(d, node)) payload := payloadWithResource(resource, "node", KeyMaterialForNode(d, node))
QueueJob(d, node.ServerID, node.ID, "apply_resource", payload) QueueJob(d, node.ServerID, node.ID, "apply_resource", payload)
if forwardNode(node) && includeExit { if forwardNode(node) && includeExit && !manualExit(node) {
exitResource, err := resourceForNodeExit(d, node) exitResource, err := resourceForNodeExit(d, node)
if err != nil { if err != nil {
return err return err
@@ -351,7 +387,7 @@ func DeriveNodeStatus(node *Node) {
} }
return return
} }
if forwardNode(node) && !node.ExitReady { if forwardNode(node) && !node.ExitReady && !manualExit(node) {
if node.Status != "degraded" { if node.Status != "degraded" {
node.Status = "deploying" node.Status = "deploying"
} }
@@ -606,12 +642,21 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
return orchNotFound("服务器") return orchNotFound("服务器")
} }
var exitServer *Server var exitServer *Server
if !direct { if !direct && !manualExit(node) {
exitServer = orchFindServer(d, xbDeref(node.ExitServerID)) exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
if exitServer == nil { if exitServer == nil {
return orchNotFound("出口服务器") return orchNotFound("出口服务器")
} }
} }
targets := []*Server{server}
if exitServer != nil {
targets = append(targets, exitServer)
}
for _, target := range targets {
if target.Enabled != nil && !*target.Enabled {
return httpErr(409, "设备 %s 已停用", target.Name)
}
}
if len(activeNodeClients(d, node)) == 0 { if len(activeNodeClients(d, node)) == 0 {
return httpErr(400, "请先给节点分配至少一个客户再部署") return httpErr(400, "请先给节点分配至少一个客户再部署")
} }
@@ -657,7 +702,7 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
} }
} }
servers := []*Server{server} servers := []*Server{server}
if !direct { if exitServer != nil {
servers = []*Server{server, exitServer} servers = []*Server{server, exitServer}
} }
nowMs := time.Now().UnixMilli() nowMs := time.Now().UnixMilli()
@@ -702,7 +747,7 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
if NodePortInUse(d, node.ServerID, node.Port, node.ID) { if NodePortInUse(d, node.ServerID, node.Port, node.ID) {
return httpErr(409, "端口 %d 已被该服务器上的其他节点占用", node.Port) 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 httpErr(409, "出口端口 %d 已被该服务器上的其他节点占用", *node.ExitPort)
} }
return nil return nil
@@ -720,6 +765,10 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
if server == nil { if server == nil {
return 0, orchNotFound("服务器") return 0, orchNotFound("服务器")
} }
// A disabled device receives no new deployments, including reconciles.
if server.Enabled != nil && !*server.Enabled {
return 0, nil
}
failInFlight := true failInFlight := true
if opts.FailInFlight != nil { if opts.FailInFlight != nil {
failInFlight = *opts.FailInFlight failInFlight = *opts.FailInFlight
@@ -935,61 +984,178 @@ func (o *Orchestrator) RepairNode(nodeID, actor string) (bool, error) {
return err == nil, err return err == nil, err
} }
// assignCustomerData is the transaction-free core of AssignCustomer,
// shared with the customer form's node alignment (AlignCustomerNodes).
func assignCustomerData(d *Data, nodeID, customerID, actor string) (*NodeClient, error) {
node := orchFindNode(d, nodeID)
if node == nil {
return nil, orchNotFound("节点")
}
customer := orchFindCustomer(d, customerID)
if customer == nil {
return nil, orchNotFound("客户")
}
if customer.Status != "active" {
return nil, httpErr(409, "只能给状态正常的客户分配节点")
}
var nc *NodeClient
for _, entry := range d.NodeClients {
if entry.NodeID == nodeID && entry.CustomerID == customerID {
nc = entry
break
}
}
if nc != nil && nc.Status == "active" {
return nil, httpErr(409, "该客户已分配到此节点")
}
if nc != nil {
nc.Status = "active"
nc.UpdatedAt = NowISO()
} else {
now := NowISO()
ensureCustomerCredentials(d, customerID)
nc = &NodeClient{
ID: NewID("ncl"),
NodeID: nodeID,
CustomerID: customerID,
Status: "active",
CreatedAt: now,
UpdatedAt: now,
}
d.NodeClients = append(d.NodeClients, nc)
}
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
// Record the assignment only.
} else {
if node.Status == "removing" && boolVal(node.AutoRemoving) {
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
node.AutoRemoving = BoolPtr(false)
}
if err := RebuildNode(d, node, false); err != nil {
return nil, err
}
}
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
return nc, nil
}
func unassignCustomerData(d *Data, nodeID, customerID, actor string) error {
node := orchFindNode(d, nodeID)
if node == nil {
return orchNotFound("节点")
}
index := -1
for i, entry := range d.NodeClients {
if entry.NodeID == nodeID && entry.CustomerID == customerID {
index = i
break
}
}
if index < 0 {
return httpErr(404, "该客户未分配到此节点")
}
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
if node.Status != "draft" && node.Status != "removing" {
if err := RebuildNode(d, node, false); err != nil {
return err
}
}
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
return nil
}
// AlignCustomerNodes mirrors alignCustomerNodes in lib/orchestrator.js:
// full-set alignment of one customer's node bindings (customer form's
// 关联入站 multi-select). Suspended customers get suspended bindings.
func AlignCustomerNodes(d *Data, customer *Customer, desiredIds []string, actor string) error {
desired := []string{}
seen := map[string]bool{}
for _, nodeID := range desiredIds {
if nodeID == "" || seen[nodeID] {
continue
}
seen[nodeID] = true
node := orchFindNode(d, nodeID)
if node == nil || node.PendingDelete {
return httpErr(400, "节点不存在")
}
desired = append(desired, nodeID)
}
rebuild := map[string]bool{}
kept := []*NodeClient{}
for _, nc := range d.NodeClients {
if nc.CustomerID != customer.ID {
kept = append(kept, nc)
continue
}
if seen[nc.NodeID] {
kept = append(kept, nc)
continue
}
Audit(d, actor, "unassign_customer", nc.NodeID, map[string]any{"customerId": customer.ID})
rebuild[nc.NodeID] = true
}
d.NodeClients = kept
for _, nodeID := range desired {
exists := false
for _, nc := range d.NodeClients {
if nc.CustomerID == customer.ID && nc.NodeID == nodeID {
exists = true
break
}
}
if exists {
continue
}
node := orchFindNode(d, nodeID)
status := "suspended"
if customer.Status == "active" {
status = "active"
}
now := NowISO()
ensureCustomerCredentials(d, customer.ID)
d.NodeClients = append(d.NodeClients, &NodeClient{
ID: NewID("ncl"),
NodeID: nodeID,
CustomerID: customer.ID,
Status: status,
CreatedAt: now,
UpdatedAt: now,
})
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customer.ID, "customer": customer.Name})
if status == "active" {
rebuild[nodeID] = true
}
}
for nodeID := range rebuild {
node := orchFindNode(d, nodeID)
if node == nil {
continue
}
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
continue
}
if node.Status == "removing" && boolVal(node.AutoRemoving) {
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
node.AutoRemoving = BoolPtr(false)
}
if err := RebuildNode(d, node, false); err != nil {
return err
}
}
return nil
}
func (o *Orchestrator) AssignCustomer(nodeID, customerID, actor string) (*NodeClient, error) { func (o *Orchestrator) AssignCustomer(nodeID, customerID, actor string) (*NodeClient, error) {
if actor == "" { if actor == "" {
actor = "admin" actor = "admin"
} }
var result *NodeClient var result *NodeClient
err := o.tx(func(d *Data) error { err := o.tx(func(d *Data) error {
node := orchFindNode(d, nodeID) nc, err := assignCustomerData(d, nodeID, customerID, actor)
if node == nil { if err != nil {
return orchNotFound("节点") return err
} }
customer := orchFindCustomer(d, customerID)
if customer == nil {
return orchNotFound("客户")
}
if customer.Status != "active" {
return httpErr(409, "只能给状态正常的客户分配节点")
}
var nc *NodeClient
for _, entry := range d.NodeClients {
if entry.NodeID == nodeID && entry.CustomerID == customerID {
nc = entry
break
}
}
if nc != nil && nc.Status == "active" {
return httpErr(409, "该客户已分配到此节点")
}
if nc != nil {
nc.Status = "active"
nc.UpdatedAt = NowISO()
} else {
now := NowISO()
nc = &NodeClient{
ID: NewID("ncl"),
NodeID: nodeID,
CustomerID: customerID,
Credentials: NewNodeClientCredentials(node.Protocol),
Status: "active",
CreatedAt: now,
UpdatedAt: now,
}
d.NodeClients = append(d.NodeClients, nc)
}
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
// Record the assignment only.
} else {
if node.Status == "removing" && boolVal(node.AutoRemoving) {
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
node.AutoRemoving = BoolPtr(false)
}
if err := RebuildNode(d, node, false); err != nil {
return err
}
}
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
result = cloneNodeClientRecord(nc) result = cloneNodeClientRecord(nc)
return nil return nil
}) })
@@ -1001,28 +1167,7 @@ func (o *Orchestrator) UnassignCustomer(nodeID, customerID, actor string) (bool,
actor = "admin" actor = "admin"
} }
err := o.tx(func(d *Data) error { err := o.tx(func(d *Data) error {
node := orchFindNode(d, nodeID) return unassignCustomerData(d, nodeID, customerID, actor)
if node == nil {
return orchNotFound("节点")
}
index := -1
for i, entry := range d.NodeClients {
if entry.NodeID == nodeID && entry.CustomerID == customerID {
index = i
break
}
}
if index < 0 {
return httpErr(404, "该客户未分配到此节点")
}
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
if node.Status != "draft" && node.Status != "removing" {
if err := RebuildNode(d, node, false); err != nil {
return err
}
}
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
return nil
}) })
return err == nil, err return err == nil, err
} }
@@ -1298,6 +1443,9 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
slot = &UsageSlot{} slot = &UsageSlot{}
customer.ServerUsage[key] = slot customer.ServerUsage[key] = slot
} }
prevCounters := slot.UsageCounters
prevSampleISO := slot.LastSampleAt
restarted := prevCounters != nil && prevCounters.Epoch != nil && sampleEpoch != nil && *sampleEpoch != *prevCounters.Epoch
delta := ApplyDelta(SlotHolder(slot), uplink, downlink, sampleEpoch) delta := ApplyDelta(SlotHolder(slot), uplink, downlink, sampleEpoch)
if delta == nil { if delta == nil {
continue continue
@@ -1307,11 +1455,33 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
customer.UsedBytes += delta.UpDelta + delta.DownDelta customer.UsedBytes += delta.UpDelta + delta.DownDelta
customer.LastUsageAt = StrPtr(reportedAt) customer.LastUsageAt = StrPtr(reportedAt)
customer.UpdatedAt = reportedAt customer.UpdatedAt = reportedAt
slot.LastSampleAt = StrPtr(reportedAt)
if delta.UpDelta > 0 || delta.DownDelta > 0 {
slot.LastActiveAt = StrPtr(reportedAt)
customer.LastOnlineAt = StrPtr(reportedAt)
}
if !restarted && prevCounters != nil && prevSampleISO != nil {
if prevMs, ok1 := ParseISOMs(*prevSampleISO); ok1 {
if nowMs, ok2 := ParseISOMs(reportedAt); ok2 {
dt := float64(nowMs-prevMs) / 1000
if dt >= 5 {
up := float64(int64(math.Round(float64(uplink-prevCounters.Uplink) / dt)))
down := float64(int64(math.Round(float64(downlink-prevCounters.Downlink) / dt)))
slot.SpeedUpBps = &up
slot.SpeedDownBps = &down
slot.SpeedAt = StrPtr(reportedAt)
}
}
}
}
case "inbound": case "inbound":
node := orchFindNode(d, sample.ID) node := orchFindNode(d, sample.ID)
if node == nil || node.ServerID != serverID { if node == nil || node.ServerID != serverID {
continue continue
} }
prevCounters := node.UsageCounters
prevSampleISO := node.LastSampleAt
restarted := prevCounters != nil && prevCounters.Epoch != nil && sampleEpoch != nil && *sampleEpoch != *prevCounters.Epoch
delta := ApplyDelta(NodeHolder(node), uplink, downlink, sampleEpoch) delta := ApplyDelta(NodeHolder(node), uplink, downlink, sampleEpoch)
if delta == nil { if delta == nil {
continue continue
@@ -1323,6 +1493,24 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
} }
node.MeteredTraffic = &MeteredTraffic{Uplink: mu + delta.UpDelta, Downlink: md + delta.DownDelta} node.MeteredTraffic = &MeteredTraffic{Uplink: mu + delta.UpDelta, Downlink: md + delta.DownDelta}
node.LastUsageAt = StrPtr(reportedAt) node.LastUsageAt = StrPtr(reportedAt)
node.LastSampleAt = StrPtr(reportedAt)
if delta.UpDelta > 0 || delta.DownDelta > 0 {
node.LastActiveAt = StrPtr(reportedAt)
}
if !restarted && prevCounters != nil && prevSampleISO != nil {
if prevMs, ok1 := ParseISOMs(*prevSampleISO); ok1 {
if nowMs, ok2 := ParseISOMs(reportedAt); ok2 {
dt := float64(nowMs-prevMs) / 1000
if dt >= 5 {
up := float64(int64(math.Round(float64(uplink-prevCounters.Uplink) / dt)))
down := float64(int64(math.Round(float64(downlink-prevCounters.Downlink) / dt)))
node.SpeedUpBps = &up
node.SpeedDownBps = &down
node.SpeedAt = StrPtr(reportedAt)
}
}
}
}
} }
} }
return nil return nil
+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")
}
}
+94
View File
@@ -0,0 +1,94 @@
package main
// Presence & speed derivation — mirrors lib/presence.js. See that file
// for the signal model (per-server user counters, per-node inbound
// counters, AnyTLS observations; 3-minute window; server-level activity
// attributed to the customer's nodes on that server).
import "time"
const PresenceWindowMs = 180000
const SpeedStaleMs = 150000
func parseISOMsOr0(value *string) int64 {
if value == nil {
return 0
}
ms, ok := ParseISOMs(*value)
if !ok {
return 0
}
return ms
}
func CustomerOnline(customer *Customer, nowMs int64) bool {
ms := parseISOMsOr0(customer.LastOnlineAt)
return ms > 0 && nowMs-ms <= PresenceWindowMs
}
func CustomerSpeedBps(customer *Customer, nowMs int64) (float64, float64) {
var up, down float64
for _, slot := range customer.ServerUsage {
if slot == nil || slot.SpeedAt == nil {
continue
}
if nowMs-parseISOMsOr0(slot.SpeedAt) > SpeedStaleMs {
continue
}
if slot.SpeedUpBps != nil {
up += *slot.SpeedUpBps
}
if slot.SpeedDownBps != nil {
down += *slot.SpeedDownBps
}
}
return up, down
}
func NodeSpeedBps(node *Node, nowMs int64) (float64, float64) {
if node.SpeedAt == nil || nowMs-parseISOMsOr0(node.SpeedAt) > SpeedStaleMs {
return 0, 0
}
var up, down float64
if node.SpeedUpBps != nil {
up = *node.SpeedUpBps
}
if node.SpeedDownBps != nil {
down = *node.SpeedDownBps
}
return up, down
}
// NodeOnlineCustomerIds mirrors nodeOnlineCustomerIds in lib/presence.js.
func NodeOnlineCustomerIds(d *Data, node *Node, nowMs int64) []string {
out := []string{}
for _, nc := range d.NodeClients {
if nc.NodeID != node.ID || nc.Status != "active" {
continue
}
customer := findCustomer(d, nc.CustomerID)
if customer == nil {
continue
}
var ms int64
if slot := customer.ServerUsage["srv:"+node.ServerID]; slot != nil && slot.LastActiveAt != nil {
ms = parseISOMsOr0(slot.LastActiveAt)
} else {
ms = parseISOMsOr0(customer.LastOnlineAt)
}
if ms > 0 && nowMs-ms <= PresenceWindowMs {
out = append(out, customer.ID)
}
}
return out
}
func CountOnlineCustomers(d *Data, now time.Time) int {
count := 0
for _, customer := range d.Customers {
if CustomerOnline(customer, now.UnixMilli()) {
count++
}
}
return count
}
+55 -18
View File
@@ -196,18 +196,47 @@ func (q xbQuery) String() string {
func NodeEmail(customerID string) string { return "ng:" + customerID } func NodeEmail(customerID string) string { return "ng:" + customerID }
func NewNodeClientCredentials(protocol string) Credentials { // NewCustomerCredentials mints the single credential set a customer uses on
keyBytes := 24 // every node (round-2 item 2, 3x-ui model). The relay password is always a
if cipher, ok := ShadowsocksMethods[protocol]; ok { // base64 32-byte root key: SS2022 user keys are derived from it per method
keyBytes = cipher.KeyBytes // (see EffectiveRelayPassword), so one set serves aes128 and aes256 nodes
} // alike; every other protocol uses it verbatim.
func NewCustomerCredentials() Credentials {
return Credentials{ return Credentials{
ClientID: NewUUID(), ClientID: NewUUID(),
RelayPassword: b64(keyBytes), RelayPassword: b64(32),
ShortID: shortId(), 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) { func EnsureNodeSecrets(node *Node) {
if node.WSPath == nil || *node.WSPath == "" { if node.WSPath == nil || *node.WSPath == "" {
node.WSPath = StrPtr("/" + hex.EncodeToString(xbRandBytes(9))) 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)) users := make([]any, 0, len(nodeClients))
shortIDs := make([]any, 0, len(nodeClients)) shortIDs := make([]any, 0, len(nodeClients))
for _, nc := range 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" { if protocol == "vless-reality-vision" {
user["flow"] = "xtls-rprx-vision" user["flow"] = "xtls-rprx-vision"
} }
users = append(users, user) users = append(users, user)
shortIDs = append(shortIDs, nc.Credentials.ShortID) shortIDs = append(shortIDs, creds.ShortID)
} }
destPort := node.Reality.DestPort destPort := node.Reality.DestPort
if destPort == 0 { if destPort == 0 {
@@ -383,7 +413,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
} }
users := make([]any, 0, len(nodeClients)) users := make([]any, 0, len(nodeClients))
for _, nc := range 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) wsPath := xbDeref(node.WSPath)
if wsPath == "" { if wsPath == "" {
@@ -406,7 +436,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
if cipher, ok := ShadowsocksMethods[protocol]; ok { if cipher, ok := ShadowsocksMethods[protocol]; ok {
users := make([]any, 0, len(nodeClients)) users := make([]any, 0, len(nodeClients))
for _, nc := range 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{ return map[string]any{
"type": "shadowsocks", "tag": tag, "listen": listen, "listen_port": port, "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" { if protocol == "socks5-auth" {
users := make([]any, 0, len(nodeClients)) users := make([]any, 0, len(nodeClients))
for _, nc := range 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 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)) users := make([]any, 0, len(nodeClients))
for _, nc := range 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{ return map[string]any{
"type": "hysteria2", "tag": tag, "listen": listen, "listen_port": port, "users": users, "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)) users := make([]any, 0, len(nodeClients))
for _, nc := range 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{ return map[string]any{
"type": "anytls", "tag": tag, "listen": listen, "listen_port": port, "users": users, "type": "anytls", "tag": tag, "listen": listen, "listen_port": port, "users": users,
@@ -469,6 +499,9 @@ type BuildNodeResourceParams struct {
Server *Server Server *Server
BindAddress string BindAddress string
ExitServer *Server 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) { func BuildNodeResource(p BuildNodeResourceParams) (map[string]any, error) {
@@ -492,9 +525,13 @@ func BuildNodeResource(p BuildNodeResourceParams) (map[string]any, error) {
if !forward { if !forward {
outbound = map[string]any{"type": "direct", "tag": outboundTag} outbound = map[string]any{"type": "direct", "tag": outboundTag}
} else { } else {
exitAddr, err := serverAddress(p.ExitServer, networkMode) exitAddr := p.ExitAddress
if err != nil { if exitAddr == "" {
return nil, err 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)} base := map[string]any{"tag": outboundTag, "server": exitAddr, "server_port": xbDerefInt(node.ExitPort)}
creds := node.ExitCredentials 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 return fmt.Sprintf("vless://%s@%s:%d?%s#%s", p.ClientID, host, p.RelayPort, q.String(), label), true
} }
if cipher, ok := ShadowsocksMethods[protocol]; ok { if cipher, ok := ShadowsocksMethods[protocol]; ok {
password := p.RelayPassword password := EffectiveRelayPassword(protocol, p.RelayPassword)
if strings.HasPrefix(cipher.Method, "2022-blake3-") && p.SSServerKey != "" { 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)) user := base64.RawURLEncoding.EncodeToString([]byte(cipher.Method + ":" + password))
return fmt.Sprintf("ss://%s@%s:%d#%s", user, host, p.RelayPort, label), true 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"}, ExitCredentials: &ExitCredentials{ClientID: "11111111-2222-3333-4444-0000000000ff", Password: exitPassword, SocksUser: "ng_0123456789"},
} }
rfClients := []*NodeClient{ 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_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_2", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000002", RelayPassword: "unused2", ShortID: "abcd0002ef01"}},
} }
entryServer := &Server{ID: "srv_entry", PublicAddress: "203.0.113.10"} entryServer := &Server{ID: "srv_entry", PublicAddress: "203.0.113.10"}
exitServer := &Server{ID: "srv_exit", PublicAddress: "exit.example.com"} exitServer := &Server{ID: "srv_exit", PublicAddress: "exit.example.com"}
@@ -102,7 +102,7 @@ func TestGoldenResources(t *testing.T) {
SSServerKey: StrPtr("CwwNDg8QERITFBUWFxgZGg=="), SSServerKey: StrPtr("CwwNDg8QERITFBUWFxgZGg=="),
} }
ssClients := []*NodeClient{ 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"} ssServer := &Server{ID: "srv_entry", PublicAddress: "203.0.113.11"}
+132
View File
@@ -0,0 +1,132 @@
package main
// Monthly traffic reset. A customer with trafficResetDay (1-31) gets the
// cycle counters (usedBytes / usedUplinkBytes / usedDownlinkBytes — the
// same fields the quota checks and the subscription userinfo read) zeroed
// once per monthly period, from the regular 60s housekeeping pass.
//
// The design is deliberately stateless and restart-safe: whether a reset
// is due is derived only from the persisted trafficResetPeriod marker, so
// a reset instant missed while the panel was down fires at the first
// housekeeping pass after it comes back — no in-memory schedule to lose.
// All dates are panel-local, matching the rest of housekeeping.
import "time"
// daysInMonth returns the number of days in the given month (pure
// calendar math, location-independent).
func daysInMonth(year int, month time.Month) int {
// Day 0 of the next month is the last day of this one.
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
}
// resetInstant returns the scheduled reset instant inside the given
// month: the reset day clamped to the month's length (the 31st fires on
// Feb 28), at 00:00 in loc.
func resetInstant(year int, month time.Month, day int, loc *time.Location) time.Time {
if dim := daysInMonth(year, month); day > dim {
day = dim
}
return time.Date(year, month, day, 0, 0, 0, 0, loc)
}
func prevMonth(year int, month time.Month) (int, time.Month) {
if month == time.January {
return year - 1, time.December
}
return year, month - 1
}
// TrafficResetPeriodKey names the monthly period `now` belongs to for the
// given reset day: the "2006-01" of the most recent scheduled reset
// instant at or before now. Crossing a reset instant flips the key.
func TrafficResetPeriodKey(now time.Time, day int) string {
loc := now.Location()
year, month, _ := now.Date()
cand := resetInstant(year, month, day, loc)
if now.Before(cand) {
// This month's reset instant is still ahead: the current period
// began at last month's reset instant.
year, month = prevMonth(year, month)
cand = resetInstant(year, month, day, loc)
}
return cand.Format("2006-01")
}
// ArmTrafficReset (re)arms a customer's period marker for its current
// reset day WITHOUT zeroing usage: the schedule takes effect from the next
// reset instant. Used when the reset day is set or changed through the
// API, and for customers whose marker is empty (data written before this
// feature existed, or externally — e.g. by the future 3x-ui converter,
// which only has to write trafficResetDay).
func ArmTrafficReset(customer *Customer, now time.Time) {
if customer.TrafficResetDay < 1 || customer.TrafficResetDay > 31 {
customer.TrafficResetPeriod = ""
return
}
customer.TrafficResetPeriod = TrafficResetPeriodKey(now, customer.TrafficResetDay)
}
// ApplyMonthlyTrafficResets performs the due monthly resets for all
// customers in d as of now. Idempotent per period via the persisted
// marker; safe to call on every housekeeping pass.
func ApplyMonthlyTrafficResets(d *Data, now time.Time) error {
for _, customer := range d.Customers {
day := customer.TrafficResetDay
if day < 1 || day > 31 {
continue
}
key := TrafficResetPeriodKey(now, day)
if customer.TrafficResetPeriod == "" {
// First sight: arm the marker only — usage already accrued in
// the current period must survive until the next reset day.
customer.TrafficResetPeriod = key
continue
}
if customer.TrafficResetPeriod == key {
continue
}
// A new period has begun since the last applied reset. Zero the
// cycle counters exactly like the manual reset-usage action (the
// model has no separate lifetime counter: these three fields are
// also what subscriptions report as upload/download).
previousUsed := customer.UsedBytes
customer.UsedBytes = 0
customer.UsedUplinkBytes = 0
customer.UsedDownlinkBytes = 0
customer.TrafficResetPeriod = key
resetAt := ISOFromTime(now)
customer.TrafficResetAt = &resetAt
customer.UpdatedAt = resetAt
Audit(d, "system:monthly_reset", "reset_customer_usage_monthly", customer.ID, map[string]any{
"previousUsedBytes": previousUsed,
"resetDay": day,
"period": key,
})
// Mirror the manual reset: a customer suspended only for the
// traffic limit comes back once nothing blocks it any more.
if customer.Status == "suspended" && strVal(customer.SuspendReason) == "traffic_limit" &&
CustomerBlockReason(d, customer) == "" {
customer.Status = "active"
customer.SuspendReason = nil
customer.SuspendReasonPresent = true
if _, err := ResumeCustomerResources(d, customer, "system:monthly_reset"); err != nil {
return err
}
}
}
return nil
}
// ParseResetDay validates a trafficResetDay request value: 0 disables
// the schedule, 1-31 is the day of month. JSON null/absent means 0.
func ParseResetDay(v any) (int, error) {
n, err := NonNegativeNumber(v, "每月重置日", true)
if err != nil {
return 0, err
}
if n > 31 {
return 0, httpErr(400, "每月重置日须在 0–31 之间(0 表示不重置)")
}
return int(n), nil
}
+420
View File
@@ -0,0 +1,420 @@
package main
// Tests for the monthly traffic reset: period-key calendar math (incl.
// short-month clamping), the housekeeping apply pass (due / arming /
// idempotency / missed-period catch-up / suspension interplay), API
// surface and persistence of the new customer fields.
import (
"encoding/json"
"os"
"strings"
"testing"
"time"
)
func local(y int, m time.Month, d, hh, mm int) time.Time {
return time.Date(y, m, d, hh, mm, 0, 0, time.Local)
}
func TestTrafficResetPeriodKey(t *testing.T) {
cases := []struct {
now time.Time
day int
want string
}{
{local(2026, 10, 20, 10, 0), 15, "2026-10"}, // mid-period
{local(2026, 10, 15, 0, 0), 15, "2026-10"}, // exactly at the instant: new period
{local(2026, 10, 14, 23, 59), 15, "2026-09"},
{local(2026, 10, 20, 0, 0), 20, "2026-10"}, // reset day == today
{local(2026, 10, 1, 0, 0), 1, "2026-10"}, // day 1 at month start
{local(2027, 1, 1, 0, 0), 31, "2026-12"}, // year rollover, before Jan 31
{local(2026, 12, 31, 0, 0), 31, "2026-12"}, // at Dec 31 instant
{local(2027, 2, 27, 12, 0), 31, "2027-01"}, // Feb has no 31st yet
{local(2027, 2, 28, 0, 0), 31, "2027-02"}, // clamped instant: Feb 28
{local(2027, 3, 1, 0, 0), 31, "2027-02"}, // still the Feb period
{local(2027, 2, 28, 12, 0), 30, "2027-02"}, // day 30 clamps to Feb 28 too
{local(2027, 2, 27, 12, 0), 30, "2027-01"},
{local(2028, 2, 29, 0, 0), 29, "2028-02"}, // leap year: Feb 29 exists
{local(2028, 3, 1, 0, 0), 29, "2028-02"},
{local(2027, 3, 1, 0, 0), 29, "2027-02"}, // non-leap: day 29 clamps to Feb 28
{local(2027, 2, 28, 0, 0), 29, "2027-02"},
}
for _, c := range cases {
if got := TrafficResetPeriodKey(c.now, c.day); got != c.want {
t.Errorf("key(%v, %d) = %q, want %q", c.now, c.day, got, c.want)
}
}
}
func resetAudits(d *Data, id string) int {
n := 0
for _, e := range d.Activity {
if e.Action == "reset_customer_usage_monthly" && e.Target == id {
n++
}
}
return n
}
func TestApplyMonthlyResetDueAndIdempotent(t *testing.T) {
c := &Customer{ID: "cus_a", Status: "active", TrafficLimitBytes: 1000,
UsedBytes: 500, UsedUplinkBytes: 200, UsedDownlinkBytes: 300,
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
d := &Data{Customers: []*Customer{c}}
now := local(2026, 10, 20, 8, 0)
if err := ApplyMonthlyTrafficResets(d, now); err != nil {
t.Fatalf("apply: %v", err)
}
if c.UsedBytes != 0 || c.UsedUplinkBytes != 0 || c.UsedDownlinkBytes != 0 {
t.Fatalf("counters not zeroed: %+v", c)
}
if c.TrafficResetPeriod != "2026-10" {
t.Fatalf("period = %q", c.TrafficResetPeriod)
}
if c.TrafficResetAt == nil || *c.TrafficResetAt != ISOFromTime(now) {
t.Fatalf("resetAt = %v", c.TrafficResetAt)
}
if n := resetAudits(d, "cus_a"); n != 1 {
t.Fatalf("audits = %d", n)
}
// Same period, fresh usage accrues: a second pass must not zero again.
c.UsedBytes, c.UsedUplinkBytes, c.UsedDownlinkBytes = 50, 20, 30
if err := ApplyMonthlyTrafficResets(d, now.Add(24*time.Hour)); err != nil {
t.Fatalf("apply2: %v", err)
}
if c.UsedBytes != 50 {
t.Fatalf("second pass zeroed within the same period: %d", c.UsedBytes)
}
if n := resetAudits(d, "cus_a"); n != 1 {
t.Fatalf("audits after second pass = %d", n)
}
}
func TestApplyMonthlyResetNotYetDue(t *testing.T) {
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 500,
TrafficResetDay: 15, TrafficResetPeriod: "2026-10"}
d := &Data{Customers: []*Customer{c}}
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 500 || c.TrafficResetAt != nil {
t.Fatalf("not-due customer touched: %+v", c)
}
}
func TestApplyMonthlyResetArmsOnFirstSight(t *testing.T) {
// Marker empty (legacy / converter-written data): arm only, the usage
// accrued in the current period must survive.
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 500,
TrafficResetDay: 15}
d := &Data{Customers: []*Customer{c}}
now := local(2026, 10, 20, 0, 0)
if err := ApplyMonthlyTrafficResets(d, now); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 500 {
t.Fatalf("first sight zeroed usage: %d", c.UsedBytes)
}
if c.TrafficResetPeriod != "2026-10" || c.TrafficResetAt != nil {
t.Fatalf("arming wrong: period=%q at=%v", c.TrafficResetPeriod, c.TrafficResetAt)
}
// And the armed schedule really fires at the next reset instant.
c.UsedBytes = 700
if err := ApplyMonthlyTrafficResets(d, local(2026, 11, 15, 0, 1)); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 0 || c.TrafficResetPeriod != "2026-11" {
t.Fatalf("armed schedule did not fire: %+v", c)
}
}
func TestApplyMonthlyResetCatchUpMissedPeriods(t *testing.T) {
// Panel down across several reset days (restart catch-up): exactly
// one reset, landing in the current period.
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 900,
TrafficResetDay: 15, TrafficResetPeriod: "2026-06"}
d := &Data{Customers: []*Customer{c}}
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 0 || c.TrafficResetPeriod != "2026-10" {
t.Fatalf("catch-up wrong: %+v", c)
}
if n := resetAudits(d, "cus_a"); n != 1 {
t.Fatalf("catch-up audits = %d, want exactly 1", n)
}
}
func TestApplyMonthlyResetShortMonthClamp(t *testing.T) {
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 100,
TrafficResetDay: 31, TrafficResetPeriod: "2027-01"}
d := &Data{Customers: []*Customer{c}}
// Feb 27: the clamped instant (Feb 28) has not arrived — no fire.
if err := ApplyMonthlyTrafficResets(d, local(2027, 2, 27, 23, 0)); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 100 {
t.Fatalf("fired before the clamped instant")
}
// Feb 28 00:01: fires, period is February's.
if err := ApplyMonthlyTrafficResets(d, local(2027, 2, 28, 0, 1)); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 0 || c.TrafficResetPeriod != "2027-02" {
t.Fatalf("clamp fire wrong: %+v", c)
}
}
func TestApplyMonthlyResetIndependentDays(t *testing.T) {
a := &Customer{ID: "cus_a", Status: "active", UsedBytes: 100,
TrafficResetDay: 1, TrafficResetPeriod: "2026-09"}
b := &Customer{ID: "cus_b", Status: "active", UsedBytes: 200,
TrafficResetDay: 25, TrafficResetPeriod: "2026-09"}
d := &Data{Customers: []*Customer{a, b}}
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
t.Fatal(err)
}
if a.UsedBytes != 0 {
t.Errorf("day-1 customer not reset")
}
if b.UsedBytes != 200 || b.TrafficResetAt != nil {
t.Errorf("day-25 customer wrongly reset: %+v", b)
}
}
func TestApplyMonthlyResetUnsetOrInvalidUntouched(t *testing.T) {
off := &Customer{ID: "cus_off", Status: "active", UsedBytes: 100}
bad := &Customer{ID: "cus_bad", Status: "active", UsedBytes: 100,
TrafficResetDay: 45, TrafficResetPeriod: "2020-01"}
neg := &Customer{ID: "cus_neg", Status: "active", UsedBytes: 100,
TrafficResetDay: -3, TrafficResetPeriod: "2020-01"}
d := &Data{Customers: []*Customer{off, bad, neg}}
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
t.Fatal(err)
}
for _, c := range d.Customers {
if c.UsedBytes != 100 || c.TrafficResetAt != nil {
t.Errorf("customer %s touched: %+v", c.ID, c)
}
}
if off.TrafficResetPeriod != "" {
t.Errorf("unset customer got a marker: %q", off.TrafficResetPeriod)
}
}
func TestApplyMonthlyResetResumesTrafficSuspended(t *testing.T) {
reason := "traffic_limit"
c := &Customer{ID: "cus_a", Status: "suspended", SuspendReason: &reason,
SuspendReasonPresent: true, TrafficLimitBytes: 1000,
UsedBytes: 1500, UsedUplinkBytes: 500, UsedDownlinkBytes: 1000,
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
d := &Data{Customers: []*Customer{c}}
if got := CustomerBlockReason(d, c); got != "traffic_limit" {
t.Fatalf("precondition block reason = %q", got)
}
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
t.Fatal(err)
}
if c.UsedBytes != 0 || c.Status != "active" || c.SuspendReason != nil {
t.Fatalf("not resumed after reset: %+v", c)
}
if got := CustomerBlockReason(d, c); got != "" {
t.Fatalf("block reason after reset = %q", got)
}
}
func TestApplyMonthlyResetKeepsOtherSuspensions(t *testing.T) {
manual := "manual"
expired := "expired"
past := "2020-01-01T00:00:00.000Z"
a := &Customer{ID: "cus_m", Status: "suspended", SuspendReason: &manual,
SuspendReasonPresent: true, UsedBytes: 100,
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
b := &Customer{ID: "cus_e", Status: "suspended", SuspendReason: &expired,
SuspendReasonPresent: true, UsedBytes: 100, ExpiresAt: &past,
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
d := &Data{Customers: []*Customer{a, b}}
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
t.Fatal(err)
}
// Counters reset (a fresh cycle awaits renewal), status untouched.
if a.UsedBytes != 0 || b.UsedBytes != 0 {
t.Fatalf("counters not reset: %d %d", a.UsedBytes, b.UsedBytes)
}
if a.Status != "suspended" || b.Status != "suspended" {
t.Fatalf("suspension lifted wrongly: %s %s", a.Status, b.Status)
}
}
func TestParseResetDay(t *testing.T) {
for in, want := range map[any]int{nil: 0, float64(0): 0, float64(1): 1, float64(31): 31, json.Number("15"): 15} {
got, err := ParseResetDay(in)
if err != nil || got != want {
t.Errorf("ParseResetDay(%v) = %d, %v; want %d", in, got, err, want)
}
}
for _, in := range []any{float64(32), float64(2.5), float64(-1)} {
if _, err := ParseResetDay(in); err == nil {
t.Errorf("ParseResetDay(%v) accepted", in)
}
}
}
func TestResetFieldsPersistAndOmitWhenUnset(t *testing.T) {
s, file := newTestStore(t)
at := "2026-10-15T00:00:00.000Z"
_, err := s.Transaction(func(d *Data) (any, error) {
d.Customers = append(d.Customers,
&Customer{ID: "cus_set", Name: "set", Status: "active",
TrafficResetDay: 15, TrafficResetPeriod: "2026-10", TrafficResetAt: &at},
&Customer{ID: "cus_unset", Name: "unset", Status: "active"})
return nil, nil
})
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"trafficResetDay":15`) ||
!strings.Contains(string(raw), `"trafficResetPeriod":"2026-10"`) ||
!strings.Contains(string(raw), `"trafficResetAt":"2026-10-15T00:00:00.000Z"`) {
t.Fatalf("reset fields not persisted: %s", raw)
}
// Reload through a fresh store: fields survive the round trip.
s2 := NewStore(file)
if err := s2.Init(); err != nil {
t.Fatal(err)
}
var set, unset *Customer
for _, c := range s2.Data.Customers {
if c.ID == "cus_set" {
set = c
} else {
unset = c
}
}
if set == nil || set.TrafficResetDay != 15 || set.TrafficResetPeriod != "2026-10" ||
set.TrafficResetAt == nil || *set.TrafficResetAt != at {
t.Fatalf("round trip lost fields: %+v", set)
}
// Unset customer: none of the new keys may appear in its JSON.
out, err := json.Marshal(unset)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), "trafficReset") {
t.Fatalf("unset customer gained reset keys: %s", out)
}
}
func TestHousekeepingAppliesMonthlyReset(t *testing.T) {
s, _ := newTestStore(t)
now := time.Now()
day := now.Day()
_, err := s.Transaction(func(d *Data) (any, error) {
d.Customers = append(d.Customers, &Customer{ID: "cus_a", Name: "a", Status: "active",
UsedBytes: 123, TrafficResetDay: day, TrafficResetPeriod: "2000-01"})
return nil, nil
})
if err != nil {
t.Fatal(err)
}
app := NewApp(s, NewSessionManager(12), NewOrchestrator(s))
app.housekeeping()
c := s.Snapshot().Customers[0]
if c.UsedBytes != 0 {
t.Fatalf("housekeeping did not reset: %d", c.UsedBytes)
}
if want := TrafficResetPeriodKey(now, day); c.TrafficResetPeriod != want {
t.Fatalf("period = %q, want %q", c.TrafficResetPeriod, want)
}
// Second pass with fresh usage: no double reset through the real loop.
_, err = s.Transaction(func(d *Data) (any, error) {
d.Customers[0].UsedBytes = 7
return nil, nil
})
if err != nil {
t.Fatal(err)
}
app.housekeeping()
if got := s.Snapshot().Customers[0].UsedBytes; got != 7 {
t.Fatalf("second housekeeping pass zeroed again: %d", got)
}
}
func loginHeaders(t *testing.T, app *App) map[string]string {
t.Helper()
rec := doRequest(app, "POST", "/api/auth/login", `{"username":"admin","password":"password12345"}`, nil)
if rec.Code != 200 {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
body := decodeBody(t, rec)
csrf, _ := body["csrf"].(string)
cookie := ""
for _, h := range rec.Header().Values("Set-Cookie") {
if strings.HasPrefix(h, "ng_session=") {
cookie = strings.Split(h, ";")[0]
}
}
if csrf == "" || cookie == "" {
t.Fatalf("login artifacts missing: csrf=%q cookie=%q", csrf, cookie)
}
return map[string]string{"Cookie": cookie, "X-CSRF-Token": csrf}
}
func TestCustomerAPIResetDayLifecycle(t *testing.T) {
app := newSmokeApp(t)
headers := loginHeaders(t, app)
// Create with a reset day: armed marker for the current period, no
// reset timestamp yet.
rec := doRequest(app, "POST", "/api/customers",
`{"name":"monthly","trafficResetDay":15}`, headers)
if rec.Code != 201 {
t.Fatalf("create %d %s", rec.Code, rec.Body.String())
}
customer, _ := decodeBody(t, rec)["customer"].(map[string]any)
if customer == nil || customer["trafficResetDay"] != float64(15) {
t.Fatalf("create shape %v", customer)
}
if want := TrafficResetPeriodKey(time.Now(), 15); customer["trafficResetPeriod"] != want {
t.Fatalf("armed period = %v, want %q", customer["trafficResetPeriod"], want)
}
if _, ok := customer["trafficResetAt"]; ok {
t.Fatalf("fresh customer has resetAt: %v", customer)
}
id, _ := customer["id"].(string)
// Out-of-range day is rejected.
rec = doRequest(app, "PATCH", "/api/customers/"+id, `{"trafficResetDay":32}`, headers)
if rec.Code != 400 {
t.Fatalf("patch 32 status %d %s", rec.Code, rec.Body.String())
}
// Changing the day re-arms for the new schedule.
rec = doRequest(app, "PATCH", "/api/customers/"+id, `{"trafficResetDay":20}`, headers)
if rec.Code != 200 {
t.Fatalf("patch 20 status %d %s", rec.Code, rec.Body.String())
}
customer, _ = decodeBody(t, rec)["customer"].(map[string]any)
if customer["trafficResetDay"] != float64(20) ||
customer["trafficResetPeriod"] != TrafficResetPeriodKey(time.Now(), 20) {
t.Fatalf("re-armed shape %v", customer)
}
// Clearing the day removes the schedule from the record entirely.
rec = doRequest(app, "PATCH", "/api/customers/"+id, `{"trafficResetDay":0}`, headers)
if rec.Code != 200 {
t.Fatalf("patch 0 status %d %s", rec.Code, rec.Body.String())
}
customer, _ = decodeBody(t, rec)["customer"].(map[string]any)
if _, ok := customer["trafficResetDay"]; ok {
t.Fatalf("cleared customer still carries resetDay: %v", customer)
}
if _, ok := customer["trafficResetPeriod"]; ok {
t.Fatalf("cleared customer still carries resetPeriod: %v", customer)
}
}
+62 -1
View File
@@ -6,6 +6,7 @@ package main
// the same serialization here. // the same serialization here.
import ( import (
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -43,7 +44,7 @@ func EmptyData() *Data {
SchemaVersion: 1, SchemaVersion: 1,
CreatedAt: NowISO(), CreatedAt: NowISO(),
Settings: Settings{ Settings: Settings{
BrandName: "NexusGate", BrandName: "amo",
SessionHours: 12, SessionHours: 12,
ObservationTtlMinutes: 10, ObservationTtlMinutes: 10,
ActivityRetentionDays: 30, ActivityRetentionDays: 30,
@@ -133,10 +134,70 @@ func (s *Store) Init() error {
if err := validateData(&data); err != nil { if err := validateData(&data); err != nil {
return err 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 s.Data = &data
return nil 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). // Snapshot returns a deep copy of the current data (JS structuredClone).
func (s *Store) Snapshot() *Data { func (s *Store) Snapshot() *Data {
s.mu.Lock() s.mu.Lock()
+1 -1
View File
@@ -30,7 +30,7 @@ func TestInitCreatesMissingFile(t *testing.T) {
if s.Data.SchemaVersion != 1 { if s.Data.SchemaVersion != 1 {
t.Errorf("SchemaVersion = %d", s.Data.SchemaVersion) t.Errorf("SchemaVersion = %d", s.Data.SchemaVersion)
} }
if s.Data.Settings.BrandName != "NexusGate" || s.Data.Settings.SessionHours != 12 || if s.Data.Settings.BrandName != "amo" || s.Data.Settings.SessionHours != 12 ||
s.Data.Settings.ObservationTtlMinutes != 10 || s.Data.Settings.ActivityRetentionDays != 30 || s.Data.Settings.ObservationTtlMinutes != 10 || s.Data.Settings.ActivityRetentionDays != 30 ||
s.Data.Settings.CompletedJobRetentionDays != 7 { s.Data.Settings.CompletedJobRetentionDays != 7 {
t.Errorf("settings defaults = %+v", s.Data.Settings) t.Errorf("settings defaults = %+v", s.Data.Settings)
+97 -6
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
) )
@@ -107,13 +108,17 @@ func NodeClientURI(d *Data, nc *NodeClient) string {
if networkMode == "" { if networkMode == "" {
networkMode = "ipv4" networkMode = "ipv4"
} }
creds := Credentials{}
if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil {
creds = *customer.Credentials
}
uri, ok := BuildClientURI(BuildClientURIParams{ uri, ok := BuildClientURI(BuildClientURIParams{
Protocol: node.Protocol, Protocol: node.Protocol,
RelayServer: server, RelayServer: server,
RelayPort: node.Port, RelayPort: node.Port,
ClientID: nc.Credentials.ClientID, ClientID: creds.ClientID,
RelayPassword: nc.Credentials.RelayPassword, RelayPassword: creds.RelayPassword,
ShortID: nc.Credentials.ShortID, ShortID: creds.ShortID,
WSPath: xbDeref(node.WSPath), WSPath: xbDeref(node.WSPath),
SocksUser: NodeEmail(nc.CustomerID), SocksUser: NodeEmail(nc.CustomerID),
Reality: node.Reality, Reality: node.Reality,
@@ -129,13 +134,29 @@ func NodeClientURI(d *Data, nc *NodeClient) string {
return uri return uri
} }
// sortIndexOf mirrors sortIndexOf in lib/subscriptions.js.
func sortIndexOf(d *Data, nodeID string) int {
for _, item := range d.Nodes {
if item.ID == nodeID {
return item.SortIndex
}
}
return 1 << 30
}
func Entries(d *Data, customerID string) []SubEntry { func Entries(d *Data, customerID string) []SubEntry {
seen := map[string]int{} seen := map[string]int{}
items := []SubEntry{} items := []SubEntry{}
bindings := []*NodeClient{}
for _, nc := range d.NodeClients { for _, nc := range d.NodeClients {
if nc.CustomerID != customerID || nc.Status != "active" { if nc.CustomerID == customerID && nc.Status == "active" {
continue bindings = append(bindings, nc)
} }
}
sort.SliceStable(bindings, func(i, j int) bool {
return sortIndexOf(d, bindings[i].NodeID) < sortIndexOf(d, bindings[j].NodeID)
})
for _, nc := range bindings {
uri := NodeClientURI(d, nc) uri := NodeClientURI(d, nc)
if uri == "" { if uri == "" {
continue continue
@@ -663,8 +684,78 @@ func MakeSingBox(items []SubEntry) string {
var clashUARe = regexp.MustCompile(`(?i)clash|mihomo`) var clashUARe = regexp.MustCompile(`(?i)clash|mihomo`)
// AggregateEntries mirrors aggregateEntries in lib/subscriptions.js:
// own entries (sortIndex order) → manual links → external sources,
// deduped by scheme+host+port+credential fingerprint, first wins.
func AggregateEntries(d *Data, customer *Customer) []SubEntry {
own := Entries(d, customer.ID)
out := []SubEntry{}
seenKeys := map[string]bool{}
seenNames := map[string]int{}
push := func(uri, name string) {
if uri == "" {
return
}
key := DedupKey(uri)
if key != "" && seenKeys[key] {
return
}
if key != "" {
seenKeys[key] = true
}
index := seenNames[name] + 1
seenNames[name] = index
finalName := name
if index != 1 {
finalName = fmt.Sprintf("%s (%d)", name, index)
}
out = append(out, SubEntry{ClientURI: WithName(uri, finalName)})
}
for _, entry := range own {
key := DedupKey(entry.ClientURI)
if key != "" {
seenKeys[key] = true
}
seenNames[NameFromURI(entry.ClientURI)]++
out = append(out, entry)
}
for i, link := range customer.ManualLinks {
if !link.Enabled {
continue
}
name := link.Remark
if name == "" {
name = NameFromURI(link.URI)
}
if name == "" {
name = fmt.Sprintf("手动节点 %d", i+1)
}
push(link.URI, name)
}
for _, source := range customer.ExternalSources {
if !source.Enabled {
continue
}
entry := d.SubSourceCache[source.ID]
if entry == nil || entry.OkAt == nil {
continue
}
for _, item := range entry.Items {
name := item.Name
if name == "" {
name = "未命名节点"
}
if source.Remark != "" {
name = "[" + source.Remark + "] " + name
}
push(item.URI, name)
}
}
return out
}
func FormatSubscription(d *Data, customer *Customer, format, userAgent string) (*SubResult, bool) { func FormatSubscription(d *Data, customer *Customer, format, userAgent string) (*SubResult, bool) {
items := Entries(d, customer.ID) items := AggregateEntries(d, customer)
if len(items) == 0 { if len(items) == 0 {
return nil, false return nil, false
} }
+12 -13
View File
@@ -36,26 +36,25 @@ func goldenSubData() *Data {
Reality: &RealityConfig{KeyID: "reality-nod_rv", ServerName: "www.apple.com", DestPort: 443}, Reality: &RealityConfig{KeyID: "reality-nod_rv", ServerName: "www.apple.com", DestPort: 443},
Artifacts: art("FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu")}, Artifacts: art("FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu")},
} }
nc := func(nodeID, clientID, relayPassword, shortID string) *NodeClient { nc := func(nodeID string) *NodeClient {
return &NodeClient{ return &NodeClient{NodeID: nodeID, CustomerID: "cus_1", Status: "active"}
NodeID: nodeID, CustomerID: "cus_1", Status: "active",
Credentials: Credentials{ClientID: clientID, RelayPassword: relayPassword, ShortID: shortID},
}
} }
clients := []*NodeClient{ clients := []*NodeClient{
nc("nod_rf", "11111111-2222-3333-4444-000000000001", "unused", "abcd0001ef01"), nc("nod_rf"), nc("nod_ss"), nc("nod_hy"), nc("nod_any"),
nc("nod_ss", "11111111-2222-3333-4444-000000000003", "FRYXGBkaGxwdHh8gISIjJA==", "abcd0003ef01"), nc("nod_ws"), nc("nod_sk"), nc("nod_rv"),
nc("nod_hy", "11111111-2222-3333-4444-000000000004", "HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz", "abcd0004ef01"),
nc("nod_any", "11111111-2222-3333-4444-000000000005", "IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6", "abcd0005ef01"),
nc("nod_ws", "11111111-2222-3333-4444-000000000006", "unused", "abcd0006ef01"),
nc("nod_sk", "11111111-2222-3333-4444-000000000007", "MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI", "abcd0007ef01"),
nc("nod_rv", "11111111-2222-3333-4444-000000000008", "unused", "abcd0008ef01"),
} }
return &Data{ return &Data{
Servers: []*Server{entryServer, v6Server}, Servers: []*Server{entryServer, v6Server},
Nodes: nodes, Nodes: nodes,
NodeClients: clients, 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)
}
}
+382
View File
@@ -0,0 +1,382 @@
package main
// Host/system sampling for the system-status page — mirrors
// lib/sysstat.js. Memory-only ring history (240 samples), ~30s minimum
// sampling interval, "today" traffic baseline kept in memory (a panel
// restart restarts the day counter from traffic seen since boot).
import (
"net"
"os"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
const historyCap = 240
const sampleMinIntervalMs = 25000
type sysSample struct {
At string `json:"at"`
CPU *float64 `json:"cpu"`
Mem *float64 `json:"mem"`
UpBps float64 `json:"upBps"`
DownBps float64 `json:"downBps"`
Connections int `json:"connections"`
}
type sysCurrent struct {
CPUPct *float64
MemTotal int64
MemUsed int64
DiskTotal int64
DiskUsed int64
UpBps float64
DownBps float64
Conns int
TotalUp int64
TotalDown int64
}
type SystemMonitor struct {
mu sync.Mutex
dataDir string
startedAt time.Time
prevIdle int64
prevTotal int64
hasPrev bool
cpuPct *float64
history []sysSample
lastSample time.Time
dayKey string
dayBaseUp int64
dayBaseDown int64
todayUp int64
todayDown int64
current *sysCurrent
}
func NewSystemMonitor(dataDir string) *SystemMonitor {
return &SystemMonitor{dataDir: dataDir, startedAt: time.Now()}
}
func readCPUTimes() (idle, total int64, ok bool) {
raw, err := os.ReadFile("/proc/stat")
if err != nil {
return 0, 0, false
}
line := strings.SplitN(string(raw), "\n", 2)[0]
fields := strings.Fields(line)
if len(fields) < 6 {
return 0, 0, false
}
var values []int64
for _, f := range fields[1:] {
n, err := strconv.ParseInt(f, 10, 64)
if err != nil {
return 0, 0, false
}
values = append(values, n)
}
idle = values[3]
if len(values) > 4 {
idle += values[4]
}
for _, v := range values {
total += v
}
return idle, total, true
}
func readMemInfo() (total, used int64) {
raw, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0, 0
}
grab := func(key string) int64 {
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, key+":") {
fields := strings.Fields(line)
if len(fields) >= 2 {
if n, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
return n * 1024
}
}
}
}
return 0
}
total = grab("MemTotal")
available := grab("MemAvailable")
if total > 0 {
used = total - available
}
return total, used
}
func isGlobalIPv4(ip net.IP) bool {
v4 := ip.To4()
if v4 == nil {
return false
}
a, b := v4[0], v4[1]
switch {
case a == 0 || a == 10 || a == 127:
return false
case a == 172 && b >= 16 && b <= 31:
return false
case a == 192 && b == 168:
return false
case a == 100 && b >= 64 && b <= 127:
return false
case a == 169 && b == 254:
return false
case a >= 224:
return false
}
return true
}
func isGlobalIPv6(ip net.IP) bool {
if ip.To4() != nil {
return false
}
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() {
return false
}
b := ip.To16()
if b == nil {
return false
}
if b[0] == 0xfc || b[0] == 0xfd { // ULA fc00::/7
return false
}
if b[0] == 0xfe && b[1]&0xc0 == 0x80 { // link-local fe80::/10
return false
}
return true
}
// HostIPs picks the host's global interface addresses (no external
// lookup of any kind), mirroring hostIPs in lib/sysstat.js.
func HostIPs() (ipv4, ipv6 *string) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, nil
}
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch a := addr.(type) {
case *net.IPNet:
ip = a.IP
case *net.IPAddr:
ip = a.IP
}
if ip == nil {
continue
}
if ipv4 == nil && isGlobalIPv4(ip) {
s := ip.String()
ipv4 = &s
}
if ipv6 == nil && isGlobalIPv6(ip) {
s := ip.String()
ipv6 = &s
}
}
}
return ipv4, ipv6
}
func round1(v float64) float64 {
return float64(int64(v*10+0.5)) / 10
}
func (m *SystemMonitor) Sample(d *Data, force bool) *sysCurrent {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
if !force && m.current != nil && now.Sub(m.lastSample).Milliseconds() < sampleMinIntervalMs {
return m.current
}
m.lastSample = now
if idle, total, ok := readCPUTimes(); ok {
if m.hasPrev {
totalDelta := total - m.prevTotal
idleDelta := idle - m.prevIdle
if totalDelta > 0 {
pct := (1 - float64(idleDelta)/float64(totalDelta)) * 100
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
m.cpuPct = &pct
}
}
m.prevIdle, m.prevTotal, m.hasPrev = idle, total, true
}
memTotal, memUsed := readMemInfo()
var diskTotal, diskUsed int64
var statfs syscall.Statfs_t
if err := syscall.Statfs(m.dataDir, &statfs); err == nil {
diskTotal = int64(statfs.Blocks) * int64(statfs.Bsize)
diskUsed = (int64(statfs.Blocks) - int64(statfs.Bfree)) * int64(statfs.Bsize)
}
var totalUp, totalDown int64
var upBps, downBps float64
nowMs := now.UnixMilli()
for _, node := range d.Nodes {
if node.MeteredTraffic != nil {
totalUp += node.MeteredTraffic.Uplink
totalDown += node.MeteredTraffic.Downlink
}
up, down := NodeSpeedBps(node, nowMs)
upBps += up
downBps += down
}
dayKey := now.Format("2006-01-02")
if m.dayKey != dayKey {
m.dayKey = dayKey
m.dayBaseUp = totalUp
m.dayBaseDown = totalDown
}
m.todayUp = totalUp - m.dayBaseUp
if m.todayUp < 0 {
m.todayUp = 0
}
m.todayDown = totalDown - m.dayBaseDown
if m.todayDown < 0 {
m.todayDown = 0
}
conns := CountOnlineCustomers(d, now)
var cpuVal, memVal *float64
if m.cpuPct != nil {
v := round1(*m.cpuPct)
cpuVal = &v
}
if memTotal > 0 {
v := round1(float64(memUsed) / float64(memTotal) * 100)
memVal = &v
}
m.history = append(m.history, sysSample{
At: ISOFromTime(now), CPU: cpuVal, Mem: memVal,
UpBps: upBps, DownBps: downBps, Connections: conns,
})
if len(m.history) > historyCap {
m.history = m.history[len(m.history)-historyCap:]
}
m.current = &sysCurrent{
CPUPct: cpuVal, MemTotal: memTotal, MemUsed: memUsed,
DiskTotal: diskTotal, DiskUsed: diskUsed,
UpBps: upBps, DownBps: downBps, Conns: conns,
TotalUp: totalUp, TotalDown: totalDown,
}
return m.current
}
func panelMemBytes() int64 {
raw, err := os.ReadFile("/proc/self/status")
if err != nil {
return 0
}
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, "VmRSS:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
if n, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
return n * 1024
}
}
}
}
return 0
}
func hostUptimeSec() int64 {
raw, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0
}
fields := strings.Fields(string(raw))
if len(fields) == 0 {
return 0
}
if v, err := strconv.ParseFloat(fields[0], 64); err == nil {
return int64(v)
}
return 0
}
func (m *SystemMonitor) Status(d *Data, version string) map[string]any {
current := m.Sample(d, false)
m.mu.Lock()
history := make([]sysSample, len(m.history))
copy(history, m.history)
todayUp, todayDown := m.todayUp, m.todayDown
startedAt := m.startedAt
m.mu.Unlock()
if current == nil {
current = &sysCurrent{}
}
memTotal, memUsed := current.MemTotal, current.MemUsed
var memPct, diskPct *float64
if memTotal > 0 {
v := round1(float64(memUsed) / float64(memTotal) * 100)
memPct = &v
}
if current.DiskTotal > 0 {
v := round1(float64(current.DiskUsed) / float64(current.DiskTotal) * 100)
diskPct = &v
}
onlineNodes := 0
for _, node := range d.Nodes {
if node.Status == "active" {
onlineNodes++
}
}
ipv4, ipv6 := HostIPs()
load1 := float64(0)
// load average from /proc/loadavg
if raw, err := os.ReadFile("/proc/loadavg"); err == nil {
fields := strings.Fields(string(raw))
if len(fields) > 0 {
load1, _ = strconv.ParseFloat(fields[0], 64)
}
}
return map[string]any{
"version": version,
"startedAt": ISOFromTime(startedAt),
"panelUptimeSec": int64(time.Since(startedAt).Seconds()),
"hostUptimeSec": hostUptimeSec(),
"panelMemBytes": panelMemBytes(),
"host": map[string]any{
"cpuPercent": current.CPUPct,
"cpuCount": runtime.NumCPU(),
"load1": load1,
"memTotal": memTotal, "memUsed": memUsed, "memPercent": memPct,
"diskTotal": current.DiskTotal, "diskUsed": current.DiskUsed, "diskPercent": diskPct,
},
"network": map[string]any{
"upBps": current.UpBps, "downBps": current.DownBps,
"connections": current.Conns, "onlineCustomers": current.Conns,
"onlineNodes": onlineNodes, "nodesTotal": len(d.Nodes), "customersTotal": len(d.Customers),
},
"today": map[string]any{"upBytes": todayUp, "downBytes": todayDown},
"totals": map[string]any{"upBytes": current.TotalUp, "downBytes": current.TotalDown},
"ips": map[string]any{"ipv4": ipv4, "ipv6": ipv6},
"history": history,
}
}
+32 -32
View File
@@ -2,7 +2,7 @@
"entries": [ "entries": [
{ {
"nodeId": "nod_rf", "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", "nodeId": "nod_ss",
@@ -10,56 +10,56 @@
}, },
{ {
"nodeId": "nod_hy", "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", "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", "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", "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", "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": { "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": { "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" "contentType": "text/yaml; charset=utf-8"
}, },
"auto-plain-ua": { "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" "contentType": "text/plain; charset=utf-8"
} }
} }
+115 -39
View File
@@ -217,23 +217,24 @@ func recordJSON[T any](raw []byte, dst *T, extra *extraFields) error {
// ---------- database ---------- // ---------- database ----------
type Data struct { type Data struct {
SchemaVersion int `json:"schemaVersion"` SchemaVersion int `json:"schemaVersion"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
Settings Settings `json:"settings"` Settings Settings `json:"settings"`
Users []*User `json:"users"` Users []*User `json:"users"`
Servers []*Server `json:"servers"` Servers []*Server `json:"servers"`
Agents []*Agent `json:"agents"` Agents []*Agent `json:"agents"`
EnrollmentTokens []*EnrollmentToken `json:"enrollmentTokens"` EnrollmentTokens []*EnrollmentToken `json:"enrollmentTokens"`
Customers []*Customer `json:"customers"` Customers []*Customer `json:"customers"`
Nodes []*Node `json:"nodes"` Nodes []*Node `json:"nodes"`
NodeClients []*NodeClient `json:"nodeClients"` NodeClients []*NodeClient `json:"nodeClients"`
Jobs []*Job `json:"jobs"` Jobs []*Job `json:"jobs"`
Activity []*Activity `json:"activity"` Activity []*Activity `json:"activity"`
Observations []*Observation `json:"observations"` Observations []*Observation `json:"observations"`
SubscriptionAccess []*SubscriptionAccess `json:"subscriptionAccess"` SubscriptionAccess []*SubscriptionAccess `json:"subscriptionAccess"`
SubscriptionClients []*SubscriptionClient `json:"subscriptionClients"` SubscriptionClients []*SubscriptionClient `json:"subscriptionClients"`
KeyStore map[string]*KeyEntry `json:"keyStore"` KeyStore map[string]*KeyEntry `json:"keyStore"`
Extra extraFields `json:"-"` SubSourceCache map[string]*SubSourceCacheEntry `json:"subSourceCache,omitempty"`
Extra extraFields `json:"-"`
} }
func (d *Data) UnmarshalJSON(raw []byte) error { func (d *Data) UnmarshalJSON(raw []byte) error {
@@ -267,12 +268,19 @@ func (d *Data) Clone() *Data {
} }
type Settings struct { type Settings struct {
BrandName string `json:"brandName"` BrandName string `json:"brandName"`
SessionHours float64 `json:"sessionHours"` SessionHours float64 `json:"sessionHours"`
ObservationTtlMinutes float64 `json:"observationTtlMinutes"` ObservationTtlMinutes float64 `json:"observationTtlMinutes"`
ActivityRetentionDays float64 `json:"activityRetentionDays"` ActivityRetentionDays float64 `json:"activityRetentionDays"`
CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"` CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"`
Extra extraFields `json:"-"` 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 { func (s *Settings) UnmarshalJSON(raw []byte) error {
@@ -324,6 +332,7 @@ type EngineState struct {
SingBoxInstalled bool `json:"singBoxInstalled"` SingBoxInstalled bool `json:"singBoxInstalled"`
Certificates []string `json:"certificates"` Certificates []string `json:"certificates"`
SingBoxStatus string `json:"singBoxStatus"` SingBoxStatus string `json:"singBoxStatus"`
SingBoxVersion string `json:"singBoxVersion,omitempty"`
At string `json:"at"` At string `json:"at"`
Extra extraFields `json:"-"` Extra extraFields `json:"-"`
} }
@@ -379,6 +388,7 @@ type Server struct {
PortRangeEnd int `json:"portRangeEnd"` PortRangeEnd int `json:"portRangeEnd"`
Labels []string `json:"labels"` Labels []string `json:"labels"`
Status string `json:"status"` Status string `json:"status"`
Enabled *bool `json:"enabled,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
LastSeenAt *string `json:"lastSeenAt,omitempty"` LastSeenAt *string `json:"lastSeenAt,omitempty"`
@@ -501,6 +511,11 @@ func (c UsageCounters) MarshalJSON() ([]byte, error) {
type UsageSlot struct { type UsageSlot struct {
UsageCounters *UsageCounters `json:"usageCounters,omitempty"` UsageCounters *UsageCounters `json:"usageCounters,omitempty"`
RetiredUsageEpochs []string `json:"retiredUsageEpochs,omitempty"` RetiredUsageEpochs []string `json:"retiredUsageEpochs,omitempty"`
LastSampleAt *string `json:"lastSampleAt,omitempty"`
LastActiveAt *string `json:"lastActiveAt,omitempty"`
SpeedUpBps *float64 `json:"speedUpBps,omitempty"`
SpeedDownBps *float64 `json:"speedDownBps,omitempty"`
SpeedAt *string `json:"speedAt,omitempty"`
Extra extraFields `json:"-"` Extra extraFields `json:"-"`
} }
@@ -532,6 +547,9 @@ type Customer struct {
UsedBytes int64 `json:"usedBytes"` UsedBytes int64 `json:"usedBytes"`
UsedUplinkBytes int64 `json:"usedUplinkBytes"` UsedUplinkBytes int64 `json:"usedUplinkBytes"`
UsedDownlinkBytes int64 `json:"usedDownlinkBytes"` UsedDownlinkBytes int64 `json:"usedDownlinkBytes"`
TrafficResetDay int `json:"trafficResetDay,omitempty"`
TrafficResetPeriod string `json:"trafficResetPeriod,omitempty"`
TrafficResetAt *string `json:"trafficResetAt,omitempty"`
ExpiresAt *string `json:"expiresAt"` ExpiresAt *string `json:"expiresAt"`
IPLimit int64 `json:"ipLimit"` IPLimit int64 `json:"ipLimit"`
DeviceLimit int64 `json:"deviceLimit"` DeviceLimit int64 `json:"deviceLimit"`
@@ -542,7 +560,48 @@ type Customer struct {
ServerUsage map[string]*UsageSlot `json:"serverUsage,omitempty"` ServerUsage map[string]*UsageSlot `json:"serverUsage,omitempty"`
LastUsageAt *string `json:"lastUsageAt,omitempty"` LastUsageAt *string `json:"lastUsageAt,omitempty"`
LastOnlineAt *string `json:"lastOnlineAt,omitempty"` LastOnlineAt *string `json:"lastOnlineAt,omitempty"`
Extra extraFields `json:"-"` // The customer's single credential set, shared by all of their node
// bindings (round-2 item 2). Populated by the store-load migration for
// legacy records and at creation for new ones.
Credentials *Credentials `json:"credentials,omitempty"`
ExternalSources []ExtSource `json:"externalSources,omitempty"`
ManualLinks []ManualLink `json:"manualLinks,omitempty"`
Extra extraFields `json:"-"`
}
// ExtSource is one external subscription source on a customer
// (SUB-AGGREGATION-DESIGN.md). UserAgent is optional.
type ExtSource struct {
ID string `json:"id"`
URL string `json:"url"`
Remark string `json:"remark"`
Enabled bool `json:"enabled"`
UserAgent string `json:"userAgent,omitempty"`
}
// ManualLink is one hand-added node link on a customer.
type ManualLink struct {
ID string `json:"id"`
URI string `json:"uri"`
Remark string `json:"remark"`
Enabled bool `json:"enabled"`
}
// SubCacheItem is one parsed entry of a fetched external source.
type SubCacheItem struct {
URI string `json:"uri"`
Name string `json:"name"`
}
// SubSourceCacheEntry is the fetch cache for one external source,
// stored top-level in data.subSourceCache keyed by source id.
type SubSourceCacheEntry struct {
FetchedAt *string `json:"fetchedAt"`
OkAt *string `json:"okAt"`
FailCount int `json:"failCount"`
LastError *string `json:"lastError"`
DroppedCount int `json:"droppedCount"`
Items []SubCacheItem `json:"items"`
} }
func (c *Customer) UnmarshalJSON(raw []byte) error { func (c *Customer) UnmarshalJSON(raw []byte) error {
@@ -644,13 +703,20 @@ func (m MeteredTraffic) MarshalJSON() ([]byte, error) {
} }
type Node struct { type Node struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
ServerID string `json:"serverId"` ServerID string `json:"serverId"`
ExitServerID *string `json:"exitServerId"` ExitServerID *string `json:"exitServerId"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Topology string `json:"topology"` Topology string `json:"topology"`
ExitProtocol *string `json:"exitProtocol"` 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"` NetworkMode string `json:"networkMode"`
RealityServerName string `json:"realityServerName"` RealityServerName string `json:"realityServerName"`
RealityDestPort int `json:"realityDestPort"` RealityDestPort int `json:"realityDestPort"`
@@ -675,6 +741,12 @@ type Node struct {
RetiredUsageEpochs []string `json:"retiredUsageEpochs"` RetiredUsageEpochs []string `json:"retiredUsageEpochs"`
MeteredTraffic *MeteredTraffic `json:"meteredTraffic"` MeteredTraffic *MeteredTraffic `json:"meteredTraffic"`
LastUsageAt *string `json:"lastUsageAt"` LastUsageAt *string `json:"lastUsageAt"`
SortIndex int `json:"sortIndex"`
SpeedUpBps *float64 `json:"speedUpBps,omitempty"`
SpeedDownBps *float64 `json:"speedDownBps,omitempty"`
SpeedAt *string `json:"speedAt,omitempty"`
LastActiveAt *string `json:"lastActiveAt,omitempty"`
LastSampleAt *string `json:"lastSampleAt,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
Extra extraFields `json:"-"` Extra extraFields `json:"-"`
@@ -720,14 +792,18 @@ func (c Credentials) MarshalJSON() ([]byte, error) {
} }
type NodeClient struct { type NodeClient struct {
ID string `json:"id"` ID string `json:"id"`
NodeID string `json:"nodeId"` NodeID string `json:"nodeId"`
CustomerID string `json:"customerId"` CustomerID string `json:"customerId"`
Credentials Credentials `json:"credentials"` // Deprecated: credentials live on the Customer (round-2 item 2). This
Status string `json:"status"` // field is only a migration input — the store-load migration converges
CreatedAt string `json:"createdAt"` // it into Customer.Credentials and clears it, so it disappears from
UpdatedAt string `json:"updatedAt"` // disk on the next save. Builders receive resolved copies instead.
Extra extraFields `json:"-"` 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 { func (n *NodeClient) UnmarshalJSON(raw []byte) error {
+104
View File
@@ -0,0 +1,104 @@
package main
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestParseLinkListBase64AndPlain(t *testing.T) {
plain := "vless://11111111-1111-1111-1111-111111111111@a.example.com:443?security=reality#One\n" +
"trojan://x@b.example.com:443#Bad\n" +
"ss://YWVzLTI1Ni1nY206cGFzcw@b.example.com:8388#Two\n"
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
items, dropped := ParseLinkList(encoded)
if len(items) != 2 || dropped != 1 {
t.Fatalf("base64 list: items=%d dropped=%d", len(items), dropped)
}
if items[0].Name != "One" || items[1].Name != "Two" {
t.Fatalf("names: %+v", items)
}
items2, dropped2 := ParseLinkList(plain)
if len(items2) != 2 || dropped2 != 1 {
t.Fatalf("plain list: items=%d dropped=%d", len(items2), dropped2)
}
}
func TestFetchExternalSourceGuards(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("vless://11111111-1111-1111-1111-111111111111@a.example.com:443#X\n"))
}))
defer server.Close()
source := ExtSource{ID: "src_t", URL: server.URL, Enabled: true}
settings := &Settings{}
blocked := FetchExternalSource(source, settings)
if blocked.OK {
t.Fatal("loopback source must be rejected by default")
}
settings.AllowPrivateSubSources = true
allowed := FetchExternalSource(source, settings)
if !allowed.OK || len(allowed.Items) != 1 {
t.Fatalf("allowPrivate fetch: %+v", allowed)
}
}
func TestAggregateEntriesOrderAndDedup(t *testing.T) {
d := &Data{
Nodes: []*Node{
{ID: "nod_b", Name: "B", SortIndex: 1, ServerID: "srv_1", Protocol: "shadowsocks-aes256-gcm", Status: "active", Port: 20003},
{ID: "nod_a", Name: "A", SortIndex: 0, ServerID: "srv_1", Protocol: "shadowsocks-aes256-gcm", Status: "active", Port: 20001},
},
Servers: []*Server{{ID: "srv_1", Name: "S", PublicAddress: "s.example.com"}},
NodeClients: []*NodeClient{
{ID: "ncl_b", NodeID: "nod_b", CustomerID: "cus_1", Status: "active"},
{ID: "ncl_a", NodeID: "nod_a", CustomerID: "cus_1", Status: "active"},
},
SubSourceCache: map[string]*SubSourceCacheEntry{
"src_1": {Items: []SubCacheItem{{URI: "ss://YWVzLTI1Ni1nY206cHctYQ@s.example.com:20002#Dup", Name: "Dup"}}},
},
}
okAt := NowISO()
d.SubSourceCache["src_1"].OkAt = &okAt
customer := &Customer{
ID: "cus_1", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "pw-a", ShortID: "abcd0001ef01"},
ManualLinks: []ManualLink{
{ID: "lnk_1", URI: "ss://YWVzLTI1Ni1nY206cHctYQ@s.example.com:20002#Whatever", Remark: "手动一号", Enabled: true},
},
ExternalSources: []ExtSource{{ID: "src_1", URL: "https://example.com/sub", Remark: "机场", Enabled: true}},
}
d.Customers = []*Customer{customer}
entries := AggregateEntries(d, customer)
names := []string{}
for _, entry := range entries {
names = append(names, NameFromURI(entry.ClientURI))
}
// Own entries first in sortIndex order (A before B although B's
// binding is stored first); the manual link follows; the external
// item duplicates the manual link (same host/port/credential) and
// is dropped.
joined := strings.Join(names, ",")
if joined != "A,B,手动一号" {
t.Fatalf("aggregate order/dedup: %v", names)
}
}
func TestPresenceWindow(t *testing.T) {
now := time.Now()
recent := ISOFromTime(now.Add(-60 * time.Second))
customer := &Customer{LastOnlineAt: &recent}
if !CustomerOnline(customer, now.UnixMilli()) {
t.Fatal("customer active 60s ago should be online")
}
old := ISOFromTime(now.Add(-10 * time.Minute))
customer.LastOnlineAt = &old
if CustomerOnline(customer, now.UnixMilli()) {
t.Fatal("customer active 10m ago should be offline")
}
}
func TestHostIPsNoPanic(t *testing.T) {
HostIPs() // just must not panic on any host
}
+1276 -475
View File
File diff suppressed because it is too large Load Diff
+38 -45
View File
@@ -3,66 +3,59 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f5f7fb"> <meta name="theme-color" content="#f0f2f5">
<title>NexusGate · 枢门控制台</title> <title>amo</title>
<link rel="stylesheet" href="/styles.css?v=0.3.0"> <script>(function(){try{var t=localStorage.getItem('nexusgate-theme');if(t==='dark'){document.documentElement.setAttribute('data-theme','dark');document.querySelector('meta[name="theme-color"]').content='#1a1b1f';}}catch(e){}})();</script>
<link rel="stylesheet" href="/styles.css?v=0.7.0">
</head> </head>
<body> <body>
<div id="login" class="login-shell hidden"> <div id="login" class="login-shell hidden">
<section class="login-main"> <section class="login-card panel">
<button class="theme-button login-theme" type="button" data-theme-toggle aria-label="切换主题">浅色</button> <div class="login-brand">amo</div>
<div class="login-card"> <form id="login-form" class="stack">
<div class="brand login-brand"><span class="brand-mark">N</span><span><b>NexusGate</b><small>枢门控制台</small></span></div> <label>登录账号<input name="username" autocomplete="username" required></label>
<div class="login-copy"> <label>登录密码<input name="password" type="password" autocomplete="current-password" required></label>
<h1>欢迎回来</h1> <button class="btn primary full" type="submit">登录</button>
</div> </form>
<form id="login-form" class="stack login-form"> <p id="login-error" class="form-error"></p>
<label>登录账号<input name="username" autocomplete="username" placeholder="请输入账号" required></label> <button class="theme-btn login-theme" type="button" data-theme-toggle>🌙 深色</button>
<label>登录密码<input name="password" type="password" autocomplete="current-password" required></label>
<button class="primary full large" type="submit">登录控制台</button>
</form>
<p id="login-error" class="form-error"></p>
</div>
</section> </section>
</div> </div>
<div id="app" class="app-shell hidden"> <div id="app" class="layout hidden">
<aside class="sidebar" id="sidebar"> <aside class="sider">
<div class="brand sidebar-brand"><span class="brand-mark small">N</span><span><b>NexusGate</b><small>枢门控制台</small></span></div> <div class="brand">amo</div>
<p class="nav-caption">工作台</p> <nav class="menu" id="nav">
<nav 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>
<button data-page="overview" class="active"><span class="nav-icon">▦</span><span class="nav-label">概览</span></button> <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>
<p class="nav-caption nav-group">资源管理</p> <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>
<button data-page="servers"><span class="nav-icon">◫</span><span class="nav-label">服务器</span></button> <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>
<button data-page="customers"><span class="nav-icon">◎</span><span class="nav-label">客户与订阅</span></button> <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>
<button data-page="chains"><span class="nav-icon">⇄</span><span class="nav-label">转发与节点</span></button>
<button data-page="commands"><span class="nav-icon">⌘</span><span class="nav-label">安装与命令</span></button>
<p class="nav-caption nav-group">安全与系统</p>
<button data-page="operations"><span class="nav-icon">⚙</span><span class="nav-label">设置与运维</span></button>
</nav> </nav>
<div class="sidebar-foot"><div><span class="live-dot"></span><span id="connection-label">控制面正常</span></div><button id="logout" class="icon-button" title="退出登录">↗</button></div> <div class="sider-foot"><span id="user-chip"></span><span class="foot-right"><span id="version-label"></span><button id="logout" class="icon-btn" type="button" data-tip="退出登录"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg></button></span></div>
</aside> </aside>
<main class="main-shell"> <div class="main">
<header class="topbar"> <header class="topbar">
<button id="menu-toggle" class="icon-button mobile-only" type="button" aria-label="展开菜单">☰</button> <span class="crumb">amo / <b id="page-title">系统状态</b></span>
<div class="title-block"><p id="breadcrumb" class="eyebrow">NEXUSGATE / 运行总览</p><h1 id="page-title">运行总览</h1></div> <span class="sp"></span>
<div class="top-actions"><button id="refresh" class="ghost" type="button">刷新数据</button><button class="theme-button" type="button" data-theme-toggle aria-label="切换主题">浅色</button><span id="user-chip" class="user-chip"></span></div> <button class="theme-btn" id="refresh" type="button" title="刷新数据">刷新</button>
<button class="theme-btn" type="button" data-theme-toggle>🌙 深色</button>
</header> </header>
<div id="content" class="content"></div> <main class="content" id="content"></main>
</main> </div>
<button id="sidebar-scrim" class="sidebar-scrim" type="button" aria-label="关闭菜单"></button>
</div> </div>
<dialog id="modal"> <div id="modal-overlay" class="modal-overlay hidden">
<section class="dialog-shell"> <section class="modal panel" role="dialog" aria-modal="true">
<header><div><p id="modal-kicker" class="eyebrow"></p><h2 id="modal-title"></h2></div><button type="button" data-close class="icon-button" aria-label="关闭">×</button></header> <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"></div> <div id="modal-body" class="modal-body"></div>
</section> </section>
</dialog> </div>
<div id="float-pop" class="float-pop hidden"></div>
<div id="toasts" aria-live="polite"></div> <div id="toasts" aria-live="polite"></div>
<script src="/vendor/qrcode.js?v=0.4.0" defer></script> <script src="/vendor/qrcode.js?v=0.4.0" defer></script>
<script src="/app.js?v=0.4.0" defer></script> <script src="/app.js?v=0.7.0" defer></script>
</body> </body>
</html> </html>
+399 -105
View File
@@ -1,111 +1,405 @@
:root { /* amo 控制台样式 — 按 3x-ui 风格样张落地。浅色默认,[data-theme="dark"] 深色。
--bg: #f4f7fb; --surface: #ffffff; --surface-soft: #f8faff; --surface-hover: #f1f5ff; 字体为系统字体栈,零外部依赖(路由器可离线部署)。 */
--text: #172033; --muted: #6f7b91; --line: #e4e9f2; --line-strong: #d6ddea; :root{
--primary: #4f6ef7; --primary-dark: #3c57d9; --primary-soft: #eef1ff; --bg:#f0f2f5; --sider:#ffffff; --card:#ffffff; --card-hover:#fafafa;
--success: #159f70; --success-soft: #e9f8f2; --warning: #bc7a12; --warning-soft: #fff6df; --border:#d9dce1; --border-soft:#e8eaed;
--danger: #d84d61; --danger-soft: #fff0f2; --shadow: 0 12px 36px rgba(34,51,84,.08); --shadow-lg: 0 28px 80px rgba(23,32,51,.16); --text:#20242a; --text-2:#5a6068; --text-3:#8a8f98;
--radius: 16px; font-family: Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; color-scheme: light; --primary:#1668dc; --primary-hover:#3c89e8; --primary-soft:rgba(22,104,220,.10); --primary-text:#0958d9;
--green:#52c41a; --green-text:#237804; --green-soft:rgba(82,196,26,.13);
--red:#ff4d4f; --red-text:#cf1322; --red-soft:rgba(255,77,79,.08);
--orange:#faad14; --orange-text:#975a00; --orange-soft:rgba(250,173,20,.14);
--purple:#9254de; --purple-text:#722ed1; --purple-soft:rgba(146,84,222,.10);
--cyan:#13c2c2; --cyan-text:#096f74; --cyan-soft:rgba(19,194,194,.10);
--pink-text:#c41d7f; --pink-soft:rgba(235,47,150,.09);
--sky-text:#0958d9; --sky-soft:rgba(22,104,220,.09);
--gray-soft:rgba(0,0,0,.05); --pill-bg:rgba(0,0,0,.045);
--hover-bg:rgba(0,0,0,.04); --row-hover:rgba(0,0,0,.02); --th-bg:#fafafa;
--dot-off:#c2c6cc; --tgl-off:#c9ccd3; --track:rgba(0,0,0,.08);
--pop-bg:#ffffff; --pop-shadow:0 12px 32px rgba(0,0,0,.16);
--trans-bg:rgba(82,196,26,.14); --trans-text:#237804;
--chart-2:#6f757f;
--radius:8px;
--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
--mono:"SF Mono",SFMono-Regular,Consolas,"Liberation Mono",monospace;
} }
html[data-theme="dark"] { [data-theme="dark"]{
--bg:#0e1421; --surface:#161e2e; --surface-soft:#111827; --surface-hover:#202b40; --text:#edf2fb; --muted:#93a0b6; --bg:#1a1b1f; --sider:#1e2024; --card:#23252b; --card-hover:#282a31;
--line:#273248; --line-strong:#344159; --primary:#7790ff; --primary-dark:#91a4ff; --primary-soft:#222d50; --border:#33363e; --border-soft:#2b2d34;
--success:#42cea0; --success-soft:#16382f; --warning:#f0bd5f; --warning-soft:#3b301b; --danger:#ff788a; --danger-soft:#45232b; --text:#e9eaee; --text-2:#a2a7b1; --text-3:#6f757f;
--shadow:0 16px 42px rgba(0,0,0,.2); --shadow-lg:0 30px 90px rgba(0,0,0,.45); color-scheme:dark; --primary-soft:rgba(22,104,220,.16); --primary-text:#69a7ff;
--green-text:#73d13d;
--red-text:#ff7875; --red-soft:rgba(255,77,79,.13);
--orange-text:#ffc53d; --orange-soft:rgba(250,173,20,.13);
--purple-text:#b37feb; --purple-soft:rgba(146,84,222,.17);
--cyan-text:#36cfc9; --cyan-soft:rgba(19,194,194,.13);
--pink-text:#ff85c0; --pink-soft:rgba(235,47,150,.16);
--sky-text:#8dbdff; --sky-soft:rgba(105,167,255,.15);
--gray-soft:rgba(255,255,255,.07); --pill-bg:rgba(255,255,255,.06);
--hover-bg:rgba(255,255,255,.05); --row-hover:rgba(255,255,255,.025); --th-bg:rgba(255,255,255,.02);
--dot-off:#5a5e66; --tgl-off:#3c404a; --track:rgba(255,255,255,.09);
--pop-bg:#2e3038; --pop-shadow:0 12px 32px rgba(0,0,0,.55);
--trans-bg:rgba(82,196,26,.12); --trans-text:#95de64;
--chart-2:#8a8f98;
} }
*{box-sizing:border-box} html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text)} body{min-height:100vh} *{margin:0;padding:0;box-sizing:border-box}
button,input,select,textarea{font:inherit} button{cursor:pointer}.hidden{display:none!important}.muted{color:var(--muted)} body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
.eyebrow{margin:0 0 7px;color:var(--primary);font-size:11px;font-weight:800;letter-spacing:.14em;text-transform:uppercase} a{color:inherit;text-decoration:none}
.brand{display:flex;align-items:center;gap:12px}.brand>span:last-child{display:flex;flex-direction:column;line-height:1.15}.brand b{font-size:17px;letter-spacing:-.02em}.brand small{margin-top:4px;color:var(--muted);font-size:10px;letter-spacing:.12em} .hidden{display:none !important}
.brand-mark{width:44px;height:44px;display:grid;place-items:center;color:white;border-radius:14px;background:linear-gradient(145deg,#617cfb,#4b5ed9);box-shadow:0 10px 24px rgba(79,110,247,.3);font-weight:900;font-size:21px}.brand-mark.small{width:38px;height:38px;border-radius:12px;font-size:18px} button{font-family:var(--font)}
.login-shell{min-height:100vh;display:grid;grid-template-columns:minmax(420px,47%) 1fr;background:var(--surface)}.login-main{position:relative;display:grid;place-items:center;padding:60px 8vw}.login-card{width:min(100%,430px)}.login-brand{margin-bottom:70px}.login-copy h1{margin:0 0 12px;font-size:clamp(32px,4vw,44px);letter-spacing:-.04em}.login-copy>p:last-child{margin:0;font-size:15px}.login-form{margin-top:38px}.login-footnote{margin:30px 0 0;color:var(--muted);font-size:12px;text-align:center}.login-theme{position:absolute;top:28px;right:30px} input,select,textarea{font-family:var(--font);color:var(--text)}
.login-visual{position:relative;overflow:hidden;margin:18px;padding:clamp(48px,7vw,110px);border-radius:28px;color:white;background:linear-gradient(140deg,#637df8 0%,#4961da 54%,#334bbf 100%);display:flex;flex-direction:column;justify-content:center}.login-visual::after{content:"";position:absolute;inset:0;opacity:.13;background-image:radial-gradient(circle at 1px 1px,#fff 1px,transparent 0);background-size:28px 28px}.visual-copy,.visual-card{position:relative;z-index:1}.visual-copy{max-width:650px}.visual-copy h2{margin:24px 0 18px;font-size:clamp(36px,4.5vw,62px);line-height:1.13;letter-spacing:-.045em}.visual-copy p{max-width:560px;margin:0;color:rgba(255,255,255,.78);font-size:17px;line-height:1.8}.visual-badge{display:inline-flex;padding:9px 14px;border:1px solid rgba(255,255,255,.28);border-radius:999px;background:rgba(255,255,255,.1);font-size:12px;font-weight:750;letter-spacing:.08em}.visual-card{display:flex;align-items:center;gap:15px;width:fit-content;margin-top:70px;padding:18px 22px;border:1px solid rgba(255,255,255,.22);border-radius:15px;background:rgba(255,255,255,.12);backdrop-filter:blur(16px)}.visual-card div{display:flex;flex-direction:column;gap:5px}.visual-card small{color:rgba(255,255,255,.7)}.orb{position:absolute;border-radius:50%;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.06)}.orb-one{width:440px;height:440px;right:-100px;top:-110px}.orb-two{width:260px;height:260px;left:-90px;bottom:-80px} :focus-visible{outline:2px solid var(--primary-hover);outline-offset:1px}
.app-shell{min-height:100vh;display:grid;grid-template-columns:246px minmax(0,1fr)}.sidebar{position:sticky;top:0;z-index:30;height:100vh;display:flex;flex-direction:column;padding:24px 15px 18px;border-right:1px solid var(--line);background:var(--surface)}.sidebar-brand{padding:0 8px 27px}.nav-caption{margin:5px 12px 10px;color:var(--muted);font-size:10px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}#nav{display:flex;flex-direction:column;gap:4px}#nav button{width:100%;display:flex;align-items:center;gap:12px;padding:11px 13px;border:0;border-radius:10px;color:var(--muted);background:transparent;font-weight:650;text-align:left;transition:.16s ease}#nav button:hover{color:var(--text);background:var(--surface-hover)}#nav button.active{color:var(--primary);background:var(--primary-soft)}.nav-icon{width:23px;height:23px;display:grid;place-items:center;border-radius:7px;font-size:17px}#nav button.active .nav-icon{color:white;background:var(--primary);box-shadow:0 5px 12px rgba(79,110,247,.25)}.sidebar-foot{margin-top:auto;display:flex;align-items:center;justify-content:space-between;padding:14px 10px 0;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.sidebar-foot>div{display:flex;align-items:center;gap:8px}.live-dot{flex:0 0 auto;width:8px;height:8px;display:inline-block;border-radius:50%;background:#35c493;box-shadow:0 0 0 4px rgba(53,196,147,.15)}
.main-shell{min-width:0}.topbar{height:88px;position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:0 34px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:blur(18px)}.title-block h1{margin:0;font-size:24px;letter-spacing:-.025em}.top-actions{display:flex;align-items:center;gap:10px}.user-chip{padding:9px 13px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--muted);font-size:12px;font-weight:700}.content{max-width:1620px;margin:0 auto;padding:28px 34px 60px}
button,.button{border:1px solid var(--line-strong);border-radius:9px;padding:8px 12px;color:var(--text);background:var(--surface);font-size:12px;font-weight:700;transition:border-color .15s,background .15s,transform .15s}button:hover{border-color:var(--primary)}button:active{transform:translateY(1px)}button.primary{border-color:var(--primary);color:white;background:var(--primary);box-shadow:0 6px 16px rgba(79,110,247,.2)}button.primary:hover{background:var(--primary-dark)}button.danger{border-color:transparent;color:var(--danger);background:var(--danger-soft)}button.ghost{background:transparent}button.full{width:100%}button.large{padding:13px 16px;font-size:14px;border-radius:11px}button:disabled{cursor:not-allowed;opacity:.48;transform:none}.icon-button,.theme-button{width:38px;height:38px;display:grid;place-items:center;padding:0;border-radius:10px;font-size:18px}.theme-button{color:var(--primary);background:var(--primary-soft);border-color:transparent}.mobile-only,.sidebar-scrim{display:none}
.page-intro{display:flex;align-items:center;justify-content:space-between;gap:22px;margin-bottom:18px}.page-intro p{max-width:820px;margin:0;color:var(--muted);font-size:13px;line-height:1.75}.metrics{display:grid;grid-template-columns:repeat(5,minmax(150px,1fr));gap:14px;margin-bottom:18px}.metric{position:relative;overflow:hidden;padding:21px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:var(--shadow)}.metric::before{content:"";position:absolute;width:5px;height:46px;left:0;top:22px;border-radius:0 5px 5px 0;background:var(--accent)}.metric span,.metric small{display:block;color:var(--muted);font-size:12px}.metric strong{display:block;margin:7px 0 4px;font-size:29px;letter-spacing:-.04em}.grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.panel{overflow:hidden;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:var(--shadow)}.panel-head{min-height:68px;display:flex;align-items:center;justify-content:space-between;gap:15px;padding:16px 20px;border-bottom:1px solid var(--line)}.panel-head h2{margin:0 0 4px;font-size:15px}.panel-head p{margin:0;color:var(--muted);font-size:11px}.panel-body{padding:20px}.health-list,.timeline,.stack{display:flex;flex-direction:column;gap:13px}.health-row{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 0;border-bottom:1px solid var(--line)}.health-row:last-child{border:0}.health-row>div{min-width:0;display:flex;flex-direction:column;gap:4px}.health-row span,.health-row small{color:var(--muted);font-size:11px}.event{display:grid;grid-template-columns:10px minmax(0,1fr) auto;align-items:center;gap:12px;padding:9px 0}.event-dot{width:8px;height:8px;border:2px solid var(--primary);border-radius:50%}.event div{display:flex;flex-direction:column;gap:3px}.event small,.event time{color:var(--muted);font-size:10px}
.toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:9px;width:100%}.search{width:min(410px,100%)}.tag{display:inline-flex;align-items:center;margin:2px 4px 2px 0;padding:4px 8px;border-radius:999px;color:var(--primary);background:var(--primary-soft);font-size:10px;font-weight:750}.status{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:999px;color:var(--muted);background:var(--surface-soft);font-size:10px;font-weight:800;white-space:nowrap}.status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.status.online,.status.active,.status.completed{color:var(--success);background:var(--success-soft)}.status.failed,.status.degraded,.status.suspended{color:var(--danger);background:var(--danger-soft)}.status.deploying,.status.queued,.status.running,.status.removing,.status.redeploying,.status.redeploy_pending,.status.changes_pending{color:var(--warning);background:var(--warning-soft)}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12px}th{padding:11px 16px;color:var(--muted);background:var(--surface-soft);font-size:10px;text-align:left;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}td{padding:14px 16px;border-top:1px solid var(--line);vertical-align:middle}tbody tr:hover{background:var(--surface-hover)}td strong,td small{display:block}td strong{font-size:12px}td small{max-width:360px;margin-top:4px;color:var(--muted);line-height:1.45}.actions{display:flex;justify-content:flex-end;flex-wrap:wrap;gap:6px}.progress{width:130px;height:4px;margin-top:8px;overflow:hidden;border-radius:5px;background:var(--line)}.progress i{height:100%;display:block;border-radius:inherit;background:var(--primary)}.empty{min-height:120px;display:grid;place-items:center;align-content:center;gap:7px;color:var(--muted);text-align:center}.empty b{color:var(--text)}
label{display:flex;flex-direction:column;gap:7px;color:var(--text);font-size:12px;font-weight:700}input,select,textarea{width:100%;min-height:42px;padding:9px 11px;outline:0;border:1px solid var(--line-strong);border-radius:9px;color:var(--text);background:var(--surface);transition:border-color .15s,box-shadow .15s}input:focus,select:focus,textarea:focus{border-color:var(--primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--primary) 14%,transparent)}textarea{min-height:88px;resize:vertical}label small,.field-note{color:var(--muted);font-size:10px;font-weight:500;line-height:1.5}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px}.form-grid .wide{grid-column:1/-1}.form-actions{grid-column:1/-1;display:flex;justify-content:flex-end;gap:9px;padding-top:8px}.inline-fields{display:grid;grid-template-columns:1fr 1fr;gap:8px}.check-grid{max-height:215px;overflow:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:4px}.check-card{display:flex;flex-direction:row;align-items:center;gap:10px;padding:10px;border:1px solid var(--line);border-radius:9px;background:var(--surface-soft)}.check-card input{width:15px;min-height:auto;accent-color:var(--primary)}.check-card span{display:flex;flex-direction:column;gap:3px}.check-card small{color:var(--muted)}.notice{padding:12px 14px;border:1px solid color-mix(in srgb,var(--primary) 25%,var(--line));border-radius:10px;color:var(--muted);background:var(--primary-soft);font-size:11px;line-height:1.65}.notice.warning{border-color:color-mix(in srgb,var(--warning) 30%,var(--line));background:var(--warning-soft)}.codebox{overflow:auto;padding:14px;border:1px solid var(--line);border-radius:10px;color:var(--text);background:var(--surface-soft);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.7;white-space:pre-wrap;word-break:break-all}.form-error{min-height:20px;color:var(--danger);font-size:12px}.section-title{margin:8px 0 -3px;grid-column:1/-1;display:flex;align-items:center;gap:10px;color:var(--muted);font-size:10px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.section-title::after{content:"";flex:1;height:1px;background:var(--line)}
dialog{width:min(760px,calc(100% - 32px));max-height:calc(100vh - 40px);padding:0;overflow:auto;border:0;border-radius:18px;color:var(--text);background:var(--surface);box-shadow:var(--shadow-lg)}dialog::backdrop{background:rgba(18,25,39,.48);backdrop-filter:blur(3px)}.dialog-shell>header{position:sticky;top:0;z-index:2;display:flex;align-items:center;justify-content:space-between;padding:20px 22px;border-bottom:1px solid var(--line);background:var(--surface)}.dialog-shell h2{margin:0;font-size:20px}#modal-body{padding:22px}#toasts{position:fixed;z-index:80;right:22px;bottom:22px;display:flex;flex-direction:column;gap:9px}.toast{max-width:390px;padding:12px 15px;border:1px solid color-mix(in srgb,var(--success) 25%,var(--line));border-radius:10px;color:var(--success);background:var(--surface);box-shadow:var(--shadow-lg);font-size:12px;font-weight:700}.toast.error{border-color:color-mix(in srgb,var(--danger) 30%,var(--line));color:var(--danger)}
@media(max-width:1120px){.metrics{grid-template-columns:repeat(3,1fr)}.login-shell{grid-template-columns:1fr 1fr}.login-main{padding:50px 7vw}.login-visual{padding:55px}}
@media(max-width:860px){.login-shell{display:block;background:var(--surface)}.login-main{min-height:100vh;padding:50px 24px}.login-visual{display:none}.login-brand{margin-bottom:48px}.app-shell{display:block}.sidebar{position:fixed;left:0;transform:translateX(-105%);width:246px;transition:transform .2s ease;box-shadow:var(--shadow-lg)}.app-shell.sidebar-open .sidebar{transform:translateX(0)}.sidebar-scrim{position:fixed;z-index:25;inset:0;border:0;border-radius:0;background:rgba(15,22,34,.42)}.app-shell.sidebar-open .sidebar-scrim{display:block}.mobile-only{display:grid}.topbar{padding:0 18px}.content{padding:22px 18px 50px}.grid-2,.grid-3{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.user-chip{display:none}}
@media(max-width:570px){.topbar{height:76px}.title-block h1{font-size:19px}.title-block .eyebrow{display:none}#refresh{display:none}.page-intro{align-items:stretch;flex-direction:column}.metrics{grid-template-columns:1fr}.form-grid{grid-template-columns:1fr}.form-grid .wide,.form-actions,.section-title{grid-column:1}.check-grid{grid-template-columns:1fr}.inline-fields{grid-template-columns:1fr}.login-copy h1{font-size:34px}dialog{width:calc(100% - 18px);max-height:calc(100vh - 18px)}#modal-body{padding:18px}}
/* Compact control-room layout: stable navigation width and readable actions in either theme. */ /* ---------- 布局 ---------- */
:root{--bg:#f5f7fc;--surface-soft:#f8faff;--primary:#3478ee;--primary-dark:#2468dc;--primary-soft:#eaf2ff;--radius:14px;--shadow:0 4px 18px rgba(26,44,80,.04)} .layout{display:flex;min-height:100vh}
html[data-theme="dark"]{--bg:#0e1420;--surface-soft:#1b2638;--primary:#79a9ff;--primary-dark:#9bbfff;--primary-soft:#213656;--shadow:0 4px 20px rgba(0,0,0,.12)} .sider{flex:0 0 224px;width:224px;background:var(--sider);border-right:1px solid var(--border-soft);position:sticky;top:0;height:100vh;display:flex;flex-direction:column;z-index:30}
.login-shell{display:grid;grid-template-columns:1fr;background:var(--bg)} .brand{display:flex;align-items:center;height:58px;padding:0 20px;border-bottom:1px solid var(--border-soft);font-size:19px;font-weight:700;letter-spacing:.4px}
.login-main{min-height:100vh;padding:28px;display:grid;place-items:center} .menu{padding:12px 10px;flex:1;overflow-y:auto}
.login-card{width:min(100%,420px);padding:38px;border:1px solid var(--line);border-radius:18px;background:var(--surface);box-shadow:var(--shadow)} .mi{display:flex;align-items:center;gap:10px;padding:8px 10px;margin:2px 0;border-radius:6px;color:var(--text-2);cursor:pointer}
.login-brand{margin-bottom:32px}.login-copy h1{margin:0;font-size:29px}.login-form{margin-top:28px} .mi svg{width:16px;height:16px;flex:none}
.login-theme{top:24px;right:28px}.login-visual{display:none} .mi:hover{background:var(--hover-bg);color:var(--text)}
.app-shell{grid-template-columns:218px minmax(0,1fr)} .mi.active{background:var(--primary-soft);color:var(--primary-text);font-weight:500}
.sidebar{width:218px;min-width:218px;padding:24px 12px 18px;color:#e7edf9;border-right:0;background:#151e2d} .sider-foot{padding:12px 20px;border-top:1px solid var(--border-soft);color:var(--text-2);font-size:12.5px;display:flex;justify-content:space-between;align-items:center;gap:8px}
.sidebar .brand b{color:#fff}.sidebar .brand small,.sidebar .nav-caption{color:#8190a6} .sider-foot .foot-right{display:inline-flex;align-items:center;gap:6px;color:var(--text-3)}
.sidebar-brand{padding:0 10px 28px}.sidebar .brand-mark{background:#3478ee;box-shadow:none} .main{flex:1;min-width:0;display:flex;flex-direction:column}
#nav{min-width:0}#nav button{min-width:0;width:100%;display:flex;flex-flow:row nowrap;align-items:center;gap:12px;white-space:nowrap;line-height:1.3;color:#a9b7cb;padding:11px 12px;font-size:13px} .topbar{height:58px;display:flex;align-items:center;gap:12px;padding:0 24px;border-bottom:1px solid var(--border-soft);background:var(--bg);position:sticky;top:0;z-index:20}
#nav button .nav-label{display:block;flex:0 0 auto;white-space:nowrap;word-break:keep-all;writing-mode:horizontal-tb} .crumb{color:var(--text-3);font-size:13px;white-space:nowrap}
#nav button:hover{color:#fff;background:#222f44}#nav button.active{color:#fff;background:#2a3b58} .crumb b{color:var(--text);font-weight:600;font-size:15px}
#nav button.active .nav-icon{color:#79a9ff;background:transparent;box-shadow:none} .topbar .sp{flex:1}
.nav-icon{flex:0 0 22px;width:22px;height:22px;font-size:16px}.nav-group{margin-top:18px;margin-bottom:6px} .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}
.sidebar-foot{border-color:#354256;color:#91a1b9}.sidebar-foot .icon-button{color:#e7edf9;background:transparent;border-color:#354256} .theme-btn:hover{border-color:var(--primary);color:var(--primary-text)}
.topbar{height:74px;padding:0 30px;background:var(--surface);backdrop-filter:none}.title-block h1{font-size:20px}.title-block .eyebrow{margin-bottom:3px;font-size:10px;color:var(--muted)} .content{padding:24px;width:100%}
.content{max-width:1460px;padding:28px 30px 60px}.theme-button{width:auto;min-width:68px;height:36px;padding:0 11px;font-size:12px;white-space:nowrap} .panel{background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:16px}
.page-intro{margin-bottom:18px}.page-intro p{max-width:900px}.panel{box-shadow:var(--shadow)} .panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;border-bottom:1px solid var(--border-soft)}
.actions{min-width:235px;flex-wrap:wrap}.actions button{white-space:nowrap} .panel-head h2{font-size:14.5px;font-weight:600}
.table-wrap table{min-width:850px}.table-wrap td{line-height:1.5}.table-wrap td:last-child{width:1%;white-space:nowrap} .panel-body{padding:14px 16px}
.protocol-note{grid-column:1/-1;line-height:1.7}.chain-section{grid-column:1/-1;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--surface-soft)} .toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.chain-section h3{margin:0 0 14px;font-size:14px}.chain-section .form-grid{gap:14px}.field-hint{color:var(--muted);font-size:11px;line-height:1.6} .page-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
.row-actions{display:flex;gap:7px;align-items:center}.danger-link{color:var(--danger)} .search{background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 12px;font-size:13px;width:260px}
@media(max-width:860px){.sidebar{width:218px;min-width:218px}.topbar{padding:0 18px}.content{padding:20px 18px 50px}} select.search{width:auto}
@media(max-width:570px){.login-card{padding:28px 22px}.top-actions{gap:5px}.theme-button{min-width:56px}.content{padding:18px 12px 40px}} .btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--card-hover);color:var(--text);border-radius:6px;padding:5px 14px;font-size:13px;cursor:pointer;white-space:nowrap}
.btn:hover{border-color:var(--primary);color:var(--primary-text)}
.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
.btn.primary:hover{background:var(--primary-hover);color:#fff}
.btn.danger{color:var(--red-text)}
.btn.danger:hover{border-color:var(--red);color:var(--red-text);background:var(--red-soft)}
.btn.small{padding:2px 10px;font-size:12px}
.btn.full{width:100%;justify-content:center;padding:8px 14px}
.btn:disabled{opacity:.55;cursor:default}
/* Long device and customer lists stay inside a searchable, keyboard-friendly picker. */ /* ---------- 系统状态 ---------- */
.picker-field{display:flex;flex-direction:column;gap:7px;font-size:12px;font-weight:700} .stat-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:16px}
.picker-field>small,.picker-content>small{color:var(--muted);font-size:11px;font-weight:500} .stat{background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);padding:14px 16px 10px;overflow:hidden}
.multi-picker{border:1px solid var(--line-strong);border-radius:10px;background:var(--surface)} .stat .hd{display:flex;align-items:center;gap:7px;color:var(--text-2);font-size:13px}
.multi-picker>summary{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 12px;cursor:pointer;list-style:none} .stat .hd svg{width:15px;height:15px;color:var(--primary);flex:none}
.multi-picker>summary::-webkit-details-marker{display:none} .stat .hd .scope{margin-left:auto;color:var(--text-3);font-size:11.5px}
.multi-picker[open]>summary{border-bottom:1px solid var(--line);color:var(--primary)} .stat .big{font-size:30px;font-weight:600;line-height:1.25;margin-top:4px;font-variant-numeric:tabular-nums}
.picker-content{padding:10px;display:grid;gap:9px}.picker-options{max-height:225px;overflow:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px} .stat .big small{font-size:15px;font-weight:500;color:var(--text-2);margin-left:2px}
.picker-option{display:flex;flex-direction:row;align-items:center;min-width:0;gap:10px;padding:9px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface-soft);cursor:pointer} .stat .det{color:var(--text-2);font-size:12.5px;font-variant-numeric:tabular-nums}
.picker-option[hidden]{display:none}.picker-option:hover{border-color:var(--primary)}.picker-option input{flex:0 0 16px;width:16px;min-height:16px;accent-color:var(--primary)}.picker-option span{min-width:0;display:grid;gap:3px}.picker-option b,.picker-option small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.picker-option small{color:var(--muted);font-size:10px;font-weight:500} .stat .mm{display:flex;justify-content:space-between;color:var(--text-3);font-size:12px;margin-top:8px;font-variant-numeric:tabular-nums}
.subscription-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft)} .spark{display:block;width:100%;height:44px;margin-top:4px}
.subscription-row>div{min-width:0;display:grid;gap:5px}.subscription-row code{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-size:11px}.subscription-row button{flex:0 0 auto} .mid-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px;margin-bottom:16px;align-items:stretch}
@media(max-width:570px){.picker-options{grid-template-columns:1fr}.subscription-row{align-items:stretch;flex-direction:column}.subscription-row button{align-self:flex-end}} .chart-card{padding:14px 18px 12px;display:flex;flex-direction:column;margin-bottom:0}
.chart-hd{display:flex;align-items:flex-start;gap:12px}
.chart-hd h2{font-size:14.5px;font-weight:600}
.chart-hd .legend{margin-left:auto;display:flex;gap:18px;font-size:13px;white-space:nowrap;font-variant-numeric:tabular-nums}
.legend .up{color:var(--primary-text);font-weight:600}
.legend .dn{color:var(--text-2);font-weight:600}
.legend .lb{color:var(--text-3);font-weight:400;font-size:12px;margin-right:3px}
.chart{flex:1;width:100%;margin-top:8px}
.stat3{display:flex;border-top:1px solid var(--border-soft);margin-top:10px;padding-top:11px}
.stat3>div{flex:1;padding:0 14px}
.stat3>div:first-child{padding-left:0}
.stat3>div+div{border-left:1px solid var(--border-soft)}
.stat3 .k{color:var(--text-3);font-size:12px}
.stat3 .v{font-size:16.5px;font-weight:600;margin-top:1px;font-variant-numeric:tabular-nums}
.conn .big{font-size:34px;font-weight:600;line-height:1.2;font-variant-numeric:tabular-nums}
.conn .big span{font-size:13px;font-weight:400;color:var(--text-3);margin-left:6px}
.conn .proto-row{display:flex;gap:26px;margin-top:8px;font-size:13px;font-variant-numeric:tabular-nums}
.conn .proto-row b{font-weight:600;margin-left:5px}
.swatch{display:inline-block;width:16px;height:0;border-top:2.5px solid var(--primary);border-radius:2px;vertical-align:middle}
.swatch.g{border-top-color:var(--chart-2)}
.info-bar{display:flex;gap:0;padding:14px 18px}
.info-bar .seg{flex:1;padding:0 18px;min-width:0}
.info-bar .seg:first-child{padding-left:0}
.info-bar .seg+.seg{border-left:1px solid var(--border-soft)}
.info-bar .cap{color:var(--text-3);font-size:12.5px;display:flex;align-items:center;gap:6px;margin-bottom:7px}
.info-bar .cap svg{width:14px;height:14px}
.kv{display:flex;gap:22px;flex-wrap:wrap}
.kv .item .k{display:block;color:var(--text-3);font-size:11px;letter-spacing:.6px}
.kv .item .v{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums}
.kv .item .v.mono{font-family:var(--mono);font-size:13px;font-weight:500;word-break:break-all}
.ln-up{stroke:var(--primary);fill:none;stroke-width:1.8;stroke-linejoin:round;stroke-linecap:round}
.ln-dn{stroke:var(--chart-2);fill:none;stroke-width:1.8;stroke-linejoin:round;stroke-linecap:round}
.mean{stroke:var(--primary);stroke-width:1;stroke-dasharray:4 4;opacity:.55;fill:none}
.mean-g{stroke:var(--chart-2);stroke-width:1;stroke-dasharray:4 4;opacity:.6;fill:none}
.grid-ln{stroke:var(--border-soft);stroke-width:1;stroke-dasharray:3 5;fill:none}
.stop-up-a{stop-color:var(--primary);stop-opacity:.26}
.stop-up-b{stop-color:var(--primary);stop-opacity:0}
.notice{border:1px solid var(--orange);background:var(--orange-soft);color:var(--orange-text);border-radius:6px;padding:8px 12px;font-size:13px;margin-bottom:16px}
/* Readable defaults on wide admin screens without inflating the navigation. */ /* ---------- 表格通用 ---------- */
table{font-size:13px}th{font-size:11px;letter-spacing:.02em}td strong{font-size:13px}td small{font-size:11px} .table-wrap{overflow-x:auto}
.panel-head p,.health-row span,.health-row small,.event small,.event time{font-size:12px} .table-wrap.visible{overflow:visible}
.status,.tag{font-size:11px}.page-intro p{font-size:13px}.actions{min-width:0} table{width:100%;border-collapse:collapse}
table.w-servers{min-width:1390px}
table.w-customers{min-width:1120px}
table.w-nodes{min-width:1180px}
table.w-jobs td{white-space:nowrap;padding-top:5px;padding-bottom:5px}
table.w-jobs th:last-child{width:22%}
table.w-jobs td:last-child{white-space:normal;min-width:120px;word-break:break-word}
thead th{text-align:left;font-size:12.5px;font-weight:600;color:var(--text-2);letter-spacing:.3px;padding:7px 12px;border-bottom:1px solid var(--border-soft);background:var(--th-bg);white-space:nowrap}
thead th .sarr{color:var(--text-3);font-size:10px;margin-left:3px}
thead th.ctr,tbody td.ctr{text-align:center}
tbody td{padding:6px 12px;border-bottom:1px solid var(--border-soft);vertical-align:middle;font-size:13.5px}
tbody tr:last-child td{border-bottom:none}
tbody tr:hover{background:var(--row-hover)}
tbody tr.dragging{opacity:.4}
tbody tr.drag-over td{border-top:2px solid var(--primary)}
.badge{display:inline-flex;align-items:center;gap:5px;padding:1px 8px;border-radius:5px;font-size:12px;line-height:20px;white-space:nowrap}
.badge.green{background:var(--green-soft);color:var(--green-text)}
.badge.red{background:var(--red-soft);color:var(--red-text)}
.badge.orange{background:var(--orange-soft);color:var(--orange-text)}
.badge.blue{background:var(--primary-soft);color:var(--primary-text)}
.badge.gray{background:var(--gray-soft);color:var(--text-2)}
.num{font-variant-numeric:tabular-nums;white-space:nowrap}
.mut{color:var(--text-3)}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex:none}
.dot.on{background:var(--green);animation:pulse 2s infinite}
.dot.off{background:var(--dot-off)}
.dot.bad{background:var(--red)}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(82,196,26,.45)}70%{box-shadow:0 0 0 6px rgba(82,196,26,0)}100%{box-shadow:0 0 0 0 rgba(82,196,26,0)}}
.op-cell{display:flex;align-items:center;gap:1px;white-space:nowrap}
.op-cell input[type="checkbox"]{margin-right:7px}
.icon-btn{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border:none;background:transparent;color:var(--text-2);border-radius:6px;cursor:pointer;padding:0;vertical-align:middle}
.icon-btn svg{width:17px;height:17px;display:block}
.icon-btn:hover{color:var(--primary-text);background:var(--hover-bg)}
.icon-btn.del{color:var(--red-text)}
.icon-btn.del:hover{background:var(--red-soft);color:var(--red-text)}
/* 图标悬停提示气泡(3x-ui 式:黑底白字圆角 + 小尖角,挂 body 避免被表格滚动容器裁剪) */
.tip-bubble{position:fixed;z-index:200;pointer-events:none;background:#141414;color:#fff;font-size:12px;line-height:1.25;padding:6px 9px;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity .12s;box-shadow:0 4px 14px rgba(0,0,0,.25)}
.tip-bubble.show{opacity:1}
.tip-bubble::after{content:"";position:absolute;top:100%;left:var(--tip-arrow-left,50%);transform:translateX(-50%);border:5px solid transparent;border-top-color:#141414;border-bottom:0}
.tgl{position:relative;display:inline-block;width:38px;height:21px;vertical-align:middle;flex:none}
.tgl input{opacity:0;width:0;height:0;position:absolute}
.tgl .tr{position:absolute;inset:0;background:var(--tgl-off);border-radius:99px;transition:background .2s;cursor:pointer}
.tgl .tr::before{content:"";position:absolute;width:17px;height:17px;border-radius:50%;background:#fff;top:2px;left:2px;transition:transform .2s;box-shadow:0 1px 3px rgba(0,0,0,.35)}
.tgl input:checked + .tr{background:var(--primary)}
.tgl input:checked + .tr::before{transform:translateX(17px)}
.tgl input:focus-visible + .tr{outline:2px solid var(--primary-hover);outline-offset:2px}
.tgl input:disabled + .tr{opacity:.5;cursor:default}
.pill{display:inline-flex;align-items:center;gap:7px;background:var(--pill-bg);color:var(--text);border-radius:999px;padding:3px 11px;font-size:12.5px;white-space:nowrap;font-variant-numeric:tabular-nums}
.pill.green{background:var(--green-soft);color:var(--green-text);font-weight:600}
.pill.red{background:var(--red-soft);color:var(--red-text);font-weight:600}
.pill.gray{background:var(--gray-soft);color:var(--text-2)}
.pill.mut{color:var(--text-3)}
.mini-bar{width:62px;height:6px;border-radius:99px;background:var(--track);overflow:hidden;display:inline-block;flex:none}
.mini-bar i{display:block;height:100%;background:var(--primary);border-radius:99px}
.pill.warn .mini-bar i{background:var(--orange)}
.pill.danger .mini-bar i{background:var(--red)}
.pager{display:flex;justify-content:flex-end;align-items:center;gap:6px;padding:9px 16px;border-top:1px solid var(--border-soft);color:var(--text-2);font-size:13px}
.pg-btn{min-width:28px;height:28px;padding:0 7px;border:1px solid var(--border);background:var(--card);border-radius:6px;color:var(--text-2);cursor:pointer;font-size:13px}
.pg-btn.active{border-color:var(--primary);color:var(--primary-text)}
.pg-btn:disabled{opacity:.45;cursor:default}
.empty{padding:28px 16px;text-align:center;color:var(--text-3);font-size:13px}
.empty b{display:block;color:var(--text-2);font-size:14px;margin-bottom:2px}
/* Keep dense management tables at one row per item, even at narrow widths. */ /* ---------- 服务器页 ---------- */
.table-wrap table{min-width:1040px;table-layout:auto} .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}
.table-wrap td{padding:9px 13px;line-height:1.35} .stats.six{grid-template-columns:repeat(6,1fr)}
.table-wrap th{padding:10px 13px} .stats .stat{padding:14px 22px;border:none;background:transparent}
.table-wrap td:last-child{min-width:200px} .stats .stat .lab{color:var(--text-2);font-size:12.5px;margin-bottom:3px}
.table-wrap td strong{font-weight:650} .stats .stat .val{display:flex;align-items:center;gap:8px;font-size:20px;font-weight:600;font-variant-numeric:tabular-nums}
.table-wrap td small{font-weight:400;margin-top:3px} .stats .stat .val svg{width:19px;height:19px;flex:none}
.actions.compact-actions{min-width:0;justify-content:flex-end;flex-wrap:nowrap;align-items:center;gap:4px} .ic-dim{color:var(--text-2)} .ic-green{color:var(--green)} .ic-red{color:var(--red)} .ic-bolt{color:var(--orange)}
.compact-actions button{padding:6px 8px;font-size:11px;font-weight:600} .exp{display:inline-flex;width:20px;height:20px;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:5px;color:var(--text-2);font-size:14px;line-height:1;background:var(--card);cursor:pointer;padding:0}
.compact-actions select{width:92px;min-height:30px;padding:4px 7px;font-size:11px;font-weight:500;cursor:pointer} .eye-btn{width:24px;height:24px;vertical-align:-6px;margin-left:4px}
.error-detail{max-width:230px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--danger)} .eye-btn svg{width:15px;height:15px}
.subscription-row{flex-wrap:wrap;background:var(--surface)} .dev-name{font-weight:600;font-size:13.5px;white-space:nowrap}
.subscription-row>div:first-child{flex:1 1 310px} .addr{font-family:var(--mono);font-size:12.5px;color:var(--text-2);white-space:nowrap}
.access-table{max-height:280px;overflow:auto;margin-top:10px;border:1px solid var(--line);border-radius:10px} .status{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font-weight:500}
.access-table table{min-width:610px} .status.on{color:var(--green-text)}
.access-table td{padding:9px 12px} .status.off{color:var(--red-text)}
.access-table td small{max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .warn-ic{width:15px;height:15px;color:var(--orange);flex:none;vertical-align:-2px}
.subscription-actions{display:flex;gap:5px;flex:0 0 auto} .ver-cell{display:inline-flex;align-items:center;gap:7px;white-space:nowrap}
.subscription-qr{flex:1 0 100%;text-align:center;padding:14px;border-top:1px solid var(--line)} .ver{font-family:var(--mono);font-size:12.5px}
.subscription-qr[hidden]{display:none} .clients-cell{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font-variant-numeric:tabular-nums}
.subscription-qr svg{max-width:min(100%,250px);height:auto;background:#fff;border:9px solid #fff;border-radius:8px} .clients-cell svg{width:16px;height:16px;color:var(--text-3);flex:none}
.status.suspending,.status.partially_suspended{color:var(--warning);background:var(--warning-soft)} .clients-cell .on-n{color:var(--green-text);font-weight:600}
.command-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr));gap:16px;align-items:start} .hb{white-space:nowrap;color:var(--text-2)}
.command-item{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 0;border-bottom:1px solid var(--line)} .detail-row td{background:var(--th-bg);padding:12px 16px}
.command-item:last-child{border-bottom:0}.command-item>div{min-width:0;display:grid;gap:5px} .detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:8px 22px;font-size:13px}
.command-item small{color:var(--muted);font-size:11px;line-height:1.55}.command-item code{font-size:12px;color:var(--primary)} .detail-grid .k{color:var(--text-3);font-size:11.5px;display:block}
.command-item button{flex:0 0 auto} .detail-grid .v{font-weight:500}
.node-link-list{display:grid;gap:8px;margin-top:8px} .detail-grid .v.err{color:var(--red-text)}
.node-link-customer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;background:var(--surface);border:1px solid var(--line);border-radius:8px} .detail-actions{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
.node-link-customer .actions{margin:0}
details summary{cursor:pointer;font-weight:600} /* ---------- 客户页 ---------- */
.pres{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:2px 11px;font-size:12.5px;font-weight:600;white-space:nowrap}
.pres.on{background:var(--green-soft);color:var(--green-text)}
.pres.off{background:var(--gray-soft);color:var(--text-3);font-weight:400}
.paused-note{display:block;color:var(--red-text);font-size:12px;font-weight:500;margin-top:4px;white-space:nowrap}
.cust-name{font-weight:600;font-size:13.5px;white-space:nowrap}
.cust-note{display:block;color:var(--text-3);font-size:12px;margin-top:1px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.in-chip{display:inline-flex;padding:2px 9px;border-radius:6px;background:var(--primary-soft);color:var(--primary-text);font-size:12.5px;font-weight:500;line-height:20px;white-space:nowrap}
.in-chip.more{background:var(--gray-soft);color:var(--text-2);margin-left:5px;cursor:pointer;border:none;font-family:var(--font)}
.in-chip.more:hover{color:var(--primary-text);background:var(--primary-soft)}
.in-chip.ss{background:var(--pink-soft);color:var(--pink-text)}
.in-chip.mixed{background:var(--orange-soft);color:var(--orange-text)}
.speed{font-variant-numeric:tabular-nums;white-space:nowrap}
.speed.mut{color:var(--text-3)}
.filter-chip{display:inline-flex;align-items:center;gap:6px;background:var(--primary-soft);color:var(--primary-text);border-radius:999px;padding:3px 6px 3px 11px;font-size:12.5px}
.filter-chip button{border:none;background:transparent;color:inherit;cursor:pointer;font-size:14px;line-height:1;padding:2px}
/* ---------- 节点页 ---------- */
.grip{display:inline-flex;padding:3px 2px;border-radius:4px;color:var(--text-3);cursor:grab;vertical-align:middle}
.grip svg{width:13px;height:13px;display:block}
.grip:hover{color:var(--text);background:var(--hover-bg)}
.grip:active{cursor:grabbing}
td.menu-td{white-space:nowrap}
.node-name{font-weight:600;font-size:13.5px;white-space:nowrap}
.srv-chip{display:inline-flex;padding:2px 10px;border-radius:6px;font-size:12.5px;font-weight:500;line-height:20px;white-space:nowrap}
.srv-chip.online{background:var(--primary-soft);color:var(--primary-text)}
.srv-chip.offline{background:var(--red-soft);color:var(--red-text)}
.srv-chip.degraded{background:var(--orange-soft);color:var(--orange-text)}
.port{font-variant-numeric:tabular-nums}
.proto{display:inline-flex;padding:0 6px;border-radius:5px;font-size:11.5px;font-weight:600;line-height:18px;white-space:nowrap}
.proto.vless{background:var(--purple-soft);color:var(--purple-text)}
.proto.ss{background:var(--cyan-soft);color:var(--cyan-text)}
.proto.mixed{background:var(--orange-soft);color:var(--orange-text)}
.proto.other{background:var(--gray-soft);color:var(--text-2)}
.trans{display:inline-flex;padding:0 6px;border-radius:5px;font-size:11.5px;line-height:18px;white-space:nowrap;background:var(--trans-bg);color:var(--trans-text)}
.sec{display:inline-flex;padding:0 6px;border-radius:5px;font-size:11.5px;line-height:18px;white-space:nowrap;background:var(--sky-soft);color:var(--sky-text)}
.sec.plain{background:var(--gray-soft);color:var(--text-2)}
.badge-stack{display:inline-flex;gap:4px;flex-wrap:nowrap;align-items:center}
.clients{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;cursor:pointer}
.clients>svg{width:17px;height:17px;color:var(--text-3);flex:none}
.online-num{color:var(--green-text);font-weight:600;padding:1px 2px}
.online-num.zero{color:var(--text-3);font-weight:400}
/* ---------- 浮层(挂 body) ---------- */
.float-pop{position:fixed;z-index:90;background:var(--pop-bg);border:1px solid var(--border);border-radius:8px;box-shadow:var(--pop-shadow);min-width:176px;max-width:320px;padding:6px 0}
.float-pop .pop-head{display:flex;justify-content:space-between;align-items:center;padding:6px 12px;border-bottom:1px solid var(--border-soft);font-size:12px;color:var(--text-2)}
.float-pop .pop-head b{color:var(--text);font-size:13px}
.float-pop .pop-item{display:flex;align-items:center;gap:8px;padding:5px 12px;color:var(--text);font-size:13px}
.float-pop .pop-item .in-chip{margin:2px 0}
.float-pop .pop-foot{padding:6px 12px;border-top:1px solid var(--border-soft);font-size:11px;color:var(--text-3)}
.float-pop .menu-item{display:flex;width:100%;align-items:center;gap:8px;padding:7px 12px;border:none;background:transparent;color:var(--text);font-size:13px;cursor:pointer;text-align:left}
.float-pop .menu-item:hover{background:var(--hover-bg)}
.float-pop .menu-item.danger{color:var(--red-text)}
.float-pop .chip-list{padding:8px 12px;display:flex;flex-direction:column;gap:6px;align-items:flex-start}
/* ---------- 弹窗与表单 ---------- */
.modal-overlay{position:fixed;inset:0;background:rgba(10,12,16,.45);z-index:80;display:flex;align-items:flex-start;justify-content:center;overflow-y:auto;padding:36px 16px}
.modal{width:640px;max-width:100%;margin:0}
.modal.wide{width:820px}
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:13px 18px;border-bottom:1px solid var(--border-soft)}
.modal-head h2{font-size:15.5px;font-weight:600}
.modal-body{padding:16px 18px}
.stack{display:flex;flex-direction:column;gap:12px}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 16px}
.form-grid .wide{grid-column:1 / -1}
label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:var(--text-2)}
label>input,label>select,label>textarea,.field>input{border:1px solid var(--border);background:var(--card);border-radius:6px;padding:6px 10px;font-size:13.5px;color:var(--text)}
label>textarea{min-height:56px;resize:vertical}
label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--primary);outline:none}
.form-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
.form-actions .left{margin-right:auto}
.inline-fields{display:flex;gap:8px}
.inline-fields>*{flex:1;min-width:0}
.pair-fields{display:flex;gap:10px;align-items:flex-end}
.pair-fields label{flex:1}
.form-error{color:var(--red-text);font-size:13px;min-height:18px}
.codebox{background:var(--bg);border:1px solid var(--border-soft);border-radius:6px;padding:10px 12px;font-family:var(--mono);font-size:12.5px;white-space:pre-wrap;word-break:break-all;color:var(--text)}
.section-title{grid-column:1 / -1;font-size:13px;font-weight:600;color:var(--text);border-top:1px solid var(--border-soft);padding-top:12px}
.kv-table{width:100%;border-collapse:collapse}
.kv-table td{padding:5px 8px;border-bottom:1px solid var(--border-soft);font-size:13px;vertical-align:top}
.kv-table td.k{color:var(--text-3);width:130px;white-space:nowrap}
.mono{font-family:var(--mono);font-size:12.5px;word-break:break-all}
/* 页签 */
.segctl{display:inline-flex;border:1px solid var(--border);border-radius:8px;overflow:hidden}
.segctl button{padding:6px 16px;background:var(--card);border:none;color:var(--text-2);cursor:pointer;font:inherit;font-size:13px}
.segctl button.active{background:var(--primary);color:#fff}
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border-soft);margin-bottom:14px}
.tabs button{border:none;background:transparent;padding:7px 14px;font-size:13.5px;color:var(--text-2);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
.tabs button.active{color:var(--primary-text);border-bottom-color:var(--primary);font-weight:500}
.tab-pane.hidden{display:none}
/* 凭据行 */
.cred-row{display:flex;align-items:center;gap:8px;padding:7px 0;border-bottom:1px solid var(--border-soft)}
.cred-row:last-child{border-bottom:none}
.cred-row .k{width:110px;flex:none;color:var(--text-3);font-size:12.5px}
.cred-row .v{flex:1;min-width:0;font-family:var(--mono);font-size:12.5px;word-break:break-all}
.cred-group{margin-bottom:14px}
.cred-group .g-head{display:flex;align-items:center;gap:8px;font-weight:600;font-size:13.5px;margin-bottom:2px}
/* 链接页签条目 */
.link-item{border:1px solid var(--border-soft);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}
.link-item .row{display:flex;gap:8px;align-items:center}
.link-item input[type="text"],.link-item input:not([type]){flex:1;border:1px solid var(--border);background:var(--card);border-radius:6px;padding:6px 10px;font-size:13px;min-width:0}
.link-item .remark{flex:0 0 150px}
.link-item .status-line{font-size:12px;color:var(--text-3)}
.link-item .status-line .err{color:var(--red-text)}
.links-head{display:flex;align-items:center;justify-content:space-between;margin:14px 0 8px}
.links-head h3{font-size:13.5px;font-weight:600}
.links-note{font-size:13px;color:var(--text-2)}
.links-empty{color:var(--text-3);font-size:13px;padding:6px 0}
/* 关联入站选择器 */
.picker{position:relative}
.picker-box{display:flex;flex-wrap:wrap;gap:6px;align-items:center;border:1px solid var(--border);border-radius:6px;padding:5px 8px;min-height:34px;cursor:text;background:var(--card)}
.picker-box .chip{display:inline-flex;align-items:center;gap:5px;background:var(--primary-soft);color:var(--primary-text);border-radius:5px;padding:1px 4px 1px 8px;font-size:12.5px}
.picker-box .chip button{border:none;background:transparent;color:inherit;cursor:pointer;font-size:13px;line-height:1;padding:2px}
.picker-box .ph{color:var(--text-3);font-size:13px}
.picker-box .search-ic{margin-left:auto;color:var(--text-3);flex:none}
.picker-panel{position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:40;background:var(--pop-bg);border:1px solid var(--border);border-radius:8px;box-shadow:var(--pop-shadow);padding:6px}
.picker-tools{display:flex;gap:8px;align-items:center;padding:2px 4px 6px}
.picker-tools input{flex:1;border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:13px;background:var(--card)}
.picker-list{max-height:220px;overflow-y:auto}
.picker-opt{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;font-size:13.5px;color:var(--text)}
.picker-opt:hover{background:var(--hover-bg)}
.picker-opt.sel{background:var(--primary-soft)}
.picker-opt .ck{margin-left:auto;color:var(--primary-text);font-weight:700;visibility:hidden}
.picker-opt.sel .ck{visibility:visible}
.picker-opt small{color:var(--text-3)}
/* 订阅弹窗 */
.subscription-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border-soft)}
.subscription-row>div:first-child{flex:1;min-width:0}
.subscription-row b{display:block;font-size:13.5px}
.subscription-row code{font-family:var(--mono);font-size:11.5px;color:var(--text-2);word-break:break-all}
.subscription-actions{display:flex;gap:6px;flex:none}
.subscription-qr{padding:10px 0;text-align:center}
.subscription-qr svg{max-width:180px;height:auto}
.warn-note{border:1px solid var(--orange);background:var(--orange-soft);color:var(--orange-text);border-radius:6px;padding:8px 12px;font-size:13px}
/* 命令页 / 运维页 */
.command-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;margin-bottom:16px}
.command-grid .panel{margin-bottom:0}
.command-item{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border-soft)}
.command-item:last-child{border-bottom:none}
.command-item>div{flex:1;min-width:0}
.command-item b{display:block;font-size:13.5px}
.command-item code{font-family:var(--mono);font-size:12.5px;color:var(--primary-text)}
.grid-3{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
.grid-3 .panel{margin-bottom:0}
.health-row{display:flex;align-items:center;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--border-soft);font-size:13.5px}
.health-row:last-child{border-bottom:none}
.health-row span{color:var(--text-3);font-size:12.5px;display:block}
.tag{display:inline-flex;padding:1px 8px;border-radius:5px;background:var(--gray-soft);color:var(--text-2);font-size:12px;line-height:20px}
/* 登录 */
.login-shell{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
.login-card{width:360px;max-width:100%;padding:26px 26px 18px;position:relative;margin:0}
.login-brand{font-size:26px;font-weight:700;letter-spacing:.4px;margin-bottom:18px}
.login-theme{position:absolute;top:14px;right:14px}
.login-card label{margin-bottom:12px}
.login-card label>input{width:100%}
/* toast */
#toasts{position:fixed;right:18px;bottom:18px;z-index:120;display:flex;flex-direction:column;gap:8px}
.toast{background:var(--pop-bg);border:1px solid var(--border);border-left:3px solid var(--green);color:var(--text);border-radius:8px;box-shadow:var(--pop-shadow);padding:9px 14px;font-size:13px;max-width:360px}
.toast.error{border-left-color:var(--red)}
@media (max-width:1100px){.stat-grid{grid-template-columns:repeat(2,1fr)}.mid-grid{grid-template-columns:1fr}.info-bar{flex-direction:column;gap:14px}.info-bar .seg{padding:0}.info-bar .seg+.seg{border-left:none}.stats{grid-template-columns:repeat(2,1fr)}.stats.six{grid-template-columns:repeat(3,1fr)}}
@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.content{padding:8px}}
+8 -6
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
REPO="${NG_REPO:-aaaamoooo/NexusGate}" REPO="${NG_REPO:-rogue/NexusGate}"
BRANCH="${NG_BRANCH:-main}" BRANCH="${NG_BRANCH:-main}"
# 自建 Gitea 分发基址(raw / release / archive 均由此拼装)
GIT_BASE="https://git.881218.xyz/${REPO}"
# Agent 实现:默认 go(静态二进制,节点机不再需要 Node.js)。 # Agent 实现:默认 go(静态二进制,节点机不再需要 Node.js)。
# NG_AGENT_IMPL=node 可回退到旧版 Node agent(agent/agent.js + nodejs 包)。 # NG_AGENT_IMPL=node 可回退到旧版 Node agent(agent/agent.js + nodejs 包)。
AGENT_IMPL="${NG_AGENT_IMPL:-go}" AGENT_IMPL="${NG_AGENT_IMPL:-go}"
# Go agent 二进制所在的 GitHub Release tag # Go agent 二进制所在的 Gitea Release tag
AGENT_RELEASE="${NG_AGENT_RELEASE:-agent-v0.6.8}" AGENT_RELEASE="${NG_AGENT_RELEASE:-agent-v0.6.8}"
CONTROLLER="" CONTROLLER=""
TOKEN="" TOKEN=""
@@ -14,14 +16,14 @@ TOKEN=""
die() { printf '错误:%s\n' "$*" >&2; exit 1; } die() { printf '错误:%s\n' "$*" >&2; exit 1; }
info() { printf '\033[1;36m[NexusGate Agent]\033[0m %s\n' "$*"; } info() { printf '\033[1;36m[NexusGate Agent]\033[0m %s\n' "$*"; }
# NG_LOCAL_SRC 指向本地源码目录时跳过 GitHub 下载(本地改动尚未 push 时用这个) # NG_LOCAL_SRC 指向本地源码目录时跳过远程下载(本地改动尚未 push 时用这个)
fetch() { fetch() {
if [[ -n "${NG_LOCAL_SRC:-}" ]]; then if [[ -n "${NG_LOCAL_SRC:-}" ]]; then
[[ -f "${NG_LOCAL_SRC}/$1" ]] || die "NG_LOCAL_SRC 目录缺少 $1" [[ -f "${NG_LOCAL_SRC}/$1" ]] || die "NG_LOCAL_SRC 目录缺少 $1"
cp -a "${NG_LOCAL_SRC}/$1" "$2" cp -a "${NG_LOCAL_SRC}/$1" "$2"
else else
# 加时间戳破坏 GitHub raw 缓存,避免拉到旧版 # 加时间戳破坏 raw 缓存,避免拉到旧版
curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/$1?t=$(date +%s)" -o "$2" curl -fL --retry 3 "${GIT_BASE}/raw/branch/${BRANCH}/$1?t=$(date +%s)" -o "$2"
fi fi
} }
@@ -50,7 +52,7 @@ fetch_agent_binary() {
if [[ -n "${NG_LOCAL_SRC:-}" && -f "${NG_LOCAL_SRC}/agent-go/nexusgate-agent-linux-${arch}" ]]; then if [[ -n "${NG_LOCAL_SRC:-}" && -f "${NG_LOCAL_SRC}/agent-go/nexusgate-agent-linux-${arch}" ]]; then
cp -a "${NG_LOCAL_SRC}/agent-go/nexusgate-agent-linux-${arch}" "$out" cp -a "${NG_LOCAL_SRC}/agent-go/nexusgate-agent-linux-${arch}" "$out"
else else
curl -fL --retry 3 "https://github.com/${REPO}/releases/download/${AGENT_RELEASE}/nexusgate-agent-linux-${arch}" -o "$out" curl -fL --retry 3 "${GIT_BASE}/releases/download/${AGENT_RELEASE}/nexusgate-agent-linux-${arch}" -o "$out"
fi fi
[[ -s "$out" ]] || die 'Agent 二进制下载不完整' [[ -s "$out" ]] || die 'Agent 二进制下载不完整'
} }
+5 -5
View File
@@ -14,7 +14,7 @@ if [[ -x "$target" && -x "$stats_target" && "${2:-}" != '--force' ]]; then
exit 0 exit 0
fi fi
# 优先从 GitHub Release 下载预编译二进制,免去现场编译 # 优先从 Gitea Release 下载预编译二进制,免去现场编译
# Release tag: sing-box-v1.13.21,需上传 nexusgate-sing-box-linux-<arch> 和 nexusgate-sing-box-stats-linux-<arch> # Release tag: sing-box-v1.13.21,需上传 nexusgate-sing-box-linux-<arch> 和 nexusgate-sing-box-stats-linux-<arch>
try_prebuilt() { try_prebuilt() {
local arch dl_arch release_tag base_url tmp_bin tmp_stats local arch dl_arch release_tag base_url tmp_bin tmp_stats
@@ -24,11 +24,11 @@ try_prebuilt() {
*) return 1 ;; *) return 1 ;;
esac esac
release_tag="${NG_SINGBOX_RELEASE:-sing-box-v1.13.21}" release_tag="${NG_SINGBOX_RELEASE:-sing-box-v1.13.21}"
base_url="https://github.com/aaaamoooo/NexusGate/releases/download/${release_tag}" base_url="https://git.881218.xyz/${NG_REPO:-rogue/NexusGate}/releases/download/${release_tag}"
tmp_bin="$(mktemp /tmp/nexusgate-singbox-bin.XXXXXX)" tmp_bin="$(mktemp /tmp/nexusgate-singbox-bin.XXXXXX)"
tmp_stats="$(mktemp /tmp/nexusgate-singbox-stats.XXXXXX)" tmp_stats="$(mktemp /tmp/nexusgate-singbox-stats.XXXXXX)"
trap 'rm -f -- "$tmp_bin" "$tmp_stats"' RETURN trap 'rm -f -- "$tmp_bin" "$tmp_stats"' RETURN
printf '尝试从 GitHub Release 下载预编译 sing-box...\n' printf '尝试从 Gitea Release 下载预编译 sing-box...\n'
if curl -fL --retry 2 --connect-timeout 15 -o "$tmp_bin" "${base_url}/nexusgate-sing-box-linux-${dl_arch}" 2>/dev/null && \ if curl -fL --retry 2 --connect-timeout 15 -o "$tmp_bin" "${base_url}/nexusgate-sing-box-linux-${dl_arch}" 2>/dev/null && \
curl -fL --retry 2 --connect-timeout 15 -o "$tmp_stats" "${base_url}/nexusgate-sing-box-stats-linux-${dl_arch}" 2>/dev/null; then curl -fL --retry 2 --connect-timeout 15 -o "$tmp_stats" "${base_url}/nexusgate-sing-box-stats-linux-${dl_arch}" 2>/dev/null; then
chmod +x "$tmp_bin" "$tmp_stats" chmod +x "$tmp_bin" "$tmp_stats"
@@ -80,12 +80,12 @@ printf '构建 sing-box %s(启用 V2Ray 统计 API,首次需下载 Go 依赖
GOBIN="$build_dir" GOTOOLCHAIN=auto GOMAXPROCS=1 go install -trimpath -ldflags='-s -w' -p 1 -tags with_v2ray_api "github.com/sagernet/sing-box/cmd/sing-box@${version}" GOBIN="$build_dir" GOTOOLCHAIN=auto GOMAXPROCS=1 go install -trimpath -ldflags='-s -w' -p 1 -tags with_v2ray_api "github.com/sagernet/sing-box/cmd/sing-box@${version}"
[[ -s "$build_dir/sing-box" ]] || { printf 'sing-box 构建失败\n' >&2; exit 1; } [[ -s "$build_dir/sing-box" ]] || { printf 'sing-box 构建失败\n' >&2; exit 1; }
repo="${NG_REPO:-a2899882/NexusGate}" repo="${NG_REPO:-rogue/NexusGate}"
branch="${NG_BRANCH:-main}" branch="${NG_BRANCH:-main}"
if [[ -n "${NG_STATS_SOURCE:-}" ]]; then if [[ -n "${NG_STATS_SOURCE:-}" ]]; then
install -m 0600 "$NG_STATS_SOURCE" "$stats_source_dir/stats-query.go" install -m 0600 "$NG_STATS_SOURCE" "$stats_source_dir/stats-query.go"
else else
curl -fL --retry 3 "https://raw.githubusercontent.com/${repo}/${branch}/agent/stats-query.go" -o "$stats_source_dir/stats-query.go" curl -fL --retry 3 "https://git.881218.xyz/${repo}/raw/branch/${branch}/agent/stats-query.go" -o "$stats_source_dir/stats-query.go"
fi fi
( cd "$stats_source_dir" ( cd "$stats_source_dir"
GOTOOLCHAIN=auto go mod init nexusgate/statsquery GOTOOLCHAIN=auto go mod init nexusgate/statsquery
+6 -4
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
REPO="${NG_REPO:-aaaamoooo/NexusGate}" REPO="${NG_REPO:-rogue/NexusGate}"
BRANCH="${NG_BRANCH:-main}" BRANCH="${NG_BRANCH:-main}"
# Go agent 二进制所在的 GitHub Release tag(与 agent-install.sh 保持一致) # 自建 Gitea 分发基址(raw / release 均由此拼装)
GIT_BASE="https://git.881218.xyz/${REPO}"
# Go agent 二进制所在的 Gitea Release tag(与 agent-install.sh 保持一致)
AGENT_RELEASE="${NG_AGENT_RELEASE:-agent-v0.6.8}" AGENT_RELEASE="${NG_AGENT_RELEASE:-agent-v0.6.8}"
die() { printf '错误:%s\n' "$*" >&2; exit 1; } die() { printf '错误:%s\n' "$*" >&2; exit 1; }
info() { printf '\033[1;36m[NexusGate Agent]\033[0m %s\n' "$*"; } info() { printf '\033[1;36m[NexusGate Agent]\033[0m %s\n' "$*"; }
@@ -27,7 +29,7 @@ TARGET_IMPL="go"
info "当前 Agent 实现:${CURRENT_IMPL};更新目标:${TARGET_IMPL}" info "当前 Agent 实现:${CURRENT_IMPL};更新目标:${TARGET_IMPL}"
raw_fetch() { raw_fetch() {
curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/$1" -o "$2" curl -fL --retry 3 "${GIT_BASE}/raw/branch/${BRANCH}/$1" -o "$2"
} }
agent_arch() { agent_arch() {
@@ -45,7 +47,7 @@ fetch_agent_binary() {
if [[ -n "${NG_LOCAL_SRC:-}" && -f "${NG_LOCAL_SRC}/agent-go/nexusgate-agent-linux-${arch}" ]]; then if [[ -n "${NG_LOCAL_SRC:-}" && -f "${NG_LOCAL_SRC}/agent-go/nexusgate-agent-linux-${arch}" ]]; then
cp -a "${NG_LOCAL_SRC}/agent-go/nexusgate-agent-linux-${arch}" "$out" cp -a "${NG_LOCAL_SRC}/agent-go/nexusgate-agent-linux-${arch}" "$out"
else else
curl -fL --retry 3 "https://github.com/${REPO}/releases/download/${AGENT_RELEASE}/nexusgate-agent-linux-${arch}" -o "$out" curl -fL --retry 3 "${GIT_BASE}/releases/download/${AGENT_RELEASE}/nexusgate-agent-linux-${arch}" -o "$out"
fi fi
[[ -s "$out" ]] || die 'Agent 二进制下载不完整' [[ -s "$out" ]] || die 'Agent 二进制下载不完整'
} }
+6 -4
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
REPO="${NG_REPO:-a2899882/NexusGate}" REPO="${NG_REPO:-rogue/NexusGate}"
BRANCH="${NG_BRANCH:-main}" BRANCH="${NG_BRANCH:-main}"
# 自建 Gitea 分发基址(源码归档由此拼装)
GIT_BASE="https://git.881218.xyz/${REPO}"
DOMAIN="" DOMAIN=""
EMAIL="" EMAIL=""
@@ -61,7 +63,7 @@ fi
node_major="$(node -p 'process.versions.node.split(`.`)[0]')" node_major="$(node -p 'process.versions.node.split(`.`)[0]')"
[[ "$node_major" -ge 18 ]] || die "需要 Node.js 18 或更高版本" [[ "$node_major" -ge 18 ]] || die "需要 Node.js 18 或更高版本"
# NG_LOCAL_SRC 指向本地源码目录时跳过 GitHub 下载(本地改动尚未 push 时的全新安装用这个) # NG_LOCAL_SRC 指向本地源码目录时跳过远程下载(本地改动尚未 push 时的全新安装用这个)
src_dir="" src_dir=""
if [[ -n "${NG_LOCAL_SRC:-}" ]]; then if [[ -n "${NG_LOCAL_SRC:-}" ]]; then
[[ -f "${NG_LOCAL_SRC}/server.js" ]] || die "NG_LOCAL_SRC 目录不完整(缺少 server.js)" [[ -f "${NG_LOCAL_SRC}/server.js" ]] || die "NG_LOCAL_SRC 目录不完整(缺少 server.js)"
@@ -71,10 +73,10 @@ else
tmp_dir="$(mktemp -d /tmp/nexusgate-install.XXXXXX)" tmp_dir="$(mktemp -d /tmp/nexusgate-install.XXXXXX)"
trap 'rm -rf -- "$tmp_dir"' EXIT trap 'rm -rf -- "$tmp_dir"' EXIT
info "下载 ${REPO}@${BRANCH}" info "下载 ${REPO}@${BRANCH}"
curl -fL --retry 3 "https://github.com/${REPO}/archive/refs/heads/${BRANCH}.tar.gz" -o "$tmp_dir/source.tgz" curl -fL --retry 3 "${GIT_BASE}/archive/${BRANCH}.tar.gz" -o "$tmp_dir/source.tgz"
mkdir -p "$tmp_dir/source" mkdir -p "$tmp_dir/source"
tar -xzf "$tmp_dir/source.tgz" -C "$tmp_dir/source" tar -xzf "$tmp_dir/source.tgz" -C "$tmp_dir/source"
# GitHub tarball 解包后只有一层顶层目录;不用 --strip-components(BusyBox tar 不支持) # 源码包解包后只有一层顶层目录;不用 --strip-components(BusyBox tar 不支持)
src_dir="$(echo "$tmp_dir/source"/*/)" src_dir="$(echo "$tmp_dir/source"/*/)"
[[ -f "${src_dir}server.js" ]] || die "安装包不完整" [[ -f "${src_dir}server.js" ]] || die "安装包不完整"
fi fi
+4 -2
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
REPO="${NG_REPO:-a2899882/NexusGate}" REPO="${NG_REPO:-rogue/NexusGate}"
BRANCH="${NG_BRANCH:-main}" BRANCH="${NG_BRANCH:-main}"
# 自建 Gitea 分发基址(源码归档由此拼装)
GIT_BASE="https://git.881218.xyz/${REPO}"
die() { printf '错误:%s\n' "$*" >&2; exit 1; } die() { printf '错误:%s\n' "$*" >&2; exit 1; }
info() { printf '\033[1;36m[NexusGate]\033[0m %s\n' "$*"; } info() { printf '\033[1;36m[NexusGate]\033[0m %s\n' "$*"; }
@@ -66,7 +68,7 @@ update_panel() {
backup "$current_backup" backup "$current_backup"
stage="$(mktemp -d /tmp/nexusgate-update.XXXXXX)" stage="$(mktemp -d /tmp/nexusgate-update.XXXXXX)"
trap 'rm -rf -- "$stage"' RETURN trap 'rm -rf -- "$stage"' RETURN
curl -fL --retry 3 "https://github.com/${REPO}/archive/refs/heads/${BRANCH}.tar.gz" -o "$stage/source.tgz" curl -fL --retry 3 "${GIT_BASE}/archive/${BRANCH}.tar.gz" -o "$stage/source.tgz"
mkdir -p "$stage/source" && tar -xzf "$stage/source.tgz" -C "$stage/source" --strip-components=1 mkdir -p "$stage/source" && tar -xzf "$stage/source.tgz" -C "$stage/source" --strip-components=1
[[ -f "$stage/source/server.js" ]] || die "更新包不完整" [[ -f "$stage/source/server.js" ]] || die "更新包不完整"
panel_stop panel_stop
+215 -7
View File
@@ -7,11 +7,15 @@ const net = require('node:net');
const { Store } = require('./lib/store'); const { Store } = require('./lib/store');
const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth'); const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth');
const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http'); const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http');
const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, dropRealityKey } = require('./lib/protocols'); const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, dropRealityKey, nodeEmail } = require('./lib/protocols');
const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator'); const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources, alignCustomerNodes } = require('./lib/orchestrator');
const { formatSubscription, nodeClientUri } = require('./lib/subscriptions'); const { formatSubscription, nodeClientUri } = require('./lib/subscriptions');
const { redactSecrets } = require('./lib/redact'); const { redactSecrets } = require('./lib/redact');
const { clientIdentity, activeClients, observedIps, pruneAccess } = require('./lib/access'); const { clientIdentity, activeClients, observedIps, pruneAccess } = require('./lib/access');
const { parseResetDay, armTrafficReset, applyMonthlyTrafficResets } = require('./lib/reset');
const { customerOnline, customerSpeedBps, nodeSpeedBps, nodeOnlineCustomerIds } = require('./lib/presence');
const { SystemMonitor } = require('./lib/sysstat');
const { fetchExternalSource, dueSources, applyFetchResult, normalizeExternalSources, normalizeManualLinks } = require('./lib/extsub');
const APP_ROOT = __dirname; const APP_ROOT = __dirname;
const PUBLIC_DIR = path.join(APP_ROOT, 'public'); const PUBLIC_DIR = path.join(APP_ROOT, 'public');
@@ -24,6 +28,7 @@ const VERSION = '0.6.8';
const store = new Store(DATA_FILE); const store = new Store(DATA_FILE);
let sessions; let sessions;
let orchestrator; let orchestrator;
let systemMonitor = null;
const loginAttempts = new Map(); const loginAttempts = new Map();
function cleanText(value, max = 120) { function cleanText(value, max = 120) {
@@ -70,6 +75,7 @@ function normalizeServer(body, current = {}) {
publicAddress: 'publicAddress' in body ? requiredText(body.publicAddress, '公网地址', 255) : current.publicAddress, publicAddress: 'publicAddress' in body ? requiredText(body.publicAddress, '公网地址', 255) : current.publicAddress,
publicAddressV6: 'publicAddressV6' in body ? cleanText(body.publicAddressV6, 255) : (current.publicAddressV6 || ''), publicAddressV6: 'publicAddressV6' in body ? cleanText(body.publicAddressV6, 255) : (current.publicAddressV6 || ''),
tlsDomain: 'tlsDomain' in body ? cleanText(body.tlsDomain, 253).toLowerCase() : (current.tlsDomain || ''), tlsDomain: 'tlsDomain' in body ? cleanText(body.tlsDomain, 253).toLowerCase() : (current.tlsDomain || ''),
enabled: 'enabled' in body ? body.enabled !== false : (current.enabled !== undefined ? current.enabled : true),
portRangeStart: 'portRangeStart' in body ? Number(body.portRangeStart) : Number(current.portRangeStart || 20000), portRangeStart: 'portRangeStart' in body ? Number(body.portRangeStart) : Number(current.portRangeStart || 20000),
portRangeEnd: 'portRangeEnd' in body ? Number(body.portRangeEnd) : Number(current.portRangeEnd || 50000), portRangeEnd: 'portRangeEnd' in body ? Number(body.portRangeEnd) : Number(current.portRangeEnd || 50000),
labels: 'labels' in body ? asIds(body.labels).slice(0, 20) : (current.labels || []) labels: 'labels' in body ? asIds(body.labels).slice(0, 20) : (current.labels || [])
@@ -134,14 +140,47 @@ function publicNode(item) {
function publicServer(item) { function publicServer(item) {
return { ...item, ...(item.engine ? { engine: { ...item.engine, detail: redactSecrets(item.engine.detail) } } : {}), return { ...item, ...(item.engine ? { engine: { ...item.engine, detail: redactSecrets(item.engine.detail) } } : {}),
enabled: item.enabled !== false,
agentVersion: store.data.agents.find((agent) => agent.serverId === item.id && agent.status === 'active')?.version || null, agentVersion: store.data.agents.find((agent) => agent.serverId === item.id && agent.status === 'active')?.version || null,
pendingCleanup: store.data.nodes.filter((entry) => (entry.serverId === item.id || entry.exitServerId === item.id) && entry.status === 'removing').length }; pendingCleanup: store.data.nodes.filter((entry) => (entry.serverId === item.id || entry.exitServerId === item.id) && entry.status === 'removing').length };
} }
function publicCustomer(item) { function publicCustomer(item) {
return { ...item, const now = Date.now();
const speed = customerSpeedBps(item, now);
const out = { ...item,
observedIpCount: observedIps(store.data, item.id).length, observedIpCount: observedIps(store.data, item.id).length,
subscriptionClientCount: activeClients(store.data, item.id).length }; subscriptionClientCount: activeClients(store.data, item.id).length,
online: customerOnline(item, now),
lastActiveAt: item.lastOnlineAt || null,
speedUpBps: speed.up, speedDownBps: speed.down };
if ((item.externalSources || []).length) {
out.externalStatus = {};
for (const source of item.externalSources) {
const cache = (store.data.subSourceCache || {})[source.id];
out.externalStatus[source.id] = cache
? { fetchedAt: cache.fetchedAt || null, okAt: cache.okAt || null, failCount: cache.failCount || 0,
lastError: cache.lastError || null, droppedCount: cache.droppedCount || 0, count: (cache.items || []).length }
: { fetchedAt: null, okAt: null, failCount: 0, lastError: null, droppedCount: 0, count: 0 };
}
}
return out;
}
// One customer's credential rows (凭据 tab): the subscription token plus
// one row per node binding, holding that binding's own credentials.
function credentialItems(data, customer) {
return data.nodeClients.filter((nc) => nc.customerId === customer.id).map((nc) => {
const node = data.nodes.find((item) => item.id === nc.nodeId);
return {
nodeClientId: nc.id, nodeId: nc.nodeId, nodeName: node ? node.name : nc.nodeId,
protocol: node ? node.protocol : '', status: nc.status,
clientId: nc.credentials ? nc.credentials.clientId || '' : '',
password: nc.credentials ? nc.credentials.relayPassword || '' : '',
shortId: nc.credentials ? nc.credentials.shortId || '' : '',
username: node && node.protocol === 'socks5-auth' ? nodeEmail(customer.id) : '',
};
});
} }
function customerBlockReason(data, customer) { function customerBlockReason(data, customer) {
@@ -398,6 +437,10 @@ async function handleAgent(req, res, pathname) {
const existing = data.observations.find((item) => item.customerId === customerId && item.ip === ip); const existing = data.observations.find((item) => item.customerId === customerId && item.ip === ip);
if (existing) existing.lastSeenAt = nowIso(); if (existing) existing.lastSeenAt = nowIso();
else data.observations.push({ id: id('obs'), customerId, ip, firstSeenAt: nowIso(), lastSeenAt: nowIso() }); else data.observations.push({ id: id('obs'), customerId, ip, firstSeenAt: nowIso(), lastSeenAt: nowIso() });
// An accepted observation is a live connection on this server:
// it is the customer's freshest presence signal.
const observedCustomer = data.customers.find((item) => item.id === customerId);
if (observedCustomer) observedCustomer.lastOnlineAt = nowIso();
affected.add(customerId); affected.add(customerId);
} }
for (const customerId of affected) { for (const customerId of affected) {
@@ -418,6 +461,33 @@ async function handleAgent(req, res, pathname) {
return false; return false;
} }
// Fetch one external source now and persist the outcome. Used by the
// manual refresh endpoint (awaited) and by save/housekeeping triggers.
async function refreshOneSource(customerId, sourceId) {
const customer = store.data.customers.find((item) => item.id === customerId);
const source = customer && (customer.externalSources || []).find((item) => item.id === sourceId);
if (!source) return null;
const result = await fetchExternalSource(source, store.data.settings);
await store.transaction((data) => { applyFetchResult(data, sourceId, result, nowIso()); });
return result;
}
function refreshSourcesInBackground(customerId, sourceIds) {
for (const sourceId of sourceIds || []) {
refreshOneSource(customerId, sourceId).catch((error) => console.error('External subscription refresh failed:', error));
}
}
async function refreshDueSources() {
try {
for (const { customerId, source } of dueSources(store.data, Date.now(), 3)) {
await refreshOneSource(customerId, source.id);
}
} catch (error) {
console.error('External subscription refresh failed:', error);
}
}
async function handleAdminApi(req, res, pathname) { async function handleAdminApi(req, res, pathname) {
if (!pathname.startsWith('/api/')) return false; if (!pathname.startsWith('/api/')) return false;
const changing = !['GET', 'HEAD'].includes(req.method); const changing = !['GET', 'HEAD'].includes(req.method);
@@ -440,6 +510,11 @@ async function handleAdminApi(req, res, pathname) {
}); });
return true; return true;
} }
if (req.method === 'GET' && pathname === '/api/system') {
if (!systemMonitor) systemMonitor = new SystemMonitor(path.dirname(DATA_FILE));
sendJson(res, 200, systemMonitor.status(store.data, VERSION));
return true;
}
if (req.method === 'GET' && pathname === '/api/protocols') { if (req.method === 'GET' && pathname === '/api/protocols') {
sendJson(res, 200, { profiles: PROFILE_CATALOG, realityPresets: REALITY_PRESETS }); sendJson(res, 200, { profiles: PROFILE_CATALOG, realityPresets: REALITY_PRESETS });
return true; return true;
@@ -602,7 +677,7 @@ async function handleAdminApi(req, res, pathname) {
} }
if (req.method === 'POST' && pathname === '/api/customers') { if (req.method === 'POST' && pathname === '/api/customers') {
const body = await readJson(req); const body = await readJson(req);
const customer = await store.transaction((data) => { const customer = await store.transaction(async (data) => {
const next = { const next = {
id: id('cus'), subscriptionToken: randomToken(32), name: requiredText(body.name, '客户名称'), group: cleanText(body.group, 80), id: id('cus'), subscriptionToken: randomToken(32), name: requiredText(body.name, '客户名称'), group: cleanText(body.group, 80),
status: 'active', trafficLimitBytes: nonNegativeNumber(body.trafficLimitBytes, '流量上限'), usedBytes: 0, usedUplinkBytes: 0, usedDownlinkBytes: 0, status: 'active', trafficLimitBytes: nonNegativeNumber(body.trafficLimitBytes, '流量上限'), usedBytes: 0, usedUplinkBytes: 0, usedDownlinkBytes: 0,
@@ -611,16 +686,25 @@ async function handleAdminApi(req, res, pathname) {
tags: asIds(body.tags).slice(0, 20), notes: cleanText(body.notes, 1000), tags: asIds(body.tags).slice(0, 20), notes: cleanText(body.notes, 1000),
createdAt: nowIso(), updatedAt: nowIso() createdAt: nowIso(), updatedAt: nowIso()
}; };
const resetDay = parseResetDay(body.trafficResetDay);
if (resetDay > 0) { next.trafficResetDay = resetDay; armTrafficReset(next); }
const sources = normalizeExternalSources(body.externalSources, id);
if (sources && sources.length) next.externalSources = sources;
const links = normalizeManualLinks(body.manualLinks, id);
if (links && links.length) next.manualLinks = links;
data.customers.push(next); data.customers.push(next);
if (Array.isArray(body.nodeIds)) await alignCustomerNodes(data, next, body.nodeIds, actor);
audit(data, actor, 'create_customer', next.id, { name: next.name }); audit(data, actor, 'create_customer', next.id, { name: next.name });
return structuredClone(next); return structuredClone(next);
}); });
refreshSourcesInBackground(customer.id, (customer.externalSources || []).map((source) => source.id));
sendJson(res, 201, { customer }); sendJson(res, 201, { customer });
return true; return true;
} }
const customerItem = route('/api/customers/:id', pathname); const customerItem = route('/api/customers/:id', pathname);
if (customerItem && req.method === 'PATCH') { if (customerItem && req.method === 'PATCH') {
const body = await readJson(req); const body = await readJson(req);
let changedSourceIds = [];
const customer = await store.transaction(async (data) => { const customer = await store.transaction(async (data) => {
const item = data.customers.find((entry) => entry.id === customerItem.id); const item = data.customers.find((entry) => entry.id === customerItem.id);
if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 }); if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
@@ -634,6 +718,32 @@ async function handleAdminApi(req, res, pathname) {
if ('deviceLimit' in body) item.deviceLimit = nonNegativeNumber(body.deviceLimit, '设备上限', true); if ('deviceLimit' in body) item.deviceLimit = nonNegativeNumber(body.deviceLimit, '设备上限', true);
if ('expiresAt' in body) item.expiresAt = optionalIso(body.expiresAt, '到期日期'); if ('expiresAt' in body) item.expiresAt = optionalIso(body.expiresAt, '到期日期');
if ('tags' in body) item.tags = asIds(body.tags).slice(0, 20); if ('tags' in body) item.tags = asIds(body.tags).slice(0, 20);
if ('trafficResetDay' in body) {
const resetDay = parseResetDay(body.trafficResetDay);
if (resetDay !== (item.trafficResetDay || 0)) {
// A changed schedule is re-armed rather than fired (mirrors
// panel-go); an unchanged echo leaves the marker alone.
if (resetDay > 0) item.trafficResetDay = resetDay; else delete item.trafficResetDay;
armTrafficReset(item);
}
}
if ('externalSources' in body) {
const previous = new Map((item.externalSources || []).map((source) => [source.id, source]));
const sources = normalizeExternalSources(body.externalSources, id) || [];
if (sources.length) item.externalSources = sources; else delete item.externalSources;
const kept = new Set(sources.map((source) => source.id));
if (data.subSourceCache) {
for (const oldId of previous.keys()) if (!kept.has(oldId)) delete data.subSourceCache[oldId];
}
changedSourceIds = sources.filter((source) => {
const old = previous.get(source.id);
return !old || old.url !== source.url || (old.userAgent || '') !== (source.userAgent || '');
}).map((source) => source.id);
}
if ('manualLinks' in body) {
const links = normalizeManualLinks(body.manualLinks, id) || [];
if (links.length) item.manualLinks = links; else delete item.manualLinks;
}
const reason = customerBlockReason(data, item); const reason = customerBlockReason(data, item);
if (body.status === 'active' && reason) { if (body.status === 'active' && reason) {
const error = new Error(`客户仍受${{ expired:'到期时间', traffic_limit:'流量额度', ip_limit:'节点 IP 上限' }[reason]}限制,请先修改限制再启用`); const error = new Error(`客户仍受${{ expired:'到期时间', traffic_limit:'流量额度', ip_limit:'节点 IP 上限' }[reason]}限制,请先修改限制再启用`);
@@ -646,10 +756,12 @@ async function handleAdminApi(req, res, pathname) {
} }
if (wasActive && item.status === 'suspended') suspendCustomerResources(data, item, actor); if (wasActive && item.status === 'suspended') suspendCustomerResources(data, item, actor);
if (!wasActive && item.status === 'active') await resumeCustomerResources(data, item, actor); if (!wasActive && item.status === 'active') await resumeCustomerResources(data, item, actor);
if (Array.isArray(body.nodeIds)) await alignCustomerNodes(data, item, body.nodeIds, actor);
item.updatedAt = nowIso(); item.updatedAt = nowIso();
audit(data, actor, 'update_customer', item.id); audit(data, actor, 'update_customer', item.id);
return structuredClone(item); return structuredClone(item);
}); });
refreshSourcesInBackground(customer.id, changedSourceIds);
sendJson(res, 200, { customer }); sendJson(res, 200, { customer });
return true; return true;
} }
@@ -686,24 +798,98 @@ async function handleAdminApi(req, res, pathname) {
data.nodes.some((node) => node.id === item.nodeId && !node.pendingDelete))) { data.nodes.some((node) => node.id === item.nodeId && !node.pendingDelete))) {
throw Object.assign(new Error('客户仍有节点分配。请先移除全部分配'), { statusCode: 409 }); throw Object.assign(new Error('客户仍有节点分配。请先移除全部分配'), { statusCode: 409 });
} }
const removed = data.customers[index];
data.customers.splice(index, 1); data.customers.splice(index, 1);
data.subscriptionAccess = data.subscriptionAccess.filter((item) => item.customerId !== customerItem.id); data.subscriptionAccess = data.subscriptionAccess.filter((item) => item.customerId !== customerItem.id);
data.subscriptionClients = data.subscriptionClients.filter((item) => item.customerId !== customerItem.id); data.subscriptionClients = data.subscriptionClients.filter((item) => item.customerId !== customerItem.id);
data.observations = data.observations.filter((item) => item.customerId !== customerItem.id); data.observations = data.observations.filter((item) => item.customerId !== customerItem.id);
if (data.subSourceCache) {
for (const source of removed.externalSources || []) delete data.subSourceCache[source.id];
}
audit(data, actor, 'delete_customer', customerItem.id); audit(data, actor, 'delete_customer', customerItem.id);
}); });
sendJson(res, 200, { ok: true }); sendJson(res, 200, { ok: true });
return true; return true;
} }
const credentialsRoute = route('/api/customers/:id/credentials', pathname);
if (credentialsRoute && req.method === 'GET') {
const customer = store.data.customers.find((item) => item.id === credentialsRoute.id);
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
sendJson(res, 200, { customerId: customer.id, subscriptionToken: customer.subscriptionToken,
items: credentialItems(store.data, customer) });
return true;
}
const regenerateRoute = route('/api/customers/:id/credentials/:ncid/regenerate', pathname);
if (regenerateRoute && req.method === 'POST') {
const item = await store.transaction(async (data) => {
const customer = data.customers.find((entry) => entry.id === regenerateRoute.id);
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
const nc = data.nodeClients.find((entry) => entry.id === regenerateRoute.ncid && entry.customerId === customer.id);
if (!nc) throw Object.assign(new Error('凭据不存在'), { statusCode: 404 });
const node = data.nodes.find((entry) => entry.id === nc.nodeId);
if (!node) throw Object.assign(new Error('节点不存在'), { statusCode: 404 });
// Same generation rules as a fresh assignment; the node resource is
// rebuilt so the old credential stops working on the wire.
nc.credentials = newNodeClientCredentials(node.protocol);
nc.updatedAt = nowIso();
if (['active', 'deploying', 'failed', 'degraded'].includes(node.status)) await rebuildNode(data, node);
audit(data, actor, 'regenerate_credentials', customer.id, { nodeId: node.id });
return credentialItems(data, customer).find((entry) => entry.nodeClientId === nc.id);
});
sendJson(res, 200, { item });
return true;
}
const sourceRefresh = route('/api/customers/:id/external-sources/:sid/refresh', pathname);
if (sourceRefresh && req.method === 'POST') {
const customer = store.data.customers.find((item) => item.id === sourceRefresh.id);
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
if (!(customer.externalSources || []).some((item) => item.id === sourceRefresh.sid)) {
throw Object.assign(new Error('外部订阅不存在'), { statusCode: 404 });
}
const result = await refreshOneSource(customer.id, sourceRefresh.sid);
sendJson(res, 200, { ok: Boolean(result && result.ok), count: result ? result.items.length : 0,
dropped: result ? result.dropped : 0, error: result ? result.error : '刷新失败' });
return true;
}
if (req.method === 'GET' && pathname === '/api/nodes') { if (req.method === 'GET' && pathname === '/api/nodes') {
const visibleNodes = store.data.nodes.filter((item) => !item.pendingDelete); const now = Date.now();
const visibleNodes = store.data.nodes.filter((item) => !item.pendingDelete)
.sort((a, b) => (Number.isFinite(a.sortIndex) ? a.sortIndex : Number.MAX_SAFE_INTEGER) - (Number.isFinite(b.sortIndex) ? b.sortIndex : Number.MAX_SAFE_INTEGER));
const visibleIds = new Set(visibleNodes.map((item) => item.id)); const visibleIds = new Set(visibleNodes.map((item) => item.id));
const nodeClients = store.data.nodeClients.filter((nc) => visibleIds.has(nc.nodeId)).map((nc) => { const nodeClients = store.data.nodeClients.filter((nc) => visibleIds.has(nc.nodeId)).map((nc) => {
const { credentials, ...safe } = nc; const { credentials, ...safe } = nc;
return { ...safe, clientUri: nodeClientUri(store.data, nc) }; return { ...safe, clientUri: nodeClientUri(store.data, nc) };
}); });
sendJson(res, 200, { nodes: visibleNodes.map(publicNode), nodeClients }); const nodes = visibleNodes.map((node) => {
const onlineCustomerIds = nodeOnlineCustomerIds(store.data, node, now);
const speed = nodeSpeedBps(node, now);
return { ...publicNode(node),
clientCount: store.data.nodeClients.filter((nc) => nc.nodeId === node.id && nc.status === 'active').length,
onlineCount: onlineCustomerIds.length, onlineCustomerIds,
speedUpBps: speed.up, speedDownBps: speed.down };
});
sendJson(res, 200, { nodes, nodeClients });
return true;
}
if (req.method === 'POST' && pathname === '/api/nodes/reorder') {
const body = await readJson(req);
const ids = asIds(body.ids);
if (!ids.length) throw Object.assign(new Error('排序列表不能为空'), { statusCode: 400 });
await store.transaction((data) => {
const byId = new Map(data.nodes.map((node) => [node.id, node]));
for (const nodeId of ids) {
if (!byId.has(nodeId)) throw Object.assign(new Error('节点不存在'), { statusCode: 400 });
}
const listed = new Set(ids);
const rest = data.nodes.filter((node) => !listed.has(node.id))
.sort((a, b) => (Number.isFinite(a.sortIndex) ? a.sortIndex : Number.MAX_SAFE_INTEGER) - (Number.isFinite(b.sortIndex) ? b.sortIndex : Number.MAX_SAFE_INTEGER));
[...ids.map((nodeId) => byId.get(nodeId)), ...rest].forEach((node, index) => { node.sortIndex = index; });
audit(data, actor, 'reorder_nodes', 'nodes', { count: ids.length });
return null;
});
sendJson(res, 200, { ok: true });
return true; return true;
} }
if (req.method === 'POST' && pathname === '/api/nodes') { if (req.method === 'POST' && pathname === '/api/nodes') {
@@ -713,6 +899,7 @@ async function handleAdminApi(req, res, pathname) {
const next = { const next = {
id: id('nod'), status: 'draft', entryReady: false, exitReady: false, id: id('nod'), status: 'draft', entryReady: false, exitReady: false,
resourceId: id('res'), exitResourceId: id('res'), resourceId: id('res'), exitResourceId: id('res'),
sortIndex: data.nodes.reduce((max, node) => Math.max(max, Number.isFinite(node.sortIndex) ? node.sortIndex : -1), -1) + 1,
...normalized, ...normalized,
artifacts: {}, error: null, pendingDelete: false, artifacts: {}, error: null, pendingDelete: false,
usageCounters: null, retiredUsageEpochs: [], meteredTraffic: null, lastUsageAt: null, usageCounters: null, retiredUsageEpochs: [], meteredTraffic: null, lastUsageAt: null,
@@ -1066,6 +1253,16 @@ async function housekeeping() {
try { try {
await store.transaction(async (data) => { await store.transaction(async (data) => {
const now = Date.now(); const now = Date.now();
// Monthly traffic resets run before the quota evaluation below,
// mirroring panel-go housekeeping.
const resetDone = applyMonthlyTrafficResets(data, new Date(), (draft, customer, previousUsed, day, period) =>
audit(draft, 'system:monthly_reset', 'reset_customer_usage_monthly', customer.id, { previousUsedBytes: previousUsed, resetDay: day, period }));
for (const customer of resetDone) {
if (customer.status === 'suspended' && customer.suspendReason === 'traffic_limit' && !customerBlockReason(data, customer)) {
customer.status = 'active'; customer.suspendReason = null; customer.updatedAt = nowIso();
await resumeCustomerResources(data, customer, 'system:monthly_reset');
}
}
for (const server of data.servers) { for (const server of data.servers) {
if (server.lastSeenAt && now - new Date(server.lastSeenAt).getTime() > 180000) server.status = 'offline'; if (server.lastSeenAt && now - new Date(server.lastSeenAt).getTime() > 180000) server.status = 'offline';
} }
@@ -1103,6 +1300,9 @@ async function housekeeping() {
return null; return null;
}); });
sessions.prune(); sessions.prune();
if (!systemMonitor) systemMonitor = new SystemMonitor(path.dirname(DATA_FILE));
systemMonitor.sample(store.data);
await refreshDueSources();
} catch (error) { } catch (error) {
console.error('Housekeeping failed:', error); console.error('Housekeeping failed:', error);
} }
@@ -1126,6 +1326,14 @@ async function main() {
if (store.data.customers.some((item) => !item.subscriptionToken)) { if (store.data.customers.some((item) => !item.subscriptionToken)) {
await store.transaction((data) => { for (const customer of data.customers) if (!customer.subscriptionToken) customer.subscriptionToken = randomToken(32); }); await store.transaction((data) => { for (const customer of data.customers) if (!customer.subscriptionToken) customer.subscriptionToken = randomToken(32); });
} }
// Node custom order: legacy records carry no sortIndex. Renumber by the
// stored array order whenever the values are not exactly 0..n-1.
const sortValues = store.data.nodes.map((node) => node.sortIndex);
const sortBroken = !sortValues.every((value) => Number.isFinite(value)) ||
[...sortValues].sort((a, b) => a - b).some((value, index) => value !== index);
if (store.data.nodes.length && sortBroken) {
await store.transaction((data) => { data.nodes.forEach((node, index) => { node.sortIndex = index; }); });
}
if (!store.data.users.length) { if (!store.data.users.length) {
const password = process.env.NG_ADMIN_PASSWORD || randomToken(15); const password = process.env.NG_ADMIN_PASSWORD || randomToken(15);
await store.transaction((data) => { await store.transaction((data) => {
+256
View File
@@ -0,0 +1,256 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const net = require('node:net');
const http = require('node:http');
const { spawn } = require('node:child_process');
const { trafficResetPeriodKey, armTrafficReset, applyMonthlyTrafficResets, parseResetDay } = require('../lib/reset');
const { parseLinkList, validateLinkUri, fetchExternalSource } = require('../lib/extsub');
const { aggregateEntries } = require('../lib/subscriptions');
const { customerOnline, customerSpeedBps, nodeOnlineCustomerIds } = require('../lib/presence');
// ---------- monthly reset (Node port of panel-go/reset.go) ----------
test('reset period key clamps to month end and flips at the reset instant', () => {
// Day 31 in February fires on Feb 28.
assert.equal(trafficResetPeriodKey(new Date(2026, 1, 27, 23, 59), 31), '2026-01');
assert.equal(trafficResetPeriodKey(new Date(2026, 1, 28, 0, 0), 31), '2026-02');
assert.equal(trafficResetPeriodKey(new Date(2026, 0, 31, 0, 0), 31), '2026-01');
assert.equal(trafficResetPeriodKey(new Date(2026, 0, 30, 23, 59), 31), '2025-12');
assert.equal(trafficResetPeriodKey(new Date(2026, 4, 15, 0, 0), 15), '2026-05');
assert.equal(trafficResetPeriodKey(new Date(2026, 4, 14, 23, 59), 15), '2026-04');
});
test('monthly reset arms on first sight, fires once per period, catches up after downtime', () => {
const data = { customers: [{ id: 'c1', trafficResetDay: 15, usedBytes: 500, usedUplinkBytes: 200, usedDownlinkBytes: 300 }] };
const customer = data.customers[0];
// First sight only arms.
let done = applyMonthlyTrafficResets(data, new Date(2026, 4, 20));
assert.equal(done.length, 0);
assert.equal(customer.usedBytes, 500);
assert.equal(customer.trafficResetPeriod, '2026-05');
// Same period: nothing.
done = applyMonthlyTrafficResets(data, new Date(2026, 5, 14));
assert.equal(done.length, 0);
// Crossing the instant fires exactly once (even when the panel was down
// across two boundaries, only the current period applies).
done = applyMonthlyTrafficResets(data, new Date(2026, 6, 20));
assert.equal(done.length, 1);
assert.equal(customer.usedBytes, 0);
assert.equal(customer.trafficResetPeriod, '2026-07');
done = applyMonthlyTrafficResets(data, new Date(2026, 6, 21));
assert.equal(done.length, 0);
});
test('parseResetDay validates like the Go implementation', () => {
assert.equal(parseResetDay(undefined), 0);
assert.equal(parseResetDay(null), 0);
assert.equal(parseResetDay(0), 0);
assert.equal(parseResetDay(31), 31);
assert.throws(() => parseResetDay(32), /0–31/);
assert.throws(() => parseResetDay(-1), /非负整数/);
assert.throws(() => parseResetDay(1.5), /非负整数/);
const customer = { trafficResetDay: 10 };
armTrafficReset(customer, new Date(2026, 4, 20));
assert.equal(customer.trafficResetPeriod, '2026-05');
});
// ---------- external subscription parsing ----------
test('parseLinkList accepts base64 and plaintext, drops unsupported schemes', () => {
const plain = [
'vless://11111111-1111-1111-1111-111111111111@a.example.com:443?security=reality#Node%20A',
'ss://YWVzLTEyOC1nY206cGFzcw@b.example.com:8388#NodeB',
'vmess://eyJ2IjoiMiJ9',
'trojan://pw@c.example.com:443#T',
'not a link',
'hysteria2://pw@d.example.com:443?sni=d.example.com#NodeD',
].join('\n');
const fromPlain = parseLinkList(plain);
assert.equal(fromPlain.items.length, 3);
assert.equal(fromPlain.dropped, 3);
assert.equal(fromPlain.items[0].name, 'Node A');
const fromB64 = parseLinkList(Buffer.from(plain, 'utf8').toString('base64'));
assert.deepEqual(fromB64, fromPlain);
assert.equal(validateLinkUri('socks5://u:p@h:1080'), true);
assert.equal(validateLinkUri('http://example.com/sub'), false);
});
test('fetchExternalSource follows the SSRF guard and parses a local fixture when allowed', async (t) => {
const body = Buffer.from('vless://11111111-1111-1111-1111-111111111111@a.example.com:443#A\n', 'utf8').toString('base64');
const server = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end(body); });
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => server.close());
const url = `http://127.0.0.1:${server.address().port}/sub`;
const blocked = await fetchExternalSource({ url }, {});
assert.equal(blocked.ok, false);
assert.match(blocked.error, /非公网/);
const allowed = await fetchExternalSource({ url }, { allowPrivateSubSources: true });
assert.equal(allowed.ok, true);
assert.equal(allowed.items.length, 1);
assert.equal(allowed.items[0].name, 'A');
});
// ---------- subscription aggregation merge ----------
function aggData() {
return {
servers: [{ id: 's1', publicAddress: 'a.example.com', tlsDomain: '' }],
nodes: [
{ id: 'n2', name: '第二', serverId: 's1', protocol: 'socks5-auth', status: 'active', port: 1080, sortIndex: 1, networkMode: 'ipv4' },
{ id: 'n1', name: '第一', serverId: 's1', protocol: 'socks5-auth', status: 'active', port: 1081, sortIndex: 0, networkMode: 'ipv4' },
],
nodeClients: [
{ id: 'nc2', nodeId: 'n2', customerId: 'c1', status: 'active', credentials: { clientId: 'u2', relayPassword: 'p2', shortId: 'x' } },
{ id: 'nc1', nodeId: 'n1', customerId: 'c1', status: 'active', credentials: { clientId: 'u1', relayPassword: 'p1', shortId: 'x' } },
],
subSourceCache: {
src1: { fetchedAt: 'x', okAt: 'x', failCount: 0, lastError: null, droppedCount: 0, items: [
{ uri: 'vless://11111111-1111-1111-1111-111111111111@ext.example.com:443#ExtNode', name: 'ExtNode' },
{ uri: 'socks5://ng%3Ac1:p1@a.example.com:1081#dup', name: 'dup' },
] },
},
};
}
test('aggregateEntries orders own by sortIndex, then manual, then external, with dedup', () => {
const data = aggData();
const customer = {
id: 'c1',
manualLinks: [{ id: 'lnk1', uri: 'hysteria2://pw@m.example.com:443?sni=m.example.com#Manual', remark: '', enabled: true }],
externalSources: [{ id: 'src1', url: 'https://src.example.com/sub', remark: '机场', enabled: true }],
};
const items = aggregateEntries(data, customer);
const names = items.map((item) => decodeURIComponent(item.clientUri.slice(item.clientUri.lastIndexOf('#') + 1)));
// The external socks5 entry duplicates own node n1 (same host/port/credentials) and is dropped.
assert.deepEqual(names, ['第一', '第二', 'Manual', '[机场] ExtNode']);
});
test('aggregateEntries with no aggregation fields matches own entries only', () => {
const data = aggData();
const items = aggregateEntries(data, { id: 'c1' });
assert.equal(items.length, 2);
});
// ---------- presence ----------
test('presence window and speed freshness', () => {
const now = Date.parse('2026-10-21T00:00:00.000Z');
const recent = new Date(now - 60000).toISOString();
const stale = new Date(now - 600000).toISOString();
assert.equal(customerOnline({ lastOnlineAt: recent }, now), true);
assert.equal(customerOnline({ lastOnlineAt: stale }, now), false);
assert.equal(customerOnline({}, now), false);
const customer = { serverUsage: { 'srv:s1': { speedUpBps: 100, speedDownBps: 200, speedAt: recent, lastActiveAt: recent } } };
assert.deepEqual(customerSpeedBps(customer, now), { up: 100, down: 200 });
assert.deepEqual(customerSpeedBps({ serverUsage: { 'srv:s1': { speedUpBps: 100, speedDownBps: 200, speedAt: stale } } }, now), { up: 0, down: 0 });
const data = {
customers: [{ id: 'c1', lastOnlineAt: stale, serverUsage: { 'srv:s1': { lastActiveAt: recent } } }],
nodeClients: [{ nodeId: 'n1', customerId: 'c1', status: 'active' }],
observations: [],
};
assert.deepEqual(nodeOnlineCustomerIds(data, { id: 'n1', serverId: 's1' }, now), ['c1']);
assert.deepEqual(nodeOnlineCustomerIds(data, { id: 'n1', serverId: 's2' }, now), []);
});
// ---------- API integration: sort / reorder / credentials / alignment ----------
async function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); });
});
}
async function waitFor(url) {
for (let i = 0; i < 60; i += 1) {
try { const response = await fetch(url); if (response.ok) return; } catch { /* retry */ }
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error('test server did not start');
}
test('api: node sortIndex, reorder, credentials, customer node alignment, presence', async (t) => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nexusgate-ui-'));
const port = await freePort();
const child = spawn(process.execPath, ['server.js'], {
cwd: path.resolve(__dirname, '..'),
env: { ...process.env, NG_HOST: '127.0.0.1', NG_PORT: String(port), NG_DATA_FILE: path.join(dir, 'data.json'), NG_ADMIN_PASSWORD: 'test-password', NG_COOKIE_SECURE: 'false' },
stdio: ['ignore', 'pipe', 'pipe'],
});
t.after(async () => { child.kill('SIGTERM'); await fs.promises.rm(dir, { recursive: true, force: true }); });
const base = `http://127.0.0.1:${port}`;
await waitFor(`${base}/healthz`);
const login = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'test-password' }) });
const session = await login.json();
const cookie = login.headers.get('set-cookie').split(';')[0];
const request = async (pathname, method = 'GET', body) => {
const response = await fetch(`${base}${pathname}`, { method, headers: { cookie, 'x-csrf-token': session.csrf, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
const json = await response.json();
assert.ok(response.ok, `${method} ${pathname}: ${JSON.stringify(json)}`);
return json;
};
const server = (await request('/api/servers', 'POST', { name: 'S1', role: 'hybrid', publicAddress: 's1.example.com' })).server;
assert.equal(server.enabled, true);
const n1 = (await request('/api/nodes', 'POST', { name: 'N1', serverId: server.id, topology: 'direct', protocol: 'socks5-auth' })).node;
const n2 = (await request('/api/nodes', 'POST', { name: 'N2', serverId: server.id, topology: 'direct', protocol: 'socks5-auth' })).node;
assert.equal(n1.sortIndex, 0);
assert.equal(n2.sortIndex, 1);
// Reorder flips list order.
await request('/api/nodes/reorder', 'POST', { ids: [n2.id, n1.id] });
let listed = (await request('/api/nodes')).nodes;
assert.deepEqual(listed.map((node) => node.name), ['N2', 'N1']);
assert.deepEqual(listed.map((node) => node.sortIndex), [0, 1]);
// Create with nodeIds alignment + monthly reset + aggregation fields.
const customer = (await request('/api/customers', 'POST', {
name: '对齐客户', trafficResetDay: 15, nodeIds: [n1.id],
manualLinks: [{ uri: 'vless://11111111-1111-1111-1111-111111111111@x.example.com:443#M', remark: '手动一' }],
})).customer;
assert.equal(customer.trafficResetDay, 15);
assert.equal(customer.trafficResetPeriod, trafficResetPeriodKey(new Date(), 15));
assert.equal(customer.manualLinks.length, 1);
let nodesView = await request('/api/nodes');
assert.equal(nodesView.nodeClients.filter((nc) => nc.customerId === customer.id).length, 1);
// PATCH alignment swaps the binding set.
await request(`/api/customers/${customer.id}`, 'PATCH', { nodeIds: [n2.id] });
nodesView = await request('/api/nodes');
const bindings = nodesView.nodeClients.filter((nc) => nc.customerId === customer.id);
assert.equal(bindings.length, 1);
assert.equal(bindings[0].nodeId, n2.id);
// Credentials view + regeneration rotates the binding credential.
const before = await request(`/api/customers/${customer.id}/credentials`);
assert.equal(before.items.length, 1);
assert.ok(before.items[0].clientId);
assert.equal(before.subscriptionToken, customer.subscriptionToken);
const regenerated = (await request(`/api/customers/${customer.id}/credentials/${before.items[0].nodeClientId}/regenerate`, 'POST', {})).item;
assert.notEqual(regenerated.clientId, before.items[0].clientId);
assert.notEqual(regenerated.password, before.items[0].password);
// Presence: enroll an agent and report usage twice; the customer turns
// online after the first positive delta.
const ticket = await request(`/api/servers/${server.id}/enrollment-token`, 'POST', {});
const enrolled = await fetch(`${base}/api/agent/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: ticket.token, hostname: 'h' }) });
const agentKey = (await enrolled.json()).agentKey;
const usage = (samples) => fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ samples, epoch: 'e1' }) });
await usage([{ kind: 'user', id: customer.id, uplink: 1000, downlink: 2000 }]);
const customers = (await request('/api/customers')).customers;
const view = customers.find((item) => item.id === customer.id);
assert.equal(view.online, true);
assert.ok(view.lastActiveAt);
// System status endpoint shape.
const system = await request('/api/system');
assert.ok('host' in system && 'network' in system && 'history' in system && 'ips' in system);
assert.equal(system.network.customersTotal, 1);
});