Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5733928198 | ||
|
|
18bbd388ea | ||
|
|
25cb58245c | ||
|
|
0037116edc | ||
|
|
d316d0f8bb | ||
|
|
b49c134513 | ||
|
|
35c9bd2cc0 | ||
|
|
1b32b57377 | ||
|
|
3de4c4945f | ||
|
|
85763ebdb7 | ||
|
|
612035ac4e | ||
|
|
0a2889b4e4 | ||
|
|
d287bb9be9 | ||
|
|
c6f6b9dbea |
+2
-82
@@ -82,91 +82,11 @@ amo 各页主内容区两侧留白偏大;把左右间距(padding/最大宽
|
|||||||
- 左侧菜单「设置与运维」→ 改名「设置」
|
- 左侧菜单「设置与运维」→ 改名「设置」
|
||||||
- 「安装与命令」页面的全部内容合并进「设置」页面,合并后删除「安装与命令」页面(菜单项与路由一并摘除)
|
- 「安装与命令」页面的全部内容合并进「设置」页面,合并后删除「安装与命令」页面(菜单项与路由一并摘除)
|
||||||
|
|
||||||
## 12. 核心版本显示为空——根因已明,下批再修(2026-10-21 11:52 用户定:小问题,下次一起修)
|
## 12. 删除系统状态页「最近活动」模块(2026-10-21 04:56,用户附截图)
|
||||||
|
|
||||||
v2 上线后现网四台核心版本列全空。已查实根因:节点 sing-box 是自编版(agent-singbox.sh 固定 v1.13.21),构建未打版本戳,二进制 `version` 输出 `sing-box version unknown`,agent 探针按设计把 unknown 当无版本。代码链(探针→心跳→落库→渲染)无误。
|
|
||||||
已定修法(未施工):agent 探针解析为空时兜底读安装记录 /etc/nexusgate/sing-box/version;agent-singbox.sh 安装成功后写该记录(去 v 前缀);现网四台补写 1.13.21 即可(安装脚本写死的版本,有据)。长期根治:下次重编 sing-box 时打版本戳。
|
|
||||||
|
|
||||||
## 13. 删除系统状态页「最近活动」模块(2026-10-21 04:56,用户附截图)
|
|
||||||
|
|
||||||
首页(系统状态页)底部的「最近活动」面板整个删除(该面板是改版时为保旧功能补回的,用户现明确不要)。只删展示模块,后端活动/审计记录本身保留不动。
|
首页(系统状态页)底部的「最近活动」面板整个删除(该面板是改版时为保旧功能补回的,用户现明确不要)。只删展示模块,后端活动/审计记录本身保留不动。
|
||||||
|
|
||||||
## 14. 设置页「任务队列」表格排紧凑(2026-10-21 04:58,用户附截图)
|
## 13. 设置页「任务队列」表格排紧凑(2026-10-21 04:58,用户附截图)
|
||||||
|
|
||||||
现状毛病:列宽分配失衡——任务 ID 被挤到换行两行、状态"已完成"竖排三行、时间两行,而错误列大片空白。
|
现状毛病:列宽分配失衡——任务 ID 被挤到换行两行、状态"已完成"竖排三行、时间两行,而错误列大片空白。
|
||||||
要求:重配列宽与换行——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 逐台换新后逐台提速。
|
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
# 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 原文(未改一字)+本报告 |
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# 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]
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
- **主控**:Go 静态二进制面板(约 13-30MB),跑在 OpenWrt 路由器或任意 Linux 上,无运行时依赖。管理节点、客户、订阅,下发配置到各 Agent。
|
- **主控**:Node.js 面板(约 61MB),跑在 OpenWrt 路由器或任意 Linux 上。管理节点、客户、订阅,下发配置到各 Agent。
|
||||||
- **Agent**:跑在节点机上,通过 token 注册。管理 sing-box 进程,上报心跳和按用户流量。
|
- **Agent**:跑在节点机上,通过 token 注册。管理 sing-box 进程,上报心跳和按用户流量。
|
||||||
|
|
||||||
## 与原版的区别
|
## 与原版的区别
|
||||||
@@ -13,13 +13,20 @@
|
|||||||
- 3x-ui 式共享节点:nodes 状态机 + 每个客户独立凭据(`ng:<customerId>` 身份)
|
- 3x-ui 式共享节点:nodes 状态机 + 每个客户独立凭据(`ng:<customerId>` 身份)
|
||||||
- 全面 sing-box:不再捆绑 xray
|
- 全面 sing-box:不再捆绑 xray
|
||||||
- 按用户流量统计:AnyTLS 走日志解析,SS 走节点级
|
- 按用户流量统计:AnyTLS 走日志解析,SS 走节点级
|
||||||
- 内存优化:主控与 Agent 均为 Go 静态二进制、无 Node.js 依赖(主控约 13-30MB、Agent 约 12MB);主控可加 `GOMEMLIMIT=28MiB GOGC=50` 压内存,sing-box 服务为 `GOMEMLIMIT=64MiB GOGC=50`
|
- 内存优化:面板 `node --max-old-space-size=128 --optimize-for-size`;Agent 为 Go 静态二进制(约 12MB,节点机无需 Node.js);sing-box `GOMEMLIMIT=64MiB GOGC=50`
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
### 主控
|
### 主控
|
||||||
|
|
||||||
主控是 Go 静态二进制 `nexusgate-panel`(panel-go/),无运行时依赖,数据放在 `/etc/nexusgate`。部署与换装步骤见 `panel-go/INSTALL-GO.md`。
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
面板地址:`http://IP:8787`,默认账号 `admin`。
|
面板地址:`http://IP:8787`,默认账号 `admin`。
|
||||||
|
|
||||||
@@ -41,11 +48,11 @@ Agent 是 Go 静态二进制,安装时从 Gitea Release 下载,节点机不
|
|||||||
|
|
||||||
VLESS / SS2022 / SS经典 / SOCKS5 / Hysteria2 / AnyTLS
|
VLESS / SS2022 / SS经典 / SOCKS5 / Hysteria2 / AnyTLS
|
||||||
|
|
||||||
## 内存占用(实测 2026-10-21)
|
## 内存占用(实测 2026-10-20,三台节点机)
|
||||||
|
|
||||||
| 组件 | 内存 |
|
| 组件 | 内存 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 主控面板 | 约 13-30MB(Go 静态二进制,OpenWrt 路由器) |
|
| 主控面板 | 约 61MB(OpenWrt 路由器) |
|
||||||
| Agent | 约 12MB(Go 静态二进制) |
|
| Agent | 约 12MB(Go 静态二进制) |
|
||||||
| sing-box | 16–36MB |
|
| sing-box | 16–36MB |
|
||||||
|
|
||||||
|
|||||||
@@ -132,14 +132,6 @@ using sing-box's own protobuf stubs. The Go agent keeps that exact design:
|
|||||||
|
|
||||||
## Known divergences (all behavior-preserving)
|
## 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
|
1. **JSON key order inside resource files / combined config**: Go reserializes
|
||||||
nested objects with alphabetically sorted keys; JS preserves insertion
|
nested objects with alphabetically sorted keys; JS preserves insertion
|
||||||
order. Semantically identical for sing-box and for the panel. Side effect:
|
order. Semantically identical for sing-box and for the panel. Side effect:
|
||||||
|
|||||||
+2
-31
@@ -214,7 +214,6 @@ var singBoxVersionCache struct {
|
|||||||
path string
|
path string
|
||||||
mtimeNs int64
|
mtimeNs int64
|
||||||
size int64
|
size int64
|
||||||
vfMtimeNs int64 // install-record file mtime (part of the cache key)
|
|
||||||
version string
|
version string
|
||||||
probed bool
|
probed bool
|
||||||
}
|
}
|
||||||
@@ -234,50 +233,22 @@ func parseSingBoxVersion(out string) string {
|
|||||||
return ""
|
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 {
|
func singBoxVersion() string {
|
||||||
info, err := os.Stat(cfg.SingBoxBin)
|
info, err := os.Stat(cfg.SingBoxBin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var vfMtimeNs int64 = -1
|
|
||||||
if vfInfo, err := os.Stat(cfg.SingBoxVersionFile); err == nil {
|
|
||||||
vfMtimeNs = vfInfo.ModTime().UnixNano()
|
|
||||||
}
|
|
||||||
singBoxVersionMu.Lock()
|
singBoxVersionMu.Lock()
|
||||||
defer singBoxVersionMu.Unlock()
|
defer singBoxVersionMu.Unlock()
|
||||||
c := &singBoxVersionCache
|
c := &singBoxVersionCache
|
||||||
if c.probed && c.path == cfg.SingBoxBin && c.mtimeNs == info.ModTime().UnixNano() && c.size == info.Size() && c.vfMtimeNs == vfMtimeNs {
|
if c.probed && c.path == cfg.SingBoxBin && c.mtimeNs == info.ModTime().UnixNano() && c.size == info.Size() {
|
||||||
return c.version
|
return c.version
|
||||||
}
|
}
|
||||||
version := ""
|
version := ""
|
||||||
if out, _, code := runCapture(5*time.Second, cfg.SingBoxBin, "version"); code == 0 {
|
if out, _, code := runCapture(5*time.Second, cfg.SingBoxBin, "version"); code == 0 {
|
||||||
version = parseSingBoxVersion(out)
|
version = parseSingBoxVersion(out)
|
||||||
}
|
}
|
||||||
if version == "" {
|
c.path, c.mtimeNs, c.size, c.version, c.probed = cfg.SingBoxBin, info.ModTime().UnixNano(), info.Size(), version, true
|
||||||
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
|
return version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-126
@@ -35,11 +35,9 @@ type config struct {
|
|||||||
Root string // NG_CONFIG_DIR
|
Root string // NG_CONFIG_DIR
|
||||||
ResourceDir string // Root + "/resources"
|
ResourceDir string // Root + "/resources"
|
||||||
SingBoxConfig string
|
SingBoxConfig string
|
||||||
SingBoxVersionFile string
|
|
||||||
SingBoxLog string
|
SingBoxLog string
|
||||||
SingBoxCursor string
|
SingBoxCursor string
|
||||||
KeyFile string
|
KeyFile string
|
||||||
SessionTokenFile string
|
|
||||||
TLSDir string
|
TLSDir string
|
||||||
APIPort string // NG_SINGBOX_API_PORT (string, as in JS)
|
APIPort string // NG_SINGBOX_API_PORT (string, as in JS)
|
||||||
PollMS int64
|
PollMS int64
|
||||||
@@ -76,11 +74,9 @@ func loadConfig() config {
|
|||||||
Root: root,
|
Root: root,
|
||||||
ResourceDir: root + "/resources",
|
ResourceDir: root + "/resources",
|
||||||
SingBoxConfig: envOr("NG_SINGBOX_CONFIG", "/etc/nexusgate/sing-box/config.json"),
|
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"),
|
SingBoxLog: envOr("NG_SINGBOX_LOG", "/var/log/nexusgate/sing-box-access.log"),
|
||||||
SingBoxCursor: envOr("NG_SINGBOX_CURSOR", "/etc/nexusgate/sing-box-cursor.json"),
|
SingBoxCursor: envOr("NG_SINGBOX_CURSOR", "/etc/nexusgate/sing-box-cursor.json"),
|
||||||
KeyFile: envOr("NG_KEY_FILE", "/etc/nexusgate/keys.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"),
|
TLSDir: envOr("NG_TLS_DIR", "/etc/nexusgate/tls"),
|
||||||
APIPort: envOr("NG_SINGBOX_API_PORT", "10086"),
|
APIPort: envOr("NG_SINGBOX_API_PORT", "10086"),
|
||||||
PollMS: int64(pollSec * 1000),
|
PollMS: int64(pollSec * 1000),
|
||||||
@@ -153,145 +149,25 @@ func fileExists(p string) bool {
|
|||||||
return err == nil
|
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,
|
// request mirrors agent.js request(): POST with bearer auth, 20s timeout,
|
||||||
// JSON body best-effort parsed ({} on failure), error message taken from
|
// JSON body best-effort parsed ({} on failure), error message taken from
|
||||||
// body.message or synthesized from the HTTP status. Bearer selection and
|
// body.message or synthesized from the HTTP status.
|
||||||
// the 401 retry follow the session-token rules above.
|
|
||||||
func request(endpoint string, body []byte) (map[string]interface{}, error) {
|
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))
|
req, err := http.NewRequest(http.MethodPost, cfg.Controller+endpoint, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
req.Header.Set("Authorization", "Bearer "+cfg.AgentKey)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, err := httpClient.Do(req)
|
resp, err := httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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)
|
raw, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{}{}
|
parsed := map[string]interface{}{}
|
||||||
if len(raw) > 0 {
|
if len(raw) > 0 {
|
||||||
_ = json.Unmarshal(raw, &parsed) // .catch(() => ({})) in JS
|
_ = json.Unmarshal(raw, &parsed) // .catch(() => ({})) in JS
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -71,49 +71,6 @@ 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) {
|
func TestSingBoxVersionMissingBinary(t *testing.T) {
|
||||||
oldBin := cfg.SingBoxBin
|
oldBin := cfg.SingBoxBin
|
||||||
cfg.SingBoxBin = filepath.Join(t.TempDir(), "does-not-exist")
|
cfg.SingBoxBin = filepath.Join(t.TempDir(), "does-not-exist")
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+29
-241
@@ -6,10 +6,8 @@ package main
|
|||||||
// line by line.
|
// line by line.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@@ -571,30 +569,6 @@ 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 == "" {
|
if IsRealityProtocol(protocol) && next.RealityServerName == "" {
|
||||||
return nil, httpErr(400, "Reality SNI 不能为空")
|
return nil, httpErr(400, "Reality SNI 不能为空")
|
||||||
}
|
}
|
||||||
@@ -781,9 +755,7 @@ func customerCredentialsView(d *Data, customer *Customer) map[string]any {
|
|||||||
}
|
}
|
||||||
username := ""
|
username := ""
|
||||||
if node.Protocol == "socks5-auth" {
|
if node.Protocol == "socks5-auth" {
|
||||||
// Item 21: the socks identity lives on the node now (fixed
|
username = NodeEmail(customer.ID)
|
||||||
// account), not per customer.
|
|
||||||
username = node.SocksUser
|
|
||||||
}
|
}
|
||||||
var publicKey any
|
var publicKey any
|
||||||
if key := realityPublicKeyOf(node); key != "" {
|
if key := realityPublicKeyOf(node); key != "" {
|
||||||
@@ -850,29 +822,19 @@ type adminAuth struct {
|
|||||||
User *User
|
User *User
|
||||||
}
|
}
|
||||||
|
|
||||||
// CurrentAdmin is currentAdmin. Since round-3 item 22 it resolves the
|
// CurrentAdmin is currentAdmin.
|
||||||
// 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 {
|
func (a *App) CurrentAdmin(r *http.Request) *adminAuth {
|
||||||
session := a.getSessions().Get(r)
|
session := a.getSessions().Get(r)
|
||||||
if session == nil {
|
if session == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var found *User
|
data := a.store.Snapshot()
|
||||||
a.store.View(func(d *Data) {
|
for _, user := range data.Users {
|
||||||
for _, user := range d.Users {
|
|
||||||
if user.ID == session.UserID && user.Status == "active" {
|
if user.ID == session.UserID && user.Status == "active" {
|
||||||
copied := *user
|
return &adminAuth{Session: session, User: user}
|
||||||
found = &copied
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
if found == nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
|
||||||
return &adminAuth{Session: session, User: found}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireAdmin is requireAdmin: sends 401/403 itself and returns nil.
|
// RequireAdmin is requireAdmin: sends 401/403 itself and returns nil.
|
||||||
@@ -889,148 +851,32 @@ func (a *App) RequireAdmin(w http.ResponseWriter, r *http.Request, csrf bool) *a
|
|||||||
return auth
|
return auth
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- agent session tokens (item 25) ----------
|
// FindAgentByBearer is findAgentByBearer: fingerprint fast path, then a
|
||||||
|
// full VerifySecret scan over legacy agents without a fingerprint.
|
||||||
// AgentSessionTokenTTL is how long an issued agent session token stays
|
func (a *App) FindAgentByBearer(r *http.Request) *Agent {
|
||||||
// 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
|
|
||||||
}
|
|
||||||
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")
|
header := r.Header.Get("Authorization")
|
||||||
if !strings.HasPrefix(header, "Bearer ") {
|
if !strings.HasPrefix(header, "Bearer ") {
|
||||||
return nil, nil
|
return nil
|
||||||
}
|
}
|
||||||
bearer := header[len("Bearer "):]
|
token := header[len("Bearer "):]
|
||||||
|
fingerprint := TokenFingerprint(token)
|
||||||
if agent := a.agentBySessionToken(bearer); agent != nil {
|
data := a.store.Snapshot()
|
||||||
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
|
var fast *Agent
|
||||||
var legacy []*Agent
|
for _, agent := range data.Agents {
|
||||||
a.store.View(func(d *Data) {
|
if agent.Status == "active" && agent.KeyFingerprint != nil && *agent.KeyFingerprint == fingerprint {
|
||||||
for _, agent := range d.Agents {
|
fast = agent
|
||||||
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if fast != nil && VerifySecret(token, fast.KeyHash) {
|
||||||
|
return fast
|
||||||
}
|
}
|
||||||
if verified == nil {
|
for _, agent := range data.Agents {
|
||||||
return nil, nil
|
if agent.Status == "active" && agent.KeyFingerprint == nil && VerifySecret(token, agent.KeyHash) {
|
||||||
}
|
|
||||||
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 agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSessionCookie() map[string]string {
|
func clearSessionCookie() map[string]string {
|
||||||
@@ -1202,7 +1048,7 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
|
|||||||
}
|
}
|
||||||
for _, old := range d.Agents {
|
for _, old := range d.Agents {
|
||||||
if old.ServerID == server.ID {
|
if old.ServerID == server.ID {
|
||||||
old.Revoke()
|
old.Status = "revoked"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fp := TokenFingerprint(agentKey)
|
fp := TokenFingerprint(agentKey)
|
||||||
@@ -1248,19 +1094,11 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
|
|||||||
if !strings.HasPrefix(pathname, "/api/agent/") {
|
if !strings.HasPrefix(pathname, "/api/agent/") {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
agent, issued := a.authenticateAgent(r)
|
agent := a.FindAgentByBearer(r)
|
||||||
if agent == nil {
|
if agent == nil {
|
||||||
SendError(w, 401, "Agent 身份校验失败", "invalid_agent")
|
SendError(w, 401, "Agent 身份校验失败", "invalid_agent")
|
||||||
return true, nil
|
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" {
|
if r.Method == http.MethodPost && pathname == "/api/agent/heartbeat" {
|
||||||
body, err := ReadJSON(r, defaultMaxBody)
|
body, err := ReadJSON(r, defaultMaxBody)
|
||||||
@@ -1916,7 +1754,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
|||||||
d.Servers = append(d.Servers[:index], d.Servers[index+1:]...)
|
d.Servers = append(d.Servers[:index], d.Servers[index+1:]...)
|
||||||
for _, agent := range d.Agents {
|
for _, agent := range d.Agents {
|
||||||
if agent.ServerID == params["id"] {
|
if agent.ServerID == params["id"] {
|
||||||
agent.Revoke()
|
agent.Status = "revoked"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Audit(d, actor, "delete_server", params["id"], nil)
|
Audit(d, actor, "delete_server", params["id"], nil)
|
||||||
@@ -2069,7 +1907,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
|||||||
d.Nodes = nodes
|
d.Nodes = nodes
|
||||||
for _, agent := range d.Agents {
|
for _, agent := range d.Agents {
|
||||||
if agent.ServerID == server.ID {
|
if agent.ServerID == server.ID {
|
||||||
agent.Revoke()
|
agent.Status = "revoked"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
d.Servers = append(d.Servers[:index], d.Servers[index+1:]...)
|
d.Servers = append(d.Servers[:index], d.Servers[index+1:]...)
|
||||||
@@ -2455,10 +2293,9 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rebuilt[node.ID] = true
|
rebuilt[node.ID] = true
|
||||||
// Credential rotation is a binding-class change: it goes
|
if err := RebuildNode(d, node, false); err != nil {
|
||||||
// through the coalesced window (item 23), not an
|
return nil, err
|
||||||
// immediate per-node restart.
|
}
|
||||||
markRebuildPending(node, false)
|
|
||||||
}
|
}
|
||||||
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodesRebuilt": len(rebuilt)})
|
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodesRebuilt": len(rebuilt)})
|
||||||
view = customerCredentialsView(d, customer)
|
view = customerCredentialsView(d, customer)
|
||||||
@@ -2716,52 +2553,6 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
|||||||
SendJSON(w, 201, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil)
|
SendJSON(w, 201, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil)
|
||||||
return true, 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 {
|
if params, ok := RoutePattern("/api/nodes/:id", pathname); ok && r.Method == http.MethodPatch {
|
||||||
body, err := ReadJSON(r, defaultMaxBody)
|
body, err := ReadJSON(r, defaultMaxBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2803,9 +2594,6 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
|||||||
item.ExitReady = false
|
item.ExitReady = false
|
||||||
}
|
}
|
||||||
if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" {
|
if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" {
|
||||||
// A node edit ships immediately and consumes any pending
|
|
||||||
// coalesced mark (item 23).
|
|
||||||
clearRebuildPending(item)
|
|
||||||
if err := RebuildNode(d, item, true); err != nil {
|
if err := RebuildNode(d, item, true); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
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"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -407,17 +407,6 @@ func run() error {
|
|||||||
app.housekeeping()
|
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)
|
fmt.Printf("NexusGate v%s listening on http://%s:%d\n", VERSION, host, port)
|
||||||
return server.ListenAndServe()
|
return server.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-95
@@ -345,12 +345,21 @@ func cancelQueuedDeletes(d *Data, nodeID, reason string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RebuildNode(d *Data, node *Node, includeExit bool) error {
|
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)
|
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)
|
node.AutoRemoving = BoolPtr(false)
|
||||||
resource, err := resourceForNode(d, node, clients)
|
resource, err := resourceForNode(d, node, clients)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -388,75 +397,6 @@ func DeriveNodeStatus(node *Node) {
|
|||||||
node.Error = nil
|
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 ----------
|
// ---------- customer suspend / resume ----------
|
||||||
|
|
||||||
func SuspendCustomerResources(d *Data, customer *Customer, actor string) (int, error) {
|
func SuspendCustomerResources(d *Data, customer *Customer, actor string) (int, error) {
|
||||||
@@ -478,7 +418,9 @@ func SuspendCustomerResources(d *Data, customer *Customer, actor string) (int, e
|
|||||||
for _, nodeID := range affected {
|
for _, nodeID := range affected {
|
||||||
node := orchFindNode(d, nodeID)
|
node := orchFindNode(d, nodeID)
|
||||||
if node != nil && node.Status != "draft" && node.Status != "removing" {
|
if node != nil && node.Status != "draft" && node.Status != "removing" {
|
||||||
markRebuildPending(node, false)
|
if err := RebuildNode(d, node, false); err != nil {
|
||||||
|
return len(affected), err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Audit(d, actor, "suspend_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
|
Audit(d, actor, "suspend_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
|
||||||
@@ -529,7 +471,9 @@ func ResumeCustomerResources(d *Data, customer *Customer, actor string) (int, er
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
includeExit := forwardNode(node) && !node.ExitReady
|
includeExit := forwardNode(node) && !node.ExitReady
|
||||||
markRebuildPending(node, includeExit)
|
if err := RebuildNode(d, node, includeExit); err != nil {
|
||||||
|
return len(affected), err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Audit(d, actor, "resume_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
|
Audit(d, actor, "resume_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
|
||||||
return len(affected), nil
|
return len(affected), nil
|
||||||
@@ -713,12 +657,9 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
|
|||||||
return httpErr(409, "设备 %s 已停用", target.Name)
|
return httpErr(409, "设备 %s 已停用", target.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Round-3 item 20: no client-count gate. Nodes are built and deployed
|
if len(activeNodeClients(d, node)) == 0 {
|
||||||
// first and bound later (the 3x-ui model); bindings may go to zero
|
return httpErr(400, "请先给节点分配至少一个客户再部署")
|
||||||
// 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 {
|
switch node.Protocol {
|
||||||
case "vless-ws-tls", "hysteria2", "anytls":
|
case "vless-ws-tls", "hysteria2", "anytls":
|
||||||
if server.TLSDomain == "" {
|
if server.TLSDomain == "" {
|
||||||
@@ -866,6 +807,9 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
clients := activeNodeClients(d, node)
|
clients := activeNodeClients(d, node)
|
||||||
|
if len(clients) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if isEntry {
|
if isEntry {
|
||||||
resource, err := resourceForNode(d, node, clients)
|
resource, err := resourceForNode(d, node, clients)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -880,9 +824,6 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
|
|||||||
}
|
}
|
||||||
QueueJob(d, serverID, node.ID, "apply_resource", payloadWithResource(resource, "node-exit", nil))
|
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.Status = "deploying"
|
||||||
}
|
}
|
||||||
node.UpdatedAt = NowISO()
|
node.UpdatedAt = NowISO()
|
||||||
@@ -966,9 +907,6 @@ func (o *Orchestrator) DeployNode(nodeID, actor string) (*Node, error) {
|
|||||||
if err := ValidateNodeDeploy(d, node); err != nil {
|
if err := ValidateNodeDeploy(d, node); err != nil {
|
||||||
return err
|
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 {
|
if err := RebuildNode(d, node, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -990,9 +928,6 @@ func (o *Orchestrator) RemoveNode(nodeID, actor string, deleteRecord bool) (bool
|
|||||||
}
|
}
|
||||||
node.PendingDelete = deleteRecord
|
node.PendingDelete = deleteRecord
|
||||||
node.AutoRemoving = BoolPtr(false)
|
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" {
|
if node.Status != "draft" {
|
||||||
QueueJob(d, node.ServerID, node.ID, "delete_resource", &JobPayload{ResourceID: node.ResourceID, Kind: "node"})
|
QueueJob(d, node.ServerID, node.ID, "delete_resource", &JobPayload{ResourceID: node.ResourceID, Kind: "node"})
|
||||||
if forwardNode(node) && node.ExitResourceID != "" {
|
if forwardNode(node) && node.ExitResourceID != "" {
|
||||||
@@ -1040,7 +975,6 @@ func (o *Orchestrator) RepairNode(nodeID, actor string) (bool, error) {
|
|||||||
if node.Status != "failed" && node.Status != "degraded" {
|
if node.Status != "failed" && node.Status != "degraded" {
|
||||||
return httpErr(409, "节点状态正常,无需修复")
|
return httpErr(409, "节点状态正常,无需修复")
|
||||||
}
|
}
|
||||||
clearRebuildPending(node)
|
|
||||||
if err := RebuildNode(d, node, true); err != nil {
|
if err := RebuildNode(d, node, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1097,7 +1031,9 @@ func assignCustomerData(d *Data, nodeID, customerID, actor string) (*NodeClient,
|
|||||||
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
||||||
node.AutoRemoving = BoolPtr(false)
|
node.AutoRemoving = BoolPtr(false)
|
||||||
}
|
}
|
||||||
markRebuildPending(node, false)
|
if err := RebuildNode(d, node, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
|
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
|
||||||
return nc, nil
|
return nc, nil
|
||||||
@@ -1120,7 +1056,9 @@ func unassignCustomerData(d *Data, nodeID, customerID, actor string) error {
|
|||||||
}
|
}
|
||||||
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
|
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
|
||||||
if node.Status != "draft" && node.Status != "removing" {
|
if node.Status != "draft" && node.Status != "removing" {
|
||||||
markRebuildPending(node, false)
|
if err := RebuildNode(d, node, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
|
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
|
||||||
return nil
|
return nil
|
||||||
@@ -1201,7 +1139,9 @@ func AlignCustomerNodes(d *Data, customer *Customer, desiredIds []string, actor
|
|||||||
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
||||||
node.AutoRemoving = BoolPtr(false)
|
node.AutoRemoving = BoolPtr(false)
|
||||||
}
|
}
|
||||||
markRebuildPending(node, false)
|
if err := RebuildNode(d, node, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1277,6 +1217,9 @@ func (o *Orchestrator) ReconcileFromHeartbeat(serverID string, reportedResourceI
|
|||||||
if node.Status == "draft" || node.Status == "removing" {
|
if node.Status == "draft" || node.Status == "removing" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if len(activeNodeClients(d, node)) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if node.ServerID == serverID && node.ResourceID != "" && !reported[node.ResourceID] {
|
if node.ServerID == serverID && node.ResourceID != "" && !reported[node.ResourceID] {
|
||||||
missing[node.ID] = true
|
missing[node.ID] = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+63
-49
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/ecdh"
|
"crypto/ecdh"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -236,27 +237,7 @@ func clientCredentials(nc *NodeClient) Credentials {
|
|||||||
return *nc.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) {
|
func EnsureNodeSecrets(node *Node) {
|
||||||
EnsureSocksCredentials(node)
|
|
||||||
if node.WSPath == nil || *node.WSPath == "" {
|
if node.WSPath == nil || *node.WSPath == "" {
|
||||||
node.WSPath = StrPtr("/" + hex.EncodeToString(xbRandBytes(9)))
|
node.WSPath = StrPtr("/" + hex.EncodeToString(xbRandBytes(9)))
|
||||||
}
|
}
|
||||||
@@ -303,19 +284,19 @@ func listenAddress(networkMode string, server *Server, bindAddress string) strin
|
|||||||
if bindAddress != "" {
|
if bindAddress != "" {
|
||||||
return bindAddress
|
return bindAddress
|
||||||
}
|
}
|
||||||
// Hotfix 2026-10-21: inbounds bind the wildcard address of their
|
if networkMode == "ipv6" {
|
||||||
// stack. The registered public address is a *connection* address
|
if server != nil && server.PublicAddressV6 != "" {
|
||||||
// (see serverAddress); pinning the listener to it left the
|
return server.PublicAddressV6
|
||||||
// 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 "::"
|
|
||||||
default: // ipv4
|
|
||||||
return "0.0.0.0"
|
|
||||||
}
|
}
|
||||||
|
return "::"
|
||||||
|
}
|
||||||
|
if networkMode == "dual" {
|
||||||
|
return "::"
|
||||||
|
}
|
||||||
|
if server != nil && server.PublicAddress != "" {
|
||||||
|
return server.PublicAddress
|
||||||
|
}
|
||||||
|
return "0.0.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
func serverAddress(server *Server, networkMode string) (string, error) {
|
func serverAddress(server *Server, networkMode string) (string, error) {
|
||||||
@@ -328,19 +309,54 @@ func serverAddress(server *Server, networkMode string) (string, error) {
|
|||||||
return server.PublicAddress, nil
|
return server.PublicAddress, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveBindAddress mirrors resolveBindAddress. Since the 2026-10-21
|
// ResolveBindAddress mirrors resolveBindAddress: dual/absent -> ("", nil).
|
||||||
// listen hotfix it always returns ("", nil): inbounds bind the wildcard
|
// DNS failures are HTTPError 400; the parenthesized code approximates
|
||||||
// address of their stack (see listenAddress), and the server's
|
// err.code with the Go resolver's short error text (e.g. "no such host"),
|
||||||
// registered public address is a connection address (serverAddress),
|
// since Go exposes no getaddrinfo code.
|
||||||
// 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) {
|
func ResolveBindAddress(networkMode string, server *Server) (string, error) {
|
||||||
|
if networkMode == "dual" {
|
||||||
return "", nil
|
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 ----------
|
// ---------- sing-box resource builders ----------
|
||||||
@@ -429,12 +445,10 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
|
|||||||
}
|
}
|
||||||
|
|
||||||
if protocol == "socks5-auth" {
|
if protocol == "socks5-auth" {
|
||||||
// Round-3 item 21: the inbound carries exactly one user — the
|
users := make([]any, 0, len(nodeClients))
|
||||||
// node's fixed account — regardless of bindings. Bindings only
|
for _, nc := range nodeClients {
|
||||||
// control subscription visibility; per-person accounting for
|
users = append(users, map[string]any{"username": NodeEmail(nc.CustomerID), "password": clientCredentials(nc).RelayPassword})
|
||||||
// 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
|
return map[string]any{"type": "socks", "tag": tag, "listen": listen, "listen_port": port, "users": users}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-26
@@ -134,11 +134,7 @@ func (s *Store) Init() error {
|
|||||||
if err := validateData(&data); err != nil {
|
if err := validateData(&data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
migrated := MigrateCustomerCredentials(&data)
|
if MigrateCustomerCredentials(&data) {
|
||||||
if MigrateSocksNodeCredentials(&data) {
|
|
||||||
migrated = true
|
|
||||||
}
|
|
||||||
if migrated {
|
|
||||||
// Persist the converged credential model immediately, so a crash
|
// Persist the converged credential model immediately, so a crash
|
||||||
// before the next transaction cannot resurrect per-binding sets.
|
// before the next transaction cannot resurrect per-binding sets.
|
||||||
if err := s.persist(&data); err != nil {
|
if err := s.persist(&data); err != nil {
|
||||||
@@ -149,27 +145,6 @@ func (s *Store) Init() error {
|
|||||||
return nil
|
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
|
// MigrateCustomerCredentials converges legacy per-binding credentials into
|
||||||
// each customer's single set (round-2 item 2; the 3x-ui model of one
|
// each customer's single set (round-2 item 2; the 3x-ui model of one
|
||||||
// credential row per client). Deterministic rules:
|
// credential row per client). Deterministic rules:
|
||||||
|
|||||||
@@ -112,31 +112,15 @@ func NodeClientURI(d *Data, nc *NodeClient) string {
|
|||||||
if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil {
|
if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil {
|
||||||
creds = *customer.Credentials
|
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{
|
uri, ok := BuildClientURI(BuildClientURIParams{
|
||||||
Protocol: node.Protocol,
|
Protocol: node.Protocol,
|
||||||
RelayServer: server,
|
RelayServer: server,
|
||||||
RelayPort: node.Port,
|
RelayPort: node.Port,
|
||||||
ClientID: creds.ClientID,
|
ClientID: creds.ClientID,
|
||||||
RelayPassword: relayPassword,
|
RelayPassword: creds.RelayPassword,
|
||||||
ShortID: creds.ShortID,
|
ShortID: creds.ShortID,
|
||||||
WSPath: xbDeref(node.WSPath),
|
WSPath: xbDeref(node.WSPath),
|
||||||
SocksUser: socksUser,
|
SocksUser: NodeEmail(nc.CustomerID),
|
||||||
Reality: node.Reality,
|
Reality: node.Reality,
|
||||||
PublicKey: publicKey,
|
PublicKey: publicKey,
|
||||||
Name: node.Name,
|
Name: node.Name,
|
||||||
|
|||||||
@@ -30,13 +30,7 @@ func goldenSubData() *Data {
|
|||||||
Topology: "direct", NetworkMode: "ipv4", Port: 23461, Status: "active",
|
Topology: "direct", NetworkMode: "ipv4", Port: 23461, Status: "active",
|
||||||
WSPath: StrPtr("/wspath123"), Artifacts: map[string]json.RawMessage{}},
|
WSPath: StrPtr("/wspath123"), Artifacts: map[string]json.RawMessage{}},
|
||||||
{ID: "nod_sk", Name: "Socks", ServerID: "srv_entry", Protocol: "socks5-auth",
|
{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",
|
{ID: "nod_rv", Name: "RV-Plain", ServerID: "srv_v6", Protocol: "vless-reality",
|
||||||
Topology: "direct", NetworkMode: "ipv4", Port: 23463, Status: "active",
|
Topology: "direct", NetworkMode: "ipv4", Port: 23463, Status: "active",
|
||||||
Reality: &RealityConfig{KeyID: "reality-nod_rv", ServerName: "www.apple.com", DestPort: 443},
|
Reality: &RealityConfig{KeyID: "reality-nod_rv", ServerName: "www.apple.com", DestPort: 443},
|
||||||
|
|||||||
+3
-3
@@ -12,7 +12,7 @@
|
|||||||
{
|
{
|
||||||
"type": "vless",
|
"type": "vless",
|
||||||
"tag": "ng-nodrf-in",
|
"tag": "ng-nodrf-in",
|
||||||
"listen": "0.0.0.0",
|
"listen": "203.0.113.10",
|
||||||
"listen_port": 23456,
|
"listen_port": 23456,
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
{
|
{
|
||||||
"type": "shadowsocks",
|
"type": "shadowsocks",
|
||||||
"tag": "ng-nodrf-exit-in",
|
"tag": "ng-nodrf-exit-in",
|
||||||
"listen": "0.0.0.0",
|
"listen": "203.0.113.20",
|
||||||
"listen_port": 23457,
|
"listen_port": 23457,
|
||||||
"method": "2022-blake3-aes-256-gcm",
|
"method": "2022-blake3-aes-256-gcm",
|
||||||
"password": "AwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISI="
|
"password": "AwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISI="
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
{
|
{
|
||||||
"type": "shadowsocks",
|
"type": "shadowsocks",
|
||||||
"tag": "ng-nodss-in",
|
"tag": "ng-nodss-in",
|
||||||
"listen": "0.0.0.0",
|
"listen": "203.0.113.11",
|
||||||
"listen_port": 23458,
|
"listen_port": 23458,
|
||||||
"method": "2022-blake3-aes-128-gcm",
|
"method": "2022-blake3-aes-128-gcm",
|
||||||
"password": "CwwNDg8QERITFBUWFxgZGg==",
|
"password": "CwwNDg8QERITFBUWFxgZGg==",
|
||||||
|
|||||||
+1
-109
@@ -254,85 +254,8 @@ func (d Data) MarshalJSON() ([]byte, error) {
|
|||||||
return mergeMarshal(plain(d), d.Extra)
|
return mergeMarshal(plain(d), d.Extra)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone is structuredClone(this.data). Since round-3 item 22 it is a
|
// Clone is structuredClone(this.data).
|
||||||
// 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 {
|
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)
|
raw, err := marshalJSON(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
@@ -512,13 +435,6 @@ type Agent struct {
|
|||||||
ServerID string `json:"serverId"`
|
ServerID string `json:"serverId"`
|
||||||
KeyHash string `json:"keyHash"`
|
KeyHash string `json:"keyHash"`
|
||||||
KeyFingerprint *string `json:"keyFingerprint,omitempty"`
|
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"`
|
Status string `json:"status"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
@@ -543,15 +459,6 @@ func (a Agent) MarshalJSON() ([]byte, error) {
|
|||||||
return mergeMarshal(plain(a), a.Extra)
|
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 {
|
type EnrollmentToken struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ServerID string `json:"serverId"`
|
ServerID string `json:"serverId"`
|
||||||
@@ -828,21 +735,6 @@ type Node struct {
|
|||||||
AutoRemoving *bool `json:"autoRemoving,omitempty"`
|
AutoRemoving *bool `json:"autoRemoving,omitempty"`
|
||||||
WSPath *string `json:"wsPath,omitempty"`
|
WSPath *string `json:"wsPath,omitempty"`
|
||||||
SSServerKey *string `json:"ssServerKey,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"`
|
Reality *RealityConfig `json:"reality,omitempty"`
|
||||||
ExitCredentials *ExitCredentials `json:"exitCredentials,omitempty"`
|
ExitCredentials *ExitCredentials `json:"exitCredentials,omitempty"`
|
||||||
UsageCounters *UsageCounters `json:"usageCounters"`
|
UsageCounters *UsageCounters `json:"usageCounters"`
|
||||||
|
|||||||
+33
-245
@@ -9,9 +9,7 @@ const state = {
|
|||||||
protocols: [], realityPresets: [], version: '0.6.8',
|
protocols: [], realityPresets: [], version: '0.6.8',
|
||||||
search: '', groupFilter: '', customerNodeFilter: '',
|
search: '', groupFilter: '', customerNodeFilter: '',
|
||||||
pages: { servers: 1, customers: 1, nodes: 1 },
|
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;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
@@ -130,9 +128,6 @@ async function api(path, options = {}) {
|
|||||||
if (response.status === 401 && path !== '/api/auth/login') showLogin();
|
if (response.status === 401 && path !== '/api/auth/login') showLogin();
|
||||||
throw new Error((data && data.message) || `请求失败 (${response.status})`);
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,59 +240,29 @@ 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) {
|
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 {
|
try {
|
||||||
if (page === 'overview') {
|
if (page === 'overview') {
|
||||||
fresh('/api/overview', setOverview);
|
const [overview, system, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/system'), api('/api/servers'), api('/api/customers'), api('/api/nodes')]);
|
||||||
fresh('/api/system', (d) => { state.system = d; });
|
state.overview = overview; state.system = system; state.version = overview.version || state.version;
|
||||||
base('servers', '/api/servers', setServers);
|
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||||
base('customers', '/api/customers', setCustomers);
|
$('#version-label').textContent = `v${state.version}`;
|
||||||
base('nodes', '/api/nodes', setNodes);
|
|
||||||
} else if (page === 'servers') {
|
} else if (page === 'servers') {
|
||||||
base('servers', '/api/servers', setServers);
|
const [servers, routes] = await Promise.all([api('/api/servers'), api('/api/nodes')]);
|
||||||
base('nodes', '/api/nodes', setNodes);
|
state.servers = servers.servers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||||
} else if (page === 'customers') {
|
} else if (page === 'customers') {
|
||||||
base('customers', '/api/customers', setCustomers);
|
const [customers, routes] = await Promise.all([api('/api/customers'), api('/api/nodes')]);
|
||||||
base('nodes', '/api/nodes', setNodes);
|
state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||||
} else if (page === 'chains') {
|
} else if (page === 'chains') {
|
||||||
base('servers', '/api/servers', setServers);
|
const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/nodes'), api('/api/protocols')]);
|
||||||
base('customers', '/api/customers', setCustomers);
|
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||||
base('nodes', '/api/nodes', setNodes);
|
state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || [];
|
||||||
base('protocols', '/api/protocols', (d) => { state.protocols = d.profiles; state.realityPresets = d.realityPresets || []; });
|
|
||||||
} else if (page === 'operations') {
|
} else if (page === 'operations') {
|
||||||
fresh('/api/jobs', (d) => { state.jobs = d.jobs; });
|
const [jobs, overview] = await Promise.all([api('/api/jobs'), api('/api/overview')]);
|
||||||
fresh('/api/overview', setOverview);
|
state.jobs = jobs.jobs; state.overview = overview; state.version = overview.version || state.version;
|
||||||
|
$('#version-label').textContent = `v${state.version}`;
|
||||||
}
|
}
|
||||||
// Render immediately with whatever is already in state (cached or
|
render();
|
||||||
// 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); }
|
} catch (error) { toast(error.message, true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,102 +438,6 @@ function serverDetailRow(server) {
|
|||||||
<button class="btn small" data-action="agent-uninstall" data-id="${esc(server.id)}">SSH 卸载 Agent</button>
|
<button class="btn small" data-action="agent-uninstall" data-id="${esc(server.id)}">SSH 卸载 Agent</button>
|
||||||
</div></td></tr>`;
|
</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() {
|
function renderServers() {
|
||||||
const q = state.search.toLowerCase();
|
const q = state.search.toLowerCase();
|
||||||
const onlineCount = state.servers.filter((s) => s.status === 'online').length;
|
const onlineCount = state.servers.filter((s) => s.status === 'online').length;
|
||||||
@@ -588,7 +457,7 @@ function renderServers() {
|
|||||||
const addr = state.addrVisible ? esc(server.publicAddress) : esc(maskAddr(server.publicAddress));
|
const addr = state.addrVisible ? esc(server.publicAddress) : esc(maskAddr(server.publicAddress));
|
||||||
const row = `<tr>
|
const row = `<tr>
|
||||||
<td><button class="exp" type="button" data-action="expand-server" data-id="${esc(server.id)}">${expanded ? '−' : '+'}</button></td>
|
<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="servers" data-id="${esc(server.id)}"${state.selection.servers.has(server.id) ? ' checked' : ''}></td>
|
<td><input type="checkbox" data-row-check></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><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><label class="tgl"><input type="checkbox" data-toggle-server data-id="${esc(server.id)}" ${server.enabled !== false ? 'checked' : ''}><span class="tr"></span></label></td>
|
||||||
<td><span class="dev-name">${esc(server.name)}</span></td>
|
<td><span class="dev-name">${esc(server.name)}</span></td>
|
||||||
@@ -605,7 +474,7 @@ function renderServers() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
return `${stats}
|
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>
|
<div class="page-tools"><button class="btn primary" data-action="add-server">+ 添加设备</button><input class="search" data-search placeholder="搜索设备名、地址" value="${esc(state.search)}"></div>
|
||||||
${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>
|
<section class="panel"><div class="table-wrap"><table class="w-servers"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>操作</th><th>已启用</th><th>名称</th><th>地址<button class="icon-btn eye-btn" type="button" data-action="toggle-addr" data-tip="显示 / 隐藏地址">${state.addrVisible ? ICONS.eyeOff : ICONS.eye}</button></th><th>状态</th><th>CPU</th><th>内存</th><th>Agent 版本</th><th>核心版本</th><th>运行时长</th><th>客户端</th><th>上次心跳</th></tr></thead>
|
||||||
<tbody>${rows || emptyRow(14, '没有匹配设备')}</tbody></table></div>${pagerHTML('servers', filtered.length)}</section>`;
|
<tbody>${rows || emptyRow(14, '没有匹配设备')}</tbody></table></div>${pagerHTML('servers', filtered.length)}</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,7 +532,7 @@ function renderCustomers() {
|
|||||||
const speedTotal = (item.speedUpBps || 0) + (item.speedDownBps || 0);
|
const speedTotal = (item.speedUpBps || 0) + (item.speedDownBps || 0);
|
||||||
const manualOff = item.status === 'suspended' && item.suspendReason === 'manual';
|
const manualOff = item.status === 'suspended' && item.suspendReason === 'manual';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<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><div class="op-cell"><input type="checkbox" data-row-check><button class="icon-btn" data-tip="二维码" data-action="customer-subscription" data-id="${esc(item.id)}">${ICONS.qr}</button><button class="icon-btn" data-tip="详情" data-action="customer-access" data-id="${esc(item.id)}">${ICONS.info}</button><button class="icon-btn" data-tip="重置流量" data-action="reset-usage" data-id="${esc(item.id)}">${ICONS.reset}</button><button class="icon-btn" data-tip="编辑" data-action="edit-customer" data-id="${esc(item.id)}">${ICONS.edit}</button><button class="icon-btn del" data-tip="删除" data-action="delete-customer" data-id="${esc(item.id)}">${ICONS.trash}</button></div></td>
|
||||||
<td><label class="tgl"><input type="checkbox" data-toggle-customer data-id="${esc(item.id)}" ${manualOff ? '' : 'checked'}><span class="tr"></span></label></td>
|
<td><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="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>
|
<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>
|
||||||
@@ -688,7 +557,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>
|
<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>` : ''}
|
${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>
|
<span class="badge blue">${filtered.length} 位客户</span></div></div>
|
||||||
${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>
|
<section class="panel"><div class="table-wrap"><table class="w-customers"><thead><tr><th>操作</th><th>已启用</th><th>在线</th><th>客户端</th><th>关联入站</th><th>流量<span class="sarr">⇅</span></th><th class="ctr">速度</th><th>剩余<span class="sarr">⇅</span></th><th>时长<span class="sarr">⇅</span></th></tr></thead>
|
||||||
<tbody>${rows || emptyRow(9, '还没有客户')}</tbody></table></div>${pagerHTML('customers', filtered.length)}</section>`;
|
<tbody>${rows || emptyRow(9, '还没有客户')}</tbody></table></div>${pagerHTML('customers', filtered.length)}</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,11 +627,11 @@ function renderCredentialsPane(customer) {
|
|||||||
const data = customerFormState.credentials;
|
const data = customerFormState.credentials;
|
||||||
if (!data) { pane.innerHTML = '<div class="empty"><b>加载中…</b></div>'; return; }
|
if (!data) { pane.innerHTML = '<div class="empty"><b>加载中…</b></div>'; return; }
|
||||||
const creds = data.credentials || {};
|
const creds = data.credentials || {};
|
||||||
// Round-3 item 15: the per-binding list (node name / status / protocol
|
const bindings = (data.bindings || []).map((entry) => `<div class="cred-group">
|
||||||
// / Reality public key) is gone — the tab shows only the subscription
|
<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>
|
||||||
// token and the one shared credential set. Reality keys live on the
|
${entry.username ? credRowHTML('用户名', entry.username) : ''}
|
||||||
// node edit form now (item 17); the socks username lives on the node
|
${entry.realityPublicKey ? credRowHTML('Reality 公钥', entry.realityPublicKey) : ''}
|
||||||
// (item 21). Backend payload unchanged.
|
</div>`).join('');
|
||||||
pane.innerHTML = `<div class="cred-group"><div class="g-head">订阅</div>
|
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>
|
<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>
|
<button type="button" class="icon-btn" data-tip="复制" data-action="copy-text" data-value="${esc(data.subscriptionToken || '')}">${ICONS.copy}</button>
|
||||||
@@ -771,7 +640,8 @@ function renderCredentialsPane(customer) {
|
|||||||
${credRowHTML('UUID', creds.clientId)}
|
${credRowHTML('UUID', creds.clientId)}
|
||||||
${credRowHTML('密码', creds.password)}
|
${credRowHTML('密码', creds.password)}
|
||||||
${credRowHTML('Reality Short ID', creds.shortId)}
|
${credRowHTML('Reality Short ID', creds.shortId)}
|
||||||
</div>`;
|
</div>
|
||||||
|
${bindings || '<div class="empty"><b>尚未关联节点</b></div>'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkStatusLine(customer, source) {
|
function linkStatusLine(customer, source) {
|
||||||
@@ -1000,7 +870,7 @@ function renderNodes() {
|
|||||||
const busy = ['deploying', 'removing'].includes(node.status);
|
const busy = ['deploying', 'removing'].includes(node.status);
|
||||||
return `<tr data-node-row="${esc(node.id)}">
|
return `<tr data-node-row="${esc(node.id)}">
|
||||||
<td><span class="grip" data-tip="拖动排序" data-grip>${ICONS.grip}</span></td>
|
<td><span class="grip" data-tip="拖动排序" data-grip>${ICONS.grip}</span></td>
|
||||||
<td><input type="checkbox" data-row-check="nodes" data-id="${esc(node.id)}"${state.selection.nodes.has(node.id) ? ' checked' : ''}></td>
|
<td><input type="checkbox" data-row-check></td>
|
||||||
<td class="num">${(node.sortIndex != null ? node.sortIndex : index) + 1}</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 class="menu-td"><button class="icon-btn" data-tip="编辑" data-action="edit-node" data-id="${esc(node.id)}">${ICONS.edit}</button><button class="icon-btn" data-tip="更多" data-action="node-menu" data-id="${esc(node.id)}">${ICONS.dots}</button></td>
|
||||||
<td><label class="tgl"><input type="checkbox" data-toggle-node data-id="${esc(node.id)}" ${enabled ? 'checked' : ''} ${busy ? 'disabled' : ''}><span class="tr"></span></label></td>
|
<td><label class="tgl"><input type="checkbox" data-toggle-node data-id="${esc(node.id)}" ${enabled ? 'checked' : ''} ${busy ? 'disabled' : ''}><span class="tr"></span></label></td>
|
||||||
@@ -1016,7 +886,7 @@ function renderNodes() {
|
|||||||
return `<div class="page-tools"><button class="btn primary" data-action="add-node">+ 添加入站</button>
|
return `<div class="page-tools"><button class="btn primary" data-action="add-node">+ 添加入站</button>
|
||||||
<div class="toolbar"><input class="search" data-search placeholder="搜索节点、服务器或协议" value="${esc(state.search)}">
|
<div class="toolbar"><input class="search" data-search placeholder="搜索节点、服务器或协议" value="${esc(state.search)}">
|
||||||
<span class="badge blue">${state.nodes.length} 个入站</span><span class="badge green">在线 ${active}</span>${degraded ? `<span class="badge orange">降级 ${degraded}</span>` : ''}</div></div>
|
<span class="badge blue">${state.nodes.length} 个入站</span><span class="badge green">在线 ${active}</span>${degraded ? `<span class="badge orange">降级 ${degraded}</span>` : ''}</div></div>
|
||||||
${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>
|
<section class="panel"><div class="table-wrap visible"><table class="w-nodes"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>ID<span class="sarr">⇅</span></th><th>菜单</th><th>启用</th><th>备注</th><th>节点</th><th>端口<span class="sarr">⇅</span></th><th>协议</th><th>客户端</th><th>流量<span class="sarr">⇅</span></th><th>速度</th><th>到期时间</th></tr></thead>
|
||||||
<tbody>${rows || emptyRow(13, '还没有节点')}</tbody></table></div>${pagerHTML('nodes', filtered.length)}</section>`;
|
<tbody>${rows || emptyRow(13, '还没有节点')}</tbody></table></div>${pagerHTML('nodes', filtered.length)}</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,11 +902,8 @@ 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="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>入口协议<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>入口端口<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 目标预设<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 目标预设<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 SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(node.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(node.realityDestPort || 443)}" min="1" max="65535"></div></label>
|
<label 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>
|
<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="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>
|
<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>
|
||||||
@@ -1050,15 +917,6 @@ function nodeForm(item = null) {
|
|||||||
<label data-forward-only data-manual-only data-exit-cred="password">落地密码<input name="exitPassword" placeholder="${item ? '留空保持不变' : ''}"></label>
|
<label data-forward-only data-manual-only data-exit-cred="password">落地密码<input name="exitPassword" placeholder="${item ? '留空保持不变' : ''}"></label>
|
||||||
<div class="form-actions wide"><button type="button" class="btn" data-close>取消</button><button class="btn primary" type="submit">${item ? '保存修改' : '创建节点'}</button></div></form>`, true);
|
<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();
|
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() {
|
function syncNodeForm() {
|
||||||
const form = $('#node-form'); if (!form) return;
|
const form = $('#node-form'); if (!form) return;
|
||||||
@@ -1069,8 +927,6 @@ function syncNodeForm() {
|
|||||||
const reality = ['vless-reality-vision', 'vless-reality'].includes(form.elements.protocol.value);
|
const reality = ['vless-reality-vision', 'vless-reality'].includes(form.elements.protocol.value);
|
||||||
$$('[data-reality-only]', form).forEach((element) => element.classList.toggle('hidden', !reality));
|
$$('[data-reality-only]', form).forEach((element) => element.classList.toggle('hidden', !reality));
|
||||||
form.elements.realityServerName.required = 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']]) {
|
for (const [mode, port] of [['portMode', 'port'], ['exitPortMode', 'exitPort']]) {
|
||||||
const fixed = form.elements[mode].value === 'fixed' && (mode === 'portMode' || !direct);
|
const fixed = form.elements[mode].value === 'fixed' && (mode === 'portMode' || !direct);
|
||||||
form.elements[port].disabled = !fixed;
|
form.elements[port].disabled = !fixed;
|
||||||
@@ -1148,57 +1004,6 @@ function applyExitLink(input) {
|
|||||||
syncNodeForm();
|
syncNodeForm();
|
||||||
toast('已从链接填入落地端点');
|
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) {
|
function nodeClientsModal(node) {
|
||||||
const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id);
|
const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id);
|
||||||
const rows = assigned.map((nc) => {
|
const rows = assigned.map((nc) => {
|
||||||
@@ -1256,7 +1061,6 @@ function renderOperations() {
|
|||||||
function render() {
|
function render() {
|
||||||
const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, operations: renderOperations };
|
const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, operations: renderOperations };
|
||||||
$('#content').innerHTML = views[state.page]();
|
$('#content').innerHTML = views[state.page]();
|
||||||
syncCheckAll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 表单提交 ---------- */
|
/* ---------- 表单提交 ---------- */
|
||||||
@@ -1300,7 +1104,7 @@ document.addEventListener('submit', async (event) => {
|
|||||||
closeModal(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers');
|
closeModal(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers');
|
||||||
} else if (form.id === 'node-form') {
|
} else if (form.id === 'node-form') {
|
||||||
const resourceId = data.get('resourceId');
|
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, socksUser: data.get('socksUser') || null, socksPassword: data.get('socksPassword') || 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 };
|
||||||
if (payload.topology !== 'direct' && payload.exitMode === 'manual') {
|
if (payload.topology !== 'direct' && payload.exitMode === 'manual') {
|
||||||
const creds = {};
|
const creds = {};
|
||||||
if (data.get('exitClientId')) creds.clientId = data.get('exitClientId');
|
if (data.get('exitClientId')) creds.clientId = data.get('exitClientId');
|
||||||
@@ -1310,7 +1114,7 @@ document.addEventListener('submit', async (event) => {
|
|||||||
payload.exitPortMode = 'fixed';
|
payload.exitPortMode = 'fixed';
|
||||||
}
|
}
|
||||||
await api(resourceId ? `/api/nodes/${resourceId}` : '/api/nodes', { method: resourceId ? 'PATCH' : 'POST', body: JSON.stringify(payload) });
|
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') {
|
} else if (form.id === 'account-form') {
|
||||||
if (data.get('newPassword') !== data.get('confirmPassword')) throw new Error('两次输入的新密码不一致');
|
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') }) });
|
await api('/api/account', { method: 'PATCH', body: JSON.stringify({ username: data.get('username'), currentPassword: data.get('currentPassword'), newPassword: data.get('newPassword') }) });
|
||||||
@@ -1369,8 +1173,6 @@ document.addEventListener('click', async (event) => {
|
|||||||
const action = button.dataset.action; const itemId = button.dataset.id;
|
const action = button.dataset.action; const itemId = button.dataset.id;
|
||||||
try {
|
try {
|
||||||
if (action === 'copy-text') { await copyText(button.dataset.value); toast('已复制到剪贴板'); }
|
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 === 'add-server') serverForm();
|
||||||
else if (action === 'edit-server') serverForm(serverById(itemId));
|
else if (action === 'edit-server') serverForm(serverById(itemId));
|
||||||
else if (action === 'enroll-server') await enrollModal(itemId);
|
else if (action === 'enroll-server') await enrollModal(itemId);
|
||||||
@@ -1483,10 +1285,6 @@ 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 === '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 === '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 === '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 === 'download-backup') location.href = '/api/backup';
|
||||||
else if (action === 'restore-backup') {
|
else if (action === 'restore-backup') {
|
||||||
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json';
|
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json';
|
||||||
@@ -1526,18 +1324,8 @@ document.addEventListener('change', async (event) => {
|
|||||||
await load('chains');
|
await load('chains');
|
||||||
} else if (target.matches('[data-group-filter]')) {
|
} else if (target.matches('[data-group-filter]')) {
|
||||||
state.groupFilter = target.value; state.pages.customers = 1; render();
|
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]')) {
|
} else if (target.matches('[data-check-all]')) {
|
||||||
const set = state.selection[target.dataset.checkAll];
|
$$('[data-row-check]').forEach((box) => { box.checked = target.checked; });
|
||||||
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-node-sync]')) syncNodeForm();
|
||||||
else if (target.matches('[data-exit-link]')) applyExitLink(target);
|
else if (target.matches('[data-exit-link]')) applyExitLink(target);
|
||||||
else if (target.matches('[data-reality-preset]')) {
|
else if (target.matches('[data-reality-preset]')) {
|
||||||
|
|||||||
@@ -74,8 +74,6 @@ input,select,textarea{font-family:var(--font);color:var(--text)}
|
|||||||
.panel-body{padding:14px 16px}
|
.panel-body{padding:14px 16px}
|
||||||
.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
.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}
|
.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}
|
.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}
|
select.search{width:auto}
|
||||||
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--card-hover);color:var(--text);border-radius:6px;padding:5px 14px;font-size:13px;cursor:pointer;white-space:nowrap}
|
.btn{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}
|
||||||
@@ -405,15 +403,3 @@ 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: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}}
|
@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}
|
|
||||||
|
|||||||
@@ -9,16 +9,7 @@ set -Eeuo pipefail
|
|||||||
version='v1.13.21'
|
version='v1.13.21'
|
||||||
target='/usr/local/bin/nexusgate-sing-box'
|
target='/usr/local/bin/nexusgate-sing-box'
|
||||||
stats_target='/usr/local/bin/nexusgate-sing-box-stats'
|
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
|
if [[ -x "$target" && -x "$stats_target" && "${2:-}" != '--force' ]]; then
|
||||||
write_version_record
|
|
||||||
printf 'sing-box 已安装:%s(如需重装,运行 ng-agent-singbox install --force)\n' "$target"
|
printf 'sing-box 已安装:%s(如需重装,运行 ng-agent-singbox install --force)\n' "$target"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -47,7 +38,6 @@ try_prebuilt() {
|
|||||||
install -m 0755 "$tmp_stats" "${stats_target}.next"
|
install -m 0755 "$tmp_stats" "${stats_target}.next"
|
||||||
mv -f "${target}.next" "$target"
|
mv -f "${target}.next" "$target"
|
||||||
mv -f "${stats_target}.next" "$stats_target"
|
mv -f "${stats_target}.next" "$stats_target"
|
||||||
write_version_record
|
|
||||||
printf 'sing-box 预编译版安装完成:%s\n' "$target"
|
printf 'sing-box 预编译版安装完成:%s\n' "$target"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -114,7 +104,6 @@ install -m 0755 "$build_dir/sing-box" "$target.next"
|
|||||||
install -m 0755 "$build_dir/stats-query" "$stats_target.next"
|
install -m 0755 "$build_dir/stats-query" "$stats_target.next"
|
||||||
mv -f -- "$target.next" "$target"
|
mv -f -- "$target.next" "$target"
|
||||||
mv -f -- "$stats_target.next" "$stats_target"
|
mv -f -- "$stats_target.next" "$stats_target"
|
||||||
write_version_record
|
|
||||||
printf 'sing-box 统计版已安装:%s\n' "$target"
|
printf 'sing-box 统计版已安装:%s\n' "$target"
|
||||||
if [[ -f /etc/nexusgate/sing-box/config.json ]]; then
|
if [[ -f /etc/nexusgate/sing-box/config.json ]]; then
|
||||||
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then
|
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user