32 Commits
Author SHA1 Message Date
aaaamoooo 1486a67124 docs(readme): panel is Go now — drop Node.js description, correct memory figures (panel ~15MB)
CI / test (push) Waiting to run
CI / anytls-smoke (push) Waiting to run
2026-10-21 22:41:28 +08:00
rogue 733ec38022 更新 README.md
CI / test (push) Waiting to run
CI / anytls-smoke (push) Waiting to run
2026-10-21 22:31:31 +08:00
aaaamoooo 5cfc5bebd7 docs(v4): batch report for items 24/25 + notes entries as authored
CI / test (push) Waiting to run
CI / anytls-smoke (push) Waiting to run
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
30 changed files with 2991 additions and 270 deletions
+82 -2
View File
@@ -82,11 +82,91 @@ amo 各页主内容区两侧留白偏大;把左右间距(padding/最大宽
- 左侧菜单「设置与运维」→ 改名「设置」
- 「安装与命令」页面的全部内容合并进「设置」页面,合并后删除「安装与命令」页面(菜单项与路由一并摘除)
## 12. 删除系统状态页「最近活动」模块(2026-10-21 04:56,用户附截图)
## 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,用户附截图)
首页(系统状态页)底部的「最近活动」面板整个删除(该面板是改版时为保旧功能补回的,用户现明确不要)。只删展示模块,后端活动/审计记录本身保留不动。
## 13. 设置页「任务队列」表格排紧凑(2026-10-21 04:58,用户附截图)
## 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]
+5 -12
View File
@@ -4,7 +4,7 @@
## 架构
- **主控**:Node.js 面板(约 61MB),跑在 OpenWrt 路由器或任意 Linux 上。管理节点、客户、订阅,下发配置到各 Agent。
- **主控**:Go 静态二进制面板(约 13-30MB),跑在 OpenWrt 路由器或任意 Linux 上,无运行时依赖。管理节点、客户、订阅,下发配置到各 Agent。
- **Agent**:跑在节点机上,通过 token 注册。管理 sing-box 进程,上报心跳和按用户流量。
## 与原版的区别
@@ -13,20 +13,13 @@
- 3x-ui 式共享节点:nodes 状态机 + 每个客户独立凭据(`ng:<customerId>` 身份)
- 全面 sing-box:不再捆绑 xray
- 按用户流量统计:AnyTLS 走日志解析,SS 走节点级
- 内存优化:面板 `node --max-old-space-size=128 --optimize-for-size`;Agent 为 Go 静态二进制(约 12MB,节点机无需 Node.js);sing-box `GOMEMLIMIT=64MiB GOGC=50`
- 内存优化:主控与 Agent 均为 Go 静态二进制、无 Node.js 依赖(主控约 13-30MB、Agent 约 12MB);主控可加 `GOMEMLIMIT=28MiB GOGC=50` 压内存,sing-box 服务为 `GOMEMLIMIT=64MiB GOGC=50`
## 安装
### 主控
```bash
# 克隆仓库
git clone https://git.881218.xyz/rogue/NexusGate.git /opt/nexusgate
cd /opt/nexusgate
# 安装依赖并启动
node --max-old-space-size=128 --optimize-for-size server.js
```
主控是 Go 静态二进制 `nexusgate-panel`(panel-go/),无运行时依赖,数据放在 `/etc/nexusgate`。部署与换装步骤见 `panel-go/INSTALL-GO.md`。
面板地址:`http://IP:8787`,默认账号 `admin`。
@@ -48,11 +41,11 @@ Agent 是 Go 静态二进制,安装时从 Gitea Release 下载,节点机不
VLESS / SS2022 / SS经典 / SOCKS5 / Hysteria2 / AnyTLS
## 内存占用(实测 2026-10-20,三台节点机)
## 内存占用(实测 2026-10-21)
| 组件 | 内存 |
|---|---|
| 主控面板 | 约 61MB(OpenWrt 路由器) |
| 主控面板 | 约 13-30MB(Go 静态二进制,OpenWrt 路由器) |
| Agent | 约 12MB(Go 静态二进制) |
| sing-box | 16–36MB |
+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:
+31 -2
View File
@@ -214,6 +214,7 @@ var singBoxVersionCache struct {
path string
mtimeNs int64
size int64
vfMtimeNs int64 // install-record file mtime (part of the cache key)
version string
probed bool
}
@@ -233,22 +234,50 @@ func parseSingBoxVersion(out string) string {
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() {
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)
}
c.path, c.mtimeNs, c.size, c.version, c.probed = cfg.SingBoxBin, info.ModTime().UnixNano(), info.Size(), version, true
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
}
+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)
}
}
+43
View File
@@ -71,6 +71,49 @@ func TestSingBoxVersionProbeCachesByMtime(t *testing.T) {
}
}
// 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")
+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")
}
}
+242 -30
View File
@@ -6,8 +6,10 @@ package main
// line by line.
import (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"math"
@@ -569,6 +571,30 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
}
}
}
// Round-3 item 21: a socks5-auth node carries its own fixed account.
// Fields merge over the stored pair (blank keeps the old value, the
// form's convention); a pair still missing afterwards is minted by
// EnsureNodeSecrets on save. The username must not start with "ng:"
// — that prefix is the per-customer identity namespace the agent
// maps to usage samples.
if protocol == "socks5-auth" {
if v, ok := body["socksUser"]; ok {
if user := CleanText(v, 128); user != "" {
if strings.HasPrefix(user, "ng:") || strings.ContainsAny(user, " \t\r\n") {
return nil, httpErr(400, "SOCKS5 用户名不能以 ng: 开头或包含空白字符")
}
next.SocksUser = user
}
}
if v, ok := body["socksPassword"]; ok {
if password := CleanText(v, 512); password != "" {
next.SocksPassword = password
}
}
} else {
next.SocksUser = ""
next.SocksPassword = ""
}
if IsRealityProtocol(protocol) && next.RealityServerName == "" {
return nil, httpErr(400, "Reality SNI 不能为空")
}
@@ -755,7 +781,9 @@ func customerCredentialsView(d *Data, customer *Customer) map[string]any {
}
username := ""
if node.Protocol == "socks5-auth" {
username = NodeEmail(customer.ID)
// Item 21: the socks identity lives on the node now (fixed
// account), not per customer.
username = node.SocksUser
}
var publicKey any
if key := realityPublicKeyOf(node); key != "" {
@@ -822,19 +850,29 @@ type adminAuth struct {
User *User
}
// CurrentAdmin is currentAdmin.
// CurrentAdmin is currentAdmin. Since round-3 item 22 it resolves the
// session's user under a read-only View instead of cloning the whole
// database: admin auth runs on EVERY request, and the user table is
// tiny. The returned User is a copy the caller may read freely.
func (a *App) CurrentAdmin(r *http.Request) *adminAuth {
session := a.getSessions().Get(r)
if session == nil {
return nil
}
data := a.store.Snapshot()
for _, user := range data.Users {
var found *User
a.store.View(func(d *Data) {
for _, user := range d.Users {
if user.ID == session.UserID && user.Status == "active" {
return &adminAuth{Session: session, User: user}
copied := *user
found = &copied
return
}
}
})
if found == nil {
return nil
}
return &adminAuth{Session: session, User: found}
}
// RequireAdmin is requireAdmin: sends 401/403 itself and returns nil.
@@ -851,32 +889,148 @@ func (a *App) RequireAdmin(w http.ResponseWriter, r *http.Request, csrf bool) *a
return auth
}
// FindAgentByBearer is findAgentByBearer: fingerprint fast path, then a
// full VerifySecret scan over legacy agents without a fingerprint.
func (a *App) FindAgentByBearer(r *http.Request) *Agent {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
// ---------- agent session tokens (item 25) ----------
// AgentSessionTokenTTL is how long an issued agent session token stays
// valid. Within the TTL the agent authenticates with a SHA-256 hash
// compare; after it the agent falls back to one scrypt-verified key
// auth and the panel issues a fresh token.
const AgentSessionTokenTTL = 24 * time.Hour
const (
agentSessionTokenHeader = "X-Agent-Session-Token"
agentSessionTokenExpiresHeader = "X-Agent-Session-Token-Expires"
)
// issuedAgentToken is a freshly minted session token to hand back to
// the agent via response headers; only its SHA-256 hash is persisted.
type issuedAgentToken struct {
Token string
ExpiresAt string
}
// agentBySessionToken resolves a session-token bearer: the stored hash
// is compared in constant time and the token must be unexpired and the
// agent active. Returns a copy of the agent record, or nil.
func (a *App) agentBySessionToken(bearer string) *Agent {
sum := sha256.Sum256([]byte(bearer))
nowMs := time.Now().UnixMilli()
var found *Agent
a.store.View(func(d *Data) {
for _, agent := range d.Agents {
if agent.Status != "active" || agent.SessionTokenHash == nil || agent.SessionTokenExpiresAt == nil {
continue
}
stored, err := hex.DecodeString(*agent.SessionTokenHash)
if err != nil || len(stored) != len(sum) {
continue
}
if subtle.ConstantTimeCompare(sum[:], stored) != 1 {
continue
}
expMs, ok := ParseISOMs(*agent.SessionTokenExpiresAt)
if !ok || expMs <= nowMs {
continue
}
copied := *agent
found = &copied
return
}
})
return found
}
// issueAgentSessionToken mints a session token for the agent, persists
// only its SHA-256 hash and expiry, and returns the plaintext for the
// response headers. Returns nil when the record is gone or no longer
// active (e.g. revoked between verification and issuance).
func (a *App) issueAgentSessionToken(agentID string) *issuedAgentToken {
token := RandomToken(40)
hash := TokenFingerprint(token)
expires := ISOFromTime(time.Now().Add(AgentSessionTokenTTL))
issued := false
_, err := a.store.Transaction(func(d *Data) (any, error) {
for _, agent := range d.Agents {
if agent.ID == agentID {
if agent.Status != "active" {
return nil, nil
}
agent.SessionTokenHash = &hash
agent.SessionTokenExpiresAt = &expires
issued = true
return nil, nil
}
}
return nil, nil
})
if err != nil || !issued {
return nil
}
token := header[len("Bearer "):]
fingerprint := TokenFingerprint(token)
data := a.store.Snapshot()
return &issuedAgentToken{Token: token, ExpiresAt: expires}
}
// authenticateAgent resolves the agent behind an /api/agent/* request.
// Order: (1) session-token fast path — SHA-256 the bearer and
// constant-time compare against the stored token hash of active,
// unexpired agents, no scrypt; (2) the original key path — fingerprint
// lookup, then a VerifySecret scan over legacy agents without a
// fingerprint. A key-path success mints a session token and returns it
// in the second value so the handler can hand it to the agent.
func (a *App) authenticateAgent(r *http.Request) (*Agent, *issuedAgentToken) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return nil, nil
}
bearer := header[len("Bearer "):]
if agent := a.agentBySessionToken(bearer); agent != nil {
return agent, nil
}
// Key path: copy candidates out under a read view, then run the
// expensive scrypt verification outside the store lock.
fingerprint := TokenFingerprint(bearer)
var fast *Agent
for _, agent := range data.Agents {
if agent.Status == "active" && agent.KeyFingerprint != nil && *agent.KeyFingerprint == fingerprint {
fast = agent
var legacy []*Agent
a.store.View(func(d *Data) {
for _, agent := range d.Agents {
if agent.Status != "active" {
continue
}
if agent.KeyFingerprint != nil && *agent.KeyFingerprint == fingerprint {
if fast == nil {
copied := *agent
fast = &copied
}
} else if agent.KeyFingerprint == nil {
copied := *agent
legacy = append(legacy, &copied)
}
}
})
var verified *Agent
if fast != nil && VerifySecret(bearer, fast.KeyHash) {
verified = fast
} else {
for _, agent := range legacy {
if VerifySecret(bearer, agent.KeyHash) {
verified = agent
break
}
}
if fast != nil && VerifySecret(token, fast.KeyHash) {
return fast
}
for _, agent := range data.Agents {
if agent.Status == "active" && agent.KeyFingerprint == nil && VerifySecret(token, agent.KeyHash) {
if verified == nil {
return nil, nil
}
return verified, a.issueAgentSessionToken(verified.ID)
}
// FindAgentByBearer is findAgentByBearer: session-token fast path,
// fingerprint lookup, then a full VerifySecret scan over legacy agents
// without a fingerprint.
func (a *App) FindAgentByBearer(r *http.Request) *Agent {
agent, _ := a.authenticateAgent(r)
return agent
}
}
return nil
}
func clearSessionCookie() map[string]string {
@@ -1048,7 +1202,7 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
}
for _, old := range d.Agents {
if old.ServerID == server.ID {
old.Status = "revoked"
old.Revoke()
}
}
fp := TokenFingerprint(agentKey)
@@ -1094,11 +1248,19 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
if !strings.HasPrefix(pathname, "/api/agent/") {
return false, nil
}
agent := a.FindAgentByBearer(r)
agent, issued := a.authenticateAgent(r)
if agent == nil {
SendError(w, 401, "Agent 身份校验失败", "invalid_agent")
return true, nil
}
if issued != nil {
// Hand the fresh session token to the agent (item 25): it
// persists the token and uses it for subsequent requests
// instead of its key. Agents that predate tokens ignore
// these headers and keep authenticating with the key.
w.Header().Set(agentSessionTokenHeader, issued.Token)
w.Header().Set(agentSessionTokenExpiresHeader, issued.ExpiresAt)
}
if r.Method == http.MethodPost && pathname == "/api/agent/heartbeat" {
body, err := ReadJSON(r, defaultMaxBody)
@@ -1754,7 +1916,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
d.Servers = append(d.Servers[:index], d.Servers[index+1:]...)
for _, agent := range d.Agents {
if agent.ServerID == params["id"] {
agent.Status = "revoked"
agent.Revoke()
}
}
Audit(d, actor, "delete_server", params["id"], nil)
@@ -1907,7 +2069,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
d.Nodes = nodes
for _, agent := range d.Agents {
if agent.ServerID == server.ID {
agent.Status = "revoked"
agent.Revoke()
}
}
d.Servers = append(d.Servers[:index], d.Servers[index+1:]...)
@@ -2293,9 +2455,10 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
continue
}
rebuilt[node.ID] = true
if err := RebuildNode(d, node, false); err != nil {
return nil, err
}
// Credential rotation is a binding-class change: it goes
// through the coalesced window (item 23), not an
// immediate per-node restart.
markRebuildPending(node, false)
}
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodesRebuilt": len(rebuilt)})
view = customerCredentialsView(d, customer)
@@ -2553,6 +2716,52 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
SendJSON(w, 201, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil)
return true, nil
}
if r.Method == http.MethodPost && pathname == "/api/reality/scan" {
// Round-3 item 16: panel-side Reality target scan. Bounded by
// parse/probe limits in realityscan.go.
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
input, _ := body["input"].(string)
if raw, ok := body["targets"].([]any); ok {
parts := []string{}
for _, item := range raw {
if s, ok := item.(string); ok {
parts = append(parts, s)
}
}
input = strings.Join(parts, ",")
}
targets, herr := parseScanTargets(input)
if herr != nil {
return true, herr
}
results := scanRealityTargets(targets)
SendJSON(w, 200, map[string]any{"results": results}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/nodes/:id/reality-keys", pathname); ok && r.Method == http.MethodGet {
// Round-3 item 17: display-only view of the panel-hosted Reality
// keypair for the node's server. This NEVER generates a pair —
// a missing entry simply reports nulls (the pair is minted by
// EnsureRealityKey at deploy time).
data := a.store.Snapshot()
node := findNode(data, params["id"])
if node == nil {
return true, httpErr(404, "节点不存在")
}
if !IsRealityProtocol(node.Protocol) {
return true, httpErr(400, "该节点不是 Reality 协议")
}
keyID := RealityKeyIDForNode(node)
var publicKey, privateKey any
if entry := data.KeyStore[keyID]; entry != nil {
publicKey, privateKey = entry.PublicKey, entry.PrivateKey
}
SendJSON(w, 200, map[string]any{"keyId": keyID, "publicKey": publicKey, "privateKey": privateKey}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/nodes/:id", pathname); ok && r.Method == http.MethodPatch {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
@@ -2594,6 +2803,9 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
item.ExitReady = false
}
if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" {
// A node edit ships immediately and consumes any pending
// coalesced mark (item 23).
clearRebuildPending(item)
if err := RebuildNode(d, item, true); err != nil {
return nil, err
}
+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"])
}
}
+11
View File
@@ -407,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()
}
+95 -38
View File
@@ -345,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 {
@@ -397,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) {
@@ -418,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)})
@@ -471,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
@@ -657,9 +713,12 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
return httpErr(409, "设备 %s 已停用", target.Name)
}
}
if len(activeNodeClients(d, node)) == 0 {
return httpErr(400, "请先给节点分配至少一个客户再部署")
}
// 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 == "" {
@@ -807,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 {
@@ -824,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()
@@ -907,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
}
@@ -928,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 != "" {
@@ -975,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
}
@@ -1031,9 +1097,7 @@ func assignCustomerData(d *Data, nodeID, customerID, actor string) (*NodeClient,
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
node.AutoRemoving = BoolPtr(false)
}
if err := RebuildNode(d, node, false); err != nil {
return nil, err
}
markRebuildPending(node, false)
}
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
return nc, nil
@@ -1056,9 +1120,7 @@ func unassignCustomerData(d *Data, nodeID, customerID, actor string) error {
}
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
@@ -1139,9 +1201,7 @@ func AlignCustomerNodes(d *Data, customer *Customer, desiredIds []string, actor
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
node.AutoRemoving = BoolPtr(false)
}
if err := RebuildNode(d, node, false); err != nil {
return err
}
markRebuildPending(node, false)
}
return nil
}
@@ -1217,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
}
+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)
}
}
})
}
}
+48 -62
View File
@@ -1,7 +1,6 @@
package main
import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
@@ -237,7 +236,27 @@ func clientCredentials(nc *NodeClient) 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)))
}
@@ -284,19 +303,19 @@ 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) {
@@ -309,54 +328,19 @@ 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 ----------
@@ -445,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": clientCredentials(nc).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
}
+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))
}
}
+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)
}
}
}
+26 -1
View File
@@ -134,7 +134,11 @@ func (s *Store) Init() error {
if err := validateData(&data); err != nil {
return err
}
if MigrateCustomerCredentials(&data) {
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 {
@@ -145,6 +149,27 @@ func (s *Store) Init() error {
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:
+18 -2
View File
@@ -112,15 +112,31 @@ func NodeClientURI(d *Data, nc *NodeClient) string {
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: creds.ClientID,
RelayPassword: creds.RelayPassword,
RelayPassword: relayPassword,
ShortID: creds.ShortID,
WSPath: xbDeref(node.WSPath),
SocksUser: NodeEmail(nc.CustomerID),
SocksUser: socksUser,
Reality: node.Reality,
PublicKey: publicKey,
Name: node.Name,
+7 -1
View File
@@ -30,7 +30,13 @@ 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},
+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==",
+109 -1
View File
@@ -254,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)
@@ -435,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"`
@@ -459,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"`
@@ -735,6 +828,21 @@ 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"`
+245 -33
View File
@@ -9,7 +9,9 @@ const state = {
protocols: [], realityPresets: [], version: '0.6.8',
search: '', groupFilter: '', customerNodeFilter: '',
pages: { servers: 1, customers: 1, nodes: 1 },
addrVisible: false, expandedServers: new Set()
addrVisible: false, expandedServers: new Set(),
selection: { servers: new Set(), customers: new Set(), nodes: new Set() },
bulkRunning: false
};
const PAGE_SIZE = 50;
@@ -128,6 +130,9 @@ async function api(path, options = {}) {
if (response.status === 401 && path !== '/api/auth/login') showLogin();
throw new Error((data && data.message) || `请求失败 (${response.status})`);
}
// Any successful mutation invalidates the page-data cache (item 22):
// every write path in this file funnels through api().
if (!['GET', 'HEAD'].includes(options.method || 'GET')) invalidateDataCache();
return data;
}
@@ -240,29 +245,59 @@ function pagerHTML(key, total) {
}
/* ---------- 数据加载与路由 ---------- */
/* 基础数据集页间缓存(第 22 条):servers/customers/nodes/protocols 在
30 秒内切页直接复用、不重复请求;任何写操作经 api() 统一失效。
渲染不再等全部请求落地:每份数据到达即更新 state 并重渲染当前页。 */
const DATA_CACHE_TTL_MS = 30000;
const dataCacheAt = { servers: 0, customers: 0, nodes: 0, protocols: 0 };
function invalidateDataCache() { for (const key of Object.keys(dataCacheAt)) dataCacheAt[key] = 0; }
function cacheFresh(key) { return dataCacheAt[key] && Date.now() - dataCacheAt[key] < DATA_CACHE_TTL_MS; }
async function load(page = state.page) {
const isCurrent = () => state.page === page && !modalOpen();
const apply = (fn) => { fn(); if (isCurrent()) render(); };
const jobs = [];
const errors = [];
// Cached base dataset: fetch only when stale. A partial failure keeps
// whatever data is already in state (the page renders progressively);
// only a total failure surfaces a toast, as the old all-or-nothing
// load did.
const base = (key, path, fn) => {
if (cacheFresh(key)) return;
jobs.push(api(path).then((data) => { dataCacheAt[key] = Date.now(); apply(() => fn(data)); }).catch((error) => errors.push(error)));
};
const fresh = (path, fn) => { jobs.push(api(path).then((data) => apply(() => fn(data))).catch((error) => errors.push(error))); };
const setServers = (d) => { state.servers = d.servers; };
const setCustomers = (d) => { state.customers = d.customers; state.customerSummary = d.summary || null; };
const setNodes = (d) => { state.nodes = d.nodes; state.nodeClients = d.nodeClients; };
const setOverview = (d) => { state.overview = d; state.version = d.version || state.version; $('#version-label').textContent = `v${state.version}`; };
try {
if (page === 'overview') {
const [overview, system, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/system'), api('/api/servers'), api('/api/customers'), api('/api/nodes')]);
state.overview = overview; state.system = system; state.version = overview.version || state.version;
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
$('#version-label').textContent = `v${state.version}`;
fresh('/api/overview', setOverview);
fresh('/api/system', (d) => { state.system = d; });
base('servers', '/api/servers', setServers);
base('customers', '/api/customers', setCustomers);
base('nodes', '/api/nodes', setNodes);
} else if (page === 'servers') {
const [servers, routes] = await Promise.all([api('/api/servers'), api('/api/nodes')]);
state.servers = servers.servers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
base('servers', '/api/servers', setServers);
base('nodes', '/api/nodes', setNodes);
} else if (page === 'customers') {
const [customers, routes] = await Promise.all([api('/api/customers'), api('/api/nodes')]);
state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
base('customers', '/api/customers', setCustomers);
base('nodes', '/api/nodes', setNodes);
} else if (page === 'chains') {
const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/nodes'), api('/api/protocols')]);
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || [];
base('servers', '/api/servers', setServers);
base('customers', '/api/customers', setCustomers);
base('nodes', '/api/nodes', setNodes);
base('protocols', '/api/protocols', (d) => { state.protocols = d.profiles; state.realityPresets = d.realityPresets || []; });
} else if (page === 'operations') {
const [jobs, overview] = await Promise.all([api('/api/jobs'), api('/api/overview')]);
state.jobs = jobs.jobs; state.overview = overview; state.version = overview.version || state.version;
$('#version-label').textContent = `v${state.version}`;
fresh('/api/jobs', (d) => { state.jobs = d.jobs; });
fresh('/api/overview', setOverview);
}
render();
// Render immediately with whatever is already in state (cached or
// previously loaded), then re-render as the rest lands.
if (isCurrent()) render();
await Promise.allSettled(jobs);
if (errors.length && errors.length === jobs.length) throw errors[0];
} catch (error) { toast(error.message, true); }
}
@@ -438,6 +473,102 @@ function serverDetailRow(server) {
<button class="btn small" data-action="agent-uninstall" data-id="${esc(server.id)}">SSH 卸载 Agent</button>
</div></td></tr>`;
}
/* ---------- 列表批量操作(第 24 条) ----------
每页一份选中集合(state.selection),行勾选框与表头全选维护它;
选中 ≥1 时工具条下出现批量条。批量动作逐项调用既有单项接口,
语义与守卫与单项完全一致:服务器 启停=PATCH enabled / 删除=DELETE
(仍被节点引用等守卫由服务端拒绝);客户 启用=status active /
停用=status suspended(与行内开关的 manual 口径一致)/ 删除=
DELETE;入站 启用=deploy / 停用=remove / 删除=DELETE。
选择状态口径:按页独立保存,重渲染与 45 秒自动刷新后按集合恢复
勾选;翻页/筛选不清空(全选只作用于当前渲染的行);批量执行后
清空本页集合。 */
const BULK_DEFS = {
servers: {
get: (id) => serverById(id),
label: (item) => item.name,
isOn: (item) => item.enabled !== false,
enable: (item) => api(`/api/servers/${item.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: true }) }),
disable: (item) => api(`/api/servers/${item.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: false }) }),
remove: (item) => api(`/api/servers/${item.id}`, { method: 'DELETE' }),
deleteConfirm: (n) => `删除选中的 ${n} 台设备?将删除控制台登记并吊销其 Agent 密钥,此操作不可恢复。`,
page: 'servers'
},
customers: {
get: (id) => customerById(id),
label: (item) => item.name,
isOn: (item) => !(item.status === 'suspended' && item.suspendReason === 'manual'),
enable: (item) => api(`/api/customers/${item.id}`, { method: 'PATCH', body: JSON.stringify({ status: 'active' }) }),
disable: (item) => api(`/api/customers/${item.id}`, { method: 'PATCH', body: JSON.stringify({ status: 'suspended' }) }),
remove: (item) => api(`/api/customers/${item.id}`, { method: 'DELETE' }),
deleteConfirm: (n) => `删除选中的 ${n} 位客户及订阅地址?此操作不可恢复(仍有节点分配的客户会被逐项拒绝)。`,
page: 'customers'
},
nodes: {
get: (id) => nodeById(id),
label: (item) => item.name,
isOn: (item) => !['draft', 'removing'].includes(item.status),
busy: (item) => ['deploying', 'removing'].includes(item.status),
enable: (item) => api(`/api/nodes/${item.id}/deploy`, { method: 'POST', body: '{}' }),
disable: (item) => api(`/api/nodes/${item.id}/remove`, { method: 'POST', body: '{}' }),
remove: (item) => api(`/api/nodes/${item.id}`, { method: 'DELETE' }),
deleteConfirm: (n) => `删除选中的 ${n} 个入站?会移除全部分配并从设备清理资源,此操作不可恢复。`,
page: 'chains'
}
};
function bulkBarHTML(key) {
const n = state.selection[key].size;
if (!n) return '';
return `<div class="bulk-bar"><span class="bulk-count">已选 <b>${n}</b> 项</span>
<button class="btn small" data-action="bulk-enable" data-bulk-page="${key}">启用</button>
<button class="btn small" data-action="bulk-disable" data-bulk-page="${key}">停用</button>
<button class="btn small danger" data-action="bulk-delete" data-bulk-page="${key}">删除</button>
<button class="btn small" data-action="bulk-clear" data-bulk-page="${key}">取消选择</button></div>`;
}
async function runBulk(key, op) {
if (state.bulkRunning) return;
const def = BULK_DEFS[key]; if (!def) return;
const ids = [...state.selection[key]];
if (!ids.length) return;
if (op === 'delete' && !await confirmModal(def.deleteConfirm(ids.length), '删除')) return;
state.bulkRunning = true;
const done = []; const failed = []; const skipped = [];
try {
for (const id of ids) {
const item = def.get(id);
if (!item) { skipped.push('已不存在'); continue; }
const name = def.label(item) || id;
if (op !== 'delete') {
if (def.busy && def.busy(item)) { skipped.push(`${name}:正忙`); continue; }
if ((op === 'enable') === def.isOn(item)) { skipped.push(`${name}:已是${op === 'enable' ? '启用' : '停用'}状态`); continue; }
}
try { await def[op === 'delete' ? 'remove' : op](item); done.push(name); }
catch (error) { failed.push(`${name}:${error.message}`); }
}
} finally { state.bulkRunning = false; }
state.selection[key].clear();
const verb = op === 'delete' ? '删除' : op === 'enable' ? '启用' : '停用';
const brief = (list) => list.slice(0, 3).join(';') + (list.length > 3 ? `;等 ${list.length} 项` : '');
let msg = `批量${verb}:成功 ${done.length} 项`;
if (skipped.length) msg += `,跳过 ${skipped.length} 项(${brief(skipped)})`;
if (failed.length) msg += `,失败 ${failed.length} 项(${brief(failed)})`;
toast(msg, failed.length > 0);
await load(def.page);
}
/* render 后同步表头全选框的三态(全选/半选/未选)。 */
function syncCheckAll() {
const all = $('[data-check-all]');
if (!all) return;
const boxes = $$('[data-row-check]');
if (!boxes.length) { all.checked = false; all.indeterminate = false; return; }
const n = boxes.filter((box) => box.checked).length;
all.checked = n === boxes.length;
all.indeterminate = n > 0 && n < boxes.length;
}
function renderServers() {
const q = state.search.toLowerCase();
const onlineCount = state.servers.filter((s) => s.status === 'online').length;
@@ -457,7 +588,7 @@ function renderServers() {
const addr = state.addrVisible ? esc(server.publicAddress) : esc(maskAddr(server.publicAddress));
const row = `<tr>
<td><button class="exp" type="button" data-action="expand-server" data-id="${esc(server.id)}">${expanded ? '−' : '+'}</button></td>
<td><input type="checkbox" data-row-check></td>
<td><input type="checkbox" data-row-check="servers" data-id="${esc(server.id)}"${state.selection.servers.has(server.id) ? ' checked' : ''}></td>
<td><div class="op-cell"><button class="icon-btn" data-tip="编辑" data-action="edit-server" data-id="${esc(server.id)}">${ICONS.edit}</button><button class="icon-btn" data-tip="复制注册命令" data-action="enroll-server" data-id="${esc(server.id)}">${ICONS.copy}</button><button class="icon-btn del" data-tip="删除" data-action="delete-server" data-id="${esc(server.id)}">${ICONS.trash}</button></div></td>
<td><label class="tgl"><input type="checkbox" data-toggle-server data-id="${esc(server.id)}" ${server.enabled !== false ? 'checked' : ''}><span class="tr"></span></label></td>
<td><span class="dev-name">${esc(server.name)}</span></td>
@@ -474,7 +605,7 @@ function renderServers() {
}).join('');
return `${stats}
<div class="page-tools"><button class="btn primary" data-action="add-server">+ 添加设备</button><input class="search" data-search placeholder="搜索设备名、地址" value="${esc(state.search)}"></div>
<section class="panel"><div class="table-wrap"><table class="w-servers"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>操作</th><th>已启用</th><th>名称</th><th>地址<button class="icon-btn eye-btn" type="button" data-action="toggle-addr" data-tip="显示 / 隐藏地址">${state.addrVisible ? ICONS.eyeOff : ICONS.eye}</button></th><th>状态</th><th>CPU</th><th>内存</th><th>Agent 版本</th><th>核心版本</th><th>运行时长</th><th>客户端</th><th>上次心跳</th></tr></thead>
${bulkBarHTML('servers')}<section class="panel"><div class="table-wrap"><table class="w-servers"><thead><tr><th></th><th><input type="checkbox" data-check-all="servers"></th><th>操作</th><th>已启用</th><th>名称</th><th>地址<button class="icon-btn eye-btn" type="button" data-action="toggle-addr" data-tip="显示 / 隐藏地址">${state.addrVisible ? ICONS.eyeOff : ICONS.eye}</button></th><th>状态</th><th>CPU</th><th>内存</th><th>Agent 版本</th><th>核心版本</th><th>运行时长</th><th>客户端</th><th>上次心跳</th></tr></thead>
<tbody>${rows || emptyRow(14, '没有匹配设备')}</tbody></table></div>${pagerHTML('servers', filtered.length)}</section>`;
}
@@ -532,7 +663,7 @@ function renderCustomers() {
const speedTotal = (item.speedUpBps || 0) + (item.speedDownBps || 0);
const manualOff = item.status === 'suspended' && item.suspendReason === 'manual';
return `<tr>
<td><div class="op-cell"><input type="checkbox" data-row-check><button class="icon-btn" data-tip="二维码" data-action="customer-subscription" data-id="${esc(item.id)}">${ICONS.qr}</button><button class="icon-btn" data-tip="详情" data-action="customer-access" data-id="${esc(item.id)}">${ICONS.info}</button><button class="icon-btn" data-tip="重置流量" data-action="reset-usage" data-id="${esc(item.id)}">${ICONS.reset}</button><button class="icon-btn" data-tip="编辑" data-action="edit-customer" data-id="${esc(item.id)}">${ICONS.edit}</button><button class="icon-btn del" data-tip="删除" data-action="delete-customer" data-id="${esc(item.id)}">${ICONS.trash}</button></div></td>
<td><div class="op-cell"><input type="checkbox" data-row-check="customers" data-id="${esc(item.id)}"${state.selection.customers.has(item.id) ? ' checked' : ''}><button class="icon-btn" data-tip="二维码" data-action="customer-subscription" data-id="${esc(item.id)}">${ICONS.qr}</button><button class="icon-btn" data-tip="详情" data-action="customer-access" data-id="${esc(item.id)}">${ICONS.info}</button><button class="icon-btn" data-tip="重置流量" data-action="reset-usage" data-id="${esc(item.id)}">${ICONS.reset}</button><button class="icon-btn" data-tip="编辑" data-action="edit-customer" data-id="${esc(item.id)}">${ICONS.edit}</button><button class="icon-btn del" data-tip="删除" data-action="delete-customer" data-id="${esc(item.id)}">${ICONS.trash}</button></div></td>
<td><label class="tgl"><input type="checkbox" data-toggle-customer data-id="${esc(item.id)}" ${manualOff ? '' : 'checked'}><span class="tr"></span></label></td>
<td><span class="pres ${item.online ? 'on' : 'off'}"><span class="dot ${item.online ? 'on' : 'off'}"></span>${item.online ? '在线' : '离线'}</span>${item.status === 'suspended' ? `<span class="paused-note">已暂停 · ${esc(suspendReasonText[item.suspendReason] || '待恢复')}</span>` : ''}</td>
<td><span class="cust-name">${esc(item.name)}</span>${item.notes ? `<span class="cust-note">${esc(item.notes)}</span>` : '<span class="cust-note"></span>'}</td>
@@ -557,7 +688,7 @@ function renderCustomers() {
<select class="search" data-group-filter><option value="">全部分组</option>${groups.map((g) => `<option value="${esc(g)}"${selected(g, state.groupFilter)}>${esc(g)}</option>`).join('')}</select>
${filterNode ? `<span class="filter-chip">节点:${esc(filterNode.name)}<button type="button" data-action="clear-node-filter" data-tip="清除">×</button></span>` : ''}
<span class="badge blue">${filtered.length} 位客户</span></div></div>
<section class="panel"><div class="table-wrap"><table class="w-customers"><thead><tr><th>操作</th><th>已启用</th><th>在线</th><th>客户端</th><th>关联入站</th><th>流量<span class="sarr">⇅</span></th><th class="ctr">速度</th><th>剩余<span class="sarr">⇅</span></th><th>时长<span class="sarr">⇅</span></th></tr></thead>
${bulkBarHTML('customers')}<section class="panel"><div class="table-wrap"><table class="w-customers"><thead><tr><th><input type="checkbox" data-check-all="customers"> 操作</th><th>已启用</th><th>在线</th><th>客户端</th><th>关联入站</th><th>流量<span class="sarr">⇅</span></th><th class="ctr">速度</th><th>剩余<span class="sarr">⇅</span></th><th>时长<span class="sarr">⇅</span></th></tr></thead>
<tbody>${rows || emptyRow(9, '还没有客户')}</tbody></table></div>${pagerHTML('customers', filtered.length)}</section>`;
}
@@ -627,11 +758,11 @@ function renderCredentialsPane(customer) {
const data = customerFormState.credentials;
if (!data) { pane.innerHTML = '<div class="empty"><b>加载中…</b></div>'; return; }
const creds = data.credentials || {};
const bindings = (data.bindings || []).map((entry) => `<div class="cred-group">
<div class="g-head">${esc(entry.nodeName)}<span class="badge ${entry.status === 'active' ? 'green' : 'gray'}">${esc(statusText[entry.status] || entry.status)}</span><span class="mut" style="font-weight:400;font-size:12px">${esc(entry.protocol)}</span></div>
${entry.username ? credRowHTML('用户名', entry.username) : ''}
${entry.realityPublicKey ? credRowHTML('Reality 公钥', entry.realityPublicKey) : ''}
</div>`).join('');
// Round-3 item 15: the per-binding list (node name / status / protocol
// / Reality public key) is gone — the tab shows only the subscription
// token and the one shared credential set. Reality keys live on the
// node edit form now (item 17); the socks username lives on the node
// (item 21). Backend payload unchanged.
pane.innerHTML = `<div class="cred-group"><div class="g-head">订阅</div>
<div class="cred-row"><span class="k">订阅令牌</span><span class="v">${esc(data.subscriptionToken || '—')}</span>
<button type="button" class="icon-btn" data-tip="复制" data-action="copy-text" data-value="${esc(data.subscriptionToken || '')}">${ICONS.copy}</button>
@@ -640,8 +771,7 @@ function renderCredentialsPane(customer) {
${credRowHTML('UUID', creds.clientId)}
${credRowHTML('密码', creds.password)}
${credRowHTML('Reality Short ID', creds.shortId)}
</div>
${bindings || '<div class="empty"><b>尚未关联节点</b></div>'}`;
</div>`;
}
function linkStatusLine(customer, source) {
@@ -870,7 +1000,7 @@ function renderNodes() {
const busy = ['deploying', 'removing'].includes(node.status);
return `<tr data-node-row="${esc(node.id)}">
<td><span class="grip" data-tip="拖动排序" data-grip>${ICONS.grip}</span></td>
<td><input type="checkbox" data-row-check></td>
<td><input type="checkbox" data-row-check="nodes" data-id="${esc(node.id)}"${state.selection.nodes.has(node.id) ? ' checked' : ''}></td>
<td class="num">${(node.sortIndex != null ? node.sortIndex : index) + 1}</td>
<td class="menu-td"><button class="icon-btn" data-tip="编辑" data-action="edit-node" data-id="${esc(node.id)}">${ICONS.edit}</button><button class="icon-btn" data-tip="更多" data-action="node-menu" data-id="${esc(node.id)}">${ICONS.dots}</button></td>
<td><label class="tgl"><input type="checkbox" data-toggle-node data-id="${esc(node.id)}" ${enabled ? 'checked' : ''} ${busy ? 'disabled' : ''}><span class="tr"></span></label></td>
@@ -886,7 +1016,7 @@ function renderNodes() {
return `<div class="page-tools"><button class="btn primary" data-action="add-node">+ 添加入站</button>
<div class="toolbar"><input class="search" data-search placeholder="搜索节点、服务器或协议" value="${esc(state.search)}">
<span class="badge blue">${state.nodes.length} 个入站</span><span class="badge green">在线 ${active}</span>${degraded ? `<span class="badge orange">降级 ${degraded}</span>` : ''}</div></div>
<section class="panel"><div class="table-wrap visible"><table class="w-nodes"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>ID<span class="sarr">⇅</span></th><th>菜单</th><th>启用</th><th>备注</th><th>节点</th><th>端口<span class="sarr">⇅</span></th><th>协议</th><th>客户端</th><th>流量<span class="sarr">⇅</span></th><th>速度</th><th>到期时间</th></tr></thead>
${bulkBarHTML('nodes')}<section class="panel"><div class="table-wrap visible"><table class="w-nodes"><thead><tr><th></th><th><input type="checkbox" data-check-all="nodes"></th><th>ID<span class="sarr">⇅</span></th><th>菜单</th><th>启用</th><th>备注</th><th>节点</th><th>端口<span class="sarr">⇅</span></th><th>协议</th><th>客户端</th><th>流量<span class="sarr">⇅</span></th><th>速度</th><th>到期时间</th></tr></thead>
<tbody>${rows || emptyRow(13, '还没有节点')}</tbody></table></div>${pagerHTML('nodes', filtered.length)}</section>`;
}
@@ -902,8 +1032,11 @@ function nodeForm(item = null) {
<label>网络栈<select name="networkMode"><option value="ipv4"${selected('ipv4', node.networkMode || 'ipv4')}>IPv4</option><option value="ipv6"${selected('ipv6', node.networkMode)}>IPv6</option><option value="dual"${selected('dual', node.networkMode)}>双栈监听</option></select></label>
<label>入口协议<select name="protocol" data-node-sync>${protocolOptions('relay-ingress', node.protocol)}</select></label>
<label>入口端口<div class="inline-fields"><select name="portMode" data-node-sync><option value="random"${selected('random', node.portMode)}>范围内随机</option><option value="fixed"${selected('fixed', node.portMode)}>固定端口</option></select><input name="port" type="number" value="${esc(node.port || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
<label data-reality-only>Reality 目标预设<select name="realityPreset" data-reality-preset>${state.realityPresets.map((entry) => `<option value="${esc(entry.id)}" data-sni="${esc(entry.serverName)}" data-port="${entry.destPort}"${selected(entry.id, preset.id)}>${esc(entry.label)} · ${esc(entry.serverName)}</option>`).join('')}<option value="custom"${selected('custom', preset.id)}>自定义</option></select></label>
<label data-reality-only>Reality 目标预设<div class="inline-fields"><select name="realityPreset" data-reality-preset>${state.realityPresets.map((entry) => `<option value="${esc(entry.id)}" data-sni="${esc(entry.serverName)}" data-port="${entry.destPort}"${selected(entry.id, preset.id)}>${esc(entry.label)} · ${esc(entry.serverName)}</option>`).join('')}<option value="custom"${selected('custom', preset.id)}>自定义</option></select><button type="button" class="btn" data-action="scan-reality">查找目标</button></div></label>
<label data-reality-only>Reality SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(node.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(node.realityDestPort || 443)}" min="1" max="65535"></div></label>
${item ? '<div class="wide cred-group" data-reality-only id="reality-keys-box"><div class="g-head">服务器 Reality 密钥对(主控托管)</div><div class="empty"><b>加载中…</b></div></div>' : ''}
<label data-socks-only>SOCKS5 固定用户名<div class="inline-fields"><input name="socksUser" value="${esc(node.socksUser || '')}" placeholder="留空自动生成"><button type="button" class="icon-btn" data-tip="复制" data-action="copy-field" data-field="socksUser">${ICONS.copy}</button></div></label>
<label data-socks-only>SOCKS5 固定密码<div class="inline-fields"><input name="socksPassword" value="${esc(node.socksPassword || '')}" placeholder="留空自动生成"><button type="button" class="icon-btn" data-tip="复制" data-action="copy-field" data-field="socksPassword">${ICONS.copy}</button></div></label>
<label>拓扑<select name="topology" data-node-sync><option value="forward"${selected('forward', node.topology || 'forward')}>转发(入口 → 出口)</option><option value="direct"${selected('direct', node.topology)}>单机直连(无需出口)</option></select></label>
<div class="section-title" data-forward-only>出口与转发传输</div>
<div class="wide" data-forward-only><div class="segctl" id="exit-mode-seg"><button type="button" data-action="exit-mode" data-mode="managed">受管设备</button><button type="button" data-action="exit-mode" data-mode="manual">手动落地端点</button></div></div>
@@ -917,6 +1050,15 @@ function nodeForm(item = null) {
<label data-forward-only data-manual-only data-exit-cred="password">落地密码<input name="exitPassword" placeholder="${item ? '留空保持不变' : ''}"></label>
<div class="form-actions wide"><button type="button" class="btn" data-close>取消</button><button class="btn primary" type="submit">${item ? '保存修改' : '创建节点'}</button></div></form>`, true);
syncNodeForm();
// Round-3 item 17: show the server's panel-hosted Reality keypair on
// existing Reality nodes (display only — no rotation action here).
if (item && ['vless-reality-vision', 'vless-reality'].includes(node.protocol)) {
api(`/api/nodes/${item.id}/reality-keys`).then((data) => {
const box = $('#reality-keys-box'); if (!box) return;
box.innerHTML = `<div class="g-head">服务器 Reality 密钥对(主控托管,同机 Reality 节点共用)</div>
${data.publicKey ? credRowHTML('公钥', data.publicKey) + credRowHTML('私钥', data.privateKey) : '<div class="empty"><b>尚未生成(首次部署时由主控生成)</b></div>'}`;
}).catch(() => { const box = $('#reality-keys-box'); if (box) box.innerHTML = '<div class="empty"><b>密钥读取失败</b></div>'; });
}
}
function syncNodeForm() {
const form = $('#node-form'); if (!form) return;
@@ -927,6 +1069,8 @@ function syncNodeForm() {
const reality = ['vless-reality-vision', 'vless-reality'].includes(form.elements.protocol.value);
$$('[data-reality-only]', form).forEach((element) => element.classList.toggle('hidden', !reality));
form.elements.realityServerName.required = reality;
const socks = form.elements.protocol.value === 'socks5-auth';
$$('[data-socks-only]', form).forEach((element) => element.classList.toggle('hidden', !socks));
for (const [mode, port] of [['portMode', 'port'], ['exitPortMode', 'exitPort']]) {
const fixed = form.elements[mode].value === 'fixed' && (mode === 'portMode' || !direct);
form.elements[port].disabled = !fixed;
@@ -1004,6 +1148,57 @@ function applyExitLink(input) {
syncNodeForm();
toast('已从链接填入落地端点');
}
/* Reality 目标扫描器(第 16 条):叠加在节点表单之上的第二层弹窗,
主控侧真实 TLS 握手探测;「使用」把目标回填到表单并关闭。 */
function closeRealityScanner() { const overlay = $('#scan-overlay'); if (overlay) overlay.remove(); }
function openRealityScanner() {
closeRealityScanner();
const overlay = document.createElement('div');
overlay.id = 'scan-overlay';
overlay.className = 'modal-overlay';
overlay.style.zIndex = '95';
overlay.innerHTML = `<section class="modal panel scan-modal" role="dialog" aria-modal="true">
<header class="modal-head"><h2>查找 Reality 目标</h2><button type="button" class="icon-btn" data-scan-close aria-label="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 6L6 18M6 6l12 12"/></svg></button></header>
<div class="modal-body">
<p class="links-note">从主控所在网络发起探测:可达性与延迟是主控视角,节点所在网络可能不同。</p>
<div class="inline-fields"><input id="scan-input" placeholder="域名 / IP / CIDR,留空扫描常用候选池"><button type="button" class="btn primary" id="scan-run">扫描</button></div>
<div id="scan-results" style="margin-top:12px"></div>
</div></section>`;
document.body.appendChild(overlay);
overlay.addEventListener('click', async (event) => {
if (event.target === overlay || event.target.closest('[data-scan-close]')) { closeRealityScanner(); return; }
if (event.target.closest('#scan-run')) { await runRealityScan(); return; }
const use = event.target.closest('[data-scan-use]');
if (use) {
const form = $('#node-form');
if (form) {
form.elements.realityServerName.value = use.dataset.host;
form.elements.realityDestPort.value = use.dataset.port;
if (form.elements.realityPreset) form.elements.realityPreset.value = 'custom';
}
closeRealityScanner();
toast('已回填 Reality 目标');
}
});
runRealityScan();
}
async function runRealityScan() {
const box = $('#scan-results'); if (!box) return;
box.innerHTML = '<div class="empty"><b>扫描中…</b></div>';
try {
const input = $('#scan-input') ? $('#scan-input').value.trim() : '';
const data = await api('/api/reality/scan', { method: 'POST', body: JSON.stringify({ input }) });
const rows = (data.results || []).map((r) => `<tr>
<td class="scan-target" title="${esc(r.target)}">${esc(r.target)}${Number(r.port) !== 443 ? `:${esc(r.port)}` : ''}</td>
<td>${r.ok ? '<span class="badge green">可用</span>' : '<span class="badge gray">不可用</span>'}</td>
<td>${esc(r.tlsVersion || '—')}</td><td>${esc(r.alpn || '—')}</td>
<td>${esc(r.keyExchange || '—')}</td><td class="scan-cert" title="${esc(r.certName || '')}">${esc(r.certName || '—')}</td>
<td class="scan-reason">${r.ok ? `${esc(r.latencyMs)} ms` : esc(r.reason || '')}</td>
<td>${r.ok ? `<button type="button" class="btn small" data-scan-use data-host="${esc(r.host)}" data-port="${esc(r.port)}">使用</button>` : ''}</td></tr>`).join('');
box.innerHTML = `<div class="scan-wrap"><table class="scan-table"><thead><tr><th>目标</th><th>状态</th><th>TLS</th><th>ALPN</th><th>密钥交换</th><th>证书域名</th><th>延迟 / 原因</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="8">无结果</td></tr>'}</tbody></table></div>`;
} catch (error) { box.innerHTML = `<div class="empty"><b>${esc(error.message)}</b></div>`; }
}
function nodeClientsModal(node) {
const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id);
const rows = assigned.map((nc) => {
@@ -1061,6 +1256,7 @@ function renderOperations() {
function render() {
const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, operations: renderOperations };
$('#content').innerHTML = views[state.page]();
syncCheckAll();
}
/* ---------- 表单提交 ---------- */
@@ -1104,7 +1300,7 @@ document.addEventListener('submit', async (event) => {
closeModal(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers');
} else if (form.id === 'node-form') {
const resourceId = data.get('resourceId');
const payload = { name: data.get('name'), serverId: data.get('serverId'), protocol: data.get('protocol'), portMode: data.get('portMode'), port: data.get('port') || null, networkMode: data.get('networkMode'), topology: data.get('topology'), exitServerId: data.get('exitServerId') || null, exitProtocol: data.get('exitProtocol') || null, exitPortMode: data.get('exitPortMode') || null, exitPort: data.get('exitPort') || null, realityServerName: data.get('realityServerName'), realityDestPort: Number(data.get('realityDestPort') || 443), exitMode: form.dataset.exitMode || 'managed', exitAddress: data.get('exitAddress') || null };
const payload = { name: data.get('name'), serverId: data.get('serverId'), protocol: data.get('protocol'), portMode: data.get('portMode'), port: data.get('port') || null, networkMode: data.get('networkMode'), topology: data.get('topology'), exitServerId: data.get('exitServerId') || null, exitProtocol: data.get('exitProtocol') || null, exitPortMode: data.get('exitPortMode') || null, exitPort: data.get('exitPort') || null, realityServerName: data.get('realityServerName'), realityDestPort: Number(data.get('realityDestPort') || 443), exitMode: form.dataset.exitMode || 'managed', exitAddress: data.get('exitAddress') || null, socksUser: data.get('socksUser') || null, socksPassword: data.get('socksPassword') || null };
if (payload.topology !== 'direct' && payload.exitMode === 'manual') {
const creds = {};
if (data.get('exitClientId')) creds.clientId = data.get('exitClientId');
@@ -1114,7 +1310,7 @@ document.addEventListener('submit', async (event) => {
payload.exitPortMode = 'fixed';
}
await api(resourceId ? `/api/nodes/${resourceId}` : '/api/nodes', { method: resourceId ? 'PATCH' : 'POST', body: JSON.stringify(payload) });
closeModal(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,请分配客户后部署'); await load('chains');
closeModal(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,可直接部署,客户可随时增减绑定'); await load('chains');
} else if (form.id === 'account-form') {
if (data.get('newPassword') !== data.get('confirmPassword')) throw new Error('两次输入的新密码不一致');
await api('/api/account', { method: 'PATCH', body: JSON.stringify({ username: data.get('username'), currentPassword: data.get('currentPassword'), newPassword: data.get('newPassword') }) });
@@ -1173,6 +1369,8 @@ document.addEventListener('click', async (event) => {
const action = button.dataset.action; const itemId = button.dataset.id;
try {
if (action === 'copy-text') { await copyText(button.dataset.value); toast('已复制到剪贴板'); }
else if (action === 'scan-reality') openRealityScanner();
else if (action === 'copy-field') { const input = button.form ? button.form.elements[button.dataset.field] : null; await copyText(input ? input.value : ''); toast('已复制到剪贴板'); }
else if (action === 'add-server') serverForm();
else if (action === 'edit-server') serverForm(serverById(itemId));
else if (action === 'enroll-server') await enrollModal(itemId);
@@ -1285,6 +1483,10 @@ document.addEventListener('click', async (event) => {
else if (action === 'repair-node') { hidePop(); if (!await confirmModal('重新下发该节点配置?', '修复')) return; await api(`/api/nodes/${itemId}/repair`, { method: 'POST', body: '{}' }); toast('修复任务已进入队列'); await load(); }
else if (action === 'remove-node') { hidePop(); if (!await confirmModal('停用会从相关设备移除配置,节点保留为草稿,确认继续?', '停用')) return; await api(`/api/nodes/${itemId}/remove`, { method: 'POST', body: '{}' }); toast('停用任务已进入队列'); await load(); }
else if (action === 'delete-node') { hidePop(); if (!await confirmModal('确定删除这个节点?会移除全部分配并从设备清理资源。', '删除')) return; await api(`/api/nodes/${itemId}`, { method: 'DELETE' }); toast('节点已删除,设备资源待 Agent 确认清理'); await load(); }
else if (action === 'bulk-clear') { state.selection[button.dataset.bulkPage].clear(); render(); }
else if (action === 'bulk-enable') await runBulk(button.dataset.bulkPage, 'enable');
else if (action === 'bulk-disable') await runBulk(button.dataset.bulkPage, 'disable');
else if (action === 'bulk-delete') await runBulk(button.dataset.bulkPage, 'delete');
else if (action === 'download-backup') location.href = '/api/backup';
else if (action === 'restore-backup') {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json';
@@ -1324,8 +1526,18 @@ document.addEventListener('change', async (event) => {
await load('chains');
} else if (target.matches('[data-group-filter]')) {
state.groupFilter = target.value; state.pages.customers = 1; render();
} else if (target.matches('[data-row-check]')) {
const set = state.selection[target.dataset.rowCheck];
if (set) {
if (target.checked) set.add(target.dataset.id); else set.delete(target.dataset.id);
render();
}
} else if (target.matches('[data-check-all]')) {
$$('[data-row-check]').forEach((box) => { box.checked = target.checked; });
const set = state.selection[target.dataset.checkAll];
if (set) {
$$('[data-row-check]').forEach((box) => { if (target.checked) set.add(box.dataset.id); else set.delete(box.dataset.id); });
render();
}
} else if (target.matches('[data-node-sync]')) syncNodeForm();
else if (target.matches('[data-exit-link]')) applyExitLink(target);
else if (target.matches('[data-reality-preset]')) {
+14
View File
@@ -74,6 +74,8 @@ input,select,textarea{font-family:var(--font);color:var(--text)}
.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}
@@ -403,3 +405,15 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri
@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