30 Commits
Author SHA1 Message Date
aaaamoooo 5cfc5bebd7 docs(v4): batch report for items 24/25 + notes entries as authored
CI / anytls-smoke (push) Canceled after 0s
CI / test (push) Canceled after 0s
2026-10-21 13:45:15 +00:00
aaaamoooo 1e7ccd3cbe feat(ui): working bulk operations on servers/customers/nodes lists (item 24) 2026-10-21 13:31:58 +00:00
aaaamoooo 408b03ee68 feat(agent-auth): session tokens replace per-request scrypt (item 25)
Panel: after one scrypt-verified key auth, issue a random 24h
session token; store only its SHA-256 hash + expiry on the agent
record. authenticateAgent tries the token path first (constant-time
hash compare, active + unexpired), then the original fingerprint /
legacy VerifySecret path, and hands fresh tokens out via the
X-Agent-Session-Token response headers. Revoke() now clears token
material at all three revocation sites.

Agent: persist the token in the config dir (0600, temp+rename like
keys.json), present it as bearer, and on any 401 clear it and retry
once with the key (which re-issues). Old panels never send the
headers, so key-only operation is unchanged; old agents ignore the
headers. Version stays 0.6.8.

Tests: panel — issuance, hash-only storage, scrypt-free token path
(sabotaged KeyHash still authenticates), expiry fallback + rotation,
hash-mismatch fallback, revocation, per-record binding, key-only
rotation. Agent — capture/persist/reuse against a fake panel, 401
fallback and re-issue, old-panel key-only, client-side expiry,
double rejection surfacing without retry loops.
2026-10-21 13:12:19 +00:00
aaaamoooo 18074daf50 fix(web): keep Reality scanner results table inside its modal (item 16 follow-up)
真机暴露:扫描器浮层沿用 .modal.wide(820px) 且结果表借用
table-wrap visible(overflow:visible),8 列 nowrap 自然宽超弹窗,
「使用」按钮被挤到弹窗边框外;后端失败原因最长约 128 字在
nowrap 下更会单行撑爆。

- public/app.js:浮层 section 改用扫描器专属 .scan-modal;
  结果包装 table-wrap visible -> .scan-wrap;目标/证书单元格
  加 scan-target/scan-cert 类与 title 全文。
- public/styles.css:新增 .scan- 专属规则——.modal.scan-modal
  宽 1140px、.scan-wrap overflow-x:auto 兜底、目标/证书列
  max-width:230px 截断省略、原因列 white-space:normal +
  max-width:280px 换行。不动 .table-wrap/.modal 既有语义,
  节点编辑弹窗本体与其它弹窗布局不受影响。

验证:以真实 styles.css + 同构 DOM 灌真机候选池 14 行 + 长
原因(近 120 字上限)/超长证书名压力行,playwright-core 驱动
Chromium 在 1280/1600 两视口断言 table 与全部「使用」按钮
右缘均在弹窗右缘内、wrap scrollWidth==clientWidth(无需滚动);
旧类名对照页同法测得表宽 1958px 冲出弹窗(负对照成立)。
2026-10-21 12:33:32 +00:00
aaaamoooo 08bf2d08a2 docs: amo v3 batch report + round notes (items 12/15/16/17/20/21/22/23) 2026-10-21 11:08:18 +00:00
aaaamoooo 0f7495546f test(panel): scale item-22 benchmarks to 20-server seed 2026-10-21 10:54:36 +00:00
aaaamoooo 6d97d4e172 perf(web): page-data cache + progressive rendering (item 22, frontend)
Page switches no longer refetch the base datasets every time:
servers/customers/nodes/protocols are reused across pages for 30s,
and every successful mutation (all write paths funnel through api())
invalidates the cache. Rendering is progressive — the page paints
immediately with what is already in state and re-renders as each
dataset lands, instead of waiting for a Promise.all of 2-5 requests.
Overview/system/jobs still fetch fresh on every load.
2026-10-21 10:50:56 +00:00
aaaamoooo 05366d9177 perf(panel): kill per-request full-database clones (item 22, backend)
Data.Clone() was a full JSON marshal/unmarshal round trip (with a
per-record map dance for unknown fields): 6.3ms and ~13.8k allocs per
call on sandbox hardware, and every admin GET paid it at least twice
(RequireAdmin's user lookup + the handler). Clone is now a reflective
deep copy of the plain data graph — byte-identical output, proven
against the JSON clone on the real seed database by
TestCloneEquivalence — at 0.37ms / 538 allocs. CurrentAdmin resolves
the session user under a read-only store View and no longer clones
at all, so an admin GET takes exactly one snapshot. Sandbox
benchmarks (11 servers / 26 customers / 25 nodes): overview
17.2ms -> 0.38ms, servers 13.3 -> 0.85ms, customers 20.5 -> 1.54ms,
nodes 15.8 -> 3.45ms, jobs 13.0 -> 0.39ms per request.
2026-10-21 10:50:00 +00:00
aaaamoooo 0ab31ae32c feat: Reality target scanner (item 16)
Panel-side POST /api/reality/scan performs real TLS handshakes
against operator input (domains / IPs / CIDRs, port suffixes) or the
built-in pool (form presets plus microsoft/google/nvidia/samsung/
cloudflare and friends) and reports per target: usable or not, TLS
version, ALPN, key exchange, first cert SAN, latency, and a short
failure reason. Key-exchange support is proven by restricted-group
handshakes (X25519MLKEM768 first, then X25519, then a combined offer)
since the negotiated group is not exposed to Go clients. Bounds: 32
targets, 8 concurrent, 4s per probe. Node form gains a 查找目标
button beside the Reality preset; the scanner is a stacked overlay
so form state survives, and 使用 backfills SNI/dest/port.
2026-10-21 10:47:10 +00:00
aaaamoooo 4199a43660 test(panel): rename colliding login helper in reality-keys test 2026-10-21 10:44:54 +00:00
aaaamoooo a9d98675ac feat: show server Reality keypair on node edit form (item 17)
New read-only GET /api/nodes/:id/reality-keys returns the
panel-hosted keypair for the node's server from keyStore (display
only — it never mints a pair; a missing entry reports nulls). The
node edit form shows public and private key rows with copy buttons
for Reality protocols.
2026-10-21 10:44:32 +00:00
aaaamoooo 8189a513e1 feat(web): drop per-binding list from customer credentials tab (item 15)
The tab now shows only the subscription token and the shared
credential set. The Reality public key moved to the node edit form
(item 17) and the socks username to the node itself (item 21), so
nothing the bindings list showed is lost. Display-only change: the
credentials endpoint payload is unchanged.
2026-10-21 10:43:37 +00:00
aaaamoooo 80140f391f feat(panel): coalesce binding-class rebuilds in a fixed 10s window (item 23)
Assign/unassign, customer-form alignment, suspend/resume and
credential rotation no longer rebuild (and restart the node's
sing-box) per change. They mark the node rebuild-pending; a 1s
background flusher applies exactly one rebuild with the final state
when the FIXED window — 10s from the first pending change, never
extended by later changes — elapses. Manual deploy, repair, node
edits and server reconciles stay immediate and consume the mark.
Removal clears it, and the flusher discards marks on deleted /
removing / draft nodes, so a pending apply can never resurrect a
node. The mark is persisted in the data file, so a panel restart
inside the window cannot strand a node: the first post-boot pass
flushes whatever is due.
2026-10-21 10:43:27 +00:00
aaaamoooo 2fafd85d1f feat(panel): build-before-bind + socks5 fixed node account (items 20+21)
Item 20: drop the zero-client deploy gate in ValidateNodeDeploy for
all protocols, stop RebuildNode from auto-removing a node whose
bindings reach zero, and let server/heartbeat reconciliation rebuild
zero-client nodes like any other. Nodes are built and deployed first;
bindings can be added and removed freely.

Item 21: socks5-auth nodes carry one node-level fixed account
(node.socksUser/socksPassword, minted at creation or by the store-load
migration MigrateSocksNodeCredentials, idempotent). The sing-box
inbound's only user is that pair regardless of bindings; subscription
links for every bound customer carry the same pair. The generated
username avoids the ng: prefix so the agent never attributes the
account's traffic to a customer — it stays on the node's inbound
counters. Edit form shows both fields (copyable) for socks nodes;
NormalizeNode validates operator-supplied usernames.
2026-10-21 10:40:58 +00:00
aaaamoooo bb974e2ed7 fix(agent): fall back to install record for sing-box core version (item 12)
Locally built sing-box v1.13.21 carries no version stamp and reports
'unknown', leaving the panel core-version column empty. The agent probe
now falls back to /etc/nexusgate/sing-box/version when the binary
reports no version; agent-singbox.sh writes that record (version
without the v prefix) on every successful install path, including the
already-installed early exit. Agent version stays 0.6.8.
2026-10-21 10:36:09 +00:00
aaaamoooo aa2ee62d32 hotfix(panel): bind inbounds to stack wildcard instead of registered public address
Inbounds were pinned to the server's registered public address
(ipv4: PublicAddress, ipv6: PublicAddressV6), so addresses not
registered as 'the' public address - intranet NICs used for relay
landings, additional v6 addresses - had nothing listening and
intranet relays were unreachable (field-verified: ss showed the
landing port bound to the public IP only). 3x-ui/xray bind the
wildcard and serve these paths fine.

- listenAddress: explicit bindAddress still wins; ipv4 -> 0.0.0.0,
  ipv6 -> ::, dual -> :: (ipv6 wildcard per user decision
  2026-10-21: multi-v6 machines must serve all their v6 addresses).
- ResolveBindAddress: no longer resolves the registered address
  into a bind pin (that resolution was the production path of the
  pinning via resourceForNode/resourceForNodeExit); returns
  ("", nil) for all stacks, as it already did for dual/absent.
- serverAddress (client connection address / subscription host)
  is unchanged.
- Golden fixtures updated for the three listen fields that
  intentionally change; new listen_hotfix_test.go covers the
  listenAddress cases and the resourceForNode/exit deploy chains.
2026-10-21 07:16:38 +00:00
aaaamoooo 79639ac338 docs: report addendum for items 10-13 2026-10-20 21:47:59 +00:00
aaaamoooo 6d12e54606 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-20 21:47:16 +00:00
aaaamoooo 070e699a14 docs: round-2 (v2) nine-item change report 2026-10-20 21:41:49 +00:00
aaaamoooo 18048f309e 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-20 21:36:20 +00:00
aaaamoooo ad18e16ac6 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-20 21:21:13 +00:00
aaaamoooo bb8a0cb0c4 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-20 21:02:59 +00:00
aaaamoooo 6e82d0d06a 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-20 20:56:28 +00:00
aaaamoooo 161a5d78d1 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-20 20:52:38 +00:00
aaaamoooo 80f1370f1b 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-20 20:48:58 +00:00
aaaamoooo d576219a27 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-20 20:45:33 +00:00
aaaamoooo 156a31c131 docs: UI reskin report 2026-10-20 18:09:12 +00:00
aaaamoooo e4ee6981f9 feat(web): 3x-ui style reskin — amo branding, light/dark themes, four pages + tabbed customer form 2026-10-20 18:09:12 +00:00
aaaamoooo 011eecc096 feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity) 2026-10-20 18:09:07 +00:00
aaaamoooo 7c21742810 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-20 15:10:13 +00:00
62 changed files with 14698 additions and 1086 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 补同语义实现。
- 客户列表页未加"重置日"列(按最小配套要求只做了表单入口 + 上次重置显示)。
- 本报告不含生产部署动作:新二进制构建/换装按既有流程另行执行。
+172
View File
@@ -0,0 +1,172 @@
# amo 下一版修改清单
用户 2026-10-21 上线当晚起陆续口述,攒齐后统一施工。
## 0. 施工口径变更(2026-10-21 04:36 用户拍板)
Node 实现(server.js / lib / agent.js)冻结退役:本版起 **Go 单一真源**,只改 panel-go + agent-go + 共用前端,不再双写、不再对 Node 做 difftest;验收金标准改为冻结 Go 现状行为作回归基线。Node 代码留仓作历史与回滚参考,不删。
## 1. 客户页顶部加统计条(2026-10-21 03:53,用户附 3x-ui 截图)
照 3x-ui 客户端页顶部那排,在 amo「客户与订阅」页上部加统计条,六个数:
- 客户端(总数)
- 在线(当前在线数,沿用现有 3 分钟 presence 窗口口径)
- 耗尽(流量已用满的客户数)
- 即将耗尽(接近用满的客户数,阈值口径施工时对齐 3x-ui)
- 关闭(已停用客户数)
- 启用(已启用客户数)
出处:用户在路由器 3x-ui(:55889/rogue)客户端页截图,顶栏现网数字为 26 / 6 / 0 / 0 / 0 / 26。
## 2. 客户凭据改为全节点共用一套(2026-10-21 03:56,用户附 amo 凭据页截图)
现状(amo):凭据长在每个节点绑定上——同一客户在 HWSG / ZouSG-V6 / ZouSG-V4 / LEGJP-V4 / LEGJP-V6 各有一套不同的 UUID、密码、Reality Short ID。
目标(照 3x-ui):凭据收归客户级——一个客户一套 UUID + 密码 + Short ID,名下所有节点共用;节点绑定不再各存一套。
实证(2026-10-21 查用户 2026-10-20 发的路由器 3x-ui 备份库,只读):clients 表 uuid/password/sub_id 只存一份(email 唯一);client_inbounds 是纯关联表,无任何凭据列。26 个客户中 19 个挂了多个入站,最多的 rogue 挂 20 个入站、共用同一套凭据。此模型与日后 3x-ui→amo 正式迁移正好 1:1 对齐。
施工前要定的点:
- 节点侧 Reality 密钥对是服务器身份,不动,仍按节点托管(keyStore)。
- 存量不是问题:用户 2026-10-21 确认现网 amo 里服务器/客户/节点全是测试数据(随便加的),切换凭据模型时测试数据直接按新模型重建,无需收敛迁移;真实用户仍在 3x-ui 侧(待正式迁移)。
## 3. 内容区左右间距调小到与 3x-ui 一致(2026-10-21 04:02,用户附 amo 节点页 + 3x-ui 主机页对照图)
amo 各页主内容区两侧留白偏大;把左右间距(padding/最大宽度约束)调小,调到与 3x-ui 一样的贴边宽度。所有页面统一,不是一页一调。
## 4. 服务器页「核心版本」加真实探测(2026-10-21 04:11 用户)
现状:服务器页核心版本恒显"—"(agent 心跳没报这个字段)。
改法:agent 探测本机 sing-box 版本随心跳上报,面板落到核心版本列显示。
实现口径(已与用户对过内存账):版本探测只在 agent 启动 / 引擎安装更新 / 二进制文件变化时跑一次 `sing-box version` 并缓存,心跳只多带一个短字符串字段;不许每轮心跳起子进程。老 agent 无此字段时继续显"—",向后兼容。
## 5. 删除「延迟」(2026-10-21 04:12 用户)
服务器页的延迟列删掉,统计条里的"平均延迟"一并去掉;系统状态页如有同名占位同删。
理由(用户原话口径):延迟要不断探测上报、没什么用、还费内存——明确不做延迟探测功能,占位列也不要留。
## 6. 行操作小图标加悬停名称提示(2026-10-21 04:15,用户附客户行图标特写)
列表行内的功能小图标(二维码、详情、重置、编辑、删除等)鼠标悬停时显示对应功能名称(tooltip/title)。客户页先行,全站同类图标统一补齐。
样式照 3x-ui(用户 2026-10-21 附特写):黑底白字圆角气泡带小尖角、出现在图标上方;被悬停的图标本身带浅灰背景高亮。名称与 3x-ui 对齐:二维码 / 详情(i 图标)/ 重置流量(⇄ 图标)/ 编辑 / 删除。
## 7. 节点端口「范围内随机」保存后反复重掷(2026-10-21 04:19,用户附编辑节点截图)
现状:新建节点时入口端口为"范围内随机",分配到随机端口;若不手工改成固定,之后每次编辑该节点(哪怕只改其他参数)点保存,端口都会被重新随机分配成另一个——端口静默漂移,客户端随之失效。
要求:随机只在新建时掷一次;已分配的端口在后续编辑中必须保持不变,除非用户显式改端口模式/数值。实现口径施工时定(如:编辑既有节点时端口模式默认按已分配端口固定呈现),验收要点:编辑既有节点任意字段保存后端口逐字不变。
## 8. 中转模型与 3x-ui 不对齐:缺"A 入站经内网 IP 中转到 B 节点"(2026-10-21 04:26,用户附中转节点编辑截图)
现状:amo 的中转是"入口 → 出口设备"模型(转发拓扑 + 出口 SOCKS5),与 3x-ui 的"出站 + 路由规则"完全不同。
缺口:无法实现用户在 3x-ui 上的既有用法——A 服务器的入站节点通过内网 IP 直接中转到 B 服务器的节点(线路鸡 → 落地鸡内网中转)。
要求:下一版补齐这种中转形态(这是用户现网在用的模式,迁移前置之一)。
设计方向(2026-10-21 04:28 已与用户对过):出口目标二选一——① 受管设备(现状,面板管两边);② 新增"手动落地端点":填地址(内网 IP/域名)+ 端口 + 协议 + 凭据(VLESS=UUID、SS2022=密码、SOCKS5=账密),面板只在 A 侧生成出站+路由(sing-box outbound + route),B 侧不要求装 agent、可以是 3x-ui 的现成节点。凭据只存主控、随部署下发。
补充(用户 2026-10-21 确认的理解):第三方/别人提供的节点同样可作落地——只要有 IP+协议+ID+密码即可,来源不限(自家内网鸡、3x-ui 节点、别人给的节点都行);前提是 A 能直达该地址端口。一个 vless:// / ss:// 分享链接本身就含全这四样,施工时可顺带做"粘贴链接自动填表"。
## 9. 删除右上角头像圆图标(2026-10-21 04:34,用户附顶栏特写)
顶栏右侧的圆形头像(显用户名首字母"G")是纯装饰 span,无任何点击事件(退出登录在侧栏底部另有按钮)——删掉。已查证同排的「刷新」按钮有效(绑定 load() 重新拉数重渲染,与 45 秒自动刷新同一套),保留不动。
## 10. 删除顶栏全局搜索框(2026-10-21 04:39,用户附顶栏截图)
顶栏"搜索服务器、客户、节点…"输入框是写死 disabled 的摆设、无任何功能;且每页都有自己的可用搜索框,此框多余——删除(顶栏共用一处,删一处全站生效)。
## 11. 菜单与命名调整 + 页面合并(2026-10-21 04:47,用户附节点页全图)
- 左侧菜单「客户与订阅」→ 改名「用户订阅」(页面标题/面包屑同步)
- 左侧菜单「服务器」→ 改名「节点管理」
- 左侧菜单「转发与节点」→ 改名「入站&出站」(页面标题/面包屑同步)
- 该页「+ 新建节点」按钮 → 改为「添加入站」;页内计数文案「9 个节点」→「9 个入站」(同页其他"节点"计数文案同步对齐)
- 左侧菜单「设置与运维」→ 改名「设置」
- 「安装与命令」页面的全部内容合并进「设置」页面,合并后删除「安装与命令」页面(菜单项与路由一并摘除)
## 12. 核心版本显示为空——根因已明,下批再修(2026-10-21 11:52 用户定:小问题,下次一起修)
v2 上线后现网四台核心版本列全空。已查实根因:节点 sing-box 是自编版(agent-singbox.sh 固定 v1.13.21),构建未打版本戳,二进制 `version` 输出 `sing-box version unknown`,agent 探针按设计把 unknown 当无版本。代码链(探针→心跳→落库→渲染)无误。
已定修法(未施工):agent 探针解析为空时兜底读安装记录 /etc/nexusgate/sing-box/version;agent-singbox.sh 安装成功后写该记录(去 v 前缀);现网四台补写 1.13.21 即可(安装脚本写死的版本,有据)。长期根治:下次重编 sing-box 时打版本戳。
## 13. 删除系统状态页「最近活动」模块(2026-10-21 04:56,用户附截图)
首页(系统状态页)底部的「最近活动」面板整个删除(该面板是改版时为保旧功能补回的,用户现明确不要)。只删展示模块,后端活动/审计记录本身保留不动。
## 14. 设置页「任务队列」表格排紧凑(2026-10-21 04:58,用户附截图)
现状毛病:列宽分配失衡——任务 ID 被挤到换行两行、状态"已完成"竖排三行、时间两行,而错误列大片空白。
要求:重配列宽与换行——ID/状态/时间等短内容不换行、单行呈现;空白列不占大宽度;整表紧凑。合并进设置页后保持此排版。
## 15. 凭据页签删掉按绑定逐行的 Reality 公钥区块(2026-10-21 14:13 用户附截图)
用户原话:红框里的节点 Reality 公钥好像没什么意义,不需要显示出来,只需要显示上面的凭据就可以了。
处理:编辑客户「凭据」页签删除整个按绑定逐行列表(节点名/状态/协议/Reality 公钥+复制按钮),只保留订阅令牌 + 凭据(全部节点共用)三件套(UUID/密码/Reality Short ID)。仅前端展示删除:公钥数据本身(主控 keyStore 托管、订阅生成、部署下发)一律不动。
## 16. 节点表单加 REALITY 目标扫描器(2026-10-21 14:21 用户附 3x-ui 扫描器截图)
用户原话:查找目标点一下,就会扫描出很多可用的域名可以用,点一下使用即可,很方便。
规格(照 3x-ui 扫描器形态):
- 入口:编辑/新建节点的 Reality 目标预设旁加「查找目标」按钮,打开扫描器弹窗。
- 输入:域名 / IP / CIDR,可留空——留空则探测内置常用候选池(microsoft/google/nvidia/amazon/samsung/intel 等,与现有目标预设同源扩充)。
- 探测:主控侧对每个候选做 TLS 握手,列表展示 目标/状态(可用与否)/TLS 版本/ALPN/密钥交换(须支持 X25519 或 X25519MLKEM768)/证书主域名(SAN)/延迟;不可用行给出简短原因。
- 回填:每行「使用」按钮,点击即把该目标回填为节点的 Reality SNI / 目标地址与端口并关弹窗。
- 注意:v1 与 3x-ui 一致在主控侧扫描,延迟/可达性是主控视角(路由器经代理出网,与海外节点的真实视角有偏差,UI 上不夸大表述);节点侧探测(走 agent job)留作二期,不在本条范围。
## 17. 节点编辑页显示 Reality 公钥+私钥(2026-10-21 14:22 用户附 3x-ui 入站安全页截图)
用户原话:之前用户页面删掉的那个公钥展示,把它和私钥一起和 3x-ui 加到对应的节点页面显示吧。
处理(与第 15 条配套):编辑节点弹窗(Reality 协议时)增加密钥对展示区:公钥、私钥各一行,带复制按钮,数据取自主控 keyStore 托管的该服务器密钥对。注意 amo 的密钥对是**服务器级**身份(同机多个 Reality 节点共用同一对),节点页显示的是其所属服务器的那一对,不要做成节点级新生成。本条只要求显示;是否加「换一对」(相当于 3x-ui 获取新证书,会触发该服务器全部 Reality 节点重部署+订阅公钥变更)本轮不做,用户未要求。
第 15 条相应修订:客户凭据页签删的是"按绑定逐行重复展示公钥"的区块;公钥信息自此只在节点页出现一份。
## 18. 入站监听钉死公网 IP,内网中转打不通(2026-10-21 15:04 实测确诊;同日已热修上线并真机验收:commit aa2ee62,路由器面板已换装(旧二进制备份 .bak-hotfix-pre),PPFL1-SK5 重部署后 ss 见 0.0.0.0:47814,纯 amo 中转链实测通、出口正确。aa2ee62 尚未推 Gitea,随下一批一并推。)
证据:用户 PPF 两机内网中转(L3 入口 VLESS → 手动落地 L1 的 SOCKS5 入站,落地地址填内网 10.240.64.2:47814)在 amo 不通;同一对机器在原版 3x-ui 正常(xray 入站默认听全部地址)。L1 本机 ss 实测:47814 只绑公网 IP 140.235.229.19,未绑 0.0.0.0/内网地址,进程为 nexusgate-sing-box(节点本身部署成功)。
根因:panel-go/protocols.go listenAddress() 在 ipv4 模式返回服务器登记的 PublicAddress(ipv6 模式同理返回 PublicAddressV6),入站只听公网网卡,内网地址无人监听。
修法(2026-10-21 15:07 用户定现在热修;15:09 范围终版):默认监听全改通配——ipv4→0.0.0.0、ipv6→"::"、dual→"::"(不变);显式 BindAddress 覆盖保留。v6 也改通配的理由(用户原话):有的节点鸡有很多个 v6 公网地址,只绑登记的那一个会漏掉其余 v6 地址上的连接。代价用户已知并接受:通配 v6 套接字在 Linux 默认会顺带收 v4 映射连接,仅 v6 节点事实上 v4 也可达。改动在主控侧 listenAddress()(BuildNodeResource/BuildNodeExitResource 共用),agent 无需改;换面板后对节点重新部署即生效。
## 19. SOCKS5 用户名上收到客户共用凭据区(2026-10-21 15:42 用户指认,与第 15 条配套)
现状:客户的 SOCKS5 用户名(ng:cus_<客户ID>,即 NodeEmail)目前仅在客户凭据页签的"按绑定逐行"区块里随节点行显示(如 PPFL1-SK5 行),而该区块第 15 条要删。删后此用户名将无任何界面可见,但手动落地端点填表等场景必须人读它(用户本次中转测试就靠抄它)。
处理:删绑定区块时,把「用户名」作为第四行加进凭据页签上部的共用凭据区(UUID / 密码 / ShortID / 用户名),带复制按钮;值由 ng:+客户ID 确定性生成,纯展示、无新存储。订阅与入站生成逻辑不变。
## 20. 入站先建后绑:放开"必须先分配客户才能部署"(2026-10-21 16:11 用户提出,方案已拍板)
现状:orchestrator.go 部署校验写死 len(activeNodeClients)==0 时 400 拒绝("请先给节点分配至少一个客户再部署");节点能建能存,卡的是部署/启用。改成 3x-ui 式:先把入站建起来部署,关联客户随时增减(绑定变更本来就会触发重新部署)。
方案(用户 18:01 按推荐方案确认):删除该校验,零客户节点可部署(VLESS/SS/AnyTLS/Hysteria2 等空用户只是无人能连,无副作用);**唯 socks5-auth 例外保留拦截**——sing-box 的 socks 入站在 users 为空时不做认证=无密码开放代理(xray 空账号是拒绝,语义不同),且监听已改通配、公网可达,拦截提示语写明此原因。
验收:零客户建一个 VLESS 节点部署成功且在节点机监听;随后绑客户自动重新部署可连;零客户建 socks5-auth 节点部署被拦且提示正确;绑客户后部署成功。
## 21. SOCKS5 节点改固定账号密码(3x-ui 式,入站级)(2026-10-21 18:06 用户拍板)
用户原话:socks5 节点改成 3x-ui 那样固定的账号和密码,直接在入站里固定;不然多人共享同一个内网中转的 socks5 节点,流量会全部算在同一个用户名下。
诊断(已核代码):现模型 socks5-auth 入站 users 按绑定客户逐个派生(ng:cus_<id>+共用密码),但中转链入口侧出站只有一组静态 ExitCredentials,全体客户的流量到落地侧都以那一组身份认证,落地侧"按人分账"是假的、全记一人头上(还会误触其限额)。入口侧才是真分账点(客户各自 UUID 认证)。
改法:
- 节点(协议=socks5-auth)新增入站级固定 socksUser/socksPassword:建节点时可填、留空自动生成随机对;节点编辑表单在 socks 协议时显示这两项(可改、带复制);入站 users 只放这一组。
- 订阅:socks 节点链接携带这组固定账号密码(所有绑定客户同一份),绑定只控制订阅里出不出现该节点,不再派生凭据。
- 记账:该固定账号的流量记为节点级(不归任何客户);面板展示口径随之调整,别再把落地 socks 流量摊给客户。中转链的按人记账在入口侧完成(现状已如此)。
- 存量:现网 socks 节点(PPFL1-SK5 等,测试数据)迁移时生成一组固定凭据替换 users;入口手动落地端点里填的 ng 账号随之作废,改填节点固定账号(文档/提示同步)。
连带修订:
- 第 19 条作废(被本条取代):客户凭据区不再加"用户名"行——socks 用户名从此在节点上,客户凭据页签的绑定区块可径直删除,无需上收。
- 第 20 条修订:socks5-auth 的零客户拦截例外取消——入站恒有固定账号,不存在空 users 变开放代理的问题,先建后绑对全部协议一致放开。
## 22. 面板切菜单慢:每请求全量克隆的成本(2026-10-21 18:12 用户报,晚间实测定位)
现象:路由器上点菜单约 3 秒才切换;3x-ui 同机同路(内网/隧道)都快。
实测证据(已逐项排除):DB 文件仅 225KB;两条访问路径都慢(非隧道问题);favicon(同进程静态文件)6–21ms 而全部 /api 接口 250–1560ms 且与响应大小无关(1.2KB 的 system 也要约 1s);放宽 GOMEMLIMIT 28MiB→64MiB、GOGC 50→100 无改善(GC 无关,已恢复原参数,RSS 回 14MB 级);SIGQUIT 协程转储无空转循环(全在 IO/通道等待);整机 load 约 3(4 核),面板单请求成本高是主因、整机繁忙是放大器。
机制:每个 admin GET 请求至少两次全量 Snapshot()——RequireAdmin 为找一个用户克隆整库一次,handler 再克隆一次;Clone() 走 JSON marshal+unmarshal 全量往返;切一页 = 2–5 个这种请求串行排队(同一把 store 锁,还要和 agent 心跳的 Transaction 克隆+落盘竞争)。3x-ui 每请求只做索引查询,故同机快。
修法(下一批,性能专项):
1. RequireAdmin 不再全量克隆找用户:用户表小规模缓存/按会话直接映射,请求级只克隆一次并在 handler 间共享 snapshot。
2. 读路径去全量 JSON 往返:以不可变快照+按需部分解析或写时复制替代 Clone() 的全量 marshal/unmarshal(保数据文件格式与事务语义不变,金标准必须全过)。
3. 前端切页不重拉全量:servers/nodes/customers 等基础数据做页间缓存(带失效点:写操作后失效),渲染不等全量 Promise.all 才上屏。
验收口径:路由器真机、同数据量下 devtools 复测,常用页切换的接口耗时回到几十 ms 级;RSS 不回涨(保持 28MiB/50 参数)。
附带教训(已发生):给用户的 sed 命令里 `.*` 被其客户端吞成 `.`,把 GOMEMLIMIT 改成 96MiB8MiB 导致 Go 运行时启动崩溃循环——已恢复;今后改值命令只用全字面替换,不用通配。
## 23. 绑定变更合并下发:连续加人只重启一次(2026-10-21 18:34 用户提出、18:36 拍板并入本批)
现状(已核代码):给节点分配/移除客户(assignCustomer/unassignCustomer)只要节点非草稿就立刻 RebuildNode 并排 apply_resource 任务,agent 重写 sing-box 配置重启生效——每加/减一个人,该节点全体用户断一次重连;连加十人重启十次。sing-box 无动态加人接口,单次重启消不掉,只能合并。
改法:绑定类变更触发的重建按下发合并——同一节点在短窗口(10 秒量级)内的多次 RebuildNode 只产生一次 apply_resource,以窗口结束时的最终状态为准。
约束:①手动点部署(/api/nodes/:id/deploy)保持立即执行,不进合并窗口;②删除/移除流程语义不变,窗口内节点被删除时待发 apply 必须正确作废、不许复活;③面板在窗口内重启不能把节点卡在未下发状态(待发状态可落盘或启动对账补发,二选一并在报告注明);④凭据重置等其他触发 RebuildNode 的路径按同一合并口径(按节点合并即可)。
验收:同一节点快速连加 5 个客户,最终只执行 1 次 apply_resource 且 5 人齐全;窗口内删节点不复活;手动部署仍立即;既有测试与金标准不回归。
口径补充(同日用户问答确认):合并窗口为固定窗口(从该节点第一次待发变更起算,不滑动延长);代价=新加入用户最多晚约 10 秒 + 一次 apply 时间才能连通该节点,窗口内老配置照常服务、老用户无感。此代价用户已知悉并接受。
## 24. 列表页勾选是空壳:补齐批量操作(2026-10-21 20:55 用户实测指出)
现象(用户附 3x-ui 对照图):3x-ui 勾选行后顶部出现「已选 N 项 + 删除」,可一键删除全部选中;amo 三个列表页(节点管理/入站&出站/用户订阅)的行勾选框与表头全选都在,全选也能勾上,但选中后界面毫无变化、无任何批量动作可用。
已核代码(public/app.js):data-row-check 渲染在 servers/customers/nodes 三页;唯一的 data-check-all 处理只是把全选状态镜像到每行 checkbox(约 1426 行);row-check 本身无 change 处理、全站无任何代码读取勾选集合(grep 无 已选/bulk 字样)。
修法:每页维护选中集合,选中 ≥1 时在工具条区显示批量操作条「已选 N 项」+ 动作:批量删除、批量启用、批量停用(按各页既有单项语义:节点 启用=部署/停用=移除、客户与服务器走各自启停);删除一次确认弹窗说清数量与不可逆;逐项执行既有单项接口/端点,守卫规则与单项完全一致(如服务器有未删节点拒绝、节点有运行中任务拒绝),结果汇总回报成功/失败数与失败原因;操作后刷新列表并清空选择。切页/重渲染后选择状态的去留施工时定并在报告注明。
范围补充(同日用户强调):**所有页面都要改**——凡是带行勾选框/全选框的列表页全部补齐批量操作,不许只做一页;施工时以 data-row-check / data-check-all 在 public/app.js 的全部出现处逐一排查销账(当前已知:节点管理、入站&出站、用户订阅三页),报告中逐页列明。
## 25. agent 鉴权令牌化:压掉待机 CPU 的 scrypt 主项(2026-10-21 21:01 用户拍板,与第 24 条同批)
背景(v3 第 22 条归因):面板待机 CPU 的主项是 agent 每次请求都跑一次 scrypt VerifySecret 验 KeyHash(沙箱单次约 88ms、20 台模拟稳态 32.3%);二开版 3x-ui 无此问题——慢哈希只用于人类登录,agent 通信走 API 令牌比对(已查其代码证实)。
修法(用户原话口径:往令牌制靠):agent 首次(或令牌失效时)仍走指纹定位 + scrypt 验一次身份,面板随即签发一张短期令牌(随机值,面板只存其快速哈希如 SHA-256 + 过期时间,绑定该 agent 身份);agent 后续请求带令牌,面板只做快速哈希比对,不再跑 scrypt。令牌过期/不符自动回退 scrypt 路径并重签。吊销语义不降级:删除/吊销 agent、重置其密钥时令牌一并失效。
兼容:旧 agent(不支持令牌)继续走 scrypt 路径可用、只是不提速;新 agent 对旧面板自动回退。鉴权强度不许降级:令牌随机强度与现有密钥同级、比对用常时比较、传输通道沿用现状(现制下密钥本就逐请求在同一通道上传输,令牌不弱于此)。
验收:①稳态 CPU 按 v3 同法复测(20 台 agent 模拟、无界面请求)——目标个位数 %,报告给改动前后同口径数字;②功能回归:agent 注册/心跳/任务拉取/用量上报全链路冒烟、面板或 agent 单侧重启后自动恢复(重签或凭令牌续用)、吊销后令牌立即失效、旧 agent 混跑可用;③全量测试与金标准不回归。
部署提示:面板与 agent 双侧都改;面板先行兼容旧 agent,agent 逐台换新后逐台提速。
+132
View File
@@ -0,0 +1,132 @@
# amo v3 批次构建报告(NEXT-ROUND-NOTES 第 12/15/16/17/20/21/22/23 条)
- 基线:`aa2ee62`(开工时核对一致);本批 10 个 item-scoped 提交,HEAD 见文末清单。
- 施工口径:第 0 条 Go 单一真源——只改 `panel-go/`、`agent-go/`、共用前端 `public/`、安装脚本 `scripts/`;Node 实现零改动(`git diff --name-only aa2ee62..HEAD` 可核)。数据文件既有字段语义未改,新字段全部 `omitempty` 向后兼容。
- 验收环境:沙箱 linux/amd64(AMD EPYC 9D64)、Go 1.24.6、自编 sing-box v1.13.21 沙箱现成二进制。**未部署、未推 Gitea**;真机(路由器 A53)数字待部署后复测。
## 逐项结果
### 1. 第 12 条 核心版本显空修复 — ✅ 完成(`bb974e2`)
- `agent-go/health.go`:探针 `singBoxVersion()` 在二进制版本为空/`unknown` 时兜底读安装记录;记录文件 mtime 纳入探针缓存键,补写记录能被及时感知。新增 `installedSingBoxVersion()`:去空白与可选 `v` 前缀、正则校验版本号形状,垃圾内容忽略。
- `agent-go/main.go`:config 新增 `SingBoxVersionFile`,默认 `/etc/nexusgate/sing-box/version`,env `NG_SINGBOX_VERSION_FILE` 可覆盖。
- `scripts/agent-singbox.sh`:新增 `write_version_record()`(写去 `v` 前缀的 `1.13.21`),在已安装早退、预编译安装、源码编译安装三条成功路径全部调用。
- agent 版本号保持 `0.6.8`(面板 AnyTLS 版本门禁按数字解析,铁律未动)。
- 测试:新增 `TestSingBoxVersionFallsBackToInstallRecord`(unknown→读记录、带戳二进制优先、垃圾记录忽略、v 前缀剥离)通过;脚本 `bash -n` 通过。现网四台补写记录属部署动作,不在本批。
### 2. 第 15 条 凭据页签删绑定区块 — ✅ 完成(`8189a51`)
- `public/app.js` `renderCredentialsPane`:删除按绑定逐行列表(节点名/状态/协议/Reality 公钥+复制),只留订阅令牌+共用凭据三件套。后端 `customerCredentialsView` 载荷、keyStore、订阅、下发数据未动。
- 第 19 条已被第 21 条取代作废:未按旧口径往共用凭据区加「用户名」行(socks 用户名自此在节点上)。
### 3. 第 16 条 REALITY 目标扫描器 — ✅ 完成(`0ab31ae`)
- 后端 `panel-go/realityscan.go`+接口 `POST /api/reality/scan`(RequireAdmin 之后):入参 `{input}` 或 `{targets:[...]}`。
- 输入解析:留空→内置候选池 14 个(与目标预设同源的 tesla/amd 等+ microsoft/google/nvidia/samsung/intel/cloudflare/bing/github/yahoo/mozilla,均为 443);支持域名/IP/host:port/IPv4 CIDR(展开上限 32 主机);显式输入超 32 目标报 400。
- 探测:真实 TLS 握手,并发上限 8、单探针超时 4 秒;返回 target/host/port/ok/reason/tlsVersion/alpn/keyExchange/keyExchangeOk/certName/latencyMs,顺序与输入一致。密钥交换判定:Go 1.24 的 `tls.ConnectionState` 无 CurveID 字段,故改为限定组握手实测——依次只提供 X25519MLKEM768、只提供 X25519、组合兜底,任一成功即记支持。探测带 `InsecureSkipVerify`(只判可达性与密钥交换能力,证书 SAN 仍回报展示;理由已注释在代码内)。
- 前端:预设旁「查找目标」按钮;扫描器为独立叠加层(DOM 元素,不走 openModal,不冲未保存的节点表单状态),带主控视角说明文案(「从主控所在网络发起探测……节点所在网络可能不同」);每行「使用」回填 Reality SNI/目标端口、预设切 custom、关层。样式 `.scan-table` 在 `public/styles.css`。
- 测试:`realityscan_test.go` 通过(池/输入/CIDR 解析、本地自签 TLS 服务器 loopback 实测 OK/TLS 1.3/密钥交换非空、死端口给 reason);冒烟实测见下。
### 4. 第 17 条 节点页 Reality 公私钥 — ✅ 完成(`a9d9867`+测试修复 `4199a43`)
- 新只读接口 `GET /api/nodes/:id/reality-keys`:取 keyStore 中该节点所属**服务器**的托管密钥对;**绝不调用生成逻辑**,无条目返回 null;非 Reality 节点 400、节点不存在 404。
- 前端:节点编辑弹窗(Reality 协议时)加展示区,公钥/私钥各一行带复制;无密钥对显示「尚未生成(首次部署时由主控生成)」。只显示,无「换一对」。
- 测试:`realitykeys_test.go` 通过;冒烟实测接口返回完整公私钥对、非 Reality 节点 400。
### 5. 第 20+21 条 先建后绑+SOCKS5 固定账号 — ✅ 完成(`2fafd85`)
- 先建后绑:`orchestrator.go` 删除 `ValidateNodeDeploy` 的零客户 400 校验,全部协议一致放开(socks 例外按第 21 条连带修订取消);删除 `RebuildNode` 零客户自动移除分支与对账路径的零客户 skip——绑客户归零只重建、不下架。
- 固定账号:`types.go` Node 新增 `socksUser`/`socksPassword`(omitempty;老文件可读、老二进制读新文件忽略新字段不崩)。`protocols.go` `EnsureSocksCredentials()` 生成:用户名 `ngs_`+10 位 hex——**刻意避开 `ng:` 前缀**,因 agent 侧 sing-box 日志解析用 `^ng:(.+)$` 把 user 计数映射为客户级 usage 样本,固定账号带该前缀会被误记到某客户头上;密码为 base64 32 字节随机(与 RelayPassword 同级)。
- 入站 users 恒为固定账号一组,与绑定数无关(0/1/2 客户均有测试覆盖)。
- 订阅:`subscriptions.go` `NodeClientURI` 对所有绑定客户携带节点固定账密;链接只在客户已绑定时出现,绑定只控制出不出现。
- 编辑表单:socks 协议时显示两字段(可改、带复制,含未保存值复制);表单提交空值=保留旧值(与 exitCredentials 合并语义一致);用户名校验拒绝 `ng:` 开头与空白;协议切离 socks 时清空两字段。新建留空保存后自动生成。
- 存量迁移:`store.go` `MigrateSocksNodeCredentials()` 在 Store.Init 与既有凭据收敛迁移合并落盘,幂等只填空缺。
- 记账:固定账号流量在 agent 侧不匹配任何客户 user 样本,走 inbound 样本记节点级 MeteredTraffic;入口侧按客户 UUID 分账逻辑未动。
- 测试:新增 `panel-go/socksfixed_test.go` 全过;订阅金标准夹具的 socks 节点改存固定账密对(取旧派生值使全部格式产物逐字不变,见金标准节)。
### 6. 第 22 条 性能专项 — ✅ 代码完成,真机复测待部署(`05366d9`+`6d97d4e`+基准扩容 `0f74955`)
后端:
- `types.go` `Clone()` 改反射深拷贝(指针/接口/切片/映射/结构体递归、[]byte 逐字拷贝、未导出字段经浅拷贝保留);原 JSON 往返保留为 `cloneViaJSON()` 供等价对照。`clone_equivalence_test.go` 在真实种子库与合成数据(含 json.Number 嵌套、keyStore、Extra)上证明两种克隆序列化逐字节相等且深独立、nil 安全。
- `handlers.go` `CurrentAdmin` 改走 `store.View` 只读命中单条 User 记录,不再为找用户整库 Snapshot——每个 admin GET 的全量克隆由至少两次降为一次(handler 自身的一次 Snapshot 保留,未逐个改 View,以保同一请求内读一致性)。事务语义、数据文件格式、并发模型不变。
前端(`public/app.js`):
- 基础数据集页间缓存(servers/customers/nodes/protocols,TTL 30 秒);`api()` 内任何非 GET 成功统一失效缓存——全站写路径都过 `api()`,无漏点。
- `load()` 改渐进渲染:先以已有数据立即渲染当前页,各数据集到达后逐个应用并重渲染;overview/system/jobs 恒新鲜拉取;仅当本次全部请求皆失败才弹错误提示(保持旧失败面)。
量化证据(沙箱 amd64,合成种子 **20 服务器 / 40 客户 / 40 节点 / 160 绑定**,改动前后同种子同测法;旧版取 `aa2ee62` worktree 构建,基准文件同源移植):
| 指标 | 改动前 | 改动后 | 倍数 |
|---|---|---|---|
| Clone 一次 | 13.14 ms(JSON 往返) | 0.54 ms(反射) | ≈24× |
| GET /api/overview | 31.74 ms | 0.48 ms | ≈66× |
| GET /api/servers | 33.65 ms | 1.29 ms | ≈26× |
| GET /api/customers | 31.17 ms | 2.29 ms | ≈14× |
| GET /api/nodes | 31.31 ms | 5.08 ms | ≈6× |
| GET /api/jobs | 24.63 ms | 0.64 ms | ≈39× |
注:改动后 nodes 接口剩余耗时主要是 PublicNode/订阅 URI 等业务序列化,不再是克隆。以上是 amd64 沙箱数值;路由器真机(A53+整机 load 约 3)验收待部署后用 devtools 同数据量复测,预期回到几十 ms 级的结论以真机数字为准。
空闲稳态 CPU 对照(用户追加项;规模按最终口径 **20 台 agent**):
- 测法:同一种子(20 服务器/40 客户/40 节点,含 20 个带指纹的 agent 凭据),新旧二进制各起一份、无任何界面请求,驱动脚本按 agent-go 现网节奏模拟 20 台 agent——poll 每 8 秒、heartbeat 每 30 秒、usage 每 60 秒(空 samples),错峰启动;预热 15 秒后取 60.06 秒窗口,用 `/proc/<pid>/stat` 的 utime+stime 差折算 %CPU(100 ticks=1 秒 CPU)。两版驱动错误数均为 0。
- 结果:**改动前 38.41% → 改动后 32.31%**。**未达到个位数目标,如实报告。**
- 剩余成本主项(实测归因):agent 鉴权 `VerifySecret` 的 scrypt 校验。指纹快路径只负责定位 agent 记录,定位后仍须对 KeyHash 做一次 scrypt 验证(不可跳过,否则指纹即凭据);沙箱实测单次 VerifySecret ≈ 87.9 ms。稳态请求率 = poll 2.5/s+heartbeat 0.67/s+usage 0.33/s ≈ 3.5/s,scrypt 一项即 ≈ 3.5×87.9ms ≈ 30.7% CPU,与改动后 32.31% 几乎完全吻合——即克隆成本已被基本消除(旧版多出的约 6 个百分点即 JSON 全量克隆+usage 事务克隆落盘),稳态 CPU 现由**密码学校验**主导,与第 22 条的克隆问题是两个独立瓶颈。若后续要继续压稳态 CPU,方向是 agent 鉴权改造(如指纹命中后改用廉价 HMAC 校验或会话化令牌),属新立项,本批未动鉴权语义。
- 事务路径说明:`Transaction` 仍做一次全量克隆+落盘(写路径语义要求),稳态下仅 usage 每分钟 20 次触发,成本已被 scrypt 淹没,不是主项;60 秒 housekeeping 的事务+Snapshot 在两版 RSS 采样中均表现为分钟级瞬时尖峰。
RSS 对照(同种子、无界面请求):
- 空闲(带现网参数 `GOMEMLIMIT=28MiB GOGC=50`,启动 20 秒后连取 3 次):改动前 10,876 KB 稳定;改动后 10,672–10,840 KB。**无回涨**,满足第 22 条「RSS 不回涨」口径。
- 稳态负载下(未加 GOMEMLIMIT 的沙箱默认,60 秒窗口每 10 秒采样):改动前 47→197 MB 持续爬升(JSON 克隆分配压力),改动后多数样本约 46 MB、housekeeping 分钟边界出现一次约 150 MB 瞬时尖峰后回落。
### 7. 第 23 条 绑定变更合并下发(本批第 8 项)— ✅ 完成(`80140f3`)
- 窗口语义:**固定窗口 10 秒**——从该节点第一次待发变更起算,后续变更不延长(用户补充口径);窗口内多次绑定类变更只在到期时以最终状态下发一次 `apply_resource`。
- 实现:Node 新增落盘字段 `rebuildPending`/`rebuildPendingAt`/`rebuildPendingExit`(omitempty);`Orchestrator.FlushPendingRebuilds(now)` 由主进程 1 秒 ticker 调用。**待发状态落盘**(二选一中取此方案):面板在窗口内重启,首个 tick 即补发,不会卡死。
- 打标路径:assign/unassign、AlignCustomerNodes、Suspend/ResumeCustomerResources、凭据 regenerate(原逐节点立即重建改打标)。
- 保持即时并清标:手动 `POST /api/nodes/:id/deploy`(约束①)、RepairNode、节点 PATCH 编辑、对账路径;RemoveNode 清标(约束②)——flusher 对 PendingDelete/removing/draft 节点的标直接丢弃,绝不复活;flush 失败写 node.Error+审计 `rebuild_flush_failed` 并继续其他节点。
- 测试:`rebuild_coalesce_test.go` 五项全过——连加 5 客户窗口内 0 次 apply、到期只 flush 1 次且资源含齐 5 人;固定窗口不滑动;窗口内删节点 flush 0 次且无 apply;手动部署立即 1 次并消费待发标;Store 重载后标仍在且可 flush。
## 横向验收
- **全量测试**:`panel-go` 与 `agent-go` 各 `go test ./...` 全绿(含第 18 条 `listen_hotfix_test`、本批新增 socksfixed/rebuild_coalesce/realitykeys/realityscan/clone_equivalence/singboxversion 等)。
- **金标准**:`golden/golden.mjs verify` 仅有 1 处 diff——`lists.customers.body.customers[0].subscriptionClientCount 3 → 0`。已判定为**种子时间漂移、非本批回归**:用 `aa2ee62` 旧二进制复跑得到逐字相同的 diff(种子内订阅客户端的活跃时间窗已随墙钟过期,过期后恒为 0)。其余 31 个 section 逐字一致(含订阅全格式、部署任务、用量投影)。因此**未 recapture 基线**:该 diff 与本批行为变更无关,贸然 recapture 会把环境漂移写进基线。
- **真实 sing-box check**:冒烟中用改动后面板实际生成的两份节点资源(SOCKS5 固定账号节点+VLESS Reality 节点),按 agent-go `combinedSingBoxConfig` 同规则组装合并配置、keyMaterial 替换 `${REALITY_PRIVATE:...}` 占位后,跑沙箱自编 sing-box v1.13.21 `check` —— **通过**(入站:socks 听 0.0.0.0、users 恰 1 组;vless 听 0.0.0.0、Reality 私钥就位)。
- **沙箱真启冒烟**(新二进制、空库引导):登录成功;overview/servers/customers/nodes/jobs/system/protocols/session 全部 200;建服务器→令牌注册 agent 成功;零客户 socks 节点建单成功且固定账密自动生成(`ngs_` 前缀)、部署不被拦(202)、agent 侧 job 资源 inbounds users 恰固定一组;零客户 VLESS Reality 部署不被拦、资源完整;绑定客户后订阅 raw 链接为 `socks5://<固定用户名>:<固定密码>@…`(密码 URL 编码);reality-keys 接口对 Reality 节点返回完整公私钥对、非 Reality 节点 400;扫描接口对 www.microsoft.com 返回结构完整的结果行(沙箱直连出网被代理拦截,ok=false 且 reason 为握手失败——见缺口),对本地 TLS 1.3 服务器返回 ok=true、tlsVersion=TLS 1.3、keyExchange=X25519、keyExchangeOk=true、certName 与延迟齐全。冒烟全项 PASS。
## 发布包
`~/workspace/NexusGate-release/amo-v3/`(CGO_ENABLED=0、-trimpath;面板构建前经 build.sh 刷新 embed_public):
| 文件 | SHA256 |
|---|---|
| nexusgate-panel-linux-amd64 | `5698b7f5861078e8244c4d558d5191408b32590fd3b699446f1b564de9d6fcd9` |
| nexusgate-panel-linux-arm64 | `57e7b5cd43fd0582bb0aa917a36734483fac3f46d6e9d61ce141d46473a4ff4b` |
| nexusgate-agent-linux-amd64 | `b0aedfdcafa5cde7051f6cce6eb82eea709628f5e060688828ab8d107ed9df92` |
| nexusgate-agent-linux-arm64 | `e8173e6712769184e87efd6c796129c12f3daf0c0127bfc629d758f412d8afa6` |
同目录附 `SHA256SUMS` 清单。
## 未决缺口(如实)
1. **稳态 CPU 未达个位数**:32.31%(旧 38.41%),主因是 agent 鉴权 scrypt 而非克隆,详见第 22 条节内归因;需另立项改鉴权,本批未动。
2. **扫描器真实域名成功路径未在沙箱验到**:沙箱直连 TCP 出网被运行环境代理拦截(curl 走代理正常、直连 TLS 全部 WRONG_VERSION 类失败,python 直连复测同结果),故 www.microsoft.com 只验到结构完整的失败行;成功路径已由本地 TLS 1.3 服务器经面板接口实测通过,真实域名待部署后在路由器网络复测。
3. **真机数字待复测**:接口耗时、切页体感、RSS 均为沙箱 amd64 数值;路由器(arm64 A53)部署后需按第 22 条验收口径用 devtools 复测。
4. **取舍记录**:`PublicNode` 未剥离 socksUser/socksPassword(管理员接口内,编辑表单回显需要);扫描器探测用 InsecureSkipVerify(只判可达性/密钥交换,理由见第 16 条)。
5. **未部署、未推 Gitea**:`aa2ee62` 热修与本批提交均只在本地;现网四台 sing-box 版本记录补写、节点重新部署等部署动作不在本批范围。
## 提交清单(aa2ee62 之后)
| 提交 | 内容 |
|---|---|
| `bb974e2` | 第 12 条 agent 核心版本兜底 |
| `2fafd85` | 第 20+21 条 先建后绑+SOCKS5 固定账号 |
| `80140f3` | 第 23 条 绑定合并下发(固定窗口) |
| `8189a51` | 第 15 条 凭据页签删绑定区块 |
| `a9d9867`+`4199a43` | 第 17 条 节点页 Reality 公私钥(+测试辅助改名) |
| `0ab31ae` | 第 16 条 Reality 目标扫描器 |
| `05366d9`+`6d97d4e` | 第 22 条 后端克隆消除+前端缓存/渐进渲染 |
| `0f74955` | 第 22 条 基准种子扩容到 20 台规模 |
| (收尾提交) | NEXT-ROUND-NOTES.md 原文(未改一字)+本报告 |
+44
View File
@@ -0,0 +1,44 @@
# amo v4 批报告(第 24、25 条)
- 基线:`18074da`(开工核对一致)
- 提交:`408b03e`(第 25 条)、`1e7ccd3`(第 24 条),文档收尾提交随本报告
- 范围口径:第 0 条 Go 单一真源——只改 `panel-go/`、`agent-go/`、共用前端 `public/`,Node 零改动
- 状态:**第 25 条完成、第 24 条完成**(完成状态只在此报告标注,NEXT-ROUND-NOTES 原文未删改,随本批一并提交)
- 未部署、未推 Gitea
## 第 25 条 agent 鉴权令牌化(提交 408b03e)
### 设计
- **签发**:agent 请求先按原路径鉴权——指纹(KeyFingerprint)定位记录后 `VerifySecret` 跑 scrypt——通过后才签发会话令牌。令牌为 `RandomToken(40)`,与 agent 密钥同一强度;每次密钥路径鉴权成功都会**轮换**签发新令牌(旧令牌即刻失效)。
- **存储(面板)**:agent 记录只存令牌的 SHA-256 哈希(`SessionTokenHash`,复用 `TokenFingerprint`)与过期时间(`SessionTokenExpiresAt`),明文不落库。两字段 omitempty,旧数据文件零迁移。
- **有效期**:**24 小时**。经响应头 `X-Agent-Session-Token` / `X-Agent-Session-Token-Expires` 下发。
- **校验**:后续请求带令牌作 bearer,面板走 `agentBySessionToken()`——`store.View` 内扫描、hex 解码后 `subtle.ConstantTimeCompare` 常时比对、校验记录 active 与过期时间。不再跑 scrypt。
- **回退**:令牌过期/哈希不符/记录吊销 → 该请求自动回退密钥路径(scrypt),成功后重签新令牌。agent 侧:收到 401 且本次用的是令牌时,清掉本地令牌、以密钥重试恰好一次(防循环);面板未签发(旧面板)时不写令牌文件、逐请求带密钥,行为与旧版一致。
- **吊销语义**:`(*Agent).Revoke()` 统一收口——置 revoked 并清空令牌两字段。三处调用:重新 enroll 时吊销同服务器旧 agent、`delete_server`、`force_forget_server`。全仓 `"revoked"` 字面量只剩 Revoke 内部一处,不存在遗漏的失效路径(agent 密钥本身从不在原记录上重置,重置只经重新 enroll 建新记录 + 吊销旧记录)。
- **存储(agent)**:`session-token.json` 放在既有状态目录(默认 `/etc/nexusgate/agent`,可经 `NG_SESSION_TOKEN_FILE` 覆盖),权限 0600、临时文件 + rename 原子落盘,与 keys.json 同规格。读取时带 1 分钟过期余量,过期直接用密钥。
- **强度不降级**:签发必须在 scrypt 验证通过之后;库里始终只有哈希(密钥哈希 + 令牌哈希);令牌绑定单条 agent 记录。
- **兼容**:旧 agent 不带令牌 → 继续逐请求 scrypt,功能不受影响;新 agent 连旧面板 → 无签发头,自动回退。agent 版本号保持 `0.6.8`。线格式不变,差异已登记 `agent-go/PARITY.md` 第 0 条。
### 改动点
面板(panel-go):
- `types.go`:`Agent` 新增 `SessionTokenHash` / `SessionTokenExpiresAt`(omitempty);新增 `(*Agent).Revoke()`。
- `handlers.go`:`AgentSessionTokenTTL = 24h`;`agentBySessionToken()`、`issueAgentSessionToken()`(Transaction 落盘、记录已非 active 则不签发)、`authenticateAgent()`(令牌路径 → 密钥路径;候选记录先从 View 拷出、scrypt 在锁外执行、成功后签发);`FindAgentByBearer` 保留为兼容 wrapper;handleAgent 在签发时设置两响应头;三处吊销改走 `Revoke()`。
- 新增 `agenttoken_test.go`:7 例(签发且只存哈希、篡改 KeyHash 后令牌仍可鉴权即令牌路径不跑 scrypt、过期被拒 + 密钥重签轮换、哈希不符被拒 + 重签、吊销后令牌与密钥双拒、令牌绑定本记录、纯密钥请求逐次轮换可用)。
agent(agent-go):
- `main.go`:`SessionTokenFile` 配置(env `NG_SESSION_TOKEN_FILE`);`agentSessionToken` + 懒加载状态(mutex);`currentSessionToken()`(1 分钟余量)、`storeSessionToken()`(0600 + rename)、`clearSessionToken()`;`request()` 拆为 `requestAttempt`(bearer 优先令牌、每响应检查签发头并落盘、401 用令牌时清令牌以密钥重试一次)。
- 新增 `sessiontoken_test.go`:5 例(fakePanel:捕获/落盘 0600/冷启重载/复用、401 回退 + 重签、旧面板无头时纯密钥、客户端已过期直接用密钥、令牌与密钥双拒时报错且恰好两次尝试)。
### 验收 ①:稳态 CPU(按 v3 同一测法复测)
测法:20 服务器种子(20 台 enrolled agent、40 节点、40 客户、约 160 绑定,数据文件 196KB,与现网 225KB 同量级);令牌感知驱动按现网节奏模拟 20 台 agent(poll 8s / heartbeat 30s / usage 60s、空 samples、错峰),无界面请求;预热 15s 后取 60s 窗口,按 `/proc/<pid>/stat` 的 utime+stime 差折算(100 ticks = 1 秒 CPU),同一台架、同一脚本、面板进程为脚本直系子进程采样。
| 二进制 | 稳态 CPU(单核 %) | 驱动侧证据 |
|---|---|---|
| 旧 `18074da`(本机同台架实测) | **28.98%** | 未签发令牌;请求延迟 74–254ms |
| 新 v4(HEAD) | **1.52%** | 恰好签发 20 个令牌、20/20 agent 转令牌路径;请求延迟 5–8ms;失败 0 |
- v3 报告的旧版数字为 32.31%(其台架种子构成略有差异),本批同台架复跑为 28.98%,量级一致;新版 **1
...[truncated 6135 chars]
+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 未改动、哈希不变。
+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`
+8
View File
@@ -132,6 +132,14 @@ using sing-box's own protobuf stubs. The Go agent keeps that exact design:
## Known divergences (all behavior-preserving)
0. **Panel session tokens (round-4 item 25, protocol superset)**: against a
panel that issues them, the Go agent persists the short-lived session
token (`session-token.json` in the config dir, 0600) and presents it as
the bearer instead of its key, falling back to the key on any 401. The
Node agent has no such concept; wire format (a Bearer value) and all
endpoints are unchanged, and panels without token support are
unaffected. Divergent by design — it exists to take scrypt off the
panel's per-request path.
1. **JSON key order inside resource files / combined config**: Go reserializes
nested objects with alphabetically sorted keys; JS preserves insertion
order. Semantically identical for sing-box and for the panel. Side effect:
+81
View File
@@ -199,6 +199,86 @@ type engineReport struct {
SingBoxInstalled bool `json:"singBoxInstalled"`
Certificates []string `json:"certificates"`
SingBoxStatus string `json:"singBoxStatus"`
SingBoxVersion string `json:"singBoxVersion,omitempty"`
}
// ---- sing-box version probe -------------------------------------------------
// The core version is probed by running `sing-box version` exactly once and
// cached against the binary's mtime+size. It is re-probed only when the file
// changes (an engine install/update replaces the binary) — the first probe
// happens on the first heartbeat after agent start. Steady-state heartbeats
// pay one stat, never a subprocess.
var singBoxVersionMu sync.Mutex
var singBoxVersionCache struct {
path string
mtimeNs int64
size int64
vfMtimeNs int64 // install-record file mtime (part of the cache key)
version string
probed bool
}
// parseSingBoxVersion extracts the version token from the first line of
// `sing-box version` output ("sing-box version 1.13.21"). Unstamped local
// builds print "unknown"; those report no version.
func parseSingBoxVersion(out string) string {
line := out
if i := strings.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
fields := strings.Fields(line)
if len(fields) >= 3 && fields[0] == "sing-box" && fields[1] == "version" && fields[2] != "unknown" {
return fields[2]
}
return ""
}
var singBoxVersionRe = regexp.MustCompile(`^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$`)
// installedSingBoxVersion reads the install record written by
// agent-singbox.sh (/etc/nexusgate/sing-box/version, content like
// "1.13.21"). It is the fallback for the locally built sing-box, whose
// binary carries no version stamp and reports "unknown" (round-3 item
// 12). A leading "v" is tolerated and stripped; anything that does not
// look like a version is ignored.
func installedSingBoxVersion() string {
data, err := os.ReadFile(cfg.SingBoxVersionFile)
if err != nil {
return ""
}
v := strings.TrimSpace(string(data))
v = strings.TrimPrefix(v, "v")
if !singBoxVersionRe.MatchString(v) {
return ""
}
return v
}
func singBoxVersion() string {
info, err := os.Stat(cfg.SingBoxBin)
if err != nil {
return ""
}
var vfMtimeNs int64 = -1
if vfInfo, err := os.Stat(cfg.SingBoxVersionFile); err == nil {
vfMtimeNs = vfInfo.ModTime().UnixNano()
}
singBoxVersionMu.Lock()
defer singBoxVersionMu.Unlock()
c := &singBoxVersionCache
if c.probed && c.path == cfg.SingBoxBin && c.mtimeNs == info.ModTime().UnixNano() && c.size == info.Size() && c.vfMtimeNs == vfMtimeNs {
return c.version
}
version := ""
if out, _, code := runCapture(5*time.Second, cfg.SingBoxBin, "version"); code == 0 {
version = parseSingBoxVersion(out)
}
if version == "" {
version = installedSingBoxVersion()
}
c.path, c.mtimeNs, c.size, c.vfMtimeNs, c.version, c.probed = cfg.SingBoxBin, info.ModTime().UnixNano(), info.Size(), vfMtimeNs, version, true
return version
}
// engineHealth mirrors agent.js engineHealth(). Callers must hold stateMu
@@ -214,6 +294,7 @@ func engineHealthLocked(resources []map[string]interface{}) engineReport {
if installed {
result.Status = service.Status
result.Detail = service.Detail
result.SingBoxVersion = singBoxVersion()
} else {
result.Status = "error"
result.Detail = "sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install"
+126 -2
View File
@@ -35,9 +35,11 @@ type config struct {
Root string // NG_CONFIG_DIR
ResourceDir string // Root + "/resources"
SingBoxConfig string
SingBoxVersionFile string
SingBoxLog string
SingBoxCursor string
KeyFile string
SessionTokenFile string
TLSDir string
APIPort string // NG_SINGBOX_API_PORT (string, as in JS)
PollMS int64
@@ -74,9 +76,11 @@ func loadConfig() config {
Root: root,
ResourceDir: root + "/resources",
SingBoxConfig: envOr("NG_SINGBOX_CONFIG", "/etc/nexusgate/sing-box/config.json"),
SingBoxVersionFile: envOr("NG_SINGBOX_VERSION_FILE", "/etc/nexusgate/sing-box/version"),
SingBoxLog: envOr("NG_SINGBOX_LOG", "/var/log/nexusgate/sing-box-access.log"),
SingBoxCursor: envOr("NG_SINGBOX_CURSOR", "/etc/nexusgate/sing-box-cursor.json"),
KeyFile: envOr("NG_KEY_FILE", "/etc/nexusgate/keys.json"),
SessionTokenFile: envOr("NG_SESSION_TOKEN_FILE", root+"/session-token.json"),
TLSDir: envOr("NG_TLS_DIR", "/etc/nexusgate/tls"),
APIPort: envOr("NG_SINGBOX_API_PORT", "10086"),
PollMS: int64(pollSec * 1000),
@@ -149,25 +153,145 @@ func fileExists(p string) bool {
return err == nil
}
// ---------- panel session token (item 25) ----------
//
// After one scrypt-verified key auth the panel issues a short-lived
// session token via the X-Agent-Session-Token response header. The
// agent persists it in its state dir (0600, same handling as
// keys.json) and presents it as the bearer on subsequent requests;
// the panel then authenticates with a fast hash compare instead of
// scrypt. Any 401 while presenting a token clears it and retries
// once with the agent key, which makes the panel issue a fresh
// token. Panels that predate session tokens never send the header,
// so the agent simply keeps authenticating with its key — the token
// layer is invisible in that direction too. This is a deliberate
// divergence from the frozen Node agent (protocol superset, wire
// compatible).
type agentSessionToken struct {
Token string `json:"token"`
ExpiresAt string `json:"expiresAt"`
}
var sessionTokenState struct {
mu sync.Mutex
loaded bool
value *agentSessionToken
}
func loadSessionTokenLocked() {
sessionTokenState.loaded = true
raw, err := os.ReadFile(cfg.SessionTokenFile)
if err != nil {
return
}
var tok agentSessionToken
if err := json.Unmarshal(raw, &tok); err == nil && tok.Token != "" {
v := tok
sessionTokenState.value = &v
}
}
// currentSessionToken returns the held token, or nil when none is
// stored or it is expired (a one-minute margin keeps a request from
// riding on a token that dies in flight).
func currentSessionToken() *agentSessionToken {
sessionTokenState.mu.Lock()
defer sessionTokenState.mu.Unlock()
if !sessionTokenState.loaded {
loadSessionTokenLocked()
}
if sessionTokenState.value == nil {
return nil
}
if exp, err := time.Parse(time.RFC3339, sessionTokenState.value.ExpiresAt); err == nil && time.Until(exp) < time.Minute {
return nil
}
tok := *sessionTokenState.value
return &tok
}
func storeSessionToken(token, expiresAt string) {
if token == "" {
return
}
sessionTokenState.mu.Lock()
defer sessionTokenState.mu.Unlock()
if !sessionTokenState.loaded {
loadSessionTokenLocked()
}
if sessionTokenState.value != nil && sessionTokenState.value.Token == token {
return
}
next := &agentSessionToken{Token: token, ExpiresAt: expiresAt}
sessionTokenState.value = next
body, err := json.Marshal(next)
if err != nil {
return
}
tmp := cfg.SessionTokenFile + ".tmp"
if err := os.WriteFile(tmp, append(body, '\n'), 0o600); err != nil {
logf("Session token persist failed %s", safeError(err.Error()))
return
}
if err := os.Rename(tmp, cfg.SessionTokenFile); err != nil {
logf("Session token persist failed %s", safeError(err.Error()))
return
}
_ = os.Chmod(cfg.SessionTokenFile, 0o600)
}
func clearSessionToken() {
sessionTokenState.mu.Lock()
defer sessionTokenState.mu.Unlock()
sessionTokenState.value = nil
sessionTokenState.loaded = true
if err := os.Remove(cfg.SessionTokenFile); err != nil && !os.IsNotExist(err) {
logf("Session token clear failed %s", safeError(err.Error()))
}
}
// request mirrors agent.js request(): POST with bearer auth, 20s timeout,
// JSON body best-effort parsed ({} on failure), error message taken from
// body.message or synthesized from the HTTP status.
// body.message or synthesized from the HTTP status. Bearer selection and
// the 401 retry follow the session-token rules above.
func request(endpoint string, body []byte) (map[string]interface{}, error) {
return requestAttempt(endpoint, body, true)
}
func requestAttempt(endpoint string, body []byte, mayRetry bool) (map[string]interface{}, error) {
bearer := cfg.AgentKey
usedToken := false
if tok := currentSessionToken(); tok != nil {
bearer = tok.Token
usedToken = true
}
req, err := http.NewRequest(http.MethodPost, cfg.Controller+endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+cfg.AgentKey)
req.Header.Set("Authorization", "Bearer "+bearer)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// A freshly issued token can ride on any authenticated response.
if issued := resp.Header.Get("X-Agent-Session-Token"); issued != "" {
storeSessionToken(issued, resp.Header.Get("X-Agent-Session-Token-Expires"))
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode == 401 && usedToken && mayRetry {
// Token rejected (expired or revoked server-side): drop it
// and retry once with the key; a valid key makes the panel
// issue a fresh token on that response.
clearSessionToken()
return requestAttempt(endpoint, body, false)
}
parsed := map[string]interface{}{}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &parsed) // .catch(() => ({})) in JS
+169
View File
@@ -0,0 +1,169 @@
package main
// Item 25: agent-side session token handling. A fake panel (httptest)
// issues tokens via response headers exactly like the real one; the
// tests pin down capture, persistence, reuse, the 401 fallback to the
// key, and old-panel behavior (no headers → key on every request).
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
// fakePanel records the bearer of every request and answers like the
// real panel: the key always authenticates (and issues issueToken
// when non-empty), the current valid token authenticates, anything
// else is a 401.
type fakePanel struct {
key string
validToken string
issueToken string
bearers []string
}
func (p *fakePanel) handler(w http.ResponseWriter, r *http.Request) {
bearer := ""
if h := r.Header.Get("Authorization"); len(h) > len("Bearer ") {
bearer = h[len("Bearer "):]
}
p.bearers = append(p.bearers, bearer)
switch bearer {
case p.key:
if p.issueToken != "" {
w.Header().Set("X-Agent-Session-Token", p.issueToken)
w.Header().Set("X-Agent-Session-Token-Expires", time.Now().Add(24*time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
}
w.WriteHeader(200)
fmt.Fprint(w, `{"ok":true}`)
case p.validToken:
w.WriteHeader(200)
fmt.Fprint(w, `{"ok":true}`)
default:
w.WriteHeader(401)
fmt.Fprint(w, `{"message":"Agent 身份校验失败"}`)
}
}
func setupTokenAgent(t *testing.T, panel *fakePanel) func() {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(panel.handler))
oldCfg := cfg
cfg.Controller = srv.URL
cfg.AgentKey = panel.key
cfg.SessionTokenFile = filepath.Join(t.TempDir(), "session-token.json")
sessionTokenState.mu.Lock()
oldState := sessionTokenState.value
oldLoaded := sessionTokenState.loaded
sessionTokenState.value = nil
sessionTokenState.loaded = false
sessionTokenState.mu.Unlock()
return func() {
srv.Close()
cfg = oldCfg
sessionTokenState.mu.Lock()
sessionTokenState.value = oldState
sessionTokenState.loaded = oldLoaded
sessionTokenState.mu.Unlock()
}
}
func TestSessionTokenCapturedPersistedAndReused(t *testing.T) {
panel := &fakePanel{key: "the-key", issueToken: "tok-1", validToken: "tok-1"}
defer setupTokenAgent(t, panel)()
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("first request: %v", err)
}
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("second request: %v", err)
}
want := []string{"the-key", "tok-1"}
if fmt.Sprint(panel.bearers) != fmt.Sprint(want) {
t.Fatalf("bearers = %v, want %v", panel.bearers, want)
}
// Persisted with keys.json-grade permissions.
info, err := os.Stat(cfg.SessionTokenFile)
if err != nil {
t.Fatalf("token file not persisted: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("token file perm = %o, want 600", perm)
}
// And it survives a cold start: reset memory, it must load from disk.
sessionTokenState.mu.Lock()
sessionTokenState.value = nil
sessionTokenState.loaded = false
sessionTokenState.mu.Unlock()
if tok := currentSessionToken(); tok == nil || tok.Token != "tok-1" {
t.Fatalf("token not reloaded from disk: %+v", tok)
}
}
func TestSessionToken401FallsBackToKeyAndReissues(t *testing.T) {
panel := &fakePanel{key: "the-key", issueToken: "tok-2", validToken: "tok-2"}
defer setupTokenAgent(t, panel)()
// Plant a stale token the panel no longer honors.
storeSessionToken("tok-stale", time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("request with stale token: %v", err)
}
want := []string{"tok-stale", "the-key"}
if fmt.Sprint(panel.bearers) != fmt.Sprint(want) {
t.Fatalf("bearers = %v, want %v (token attempt, then key retry)", panel.bearers, want)
}
if tok := currentSessionToken(); tok == nil || tok.Token != "tok-2" {
t.Fatalf("fresh token not stored after retry: %+v", tok)
}
}
func TestSessionTokenAbsentKeepsKeyAuth(t *testing.T) {
// Old panel: never issues tokens.
panel := &fakePanel{key: "the-key"}
defer setupTokenAgent(t, panel)()
for i := 0; i < 2; i++ {
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("request %d: %v", i, err)
}
}
if fmt.Sprint(panel.bearers) != "[the-key the-key]" {
t.Fatalf("bearers = %v, want key on every request", panel.bearers)
}
if _, err := os.Stat(cfg.SessionTokenFile); !os.IsNotExist(err) {
t.Fatalf("no token file expected against an old panel, stat err = %v", err)
}
}
func TestSessionTokenExpiredClientSideUsesKey(t *testing.T) {
panel := &fakePanel{key: "the-key", issueToken: "tok-3", validToken: "tok-3"}
defer setupTokenAgent(t, panel)()
storeSessionToken("tok-old", time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("request: %v", err)
}
if len(panel.bearers) != 1 || panel.bearers[0] != "the-key" {
t.Fatalf("bearers = %v, want the key used directly (no doomed token attempt)", panel.bearers)
}
}
func TestSessionTokenKeyRejectedSurfacesError(t *testing.T) {
panel := &fakePanel{key: "the-real-key"}
defer setupTokenAgent(t, panel)()
cfg.AgentKey = "wrong-key"
storeSessionToken("tok-stale", time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
if _, err := request("/api/agent/poll", []byte("{}")); err == nil {
t.Fatal("expected an error when both token and key are rejected")
}
// Exactly two attempts: token, then key — never a loop.
if len(panel.bearers) != 2 {
t.Fatalf("attempts = %v, want exactly 2", panel.bearers)
}
if tok := currentSessionToken(); tok != nil {
t.Fatalf("stale token should have been cleared, still held: %+v", tok)
}
}
+124
View File
@@ -0,0 +1,124 @@
package main
// Tests for the sing-box version probe (round-2 item 4): parsing of
// `sing-box version` output and the mtime/size-keyed cache that keeps
// heartbeats from spawning the binary every round.
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseSingBoxVersion(t *testing.T) {
cases := []struct{ in, want string }{
{"sing-box version 1.13.21\n\nEnvironment: go1.24.6 linux/amd64\n", "1.13.21"},
{"sing-box version 1.13.21", "1.13.21"},
{"sing-box version unknown\n", ""},
{"", ""},
{"garbage output\n", ""},
{"sing-box version\n", ""},
}
for _, c := range cases {
if got := parseSingBoxVersion(c.in); got != c.want {
t.Fatalf("parseSingBoxVersion(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func writeFakeSingBox(t *testing.T, dir, version string) (binPath, countPath string) {
t.Helper()
binPath = filepath.Join(dir, "fake-sing-box")
countPath = filepath.Join(dir, "count")
script := "#!/bin/sh\necho x >> \"" + countPath + "\"\necho 'sing-box version " + version + "'\n"
if err := os.WriteFile(binPath, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return binPath, countPath
}
func TestSingBoxVersionProbeCachesByMtime(t *testing.T) {
dir := t.TempDir()
binPath, countPath := writeFakeSingBox(t, dir, "1.2.3")
oldBin := cfg.SingBoxBin
cfg.SingBoxBin = binPath
defer func() { cfg.SingBoxBin = oldBin }()
singBoxVersionCache.probed = false
defer func() { singBoxVersionCache.probed = false }()
count := func() int {
data, _ := os.ReadFile(countPath)
return strings.Count(string(data), "x")
}
if got := singBoxVersion(); got != "1.2.3" {
t.Fatalf("first probe = %q, want 1.2.3", got)
}
if got := singBoxVersion(); got != "1.2.3" {
t.Fatalf("cached probe = %q, want 1.2.3", got)
}
if n := count(); n != 1 {
t.Fatalf("binary executed %d times across two heartbeats, want 1 (cached)", n)
}
// Replacing the binary (engine install/update) must trigger one re-probe.
writeFakeSingBox(t, dir, "1.2.4")
if got := singBoxVersion(); got != "1.2.4" {
t.Fatalf("probe after binary replacement = %q, want 1.2.4", got)
}
if n := count(); n != 2 {
t.Fatalf("binary executed %d times after replacement, want 2", n)
}
}
// Round-3 item 12: when the binary reports "unknown" (the locally built
// sing-box has no version stamp), the probe falls back to the install
// record written by agent-singbox.sh.
func TestSingBoxVersionFallsBackToInstallRecord(t *testing.T) {
dir := t.TempDir()
binPath, _ := writeFakeSingBox(t, dir, "unknown")
recordPath := filepath.Join(dir, "version")
if err := os.WriteFile(recordPath, []byte("1.13.21\n"), 0o644); err != nil {
t.Fatal(err)
}
oldBin, oldRecord := cfg.SingBoxBin, cfg.SingBoxVersionFile
cfg.SingBoxBin, cfg.SingBoxVersionFile = binPath, recordPath
defer func() { cfg.SingBoxBin, cfg.SingBoxVersionFile = oldBin, oldRecord }()
singBoxVersionCache.probed = false
defer func() { singBoxVersionCache.probed = false }()
if got := singBoxVersion(); got != "1.13.21" {
t.Fatalf("fallback probe = %q, want 1.13.21", got)
}
// A stamped binary wins over the record.
writeFakeSingBox(t, dir, "1.13.22")
if got := singBoxVersion(); got != "1.13.22" {
t.Fatalf("stamped probe = %q, want 1.13.22 (record must not shadow it)", got)
}
// A garbage record is ignored.
if err := os.WriteFile(recordPath, []byte("not-a-version\n"), 0o644); err != nil {
t.Fatal(err)
}
writeFakeSingBox(t, dir, "unknown")
if got := singBoxVersion(); got != "" {
t.Fatalf("garbage record: got %q, want empty", got)
}
if got := installedSingBoxVersion(); got != "" {
t.Fatalf("installedSingBoxVersion(garbage) = %q, want empty", got)
}
if err := os.WriteFile(recordPath, []byte("v1.13.21\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := installedSingBoxVersion(); got != "1.13.21" {
t.Fatalf("installedSingBoxVersion(v-prefixed) = %q, want 1.13.21", got)
}
}
func TestSingBoxVersionMissingBinary(t *testing.T) {
oldBin := cfg.SingBoxBin
cfg.SingBoxBin = filepath.Join(t.TempDir(), "does-not-exist")
defer func() { cfg.SingBoxBin = oldBin }()
if got := singBoxVersion(); got != "" {
t.Fatalf("missing binary: got %q, want empty", got)
}
}
+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);
const server = assertFound(data.servers.find((s) => s.id === node.serverId), '服务器');
const exitServer = direct ? null : assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器');
for (const target of direct ? [server] : [server, exitServer]) {
if (target.enabled === false) { const error = new Error(`设备 ${target.name} 已停用`); error.statusCode = 409; throw error; }
}
if (!activeNodeClients(data, node).length) { const error = new Error('请先给节点分配至少一个客户再部署'); error.statusCode = 400; throw error; }
if (['vless-ws-tls', 'hysteria2', 'anytls'].includes(node.protocol)) {
if (!server.tlsDomain) { const error = new Error(`${node.protocol} 需要先填写设备 TLS 域名并在节点上安装证书`); error.statusCode = 400; throw error; }
@@ -281,6 +284,8 @@ function validateNodeDeploy(data, node) {
// gates on "no in-flight jobs" instead and passes failInFlight: false.
async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null, failInFlight = true } = {}) {
const server = assertFound(data.servers.find((item) => item.id === serverId), '服务器');
// A disabled device receives no new deployments, including reconciles.
if (server.enabled === false) return 0;
if (failInFlight) {
for (const job of data.jobs.filter((item) => item.serverId === serverId && ['queued', 'running'].includes(item.status))) {
job.status = 'failed'; job.error = 'Agent 重新注册,旧任务已由对账任务替代'; job.updatedAt = nowIso();
@@ -314,6 +319,90 @@ async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null,
return count;
}
// Data-level assign/unassign (the transaction-free cores of the
// Orchestrator methods) so the customer form's node alignment can run
// inside the customer create/PATCH transaction.
async function assignCustomerData(data, nodeId, customerId, actor = 'admin') {
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const customer = assertFound(data.customers.find((c) => c.id === customerId), '客户');
if (customer.status !== 'active') { const error = new Error('只能给状态正常的客户分配节点'); error.statusCode = 409; throw error; }
let nc = data.nodeClients.find((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (nc && nc.status === 'active') { const error = new Error('该客户已分配到此节点'); error.statusCode = 409; throw error; }
if (nc) {
nc.status = 'active'; nc.updatedAt = nowIso();
} else {
nc = {
id: id('ncl'), nodeId, customerId, credentials: newNodeClientCredentials(node.protocol),
status: 'active', createdAt: nowIso(), updatedAt: nowIso(),
};
data.nodeClients.push(nc);
}
// A manually undeployed node stays draft until explicitly deployed.
// Automatic teardowns (suspend) are revived when clients return.
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) {
// record the assignment only
} else {
if (node.status === 'removing' && node.autoRemoving) {
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
node.autoRemoving = false;
}
await rebuildNode(data, node);
}
audit(data, actor, 'assign_customer', node.id, { customerId, customer: customer.name });
return structuredClone(nc);
}
async function unassignCustomerData(data, nodeId, customerId, actor = 'admin') {
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const index = data.nodeClients.findIndex((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (index < 0) { const error = new Error('该客户未分配到此节点'); error.statusCode = 404; throw error; }
data.nodeClients.splice(index, 1);
if (node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node);
audit(data, actor, 'unassign_customer', node.id, { customerId });
return true;
}
// Full-set alignment used by the customer form's 关联入站 multi-select:
// bindings not in desiredIds are removed, missing ones are created.
// Suspended customers get suspended bindings (mirroring their resource
// state) without touching node resources.
async function alignCustomerNodes(data, customer, desiredIds, actor = 'admin') {
const desired = [...new Set((desiredIds || []).map(String).filter(Boolean))];
for (const nodeId of desired) {
const node = data.nodes.find((n) => n.id === nodeId && !n.pendingDelete);
if (!node) { const error = new Error('节点不存在'); error.statusCode = 400; throw error; }
}
const current = data.nodeClients.filter((nc) => nc.customerId === customer.id);
const rebuild = new Set();
for (const nc of [...current]) {
if (desired.includes(nc.nodeId)) continue;
data.nodeClients.splice(data.nodeClients.indexOf(nc), 1);
audit(data, actor, 'unassign_customer', nc.nodeId, { customerId: customer.id });
rebuild.add(nc.nodeId);
}
for (const nodeId of desired) {
if (current.some((nc) => nc.nodeId === nodeId)) continue;
const node = data.nodes.find((n) => n.id === nodeId);
const nc = {
id: id('ncl'), nodeId, customerId: customer.id, credentials: newNodeClientCredentials(node.protocol),
status: customer.status === 'active' ? 'active' : 'suspended', createdAt: nowIso(), updatedAt: nowIso(),
};
data.nodeClients.push(nc);
audit(data, actor, 'assign_customer', node.id, { customerId: customer.id, customer: customer.name });
if (nc.status === 'active') rebuild.add(nodeId);
}
for (const nodeId of rebuild) {
const node = data.nodes.find((n) => n.id === nodeId);
if (!node) continue;
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) continue;
if (node.status === 'removing' && node.autoRemoving) {
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
node.autoRemoving = false;
}
await rebuildNode(data, node);
}
}
class Orchestrator {
constructor(store) {
this.store = store;
@@ -365,47 +454,11 @@ class Orchestrator {
}
async assignCustomer(nodeId, customerId, actor = 'admin') {
return this.store.transaction(async (data) => {
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const customer = assertFound(data.customers.find((c) => c.id === customerId), '客户');
if (customer.status !== 'active') { const error = new Error('只能给状态正常的客户分配节点'); error.statusCode = 409; throw error; }
let nc = data.nodeClients.find((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (nc && nc.status === 'active') { const error = new Error('该客户已分配到此节点'); error.statusCode = 409; throw error; }
if (nc) {
nc.status = 'active'; nc.updatedAt = nowIso();
} else {
nc = {
id: id('ncl'), nodeId, customerId, credentials: newNodeClientCredentials(node.protocol),
status: 'active', createdAt: nowIso(), updatedAt: nowIso(),
};
data.nodeClients.push(nc);
}
// A manually undeployed node stays draft until explicitly deployed.
// Automatic teardowns (suspend) are revived when clients return.
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) {
// record the assignment only
} else {
if (node.status === 'removing' && node.autoRemoving) {
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
node.autoRemoving = false;
}
await rebuildNode(data, node);
}
audit(data, actor, 'assign_customer', node.id, { customerId, customer: customer.name });
return structuredClone(nc);
});
return this.store.transaction(async (data) => assignCustomerData(data, nodeId, customerId, actor));
}
async unassignCustomer(nodeId, customerId, actor = 'admin') {
return this.store.transaction(async (data) => {
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const index = data.nodeClients.findIndex((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (index < 0) { const error = new Error('该客户未分配到此节点'); error.statusCode = 404; throw error; }
data.nodeClients.splice(index, 1);
if (node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node);
audit(data, actor, 'unassign_customer', node.id, { customerId });
return true;
});
return this.store.transaction(async (data) => unassignCustomerData(data, nodeId, customerId, actor));
}
async reconcileServer(serverId, actor = 'system', options = {}) {
@@ -516,8 +569,27 @@ class Orchestrator {
const key = `srv:${serverId}`;
customer.serverUsage = customer.serverUsage || {};
const slot = customer.serverUsage[key] || (customer.serverUsage[key] = {});
const prevCounters = slot.usageCounters;
const prevSampleAt = slot.lastSampleAt;
const delta = applyDelta(slot, uplink, downlink, sampleEpoch);
if (!delta) continue;
slot.lastSampleAt = reportedAt;
// Presence + approximate speed from the same delta (no extra
// sampling channel): a positive delta is activity; speed is the
// delta over the gap since the previous processed sample.
const restarted = Boolean(sampleEpoch && prevCounters && prevCounters.epoch && sampleEpoch !== prevCounters.epoch);
if (delta.upDelta + delta.downDelta > 0) {
customer.lastOnlineAt = reportedAt;
slot.lastActiveAt = reportedAt;
}
if (prevSampleAt && !restarted) {
const dt = (Date.parse(reportedAt) - Date.parse(prevSampleAt)) / 1000;
if (dt >= 5) {
slot.speedUpBps = Math.round(delta.upDelta / dt);
slot.speedDownBps = Math.round(delta.downDelta / dt);
slot.speedAt = reportedAt;
}
}
customer.usedUplinkBytes = (customer.usedUplinkBytes || 0) + delta.upDelta;
customer.usedDownlinkBytes = (customer.usedDownlinkBytes || 0) + delta.downDelta;
customer.usedBytes = (customer.usedBytes || 0) + delta.upDelta + delta.downDelta;
@@ -528,8 +600,21 @@ class Orchestrator {
// transport and is never double counted (same rule as before).
const node = data.nodes.find((item) => item.id === sample.id && item.serverId === serverId);
if (!node) continue;
const prevCounters = node.usageCounters;
const prevSampleAt = node.lastSampleAt;
const delta = applyDelta(node, uplink, downlink, sampleEpoch);
if (!delta) continue;
node.lastSampleAt = reportedAt;
const restarted = Boolean(sampleEpoch && prevCounters && prevCounters.epoch && sampleEpoch !== prevCounters.epoch);
if (delta.upDelta + delta.downDelta > 0) node.lastActiveAt = reportedAt;
if (prevSampleAt && !restarted) {
const dt = (Date.parse(reportedAt) - Date.parse(prevSampleAt)) / 1000;
if (dt >= 5) {
node.speedUpBps = Math.round(delta.upDelta / dt);
node.speedDownBps = Math.round(delta.downDelta / dt);
node.speedAt = reportedAt;
}
}
const metered = node.meteredTraffic || { uplink: 0, downlink: 0 };
node.meteredTraffic = { uplink: metered.uplink + delta.upDelta, downlink: metered.downlink + delta.downDelta };
node.lastUsageAt = reportedAt;
@@ -541,4 +626,5 @@ class Orchestrator {
module.exports = { Orchestrator, allocatePort, selectPort, nodePortInUse, queueJob, audit, id, nowIso, cancelQueuedDeletes, resourceForNode,
resourceForNodeExit, rebuildNode, applyDelta, forwardNode, activeNodeClients,
suspendCustomerResources, resumeCustomerResources, HEARTBEAT_RECONCILE_INTERVAL_MS };
suspendCustomerResources, resumeCustomerResources, HEARTBEAT_RECONCILE_INTERVAL_MS,
assignCustomerData, unassignCustomerData, alignCustomerNodes };
+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,
createdAt: new Date().toISOString(),
settings: {
brandName: 'NexusGate',
brandName: 'amo',
sessionHours: 12,
observationTtlMinutes: 10,
activityRetentionDays: 30,
+54 -3
View File
@@ -1,6 +1,12 @@
'use strict';
const { buildClientUri, nodeEmail } = require('./protocols');
const { dedupKey, withName, nameFromUri } = require('./extsub');
function nodeSortIndex(data, nodeId) {
const node = data.nodes.find((item) => item.id === nodeId);
return node && Number.isFinite(node.sortIndex) ? node.sortIndex : Number.MAX_SAFE_INTEGER;
}
// Client URLs are only issued for confirmed, running nodes. Each link is built
// on the fly from the node's current config and the customer's own credentials
@@ -22,7 +28,11 @@ function nodeClientUri(data, nodeClient) {
function entries(data, customerId) {
const seen = new Map();
const items = [];
for (const nodeClient of data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active')) {
// Own nodes follow the panel-wide custom order (sortIndex); the sort is
// stable, so equal/unset indexes keep the binding creation order.
const bindings = data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active');
bindings.sort((a, b) => nodeSortIndex(data, a.nodeId) - nodeSortIndex(data, b.nodeId));
for (const nodeClient of bindings) {
const uri = nodeClientUri(data, nodeClient);
if (!uri) continue;
const node = data.nodes.find((item) => item.id === nodeClient.nodeId);
@@ -38,6 +48,47 @@ function entries(data, customerId) {
return items;
}
// Full subscription item list: own nodes (sortIndex order), then manual
// links (list order), then external sources (source order, cached order).
// Global dedup by scheme+host+port+credential fingerprint, own first.
// Name collisions across segments get the " (n)" suffix treatment.
function aggregateEntries(data, customer) {
const items = entries(data, customer.id);
const usedKeys = new Set();
const usedNames = new Set();
for (const item of items) {
const key = dedupKey(item.clientUri);
if (key) usedKeys.add(key);
usedNames.add(nameFromUri(item.clientUri));
}
const append = (uri, rawName) => {
const key = dedupKey(uri);
if (key && usedKeys.has(key)) return;
let name = rawName || '未命名节点';
if (usedNames.has(name)) {
let index = 2;
while (usedNames.has(`${name} (${index})`)) index += 1;
name = `${name} (${index})`;
}
if (key) usedKeys.add(key);
usedNames.add(name);
items.push({ nodeId: '', clientUri: withName(uri, name) });
};
(customer.manualLinks || []).forEach((link, index) => {
if (!link.enabled) return;
append(link.uri, link.remark || nameFromUri(link.uri) || `手动节点 ${index + 1}`);
});
for (const source of customer.externalSources || []) {
if (!source.enabled) continue;
const cache = (data.subSourceCache || {})[source.id];
if (!cache || !Array.isArray(cache.items)) continue;
for (const cached of cache.items) {
append(cached.uri, `${source.remark ? `[${source.remark}] ` : ''}${cached.name || '未命名节点'}`);
}
}
return items;
}
function q(value) { return JSON.stringify(String(value)); }
function clashProxy(item) {
@@ -149,7 +200,7 @@ function makeSingBox(items) {
}
function formatSubscription(data, customer, format, userAgent = '') {
const items = entries(data, customer.id);
const items = aggregateEntries(data, customer);
if (!items.length) return null;
const selected = format === 'auto' ? (/clash|mihomo/i.test(userAgent) ? 'clash' : 'base64') : format;
if (selected === 'raw') return { body:items.map((item) => item.clientUri).join('\n') + '\n', contentType:'text/plain; charset=utf-8' };
@@ -160,4 +211,4 @@ function formatSubscription(data, customer, format, userAgent = '') {
return undefined;
}
module.exports = { entries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };
module.exports = { entries, aggregateEntries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };
+189
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 };
+255
View File
@@ -0,0 +1,255 @@
package main
// Item 25: agent session tokens. After one scrypt-verified key auth the
// panel issues a random session token (only its SHA-256 hash is stored);
// subsequent requests authenticate by hash compare. These tests drive
// the real /api/agent/heartbeat route through the smoke harness.
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
)
const testAgentKey = "test-agent-key-0123456789abcdef-XYZ"
func seedAgentApp(t *testing.T, key string) (*App, string) {
t.Helper()
cookieSecure = false
store := NewStore(filepath.Join(t.TempDir(), "nexusgate.json"))
if err := store.Init(); err != nil {
t.Fatalf("store init: %v", err)
}
fp := TokenFingerprint(key)
agentID := ""
_, err := store.Transaction(func(d *Data) (any, error) {
d.Servers = append(d.Servers, &Server{
ID: "srv_tok", Name: "tok", Status: "online",
CreatedAt: NowISO(), UpdatedAt: NowISO(),
})
agent := &Agent{
ID: NewID("agt"),
ServerID: "srv_tok",
KeyHash: HashSecret(key),
KeyFingerprint: &fp,
Status: "active",
CreatedAt: NowISO(),
LastSeenAt: "2000-01-01T00:00:00.000Z",
}
agentID = agent.ID
d.Agents = append(d.Agents, agent)
return nil, nil
})
if err != nil {
t.Fatalf("seed agent: %v", err)
}
return NewApp(store, NewSessionManager(12), NewOrchestrator(store)), agentID
}
func agentHeartbeat(t *testing.T, app *App, bearer string) *httptest.ResponseRecorder {
t.Helper()
return doRequest(app, http.MethodPost, "/api/agent/heartbeat", "{}", map[string]string{
"Authorization": "Bearer " + bearer,
})
}
func storedAgent(t *testing.T, app *App, agentID string) *Agent {
t.Helper()
var out *Agent
app.store.View(func(d *Data) {
for _, agent := range d.Agents {
if agent.ID == agentID {
copied := *agent
out = &copied
}
}
})
if out == nil {
t.Fatalf("agent %s not in store", agentID)
}
return out
}
func mutateAgent(t *testing.T, app *App, agentID string, fn func(*Agent)) {
t.Helper()
_, err := app.store.Transaction(func(d *Data) (any, error) {
for _, agent := range d.Agents {
if agent.ID == agentID {
fn(agent)
}
}
return nil, nil
})
if err != nil {
t.Fatalf("mutate agent: %v", err)
}
}
// Key auth succeeds and issues a session token whose hash (and only the
// hash) lands on the agent record.
func TestAgentSessionTokenIssuedOnKeyAuth(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 {
t.Fatalf("key heartbeat: got %d (%s)", rec.Code, rec.Body.String())
}
token := rec.Header().Get(agentSessionTokenHeader)
expires := rec.Header().Get(agentSessionTokenExpiresHeader)
if token == "" || expires == "" {
t.Fatalf("no session token issued: token=%q expires=%q", token, expires)
}
if token == testAgentKey {
t.Fatal("session token must differ from the agent key")
}
stored := storedAgent(t, app, agentID)
if stored.SessionTokenHash == nil || *stored.SessionTokenHash != TokenFingerprint(token) {
t.Fatalf("stored hash does not match issued token: %+v", stored.SessionTokenHash)
}
if stored.SessionTokenExpiresAt == nil || *stored.SessionTokenExpiresAt != expires {
t.Fatalf("stored expiry %v != header expiry %q", stored.SessionTokenExpiresAt, expires)
}
expMs, ok := ParseISOMs(expires)
if !ok || expMs <= time.Now().UnixMilli() {
t.Fatalf("issued expiry not in the future: %q", expires)
}
}
// Token auth succeeds even when the key hash can no longer verify —
// proving the token path never touches scrypt — and does not re-issue.
func TestAgentSessionTokenAuthSkipsScrypt(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
if token == "" {
t.Fatal("setup: no token issued")
}
// Sabotage the key hash: any scrypt verification now fails.
mutateAgent(t, app, agentID, func(agent *Agent) {
agent.KeyHash = "scrypt:deadbeef:abcd"
})
rec := agentHeartbeat(t, app, token)
if rec.Code != 200 {
t.Fatalf("token heartbeat with sabotaged key hash: got %d", rec.Code)
}
if again := rec.Header().Get(agentSessionTokenHeader); again != "" {
t.Fatalf("token path must not re-issue, got %q", again)
}
// And the (now unverifiable) key is rejected.
if rec := agentHeartbeat(t, app, testAgentKey); rec.Code != 401 {
t.Fatalf("sabotaged key: got %d, want 401", rec.Code)
}
}
// An expired token is rejected; the key still works and re-issues a
// fresh token (rotation).
func TestAgentSessionTokenExpiryFallsBackToKey(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
mutateAgent(t, app, agentID, func(agent *Agent) {
past := ISOFromTime(time.Now().Add(-time.Hour))
agent.SessionTokenExpiresAt = &past
})
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
t.Fatalf("expired token: got %d, want 401", rec.Code)
}
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 {
t.Fatalf("key after expiry: got %d", rec.Code)
}
fresh := rec.Header().Get(agentSessionTokenHeader)
if fresh == "" || fresh == token {
t.Fatalf("expected a fresh token after expiry, got %q", fresh)
}
if rec := agentHeartbeat(t, app, fresh); rec.Code != 200 {
t.Fatalf("fresh token: got %d", rec.Code)
}
}
// A token whose stored hash no longer matches (tampered / rotated
// server-side) is rejected; the key re-issues.
func TestAgentSessionTokenHashMismatchFallsBack(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
mutateAgent(t, app, agentID, func(agent *Agent) {
wrong := TokenFingerprint("some-other-token")
agent.SessionTokenHash = &wrong
})
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
t.Fatalf("mismatched token: got %d, want 401", rec.Code)
}
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 || rec.Header().Get(agentSessionTokenHeader) == "" {
t.Fatalf("key re-issue after mismatch: code=%d token=%q", rec.Code, rec.Header().Get(agentSessionTokenHeader))
}
}
// Revoking the agent kills both the token and the key immediately, and
// Revoke() clears the stored token material.
func TestAgentSessionTokenRevokedAgentRejected(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
mutateAgent(t, app, agentID, func(agent *Agent) { agent.Revoke() })
stored := storedAgent(t, app, agentID)
if stored.SessionTokenHash != nil || stored.SessionTokenExpiresAt != nil {
t.Fatal("Revoke() must clear session token fields")
}
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
t.Fatalf("revoked agent token: got %d, want 401", rec.Code)
}
if rec := agentHeartbeat(t, app, testAgentKey); rec.Code != 401 {
t.Fatalf("revoked agent key: got %d, want 401", rec.Code)
}
}
// The token is bound to its own agent record: it must not authenticate
// a request as a different agent (observed via which record's
// LastSeenAt the heartbeat advances).
func TestAgentSessionTokenBoundToRecord(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
otherKey := "other-agent-key-9876543210"
otherFP := TokenFingerprint(otherKey)
otherID := ""
_, err := app.store.Transaction(func(d *Data) (any, error) {
other := &Agent{
ID: NewID("agt"), ServerID: "srv_tok",
KeyHash: HashSecret(otherKey), KeyFingerprint: &otherFP,
Status: "active", CreatedAt: NowISO(),
LastSeenAt: "2000-01-01T00:00:00.000Z",
}
otherID = other.ID
d.Agents = append(d.Agents, other)
return nil, nil
})
if err != nil {
t.Fatalf("seed second agent: %v", err)
}
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
if rec := agentHeartbeat(t, app, token); rec.Code != 200 {
t.Fatalf("token heartbeat: got %d", rec.Code)
}
if got := storedAgent(t, app, agentID).LastSeenAt; got == "2000-01-01T00:00:00.000Z" {
t.Fatal("token heartbeat did not advance its own agent's LastSeenAt")
}
if got := storedAgent(t, app, otherID).LastSeenAt; got != "2000-01-01T00:00:00.000Z" {
t.Fatalf("token heartbeat advanced the WRONG agent (LastSeenAt=%s)", got)
}
}
// Key-only agents (pre-token clients) keep working request after
// request; each key auth rotates the stored token.
func TestAgentKeyOnlyRequestsKeepWorking(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
var prevHash string
for i := 0; i < 3; i++ {
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 {
t.Fatalf("key heartbeat %d: got %d", i, rec.Code)
}
stored := storedAgent(t, app, agentID)
if stored.SessionTokenHash == nil || *stored.SessionTokenHash == prevHash {
t.Fatalf("key heartbeat %d did not rotate the token", i)
}
prevHash = *stored.SessionTokenHash
}
}
+92
View File
@@ -0,0 +1,92 @@
package main
// Item 22: the reflective Clone must be a perfect substitute for the
// JSON round-trip clone — byte-identical serialization on the real
// seed database, and fully independent of the original afterwards.
import (
"bytes"
"encoding/json"
"os"
"testing"
)
func TestCloneEquivalenceOnSeed(t *testing.T) {
raw, err := os.ReadFile("difftest/seed.json")
if err != nil {
t.Fatal(err)
}
var d Data
if err := unmarshalJSONUseNumber(raw, &d); err != nil {
t.Fatal(err)
}
fast := d.Clone()
slow := d.cloneViaJSON()
fastJSON, err := marshalJSON(fast)
if err != nil {
t.Fatal(err)
}
slowJSON, err := marshalJSON(slow)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fastJSON, slowJSON) {
t.Fatalf("clone mismatch:\nfast: %.400s\nslow: %.400s", fastJSON, slowJSON)
}
origJSON, err := marshalJSON(&d)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fastJSON, origJSON) {
t.Fatal("clone must serialize identically to the source data")
}
// Deep independence: mutating the clone at several depths must not
// leak into the source.
if len(fast.Customers) > 0 {
fast.Customers[0].Name = "MUTATED"
if fast.Customers[0].Credentials != nil {
fast.Customers[0].Credentials.RelayPassword = "MUTATED"
}
}
if len(fast.Nodes) > 0 {
fast.Nodes[0].Name = "MUTATED"
}
for key := range fast.KeyStore {
fast.KeyStore[key].PrivateKey = "MUTATED"
break
}
afterJSON, err := marshalJSON(&d)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(afterJSON, origJSON) {
t.Fatal("clone mutation leaked into the source data")
}
}
func TestCloneEquivalenceSynthetic(t *testing.T) {
d := perfSeedData()
// Add the shapes the perf seed lacks: activity detail with nested
// generic values, keyStore entries, unknown (Extra) fields.
d.Activity = []*Activity{{ID: "act_1", At: NowISO(), Actor: "admin", Action: "x", Target: "y",
Detail: map[string]any{"n": json.Number("42"), "nested": map[string]any{"list": []any{"a", json.Number("1.5"), true, nil}}}}}
d.KeyStore["reality-nod_0"] = &KeyEntry{PrivateKey: "priv", PublicKey: "pub", UpdatedAt: NowISO()}
fastJSON, err := marshalJSON(d.Clone())
if err != nil {
t.Fatal(err)
}
slowJSON, err := marshalJSON(d.cloneViaJSON())
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fastJSON, slowJSON) {
t.Fatalf("synthetic clone mismatch:\nfast: %.300s\nslow: %.300s", fastJSON, slowJSON)
}
if d.Clone() == nil || EmptyData().Clone() == nil {
t.Fatal("clone of empty data must be non-nil")
}
var nilData *Data
if nilData.Clone() != nil {
t.Fatal("clone of nil must be nil")
}
}
+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
// ---------------------------------------------------------------------------
const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc'];
const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc', 'src', 'lnk'];
const PREFIXED_ID_RE = new RegExp(`\\b(${ID_PREFIXES.join('|')})_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`, 'g');
const BARE_UUID_RE = /(?<![0-9A-Za-z_-])[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![0-9A-Za-z_-])/g;
const ISO_TS_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})/g;
@@ -392,11 +392,30 @@ export class Normalizer {
if (this.panelOrigin) out = out.split(this.panelOrigin).join('<panel>');
// Learned secrets first (longest raw first so a full token wins over its
// own prefixes), including the 16-char prefix shown on the info page.
const secrets = [...this.secrets.entries()].sort((a, b) => b[0].length - a[0].length);
for (const [raw, placeholder] of secrets) {
if (raw.length >= 8) out = out.split(raw).join(placeholder);
if (raw.length > 16) out = out.split(raw.slice(0, 16)).join(placeholder);
// A base64 secret's prefix truncations (the SS2022 user keys derived
// from a customer's 32-byte root key) are the same logical secret, so
// their encodings are replaced with the same placeholder.
const replacements = [];
for (const [raw, placeholder] of this.secrets) {
if (raw.length >= 8) replacements.push([raw, placeholder]);
if (raw.length > 16) replacements.push([raw.slice(0, 16), placeholder]);
if (/^[A-Za-z0-9+/]+={0,2}$/.test(raw) && raw.length % 4 === 0) {
const bytes = Buffer.from(raw, 'base64');
if (bytes.toString('base64') === raw) {
for (const n of [16, 24]) {
if (n < bytes.length) replacements.push([bytes.subarray(0, n).toString('base64'), placeholder]);
}
}
}
}
// The Surge formatter mangles '=' to '_' inside passwords, so the
// mangled variant of every replacement is the same secret too.
for (const [raw, placeholder] of [...replacements]) {
const mangled = raw.replace(/=/g, '_');
if (mangled !== raw) replacements.push([mangled, placeholder]);
}
replacements.sort((a, b) => b[0].length - a[0].length);
for (const [raw, placeholder] of replacements) out = out.split(raw).join(placeholder);
for (const [raw, placeholder] of this.hashes) out = out.split(raw).join(placeholder);
out = out.replace(PREFIXED_ID_RE, (whole, prefix) => this.#idPlaceholder(prefix, whole));
for (const [fragment, placeholder] of this.fragments) out = out.split(fragment).join(placeholder);
@@ -424,6 +443,10 @@ export class Normalizer {
return out;
}
if (typeof value === 'number') {
// Speeds are derived from adjacent usage reports over wall-clock
// time (dt differs per side by construction), so only their shape
// is comparable: positive vs zero. See UI-RESKIN-REPORT.md.
if (keyName === 'speedUpBps' || keyName === 'speedDownBps') return value > 0 ? '<speed+>' : 0;
if (keyName && PORT_FIELDS.has(keyName) && this.ports.has(value)) return this.ports.get(value);
return value;
}
+199
View File
@@ -21,8 +21,11 @@
// executed and the first differing JSON path is reported per step.
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
import {
ADMIN_USERNAME, DEFAULT_GO_BIN, DIFFTEST_DIR, Normalizer, PanelClient,
Reporter, UA_DEFAULT, completeJob, fetchAllSubscriptions, firstDiff,
@@ -284,6 +287,201 @@ async function runPhase2(runner, { seedLog, seedExpect }) {
});
}
// ---------------------------------------------------------------------------
// Phase 3: UI-reskin features — node reorder, presence/speed, /api/system,
// customer-form node alignment, credentials, subscription aggregation.
// Time-derived values (speeds, /api/system numbers) are compared by shape
// only; everything else is compared exactly like phase 2.
// ---------------------------------------------------------------------------
const AGG_FIXTURE_BODY = Buffer.from([
'ss://YWVzLTI1Ni1nY206ZXh0LXB3@ext.example.com:8388#ExtOne',
'vless://22222222-2222-2222-2222-222222222222@ext2.example.com:443?security=none#ExtTwo',
'trojan://x@bad.example.com:443#Dropped',
'',
].join('\n')).toString('base64');
const AGG_MANUAL_LINK = 'ss://YWVzLTI1Ni1nY206bWFudWFsLXB3@manual.example.com:8388#ManualOne';
async function runPhase3(runner) {
// --- node reorder + subscription order ----------------------------------
await runner.step('p3/reorder-nodes', async (side) => {
const res = await side.client.admin('POST', '/api/nodes/reorder', { ids: [side.ids.n2, side.ids.n1] });
const list = await side.client.admin('GET', '/api/nodes');
return {
reorder: view(res),
order: list.json.nodes.map((n) => n.name),
sortIndexes: list.json.nodes.map((n) => n.sortIndex),
};
});
await runner.step('p3/alice-sub-order-after-reorder', async (side) => {
const res = await side.client.getSubscription(side.subTokens.alice, 'raw', UA_DEFAULT);
const names = res.body.split('\n').filter(Boolean).map((line) => {
const idx = line.lastIndexOf('#');
return idx >= 0 ? decodeURIComponent(line.slice(idx + 1)) : line;
});
return { status: res.status, names };
});
// --- presence & speed: two usage reports ~6s apart -----------------------
await runner.step('p3/usage-report-1', async (side) =>
view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
epoch: 'diff-phase2-epoch-1',
samples: [
{ kind: 'user', id: side.ids.alice, uplink: 1500, downlink: 2100 },
{ kind: 'inbound', id: side.ids.n1, uplink: 5000, downlink: 6000 },
],
})));
await runner.step('p3/usage-report-2-after-6s', async (side) => {
await sleep(6000);
return view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
epoch: 'diff-phase2-epoch-1',
samples: [
{ kind: 'user', id: side.ids.alice, uplink: 4500, downlink: 6300 },
{ kind: 'inbound', id: side.ids.n1, uplink: 8000, downlink: 9000 },
],
}));
});
await runner.step('p3/presence-projection', async (side) => {
const customers = await side.client.admin('GET', '/api/customers');
const nodes = await side.client.admin('GET', '/api/nodes');
const nameOf = (id) => { const c = customers.json.customers.find((x) => x.id === id); return c ? c.name : '?'; };
return {
customers: customers.json.customers.map((c) => ({
name: c.name, online: c.online,
speedPositive: (c.speedUpBps || 0) > 0 && (c.speedDownBps || 0) > 0,
hasLastActiveAt: Boolean(c.lastActiveAt),
})),
nodes: nodes.json.nodes.map((n) => ({
name: n.name, clientCount: n.clientCount, onlineCount: n.onlineCount,
onlineNames: (n.onlineCustomerIds || []).map(nameOf),
speedPositive: (n.speedUpBps || 0) > 0,
})),
};
});
await runner.step('p3/system-shape', async (side) => {
const res = await side.client.admin('GET', '/api/system');
const s = res.json || {};
return {
status: res.status, version: s.version,
hostKeys: s.host ? Object.keys(s.host).sort() : null,
networkKeys: s.network ? Object.keys(s.network).sort() : null,
todayKeys: s.today ? Object.keys(s.today).sort() : null,
ipsKeys: s.ips ? Object.keys(s.ips).sort() : null,
historyIsArray: Array.isArray(s.history),
connectionsIsNumber: Boolean(s.network) && typeof s.network.connections === 'number',
panelMemPositive: s.panelMemBytes > 0,
startedAtPresent: typeof s.startedAt === 'string',
};
});
// --- customer-form node alignment (on Carol: no golden subscriptions) ----
await runner.step('p3/align-carol-add-n1', async (side) => {
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [side.ids.n1] });
const nodes = await side.client.admin('GET', '/api/nodes');
const mine = nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol);
if (mine.length) side.ids.carolNc = mine[0].id;
return {
status: res.status, count: mine.length,
nodeNames: mine.map((nc) => { const n = nodes.json.nodes.find((x) => x.id === nc.nodeId); return n ? n.name : '?'; }),
statuses: mine.map((nc) => nc.status),
};
});
// --- credentials ----------------------------------------------------------
await runner.step('p3/credentials-get', async (side) => {
const res = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
return {
status: res.status,
hasToken: typeof res.json.subscriptionToken === 'string' && res.json.subscriptionToken.length > 10,
items: (res.json.items || []).map((entry) => ({
nodeName: entry.nodeName, protocol: entry.protocol, status: entry.status,
hasClientId: Boolean(entry.clientId), hasPassword: Boolean(entry.password),
hasShortId: Boolean(entry.shortId), username: entry.username,
})),
};
});
await runner.step('p3/credentials-regenerate', async (side) => {
const before = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
const regen = await side.client.admin('POST', `/api/customers/${side.ids.carol}/credentials/${side.ids.carolNc}/regenerate`, {});
const after = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
const b = (before.json.items || [])[0] || {};
const a = (after.json.items || [])[0] || {};
return {
status: regen.status,
changed: b.clientId !== a.clientId || b.password !== a.password,
itemNodeName: regen.json && regen.json.item ? regen.json.item.nodeName : null,
itemHasClientId: Boolean(regen.json && regen.json.item && regen.json.item.clientId),
};
});
await runner.step('p3/align-carol-remove-all', async (side) => {
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [] });
const nodes = await side.client.admin('GET', '/api/nodes');
return {
status: res.status,
bindingsForCarol: nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol).length,
};
});
// --- subscription aggregation ---------------------------------------------
// Both panels must allow private subscription sources to reach the
// loopback fixture: flip the setting in each side's data file and
// restart (there is deliberately no settings API).
const fixture = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(AGG_FIXTURE_BODY);
});
await new Promise((resolve) => fixture.listen(0, '127.0.0.1', resolve));
const fixtureUrl = `http://127.0.0.1:${fixture.address().port}/sub`;
try {
for (const side of [runner.sideA, runner.sideB]) {
await stopPanel(side.proc);
const db = JSON.parse(fs.readFileSync(side.dataFile, 'utf8'));
db.settings = db.settings || {};
db.settings.allowPrivateSubSources = true;
fs.writeFileSync(side.dataFile, JSON.stringify(db));
await restartSide(side);
}
await runner.step('p3/agg-relogin', async (side) => view(await side.client.login()));
await runner.step('p3/agg-create-dave', async (side) => {
const res = await side.client.admin('POST', '/api/customers', {
name: 'Dave',
manualLinks: [{ uri: AGG_MANUAL_LINK, remark: '手动一号', enabled: true }],
externalSources: [{ url: fixtureUrl, remark: '机场', enabled: true }],
});
if (res.json && res.json.customer) {
side.ids.dave = res.json.customer.id;
side.subTokens.dave = res.json.customer.subscriptionToken;
const source = (res.json.customer.externalSources || [])[0];
if (source) side.ids.daveSrc = source.id;
}
return {
status: res.status,
manualCount: res.json && res.json.customer ? (res.json.customer.manualLinks || []).length : null,
sourceCount: res.json && res.json.customer ? (res.json.customer.externalSources || []).length : null,
};
});
await runner.step('p3/agg-refresh', async (side) =>
view(await side.client.admin('POST', `/api/customers/${side.ids.dave}/external-sources/${side.ids.daveSrc}/refresh`, {})));
await runner.step('p3/agg-customer-status', async (side) => {
const res = await side.client.admin('GET', '/api/customers');
const dave = res.json.customers.find((c) => c.id === side.ids.dave);
const status = dave && dave.externalStatus ? Object.values(dave.externalStatus)[0] : null;
return {
hasExternalStatus: Boolean(dave && dave.externalStatus),
count: status ? status.count : null,
dropped: status ? status.droppedCount : null,
failCount: status ? status.failCount : null,
hasOkAt: Boolean(status && status.okAt),
lastError: status ? status.lastError : 'missing',
};
});
await runner.step('p3/agg-subscriptions', async (side) =>
fetchAllSubscriptions(side.client, side.subTokens.dave));
} finally {
fixture.close();
}
}
// ---------------------------------------------------------------------------
// DB interoperability: swap the two panels' DB files and keep working.
// ---------------------------------------------------------------------------
@@ -360,6 +558,7 @@ async function runPair({ reporter, kindA, kindB, bin, seedPath, seedLog, seedExp
try {
const runner = new StepRunner(reporter, sideA, sideB);
await runPhase2(runner, { seedLog, seedExpect });
await runPhase3(runner);
await runInterop(reporter, sideA, sideB, { seedExpect });
} finally {
await destroySide(sideA, keep);
+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);
}
+881 -48
View File
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
package main
// Tests for the 2026-10-21 listen hotfix: inbounds bind the wildcard
// address of their stack instead of the server's registered public
// address, so intranet addresses (relay landings) and additional v6
// addresses are served. The registered address remains the connection
// address (serverAddress) and is untouched by these tests.
import "testing"
func TestListenAddressWildcardHotfix(t *testing.T) {
v4Server := &Server{ID: "s4", PublicAddress: "203.0.113.10"}
v6Server := &Server{ID: "s6", PublicAddressV6: "2001:db8::10"}
dualServer := &Server{ID: "sd", PublicAddress: "203.0.113.10", PublicAddressV6: "2001:db8::10"}
cases := []struct {
name string
mode string
srv *Server
bind string
want string
}{
{"ipv4 with registered public address binds wildcard", "ipv4", v4Server, "", "0.0.0.0"},
{"ipv4 explicit bind wins", "ipv4", v4Server, "192.0.2.5", "192.0.2.5"},
{"ipv6 with registered v6 address binds wildcard", "ipv6", v6Server, "", "::"},
{"ipv6 without registered address binds wildcard", "ipv6", nil, "", "::"},
{"dual binds wildcard", "dual", dualServer, "", "::"},
{"ipv6 explicit bind wins", "ipv6", v6Server, "2001:db8::99", "2001:db8::99"},
}
for _, c := range cases {
if got := listenAddress(c.mode, c.srv, c.bind); got != c.want {
t.Errorf("%s: listenAddress(%q) = %q, want %q", c.name, c.mode, got, c.want)
}
}
}
// ResolveBindAddress fed the pin into every deploy: it resolved the
// registered public address into the bind address. It must no longer
// return a pin for any stack.
func TestResolveBindAddressNoPin(t *testing.T) {
srv := &Server{ID: "s", PublicAddress: "203.0.113.10", PublicAddressV6: "2001:db8::10"}
for _, mode := range []string{"ipv4", "ipv6", "dual"} {
got, err := ResolveBindAddress(mode, srv)
if err != nil {
t.Fatalf("mode %s: unexpected error: %v", mode, err)
}
if got != "" {
t.Errorf("mode %s: ResolveBindAddress = %q, want empty (no pin)", mode, got)
}
}
}
func firstInbound(t *testing.T, res map[string]any) map[string]any {
t.Helper()
inbounds, ok := res["inbounds"].([]any)
if !ok || len(inbounds) == 0 {
t.Fatalf("resource has no inbounds: %v", res)
}
ib, ok := inbounds[0].(map[string]any)
if !ok {
t.Fatalf("inbound is not a map: %T", inbounds[0])
}
return ib
}
// Resource-level smoke through BuildNodeResource (the builder the
// brief names): a SOCKS5 landing node on a server with a registered
// public v4 address must come out listening on 0.0.0.0.
func TestBuildNodeResourceSocksListenWildcard(t *testing.T) {
server := &Server{ID: "srv_l1", Name: "L1", PublicAddress: "203.0.113.10"}
node := &Node{
ID: "node_sk5", Name: "SK5", ServerID: "srv_l1",
Protocol: "socks5-auth", Topology: "direct",
NetworkMode: "ipv4", Port: 47814,
}
res, err := BuildNodeResource(BuildNodeResourceParams{
ResourceID: "res_sk5", TagPrefix: "sk5",
Node: node, Server: server,
})
if err != nil {
t.Fatalf("build: %v", err)
}
ib := firstInbound(t, res)
if ib["listen"] != "0.0.0.0" {
t.Errorf("listen = %v, want 0.0.0.0", ib["listen"])
}
if ib["listen_port"] != 47814 {
t.Errorf("listen_port = %v, want 47814", ib["listen_port"])
}
}
// The true deploy chain: resourceForNode runs ResolveBindAddress
// before BuildNodeResource. With the hotfix the produced resources
// must carry the wildcard listen for both stacks — an ipv4 SOCKS5
// landing (the reported intranet-relay case) and an ipv6 SS2022 node
// on a server with a registered v6 address.
func TestResourceForNodeListenWildcard(t *testing.T) {
d := &Data{Servers: []*Server{
{ID: "srv_l1", Name: "L1", PublicAddress: "203.0.113.10"},
{ID: "srv_v6", Name: "V6", PublicAddressV6: "2001:db8::10"},
}}
socks := &Node{
ID: "node_sk5", Name: "SK5", ServerID: "srv_l1",
Protocol: "socks5-auth", Topology: "direct",
NetworkMode: "ipv4", Port: 47814, ResourceID: "res_sk5",
}
res, err := resourceForNode(d, socks, nil)
if err != nil {
t.Fatalf("resourceForNode socks: %v", err)
}
ib := firstInbound(t, res)
if ib["listen"] != "0.0.0.0" || ib["listen_port"] != 47814 {
t.Errorf("socks inbound listen=%v port=%v, want 0.0.0.0:47814", ib["listen"], ib["listen_port"])
}
ss := &Node{
ID: "node_ss6", Name: "SS6", ServerID: "srv_v6",
Protocol: "shadowsocks-2022-aes256", Topology: "direct",
NetworkMode: "ipv6", Port: 47815, ResourceID: "res_ss6",
}
res, err = resourceForNode(d, ss, nil)
if err != nil {
t.Fatalf("resourceForNode ss2022: %v", err)
}
ib = firstInbound(t, res)
if ib["listen"] != "::" || ib["listen_port"] != 47815 {
t.Errorf("ss2022 inbound listen=%v port=%v, want :::47815", ib["listen"], ib["listen_port"])
}
}
// The exit-resource chain (resourceForNodeExit -> BuildNodeExitResource)
// is the other listenAddress call site: an ipv4 exit server with a
// registered public address must also produce a wildcard listen.
func TestResourceForNodeExitListenWildcard(t *testing.T) {
exitSrv := "srv_exit"
exitPort := 47816
exitProto := "socks5-auth"
d := &Data{Servers: []*Server{
{ID: "srv_entry", Name: "Entry", PublicAddress: "198.51.100.20"},
{ID: exitSrv, Name: "Exit", PublicAddress: "203.0.113.10"},
}}
node := &Node{
ID: "node_fwd", Name: "FWD", ServerID: "srv_entry",
Protocol: "vless-tcp", Topology: "forward",
NetworkMode: "ipv4", Port: 47817, ResourceID: "res_fwd",
ExitServerID: &exitSrv, ExitPort: &exitPort, ExitProtocol: &exitProto,
ExitResourceID: "res_fwd_exit",
}
res, err := resourceForNodeExit(d, node)
if err != nil {
t.Fatalf("resourceForNodeExit: %v", err)
}
ib := firstInbound(t, res)
if ib["listen"] != "0.0.0.0" || ib["listen_port"] != 47816 {
t.Errorf("exit inbound listen=%v port=%v, want 0.0.0.0:47816", ib["listen"], ib["listen_port"])
}
}
+50
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 {
expired := false
if customer.ExpiresAt != nil {
@@ -263,6 +272,10 @@ func (a *App) housekeeping() {
return
}
a.getSessions().Prune()
// System-status sampling + due external-source refreshes (outside the
// transaction; fetches must not hold the store lock).
a.monitor().Sample(a.store.Snapshot(), false)
a.RefreshDueSources()
}
// ---------- main ----------
@@ -352,6 +365,32 @@ func run() error {
}
}
// Node sort order migration (idempotent): unless the stored
// sortIndex values are exactly the permutation 0..n-1, renumber by
// storage order. Mirrors the Node implementation's boot migration.
{
nodes := store.Snapshot().Nodes
seen := map[int]bool{}
valid := true
for _, node := range nodes {
if node.SortIndex < 0 || node.SortIndex >= len(nodes) || seen[node.SortIndex] {
valid = false
break
}
seen[node.SortIndex] = true
}
if !valid {
if _, err := store.Transaction(func(d *Data) (any, error) {
for index, node := range d.Nodes {
node.SortIndex = index
}
return nil, nil
}); err != nil {
return err
}
}
}
app := NewApp(store, nil, NewOrchestrator(store))
app.replaceSessions(NewSessionManager(app.sessionHours()))
@@ -368,6 +407,17 @@ func run() error {
app.housekeeping()
}
}()
// Coalesced rebuild flusher (round-3 item 23): applies pending
// binding-class rebuilds once their fixed window elapses. Pending
// marks are persisted, so this also drains whatever a previous
// process left mid-window.
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
_, _ = app.orchestrator.FlushPendingRebuilds(time.Now())
}
}()
fmt.Printf("NexusGate v%s listening on http://%s:%d\n", VERSION, host, port)
return server.ListenAndServe()
}
+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)
}
}
+306 -61
View File
@@ -59,6 +59,13 @@ func forwardNode(node *Node) bool {
return topology == "forward"
}
// manualExit reports whether a forward node's exit is an unmanaged landing
// endpoint (round-2 item 8): no exit server, no exit resource, nothing to
// deploy on the far side.
func manualExit(node *Node) bool {
return forwardNode(node) && node.ExitMode == "manual"
}
func tagForNode(node *Node) string {
var sb strings.Builder
for _, r := range node.ID {
@@ -239,6 +246,16 @@ func payloadWithResource(resource map[string]any, kind string, keyMaterial map[s
return payload
}
// ensureCustomerCredentials fills a missing customer-level credential set
// (defensive: the store-load migration and customer creation provide one).
func ensureCustomerCredentials(d *Data, customerID string) {
customer := findCustomer(d, customerID)
if customer != nil && (customer.Credentials == nil || customer.Credentials.ClientID == "") {
creds := NewCustomerCredentials()
customer.Credentials = &creds
}
}
func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any, error) {
server := orchFindServer(d, node.ServerID)
if server == nil {
@@ -253,7 +270,10 @@ func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any
return nil, err
}
var exitServer *Server
if forwardNode(node) {
exitAddress := ""
if manualExit(node) {
exitAddress = node.ExitAddress
} else if forwardNode(node) {
exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
if exitServer == nil {
return nil, orchNotFound("出口服务器")
@@ -262,14 +282,30 @@ func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any
if clients == nil {
clients = activeNodeClients(d, node)
}
// Credentials live on the customer (round-2 item 2); stored bindings
// carry none. Stamp each customer's set onto throwaway copies for the
// resource builder so the stored records stay clean.
resolved := make([]*NodeClient, len(clients))
for i, nc := range clients {
ensureCustomerCredentials(d, nc.CustomerID)
cp := *nc
if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil {
creds := *customer.Credentials
cp.Credentials = &creds
} else {
cp.Credentials = nil
}
resolved[i] = &cp
}
return BuildNodeResource(BuildNodeResourceParams{
ResourceID: node.ResourceID,
TagPrefix: tagForNode(node),
Node: node,
NodeClients: clients,
NodeClients: resolved,
Server: server,
BindAddress: bindAddress,
ExitServer: exitServer,
ExitAddress: exitAddress,
})
}
@@ -309,21 +345,12 @@ func cancelQueuedDeletes(d *Data, nodeID, reason string) bool {
}
func RebuildNode(d *Data, node *Node, includeExit bool) error {
// Round-3 item 20: a node whose bindings drop to zero is rebuilt
// like any other (its inbound just ends up with no customer users);
// it is NOT auto-removed. The pre-item-20 auto-removal branch is
// gone; the AutoRemoving revival paths in assign/resume stay for
// records an older panel left in that state.
clients := activeNodeClients(d, node)
if len(clients) == 0 {
if node.Status != "draft" && node.Status != "removing" {
QueueJob(d, node.ServerID, node.ID, "delete_resource", &JobPayload{ResourceID: node.ResourceID, Kind: "node"})
if forwardNode(node) && node.ExitResourceID != "" {
QueueJob(d, xbDeref(node.ExitServerID), node.ID, "delete_resource", &JobPayload{ResourceID: node.ExitResourceID, Kind: "node-exit"})
}
node.Status = "removing"
node.AutoRemoving = BoolPtr(true)
node.PendingDelete = false
node.Error = nil
}
node.UpdatedAt = NowISO()
return nil
}
node.AutoRemoving = BoolPtr(false)
resource, err := resourceForNode(d, node, clients)
if err != nil {
@@ -331,7 +358,7 @@ func RebuildNode(d *Data, node *Node, includeExit bool) error {
}
payload := payloadWithResource(resource, "node", KeyMaterialForNode(d, node))
QueueJob(d, node.ServerID, node.ID, "apply_resource", payload)
if forwardNode(node) && includeExit {
if forwardNode(node) && includeExit && !manualExit(node) {
exitResource, err := resourceForNodeExit(d, node)
if err != nil {
return err
@@ -351,7 +378,7 @@ func DeriveNodeStatus(node *Node) {
}
return
}
if forwardNode(node) && !node.ExitReady {
if forwardNode(node) && !node.ExitReady && !manualExit(node) {
if node.Status != "degraded" {
node.Status = "deploying"
}
@@ -361,6 +388,75 @@ func DeriveNodeStatus(node *Node) {
node.Error = nil
}
// ---------- coalesced rebuilds (round-3 item 23) ----------
// RebuildCoalesceWindow is the FIXED coalescing window for binding-class
// rebuilds: it starts at a node's first pending change and is never
// extended by later changes inside it, so a burst of assign/unassign
// operations can never postpone the dispatch indefinitely. When the
// window elapses the flusher applies exactly one rebuild with the
// node's final state.
const RebuildCoalesceWindow = 10 * time.Second
// markRebuildPending records a binding-class change for the coalesced
// flusher instead of rebuilding (and restarting the node's sing-box)
// immediately. The window is anchored at the first pending change:
// re-marking inside the window only widens the exit flag.
func markRebuildPending(node *Node, includeExit bool) {
if !node.RebuildPending {
node.RebuildPending = true
node.RebuildPendingAt = NowISO()
}
if includeExit {
node.RebuildPendingExit = true
}
node.UpdatedAt = NowISO()
}
// clearRebuildPending drops a pending coalesced rebuild — used when an
// immediate action (manual deploy, repair, node edit, removal, server
// reconcile) already ships the node's current state or ends its life.
func clearRebuildPending(node *Node) {
node.RebuildPending = false
node.RebuildPendingAt = ""
node.RebuildPendingExit = false
}
// FlushPendingRebuilds applies one rebuild per node whose fixed window
// has elapsed, with the node's state as of now. Pending marks on nodes
// that were deleted, are being removed, or fell back to draft are
// discarded — a pending apply must never resurrect a node. Called once
// per second from the panel's background loop; because the mark is
// persisted in the data file, a panel restart inside the window simply
// means the first post-boot pass flushes whatever is due.
func (o *Orchestrator) FlushPendingRebuilds(now time.Time) (int, error) {
flushed := 0
err := o.tx(func(d *Data) error {
for _, node := range d.Nodes {
if !node.RebuildPending {
continue
}
at, ok := ParseISO(node.RebuildPendingAt)
if !ok || now.Sub(at) < RebuildCoalesceWindow {
continue
}
includeExit := node.RebuildPendingExit
clearRebuildPending(node)
if node.PendingDelete || node.Status == "removing" || node.Status == "draft" {
continue
}
if err := RebuildNode(d, node, includeExit); err != nil {
node.Error = StrPtr(err.Error())
Audit(d, "system", "rebuild_flush_failed", node.ID, map[string]any{"error": err.Error()})
continue
}
flushed++
}
return nil
})
return flushed, err
}
// ---------- customer suspend / resume ----------
func SuspendCustomerResources(d *Data, customer *Customer, actor string) (int, error) {
@@ -382,9 +478,7 @@ func SuspendCustomerResources(d *Data, customer *Customer, actor string) (int, e
for _, nodeID := range affected {
node := orchFindNode(d, nodeID)
if node != nil && node.Status != "draft" && node.Status != "removing" {
if err := RebuildNode(d, node, false); err != nil {
return len(affected), err
}
markRebuildPending(node, false)
}
}
Audit(d, actor, "suspend_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
@@ -435,9 +529,7 @@ func ResumeCustomerResources(d *Data, customer *Customer, actor string) (int, er
}
}
includeExit := forwardNode(node) && !node.ExitReady
if err := RebuildNode(d, node, includeExit); err != nil {
return len(affected), err
}
markRebuildPending(node, includeExit)
}
Audit(d, actor, "resume_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
return len(affected), nil
@@ -606,15 +698,27 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
return orchNotFound("服务器")
}
var exitServer *Server
if !direct {
if !direct && !manualExit(node) {
exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
if exitServer == nil {
return orchNotFound("出口服务器")
}
}
if len(activeNodeClients(d, node)) == 0 {
return httpErr(400, "请先给节点分配至少一个客户再部署")
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)
}
}
// Round-3 item 20: no client-count gate. Nodes are built and deployed
// first and bound later (the 3x-ui model); bindings may go to zero
// and back without tearing the node down. A zero-client inbound
// simply has no users who can connect — including socks5-auth,
// whose inbound always carries its node-level fixed account
// (item 21), so it never degenerates into an open proxy.
switch node.Protocol {
case "vless-ws-tls", "hysteria2", "anytls":
if server.TLSDomain == "" {
@@ -657,7 +761,7 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
}
}
servers := []*Server{server}
if !direct {
if exitServer != nil {
servers = []*Server{server, exitServer}
}
nowMs := time.Now().UnixMilli()
@@ -702,7 +806,7 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
if NodePortInUse(d, node.ServerID, node.Port, node.ID) {
return httpErr(409, "端口 %d 已被该服务器上的其他节点占用", node.Port)
}
if !direct && node.ExitPort != nil && NodePortInUse(d, xbDeref(node.ExitServerID), *node.ExitPort, node.ID) {
if !direct && !manualExit(node) && node.ExitPort != nil && NodePortInUse(d, xbDeref(node.ExitServerID), *node.ExitPort, node.ID) {
return httpErr(409, "出口端口 %d 已被该服务器上的其他节点占用", *node.ExitPort)
}
return nil
@@ -720,6 +824,10 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
if server == nil {
return 0, orchNotFound("服务器")
}
// A disabled device receives no new deployments, including reconciles.
if server.Enabled != nil && !*server.Enabled {
return 0, nil
}
failInFlight := true
if opts.FailInFlight != nil {
failInFlight = *opts.FailInFlight
@@ -758,9 +866,6 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
}
} else {
clients := activeNodeClients(d, node)
if len(clients) == 0 {
continue
}
if isEntry {
resource, err := resourceForNode(d, node, clients)
if err != nil {
@@ -775,6 +880,9 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
}
QueueJob(d, serverID, node.ID, "apply_resource", payloadWithResource(resource, "node-exit", nil))
}
// The reconcile just shipped the node's current state; a
// coalesced pending mark for it is satisfied.
clearRebuildPending(node)
node.Status = "deploying"
}
node.UpdatedAt = NowISO()
@@ -858,6 +966,9 @@ func (o *Orchestrator) DeployNode(nodeID, actor string) (*Node, error) {
if err := ValidateNodeDeploy(d, node); err != nil {
return err
}
// Manual deploy is immediate (item 23 constraint 1): it ships the
// current state now and consumes any coalesced pending mark.
clearRebuildPending(node)
if err := RebuildNode(d, node, true); err != nil {
return err
}
@@ -879,6 +990,9 @@ func (o *Orchestrator) RemoveNode(nodeID, actor string, deleteRecord bool) (bool
}
node.PendingDelete = deleteRecord
node.AutoRemoving = BoolPtr(false)
// A pending coalesced apply must never resurrect a node that is
// being removed (item 23 constraint 2).
clearRebuildPending(node)
if node.Status != "draft" {
QueueJob(d, node.ServerID, node.ID, "delete_resource", &JobPayload{ResourceID: node.ResourceID, Kind: "node"})
if forwardNode(node) && node.ExitResourceID != "" {
@@ -926,6 +1040,7 @@ func (o *Orchestrator) RepairNode(nodeID, actor string) (bool, error) {
if node.Status != "failed" && node.Status != "degraded" {
return httpErr(409, "节点状态正常,无需修复")
}
clearRebuildPending(node)
if err := RebuildNode(d, node, true); err != nil {
return err
}
@@ -935,22 +1050,19 @@ func (o *Orchestrator) RepairNode(nodeID, actor string) (bool, error) {
return err == nil, err
}
func (o *Orchestrator) AssignCustomer(nodeID, customerID, actor string) (*NodeClient, error) {
if actor == "" {
actor = "admin"
}
var result *NodeClient
err := o.tx(func(d *Data) error {
// 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 orchNotFound("节点")
return nil, orchNotFound("节点")
}
customer := orchFindCustomer(d, customerID)
if customer == nil {
return orchNotFound("客户")
return nil, orchNotFound("客户")
}
if customer.Status != "active" {
return httpErr(409, "只能给状态正常的客户分配节点")
return nil, httpErr(409, "只能给状态正常的客户分配节点")
}
var nc *NodeClient
for _, entry := range d.NodeClients {
@@ -960,18 +1072,18 @@ func (o *Orchestrator) AssignCustomer(nodeID, customerID, actor string) (*NodeCl
}
}
if nc != nil && nc.Status == "active" {
return httpErr(409, "该客户已分配到此节点")
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,
Credentials: NewNodeClientCredentials(node.Protocol),
Status: "active",
CreatedAt: now,
UpdatedAt: now,
@@ -985,22 +1097,13 @@ func (o *Orchestrator) AssignCustomer(nodeID, customerID, actor string) (*NodeCl
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
node.AutoRemoving = BoolPtr(false)
}
if err := RebuildNode(d, node, false); err != nil {
return err
}
markRebuildPending(node, false)
}
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
result = cloneNodeClientRecord(nc)
return nil
})
return result, err
return nc, nil
}
func (o *Orchestrator) UnassignCustomer(nodeID, customerID, actor string) (bool, error) {
if actor == "" {
actor = "admin"
}
err := o.tx(func(d *Data) error {
func unassignCustomerData(d *Data, nodeID, customerID, actor string) error {
node := orchFindNode(d, nodeID)
if node == nil {
return orchNotFound("节点")
@@ -1017,12 +1120,114 @@ func (o *Orchestrator) UnassignCustomer(nodeID, customerID, actor string) (bool,
}
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
if node.Status != "draft" && node.Status != "removing" {
if err := RebuildNode(d, node, false); err != nil {
return err
}
markRebuildPending(node, false)
}
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
return nil
}
// 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)
}
markRebuildPending(node, false)
}
return nil
}
func (o *Orchestrator) AssignCustomer(nodeID, customerID, actor string) (*NodeClient, error) {
if actor == "" {
actor = "admin"
}
var result *NodeClient
err := o.tx(func(d *Data) error {
nc, err := assignCustomerData(d, nodeID, customerID, actor)
if err != nil {
return err
}
result = cloneNodeClientRecord(nc)
return nil
})
return result, err
}
func (o *Orchestrator) UnassignCustomer(nodeID, customerID, actor string) (bool, error) {
if actor == "" {
actor = "admin"
}
err := o.tx(func(d *Data) error {
return unassignCustomerData(d, nodeID, customerID, actor)
})
return err == nil, err
}
@@ -1072,9 +1277,6 @@ func (o *Orchestrator) ReconcileFromHeartbeat(serverID string, reportedResourceI
if node.Status == "draft" || node.Status == "removing" {
continue
}
if len(activeNodeClients(d, node)) == 0 {
continue
}
if node.ServerID == serverID && node.ResourceID != "" && !reported[node.ResourceID] {
missing[node.ID] = true
}
@@ -1298,6 +1500,9 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
slot = &UsageSlot{}
customer.ServerUsage[key] = slot
}
prevCounters := slot.UsageCounters
prevSampleISO := slot.LastSampleAt
restarted := prevCounters != nil && prevCounters.Epoch != nil && sampleEpoch != nil && *sampleEpoch != *prevCounters.Epoch
delta := ApplyDelta(SlotHolder(slot), uplink, downlink, sampleEpoch)
if delta == nil {
continue
@@ -1307,11 +1512,33 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
customer.UsedBytes += delta.UpDelta + delta.DownDelta
customer.LastUsageAt = StrPtr(reportedAt)
customer.UpdatedAt = reportedAt
slot.LastSampleAt = StrPtr(reportedAt)
if delta.UpDelta > 0 || delta.DownDelta > 0 {
slot.LastActiveAt = StrPtr(reportedAt)
customer.LastOnlineAt = StrPtr(reportedAt)
}
if !restarted && prevCounters != nil && prevSampleISO != nil {
if prevMs, ok1 := ParseISOMs(*prevSampleISO); ok1 {
if nowMs, ok2 := ParseISOMs(reportedAt); ok2 {
dt := float64(nowMs-prevMs) / 1000
if dt >= 5 {
up := float64(int64(math.Round(float64(uplink-prevCounters.Uplink) / dt)))
down := float64(int64(math.Round(float64(downlink-prevCounters.Downlink) / dt)))
slot.SpeedUpBps = &up
slot.SpeedDownBps = &down
slot.SpeedAt = StrPtr(reportedAt)
}
}
}
}
case "inbound":
node := orchFindNode(d, sample.ID)
if node == nil || node.ServerID != serverID {
continue
}
prevCounters := node.UsageCounters
prevSampleISO := node.LastSampleAt
restarted := prevCounters != nil && prevCounters.Epoch != nil && sampleEpoch != nil && *sampleEpoch != *prevCounters.Epoch
delta := ApplyDelta(NodeHolder(node), uplink, downlink, sampleEpoch)
if delta == nil {
continue
@@ -1323,6 +1550,24 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
}
node.MeteredTraffic = &MeteredTraffic{Uplink: mu + delta.UpDelta, Downlink: md + delta.DownDelta}
node.LastUsageAt = StrPtr(reportedAt)
node.LastSampleAt = StrPtr(reportedAt)
if delta.UpDelta > 0 || delta.DownDelta > 0 {
node.LastActiveAt = StrPtr(reportedAt)
}
if !restarted && prevCounters != nil && prevSampleISO != nil {
if prevMs, ok1 := ParseISOMs(*prevSampleISO); ok1 {
if nowMs, ok2 := ParseISOMs(reportedAt); ok2 {
dt := float64(nowMs-prevMs) / 1000
if dt >= 5 {
up := float64(int64(math.Round(float64(uplink-prevCounters.Uplink) / dt)))
down := float64(int64(math.Round(float64(downlink-prevCounters.Downlink) / dt)))
node.SpeedUpBps = &up
node.SpeedDownBps = &down
node.SpeedAt = StrPtr(reportedAt)
}
}
}
}
}
}
return nil
+109
View File
@@ -0,0 +1,109 @@
package main
// Benchmarks for round-3 item 22 (per-request full-clone cost). Not run
// by `go test` without -bench. The seed is synthetic data at the scale
// fixed for this round's acceptance: 20 servers / 40 customers /
// 40 nodes / ~160 bindings (20-agent steady-state tests use the same
// scale).
import (
"encoding/json"
"fmt"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
func perfSeedData() *Data {
d := EmptyData()
d.Users = []*User{{ID: "usr_1", Username: "admin", PasswordHash: "x", Role: "owner", Status: "active", CreatedAt: NowISO()}}
for i := 0; i < 20; i++ {
d.Servers = append(d.Servers, &Server{
ID: fmt.Sprintf("srv_%d", i), Name: fmt.Sprintf("S%d", i), PublicAddress: "203.0.113.1",
Status: "online", LastSeenAt: StrPtr(NowISO()), PortRangeStart: 20000, PortRangeEnd: 50000,
Labels: []string{}, CreatedAt: NowISO(), UpdatedAt: NowISO(),
})
}
for i := 0; i < 40; i++ {
d.Customers = append(d.Customers, &Customer{
ID: fmt.Sprintf("cus_%d", i), Name: fmt.Sprintf("C%d", i), Status: "active",
SubscriptionToken: fmt.Sprintf("tok-%032d", i),
Credentials: &Credentials{ClientID: NewUUID(), RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001"},
Tags: []string{}, CreatedAt: NowISO(), UpdatedAt: NowISO(),
})
}
protocols := []string{"vless-reality-vision", "shadowsocks-2022-aes128", "socks5-auth", "vless-ws", "hysteria2"}
for i := 0; i < 40; i++ {
node := &Node{
ID: fmt.Sprintf("nod_%d", i), Name: fmt.Sprintf("N%d", i), ServerID: fmt.Sprintf("srv_%d", i%20),
Protocol: protocols[i%len(protocols)], Topology: "direct", NetworkMode: "ipv4", Port: 23000 + i,
Status: "active", ResourceID: fmt.Sprintf("res_%d", i), Artifacts: map[string]json.RawMessage{},
}
if node.Protocol == "socks5-auth" {
node.SocksUser, node.SocksPassword = "ngs_fixed", "fixedpass"
}
if IsRealityProtocol(node.Protocol) {
node.Reality = &RealityConfig{KeyID: fmt.Sprintf("reality-nod_%d", i), ServerName: "www.tesla.com", DestPort: 443}
}
d.Nodes = append(d.Nodes, node)
for j := 0; j < 4; j++ {
d.NodeClients = append(d.NodeClients, &NodeClient{
ID: fmt.Sprintf("nc_%d_%d", i, j), NodeID: node.ID,
CustomerID: fmt.Sprintf("cus_%d", (i+j)%40), Status: "active",
CreatedAt: NowISO(), UpdatedAt: NowISO(),
})
}
}
return d
}
func BenchmarkCloneJSONRoundTrip(b *testing.B) {
d := perfSeedData()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = d.cloneViaJSON()
}
}
func BenchmarkCloneFast(b *testing.B) {
d := perfSeedData()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = d.Clone()
}
}
func BenchmarkAdminGET(b *testing.B) {
cookieSecure = false
store := NewStore(filepath.Join(b.TempDir(), "nexusgate.json"))
if err := store.Init(); err != nil {
b.Fatal(err)
}
seed := perfSeedData()
seed.Users[0].PasswordHash = HashSecret("password12345")
if _, err := store.Transaction(func(d *Data) (any, error) { *d = *seed; return nil, nil }); err != nil {
b.Fatal(err)
}
app := NewApp(store, NewSessionManager(12), NewOrchestrator(store))
login := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"username":"admin","password":"password12345"}`))
rec := httptest.NewRecorder()
app.ServeHTTP(rec, login)
if rec.Code != 200 {
b.Fatalf("login %d", rec.Code)
}
cookie := strings.Split(rec.Header().Get("Set-Cookie"), ";")[0]
for _, path := range []string{"/api/overview", "/api/servers", "/api/customers", "/api/nodes", "/api/jobs"} {
b.Run(strings.TrimPrefix(path, "/api/"), func(b *testing.B) {
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", path, nil)
req.Header.Set("Cookie", cookie)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
if rec.Code != 200 {
b.Fatalf("%s -> %d", path, rec.Code)
}
}
})
}
}
+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
}
+100 -77
View File
@@ -1,7 +1,6 @@
package main
import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
@@ -196,19 +195,68 @@ func (q xbQuery) String() string {
func NodeEmail(customerID string) string { return "ng:" + customerID }
func NewNodeClientCredentials(protocol string) Credentials {
keyBytes := 24
if cipher, ok := ShadowsocksMethods[protocol]; ok {
keyBytes = cipher.KeyBytes
}
// NewCustomerCredentials mints the single credential set a customer uses on
// every node (round-2 item 2, 3x-ui model). The relay password is always a
// base64 32-byte root key: SS2022 user keys are derived from it per method
// (see EffectiveRelayPassword), so one set serves aes128 and aes256 nodes
// alike; every other protocol uses it verbatim.
func NewCustomerCredentials() Credentials {
return Credentials{
ClientID: NewUUID(),
RelayPassword: b64(keyBytes),
RelayPassword: b64(32),
ShortID: shortId(),
}
}
// EffectiveRelayPassword maps a customer's relay password onto a node's
// protocol. SS2022 methods require the user key to be base64 of exactly the
// method's key length, so the user key is the deterministic prefix
// truncation of the 32-byte root key. Legacy SS methods and all other
// protocols take the password verbatim.
func EffectiveRelayPassword(protocol, relayPassword string) string {
cipher, ok := ShadowsocksMethods[protocol]
if !ok || !strings.HasPrefix(cipher.Method, "2022-blake3-") {
return relayPassword
}
raw, err := base64.StdEncoding.DecodeString(relayPassword)
if err != nil || len(raw) < cipher.KeyBytes {
return relayPassword
}
return base64.StdEncoding.EncodeToString(raw[:cipher.KeyBytes])
}
// clientCredentials resolves the credentials stamped onto a binding for
// resource building. Stored bindings no longer carry credentials (they live
// on the customer); builders receive resolved copies, and this accessor
// keeps a nil hole from becoming a panic.
func clientCredentials(nc *NodeClient) Credentials {
if nc == nil || nc.Credentials == nil {
return Credentials{}
}
return *nc.Credentials
}
// EnsureSocksCredentials mints the node-level fixed account of a
// socks5-auth node (round-3 item 21) when either half is missing. The
// username deliberately avoids the "ng:" prefix: the agent maps sing-box
// user counters named "ng:<customerId>" to per-customer usage samples,
// and the fixed account's traffic must stay node-level (the "inbound"
// sample), never attributed to a customer. The password is a base64
// 32-byte random, the same strength as a customer's relay password.
func EnsureSocksCredentials(node *Node) {
if node.Protocol != "socks5-auth" {
return
}
if node.SocksUser == "" {
node.SocksUser = "ngs_" + hex.EncodeToString(xbRandBytes(5))
}
if node.SocksPassword == "" {
node.SocksPassword = b64(32)
}
}
func EnsureNodeSecrets(node *Node) {
EnsureSocksCredentials(node)
if node.WSPath == nil || *node.WSPath == "" {
node.WSPath = StrPtr("/" + hex.EncodeToString(xbRandBytes(9)))
}
@@ -255,20 +303,20 @@ func listenAddress(networkMode string, server *Server, bindAddress string) strin
if bindAddress != "" {
return bindAddress
}
if networkMode == "ipv6" {
if server != nil && server.PublicAddressV6 != "" {
return server.PublicAddressV6
}
// Hotfix 2026-10-21: inbounds bind the wildcard address of their
// stack. The registered public address is a *connection* address
// (see serverAddress); pinning the listener to it left the
// machine's other addresses (intranet NICs, additional v6
// addresses) unserved and broke intranet relay landings, which
// 3x-ui/xray serve by binding the wildcard. The server parameter
// is kept for signature parity with the Node implementation.
switch networkMode {
case "ipv6", "dual":
return "::"
}
if networkMode == "dual" {
return "::"
}
if server != nil && server.PublicAddress != "" {
return server.PublicAddress
}
default: // ipv4
return "0.0.0.0"
}
}
func serverAddress(server *Server, networkMode string) (string, error) {
if server == nil {
@@ -280,55 +328,20 @@ func serverAddress(server *Server, networkMode string) (string, error) {
return server.PublicAddress, nil
}
// ResolveBindAddress mirrors resolveBindAddress: dual/absent -> ("", nil).
// DNS failures are HTTPError 400; the parenthesized code approximates
// err.code with the Go resolver's short error text (e.g. "no such host"),
// since Go exposes no getaddrinfo code.
// ResolveBindAddress mirrors resolveBindAddress. Since the 2026-10-21
// listen hotfix it always returns ("", nil): inbounds bind the wildcard
// address of their stack (see listenAddress), and the server's
// registered public address is a connection address (serverAddress),
// never a bind pin — resolving it into a bind address here is what
// pinned every inbound to the public NIC and broke intranet relays.
// It previously also returned ("", nil) for dual and for servers
// without a registered address, so wildcard binding was already the
// fallback semantics. The function is kept so the orchestrator's
// deploy path keeps its shape and an explicit per-deploy bind address
// can be reintroduced here if one is ever needed.
func ResolveBindAddress(networkMode string, server *Server) (string, error) {
if networkMode == "dual" {
return "", nil
}
var raw string
if server != nil {
if networkMode == "ipv6" {
raw = server.PublicAddressV6
} else {
raw = server.PublicAddress
}
}
if raw == "" {
return "", nil
}
text := strings.TrimSpace(raw)
family := 4
record := "A"
network := "ip4"
if networkMode == "ipv6" {
family = 6
record = "AAAA"
network = "ip6"
}
_ = family
if ip := net.ParseIP(text); ip != nil {
return text, nil
}
addrs, err := net.DefaultResolver.LookupIP(context.Background(), network, text)
if err != nil || len(addrs) == 0 {
code := ""
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.Err != "" {
code = dnsErr.Err
} else {
code = err.Error()
}
} else {
code = "no addresses"
}
return "", httpErr(400, "绑定地址 DNS 解析失败:%s 没有 %s 记录(%s),请检查该服务器登记的地址", text, record, code)
}
return addrs[0].String(), nil
}
// ---------- sing-box resource builders ----------
@@ -352,12 +365,13 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
users := make([]any, 0, len(nodeClients))
shortIDs := make([]any, 0, len(nodeClients))
for _, nc := range nodeClients {
user := map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": nc.Credentials.ClientID}
creds := clientCredentials(nc)
user := map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": creds.ClientID}
if protocol == "vless-reality-vision" {
user["flow"] = "xtls-rprx-vision"
}
users = append(users, user)
shortIDs = append(shortIDs, nc.Credentials.ShortID)
shortIDs = append(shortIDs, creds.ShortID)
}
destPort := node.Reality.DestPort
if destPort == 0 {
@@ -383,7 +397,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
}
users := make([]any, 0, len(nodeClients))
for _, nc := range nodeClients {
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": nc.Credentials.ClientID})
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": clientCredentials(nc).ClientID})
}
wsPath := xbDeref(node.WSPath)
if wsPath == "" {
@@ -406,7 +420,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
if cipher, ok := ShadowsocksMethods[protocol]; ok {
users := make([]any, 0, len(nodeClients))
for _, nc := range nodeClients {
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": nc.Credentials.RelayPassword})
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": EffectiveRelayPassword(protocol, clientCredentials(nc).RelayPassword)})
}
return map[string]any{
"type": "shadowsocks", "tag": tag, "listen": listen, "listen_port": port,
@@ -415,10 +429,12 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
}
if protocol == "socks5-auth" {
users := make([]any, 0, len(nodeClients))
for _, nc := range nodeClients {
users = append(users, map[string]any{"username": NodeEmail(nc.CustomerID), "password": nc.Credentials.RelayPassword})
}
// Round-3 item 21: the inbound carries exactly one user — the
// node's fixed account — regardless of bindings. Bindings only
// control subscription visibility; per-person accounting for
// relay chains happens at the entry side, not here.
EnsureSocksCredentials(node)
users := []any{map[string]any{"username": node.SocksUser, "password": node.SocksPassword}}
return map[string]any{"type": "socks", "tag": tag, "listen": listen, "listen_port": port, "users": users}, nil
}
@@ -428,7 +444,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
}
users := make([]any, 0, len(nodeClients))
for _, nc := range nodeClients {
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": nc.Credentials.RelayPassword})
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": clientCredentials(nc).RelayPassword})
}
return map[string]any{
"type": "hysteria2", "tag": tag, "listen": listen, "listen_port": port, "users": users,
@@ -446,7 +462,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
}
users := make([]any, 0, len(nodeClients))
for _, nc := range nodeClients {
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": nc.Credentials.RelayPassword})
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": clientCredentials(nc).RelayPassword})
}
return map[string]any{
"type": "anytls", "tag": tag, "listen": listen, "listen_port": port, "users": users,
@@ -469,6 +485,9 @@ type BuildNodeResourceParams struct {
Server *Server
BindAddress string
ExitServer *Server
// ExitAddress, when non-empty, is the manual landing endpoint the
// entry outbound connects to (no managed exit server involved).
ExitAddress string
}
func BuildNodeResource(p BuildNodeResourceParams) (map[string]any, error) {
@@ -492,10 +511,14 @@ func BuildNodeResource(p BuildNodeResourceParams) (map[string]any, error) {
if !forward {
outbound = map[string]any{"type": "direct", "tag": outboundTag}
} else {
exitAddr, err := serverAddress(p.ExitServer, networkMode)
exitAddr := p.ExitAddress
if exitAddr == "" {
var err error
exitAddr, err = serverAddress(p.ExitServer, networkMode)
if err != nil {
return nil, err
}
}
base := map[string]any{"tag": outboundTag, "server": exitAddr, "server_port": xbDerefInt(node.ExitPort)}
creds := node.ExitCredentials
if creds == nil {
@@ -682,9 +705,9 @@ func BuildClientURI(p BuildClientURIParams) (string, bool) {
return fmt.Sprintf("vless://%s@%s:%d?%s#%s", p.ClientID, host, p.RelayPort, q.String(), label), true
}
if cipher, ok := ShadowsocksMethods[protocol]; ok {
password := p.RelayPassword
password := EffectiveRelayPassword(protocol, p.RelayPassword)
if strings.HasPrefix(cipher.Method, "2022-blake3-") && p.SSServerKey != "" {
password = p.SSServerKey + ":" + p.RelayPassword
password = p.SSServerKey + ":" + password
}
user := base64.RawURLEncoding.EncodeToString([]byte(cipher.Method + ":" + password))
return fmt.Sprintf("ss://%s@%s:%d#%s", user, host, p.RelayPort, label), true
+3 -3
View File
@@ -83,8 +83,8 @@ func TestGoldenResources(t *testing.T) {
ExitCredentials: &ExitCredentials{ClientID: "11111111-2222-3333-4444-0000000000ff", Password: exitPassword, SocksUser: "ng_0123456789"},
}
rfClients := []*NodeClient{
{NodeID: "nod_rf", CustomerID: "cus_1", Credentials: Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "unused1", ShortID: "abcd0001ef01"}},
{NodeID: "nod_rf", CustomerID: "cus_2", Credentials: Credentials{ClientID: "11111111-2222-3333-4444-000000000002", RelayPassword: "unused2", ShortID: "abcd0002ef01"}},
{NodeID: "nod_rf", CustomerID: "cus_1", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "unused1", ShortID: "abcd0001ef01"}},
{NodeID: "nod_rf", CustomerID: "cus_2", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000002", RelayPassword: "unused2", ShortID: "abcd0002ef01"}},
}
entryServer := &Server{ID: "srv_entry", PublicAddress: "203.0.113.10"}
exitServer := &Server{ID: "srv_exit", PublicAddress: "exit.example.com"}
@@ -102,7 +102,7 @@ func TestGoldenResources(t *testing.T) {
SSServerKey: StrPtr("CwwNDg8QERITFBUWFxgZGg=="),
}
ssClients := []*NodeClient{
{NodeID: "nod_ss", CustomerID: "cus_1", Credentials: Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001ef01"}},
{NodeID: "nod_ss", CustomerID: "cus_1", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001ef01"}},
}
ssServer := &Server{ID: "srv_entry", PublicAddress: "203.0.113.11"}
+73
View File
@@ -0,0 +1,73 @@
package main
// Item 17: GET /api/nodes/:id/reality-keys is a display-only view of the
// panel-hosted keypair — it must never mint one.
import (
"strings"
"testing"
)
func realityLoginHeaders(t *testing.T, app *App) map[string]string {
t.Helper()
rec := doRequest(app, "POST", "/api/auth/login", `{"username":"admin","password":"password12345"}`, nil)
if rec.Code != 200 {
t.Fatalf("login %d", rec.Code)
}
cookie := strings.Split(rec.Header().Get("Set-Cookie"), ";")[0]
return map[string]string{"Cookie": cookie}
}
func TestRealityKeysEndpoint(t *testing.T) {
app := newSmokeApp(t)
_, err := app.store.Transaction(func(d *Data) (any, error) {
d.Servers = append(d.Servers, &Server{ID: "srv_1", Name: "S1", PublicAddress: "entry.example.com"})
d.Nodes = append(d.Nodes,
&Node{ID: "nod_r", Protocol: "vless-reality-vision", ServerID: "srv_1", Status: "active",
Reality: &RealityConfig{KeyID: "reality-nod_r", ServerName: "www.tesla.com", DestPort: 443}},
&Node{ID: "nod_s", Protocol: "socks5-auth", ServerID: "srv_1", Status: "active"},
)
d.KeyStore["reality-nod_r"] = &KeyEntry{PrivateKey: "PRIV", PublicKey: "PUB", UpdatedAt: NowISO()}
return nil, nil
})
if err != nil {
t.Fatal(err)
}
headers := realityLoginHeaders(t, app)
rec := doRequest(app, "GET", "/api/nodes/nod_r/reality-keys", "", headers)
if rec.Code != 200 {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
body := decodeBody(t, rec)
if body["publicKey"] != "PUB" || body["privateKey"] != "PRIV" || body["keyId"] != "reality-nod_r" {
t.Fatalf("body %v", body)
}
// Non-Reality node -> 400; unknown node -> 404.
if rec := doRequest(app, "GET", "/api/nodes/nod_s/reality-keys", "", headers); rec.Code != 400 {
t.Fatalf("socks node status %d, want 400", rec.Code)
}
if rec := doRequest(app, "GET", "/api/nodes/nope/reality-keys", "", headers); rec.Code != 404 {
t.Fatalf("unknown node status %d, want 404", rec.Code)
}
// A Reality node without a stored pair reports nulls and the
// endpoint must NOT create one.
_, err = app.store.Transaction(func(d *Data) (any, error) {
d.Nodes = append(d.Nodes, &Node{ID: "nod_r2", Protocol: "vless-reality", ServerID: "srv_1", Status: "draft",
Reality: &RealityConfig{KeyID: "reality-nod_r2", ServerName: "www.tesla.com", DestPort: 443}})
return nil, nil
})
if err != nil {
t.Fatal(err)
}
rec = doRequest(app, "GET", "/api/nodes/nod_r2/reality-keys", "", headers)
body = decodeBody(t, rec)
if rec.Code != 200 || body["publicKey"] != nil || body["privateKey"] != nil {
t.Fatalf("missing pair: %d %v", rec.Code, body)
}
if _, exists := app.store.Snapshot().KeyStore["reality-nod_r2"]; exists {
t.Fatal("display endpoint must never generate a keypair")
}
}
+262
View File
@@ -0,0 +1,262 @@
package main
// Reality target scanner (round-3 item 16, modeled on the 3x-ui
// scanner): the panel itself performs a real TLS handshake against
// each candidate and reports whether it can serve as a Reality dest —
// handshake success plus an X25519-family key exchange. Results are
// the PANEL's point of view (the router reaches the internet through
// its own egress; a node's view can differ), and the UI says so.
//
// Bounds: at most scanMaxTargets probes per request, scanConcurrency
// in flight, scanProbeTimeout per probe — a scan can never wedge the
// panel.
import (
"crypto/tls"
"encoding/binary"
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
)
const (
scanMaxTargets = 32
scanConcurrency = 8
scanProbeTimeout = 4 * time.Second
scanCIDRMaxHosts = 32
)
// scanPool is the built-in candidate pool: the form's Reality presets
// (same source) plus the usual scanner extras.
func scanPool() []string {
seen := map[string]bool{}
pool := []string{}
add := func(host string) {
if !seen[host] {
seen[host] = true
pool = append(pool, host)
}
}
for _, preset := range RealityPresets {
add(preset.ServerName)
}
for _, host := range []string{
"www.microsoft.com", "www.google.com", "www.nvidia.com",
"www.samsung.com", "www.cloudflare.com", "www.bing.com",
"www.github.com", "www.yahoo.com", "www.mozilla.org",
} {
add(host)
}
return pool
}
type scanTarget struct {
Host string
Port int
}
// parseScanTargets expands operator input (comma/space separated
// domains, IPs, host:port pairs and CIDRs) into probe targets. Empty
// input means the built-in pool.
func parseScanTargets(input string) ([]scanTarget, error) {
input = strings.TrimSpace(input)
if input == "" {
pool := scanPool()
out := make([]scanTarget, 0, len(pool))
for _, host := range pool {
out = append(out, scanTarget{Host: host, Port: 443})
}
return out, nil
}
out := []scanTarget{}
for _, part := range strings.FieldsFunc(input, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == ';'
}) {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.Contains(part, "/") {
hosts, err := expandCIDR(part)
if err != nil {
return nil, httpErr(400, "CIDR 无效: %s", part)
}
for _, host := range hosts {
out = append(out, scanTarget{Host: host, Port: 443})
}
continue
}
host, port := part, 443
if h, p, err := net.SplitHostPort(part); err == nil {
n, err := strconv.Atoi(p)
if err != nil || n < 1 || n > 65535 {
return nil, httpErr(400, "端口无效: %s", part)
}
host, port = h, n
}
host = strings.Trim(host, "[]")
if host == "" || strings.ContainsAny(host, " \t") {
return nil, httpErr(400, "目标无效: %s", part)
}
out = append(out, scanTarget{Host: host, Port: port})
if len(out) > scanMaxTargets {
return nil, httpErr(400, "一次最多扫描 %d 个目标", scanMaxTargets)
}
}
if len(out) == 0 {
return nil, httpErr(400, "没有可扫描的目标")
}
if len(out) > scanMaxTargets {
out = out[:scanMaxTargets]
}
return out, nil
}
func expandCIDR(cidr string) ([]string, error) {
ip, network, err := net.ParseCIDR(cidr)
if err != nil {
return nil, err
}
if ip.To4() == nil {
return nil, fmt.Errorf("only IPv4 CIDR supported")
}
base := network.IP.To4()
mask := binary.BigEndian.Uint32(network.Mask)
start := binary.BigEndian.Uint32(base) & mask
count := uint32(1) << (32 - onesCount(network.Mask))
hosts := []string{}
for i := uint32(0); i < count && len(hosts) < scanCIDRMaxHosts; i++ {
addr := make(net.IP, 4)
binary.BigEndian.PutUint32(addr, start+i)
hosts = append(hosts, addr.String())
}
return hosts, nil
}
func onesCount(mask net.IPMask) int {
ones, _ := mask.Size()
return ones
}
type scanResult struct {
Target string `json:"target"`
Host string `json:"host"`
Port int `json:"port"`
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
TLSVersion string `json:"tlsVersion,omitempty"`
ALPN string `json:"alpn,omitempty"`
KeyExchange string `json:"keyExchange,omitempty"`
KeyExchangeOK bool `json:"keyExchangeOk"`
CertName string `json:"certName,omitempty"`
LatencyMs int64 `json:"latencyMs"`
}
func tlsVersionName(v uint16) string {
switch v {
case tls.VersionTLS13:
return "TLS 1.3"
case tls.VersionTLS12:
return "TLS 1.2"
case tls.VersionTLS11:
return "TLS 1.1"
case tls.VersionTLS10:
return "TLS 1.0"
}
return ""
}
// dialScanTLS performs one bounded TLS handshake. curves, when
// non-nil, restricts the offered key-exchange groups — the only way a
// Go TLS client can learn which groups a server accepts (the negotiated
// group is not exposed on ConnectionState in this Go version).
func dialScanTLS(target scanTarget, curves []tls.CurveID) (*tls.Conn, error) {
dialer := &net.Dialer{Timeout: scanProbeTimeout}
conf := &tls.Config{
ServerName: target.Host,
NextProtos: []string{"h2", "http/1.1"},
MinVersion: tls.VersionTLS12,
// The probe judges reachability and key exchange, not trust:
// Reality dests are public sites whose cert the panel has no
// business pinning. The SAN is still reported for the operator.
InsecureSkipVerify: true, //nolint:gosec -- probe only, see above
}
if curves != nil {
conf.CurvePreferences = curves
}
return tls.DialWithDialer(dialer, "tcp", net.JoinHostPort(target.Host, strconv.Itoa(target.Port)), conf)
}
func curveSupported(target scanTarget, curves ...tls.CurveID) bool {
conn, err := dialScanTLS(target, curves)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// probeRealityTarget judges a target by the Reality dest criteria: a
// TLS handshake completes and the key exchange can be X25519 or
// X25519MLKEM768 (probed strongest first; a combined-offer handshake
// is the fallback so a server that refuses single-group offers is not
// misjudged).
func probeRealityTarget(target scanTarget) scanResult {
result := scanResult{Target: target.Host, Host: target.Host, Port: target.Port}
start := time.Now()
conn, err := dialScanTLS(target, nil)
result.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
result.Reason = "连接或握手失败: " + truncateRunes(err.Error(), 120)
return result
}
state := conn.ConnectionState()
_ = conn.Close()
result.TLSVersion = tlsVersionName(state.Version)
result.ALPN = state.NegotiatedProtocol
if len(state.PeerCertificates) > 0 {
cert := state.PeerCertificates[0]
if len(cert.DNSNames) > 0 {
result.CertName = cert.DNSNames[0]
} else {
result.CertName = cert.Subject.CommonName
}
}
switch {
case curveSupported(target, tls.X25519MLKEM768):
result.KeyExchange = "X25519MLKEM768"
case curveSupported(target, tls.X25519):
result.KeyExchange = "X25519"
case curveSupported(target, tls.X25519MLKEM768, tls.X25519):
result.KeyExchange = "X25519 / X25519MLKEM768"
}
result.KeyExchangeOK = result.KeyExchange != ""
if !result.KeyExchangeOK {
result.Reason = "密钥交换不支持 X25519"
return result
}
result.OK = true
return result
}
// scanRealityTargets probes all targets with bounded concurrency and
// returns results in input order.
func scanRealityTargets(targets []scanTarget) []scanResult {
results := make([]scanResult, len(targets))
sem := make(chan struct{}, scanConcurrency)
var wg sync.WaitGroup
for i, target := range targets {
wg.Add(1)
go func(i int, target scanTarget) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
results[i] = probeRealityTarget(target)
}(i, target)
}
wg.Wait()
return results
}
+122
View File
@@ -0,0 +1,122 @@
package main
// Item 16 scanner tests: input parsing (offline) and the probe against
// a local TLS server (loopback only — the live-domain check happens in
// the sandbox smoke run).
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"strings"
"testing"
"time"
)
func TestParseScanTargetsPool(t *testing.T) {
targets, err := parseScanTargets("")
if err != nil {
t.Fatal(err)
}
if len(targets) < len(RealityPresets) || len(targets) > scanMaxTargets {
t.Fatalf("pool size %d out of bounds", len(targets))
}
seen := map[string]bool{}
for _, target := range targets {
seen[target.Host] = true
if target.Port != 443 {
t.Fatalf("pool target %s port %d, want 443", target.Host, target.Port)
}
}
for _, want := range []string{"www.microsoft.com", "www.google.com", "www.nvidia.com", "www.samsung.com", "www.intel.com"} {
if !seen[want] {
t.Fatalf("pool missing %s", want)
}
}
}
func TestParseScanTargetsInput(t *testing.T) {
targets, err := parseScanTargets("a.example.com, b.example.com:8443 203.0.113.7")
if err != nil {
t.Fatal(err)
}
if len(targets) != 3 || targets[0].Port != 443 || targets[1].Port != 8443 || targets[2].Host != "203.0.113.7" {
t.Fatalf("targets %+v", targets)
}
cidr, err := parseScanTargets("203.0.113.0/30")
if err != nil {
t.Fatal(err)
}
if len(cidr) != 4 {
t.Fatalf("cidr /30 expanded to %d hosts, want 4", len(cidr))
}
if _, err := parseScanTargets("10.0.0.0/33"); err == nil {
t.Fatal("invalid CIDR must error")
}
if _, err := parseScanTargets("a.example.com:99999"); err == nil {
t.Fatal("invalid port must error")
}
many := strings.TrimSuffix(strings.Repeat("a.example.com,", scanMaxTargets+1), ",")
if _, err := parseScanTargets(many); err == nil {
t.Fatal("over-cap input must error")
}
}
func TestProbeRealityTargetLocalTLS(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
DNSNames: []string{"localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
cert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
_ = conn.(*tls.Conn).Handshake()
}()
}
}()
port := listener.Addr().(*net.TCPAddr).Port
result := probeRealityTarget(scanTarget{Host: "127.0.0.1", Port: port})
if !result.OK {
t.Fatalf("local TLS target must be OK: %+v", result)
}
if result.TLSVersion != "TLS 1.3" {
t.Fatalf("tlsVersion %q, want TLS 1.3", result.TLSVersion)
}
if result.KeyExchange == "" || !result.KeyExchangeOK {
t.Fatalf("keyExchange %q ok=%v", result.KeyExchange, result.KeyExchangeOK)
}
// A dead port reports not-OK with a reason, never a panic.
dead := probeRealityTarget(scanTarget{Host: "127.0.0.1", Port: 1})
if dead.OK || dead.Reason == "" {
t.Fatalf("dead target: %+v", dead)
}
}
+197
View File
@@ -0,0 +1,197 @@
package main
// Tests for round-3 item 23: binding-class rebuilds are coalesced in a
// FIXED 10-second window (anchored at the first pending change, never
// extended), manual deploy stays immediate, and a pending apply never
// resurrects a node removed inside the window.
import (
"encoding/json"
"testing"
"time"
)
func coalesceFixture(t *testing.T) (*Store, *Orchestrator, *Node) {
t.Helper()
store, _ := newTestStore(t)
d := store.Data
d.Servers = []*Server{{ID: "srv_1", Name: "S1", PublicAddress: "entry.example.com",
PortRangeStart: 20000, PortRangeEnd: 50000, Status: "online", LastSeenAt: StrPtr(NowISO())}}
for i := 1; i <= 5; i++ {
d.Customers = append(d.Customers, &Customer{
ID: "cus_" + string(rune('0'+i)), Name: "C", Status: "active",
Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-00000000000" + string(rune('0'+i)), RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd000" + string(rune('0'+i))},
})
}
node := &Node{ID: "nod_1", Name: "N1", Protocol: "vless-reality-vision", ServerID: "srv_1", Topology: "direct",
Status: "active", Port: 23456, ResourceID: "res_1", EntryReady: true,
Reality: &RealityConfig{KeyID: "reality-nod_1", ServerName: "www.tesla.com", DestPort: 443}}
d.Nodes = []*Node{node}
return store, NewOrchestrator(store), node
}
func applyJobsFor(d *Data, nodeID string) []*Job {
out := []*Job{}
for _, job := range d.Jobs {
if job.NodeID == nodeID && job.Action == "apply_resource" {
out = append(out, job)
}
}
return out
}
func resourceUsers(t *testing.T, job *Job) []any {
t.Helper()
var resource struct {
Inbounds []struct {
Users []any `json:"users"`
} `json:"inbounds"`
}
if err := json.Unmarshal(job.Payload.Resource, &resource); err != nil {
t.Fatal(err)
}
if len(resource.Inbounds) != 1 {
t.Fatalf("inbounds = %d, want 1", len(resource.Inbounds))
}
return resource.Inbounds[0].Users
}
func TestCoalescedAssignsFlushOnceWithFinalState(t *testing.T) {
store, orch, node := coalesceFixture(t)
var firstMark string
for i := 1; i <= 5; i++ {
if _, err := orch.AssignCustomer(node.ID, "cus_"+string(rune('0'+i)), "admin"); err != nil {
t.Fatal(err)
}
snap := store.Snapshot()
n := snap.Nodes[0]
if !n.RebuildPending {
t.Fatal("assign must mark the node pending, not rebuild immediately")
}
if i == 1 {
firstMark = n.RebuildPendingAt
} else if n.RebuildPendingAt != firstMark {
t.Fatalf("fixed window violated: mark moved from %s to %s", firstMark, n.RebuildPendingAt)
}
if got := applyJobsFor(snap, node.ID); len(got) != 0 {
t.Fatalf("after assign %d: %d apply jobs, want 0 inside the window", i, len(got))
}
}
// Flushing before the window elapses is a no-op.
markTime, ok := ParseISO(firstMark)
if !ok {
t.Fatalf("unparseable pending mark %q", firstMark)
}
if n, err := orch.FlushPendingRebuilds(markTime.Add(5 * time.Second)); err != nil || n != 0 {
t.Fatalf("early flush = %d, %v; want 0, nil", n, err)
}
// At window end exactly one apply ships, carrying all five users.
flushed, err := orch.FlushPendingRebuilds(markTime.Add(RebuildCoalesceWindow + time.Second))
if err != nil || flushed != 1 {
t.Fatalf("flush = %d, %v; want 1, nil", flushed, err)
}
snap := store.Snapshot()
jobs := applyJobsFor(snap, node.ID)
if len(jobs) != 1 {
t.Fatalf("apply jobs = %d, want exactly 1", len(jobs))
}
if users := resourceUsers(t, jobs[0]); len(users) != 5 {
t.Fatalf("flushed resource has %d users, want all 5", len(users))
}
if snap.Nodes[0].RebuildPending {
t.Fatal("pending mark must be consumed by the flush")
}
// A second flush ships nothing more.
if n, _ := orch.FlushPendingRebuilds(markTime.Add(time.Hour)); n != 0 {
t.Fatalf("second flush = %d, want 0", n)
}
if got := applyJobsFor(store.Snapshot(), node.ID); len(got) != 1 {
t.Fatalf("apply jobs after second flush = %d, want 1", len(got))
}
}
func TestFixedWindowDoesNotSlide(t *testing.T) {
store, orch, node := coalesceFixture(t)
if _, err := orch.AssignCustomer(node.ID, "cus_1", "admin"); err != nil {
t.Fatal(err)
}
firstMark := store.Snapshot().Nodes[0].RebuildPendingAt
markTime, _ := ParseISO(firstMark)
// A change late in the window must not push the deadline out: the
// flush still happens at first-mark + window.
if _, err := orch.AssignCustomer(node.ID, "cus_2", "admin"); err != nil {
t.Fatal(err)
}
flushed, err := orch.FlushPendingRebuilds(markTime.Add(RebuildCoalesceWindow + 100*time.Millisecond))
if err != nil || flushed != 1 {
t.Fatalf("flush at first-mark+window = %d, %v; want 1 (fixed window)", flushed, err)
}
}
func TestPendingApplyDiscardedWhenNodeRemoved(t *testing.T) {
store, orch, node := coalesceFixture(t)
if _, err := orch.AssignCustomer(node.ID, "cus_1", "admin"); err != nil {
t.Fatal(err)
}
markTime, _ := ParseISO(store.Snapshot().Nodes[0].RebuildPendingAt)
if _, err := orch.RemoveNode(node.ID, "admin", true); err != nil {
t.Fatal(err)
}
flushed, err := orch.FlushPendingRebuilds(markTime.Add(time.Hour))
if err != nil || flushed != 0 {
t.Fatalf("flush after removal = %d, %v; want 0", flushed, err)
}
if got := applyJobsFor(store.Snapshot(), node.ID); len(got) != 0 {
t.Fatalf("removed node got %d apply jobs; a pending apply must never resurrect it", len(got))
}
}
func TestManualDeployImmediateAndConsumesPending(t *testing.T) {
store, orch, node := coalesceFixture(t)
if _, err := orch.AssignCustomer(node.ID, "cus_1", "admin"); err != nil {
t.Fatal(err)
}
if _, err := orch.DeployNode(node.ID, "admin"); err != nil {
t.Fatal(err)
}
snap := store.Snapshot()
if got := applyJobsFor(snap, node.ID); len(got) != 1 {
t.Fatalf("manual deploy must apply immediately: %d jobs", len(got))
}
if snap.Nodes[0].RebuildPending {
t.Fatal("manual deploy must consume the pending mark")
}
if n, _ := orch.FlushPendingRebuilds(time.Now().Add(time.Hour)); n != 0 {
t.Fatalf("flush after manual deploy = %d, want 0", n)
}
if got := applyJobsFor(store.Snapshot(), node.ID); len(got) != 1 {
t.Fatalf("apply jobs = %d, want still 1", len(got))
}
}
func TestPendingMarkSurvivesStoreReload(t *testing.T) {
store, orch, node := coalesceFixture(t)
if _, err := orch.AssignCustomer(node.ID, "cus_1", "admin"); err != nil {
t.Fatal(err)
}
// Simulate a panel restart inside the window: a fresh Store over the
// same file must still see the pending mark and flush it when due —
// the node can never be stranded un-deployed.
reloaded := NewStore(store.File)
if err := reloaded.Init(); err != nil {
t.Fatal(err)
}
n := reloaded.Data.Nodes[0]
if !n.RebuildPending {
t.Fatal("pending mark must be persisted in the data file")
}
markTime, _ := ParseISO(n.RebuildPendingAt)
orch2 := NewOrchestrator(reloaded)
flushed, err := orch2.FlushPendingRebuilds(markTime.Add(RebuildCoalesceWindow + time.Second))
if err != nil || flushed != 1 {
t.Fatalf("post-restart flush = %d, %v; want 1", flushed, err)
}
if got := applyJobsFor(reloaded.Snapshot(), node.ID); len(got) != 1 {
t.Fatalf("apply jobs after restart flush = %d, want 1", len(got))
}
}
+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)
}
}
+174
View File
@@ -0,0 +1,174 @@
package main
// Tests for round-3 items 20+21: build-before-bind (no client-count
// deploy gate, no auto-removal at zero bindings) and the socks5-auth
// node-level fixed account.
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
func socksNodeData() *Data {
d := EmptyData()
d.Servers = []*Server{{ID: "srv_1", Name: "S1", PublicAddress: "entry.example.com", PortRangeStart: 20000, PortRangeEnd: 50000,
Status: "online", LastSeenAt: StrPtr(NowISO())}}
d.Customers = []*Customer{
{ID: "cus_1", Name: "A", Status: "active", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001"}},
{ID: "cus_2", Name: "B", Status: "active", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000002", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0002"}},
}
return d
}
func TestSocksInboundAlwaysFixedAccount(t *testing.T) {
node := &Node{ID: "nod_sk", Protocol: "socks5-auth", Port: 23462, NetworkMode: "ipv4",
SocksUser: "fixeduser", SocksPassword: "fixedpass"}
server := &Server{ID: "srv_1", PublicAddress: "entry.example.com"}
for _, nClients := range []int{0, 1, 2} {
clients := []*NodeClient{}
for i := 0; i < nClients; i++ {
clients = append(clients, &NodeClient{NodeID: node.ID, CustomerID: "cus_1", Status: "active",
Credentials: &Credentials{ClientID: "x", RelayPassword: "y", ShortID: "z"}})
}
inbound, err := buildSingBoxInbound("t-in", node, clients, server, "")
if err != nil {
t.Fatalf("clients=%d: %v", nClients, err)
}
users, _ := inbound["users"].([]any)
if len(users) != 1 {
t.Fatalf("clients=%d: users = %d, want exactly the fixed account", nClients, len(users))
}
u, _ := users[0].(map[string]any)
if u["username"] != "fixeduser" || u["password"] != "fixedpass" {
t.Fatalf("clients=%d: user = %v, want the fixed pair", nClients, u)
}
}
// A node without a stored pair gets one minted, never an ng: identity.
bare := &Node{ID: "nod_bare", Protocol: "socks5-auth", Port: 23463, NetworkMode: "ipv4"}
if _, err := buildSingBoxInbound("t-in", bare, nil, server, ""); err != nil {
t.Fatal(err)
}
if bare.SocksUser == "" || strings.HasPrefix(bare.SocksUser, "ng:") {
t.Fatalf("minted user %q must exist and avoid the ng: namespace", bare.SocksUser)
}
if raw, err := base64.StdEncoding.DecodeString(bare.SocksPassword); err != nil || len(raw) != 32 {
t.Fatalf("minted password must be a base64 32-byte key, got %q", bare.SocksPassword)
}
}
func TestSocksMigrationIdempotent(t *testing.T) {
d := socksNodeData()
d.Nodes = []*Node{
{ID: "nod_old", Protocol: "socks5-auth", Status: "active"},
{ID: "nod_set", Protocol: "socks5-auth", Status: "active", SocksUser: "kept", SocksPassword: "keptpw"},
{ID: "nod_vless", Protocol: "vless-reality", Status: "active"},
}
if !MigrateSocksNodeCredentials(d) {
t.Fatal("first migration must report a change")
}
old := d.Nodes[0]
if old.SocksUser == "" || old.SocksPassword == "" {
t.Fatal("legacy socks node must receive a fixed pair")
}
if d.Nodes[1].SocksUser != "kept" || d.Nodes[1].SocksPassword != "keptpw" {
t.Fatal("an existing pair must be preserved")
}
if d.Nodes[2].SocksUser != "" {
t.Fatal("non-socks nodes must not gain an account")
}
user, password := old.SocksUser, old.SocksPassword
if MigrateSocksNodeCredentials(d) {
t.Fatal("second migration must be a no-op")
}
if old.SocksUser != user || old.SocksPassword != password {
t.Fatal("migration must be idempotent")
}
}
func TestSocksMigrationRunsOnStoreLoad(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "data.json")
d := socksNodeData()
seed := EmptyData()
seed.Servers = d.Servers
seed.Customers = d.Customers
seed.Nodes = []*Node{{ID: "nod_old", Protocol: "socks5-auth", Status: "active"}}
raw, err := marshalJSON(seed)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file, append(raw, '\n'), 0o600); err != nil {
t.Fatal(err)
}
store := NewStore(file)
if err := store.Init(); err != nil {
t.Fatal(err)
}
node := store.Data.Nodes[0]
if node.SocksUser == "" || node.SocksPassword == "" {
t.Fatal("store load must migrate legacy socks nodes")
}
// And the migration is persisted, not just in memory.
reloaded := NewStore(file)
if err := reloaded.Init(); err != nil {
t.Fatal(err)
}
if reloaded.Data.Nodes[0].SocksUser != node.SocksUser {
t.Fatal("migrated pair must be persisted to disk")
}
}
func TestZeroClientDeployNotGated(t *testing.T) {
d := socksNodeData()
vless := &Node{ID: "nod_v", Protocol: "vless-reality-vision", ServerID: "srv_1", Topology: "direct", Status: "draft", Port: 23456,
Reality: &RealityConfig{KeyID: "reality-nod_v", ServerName: "www.tesla.com", DestPort: 443}}
socks := &Node{ID: "nod_s", Protocol: "socks5-auth", ServerID: "srv_1", Topology: "direct", Status: "draft", Port: 23457}
d.Nodes = []*Node{vless, socks}
for _, node := range d.Nodes {
if err := ValidateNodeDeploy(d, node); err != nil {
t.Fatalf("%s zero-client deploy must be allowed: %v", node.Protocol, err)
}
}
}
func TestZeroClientRebuildKeepsNode(t *testing.T) {
d := socksNodeData()
node := &Node{ID: "nod_v", Protocol: "vless-reality-vision", ServerID: "srv_1", Topology: "direct", Status: "active",
ResourceID: "res_v", Reality: &RealityConfig{KeyID: "reality-nod_v", ServerName: "www.tesla.com", DestPort: 443}}
d.Nodes = []*Node{node}
if err := RebuildNode(d, node, true); err != nil {
t.Fatal(err)
}
if node.Status == "removing" {
t.Fatal("zero-client rebuild must not auto-remove the node")
}
applies, deletes := 0, 0
for _, job := range d.Jobs {
if job.NodeID == node.ID && job.Action == "apply_resource" {
applies++
}
if job.NodeID == node.ID && job.Action == "delete_resource" {
deletes++
}
}
if applies != 1 || deletes != 0 {
t.Fatalf("jobs: applies=%d deletes=%d, want 1/0", applies, deletes)
}
}
func TestSocksSubscriptionCarriesFixedAccount(t *testing.T) {
d := socksNodeData()
node := &Node{ID: "nod_s", Name: "Socks", Protocol: "socks5-auth", ServerID: "srv_1", Topology: "direct",
NetworkMode: "ipv4", Port: 23462, Status: "active", SocksUser: "fixeduser", SocksPassword: "fixedpass"}
d.Nodes = []*Node{node}
for _, customerID := range []string{"cus_1", "cus_2"} {
nc := &NodeClient{NodeID: node.ID, CustomerID: customerID, Status: "active"}
uri := NodeClientURI(d, nc)
if !strings.HasPrefix(uri, "socks5://fixeduser:fixedpass@entry.example.com:23462") {
t.Fatalf("%s: uri = %q, want the fixed account", customerID, uri)
}
}
}
+87 -1
View File
@@ -6,6 +6,7 @@ package main
// the same serialization here.
import (
"encoding/base64"
"errors"
"fmt"
"os"
@@ -43,7 +44,7 @@ func EmptyData() *Data {
SchemaVersion: 1,
CreatedAt: NowISO(),
Settings: Settings{
BrandName: "NexusGate",
BrandName: "amo",
SessionHours: 12,
ObservationTtlMinutes: 10,
ActivityRetentionDays: 30,
@@ -133,10 +134,95 @@ func (s *Store) Init() error {
if err := validateData(&data); err != nil {
return err
}
migrated := MigrateCustomerCredentials(&data)
if MigrateSocksNodeCredentials(&data) {
migrated = true
}
if migrated {
// Persist the converged credential model immediately, so a crash
// before the next transaction cannot resurrect per-binding sets.
if err := s.persist(&data); err != nil {
return err
}
}
s.Data = &data
return nil
}
// MigrateSocksNodeCredentials gives every stored socks5-auth node its
// node-level fixed account (round-3 item 21): nodes created before the
// fixed-account model carried per-customer users derived at build time
// and stored no account of their own. A node missing either half gets a
// freshly minted pair (EnsureSocksCredentials only fills empty fields,
// so the migration is idempotent). Returns true when anything changed.
func MigrateSocksNodeCredentials(d *Data) bool {
changed := false
for _, node := range d.Nodes {
if node.Protocol != "socks5-auth" {
continue
}
user, password := node.SocksUser, node.SocksPassword
EnsureSocksCredentials(node)
if node.SocksUser != user || node.SocksPassword != password {
changed = true
}
}
return changed
}
// MigrateCustomerCredentials converges legacy per-binding credentials into
// each customer's single set (round-2 item 2; the 3x-ui model of one
// credential row per client). Deterministic rules:
// - A customer with a complete stored set keeps it.
// - Otherwise the set is copied from the customer's first binding (in
// stored order) that carries credentials; a customer with no usable
// binding credentials gets a fresh set.
// - The relay password must be a base64 32-byte root key, because SS2022
// user keys derive from it by prefix truncation (EffectiveRelayPassword).
// If the converged password does not decode to exactly 32 bytes, only
// the password is regenerated — clientId and shortId are preserved.
// - Every binding's stored credentials are then cleared, so bindings stop
// carrying credentials at all.
//
// Returns true when anything changed.
func MigrateCustomerCredentials(d *Data) bool {
changed := false
for _, customer := range d.Customers {
var creds *Credentials
if customer.Credentials != nil && customer.Credentials.ClientID != "" {
copied := *customer.Credentials
creds = &copied
} else {
for _, nc := range d.NodeClients {
if nc.CustomerID == customer.ID && nc.Credentials != nil && nc.Credentials.ClientID != "" {
copied := *nc.Credentials
creds = &copied
break
}
}
}
if creds == nil {
fresh := NewCustomerCredentials()
creds = &fresh
}
if raw, err := base64.StdEncoding.DecodeString(creds.RelayPassword); err != nil || len(raw) != 32 {
creds.RelayPassword = b64(32)
}
old := customer.Credentials
if old == nil || old.ClientID != creds.ClientID || old.RelayPassword != creds.RelayPassword || old.ShortID != creds.ShortID {
customer.Credentials = creds
changed = true
}
}
for _, nc := range d.NodeClients {
if nc.Credentials != nil {
nc.Credentials = nil
changed = true
}
}
return changed
}
// Snapshot returns a deep copy of the current data (JS structuredClone).
func (s *Store) Snapshot() *Data {
s.mu.Lock()
+1 -1
View File
@@ -30,7 +30,7 @@ func TestInitCreatesMissingFile(t *testing.T) {
if s.Data.SchemaVersion != 1 {
t.Errorf("SchemaVersion = %d", s.Data.SchemaVersion)
}
if s.Data.Settings.BrandName != "NexusGate" || s.Data.Settings.SessionHours != 12 ||
if s.Data.Settings.BrandName != "amo" || s.Data.Settings.SessionHours != 12 ||
s.Data.Settings.ObservationTtlMinutes != 10 || s.Data.Settings.ActivityRetentionDays != 30 ||
s.Data.Settings.CompletedJobRetentionDays != 7 {
t.Errorf("settings defaults = %+v", s.Data.Settings)
+114 -7
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
)
@@ -107,15 +108,35 @@ func NodeClientURI(d *Data, nc *NodeClient) string {
if networkMode == "" {
networkMode = "ipv4"
}
creds := Credentials{}
if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil {
creds = *customer.Credentials
}
// Round-3 item 21: a socks node's link carries the node's fixed
// account, identical for every bound customer; the binding only
// controls whether the node appears in the subscription at all.
socksUser := NodeEmail(nc.CustomerID)
relayPassword := creds.RelayPassword
if node.Protocol == "socks5-auth" {
socksUser, relayPassword = node.SocksUser, node.SocksPassword
if socksUser == "" || relayPassword == "" {
// Legacy record the load migration has not reached (only
// possible on an in-memory fixture): mint into a throwaway
// copy — a read path must never mutate the node record.
probe := *node
EnsureSocksCredentials(&probe)
socksUser, relayPassword = probe.SocksUser, probe.SocksPassword
}
}
uri, ok := BuildClientURI(BuildClientURIParams{
Protocol: node.Protocol,
RelayServer: server,
RelayPort: node.Port,
ClientID: nc.Credentials.ClientID,
RelayPassword: nc.Credentials.RelayPassword,
ShortID: nc.Credentials.ShortID,
ClientID: creds.ClientID,
RelayPassword: relayPassword,
ShortID: creds.ShortID,
WSPath: xbDeref(node.WSPath),
SocksUser: NodeEmail(nc.CustomerID),
SocksUser: socksUser,
Reality: node.Reality,
PublicKey: publicKey,
Name: node.Name,
@@ -129,13 +150,29 @@ func NodeClientURI(d *Data, nc *NodeClient) string {
return uri
}
// sortIndexOf mirrors sortIndexOf in lib/subscriptions.js.
func sortIndexOf(d *Data, nodeID string) int {
for _, item := range d.Nodes {
if item.ID == nodeID {
return item.SortIndex
}
}
return 1 << 30
}
func Entries(d *Data, customerID string) []SubEntry {
seen := map[string]int{}
items := []SubEntry{}
bindings := []*NodeClient{}
for _, nc := range d.NodeClients {
if nc.CustomerID != customerID || nc.Status != "active" {
continue
if nc.CustomerID == customerID && nc.Status == "active" {
bindings = append(bindings, nc)
}
}
sort.SliceStable(bindings, func(i, j int) bool {
return sortIndexOf(d, bindings[i].NodeID) < sortIndexOf(d, bindings[j].NodeID)
})
for _, nc := range bindings {
uri := NodeClientURI(d, nc)
if uri == "" {
continue
@@ -663,8 +700,78 @@ func MakeSingBox(items []SubEntry) string {
var clashUARe = regexp.MustCompile(`(?i)clash|mihomo`)
// AggregateEntries mirrors aggregateEntries in lib/subscriptions.js:
// own entries (sortIndex order) → manual links → external sources,
// deduped by scheme+host+port+credential fingerprint, first wins.
func AggregateEntries(d *Data, customer *Customer) []SubEntry {
own := Entries(d, customer.ID)
out := []SubEntry{}
seenKeys := map[string]bool{}
seenNames := map[string]int{}
push := func(uri, name string) {
if uri == "" {
return
}
key := DedupKey(uri)
if key != "" && seenKeys[key] {
return
}
if key != "" {
seenKeys[key] = true
}
index := seenNames[name] + 1
seenNames[name] = index
finalName := name
if index != 1 {
finalName = fmt.Sprintf("%s (%d)", name, index)
}
out = append(out, SubEntry{ClientURI: WithName(uri, finalName)})
}
for _, entry := range own {
key := DedupKey(entry.ClientURI)
if key != "" {
seenKeys[key] = true
}
seenNames[NameFromURI(entry.ClientURI)]++
out = append(out, entry)
}
for i, link := range customer.ManualLinks {
if !link.Enabled {
continue
}
name := link.Remark
if name == "" {
name = NameFromURI(link.URI)
}
if name == "" {
name = fmt.Sprintf("手动节点 %d", i+1)
}
push(link.URI, name)
}
for _, source := range customer.ExternalSources {
if !source.Enabled {
continue
}
entry := d.SubSourceCache[source.ID]
if entry == nil || entry.OkAt == nil {
continue
}
for _, item := range entry.Items {
name := item.Name
if name == "" {
name = "未命名节点"
}
if source.Remark != "" {
name = "[" + source.Remark + "] " + name
}
push(item.URI, name)
}
}
return out
}
func FormatSubscription(d *Data, customer *Customer, format, userAgent string) (*SubResult, bool) {
items := Entries(d, customer.ID)
items := AggregateEntries(d, customer)
if len(items) == 0 {
return nil, false
}
+19 -14
View File
@@ -30,32 +30,37 @@ func goldenSubData() *Data {
Topology: "direct", NetworkMode: "ipv4", Port: 23461, Status: "active",
WSPath: StrPtr("/wspath123"), Artifacts: map[string]json.RawMessage{}},
{ID: "nod_sk", Name: "Socks", ServerID: "srv_entry", Protocol: "socks5-auth",
Topology: "direct", NetworkMode: "ipv4", Port: 23462, Status: "active", Artifacts: map[string]json.RawMessage{}},
Topology: "direct", NetworkMode: "ipv4", Port: 23462, Status: "active", Artifacts: map[string]json.RawMessage{},
// Round-3 item 21: the pair lives on the node now. The values
// deliberately equal the old per-binding derivation
// (ng:cus_1 + the customer's relay password) so every format
// body stays byte-identical — the change is only in where
// the pair comes from.
SocksUser: "ng:cus_1", SocksPassword: "FRYXGBkaGxwdHh8gISIjJA=="},
{ID: "nod_rv", Name: "RV-Plain", ServerID: "srv_v6", Protocol: "vless-reality",
Topology: "direct", NetworkMode: "ipv4", Port: 23463, Status: "active",
Reality: &RealityConfig{KeyID: "reality-nod_rv", ServerName: "www.apple.com", DestPort: 443},
Artifacts: art("FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu")},
}
nc := func(nodeID, clientID, relayPassword, shortID string) *NodeClient {
return &NodeClient{
NodeID: nodeID, CustomerID: "cus_1", Status: "active",
Credentials: Credentials{ClientID: clientID, RelayPassword: relayPassword, ShortID: shortID},
}
nc := func(nodeID string) *NodeClient {
return &NodeClient{NodeID: nodeID, CustomerID: "cus_1", Status: "active"}
}
clients := []*NodeClient{
nc("nod_rf", "11111111-2222-3333-4444-000000000001", "unused", "abcd0001ef01"),
nc("nod_ss", "11111111-2222-3333-4444-000000000003", "FRYXGBkaGxwdHh8gISIjJA==", "abcd0003ef01"),
nc("nod_hy", "11111111-2222-3333-4444-000000000004", "HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz", "abcd0004ef01"),
nc("nod_any", "11111111-2222-3333-4444-000000000005", "IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6", "abcd0005ef01"),
nc("nod_ws", "11111111-2222-3333-4444-000000000006", "unused", "abcd0006ef01"),
nc("nod_sk", "11111111-2222-3333-4444-000000000007", "MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI", "abcd0007ef01"),
nc("nod_rv", "11111111-2222-3333-4444-000000000008", "unused", "abcd0008ef01"),
nc("nod_rf"), nc("nod_ss"), nc("nod_hy"), nc("nod_any"),
nc("nod_ws"), nc("nod_sk"), nc("nod_rv"),
}
return &Data{
Servers: []*Server{entryServer, v6Server},
Nodes: nodes,
NodeClients: clients,
Customers: []*Customer{{ID: "cus_1"}},
// One credential set for the whole customer (round-2 item 2). The
// values are the old RF binding's uuid/shortId and the old SS
// binding's password, so the RF and SS entries are byte-stable
// against the previous golden file; every other entry now carries
// this same set.
Customers: []*Customer{{ID: "cus_1", Credentials: &Credentials{
ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001ef01",
}}},
}
}
+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,
}
}
+3 -3
View File
@@ -12,7 +12,7 @@
{
"type": "vless",
"tag": "ng-nodrf-in",
"listen": "203.0.113.10",
"listen": "0.0.0.0",
"listen_port": 23456,
"users": [
{
@@ -76,7 +76,7 @@
{
"type": "shadowsocks",
"tag": "ng-nodrf-exit-in",
"listen": "203.0.113.20",
"listen": "0.0.0.0",
"listen_port": 23457,
"method": "2022-blake3-aes-256-gcm",
"password": "AwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISI="
@@ -111,7 +111,7 @@
{
"type": "shadowsocks",
"tag": "ng-nodss-in",
"listen": "203.0.113.11",
"listen": "0.0.0.0",
"listen_port": 23458,
"method": "2022-blake3-aes-128-gcm",
"password": "CwwNDg8QERITFBUWFxgZGg==",
+32 -32
View File
@@ -2,7 +2,7 @@
"entries": [
{
"nodeId": "nod_rf",
"clientUri": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23456?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu&sid=abcd0001ef01&type=tcp&headerType=none&flow=xtls-rprx-vision#RF-Reality"
"clientUri": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23456?encryption=none\u0026security=reality\u0026sni=www.amd.com\u0026fp=chrome\u0026pbk=FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\u0026sid=abcd0001ef01\u0026type=tcp\u0026headerType=none\u0026flow=xtls-rprx-vision#RF-Reality"
},
{
"nodeId": "nod_ss",
@@ -10,56 +10,56 @@
},
{
"nodeId": "nod_hy",
"clientUri": "hysteria2://HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz@entry.example.com:23459?sni=tls.example.com&alpn=h3#HY2"
"clientUri": "hysteria2://FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23459?sni=tls.example.com\u0026alpn=h3#HY2"
},
{
"nodeId": "nod_any",
"clientUri": "anytls://IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6@entry.example.com:23460?sni=tls.example.com#AnyTLS"
"clientUri": "anytls://FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23460?sni=tls.example.com#AnyTLS"
},
{
"nodeId": "nod_ws",
"clientUri": "vless://11111111-2222-3333-4444-000000000006@entry.example.com:23461?encryption=none&security=tls&type=ws&path=%2Fwspath123&sni=tls.example.com#WS-TLS"
"clientUri": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23461?encryption=none\u0026security=tls\u0026type=ws\u0026path=%2Fwspath123\u0026sni=tls.example.com#WS-TLS"
},
{
"nodeId": "nod_sk",
"clientUri": "socks5://ng%3Acus_1:MTIzNDU2Nzg5Ojs8PT4%2FQEFCQ0RFRkdI@entry.example.com:23462#Socks"
"clientUri": "socks5://ng%3Acus_1:FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23462#Socks"
},
{
"nodeId": "nod_rv",
"clientUri": "vless://11111111-2222-3333-4444-000000000008@v6.example.com:23463?encryption=none&security=reality&sni=www.apple.com&fp=chrome&pbk=FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu&sid=abcd0008ef01&type=tcp&headerType=none#RV-Plain"
"clientUri": "vless://11111111-2222-3333-4444-000000000001@v6.example.com:23463?encryption=none\u0026security=reality\u0026sni=www.apple.com\u0026fp=chrome\u0026pbk=FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\u0026sid=abcd0001ef01\u0026type=tcp\u0026headerType=none#RV-Plain"
}
],
"formats": {
"raw": {
"body": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23456?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu&sid=abcd0001ef01&type=tcp&headerType=none&flow=xtls-rprx-vision#RF-Reality\nss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206Q3d3TkRnOFFFUklURkJVV0Z4Z1pHZz09OkZSWVhHQmthR3h3ZEhoOGdJU0lqSkE9PQ@entry.example.com:23458#SS-Direct\nhysteria2://HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz@entry.example.com:23459?sni=tls.example.com&alpn=h3#HY2\nanytls://IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6@entry.example.com:23460?sni=tls.example.com#AnyTLS\nvless://11111111-2222-3333-4444-000000000006@entry.example.com:23461?encryption=none&security=tls&type=ws&path=%2Fwspath123&sni=tls.example.com#WS-TLS\nsocks5://ng%3Acus_1:MTIzNDU2Nzg5Ojs8PT4%2FQEFCQ0RFRkdI@entry.example.com:23462#Socks\nvless://11111111-2222-3333-4444-000000000008@v6.example.com:23463?encryption=none&security=reality&sni=www.apple.com&fp=chrome&pbk=FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu&sid=abcd0008ef01&type=tcp&headerType=none#RV-Plain\n",
"contentType": "text/plain; charset=utf-8"
},
"base64": {
"body": "dmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NTY/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy5hbWQuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSZktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDAxZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUmZmxvdz14dGxzLXJwcngtdmlzaW9uI1JGLVJlYWxpdHkKc3M6Ly9NakF5TWkxaWJHRnJaVE10WVdWekxURXlPQzFuWTIwNlEzZDNUa1JuT0ZGRlVrbFVSa0pWVjBaNFoxcEhaejA5T2taU1dWaEhRbXRoUjNoM1pFaG9PR2RKVTBscVNrRTlQUUBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OCNTUy1EaXJlY3QKaHlzdGVyaWEyOi8vSEIwZUh5QWhJaU1rSlNZbktDa3FLeXd0TGk4d01USXpAZW50cnkuZXhhbXBsZS5jb206MjM0NTk/c25pPXRscy5leGFtcGxlLmNvbSZhbHBuPWgzI0hZMgphbnl0bHM6Ly9JeVFsSmljb0tTb3JMQzB1THpBeE1qTTBOVFkzT0RrNkBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MD9zbmk9dGxzLmV4YW1wbGUuY29tI0FueVRMUwp2bGVzczovLzExMTExMTExLTIyMjItMzMzMy00NDQ0LTAwMDAwMDAwMDAwNkBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MT9lbmNyeXB0aW9uPW5vbmUmc2VjdXJpdHk9dGxzJnR5cGU9d3MmcGF0aD0lMkZ3c3BhdGgxMjMmc25pPXRscy5leGFtcGxlLmNvbSNXUy1UTFMKc29ja3M1Oi8vbmclM0FjdXNfMTpNVEl6TkRVMk56ZzVPanM4UFQ0JTJGUUVGQ1EwUkZSa2RJQGVudHJ5LmV4YW1wbGUuY29tOjIzNDYyI1NvY2tzCnZsZXNzOi8vMTExMTExMTEtMjIyMi0zMzMzLTQ0NDQtMDAwMDAwMDAwMDA4QHY2LmV4YW1wbGUuY29tOjIzNDYzP2VuY3J5cHRpb249bm9uZSZzZWN1cml0eT1yZWFsaXR5JnNuaT13d3cuYXBwbGUuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSdktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDA4ZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUjUlYtUGxhaW4K",
"contentType": "text/plain; charset=utf-8"
},
"clash": {
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000006\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000008\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0008ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"MATCH,节点选择\"\n",
"contentType": "text/yaml; charset=utf-8"
},
"clash-smart": {
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000006\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000008\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0008ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - \"DIRECT\"\n - name: \"自动选择\"\n type: url-test\n url: \"https://www.gstatic.com/generate_204\"\n interval: 300\n tolerance: 80\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"流媒体\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"AI 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"Meta 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"GEOSITE,category-ads-all,REJECT\"\n - \"GEOSITE,private,DIRECT\"\n - \"GEOSITE,openai,AI 服务\"\n - \"GEOSITE,netflix,流媒体\"\n - \"GEOSITE,facebook,Meta 服务\"\n - \"DOMAIN-SUFFIX,fbcdn.net,Meta 服务\"\n - \"DOMAIN-SUFFIX,instagram.com,Meta 服务\"\n - \"DOMAIN-SUFFIX,whatsapp.net,Meta 服务\"\n - \"GEOSITE,geolocation-!cn,节点选择\"\n - \"GEOSITE,cn,DIRECT\"\n - \"GEOIP,CN,DIRECT\"\n - \"MATCH,节点选择\"\n",
"contentType": "text/yaml; charset=utf-8"
},
"singbox": {
"body": "{\n \"log\": {\n \"level\": \"warn\"\n },\n \"inbounds\": [\n {\n \"type\": \"mixed\",\n \"tag\": \"local\",\n \"listen\": \"127.0.0.1\",\n \"listen_port\": 2080\n }\n ],\n \"outbounds\": [\n {\n \"type\": \"selector\",\n \"tag\": \"proxy\",\n \"outbounds\": [\n \"node-1\",\n \"node-2\",\n \"node-3\",\n \"node-4\",\n \"node-5\",\n \"node-6\",\n \"node-7\"\n ]\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-1\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23456,\n \"uuid\": \"11111111-2222-3333-4444-000000000001\",\n \"flow\": \"xtls-rprx-vision\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"www.amd.com\",\n \"reality\": {\n \"enabled\": true,\n \"public_key\": \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\",\n \"short_id\": \"abcd0001ef01\"\n }\n }\n },\n {\n \"type\": \"shadowsocks\",\n \"tag\": \"node-2\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23458,\n \"method\": \"2022-blake3-aes-128-gcm\",\n \"password\": \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n },\n {\n \"type\": \"hysteria2\",\n \"tag\": \"node-3\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23459,\n \"password\": \"HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\",\n \"alpn\": [\n \"h3\"\n ]\n }\n },\n {\n \"type\": \"anytls\",\n \"tag\": \"node-4\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23460,\n \"password\": \"IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\"\n }\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-5\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23461,\n \"uuid\": \"11111111-2222-3333-4444-000000000006\",\n \"transport\": {\n \"type\": \"ws\",\n \"path\": \"/wspath123\"\n },\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\"\n }\n },\n {\n \"type\": \"socks\",\n \"tag\": \"node-6\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23462,\n \"version\": \"5\",\n \"username\": \"ng:cus_1\",\n \"password\": \"MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\"\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-7\",\n \"server\": \"v6.example.com\",\n \"server_port\": 23463,\n \"uuid\": \"11111111-2222-3333-4444-000000000008\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"www.apple.com\",\n \"reality\": {\n \"enabled\": true,\n \"public_key\": \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\",\n \"short_id\": \"abcd0008ef01\"\n }\n }\n },\n {\n \"type\": \"direct\",\n \"tag\": \"direct\"\n }\n ],\n \"route\": {\n \"final\": \"proxy\"\n }\n}\n",
"contentType": "application/json; charset=utf-8"
},
"surge": {
"body": "[General]\nloglevel = notify\n[Proxy]\nSS-Direct = ss, entry.example.com, 23458, encrypt-method=2022-blake3-aes-128-gcm, password=CwwNDg8QERITFBUWFxgZGg__:FRYXGBkaGxwdHh8gISIjJA__\nSocks = socks5, entry.example.com, 23462, ng:cus_1, MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\n[Proxy Group]\n节点选择 = select, SS-Direct, Socks, DIRECT\n自动选择 = url-test, SS-Direct, Socks, url=http://www.gstatic.com/generate_204, interval=600\n[Rule]\nGEOIP,CN,DIRECT\nFINAL,节点选择\n",
"contentType": "text/plain; charset=utf-8"
},
"auto-clash-ua": {
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"HB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"IyQlJicoKSorLC0uLzAxMjM0NTY3ODk6\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000006\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdI\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000008\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0008ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"MATCH,节点选择\"\n",
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"MATCH,节点选择\"\n",
"contentType": "text/yaml; charset=utf-8"
},
"auto-plain-ua": {
"body": "dmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NTY/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy5hbWQuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSZktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDAxZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUmZmxvdz14dGxzLXJwcngtdmlzaW9uI1JGLVJlYWxpdHkKc3M6Ly9NakF5TWkxaWJHRnJaVE10WVdWekxURXlPQzFuWTIwNlEzZDNUa1JuT0ZGRlVrbFVSa0pWVjBaNFoxcEhaejA5T2taU1dWaEhRbXRoUjNoM1pFaG9PR2RKVTBscVNrRTlQUUBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OCNTUy1EaXJlY3QKaHlzdGVyaWEyOi8vSEIwZUh5QWhJaU1rSlNZbktDa3FLeXd0TGk4d01USXpAZW50cnkuZXhhbXBsZS5jb206MjM0NTk/c25pPXRscy5leGFtcGxlLmNvbSZhbHBuPWgzI0hZMgphbnl0bHM6Ly9JeVFsSmljb0tTb3JMQzB1THpBeE1qTTBOVFkzT0RrNkBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MD9zbmk9dGxzLmV4YW1wbGUuY29tI0FueVRMUwp2bGVzczovLzExMTExMTExLTIyMjItMzMzMy00NDQ0LTAwMDAwMDAwMDAwNkBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MT9lbmNyeXB0aW9uPW5vbmUmc2VjdXJpdHk9dGxzJnR5cGU9d3MmcGF0aD0lMkZ3c3BhdGgxMjMmc25pPXRscy5leGFtcGxlLmNvbSNXUy1UTFMKc29ja3M1Oi8vbmclM0FjdXNfMTpNVEl6TkRVMk56ZzVPanM4UFQ0JTJGUUVGQ1EwUkZSa2RJQGVudHJ5LmV4YW1wbGUuY29tOjIzNDYyI1NvY2tzCnZsZXNzOi8vMTExMTExMTEtMjIyMi0zMzMzLTQ0NDQtMDAwMDAwMDAwMDA4QHY2LmV4YW1wbGUuY29tOjIzNDYzP2VuY3J5cHRpb249bm9uZSZzZWN1cml0eT1yZWFsaXR5JnNuaT13d3cuYXBwbGUuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSdktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDA4ZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUjUlYtUGxhaW4K",
"body": "dmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NTY/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy5hbWQuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSZktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDAxZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUmZmxvdz14dGxzLXJwcngtdmlzaW9uI1JGLVJlYWxpdHkKc3M6Ly9NakF5TWkxaWJHRnJaVE10WVdWekxURXlPQzFuWTIwNlEzZDNUa1JuT0ZGRlVrbFVSa0pWVjBaNFoxcEhaejA5T2taU1dWaEhRbXRoUjNoM1pFaG9PR2RKVTBscVNrRTlQUUBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OCNTUy1EaXJlY3QKaHlzdGVyaWEyOi8vRlJZWEdCa2FHeHdkSGg4Z0lTSWpKQSUzRCUzREBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OT9zbmk9dGxzLmV4YW1wbGUuY29tJmFscG49aDMjSFkyCmFueXRsczovL0ZSWVhHQmthR3h3ZEhoOGdJU0lqSkElM0QlM0RAZW50cnkuZXhhbXBsZS5jb206MjM0NjA/c25pPXRscy5leGFtcGxlLmNvbSNBbnlUTFMKdmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NjE/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXRscyZ0eXBlPXdzJnBhdGg9JTJGd3NwYXRoMTIzJnNuaT10bHMuZXhhbXBsZS5jb20jV1MtVExTCnNvY2tzNTovL25nJTNBY3VzXzE6RlJZWEdCa2FHeHdkSGg4Z0lTSWpKQSUzRCUzREBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MiNTb2Nrcwp2bGVzczovLzExMTExMTExLTIyMjItMzMzMy00NDQ0LTAwMDAwMDAwMDAwMUB2Ni5leGFtcGxlLmNvbToyMzQ2Mz9lbmNyeXB0aW9uPW5vbmUmc2VjdXJpdHk9cmVhbGl0eSZzbmk9d3d3LmFwcGxlLmNvbSZmcD1jaHJvbWUmcGJrPUZJWEVEUEJLUnZLZXkwMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFyc3R1JnNpZD1hYmNkMDAwMWVmMDEmdHlwZT10Y3AmaGVhZGVyVHlwZT1ub25lI1JWLVBsYWluCg==",
"contentType": "text/plain; charset=utf-8"
},
"base64": {
"body": "dmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NTY/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy5hbWQuY29tJmZwPWNocm9tZSZwYms9RklYRURQQktSZktleTAxMjM0NTY3ODlhYmNkZWZnaGlqa2xtbm9wcXJzdHUmc2lkPWFiY2QwMDAxZWYwMSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUmZmxvdz14dGxzLXJwcngtdmlzaW9uI1JGLVJlYWxpdHkKc3M6Ly9NakF5TWkxaWJHRnJaVE10WVdWekxURXlPQzFuWTIwNlEzZDNUa1JuT0ZGRlVrbFVSa0pWVjBaNFoxcEhaejA5T2taU1dWaEhRbXRoUjNoM1pFaG9PR2RKVTBscVNrRTlQUUBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OCNTUy1EaXJlY3QKaHlzdGVyaWEyOi8vRlJZWEdCa2FHeHdkSGg4Z0lTSWpKQSUzRCUzREBlbnRyeS5leGFtcGxlLmNvbToyMzQ1OT9zbmk9dGxzLmV4YW1wbGUuY29tJmFscG49aDMjSFkyCmFueXRsczovL0ZSWVhHQmthR3h3ZEhoOGdJU0lqSkElM0QlM0RAZW50cnkuZXhhbXBsZS5jb206MjM0NjA/c25pPXRscy5leGFtcGxlLmNvbSNBbnlUTFMKdmxlc3M6Ly8xMTExMTExMS0yMjIyLTMzMzMtNDQ0NC0wMDAwMDAwMDAwMDFAZW50cnkuZXhhbXBsZS5jb206MjM0NjE/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXRscyZ0eXBlPXdzJnBhdGg9JTJGd3NwYXRoMTIzJnNuaT10bHMuZXhhbXBsZS5jb20jV1MtVExTCnNvY2tzNTovL25nJTNBY3VzXzE6RlJZWEdCa2FHeHdkSGg4Z0lTSWpKQSUzRCUzREBlbnRyeS5leGFtcGxlLmNvbToyMzQ2MiNTb2Nrcwp2bGVzczovLzExMTExMTExLTIyMjItMzMzMy00NDQ0LTAwMDAwMDAwMDAwMUB2Ni5leGFtcGxlLmNvbToyMzQ2Mz9lbmNyeXB0aW9uPW5vbmUmc2VjdXJpdHk9cmVhbGl0eSZzbmk9d3d3LmFwcGxlLmNvbSZmcD1jaHJvbWUmcGJrPUZJWEVEUEJLUnZLZXkwMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFyc3R1JnNpZD1hYmNkMDAwMWVmMDEmdHlwZT10Y3AmaGVhZGVyVHlwZT1ub25lI1JWLVBsYWluCg==",
"contentType": "text/plain; charset=utf-8"
},
"clash": {
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"MATCH,节点选择\"\n",
"contentType": "text/yaml; charset=utf-8"
},
"clash-smart": {
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"RF-Reality\"\n server: \"entry.example.com\"\n port: 23456\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\n flow: \"xtls-rprx-vision\"\n - name: \"SS-Direct\"\n server: \"entry.example.com\"\n port: 23458\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"HY2\"\n server: \"entry.example.com\"\n port: 23459\n type: \"hysteria2\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n alpn: [\"h3\"]\n - name: \"AnyTLS\"\n server: \"entry.example.com\"\n port: 23460\n type: \"anytls\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n sni: \"tls.example.com\"\n udp: true\n - name: \"WS-TLS\"\n server: \"entry.example.com\"\n port: 23461\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"ws\"\n ws-opts: \n path: \"/wspath123\"\n servername: \"tls.example.com\"\n - name: \"Socks\"\n server: \"entry.example.com\"\n port: 23462\n type: \"socks5\"\n username: \"ng:cus_1\"\n password: \"FRYXGBkaGxwdHh8gISIjJA==\"\n - name: \"RV-Plain\"\n server: \"v6.example.com\"\n port: 23463\n type: \"vless\"\n uuid: \"11111111-2222-3333-4444-000000000001\"\n tls: true\n network: \"tcp\"\n servername: \"www.apple.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\"\n short-id: \"abcd0001ef01\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - \"DIRECT\"\n - name: \"自动选择\"\n type: url-test\n url: \"https://www.gstatic.com/generate_204\"\n interval: 300\n tolerance: 80\n proxies:\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"流媒体\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"AI 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\n - name: \"Meta 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"RF-Reality\"\n - \"SS-Direct\"\n - \"HY2\"\n - \"AnyTLS\"\n - \"WS-TLS\"\n - \"Socks\"\n - \"RV-Plain\"\nrules:\n - \"GEOSITE,category-ads-all,REJECT\"\n - \"GEOSITE,private,DIRECT\"\n - \"GEOSITE,openai,AI 服务\"\n - \"GEOSITE,netflix,流媒体\"\n - \"GEOSITE,facebook,Meta 服务\"\n - \"DOMAIN-SUFFIX,fbcdn.net,Meta 服务\"\n - \"DOMAIN-SUFFIX,instagram.com,Meta 服务\"\n - \"DOMAIN-SUFFIX,whatsapp.net,Meta 服务\"\n - \"GEOSITE,geolocation-!cn,节点选择\"\n - \"GEOSITE,cn,DIRECT\"\n - \"GEOIP,CN,DIRECT\"\n - \"MATCH,节点选择\"\n",
"contentType": "text/yaml; charset=utf-8"
},
"raw": {
"body": "vless://11111111-2222-3333-4444-000000000001@entry.example.com:23456?encryption=none\u0026security=reality\u0026sni=www.amd.com\u0026fp=chrome\u0026pbk=FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\u0026sid=abcd0001ef01\u0026type=tcp\u0026headerType=none\u0026flow=xtls-rprx-vision#RF-Reality\nss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206Q3d3TkRnOFFFUklURkJVV0Z4Z1pHZz09OkZSWVhHQmthR3h3ZEhoOGdJU0lqSkE9PQ@entry.example.com:23458#SS-Direct\nhysteria2://FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23459?sni=tls.example.com\u0026alpn=h3#HY2\nanytls://FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23460?sni=tls.example.com#AnyTLS\nvless://11111111-2222-3333-4444-000000000001@entry.example.com:23461?encryption=none\u0026security=tls\u0026type=ws\u0026path=%2Fwspath123\u0026sni=tls.example.com#WS-TLS\nsocks5://ng%3Acus_1:FRYXGBkaGxwdHh8gISIjJA%3D%3D@entry.example.com:23462#Socks\nvless://11111111-2222-3333-4444-000000000001@v6.example.com:23463?encryption=none\u0026security=reality\u0026sni=www.apple.com\u0026fp=chrome\u0026pbk=FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\u0026sid=abcd0001ef01\u0026type=tcp\u0026headerType=none#RV-Plain\n",
"contentType": "text/plain; charset=utf-8"
},
"singbox": {
"body": "{\n \"log\": {\n \"level\": \"warn\"\n },\n \"inbounds\": [\n {\n \"type\": \"mixed\",\n \"tag\": \"local\",\n \"listen\": \"127.0.0.1\",\n \"listen_port\": 2080\n }\n ],\n \"outbounds\": [\n {\n \"type\": \"selector\",\n \"tag\": \"proxy\",\n \"outbounds\": [\n \"node-1\",\n \"node-2\",\n \"node-3\",\n \"node-4\",\n \"node-5\",\n \"node-6\",\n \"node-7\"\n ]\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-1\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23456,\n \"uuid\": \"11111111-2222-3333-4444-000000000001\",\n \"flow\": \"xtls-rprx-vision\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"www.amd.com\",\n \"reality\": {\n \"enabled\": true,\n \"public_key\": \"FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu\",\n \"short_id\": \"abcd0001ef01\"\n }\n }\n },\n {\n \"type\": \"shadowsocks\",\n \"tag\": \"node-2\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23458,\n \"method\": \"2022-blake3-aes-128-gcm\",\n \"password\": \"CwwNDg8QERITFBUWFxgZGg==:FRYXGBkaGxwdHh8gISIjJA==\"\n },\n {\n \"type\": \"hysteria2\",\n \"tag\": \"node-3\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23459,\n \"password\": \"FRYXGBkaGxwdHh8gISIjJA==\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\",\n \"alpn\": [\n \"h3\"\n ]\n }\n },\n {\n \"type\": \"anytls\",\n \"tag\": \"node-4\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23460,\n \"password\": \"FRYXGBkaGxwdHh8gISIjJA==\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\"\n }\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-5\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23461,\n \"uuid\": \"11111111-2222-3333-4444-000000000001\",\n \"transport\": {\n \"type\": \"ws\",\n \"path\": \"/wspath123\"\n },\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"tls.example.com\"\n }\n },\n {\n \"type\": \"socks\",\n \"tag\": \"node-6\",\n \"server\": \"entry.example.com\",\n \"server_port\": 23462,\n \"version\": \"5\",\n \"username\": \"ng:cus_1\",\n \"password\": \"FRYXGBkaGxwdHh8gISIjJA==\"\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-7\",\n \"server\": \"v6.example.com\",\n \"server_port\": 23463,\n \"uuid\": \"11111111-2222-3333-4444-000000000001\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"www.apple.com\",\n \"reality\": {\n \"enabled\": true,\n \"public_key\": \"FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu\",\n \"short_id\": \"abcd0001ef01\"\n }\n }\n },\n {\n \"type\": \"direct\",\n \"tag\": \"direct\"\n }\n ],\n \"route\": {\n \"final\": \"proxy\"\n }\n}\n",
"contentType": "application/json; charset=utf-8"
},
"surge": {
"body": "[General]\nloglevel = notify\n[Proxy]\nSS-Direct = ss, entry.example.com, 23458, encrypt-method=2022-blake3-aes-128-gcm, password=CwwNDg8QERITFBUWFxgZGg__:FRYXGBkaGxwdHh8gISIjJA__\nSocks = socks5, entry.example.com, 23462, ng:cus_1, FRYXGBkaGxwdHh8gISIjJA__\n[Proxy Group]\n节点选择 = select, SS-Direct, Socks, DIRECT\n自动选择 = url-test, SS-Direct, Socks, url=http://www.gstatic.com/generate_204, interval=600\n[Rule]\nGEOIP,CN,DIRECT\nFINAL,节点选择\n",
"contentType": "text/plain; charset=utf-8"
}
}
+186 -2
View File
@@ -233,6 +233,7 @@ type Data struct {
SubscriptionAccess []*SubscriptionAccess `json:"subscriptionAccess"`
SubscriptionClients []*SubscriptionClient `json:"subscriptionClients"`
KeyStore map[string]*KeyEntry `json:"keyStore"`
SubSourceCache map[string]*SubSourceCacheEntry `json:"subSourceCache,omitempty"`
Extra extraFields `json:"-"`
}
@@ -253,8 +254,85 @@ func (d Data) MarshalJSON() ([]byte, error) {
return mergeMarshal(plain(d), d.Extra)
}
// Clone is structuredClone(this.data).
// Clone is structuredClone(this.data). Since round-3 item 22 it is a
// reflective deep copy: the old JSON marshal/unmarshal round trip cost
// a full serialize + parse of the whole database (with a per-record
// map dance for unknown fields) on EVERY store read — twice per admin
// request — and dominated panel latency on the router's CPU. The data
// graph is plain exported structs/slices/maps/strings/numbers, which
// reflection copies exactly; TestCloneEquivalence proves byte-identical
// output against cloneViaJSON on the real seed database.
func (d *Data) Clone() *Data {
if d == nil {
return nil
}
return cloneReflectValue(reflect.ValueOf(d)).Interface().(*Data)
}
// cloneReflectValue deep-copies v. Structs are shallow-copied first (so
// any unexported field keeps its value) and every exported field is
// then replaced by its deep copy; []byte (incl. json.RawMessage) is
// copied verbatim; scalars pass through.
func cloneReflectValue(v reflect.Value) reflect.Value {
if !v.IsValid() {
return v
}
switch v.Kind() {
case reflect.Pointer:
if v.IsNil() {
return v
}
out := reflect.New(v.Type().Elem())
out.Elem().Set(cloneReflectValue(v.Elem()))
return out
case reflect.Interface:
if v.IsNil() {
return v
}
return reflect.ValueOf(cloneReflectValue(v.Elem()).Interface())
case reflect.Slice:
if v.IsNil() {
return v
}
if v.Type().Elem().Kind() == reflect.Uint8 {
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
reflect.Copy(out, v)
return out
}
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(cloneReflectValue(v.Index(i)))
}
return out
case reflect.Map:
if v.IsNil() {
return v
}
out := reflect.MakeMapWithSize(v.Type(), v.Len())
iter := v.MapRange()
for iter.Next() {
out.SetMapIndex(cloneReflectValue(iter.Key()), cloneReflectValue(iter.Value()))
}
return out
case reflect.Struct:
out := reflect.New(v.Type()).Elem()
out.Set(v)
for i := 0; i < v.NumField(); i++ {
if v.Type().Field(i).PkgPath != "" {
continue // unexported: kept from the shallow copy
}
out.Field(i).Set(cloneReflectValue(v.Field(i)))
}
return out
default:
return v
}
}
// cloneViaJSON is the original Clone implementation: a full JSON
// marshal/unmarshal round trip. Kept for the equivalence test and the
// item-22 benchmarks that quantify what the reflective Clone replaced.
func (d *Data) cloneViaJSON() *Data {
raw, err := marshalJSON(d)
if err != nil {
panic(err)
@@ -272,6 +350,13 @@ type Settings struct {
ObservationTtlMinutes float64 `json:"observationTtlMinutes"`
ActivityRetentionDays float64 `json:"activityRetentionDays"`
CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"`
ExternalSubRefreshMinutes float64 `json:"externalSubRefreshMinutes,omitempty"`
AllowPrivateSubSources bool `json:"allowPrivateSubSources,omitempty"`
// Depleting-soon thresholds for the customers summary, aligned with
// 3x-ui's expireDiff/trafficDiff settings (same names, units and
// factory default of 0 = nobody counts as depleting soon).
ExpireDiff float64 `json:"expireDiff,omitempty"`
TrafficDiff float64 `json:"trafficDiff,omitempty"`
Extra extraFields `json:"-"`
}
@@ -324,6 +409,7 @@ type EngineState struct {
SingBoxInstalled bool `json:"singBoxInstalled"`
Certificates []string `json:"certificates"`
SingBoxStatus string `json:"singBoxStatus"`
SingBoxVersion string `json:"singBoxVersion,omitempty"`
At string `json:"at"`
Extra extraFields `json:"-"`
}
@@ -379,6 +465,7 @@ type Server struct {
PortRangeEnd int `json:"portRangeEnd"`
Labels []string `json:"labels"`
Status string `json:"status"`
Enabled *bool `json:"enabled,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
LastSeenAt *string `json:"lastSeenAt,omitempty"`
@@ -425,6 +512,13 @@ type Agent struct {
ServerID string `json:"serverId"`
KeyHash string `json:"keyHash"`
KeyFingerprint *string `json:"keyFingerprint,omitempty"`
// SessionTokenHash is the SHA-256 hex of the short-lived agent session
// token (item 25): after one scrypt-verified key auth the panel issues
// a random token and stores only its hash here, so steady-state agent
// requests authenticate with a constant-time hash compare instead of
// a scrypt run. The plaintext token is never persisted panel-side.
SessionTokenHash *string `json:"sessionTokenHash,omitempty"`
SessionTokenExpiresAt *string `json:"sessionTokenExpiresAt,omitempty"`
Status string `json:"status"`
Hostname string `json:"hostname"`
Version string `json:"version"`
@@ -449,6 +543,15 @@ func (a Agent) MarshalJSON() ([]byte, error) {
return mergeMarshal(plain(a), a.Extra)
}
// Revoke marks the agent revoked and drops any issued session token
// (item 25), so the token dies with the revocation even if the record's
// status were flipped back later.
func (a *Agent) Revoke() {
a.Status = "revoked"
a.SessionTokenHash = nil
a.SessionTokenExpiresAt = nil
}
type EnrollmentToken struct {
ID string `json:"id"`
ServerID string `json:"serverId"`
@@ -501,6 +604,11 @@ func (c UsageCounters) MarshalJSON() ([]byte, error) {
type UsageSlot struct {
UsageCounters *UsageCounters `json:"usageCounters,omitempty"`
RetiredUsageEpochs []string `json:"retiredUsageEpochs,omitempty"`
LastSampleAt *string `json:"lastSampleAt,omitempty"`
LastActiveAt *string `json:"lastActiveAt,omitempty"`
SpeedUpBps *float64 `json:"speedUpBps,omitempty"`
SpeedDownBps *float64 `json:"speedDownBps,omitempty"`
SpeedAt *string `json:"speedAt,omitempty"`
Extra extraFields `json:"-"`
}
@@ -532,6 +640,9 @@ type Customer struct {
UsedBytes int64 `json:"usedBytes"`
UsedUplinkBytes int64 `json:"usedUplinkBytes"`
UsedDownlinkBytes int64 `json:"usedDownlinkBytes"`
TrafficResetDay int `json:"trafficResetDay,omitempty"`
TrafficResetPeriod string `json:"trafficResetPeriod,omitempty"`
TrafficResetAt *string `json:"trafficResetAt,omitempty"`
ExpiresAt *string `json:"expiresAt"`
IPLimit int64 `json:"ipLimit"`
DeviceLimit int64 `json:"deviceLimit"`
@@ -542,9 +653,50 @@ type Customer struct {
ServerUsage map[string]*UsageSlot `json:"serverUsage,omitempty"`
LastUsageAt *string `json:"lastUsageAt,omitempty"`
LastOnlineAt *string `json:"lastOnlineAt,omitempty"`
// 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 {
type plain Customer
var p plain
@@ -651,6 +803,13 @@ type Node struct {
Protocol string `json:"protocol"`
Topology string `json:"topology"`
ExitProtocol *string `json:"exitProtocol"`
// ExitMode selects the forward exit target: "" / "managed" is a panel
// server (ExitServerID); "manual" is an unmanaged landing endpoint
// described by ExitAddress + ExitPort + ExitProtocol + ExitCredentials
// (round-2 item 8). Manual exits have no exit resource and are always
// ExitReady — the panel only builds the entry-side outbound.
ExitMode string `json:"exitMode,omitempty"`
ExitAddress string `json:"exitAddress,omitempty"`
NetworkMode string `json:"networkMode"`
RealityServerName string `json:"realityServerName"`
RealityDestPort int `json:"realityDestPort"`
@@ -669,12 +828,33 @@ type Node struct {
AutoRemoving *bool `json:"autoRemoving,omitempty"`
WSPath *string `json:"wsPath,omitempty"`
SSServerKey *string `json:"ssServerKey,omitempty"`
// SocksUser/SocksPassword are the node-level fixed account of a
// socks5-auth inbound (round-3 item 21, the 3x-ui inbound model):
// the inbound's only user, carried by every subscriber's link for
// this node. Empty on legacy records until the store-load migration
// (MigrateSocksNodeCredentials) mints a pair.
SocksUser string `json:"socksUser,omitempty"`
SocksPassword string `json:"socksPassword,omitempty"`
// RebuildPending marks a node whose binding-class changes are being
// coalesced (round-3 item 23): the flusher applies ONE rebuild with
// the final state once the fixed window (from RebuildPendingAt, the
// FIRST pending change) elapses. Persisted so a panel restart inside
// the window cannot strand the node un-deployed.
RebuildPending bool `json:"rebuildPending,omitempty"`
RebuildPendingAt string `json:"rebuildPendingAt,omitempty"`
RebuildPendingExit bool `json:"rebuildPendingExit,omitempty"`
Reality *RealityConfig `json:"reality,omitempty"`
ExitCredentials *ExitCredentials `json:"exitCredentials,omitempty"`
UsageCounters *UsageCounters `json:"usageCounters"`
RetiredUsageEpochs []string `json:"retiredUsageEpochs"`
MeteredTraffic *MeteredTraffic `json:"meteredTraffic"`
LastUsageAt *string `json:"lastUsageAt"`
SortIndex int `json:"sortIndex"`
SpeedUpBps *float64 `json:"speedUpBps,omitempty"`
SpeedDownBps *float64 `json:"speedDownBps,omitempty"`
SpeedAt *string `json:"speedAt,omitempty"`
LastActiveAt *string `json:"lastActiveAt,omitempty"`
LastSampleAt *string `json:"lastSampleAt,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Extra extraFields `json:"-"`
@@ -723,7 +903,11 @@ type NodeClient struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
CustomerID string `json:"customerId"`
Credentials Credentials `json:"credentials"`
// Deprecated: credentials live on the Customer (round-2 item 2). This
// field is only a migration input — the store-load migration converges
// it into Customer.Credentials and clears it, so it disappears from
// disk on the next save. Builders receive resolved copies instead.
Credentials *Credentials `json:"credentials,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
+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
}
+1481 -468
View File
File diff suppressed because it is too large Load Diff
+35 -42
View File
@@ -3,66 +3,59 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#f5f7fb">
<title>NexusGate · 枢门控制台</title>
<link rel="stylesheet" href="/styles.css?v=0.3.0">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f0f2f5">
<title>amo</title>
<script>(function(){try{var t=localStorage.getItem('nexusgate-theme');if(t==='dark'){document.documentElement.setAttribute('data-theme','dark');document.querySelector('meta[name="theme-color"]').content='#1a1b1f';}}catch(e){}})();</script>
<link rel="stylesheet" href="/styles.css?v=0.7.0">
</head>
<body>
<div id="login" class="login-shell hidden">
<section class="login-main">
<button class="theme-button login-theme" type="button" data-theme-toggle aria-label="切换主题">浅色</button>
<div class="login-card">
<div class="brand login-brand"><span class="brand-mark">N</span><span><b>NexusGate</b><small>枢门控制台</small></span></div>
<div class="login-copy">
<h1>欢迎回来</h1>
</div>
<form id="login-form" class="stack login-form">
<label>登录账号<input name="username" autocomplete="username" placeholder="请输入账号" required></label>
<section class="login-card panel">
<div class="login-brand">amo</div>
<form id="login-form" class="stack">
<label>登录账号<input name="username" autocomplete="username" required></label>
<label>登录密码<input name="password" type="password" autocomplete="current-password" required></label>
<button class="primary full large" type="submit">登录控制台</button>
<button class="btn primary full" type="submit">登录</button>
</form>
<p id="login-error" class="form-error"></p>
</div>
<button class="theme-btn login-theme" type="button" data-theme-toggle>🌙 深色</button>
</section>
</div>
<div id="app" class="app-shell hidden">
<aside class="sidebar" id="sidebar">
<div class="brand sidebar-brand"><span class="brand-mark small">N</span><span><b>NexusGate</b><small>枢门控制台</small></span></div>
<p class="nav-caption">工作台</p>
<nav id="nav">
<button data-page="overview" class="active"><span class="nav-icon">▦</span><span class="nav-label">概览</span></button>
<p class="nav-caption nav-group">资源管理</p>
<button data-page="servers"><span class="nav-icon">◫</span><span class="nav-label">服务器</span></button>
<button data-page="customers"><span class="nav-icon">◎</span><span class="nav-label">客户与订阅</span></button>
<button data-page="chains"><span class="nav-icon">⇄</span><span class="nav-label">转发与节点</span></button>
<button data-page="commands"><span class="nav-icon">⌘</span><span class="nav-label">安装与命令</span></button>
<p class="nav-caption nav-group">安全与系统</p>
<button data-page="operations"><span class="nav-icon">⚙</span><span class="nav-label">设置与运维</span></button>
<div id="app" class="layout hidden">
<aside class="sider">
<div class="brand">amo</div>
<nav class="menu" id="nav">
<a class="mi" data-page="overview" href="#overview"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12l9-9 9 9"/><path d="M5 10v10h14V10"/></svg>系统状态</a>
<a class="mi" data-page="servers" href="#servers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>节点管理</a>
<a class="mi" data-page="customers" href="#customers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.8-3.2 3.4-5 6.5-5s5.7 1.8 6.5 5"/><path d="M16 4.6a3.5 3.5 0 010 6.8M21.5 20c-.6-2.6-2.2-4.2-4.5-4.8"/></svg>用户订阅</a>
<a class="mi" data-page="chains" href="#chains"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="12" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.3 10.8l9.4-3.6M7.3 13.2l9.4 3.6"/></svg>入站&amp;出站</a>
<a class="mi" data-page="operations" href="#operations"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 00-.1-1.2l2-1.5-2-3.4-2.3 1a7 7 0 00-2-1.2L14.2 3h-4l-.4 2.5a7 7 0 00-2 1.2l-2.3-1-2 3.4 2 1.5A7 7 0 005 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.4 2.3-1a7 7 0 002 1.2l.4 2.7h4l.4-2.5a7 7 0 002-1.2l2.3 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2z"/></svg>设置</a>
</nav>
<div class="sidebar-foot"><div><span class="live-dot"></span><span id="connection-label">控制面正常</span></div><button id="logout" class="icon-button" title="退出登录">↗</button></div>
<div class="sider-foot"><span id="user-chip"></span><span class="foot-right"><span id="version-label"></span><button id="logout" class="icon-btn" type="button" data-tip="退出登录"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg></button></span></div>
</aside>
<main class="main-shell">
<div class="main">
<header class="topbar">
<button id="menu-toggle" class="icon-button mobile-only" type="button" aria-label="展开菜单">☰</button>
<div class="title-block"><p id="breadcrumb" class="eyebrow">NEXUSGATE / 运行总览</p><h1 id="page-title">运行总览</h1></div>
<div class="top-actions"><button id="refresh" class="ghost" type="button">刷新数据</button><button class="theme-button" type="button" data-theme-toggle aria-label="切换主题">浅色</button><span id="user-chip" class="user-chip"></span></div>
<span class="crumb">amo / <b id="page-title">系统状态</b></span>
<span class="sp"></span>
<button class="theme-btn" id="refresh" type="button" title="刷新数据">刷新</button>
<button class="theme-btn" type="button" data-theme-toggle>🌙 深色</button>
</header>
<div id="content" class="content"></div>
</main>
<button id="sidebar-scrim" class="sidebar-scrim" type="button" aria-label="关闭菜单"></button>
<main class="content" id="content"></main>
</div>
</div>
<dialog id="modal">
<section class="dialog-shell">
<header><div><p id="modal-kicker" class="eyebrow"></p><h2 id="modal-title"></h2></div><button type="button" data-close class="icon-button" aria-label="关闭">×</button></header>
<div id="modal-body"></div>
<div id="modal-overlay" class="modal-overlay hidden">
<section class="modal panel" role="dialog" aria-modal="true">
<header class="modal-head"><h2 id="modal-title"></h2><button type="button" class="icon-btn" data-close aria-label="关闭" data-tip="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 6L6 18M6 6l12 12"/></svg></button></header>
<div id="modal-body" class="modal-body"></div>
</section>
</dialog>
</div>
<div id="float-pop" class="float-pop hidden"></div>
<div id="toasts" aria-live="polite"></div>
<script src="/vendor/qrcode.js?v=0.4.0" defer></script>
<script src="/app.js?v=0.4.0" defer></script>
<script src="/app.js?v=0.7.0" defer></script>
</body>
</html>
+412 -104
View File
@@ -1,111 +1,419 @@
/* amo 控制台样式 — 按 3x-ui 风格样张落地。浅色默认,[data-theme="dark"] 深色。
字体为系统字体栈,零外部依赖(路由器可离线部署)。 */
:root{
--bg: #f4f7fb; --surface: #ffffff; --surface-soft: #f8faff; --surface-hover: #f1f5ff;
--text: #172033; --muted: #6f7b91; --line: #e4e9f2; --line-strong: #d6ddea;
--primary: #4f6ef7; --primary-dark: #3c57d9; --primary-soft: #eef1ff;
--success: #159f70; --success-soft: #e9f8f2; --warning: #bc7a12; --warning-soft: #fff6df;
--danger: #d84d61; --danger-soft: #fff0f2; --shadow: 0 12px 36px rgba(34,51,84,.08); --shadow-lg: 0 28px 80px rgba(23,32,51,.16);
--radius: 16px; font-family: Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; color-scheme: light;
--bg:#f0f2f5; --sider:#ffffff; --card:#ffffff; --card-hover:#fafafa;
--border:#d9dce1; --border-soft:#e8eaed;
--text:#20242a; --text-2:#5a6068; --text-3:#8a8f98;
--primary:#1668dc; --primary-hover:#3c89e8; --primary-soft:rgba(22,104,220,.10); --primary-text:#0958d9;
--green:#52c41a; --green-text:#237804; --green-soft:rgba(82,196,26,.13);
--red:#ff4d4f; --red-text:#cf1322; --red-soft:rgba(255,77,79,.08);
--orange:#faad14; --orange-text:#975a00; --orange-soft:rgba(250,173,20,.14);
--purple:#9254de; --purple-text:#722ed1; --purple-soft:rgba(146,84,222,.10);
--cyan:#13c2c2; --cyan-text:#096f74; --cyan-soft:rgba(19,194,194,.10);
--pink-text:#c41d7f; --pink-soft:rgba(235,47,150,.09);
--sky-text:#0958d9; --sky-soft:rgba(22,104,220,.09);
--gray-soft:rgba(0,0,0,.05); --pill-bg:rgba(0,0,0,.045);
--hover-bg:rgba(0,0,0,.04); --row-hover:rgba(0,0,0,.02); --th-bg:#fafafa;
--dot-off:#c2c6cc; --tgl-off:#c9ccd3; --track:rgba(0,0,0,.08);
--pop-bg:#ffffff; --pop-shadow:0 12px 32px rgba(0,0,0,.16);
--trans-bg:rgba(82,196,26,.14); --trans-text:#237804;
--chart-2:#6f757f;
--radius:8px;
--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
--mono:"SF Mono",SFMono-Regular,Consolas,"Liberation Mono",monospace;
}
html[data-theme="dark"] {
--bg:#0e1421; --surface:#161e2e; --surface-soft:#111827; --surface-hover:#202b40; --text:#edf2fb; --muted:#93a0b6;
--line:#273248; --line-strong:#344159; --primary:#7790ff; --primary-dark:#91a4ff; --primary-soft:#222d50;
--success:#42cea0; --success-soft:#16382f; --warning:#f0bd5f; --warning-soft:#3b301b; --danger:#ff788a; --danger-soft:#45232b;
--shadow:0 16px 42px rgba(0,0,0,.2); --shadow-lg:0 30px 90px rgba(0,0,0,.45); color-scheme:dark;
[data-theme="dark"]{
--bg:#1a1b1f; --sider:#1e2024; --card:#23252b; --card-hover:#282a31;
--border:#33363e; --border-soft:#2b2d34;
--text:#e9eaee; --text-2:#a2a7b1; --text-3:#6f757f;
--primary-soft:rgba(22,104,220,.16); --primary-text:#69a7ff;
--green-text:#73d13d;
--red-text:#ff7875; --red-soft:rgba(255,77,79,.13);
--orange-text:#ffc53d; --orange-soft:rgba(250,173,20,.13);
--purple-text:#b37feb; --purple-soft:rgba(146,84,222,.17);
--cyan-text:#36cfc9; --cyan-soft:rgba(19,194,194,.13);
--pink-text:#ff85c0; --pink-soft:rgba(235,47,150,.16);
--sky-text:#8dbdff; --sky-soft:rgba(105,167,255,.15);
--gray-soft:rgba(255,255,255,.07); --pill-bg:rgba(255,255,255,.06);
--hover-bg:rgba(255,255,255,.05); --row-hover:rgba(255,255,255,.025); --th-bg:rgba(255,255,255,.02);
--dot-off:#5a5e66; --tgl-off:#3c404a; --track:rgba(255,255,255,.09);
--pop-bg:#2e3038; --pop-shadow:0 12px 32px rgba(0,0,0,.55);
--trans-bg:rgba(82,196,26,.12); --trans-text:#95de64;
--chart-2:#8a8f98;
}
*{box-sizing:border-box} html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text)} body{min-height:100vh}
button,input,select,textarea{font:inherit} button{cursor:pointer}.hidden{display:none!important}.muted{color:var(--muted)}
.eyebrow{margin:0 0 7px;color:var(--primary);font-size:11px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}
.brand{display:flex;align-items:center;gap:12px}.brand>span:last-child{display:flex;flex-direction:column;line-height:1.15}.brand b{font-size:17px;letter-spacing:-.02em}.brand small{margin-top:4px;color:var(--muted);font-size:10px;letter-spacing:.12em}
.brand-mark{width:44px;height:44px;display:grid;place-items:center;color:white;border-radius:14px;background:linear-gradient(145deg,#617cfb,#4b5ed9);box-shadow:0 10px 24px rgba(79,110,247,.3);font-weight:900;font-size:21px}.brand-mark.small{width:38px;height:38px;border-radius:12px;font-size:18px}
.login-shell{min-height:100vh;display:grid;grid-template-columns:minmax(420px,47%) 1fr;background:var(--surface)}.login-main{position:relative;display:grid;place-items:center;padding:60px 8vw}.login-card{width:min(100%,430px)}.login-brand{margin-bottom:70px}.login-copy h1{margin:0 0 12px;font-size:clamp(32px,4vw,44px);letter-spacing:-.04em}.login-copy>p:last-child{margin:0;font-size:15px}.login-form{margin-top:38px}.login-footnote{margin:30px 0 0;color:var(--muted);font-size:12px;text-align:center}.login-theme{position:absolute;top:28px;right:30px}
.login-visual{position:relative;overflow:hidden;margin:18px;padding:clamp(48px,7vw,110px);border-radius:28px;color:white;background:linear-gradient(140deg,#637df8 0%,#4961da 54%,#334bbf 100%);display:flex;flex-direction:column;justify-content:center}.login-visual::after{content:"";position:absolute;inset:0;opacity:.13;background-image:radial-gradient(circle at 1px 1px,#fff 1px,transparent 0);background-size:28px 28px}.visual-copy,.visual-card{position:relative;z-index:1}.visual-copy{max-width:650px}.visual-copy h2{margin:24px 0 18px;font-size:clamp(36px,4.5vw,62px);line-height:1.13;letter-spacing:-.045em}.visual-copy p{max-width:560px;margin:0;color:rgba(255,255,255,.78);font-size:17px;line-height:1.8}.visual-badge{display:inline-flex;padding:9px 14px;border:1px solid rgba(255,255,255,.28);border-radius:999px;background:rgba(255,255,255,.1);font-size:12px;font-weight:750;letter-spacing:.08em}.visual-card{display:flex;align-items:center;gap:15px;width:fit-content;margin-top:70px;padding:18px 22px;border:1px solid rgba(255,255,255,.22);border-radius:15px;background:rgba(255,255,255,.12);backdrop-filter:blur(16px)}.visual-card div{display:flex;flex-direction:column;gap:5px}.visual-card small{color:rgba(255,255,255,.7)}.orb{position:absolute;border-radius:50%;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.06)}.orb-one{width:440px;height:440px;right:-100px;top:-110px}.orb-two{width:260px;height:260px;left:-90px;bottom:-80px}
.app-shell{min-height:100vh;display:grid;grid-template-columns:246px minmax(0,1fr)}.sidebar{position:sticky;top:0;z-index:30;height:100vh;display:flex;flex-direction:column;padding:24px 15px 18px;border-right:1px solid var(--line);background:var(--surface)}.sidebar-brand{padding:0 8px 27px}.nav-caption{margin:5px 12px 10px;color:var(--muted);font-size:10px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}#nav{display:flex;flex-direction:column;gap:4px}#nav button{width:100%;display:flex;align-items:center;gap:12px;padding:11px 13px;border:0;border-radius:10px;color:var(--muted);background:transparent;font-weight:650;text-align:left;transition:.16s ease}#nav button:hover{color:var(--text);background:var(--surface-hover)}#nav button.active{color:var(--primary);background:var(--primary-soft)}.nav-icon{width:23px;height:23px;display:grid;place-items:center;border-radius:7px;font-size:17px}#nav button.active .nav-icon{color:white;background:var(--primary);box-shadow:0 5px 12px rgba(79,110,247,.25)}.sidebar-foot{margin-top:auto;display:flex;align-items:center;justify-content:space-between;padding:14px 10px 0;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.sidebar-foot>div{display:flex;align-items:center;gap:8px}.live-dot{flex:0 0 auto;width:8px;height:8px;display:inline-block;border-radius:50%;background:#35c493;box-shadow:0 0 0 4px rgba(53,196,147,.15)}
.main-shell{min-width:0}.topbar{height:88px;position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:0 34px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:blur(18px)}.title-block h1{margin:0;font-size:24px;letter-spacing:-.025em}.top-actions{display:flex;align-items:center;gap:10px}.user-chip{padding:9px 13px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--muted);font-size:12px;font-weight:700}.content{max-width:1620px;margin:0 auto;padding:28px 34px 60px}
button,.button{border:1px solid var(--line-strong);border-radius:9px;padding:8px 12px;color:var(--text);background:var(--surface);font-size:12px;font-weight:700;transition:border-color .15s,background .15s,transform .15s}button:hover{border-color:var(--primary)}button:active{transform:translateY(1px)}button.primary{border-color:var(--primary);color:white;background:var(--primary);box-shadow:0 6px 16px rgba(79,110,247,.2)}button.primary:hover{background:var(--primary-dark)}button.danger{border-color:transparent;color:var(--danger);background:var(--danger-soft)}button.ghost{background:transparent}button.full{width:100%}button.large{padding:13px 16px;font-size:14px;border-radius:11px}button:disabled{cursor:not-allowed;opacity:.48;transform:none}.icon-button,.theme-button{width:38px;height:38px;display:grid;place-items:center;padding:0;border-radius:10px;font-size:18px}.theme-button{color:var(--primary);background:var(--primary-soft);border-color:transparent}.mobile-only,.sidebar-scrim{display:none}
.page-intro{display:flex;align-items:center;justify-content:space-between;gap:22px;margin-bottom:18px}.page-intro p{max-width:820px;margin:0;color:var(--muted);font-size:13px;line-height:1.75}.metrics{display:grid;grid-template-columns:repeat(5,minmax(150px,1fr));gap:14px;margin-bottom:18px}.metric{position:relative;overflow:hidden;padding:21px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:var(--shadow)}.metric::before{content:"";position:absolute;width:5px;height:46px;left:0;top:22px;border-radius:0 5px 5px 0;background:var(--accent)}.metric span,.metric small{display:block;color:var(--muted);font-size:12px}.metric strong{display:block;margin:7px 0 4px;font-size:29px;letter-spacing:-.04em}.grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.panel{overflow:hidden;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:var(--shadow)}.panel-head{min-height:68px;display:flex;align-items:center;justify-content:space-between;gap:15px;padding:16px 20px;border-bottom:1px solid var(--line)}.panel-head h2{margin:0 0 4px;font-size:15px}.panel-head p{margin:0;color:var(--muted);font-size:11px}.panel-body{padding:20px}.health-list,.timeline,.stack{display:flex;flex-direction:column;gap:13px}.health-row{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 0;border-bottom:1px solid var(--line)}.health-row:last-child{border:0}.health-row>div{min-width:0;display:flex;flex-direction:column;gap:4px}.health-row span,.health-row small{color:var(--muted);font-size:11px}.event{display:grid;grid-template-columns:10px minmax(0,1fr) auto;align-items:center;gap:12px;padding:9px 0}.event-dot{width:8px;height:8px;border:2px solid var(--primary);border-radius:50%}.event div{display:flex;flex-direction:column;gap:3px}.event small,.event time{color:var(--muted);font-size:10px}
.toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:9px;width:100%}.search{width:min(410px,100%)}.tag{display:inline-flex;align-items:center;margin:2px 4px 2px 0;padding:4px 8px;border-radius:999px;color:var(--primary);background:var(--primary-soft);font-size:10px;font-weight:750}.status{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:999px;color:var(--muted);background:var(--surface-soft);font-size:10px;font-weight:800;white-space:nowrap}.status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.status.online,.status.active,.status.completed{color:var(--success);background:var(--success-soft)}.status.failed,.status.degraded,.status.suspended{color:var(--danger);background:var(--danger-soft)}.status.deploying,.status.queued,.status.running,.status.removing,.status.redeploying,.status.redeploy_pending,.status.changes_pending{color:var(--warning);background:var(--warning-soft)}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12px}th{padding:11px 16px;color:var(--muted);background:var(--surface-soft);font-size:10px;text-align:left;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}td{padding:14px 16px;border-top:1px solid var(--line);vertical-align:middle}tbody tr:hover{background:var(--surface-hover)}td strong,td small{display:block}td strong{font-size:12px}td small{max-width:360px;margin-top:4px;color:var(--muted);line-height:1.45}.actions{display:flex;justify-content:flex-end;flex-wrap:wrap;gap:6px}.progress{width:130px;height:4px;margin-top:8px;overflow:hidden;border-radius:5px;background:var(--line)}.progress i{height:100%;display:block;border-radius:inherit;background:var(--primary)}.empty{min-height:120px;display:grid;place-items:center;align-content:center;gap:7px;color:var(--muted);text-align:center}.empty b{color:var(--text)}
label{display:flex;flex-direction:column;gap:7px;color:var(--text);font-size:12px;font-weight:700}input,select,textarea{width:100%;min-height:42px;padding:9px 11px;outline:0;border:1px solid var(--line-strong);border-radius:9px;color:var(--text);background:var(--surface);transition:border-color .15s,box-shadow .15s}input:focus,select:focus,textarea:focus{border-color:var(--primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--primary) 14%,transparent)}textarea{min-height:88px;resize:vertical}label small,.field-note{color:var(--muted);font-size:10px;font-weight:500;line-height:1.5}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px}.form-grid .wide{grid-column:1/-1}.form-actions{grid-column:1/-1;display:flex;justify-content:flex-end;gap:9px;padding-top:8px}.inline-fields{display:grid;grid-template-columns:1fr 1fr;gap:8px}.check-grid{max-height:215px;overflow:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:4px}.check-card{display:flex;flex-direction:row;align-items:center;gap:10px;padding:10px;border:1px solid var(--line);border-radius:9px;background:var(--surface-soft)}.check-card input{width:15px;min-height:auto;accent-color:var(--primary)}.check-card span{display:flex;flex-direction:column;gap:3px}.check-card small{color:var(--muted)}.notice{padding:12px 14px;border:1px solid color-mix(in srgb,var(--primary) 25%,var(--line));border-radius:10px;color:var(--muted);background:var(--primary-soft);font-size:11px;line-height:1.65}.notice.warning{border-color:color-mix(in srgb,var(--warning) 30%,var(--line));background:var(--warning-soft)}.codebox{overflow:auto;padding:14px;border:1px solid var(--line);border-radius:10px;color:var(--text);background:var(--surface-soft);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.7;white-space:pre-wrap;word-break:break-all}.form-error{min-height:20px;color:var(--danger);font-size:12px}.section-title{margin:8px 0 -3px;grid-column:1/-1;display:flex;align-items:center;gap:10px;color:var(--muted);font-size:10px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}.section-title::after{content:"";flex:1;height:1px;background:var(--line)}
dialog{width:min(760px,calc(100% - 32px));max-height:calc(100vh - 40px);padding:0;overflow:auto;border:0;border-radius:18px;color:var(--text);background:var(--surface);box-shadow:var(--shadow-lg)}dialog::backdrop{background:rgba(18,25,39,.48);backdrop-filter:blur(3px)}.dialog-shell>header{position:sticky;top:0;z-index:2;display:flex;align-items:center;justify-content:space-between;padding:20px 22px;border-bottom:1px solid var(--line);background:var(--surface)}.dialog-shell h2{margin:0;font-size:20px}#modal-body{padding:22px}#toasts{position:fixed;z-index:80;right:22px;bottom:22px;display:flex;flex-direction:column;gap:9px}.toast{max-width:390px;padding:12px 15px;border:1px solid color-mix(in srgb,var(--success) 25%,var(--line));border-radius:10px;color:var(--success);background:var(--surface);box-shadow:var(--shadow-lg);font-size:12px;font-weight:700}.toast.error{border-color:color-mix(in srgb,var(--danger) 30%,var(--line));color:var(--danger)}
@media(max-width:1120px){.metrics{grid-template-columns:repeat(3,1fr)}.login-shell{grid-template-columns:1fr 1fr}.login-main{padding:50px 7vw}.login-visual{padding:55px}}
@media(max-width:860px){.login-shell{display:block;background:var(--surface)}.login-main{min-height:100vh;padding:50px 24px}.login-visual{display:none}.login-brand{margin-bottom:48px}.app-shell{display:block}.sidebar{position:fixed;left:0;transform:translateX(-105%);width:246px;transition:transform .2s ease;box-shadow:var(--shadow-lg)}.app-shell.sidebar-open .sidebar{transform:translateX(0)}.sidebar-scrim{position:fixed;z-index:25;inset:0;border:0;border-radius:0;background:rgba(15,22,34,.42)}.app-shell.sidebar-open .sidebar-scrim{display:block}.mobile-only{display:grid}.topbar{padding:0 18px}.content{padding:22px 18px 50px}.grid-2,.grid-3{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.user-chip{display:none}}
@media(max-width:570px){.topbar{height:76px}.title-block h1{font-size:19px}.title-block .eyebrow{display:none}#refresh{display:none}.page-intro{align-items:stretch;flex-direction:column}.metrics{grid-template-columns:1fr}.form-grid{grid-template-columns:1fr}.form-grid .wide,.form-actions,.section-title{grid-column:1}.check-grid{grid-template-columns:1fr}.inline-fields{grid-template-columns:1fr}.login-copy h1{font-size:34px}dialog{width:calc(100% - 18px);max-height:calc(100vh - 18px)}#modal-body{padding:18px}}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
a{color:inherit;text-decoration:none}
.hidden{display:none !important}
button{font-family:var(--font)}
input,select,textarea{font-family:var(--font);color:var(--text)}
:focus-visible{outline:2px solid var(--primary-hover);outline-offset:1px}
/* Compact control-room layout: stable navigation width and readable actions in either theme. */
:root{--bg:#f5f7fc;--surface-soft:#f8faff;--primary:#3478ee;--primary-dark:#2468dc;--primary-soft:#eaf2ff;--radius:14px;--shadow:0 4px 18px rgba(26,44,80,.04)}
html[data-theme="dark"]{--bg:#0e1420;--surface-soft:#1b2638;--primary:#79a9ff;--primary-dark:#9bbfff;--primary-soft:#213656;--shadow:0 4px 20px rgba(0,0,0,.12)}
.login-shell{display:grid;grid-template-columns:1fr;background:var(--bg)}
.login-main{min-height:100vh;padding:28px;display:grid;place-items:center}
.login-card{width:min(100%,420px);padding:38px;border:1px solid var(--line);border-radius:18px;background:var(--surface);box-shadow:var(--shadow)}
.login-brand{margin-bottom:32px}.login-copy h1{margin:0;font-size:29px}.login-form{margin-top:28px}
.login-theme{top:24px;right:28px}.login-visual{display:none}
.app-shell{grid-template-columns:218px minmax(0,1fr)}
.sidebar{width:218px;min-width:218px;padding:24px 12px 18px;color:#e7edf9;border-right:0;background:#151e2d}
.sidebar .brand b{color:#fff}.sidebar .brand small,.sidebar .nav-caption{color:#8190a6}
.sidebar-brand{padding:0 10px 28px}.sidebar .brand-mark{background:#3478ee;box-shadow:none}
#nav{min-width:0}#nav button{min-width:0;width:100%;display:flex;flex-flow:row nowrap;align-items:center;gap:12px;white-space:nowrap;line-height:1.3;color:#a9b7cb;padding:11px 12px;font-size:13px}
#nav button .nav-label{display:block;flex:0 0 auto;white-space:nowrap;word-break:keep-all;writing-mode:horizontal-tb}
#nav button:hover{color:#fff;background:#222f44}#nav button.active{color:#fff;background:#2a3b58}
#nav button.active .nav-icon{color:#79a9ff;background:transparent;box-shadow:none}
.nav-icon{flex:0 0 22px;width:22px;height:22px;font-size:16px}.nav-group{margin-top:18px;margin-bottom:6px}
.sidebar-foot{border-color:#354256;color:#91a1b9}.sidebar-foot .icon-button{color:#e7edf9;background:transparent;border-color:#354256}
.topbar{height:74px;padding:0 30px;background:var(--surface);backdrop-filter:none}.title-block h1{font-size:20px}.title-block .eyebrow{margin-bottom:3px;font-size:10px;color:var(--muted)}
.content{max-width:1460px;padding:28px 30px 60px}.theme-button{width:auto;min-width:68px;height:36px;padding:0 11px;font-size:12px;white-space:nowrap}
.page-intro{margin-bottom:18px}.page-intro p{max-width:900px}.panel{box-shadow:var(--shadow)}
.actions{min-width:235px;flex-wrap:wrap}.actions button{white-space:nowrap}
.table-wrap table{min-width:850px}.table-wrap td{line-height:1.5}.table-wrap td:last-child{width:1%;white-space:nowrap}
.protocol-note{grid-column:1/-1;line-height:1.7}.chain-section{grid-column:1/-1;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--surface-soft)}
.chain-section h3{margin:0 0 14px;font-size:14px}.chain-section .form-grid{gap:14px}.field-hint{color:var(--muted);font-size:11px;line-height:1.6}
.row-actions{display:flex;gap:7px;align-items:center}.danger-link{color:var(--danger)}
@media(max-width:860px){.sidebar{width:218px;min-width:218px}.topbar{padding:0 18px}.content{padding:20px 18px 50px}}
@media(max-width:570px){.login-card{padding:28px 22px}.top-actions{gap:5px}.theme-button{min-width:56px}.content{padding:18px 12px 40px}}
/* ---------- 布局 ---------- */
.layout{display:flex;min-height:100vh}
.sider{flex:0 0 224px;width:224px;background:var(--sider);border-right:1px solid var(--border-soft);position:sticky;top:0;height:100vh;display:flex;flex-direction:column;z-index:30}
.brand{display:flex;align-items:center;height:58px;padding:0 20px;border-bottom:1px solid var(--border-soft);font-size:19px;font-weight:700;letter-spacing:.4px}
.menu{padding:12px 10px;flex:1;overflow-y:auto}
.mi{display:flex;align-items:center;gap:10px;padding:8px 10px;margin:2px 0;border-radius:6px;color:var(--text-2);cursor:pointer}
.mi svg{width:16px;height:16px;flex:none}
.mi:hover{background:var(--hover-bg);color:var(--text)}
.mi.active{background:var(--primary-soft);color:var(--primary-text);font-weight:500}
.sider-foot{padding:12px 20px;border-top:1px solid var(--border-soft);color:var(--text-2);font-size:12.5px;display:flex;justify-content:space-between;align-items:center;gap:8px}
.sider-foot .foot-right{display:inline-flex;align-items:center;gap:6px;color:var(--text-3)}
.main{flex:1;min-width:0;display:flex;flex-direction:column}
.topbar{height:58px;display:flex;align-items:center;gap:12px;padding:0 24px;border-bottom:1px solid var(--border-soft);background:var(--bg);position:sticky;top:0;z-index:20}
.crumb{color:var(--text-3);font-size:13px;white-space:nowrap}
.crumb b{color:var(--text);font-weight:600;font-size:15px}
.topbar .sp{flex:1}
.theme-btn{border:1px solid var(--border);background:var(--card);color:var(--text-2);border-radius:6px;padding:5px 11px;font-size:12.5px;cursor:pointer;white-space:nowrap}
.theme-btn:hover{border-color:var(--primary);color:var(--primary-text)}
.content{padding:24px;width:100%}
.panel{background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:16px}
.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;border-bottom:1px solid var(--border-soft)}
.panel-head h2{font-size:14.5px;font-weight:600}
.panel-body{padding:14px 16px}
.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.page-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
.bulk-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;padding:8px 12px;border:1px solid var(--primary);border-radius:8px;background:var(--card-hover)}
.bulk-bar .bulk-count{font-size:13px;color:var(--text)}
.search{background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 12px;font-size:13px;width:260px}
select.search{width:auto}
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--card-hover);color:var(--text);border-radius:6px;padding:5px 14px;font-size:13px;cursor:pointer;white-space:nowrap}
.btn:hover{border-color:var(--primary);color:var(--primary-text)}
.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
.btn.primary:hover{background:var(--primary-hover);color:#fff}
.btn.danger{color:var(--red-text)}
.btn.danger:hover{border-color:var(--red);color:var(--red-text);background:var(--red-soft)}
.btn.small{padding:2px 10px;font-size:12px}
.btn.full{width:100%;justify-content:center;padding:8px 14px}
.btn:disabled{opacity:.55;cursor:default}
/* Long device and customer lists stay inside a searchable, keyboard-friendly picker. */
.picker-field{display:flex;flex-direction:column;gap:7px;font-size:12px;font-weight:700}
.picker-field>small,.picker-content>small{color:var(--muted);font-size:11px;font-weight:500}
.multi-picker{border:1px solid var(--line-strong);border-radius:10px;background:var(--surface)}
.multi-picker>summary{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 12px;cursor:pointer;list-style:none}
.multi-picker>summary::-webkit-details-marker{display:none}
.multi-picker[open]>summary{border-bottom:1px solid var(--line);color:var(--primary)}
.picker-content{padding:10px;display:grid;gap:9px}.picker-options{max-height:225px;overflow:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
.picker-option{display:flex;flex-direction:row;align-items:center;min-width:0;gap:10px;padding:9px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface-soft);cursor:pointer}
.picker-option[hidden]{display:none}.picker-option:hover{border-color:var(--primary)}.picker-option input{flex:0 0 16px;width:16px;min-height:16px;accent-color:var(--primary)}.picker-option span{min-width:0;display:grid;gap:3px}.picker-option b,.picker-option small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.picker-option small{color:var(--muted);font-size:10px;font-weight:500}
.subscription-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft)}
.subscription-row>div{min-width:0;display:grid;gap:5px}.subscription-row code{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-size:11px}.subscription-row button{flex:0 0 auto}
@media(max-width:570px){.picker-options{grid-template-columns:1fr}.subscription-row{align-items:stretch;flex-direction:column}.subscription-row button{align-self:flex-end}}
/* ---------- 系统状态 ---------- */
.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:16px}
.stat{background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);padding:14px 16px 10px;overflow:hidden}
.stat .hd{display:flex;align-items:center;gap:7px;color:var(--text-2);font-size:13px}
.stat .hd svg{width:15px;height:15px;color:var(--primary);flex:none}
.stat .hd .scope{margin-left:auto;color:var(--text-3);font-size:11.5px}
.stat .big{font-size:30px;font-weight:600;line-height:1.25;margin-top:4px;font-variant-numeric:tabular-nums}
.stat .big small{font-size:15px;font-weight:500;color:var(--text-2);margin-left:2px}
.stat .det{color:var(--text-2);font-size:12.5px;font-variant-numeric:tabular-nums}
.stat .mm{display:flex;justify-content:space-between;color:var(--text-3);font-size:12px;margin-top:8px;font-variant-numeric:tabular-nums}
.spark{display:block;width:100%;height:44px;margin-top:4px}
.mid-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px;margin-bottom:16px;align-items:stretch}
.chart-card{padding:14px 18px 12px;display:flex;flex-direction:column;margin-bottom:0}
.chart-hd{display:flex;align-items:flex-start;gap:12px}
.chart-hd h2{font-size:14.5px;font-weight:600}
.chart-hd .legend{margin-left:auto;display:flex;gap:18px;font-size:13px;white-space:nowrap;font-variant-numeric:tabular-nums}
.legend .up{color:var(--primary-text);font-weight:600}
.legend .dn{color:var(--text-2);font-weight:600}
.legend .lb{color:var(--text-3);font-weight:400;font-size:12px;margin-right:3px}
.chart{flex:1;width:100%;margin-top:8px}
.stat3{display:flex;border-top:1px solid var(--border-soft);margin-top:10px;padding-top:11px}
.stat3>div{flex:1;padding:0 14px}
.stat3>div:first-child{padding-left:0}
.stat3>div+div{border-left:1px solid var(--border-soft)}
.stat3 .k{color:var(--text-3);font-size:12px}
.stat3 .v{font-size:16.5px;font-weight:600;margin-top:1px;font-variant-numeric:tabular-nums}
.conn .big{font-size:34px;font-weight:600;line-height:1.2;font-variant-numeric:tabular-nums}
.conn .big span{font-size:13px;font-weight:400;color:var(--text-3);margin-left:6px}
.conn .proto-row{display:flex;gap:26px;margin-top:8px;font-size:13px;font-variant-numeric:tabular-nums}
.conn .proto-row b{font-weight:600;margin-left:5px}
.swatch{display:inline-block;width:16px;height:0;border-top:2.5px solid var(--primary);border-radius:2px;vertical-align:middle}
.swatch.g{border-top-color:var(--chart-2)}
.info-bar{display:flex;gap:0;padding:14px 18px}
.info-bar .seg{flex:1;padding:0 18px;min-width:0}
.info-bar .seg:first-child{padding-left:0}
.info-bar .seg+.seg{border-left:1px solid var(--border-soft)}
.info-bar .cap{color:var(--text-3);font-size:12.5px;display:flex;align-items:center;gap:6px;margin-bottom:7px}
.info-bar .cap svg{width:14px;height:14px}
.kv{display:flex;gap:22px;flex-wrap:wrap}
.kv .item .k{display:block;color:var(--text-3);font-size:11px;letter-spacing:.6px}
.kv .item .v{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums}
.kv .item .v.mono{font-family:var(--mono);font-size:13px;font-weight:500;word-break:break-all}
.ln-up{stroke:var(--primary);fill:none;stroke-width:1.8;stroke-linejoin:round;stroke-linecap:round}
.ln-dn{stroke:var(--chart-2);fill:none;stroke-width:1.8;stroke-linejoin:round;stroke-linecap:round}
.mean{stroke:var(--primary);stroke-width:1;stroke-dasharray:4 4;opacity:.55;fill:none}
.mean-g{stroke:var(--chart-2);stroke-width:1;stroke-dasharray:4 4;opacity:.6;fill:none}
.grid-ln{stroke:var(--border-soft);stroke-width:1;stroke-dasharray:3 5;fill:none}
.stop-up-a{stop-color:var(--primary);stop-opacity:.26}
.stop-up-b{stop-color:var(--primary);stop-opacity:0}
.notice{border:1px solid var(--orange);background:var(--orange-soft);color:var(--orange-text);border-radius:6px;padding:8px 12px;font-size:13px;margin-bottom:16px}
/* Readable defaults on wide admin screens without inflating the navigation. */
table{font-size:13px}th{font-size:11px;letter-spacing:.02em}td strong{font-size:13px}td small{font-size:11px}
.panel-head p,.health-row span,.health-row small,.event small,.event time{font-size:12px}
.status,.tag{font-size:11px}.page-intro p{font-size:13px}.actions{min-width:0}
/* ---------- 表格通用 ---------- */
.table-wrap{overflow-x:auto}
.table-wrap.visible{overflow:visible}
table{width:100%;border-collapse:collapse}
table.w-servers{min-width:1390px}
table.w-customers{min-width:1120px}
table.w-nodes{min-width:1180px}
table.w-jobs td{white-space:nowrap;padding-top:5px;padding-bottom:5px}
table.w-jobs th:last-child{width:22%}
table.w-jobs td:last-child{white-space:normal;min-width:120px;word-break:break-word}
thead th{text-align:left;font-size:12.5px;font-weight:600;color:var(--text-2);letter-spacing:.3px;padding:7px 12px;border-bottom:1px solid var(--border-soft);background:var(--th-bg);white-space:nowrap}
thead th .sarr{color:var(--text-3);font-size:10px;margin-left:3px}
thead th.ctr,tbody td.ctr{text-align:center}
tbody td{padding:6px 12px;border-bottom:1px solid var(--border-soft);vertical-align:middle;font-size:13.5px}
tbody tr:last-child td{border-bottom:none}
tbody tr:hover{background:var(--row-hover)}
tbody tr.dragging{opacity:.4}
tbody tr.drag-over td{border-top:2px solid var(--primary)}
.badge{display:inline-flex;align-items:center;gap:5px;padding:1px 8px;border-radius:5px;font-size:12px;line-height:20px;white-space:nowrap}
.badge.green{background:var(--green-soft);color:var(--green-text)}
.badge.red{background:var(--red-soft);color:var(--red-text)}
.badge.orange{background:var(--orange-soft);color:var(--orange-text)}
.badge.blue{background:var(--primary-soft);color:var(--primary-text)}
.badge.gray{background:var(--gray-soft);color:var(--text-2)}
.num{font-variant-numeric:tabular-nums;white-space:nowrap}
.mut{color:var(--text-3)}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex:none}
.dot.on{background:var(--green);animation:pulse 2s infinite}
.dot.off{background:var(--dot-off)}
.dot.bad{background:var(--red)}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(82,196,26,.45)}70%{box-shadow:0 0 0 6px rgba(82,196,26,0)}100%{box-shadow:0 0 0 0 rgba(82,196,26,0)}}
.op-cell{display:flex;align-items:center;gap:1px;white-space:nowrap}
.op-cell input[type="checkbox"]{margin-right:7px}
.icon-btn{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border:none;background:transparent;color:var(--text-2);border-radius:6px;cursor:pointer;padding:0;vertical-align:middle}
.icon-btn svg{width:17px;height:17px;display:block}
.icon-btn:hover{color:var(--primary-text);background:var(--hover-bg)}
.icon-btn.del{color:var(--red-text)}
.icon-btn.del:hover{background:var(--red-soft);color:var(--red-text)}
/* 图标悬停提示气泡(3x-ui 式:黑底白字圆角 + 小尖角,挂 body 避免被表格滚动容器裁剪) */
.tip-bubble{position:fixed;z-index:200;pointer-events:none;background:#141414;color:#fff;font-size:12px;line-height:1.25;padding:6px 9px;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity .12s;box-shadow:0 4px 14px rgba(0,0,0,.25)}
.tip-bubble.show{opacity:1}
.tip-bubble::after{content:"";position:absolute;top:100%;left:var(--tip-arrow-left,50%);transform:translateX(-50%);border:5px solid transparent;border-top-color:#141414;border-bottom:0}
.tgl{position:relative;display:inline-block;width:38px;height:21px;vertical-align:middle;flex:none}
.tgl input{opacity:0;width:0;height:0;position:absolute}
.tgl .tr{position:absolute;inset:0;background:var(--tgl-off);border-radius:99px;transition:background .2s;cursor:pointer}
.tgl .tr::before{content:"";position:absolute;width:17px;height:17px;border-radius:50%;background:#fff;top:2px;left:2px;transition:transform .2s;box-shadow:0 1px 3px rgba(0,0,0,.35)}
.tgl input:checked + .tr{background:var(--primary)}
.tgl input:checked + .tr::before{transform:translateX(17px)}
.tgl input:focus-visible + .tr{outline:2px solid var(--primary-hover);outline-offset:2px}
.tgl input:disabled + .tr{opacity:.5;cursor:default}
.pill{display:inline-flex;align-items:center;gap:7px;background:var(--pill-bg);color:var(--text);border-radius:999px;padding:3px 11px;font-size:12.5px;white-space:nowrap;font-variant-numeric:tabular-nums}
.pill.green{background:var(--green-soft);color:var(--green-text);font-weight:600}
.pill.red{background:var(--red-soft);color:var(--red-text);font-weight:600}
.pill.gray{background:var(--gray-soft);color:var(--text-2)}
.pill.mut{color:var(--text-3)}
.mini-bar{width:62px;height:6px;border-radius:99px;background:var(--track);overflow:hidden;display:inline-block;flex:none}
.mini-bar i{display:block;height:100%;background:var(--primary);border-radius:99px}
.pill.warn .mini-bar i{background:var(--orange)}
.pill.danger .mini-bar i{background:var(--red)}
.pager{display:flex;justify-content:flex-end;align-items:center;gap:6px;padding:9px 16px;border-top:1px solid var(--border-soft);color:var(--text-2);font-size:13px}
.pg-btn{min-width:28px;height:28px;padding:0 7px;border:1px solid var(--border);background:var(--card);border-radius:6px;color:var(--text-2);cursor:pointer;font-size:13px}
.pg-btn.active{border-color:var(--primary);color:var(--primary-text)}
.pg-btn:disabled{opacity:.45;cursor:default}
.empty{padding:28px 16px;text-align:center;color:var(--text-3);font-size:13px}
.empty b{display:block;color:var(--text-2);font-size:14px;margin-bottom:2px}
/* Keep dense management tables at one row per item, even at narrow widths. */
.table-wrap table{min-width:1040px;table-layout:auto}
.table-wrap td{padding:9px 13px;line-height:1.35}
.table-wrap th{padding:10px 13px}
.table-wrap td:last-child{min-width:200px}
.table-wrap td strong{font-weight:650}
.table-wrap td small{font-weight:400;margin-top:3px}
.actions.compact-actions{min-width:0;justify-content:flex-end;flex-wrap:nowrap;align-items:center;gap:4px}
.compact-actions button{padding:6px 8px;font-size:11px;font-weight:600}
.compact-actions select{width:92px;min-height:30px;padding:4px 7px;font-size:11px;font-weight:500;cursor:pointer}
.error-detail{max-width:230px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--danger)}
.subscription-row{flex-wrap:wrap;background:var(--surface)}
.subscription-row>div:first-child{flex:1 1 310px}
.access-table{max-height:280px;overflow:auto;margin-top:10px;border:1px solid var(--line);border-radius:10px}
.access-table table{min-width:610px}
.access-table td{padding:9px 12px}
.access-table td small{max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.subscription-actions{display:flex;gap:5px;flex:0 0 auto}
.subscription-qr{flex:1 0 100%;text-align:center;padding:14px;border-top:1px solid var(--line)}
.subscription-qr[hidden]{display:none}
.subscription-qr svg{max-width:min(100%,250px);height:auto;background:#fff;border:9px solid #fff;border-radius:8px}
.status.suspending,.status.partially_suspended{color:var(--warning);background:var(--warning-soft)}
.command-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr));gap:16px;align-items:start}
.command-item{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 0;border-bottom:1px solid var(--line)}
.command-item:last-child{border-bottom:0}.command-item>div{min-width:0;display:grid;gap:5px}
.command-item small{color:var(--muted);font-size:11px;line-height:1.55}.command-item code{font-size:12px;color:var(--primary)}
.command-item button{flex:0 0 auto}
.node-link-list{display:grid;gap:8px;margin-top:8px}
.node-link-customer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;background:var(--surface);border:1px solid var(--line);border-radius:8px}
.node-link-customer .actions{margin:0}
details summary{cursor:pointer;font-weight:600}
/* ---------- 服务器页 ---------- */
.stats{display:grid;grid-template-columns:repeat(3,1fr);background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:14px}
.stats.six{grid-template-columns:repeat(6,1fr)}
.stats .stat{padding:14px 22px;border:none;background:transparent}
.stats .stat .lab{color:var(--text-2);font-size:12.5px;margin-bottom:3px}
.stats .stat .val{display:flex;align-items:center;gap:8px;font-size:20px;font-weight:600;font-variant-numeric:tabular-nums}
.stats .stat .val svg{width:19px;height:19px;flex:none}
.ic-dim{color:var(--text-2)} .ic-green{color:var(--green)} .ic-red{color:var(--red)} .ic-bolt{color:var(--orange)}
.exp{display:inline-flex;width:20px;height:20px;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:5px;color:var(--text-2);font-size:14px;line-height:1;background:var(--card);cursor:pointer;padding:0}
.eye-btn{width:24px;height:24px;vertical-align:-6px;margin-left:4px}
.eye-btn svg{width:15px;height:15px}
.dev-name{font-weight:600;font-size:13.5px;white-space:nowrap}
.addr{font-family:var(--mono);font-size:12.5px;color:var(--text-2);white-space:nowrap}
.status{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font-weight:500}
.status.on{color:var(--green-text)}
.status.off{color:var(--red-text)}
.warn-ic{width:15px;height:15px;color:var(--orange);flex:none;vertical-align:-2px}
.ver-cell{display:inline-flex;align-items:center;gap:7px;white-space:nowrap}
.ver{font-family:var(--mono);font-size:12.5px}
.clients-cell{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font-variant-numeric:tabular-nums}
.clients-cell svg{width:16px;height:16px;color:var(--text-3);flex:none}
.clients-cell .on-n{color:var(--green-text);font-weight:600}
.hb{white-space:nowrap;color:var(--text-2)}
.detail-row td{background:var(--th-bg);padding:12px 16px}
.detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:8px 22px;font-size:13px}
.detail-grid .k{color:var(--text-3);font-size:11.5px;display:block}
.detail-grid .v{font-weight:500}
.detail-grid .v.err{color:var(--red-text)}
.detail-actions{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
/* ---------- 客户页 ---------- */
.pres{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:2px 11px;font-size:12.5px;font-weight:600;white-space:nowrap}
.pres.on{background:var(--green-soft);color:var(--green-text)}
.pres.off{background:var(--gray-soft);color:var(--text-3);font-weight:400}
.paused-note{display:block;color:var(--red-text);font-size:12px;font-weight:500;margin-top:4px;white-space:nowrap}
.cust-name{font-weight:600;font-size:13.5px;white-space:nowrap}
.cust-note{display:block;color:var(--text-3);font-size:12px;margin-top:1px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.in-chip{display:inline-flex;padding:2px 9px;border-radius:6px;background:var(--primary-soft);color:var(--primary-text);font-size:12.5px;font-weight:500;line-height:20px;white-space:nowrap}
.in-chip.more{background:var(--gray-soft);color:var(--text-2);margin-left:5px;cursor:pointer;border:none;font-family:var(--font)}
.in-chip.more:hover{color:var(--primary-text);background:var(--primary-soft)}
.in-chip.ss{background:var(--pink-soft);color:var(--pink-text)}
.in-chip.mixed{background:var(--orange-soft);color:var(--orange-text)}
.speed{font-variant-numeric:tabular-nums;white-space:nowrap}
.speed.mut{color:var(--text-3)}
.filter-chip{display:inline-flex;align-items:center;gap:6px;background:var(--primary-soft);color:var(--primary-text);border-radius:999px;padding:3px 6px 3px 11px;font-size:12.5px}
.filter-chip button{border:none;background:transparent;color:inherit;cursor:pointer;font-size:14px;line-height:1;padding:2px}
/* ---------- 节点页 ---------- */
.grip{display:inline-flex;padding:3px 2px;border-radius:4px;color:var(--text-3);cursor:grab;vertical-align:middle}
.grip svg{width:13px;height:13px;display:block}
.grip:hover{color:var(--text);background:var(--hover-bg)}
.grip:active{cursor:grabbing}
td.menu-td{white-space:nowrap}
.node-name{font-weight:600;font-size:13.5px;white-space:nowrap}
.srv-chip{display:inline-flex;padding:2px 10px;border-radius:6px;font-size:12.5px;font-weight:500;line-height:20px;white-space:nowrap}
.srv-chip.online{background:var(--primary-soft);color:var(--primary-text)}
.srv-chip.offline{background:var(--red-soft);color:var(--red-text)}
.srv-chip.degraded{background:var(--orange-soft);color:var(--orange-text)}
.port{font-variant-numeric:tabular-nums}
.proto{display:inline-flex;padding:0 6px;border-radius:5px;font-size:11.5px;font-weight:600;line-height:18px;white-space:nowrap}
.proto.vless{background:var(--purple-soft);color:var(--purple-text)}
.proto.ss{background:var(--cyan-soft);color:var(--cyan-text)}
.proto.mixed{background:var(--orange-soft);color:var(--orange-text)}
.proto.other{background:var(--gray-soft);color:var(--text-2)}
.trans{display:inline-flex;padding:0 6px;border-radius:5px;font-size:11.5px;line-height:18px;white-space:nowrap;background:var(--trans-bg);color:var(--trans-text)}
.sec{display:inline-flex;padding:0 6px;border-radius:5px;font-size:11.5px;line-height:18px;white-space:nowrap;background:var(--sky-soft);color:var(--sky-text)}
.sec.plain{background:var(--gray-soft);color:var(--text-2)}
.badge-stack{display:inline-flex;gap:4px;flex-wrap:nowrap;align-items:center}
.clients{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;cursor:pointer}
.clients>svg{width:17px;height:17px;color:var(--text-3);flex:none}
.online-num{color:var(--green-text);font-weight:600;padding:1px 2px}
.online-num.zero{color:var(--text-3);font-weight:400}
/* ---------- 浮层(挂 body) ---------- */
.float-pop{position:fixed;z-index:90;background:var(--pop-bg);border:1px solid var(--border);border-radius:8px;box-shadow:var(--pop-shadow);min-width:176px;max-width:320px;padding:6px 0}
.float-pop .pop-head{display:flex;justify-content:space-between;align-items:center;padding:6px 12px;border-bottom:1px solid var(--border-soft);font-size:12px;color:var(--text-2)}
.float-pop .pop-head b{color:var(--text);font-size:13px}
.float-pop .pop-item{display:flex;align-items:center;gap:8px;padding:5px 12px;color:var(--text);font-size:13px}
.float-pop .pop-item .in-chip{margin:2px 0}
.float-pop .pop-foot{padding:6px 12px;border-top:1px solid var(--border-soft);font-size:11px;color:var(--text-3)}
.float-pop .menu-item{display:flex;width:100%;align-items:center;gap:8px;padding:7px 12px;border:none;background:transparent;color:var(--text);font-size:13px;cursor:pointer;text-align:left}
.float-pop .menu-item:hover{background:var(--hover-bg)}
.float-pop .menu-item.danger{color:var(--red-text)}
.float-pop .chip-list{padding:8px 12px;display:flex;flex-direction:column;gap:6px;align-items:flex-start}
/* ---------- 弹窗与表单 ---------- */
.modal-overlay{position:fixed;inset:0;background:rgba(10,12,16,.45);z-index:80;display:flex;align-items:flex-start;justify-content:center;overflow-y:auto;padding:36px 16px}
.modal{width:640px;max-width:100%;margin:0}
.modal.wide{width:820px}
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:13px 18px;border-bottom:1px solid var(--border-soft)}
.modal-head h2{font-size:15.5px;font-weight:600}
.modal-body{padding:16px 18px}
.stack{display:flex;flex-direction:column;gap:12px}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 16px}
.form-grid .wide{grid-column:1 / -1}
label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:var(--text-2)}
label>input,label>select,label>textarea,.field>input{border:1px solid var(--border);background:var(--card);border-radius:6px;padding:6px 10px;font-size:13.5px;color:var(--text)}
label>textarea{min-height:56px;resize:vertical}
label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--primary);outline:none}
.form-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
.form-actions .left{margin-right:auto}
.inline-fields{display:flex;gap:8px}
.inline-fields>*{flex:1;min-width:0}
.pair-fields{display:flex;gap:10px;align-items:flex-end}
.pair-fields label{flex:1}
.form-error{color:var(--red-text);font-size:13px;min-height:18px}
.codebox{background:var(--bg);border:1px solid var(--border-soft);border-radius:6px;padding:10px 12px;font-family:var(--mono);font-size:12.5px;white-space:pre-wrap;word-break:break-all;color:var(--text)}
.section-title{grid-column:1 / -1;font-size:13px;font-weight:600;color:var(--text);border-top:1px solid var(--border-soft);padding-top:12px}
.kv-table{width:100%;border-collapse:collapse}
.kv-table td{padding:5px 8px;border-bottom:1px solid var(--border-soft);font-size:13px;vertical-align:top}
.kv-table td.k{color:var(--text-3);width:130px;white-space:nowrap}
.mono{font-family:var(--mono);font-size:12.5px;word-break:break-all}
/* 页签 */
.segctl{display:inline-flex;border:1px solid var(--border);border-radius:8px;overflow:hidden}
.segctl button{padding:6px 16px;background:var(--card);border:none;color:var(--text-2);cursor:pointer;font:inherit;font-size:13px}
.segctl button.active{background:var(--primary);color:#fff}
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border-soft);margin-bottom:14px}
.tabs button{border:none;background:transparent;padding:7px 14px;font-size:13.5px;color:var(--text-2);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
.tabs button.active{color:var(--primary-text);border-bottom-color:var(--primary);font-weight:500}
.tab-pane.hidden{display:none}
/* 凭据行 */
.cred-row{display:flex;align-items:center;gap:8px;padding:7px 0;border-bottom:1px solid var(--border-soft)}
.cred-row:last-child{border-bottom:none}
.cred-row .k{width:110px;flex:none;color:var(--text-3);font-size:12.5px}
.cred-row .v{flex:1;min-width:0;font-family:var(--mono);font-size:12.5px;word-break:break-all}
.cred-group{margin-bottom:14px}
.cred-group .g-head{display:flex;align-items:center;gap:8px;font-weight:600;font-size:13.5px;margin-bottom:2px}
/* 链接页签条目 */
.link-item{border:1px solid var(--border-soft);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}
.link-item .row{display:flex;gap:8px;align-items:center}
.link-item input[type="text"],.link-item input:not([type]){flex:1;border:1px solid var(--border);background:var(--card);border-radius:6px;padding:6px 10px;font-size:13px;min-width:0}
.link-item .remark{flex:0 0 150px}
.link-item .status-line{font-size:12px;color:var(--text-3)}
.link-item .status-line .err{color:var(--red-text)}
.links-head{display:flex;align-items:center;justify-content:space-between;margin:14px 0 8px}
.links-head h3{font-size:13.5px;font-weight:600}
.links-note{font-size:13px;color:var(--text-2)}
.links-empty{color:var(--text-3);font-size:13px;padding:6px 0}
/* 关联入站选择器 */
.picker{position:relative}
.picker-box{display:flex;flex-wrap:wrap;gap:6px;align-items:center;border:1px solid var(--border);border-radius:6px;padding:5px 8px;min-height:34px;cursor:text;background:var(--card)}
.picker-box .chip{display:inline-flex;align-items:center;gap:5px;background:var(--primary-soft);color:var(--primary-text);border-radius:5px;padding:1px 4px 1px 8px;font-size:12.5px}
.picker-box .chip button{border:none;background:transparent;color:inherit;cursor:pointer;font-size:13px;line-height:1;padding:2px}
.picker-box .ph{color:var(--text-3);font-size:13px}
.picker-box .search-ic{margin-left:auto;color:var(--text-3);flex:none}
.picker-panel{position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:40;background:var(--pop-bg);border:1px solid var(--border);border-radius:8px;box-shadow:var(--pop-shadow);padding:6px}
.picker-tools{display:flex;gap:8px;align-items:center;padding:2px 4px 6px}
.picker-tools input{flex:1;border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:13px;background:var(--card)}
.picker-list{max-height:220px;overflow-y:auto}
.picker-opt{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;font-size:13.5px;color:var(--text)}
.picker-opt:hover{background:var(--hover-bg)}
.picker-opt.sel{background:var(--primary-soft)}
.picker-opt .ck{margin-left:auto;color:var(--primary-text);font-weight:700;visibility:hidden}
.picker-opt.sel .ck{visibility:visible}
.picker-opt small{color:var(--text-3)}
/* 订阅弹窗 */
.subscription-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border-soft)}
.subscription-row>div:first-child{flex:1;min-width:0}
.subscription-row b{display:block;font-size:13.5px}
.subscription-row code{font-family:var(--mono);font-size:11.5px;color:var(--text-2);word-break:break-all}
.subscription-actions{display:flex;gap:6px;flex:none}
.subscription-qr{padding:10px 0;text-align:center}
.subscription-qr svg{max-width:180px;height:auto}
.warn-note{border:1px solid var(--orange);background:var(--orange-soft);color:var(--orange-text);border-radius:6px;padding:8px 12px;font-size:13px}
/* 命令页 / 运维页 */
.command-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;margin-bottom:16px}
.command-grid .panel{margin-bottom:0}
.command-item{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border-soft)}
.command-item:last-child{border-bottom:none}
.command-item>div{flex:1;min-width:0}
.command-item b{display:block;font-size:13.5px}
.command-item code{font-family:var(--mono);font-size:12.5px;color:var(--primary-text)}
.grid-3{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
.grid-3 .panel{margin-bottom:0}
.health-row{display:flex;align-items:center;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--border-soft);font-size:13.5px}
.health-row:last-child{border-bottom:none}
.health-row span{color:var(--text-3);font-size:12.5px;display:block}
.tag{display:inline-flex;padding:1px 8px;border-radius:5px;background:var(--gray-soft);color:var(--text-2);font-size:12px;line-height:20px}
/* 登录 */
.login-shell{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
.login-card{width:360px;max-width:100%;padding:26px 26px 18px;position:relative;margin:0}
.login-brand{font-size:26px;font-weight:700;letter-spacing:.4px;margin-bottom:18px}
.login-theme{position:absolute;top:14px;right:14px}
.login-card label{margin-bottom:12px}
.login-card label>input{width:100%}
/* toast */
#toasts{position:fixed;right:18px;bottom:18px;z-index:120;display:flex;flex-direction:column;gap:8px}
.toast{background:var(--pop-bg);border:1px solid var(--border);border-left:3px solid var(--green);color:var(--text);border-radius:8px;box-shadow:var(--pop-shadow);padding:9px 14px;font-size:13px;max-width:360px}
.toast.error{border-left-color:var(--red)}
@media (max-width:1100px){.stat-grid{grid-template-columns:repeat(2,1fr)}.mid-grid{grid-template-columns:1fr}.info-bar{flex-direction:column;gap:14px}.info-bar .seg{padding:0}.info-bar .seg+.seg{border-left:none}.stats{grid-template-columns:repeat(2,1fr)}.stats.six{grid-template-columns:repeat(3,1fr)}}
@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.content{padding:8px}}
/* Reality 目标扫描器结果表(第 16 条) */
.scan-table{width:100%;border-collapse:collapse;font-size:13px}
.scan-table th,.scan-table td{padding:6px 8px;text-align:left;border-bottom:1px solid var(--border-soft);white-space:nowrap}
.scan-table th{color:var(--mut);font-weight:600}
/* 扫描器浮层专属收口(真机修复:结果表曾冲出弹窗边框):弹窗加宽、结果容器
横向滚动兜底、目标/证书列截断、失败原因列允许换行。全部 .scan- 专属,
不影响节点编辑弹窗本体与其它弹窗/主页面表格的既有布局。 */
.modal.scan-modal{width:1140px}
.scan-wrap{overflow-x:auto}
.scan-table td.scan-target,.scan-table td.scan-cert{max-width:230px;overflow:hidden;text-overflow:ellipsis}
.scan-table td.scan-reason{white-space:normal;max-width:280px;overflow-wrap:anywhere}
+11
View File
@@ -9,7 +9,16 @@ set -Eeuo pipefail
version='v1.13.21'
target='/usr/local/bin/nexusgate-sing-box'
stats_target='/usr/local/bin/nexusgate-sing-box-stats'
# The locally built sing-box carries no version stamp (`sing-box version`
# prints "unknown"), so the agent cannot probe the core version from the
# binary. Record the installed version (without the leading v) for the
# agent's fallback probe — see agent-go installedSingBoxVersion.
write_version_record() {
install -d -m 0700 /etc/nexusgate/sing-box
printf '%s\n' "${version#v}" > /etc/nexusgate/sing-box/version
}
if [[ -x "$target" && -x "$stats_target" && "${2:-}" != '--force' ]]; then
write_version_record
printf 'sing-box 已安装:%s(如需重装,运行 ng-agent-singbox install --force)\n' "$target"
exit 0
fi
@@ -38,6 +47,7 @@ try_prebuilt() {
install -m 0755 "$tmp_stats" "${stats_target}.next"
mv -f "${target}.next" "$target"
mv -f "${stats_target}.next" "$stats_target"
write_version_record
printf 'sing-box 预编译版安装完成:%s\n' "$target"
return 0
fi
@@ -104,6 +114,7 @@ install -m 0755 "$build_dir/sing-box" "$target.next"
install -m 0755 "$build_dir/stats-query" "$stats_target.next"
mv -f -- "$target.next" "$target"
mv -f -- "$stats_target.next" "$stats_target"
write_version_record
printf 'sing-box 统计版已安装:%s\n' "$target"
if [[ -f /etc/nexusgate/sing-box/config.json ]]; then
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then
+215 -7
View File
@@ -7,11 +7,15 @@ const net = require('node:net');
const { Store } = require('./lib/store');
const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth');
const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http');
const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, dropRealityKey } = require('./lib/protocols');
const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator');
const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, dropRealityKey, nodeEmail } = require('./lib/protocols');
const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources, alignCustomerNodes } = require('./lib/orchestrator');
const { formatSubscription, nodeClientUri } = require('./lib/subscriptions');
const { redactSecrets } = require('./lib/redact');
const { clientIdentity, activeClients, observedIps, pruneAccess } = require('./lib/access');
const { parseResetDay, armTrafficReset, applyMonthlyTrafficResets } = require('./lib/reset');
const { customerOnline, customerSpeedBps, nodeSpeedBps, nodeOnlineCustomerIds } = require('./lib/presence');
const { SystemMonitor } = require('./lib/sysstat');
const { fetchExternalSource, dueSources, applyFetchResult, normalizeExternalSources, normalizeManualLinks } = require('./lib/extsub');
const APP_ROOT = __dirname;
const PUBLIC_DIR = path.join(APP_ROOT, 'public');
@@ -24,6 +28,7 @@ const VERSION = '0.6.8';
const store = new Store(DATA_FILE);
let sessions;
let orchestrator;
let systemMonitor = null;
const loginAttempts = new Map();
function cleanText(value, max = 120) {
@@ -70,6 +75,7 @@ function normalizeServer(body, current = {}) {
publicAddress: 'publicAddress' in body ? requiredText(body.publicAddress, '公网地址', 255) : current.publicAddress,
publicAddressV6: 'publicAddressV6' in body ? cleanText(body.publicAddressV6, 255) : (current.publicAddressV6 || ''),
tlsDomain: 'tlsDomain' in body ? cleanText(body.tlsDomain, 253).toLowerCase() : (current.tlsDomain || ''),
enabled: 'enabled' in body ? body.enabled !== false : (current.enabled !== undefined ? current.enabled : true),
portRangeStart: 'portRangeStart' in body ? Number(body.portRangeStart) : Number(current.portRangeStart || 20000),
portRangeEnd: 'portRangeEnd' in body ? Number(body.portRangeEnd) : Number(current.portRangeEnd || 50000),
labels: 'labels' in body ? asIds(body.labels).slice(0, 20) : (current.labels || [])
@@ -134,14 +140,47 @@ function publicNode(item) {
function publicServer(item) {
return { ...item, ...(item.engine ? { engine: { ...item.engine, detail: redactSecrets(item.engine.detail) } } : {}),
enabled: item.enabled !== false,
agentVersion: store.data.agents.find((agent) => agent.serverId === item.id && agent.status === 'active')?.version || null,
pendingCleanup: store.data.nodes.filter((entry) => (entry.serverId === item.id || entry.exitServerId === item.id) && entry.status === 'removing').length };
}
function publicCustomer(item) {
return { ...item,
const now = Date.now();
const speed = customerSpeedBps(item, now);
const out = { ...item,
observedIpCount: observedIps(store.data, item.id).length,
subscriptionClientCount: activeClients(store.data, item.id).length };
subscriptionClientCount: activeClients(store.data, item.id).length,
online: customerOnline(item, now),
lastActiveAt: item.lastOnlineAt || null,
speedUpBps: speed.up, speedDownBps: speed.down };
if ((item.externalSources || []).length) {
out.externalStatus = {};
for (const source of item.externalSources) {
const cache = (store.data.subSourceCache || {})[source.id];
out.externalStatus[source.id] = cache
? { fetchedAt: cache.fetchedAt || null, okAt: cache.okAt || null, failCount: cache.failCount || 0,
lastError: cache.lastError || null, droppedCount: cache.droppedCount || 0, count: (cache.items || []).length }
: { fetchedAt: null, okAt: null, failCount: 0, lastError: null, droppedCount: 0, count: 0 };
}
}
return out;
}
// One customer's credential rows (凭据 tab): the subscription token plus
// one row per node binding, holding that binding's own credentials.
function credentialItems(data, customer) {
return data.nodeClients.filter((nc) => nc.customerId === customer.id).map((nc) => {
const node = data.nodes.find((item) => item.id === nc.nodeId);
return {
nodeClientId: nc.id, nodeId: nc.nodeId, nodeName: node ? node.name : nc.nodeId,
protocol: node ? node.protocol : '', status: nc.status,
clientId: nc.credentials ? nc.credentials.clientId || '' : '',
password: nc.credentials ? nc.credentials.relayPassword || '' : '',
shortId: nc.credentials ? nc.credentials.shortId || '' : '',
username: node && node.protocol === 'socks5-auth' ? nodeEmail(customer.id) : '',
};
});
}
function customerBlockReason(data, customer) {
@@ -398,6 +437,10 @@ async function handleAgent(req, res, pathname) {
const existing = data.observations.find((item) => item.customerId === customerId && item.ip === ip);
if (existing) existing.lastSeenAt = nowIso();
else data.observations.push({ id: id('obs'), customerId, ip, firstSeenAt: nowIso(), lastSeenAt: nowIso() });
// An accepted observation is a live connection on this server:
// it is the customer's freshest presence signal.
const observedCustomer = data.customers.find((item) => item.id === customerId);
if (observedCustomer) observedCustomer.lastOnlineAt = nowIso();
affected.add(customerId);
}
for (const customerId of affected) {
@@ -418,6 +461,33 @@ async function handleAgent(req, res, pathname) {
return false;
}
// Fetch one external source now and persist the outcome. Used by the
// manual refresh endpoint (awaited) and by save/housekeeping triggers.
async function refreshOneSource(customerId, sourceId) {
const customer = store.data.customers.find((item) => item.id === customerId);
const source = customer && (customer.externalSources || []).find((item) => item.id === sourceId);
if (!source) return null;
const result = await fetchExternalSource(source, store.data.settings);
await store.transaction((data) => { applyFetchResult(data, sourceId, result, nowIso()); });
return result;
}
function refreshSourcesInBackground(customerId, sourceIds) {
for (const sourceId of sourceIds || []) {
refreshOneSource(customerId, sourceId).catch((error) => console.error('External subscription refresh failed:', error));
}
}
async function refreshDueSources() {
try {
for (const { customerId, source } of dueSources(store.data, Date.now(), 3)) {
await refreshOneSource(customerId, source.id);
}
} catch (error) {
console.error('External subscription refresh failed:', error);
}
}
async function handleAdminApi(req, res, pathname) {
if (!pathname.startsWith('/api/')) return false;
const changing = !['GET', 'HEAD'].includes(req.method);
@@ -440,6 +510,11 @@ async function handleAdminApi(req, res, pathname) {
});
return true;
}
if (req.method === 'GET' && pathname === '/api/system') {
if (!systemMonitor) systemMonitor = new SystemMonitor(path.dirname(DATA_FILE));
sendJson(res, 200, systemMonitor.status(store.data, VERSION));
return true;
}
if (req.method === 'GET' && pathname === '/api/protocols') {
sendJson(res, 200, { profiles: PROFILE_CATALOG, realityPresets: REALITY_PRESETS });
return true;
@@ -602,7 +677,7 @@ async function handleAdminApi(req, res, pathname) {
}
if (req.method === 'POST' && pathname === '/api/customers') {
const body = await readJson(req);
const customer = await store.transaction((data) => {
const customer = await store.transaction(async (data) => {
const next = {
id: id('cus'), subscriptionToken: randomToken(32), name: requiredText(body.name, '客户名称'), group: cleanText(body.group, 80),
status: 'active', trafficLimitBytes: nonNegativeNumber(body.trafficLimitBytes, '流量上限'), usedBytes: 0, usedUplinkBytes: 0, usedDownlinkBytes: 0,
@@ -611,16 +686,25 @@ async function handleAdminApi(req, res, pathname) {
tags: asIds(body.tags).slice(0, 20), notes: cleanText(body.notes, 1000),
createdAt: nowIso(), updatedAt: nowIso()
};
const resetDay = parseResetDay(body.trafficResetDay);
if (resetDay > 0) { next.trafficResetDay = resetDay; armTrafficReset(next); }
const sources = normalizeExternalSources(body.externalSources, id);
if (sources && sources.length) next.externalSources = sources;
const links = normalizeManualLinks(body.manualLinks, id);
if (links && links.length) next.manualLinks = links;
data.customers.push(next);
if (Array.isArray(body.nodeIds)) await alignCustomerNodes(data, next, body.nodeIds, actor);
audit(data, actor, 'create_customer', next.id, { name: next.name });
return structuredClone(next);
});
refreshSourcesInBackground(customer.id, (customer.externalSources || []).map((source) => source.id));
sendJson(res, 201, { customer });
return true;
}
const customerItem = route('/api/customers/:id', pathname);
if (customerItem && req.method === 'PATCH') {
const body = await readJson(req);
let changedSourceIds = [];
const customer = await store.transaction(async (data) => {
const item = data.customers.find((entry) => entry.id === customerItem.id);
if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
@@ -634,6 +718,32 @@ async function handleAdminApi(req, res, pathname) {
if ('deviceLimit' in body) item.deviceLimit = nonNegativeNumber(body.deviceLimit, '设备上限', true);
if ('expiresAt' in body) item.expiresAt = optionalIso(body.expiresAt, '到期日期');
if ('tags' in body) item.tags = asIds(body.tags).slice(0, 20);
if ('trafficResetDay' in body) {
const resetDay = parseResetDay(body.trafficResetDay);
if (resetDay !== (item.trafficResetDay || 0)) {
// A changed schedule is re-armed rather than fired (mirrors
// panel-go); an unchanged echo leaves the marker alone.
if (resetDay > 0) item.trafficResetDay = resetDay; else delete item.trafficResetDay;
armTrafficReset(item);
}
}
if ('externalSources' in body) {
const previous = new Map((item.externalSources || []).map((source) => [source.id, source]));
const sources = normalizeExternalSources(body.externalSources, id) || [];
if (sources.length) item.externalSources = sources; else delete item.externalSources;
const kept = new Set(sources.map((source) => source.id));
if (data.subSourceCache) {
for (const oldId of previous.keys()) if (!kept.has(oldId)) delete data.subSourceCache[oldId];
}
changedSourceIds = sources.filter((source) => {
const old = previous.get(source.id);
return !old || old.url !== source.url || (old.userAgent || '') !== (source.userAgent || '');
}).map((source) => source.id);
}
if ('manualLinks' in body) {
const links = normalizeManualLinks(body.manualLinks, id) || [];
if (links.length) item.manualLinks = links; else delete item.manualLinks;
}
const reason = customerBlockReason(data, item);
if (body.status === 'active' && reason) {
const error = new Error(`客户仍受${{ expired:'到期时间', traffic_limit:'流量额度', ip_limit:'节点 IP 上限' }[reason]}限制,请先修改限制再启用`);
@@ -646,10 +756,12 @@ async function handleAdminApi(req, res, pathname) {
}
if (wasActive && item.status === 'suspended') suspendCustomerResources(data, item, actor);
if (!wasActive && item.status === 'active') await resumeCustomerResources(data, item, actor);
if (Array.isArray(body.nodeIds)) await alignCustomerNodes(data, item, body.nodeIds, actor);
item.updatedAt = nowIso();
audit(data, actor, 'update_customer', item.id);
return structuredClone(item);
});
refreshSourcesInBackground(customer.id, changedSourceIds);
sendJson(res, 200, { customer });
return true;
}
@@ -686,24 +798,98 @@ async function handleAdminApi(req, res, pathname) {
data.nodes.some((node) => node.id === item.nodeId && !node.pendingDelete))) {
throw Object.assign(new Error('客户仍有节点分配。请先移除全部分配'), { statusCode: 409 });
}
const removed = data.customers[index];
data.customers.splice(index, 1);
data.subscriptionAccess = data.subscriptionAccess.filter((item) => item.customerId !== customerItem.id);
data.subscriptionClients = data.subscriptionClients.filter((item) => item.customerId !== customerItem.id);
data.observations = data.observations.filter((item) => item.customerId !== customerItem.id);
if (data.subSourceCache) {
for (const source of removed.externalSources || []) delete data.subSourceCache[source.id];
}
audit(data, actor, 'delete_customer', customerItem.id);
});
sendJson(res, 200, { ok: true });
return true;
}
const credentialsRoute = route('/api/customers/:id/credentials', pathname);
if (credentialsRoute && req.method === 'GET') {
const customer = store.data.customers.find((item) => item.id === credentialsRoute.id);
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
sendJson(res, 200, { customerId: customer.id, subscriptionToken: customer.subscriptionToken,
items: credentialItems(store.data, customer) });
return true;
}
const regenerateRoute = route('/api/customers/:id/credentials/:ncid/regenerate', pathname);
if (regenerateRoute && req.method === 'POST') {
const item = await store.transaction(async (data) => {
const customer = data.customers.find((entry) => entry.id === regenerateRoute.id);
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
const nc = data.nodeClients.find((entry) => entry.id === regenerateRoute.ncid && entry.customerId === customer.id);
if (!nc) throw Object.assign(new Error('凭据不存在'), { statusCode: 404 });
const node = data.nodes.find((entry) => entry.id === nc.nodeId);
if (!node) throw Object.assign(new Error('节点不存在'), { statusCode: 404 });
// Same generation rules as a fresh assignment; the node resource is
// rebuilt so the old credential stops working on the wire.
nc.credentials = newNodeClientCredentials(node.protocol);
nc.updatedAt = nowIso();
if (['active', 'deploying', 'failed', 'degraded'].includes(node.status)) await rebuildNode(data, node);
audit(data, actor, 'regenerate_credentials', customer.id, { nodeId: node.id });
return credentialItems(data, customer).find((entry) => entry.nodeClientId === nc.id);
});
sendJson(res, 200, { item });
return true;
}
const sourceRefresh = route('/api/customers/:id/external-sources/:sid/refresh', pathname);
if (sourceRefresh && req.method === 'POST') {
const customer = store.data.customers.find((item) => item.id === sourceRefresh.id);
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
if (!(customer.externalSources || []).some((item) => item.id === sourceRefresh.sid)) {
throw Object.assign(new Error('外部订阅不存在'), { statusCode: 404 });
}
const result = await refreshOneSource(customer.id, sourceRefresh.sid);
sendJson(res, 200, { ok: Boolean(result && result.ok), count: result ? result.items.length : 0,
dropped: result ? result.dropped : 0, error: result ? result.error : '刷新失败' });
return true;
}
if (req.method === 'GET' && pathname === '/api/nodes') {
const visibleNodes = store.data.nodes.filter((item) => !item.pendingDelete);
const now = Date.now();
const visibleNodes = store.data.nodes.filter((item) => !item.pendingDelete)
.sort((a, b) => (Number.isFinite(a.sortIndex) ? a.sortIndex : Number.MAX_SAFE_INTEGER) - (Number.isFinite(b.sortIndex) ? b.sortIndex : Number.MAX_SAFE_INTEGER));
const visibleIds = new Set(visibleNodes.map((item) => item.id));
const nodeClients = store.data.nodeClients.filter((nc) => visibleIds.has(nc.nodeId)).map((nc) => {
const { credentials, ...safe } = nc;
return { ...safe, clientUri: nodeClientUri(store.data, nc) };
});
sendJson(res, 200, { nodes: visibleNodes.map(publicNode), nodeClients });
const nodes = visibleNodes.map((node) => {
const onlineCustomerIds = nodeOnlineCustomerIds(store.data, node, now);
const speed = nodeSpeedBps(node, now);
return { ...publicNode(node),
clientCount: store.data.nodeClients.filter((nc) => nc.nodeId === node.id && nc.status === 'active').length,
onlineCount: onlineCustomerIds.length, onlineCustomerIds,
speedUpBps: speed.up, speedDownBps: speed.down };
});
sendJson(res, 200, { nodes, nodeClients });
return true;
}
if (req.method === 'POST' && pathname === '/api/nodes/reorder') {
const body = await readJson(req);
const ids = asIds(body.ids);
if (!ids.length) throw Object.assign(new Error('排序列表不能为空'), { statusCode: 400 });
await store.transaction((data) => {
const byId = new Map(data.nodes.map((node) => [node.id, node]));
for (const nodeId of ids) {
if (!byId.has(nodeId)) throw Object.assign(new Error('节点不存在'), { statusCode: 400 });
}
const listed = new Set(ids);
const rest = data.nodes.filter((node) => !listed.has(node.id))
.sort((a, b) => (Number.isFinite(a.sortIndex) ? a.sortIndex : Number.MAX_SAFE_INTEGER) - (Number.isFinite(b.sortIndex) ? b.sortIndex : Number.MAX_SAFE_INTEGER));
[...ids.map((nodeId) => byId.get(nodeId)), ...rest].forEach((node, index) => { node.sortIndex = index; });
audit(data, actor, 'reorder_nodes', 'nodes', { count: ids.length });
return null;
});
sendJson(res, 200, { ok: true });
return true;
}
if (req.method === 'POST' && pathname === '/api/nodes') {
@@ -713,6 +899,7 @@ async function handleAdminApi(req, res, pathname) {
const next = {
id: id('nod'), status: 'draft', entryReady: false, exitReady: false,
resourceId: id('res'), exitResourceId: id('res'),
sortIndex: data.nodes.reduce((max, node) => Math.max(max, Number.isFinite(node.sortIndex) ? node.sortIndex : -1), -1) + 1,
...normalized,
artifacts: {}, error: null, pendingDelete: false,
usageCounters: null, retiredUsageEpochs: [], meteredTraffic: null, lastUsageAt: null,
@@ -1066,6 +1253,16 @@ async function housekeeping() {
try {
await store.transaction(async (data) => {
const now = Date.now();
// Monthly traffic resets run before the quota evaluation below,
// mirroring panel-go housekeeping.
const resetDone = applyMonthlyTrafficResets(data, new Date(), (draft, customer, previousUsed, day, period) =>
audit(draft, 'system:monthly_reset', 'reset_customer_usage_monthly', customer.id, { previousUsedBytes: previousUsed, resetDay: day, period }));
for (const customer of resetDone) {
if (customer.status === 'suspended' && customer.suspendReason === 'traffic_limit' && !customerBlockReason(data, customer)) {
customer.status = 'active'; customer.suspendReason = null; customer.updatedAt = nowIso();
await resumeCustomerResources(data, customer, 'system:monthly_reset');
}
}
for (const server of data.servers) {
if (server.lastSeenAt && now - new Date(server.lastSeenAt).getTime() > 180000) server.status = 'offline';
}
@@ -1103,6 +1300,9 @@ async function housekeeping() {
return null;
});
sessions.prune();
if (!systemMonitor) systemMonitor = new SystemMonitor(path.dirname(DATA_FILE));
systemMonitor.sample(store.data);
await refreshDueSources();
} catch (error) {
console.error('Housekeeping failed:', error);
}
@@ -1126,6 +1326,14 @@ async function main() {
if (store.data.customers.some((item) => !item.subscriptionToken)) {
await store.transaction((data) => { for (const customer of data.customers) if (!customer.subscriptionToken) customer.subscriptionToken = randomToken(32); });
}
// Node custom order: legacy records carry no sortIndex. Renumber by the
// stored array order whenever the values are not exactly 0..n-1.
const sortValues = store.data.nodes.map((node) => node.sortIndex);
const sortBroken = !sortValues.every((value) => Number.isFinite(value)) ||
[...sortValues].sort((a, b) => a - b).some((value, index) => value !== index);
if (store.data.nodes.length && sortBroken) {
await store.transaction((data) => { data.nodes.forEach((node, index) => { node.sortIndex = index; }); });
}
if (!store.data.users.length) {
const password = process.env.NG_ADMIN_PASSWORD || randomToken(15);
await store.transaction((data) => {
+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);
});