From 084afeb8f4d4ba106959f7eeabfdb9dd730bc430 Mon Sep 17 00:00:00 2001 From: Muse Date: Mon, 19 Oct 2026 22:23:40 +0800 Subject: [PATCH] =?UTF-8?q?3x-ui=20=E5=BC=8F=E5=85=B1=E4=BA=AB=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=94=B9=E9=80=A0=EF=BC=9Anodes=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9C=BA=20+=20nodeClients=20=E5=90=84=E8=87=AA=E5=87=AD?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 VMess 协议 - VLESS/SS2022 按用户流量统计(AnyTLS 日志解析,SS 节点级) - sing-box v2ray_api stats 支持 - 内存优化:面板 128M,agent 64M,sing-box 64M --- .github/workflows/ci.yml | 11 - README.md | 55 ++- agent/agent.js | 394 +++++++----------- agent/stats-query.go | 4 +- docs/ARCHITECTURE.md | 6 +- docs/ROADMAP.md | 6 +- docs/SECURITY.md | 8 +- lib/orchestrator.js | 618 +++++++++++++++-------------- lib/protocols.js | 403 +++++++++++-------- lib/store.js | 6 +- lib/subscriptions.js | 67 ++-- openrc/nexusgate | 35 ++ openrc/nexusgate-agent | 2 +- openrc/nexusgate-xray | 17 - package.json | 2 +- public/app.js | 310 +++++++++------ public/index.html | 1 - public/styles.css | 4 + scripts/agent-cert.sh | 4 - scripts/agent-doctor.sh | 53 +-- scripts/agent-install.sh | 78 ++-- scripts/agent-uninstall.sh | 11 +- scripts/agent-update.sh | 18 +- scripts/hy2-smoke.sh | 71 ---- scripts/install.sh | 138 +++++-- scripts/nexusgate.sh | 95 +++-- server.js | 485 +++++++++++++++------- systemd/nexusgate-agent.service | 4 +- systemd/nexusgate-sing-box.service | 2 + systemd/nexusgate-xray.service | 15 - test/agent.test.js | 225 +++++------ test/api.test.js | 338 +++++++++------- test/jobs.test.js | 4 +- test/protocols.test.js | 238 +++++++---- test/subscriptions.test.js | 163 +++++--- 35 files changed, 2142 insertions(+), 1749 deletions(-) create mode 100644 openrc/nexusgate delete mode 100755 openrc/nexusgate-xray delete mode 100644 scripts/hy2-smoke.sh delete mode 100644 systemd/nexusgate-xray.service diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0aa30c2..f492422 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,14 +37,3 @@ jobs: sudo env PATH="$PATH" NG_STATS_SOURCE="$GITHUB_WORKSPACE/agent/stats-query.go" bash scripts/agent-singbox.sh install - name: AnyTLS handshake and two-way traffic counters run: bash scripts/anytls-smoke.sh - - name: Install Xray 26.3.27 for Hysteria 2 interoperability - run: | - mkdir -p "$RUNNER_TEMP/nexusgate-xray" - curl -fL --retry 3 https://github.com/XTLS/Xray-core/releases/download/v26.3.27/Xray-linux-64.zip -o "$RUNNER_TEMP/nexusgate-xray.zip" - unzip -q "$RUNNER_TEMP/nexusgate-xray.zip" xray -d "$RUNNER_TEMP/nexusgate-xray" - - name: Build QUIC-enabled sing-box test client - run: | - mkdir -p "$RUNNER_TEMP/nexusgate-hy2-client" - GOBIN="$RUNNER_TEMP/nexusgate-hy2-client" GOMAXPROCS=2 go install -trimpath -ldflags='-s -w' -p 2 -tags with_quic github.com/sagernet/sing-box/cmd/sing-box@v1.12.23 - - name: Hysteria 2 handshake, UDP listener and two-way counters - run: NG_XRAY_BIN="$RUNNER_TEMP/nexusgate-xray/xray" NG_SINGBOX_BIN="$RUNNER_TEMP/nexusgate-hy2-client/sing-box" bash scripts/hy2-smoke.sh diff --git a/README.md b/README.md index 5f2da77..f5eb440 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ **v0.6.6 修复:** 控制面记住入口进程最近退出的计量周期,忽略重载后才到达的旧上报,避免双向流量重复扣额。Agent 在解析入口计数失败时会阻止配置重载,并保留正在运行的资源供排查;证书安装和自动续签触发核心重启前,也先尝试上报当前计数。过期的节点 IP 观察记录会随后台清理删除,避免长期堆积。Clash 线路名与内置策略组或 `DIRECT` 撞名时才添加“(节点)”区分,其余线路保持原名。 -**升级到 AnyTLS:** 面板机运行 `ng update`,入口设备运行 `ng-agent update && ng-agent engine install`。尚无有效证书时再在入口机运行 `ng-agent cert`,按提示输入域名与验证方式;已有有效证书无需重新申请。sing-box 仅在 AnyTLS 入口安装,普通入口和出口不构建 Go 引擎;Agent 更新不会重复编译 sing-box。执行 `ng-agent doctor` 确认引擎和证书已就绪,然后部署 AnyTLS 线路并开放入口 TCP 端口。AnyTLS → VLESS TCP 可以部署,但第二段未加密;跨公网推荐选择 SS2022 出口传输。 +**升级到 sing-box 全协议:** 面板机运行 `ng update`,入口/出口设备运行 `ng-agent update`。sing-box(含统计组件)现在是唯一的入口/出口引擎,安装和更新时自动构建,不再区分 AnyTLS 入口。尚无有效证书时在入口机运行 `ng-agent cert`,按提示输入域名与验证方式;已有有效证书无需重新申请。执行 `ng-agent doctor` 确认引擎和证书已就绪,然后部署线路并开放入口 TCP/UDP 端口。AnyTLS → VLESS TCP 可以部署,但第二段未加密;跨公网推荐选择 SS2022 出口传输。 **v0.6.5 修复:** HY2 加入同一客户的 Clash 订阅时,`alpn` 列表现在生成为合法 YAML;旧订阅链接无需重置,面板更新后客户端重新拉取即可。节点显示线路名称;已有链接在订阅返回时也会改成当前线路名,多入口重名时仅附加序号。部署列表显示实际已应用协议及每个入口自新版计量开始的上行、下行,用来区分已保存但未应用的协议编辑。流量仍按客户端入口的上行+下行累计;新增节点或重载前,Agent 先上报旧进程的最终计数,同一进程周期的迟到旧样本不再重复扣额。Speedtest 的结果不能单独证明该流量经过了指定代理节点。侧边栏“安装与命令”按面板、入口、出口分类提供复制命令;AnyTLS 独占机器会停止空闲 Xray,logrotate 每日保留最多 7 份、每次检查时按 20 MB 轮转,sing-box 构建缓存会在结束时删除。`ng update` 的自动回滚备份保留最近 5 份,手工备份不受影响。 @@ -46,15 +46,15 @@ flowchart TB - 设备、客户、线路均可创建后编辑;运行中线路修改后进入“待重新部署”。列表操作按行收纳,长列表不再因按钮换行变高。 - 线路表单中的入口设备和客户可搜索后多选,长列表在固定高度内滚动。 - 支持完整重建失败线路,以及只重试失败部署项。 -- Agent 重装后自动撤销旧密钥、重新对账并恢复已有资源;首次心跳成功才提示安装完成。Xray 配置错误不会再让 Agent 控制通道退出;引擎状态和错误会显示在设备行。 +- Agent 重装后自动撤销旧密钥、重新对账并恢复已有资源;首次心跳成功才提示安装完成。sing-box 配置错误不会再让 Agent 控制通道退出;引擎状态和错误会显示在设备行。 - 部署前检查目标 Agent 最近心跳;即使核心服务报错,仍可下发修复资源,具体失败由任务日志返回。面板机可以兼任出口/单机节点,`ng update` 会同步升级本机已安装的 Agent。 - 任务 5 分钟租约、超时自动重试,连续 3 次失败才标记异常。 - 客户到期使用日期选择器、常用期限下拉和时间下拉,不要求手写日期格式。 - 客户独立凭据、流量额度、到期、滚动 IP 上限、用量清零及启停。 - 流量、到期或节点 IP 上限触发暂停后,会移除入口和出口资源以阻断已导入的节点。调整额度、续期或提高 IP 上限后,控制面自动按原端口和原凭据恢复;清理已开始时先等待清理完成,再下发恢复任务。客户手动停用后需要手动启用。旧版已被移除而变为草稿的线路,可在“转发与节点”点“恢复原节点”一次性找回原部署;若原端口已被别的线路占用,须全新部署并更新客户端。 -- 流量额度为入口节点上行+下行之和,转发出口不重复计费。Agent 分别查询 Xray 和 sing-box 的 JSON 双向累计计数;Xray 省略的零值字段按 0 处理;控制面按各部署的进程周期计算增量,重复请求不重复计费。Agent 升级后若配置相同且核心正常运行,不重启核心丢失内存计数。客户表显示上行、下行及最后计量时间,设备表显示统计错误;订阅头使用独立的 `upload` / `download` 字段。 +- 流量额度为入口节点上行+下行之和,转发出口不重复计费。Agent 查询 sing-box 的 JSON 双向累计计数(入口级与用户级);控制面按各部署的进程周期计算增量,重复请求不重复计费。Agent 升级后若配置相同且核心正常运行,不重启核心丢失内存计数。客户表显示上行、下行及最后计量时间,设备表显示统计错误;订阅头使用独立的 `upload` / `download` 字段。 - 客户列表显示滚动节点 IP 数与最近 24 小时的订阅客户端估计数;“访问”可查看节点 IP 首末上报时间和最近 100 条订阅请求(结果、来源 IP、User-Agent、格式、返回大小)。日志最多保留 30 天 / 全局 10000 条,可为单个客户清空订阅访问窗口。 -- 订阅客户端估计上限会在订阅下载时按 `X-Device-ID` / `X-Client-ID` 或 User-Agent 限制新客户端。节点 IP 超限则由入口 Agent 访问日志上报后停用客户,出口收到的入口机 IP 不计为客户 IP。Agent 现在只有在观察记录成功送达控制面后才推进日志游标,临时网络失败可重试。 +- 订阅客户端估计上限会在订阅下载时按 `X-Device-ID` / `X-Client-ID` 或 User-Agent 限制新客户端。节点 IP 超限则由入口 Agent 的 sing-box 已认证连接日志上报后停用客户,出口收到的入口机 IP 不计为客户 IP。Agent 现在只有在观察记录成功送达控制面后才推进日志游标,临时网络失败可重试。 - Reality 私钥只在节点机生成;控制面只接收客户端公钥。升级时旧部署错误中的误报私钥会从历史记录中脱敏。 - Tesla、Amazon、Apple、Intel、AMD Reality 目标预设,也可自定义。 - IPv4、IPv6 和双栈监听;IPv6 客户端链接会自动使用方括号格式。 @@ -69,7 +69,7 @@ flowchart TB - Agent 支持 Debian/Ubuntu、RHEL 系 systemd,以及 Alpine OpenRC。 - Agent 访问与错误日志由 logrotate 每日检查、最多保留 7 份,达到 20 MB 也会轮转;订阅访问记录仍按控制面 30 天/全局 10000 条清理。 - 节点证书一键申请:入口机运行 `ng-agent cert`,输入域名(邮箱可选);优先选 CF DNS 验证或有公网 80 端口时选 HTTP,设置续签、检查证书并把域名同步到该设备。CF Token 在安装 Certbot DNS 插件前验证,仅 root 可读;面板部署前确认入口 Agent 已上报证书状态。 -- 轻量节点安装只装 Xray 和 Node Agent;需要 AnyTLS 时在对应入口机按需安装 sing-box。临时 Go 编译缓存随安装结束清理,证书心跳复用未变化证书的验证结果。已有运行中的 AnyTLS 不受按需安装规则影响。 +- 所有节点(入口/出口/单机)统一安装 sing-box 和 Node Agent;临时 Go 编译缓存随安装结束清理,证书心跳复用未变化证书的验证结果。 ## 协议矩阵 @@ -79,9 +79,8 @@ flowchart TB | 客户入口 / 单机节点 | VLESS + Reality | 可部署 | | 客户入口 / 单机节点 | VLESS + WebSocket(无 TLS) | 可部署,建议仅配合可信网络或外层 TLS | | 客户入口 / 单机节点 | VLESS + WebSocket + TLS | 可部署;入口设备需填写 TLS 域名并安装有效证书 | -| 客户入口 / 单机节点 | Hysteria 2 | 可部署(测试阶段);入口证书、UDP 入站端口和新版 Xray 必需 | +| 客户入口 / 单机节点 | Hysteria 2 | 可部署(测试阶段);入口证书、UDP 入站端口和 sing-box 必需 | | 客户入口 / 单机节点 | AnyTLS | 可部署(测试阶段);入口证书、TCP 端口和带统计接口的 sing-box 必需 | -| 客户入口 / 单机节点 | VMess + WebSocket(无 TLS) | 可部署,兼容模式 | | 客户入口 / 单机节点 | Shadowsocks 2022 AES-128 / AES-256 | 可部署 | | 客户入口 / 单机节点 | Shadowsocks AES-128-GCM / AES-256-GCM | 可部署 | | 客户入口 / 单机节点 | SOCKS5 用户密码 | 可部署,仅建议可信网络或外层隧道 | @@ -89,7 +88,7 @@ flowchart TB | 入口 → 出口 | VLESS TCP | 可部署,建议可信网络或外层隧道 | | 入口 → 出口 | SOCKS5 用户密码 | 可部署,仅建议可信网络或外层隧道 | -Hysteria 2 复用当前 Xray Agent 的任务、统计及出口路由。AnyTLS 由单独的 sing-box 服务承载,使用自己的统计接口及经认证连接日志;两者与 VLESS WS TLS 均需入口证书。Hysteria 2 使用所选端口的 **UDP**,AnyTLS 和 VLESS WS TLS 使用 **TCP**。AnyTLS 是独立协议,不是 VLESS 的扩展。 +全部协议均由 sing-box 承载:入口 Agent 管理 sing-box 服务、证书、统计、已认证来源 IP、失败回滚与清理。Hysteria 2 使用所选端口的 **UDP**,其余使用 **TCP**。AnyTLS 是独立协议,不是 VLESS 的扩展。 ### Cloudflare 域名和节点证书 @@ -110,17 +109,17 @@ Hysteria 2 复用当前 Xray Agent 的任务、统计及出口路由。AnyTLS Reality 默认目标/SNI 为 `www.tesla.com:443`,也可选 Amazon、Apple、Intel、AMD 或自定义。目标必须实际接受所选 SNI,预设本身不是防共享或防盗用手段;访问控制依赖每客户随机 UUID、shortId 等凭据。 -> “订阅客户端估计上限”只限制新的订阅下载:同一 User-Agent 可能是多台设备,同一设备也可能换 User-Agent,`X-Device-ID` 可以被客户端自行更改。无法凭共享节点链接可靠限制物理设备,必须另做每设备凭据签发与撤销。节点 IP 统计来自 Xray 访问日志或 sing-box 已认证连接日志,约每分钟上报一次、滚动 10 分钟;部分 UDP-only 流量可能不出现在访问日志,故不能保证精确实时 IP 上限。超限后目前是停用整个客户并排队移除其部署。订阅令牌重置只阻止再下载;已导入的节点凭据须停用或重建线路后才能撤销。 +> “订阅客户端估计上限”只限制新的订阅下载:同一 User-Agent 可能是多台设备,同一设备也可能换 User-Agent,`X-Device-ID` 可以被客户端自行更改。无法凭共享节点链接可靠限制物理设备,必须另做每设备凭据签发与撤销。节点 IP 统计来自 sing-box 已认证连接日志,约每分钟上报一次、滚动 10 分钟;部分 UDP-only 流量可能不出现在日志,故不能保证精确实时 IP 上限。超限后目前是停用整个客户并排队移除其部署。订阅令牌重置只阻止再下载;已导入的节点凭据须停用或重建线路后才能撤销。 Hysteria 2、VLESS WS TLS 和 AnyTLS 都依赖 TLS。可以不用公网 CA 而改用自签证书,但服务端仍须有证书,客户端须导入或固定证书指纹;NexusGate 当前只自动配置受信任证书路径,不会生成自签节点订阅。无需节点证书时可使用已支持的 VLESS Reality。 -“AnyTLS + VLESS”表示两段链路:客户端以 AnyTLS 接入入口机,再由入口机以 VLESS TCP 连到出口机。入口 Agent 管理 sing-box 服务、证书、统计、已认证来源 IP、失败回滚与清理;出口仍由 Xray 管理。自建 sing-box 使用固定的 1.12.23 版本和 `with_v2ray_api` 编译标签。节点 URI 被复制后仍可被使用,协议本身不会阻止共享;每设备凭据与单设备撤销尚未实现。 +“AnyTLS + VLESS”表示两段链路:客户端以 AnyTLS 接入入口机,再由入口机以 VLESS TCP 连到出口机。入口与出口 Agent 均管理 sing-box 服务、证书、统计、已认证来源 IP、失败回滚与清理。自建 sing-box 使用固定的 1.12.23 版本和 `with_v2ray_api` 编译标签。节点 URI 被复制后仍可被使用,协议本身不会阻止共享;每设备凭据与单设备撤销尚未实现。 -**引擎取舍:** 保留 Xray 现有部署并增加 sing-box AnyTLS 入口,避免替换旧资源导致端口和 Reality 密钥变化。AnyTLS 可转发到 SS2022、SS、VLESS TCP 和 SOCKS5 出口;跨公网优先使用加密的 SS2022。新节点仍须在目标 VPS 上进行真实客户端连接与双向计数验证,自动化配置测试不能替代真实网络测试。 +**引擎取舍:** 全面弃用 Xray,只用 sing-box。入口与出口统一走 sing-box 配置,流量统计走 v2ray_api 用户级计数 + AnyTLS 已认证连接日志。AnyTLS 可转发到 SS2022、SS、VLESS TCP 和 SOCKS5 出口;跨公网优先使用加密的 SS2022。新节点仍须在目标 VPS 上进行真实客户端连接与双向计数验证,自动化配置测试不能替代真实网络测试。 -如果 Clash Verge 显示“系统代理已关闭”,普通浏览器测速和播放视频可能走本机直连,节点统计仍为 0 B。测试时开启系统代理或 TUN,手动选定节点并确认客户端连接日志,再等待 Agent 一次上报(约 60 秒),刷新客户列表与客户端订阅。入口机 `ng-agent doctor` 会显示 Xray 原始入站双向计数和查询错误;若节点有连接而计数仍为空,请先检查当前设备的统计状态和 Xray 配置。Clash 智能分流模板提供 Meta 服务独立策略组,可针对 Facebook、Instagram 和 WhatsApp 选择同一出口的不同节点对比;分流规则本身不能保证速度提升。 +如果 Clash Verge 显示“系统代理已关闭”,普通浏览器测速和播放视频可能走本机直连,节点统计仍为 0 B。测试时开启系统代理或 TUN,手动选定节点并确认客户端连接日志,再等待 Agent 一次上报(约 60 秒),刷新客户列表与客户端订阅。入口机 `ng-agent doctor` 会显示 sing-box 原始入站双向计数和查询错误;若节点有连接而计数仍为空,请先检查当前设备的统计状态和 sing-box 配置。Clash 智能分流模板提供 Meta 服务独立策略组,可针对 Facebook、Instagram 和 WhatsApp 选择同一出口的不同节点对比;分流规则本身不能保证速度提升。 -额度停用在下一次上报与后台巡检后执行,测速或短时大流量可能暂时超过额度;Xray 异常重启、节点下线或删除前尚未上报的内存计数也可能丢失。本版统计适合运营额度,不是实时硬限速或精确计费账本。若客户此前始终显示 0 B,旧流量不会在升级后自动补算;从新版 Agent 首次成功上报开始核对。 +额度停用在下一次上报与后台巡检后执行,测速或短时大流量可能暂时超过额度;sing-box 异常重启、节点下线或删除前尚未上报的内存计数也可能丢失。本版统计适合运营额度,不是实时硬限速或精确计费账本。若客户此前始终显示 0 B,旧流量不会在升级后自动补算;从新版 Agent 首次成功上报开始核对。 ## 安装控制面 @@ -147,7 +146,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/a2899882/NexusGate/main/scri 4. 在“转发与节点”选择转发线路或单机直连,选择协议、设备、客户和端口策略。 5. 点击部署,在“部署与链接”复制单条客户端链接,或在“客户与订阅 → 订阅链接”获取各客户端订阅和二维码。 -订阅 URL 属于凭据;请经 HTTPS 私下交付。自动识别格式根据客户端 User-Agent 返回 Clash/Mihomo 或 Base64;识别不准时使用固定格式。智能分流包含广告拦截、国内直连、AI/流媒体分组和自动延迟选择;客户端需要可用的 Mihomo geodata。Surge 格式只导出它支持的 SS、SOCKS5 和 VMess 节点;若无兼容节点则返回错误。sing-box JSON 是本机 127.0.0.1:2080 混合入站配置,可能与已有监听端口冲突。自定义订阅模板和可靠的一设备一凭据仍待开发。 +订阅 URL 属于凭据;请经 HTTPS 私下交付。自动识别格式根据客户端 User-Agent 返回 Clash/Mihomo 或 Base64;识别不准时使用固定格式。智能分流包含广告拦截、国内直连、AI/流媒体分组和自动延迟选择;客户端需要可用的 Mihomo geodata。Surge 格式只导出它支持的 SS、SOCKS5 节点;若无兼容节点则返回错误。sing-box JSON 是本机 127.0.0.1:2080 混合入站配置,可能与已有监听端口冲突。自定义订阅模板和可靠的一设备一凭据仍待开发。 > 云安全组和系统防火墙必须放行实际使用的 TCP/UDP 端口。默认端口池为 `20000–50000`,生产环境建议按设备缩小范围。 @@ -161,6 +160,18 @@ apk add --no-cache bash curl 然后运行面板生成的同一条注册命令。安装器会自动使用 OpenRC。 +### OpenWrt 控制面 + +控制面可以直接跑在 OpenWrt 路由器上(procd 服务),节点鸡只需要装 Agent。先传安装包到路由器并解压(本地改动未 push 时不要用 GitHub 一键脚本),然后: + +```sh +opkg update && opkg install bash curl ca-certificates +tar -xzf nexusgate-*.tar.gz -C /tmp/nexusgate-release +NG_LOCAL_SRC=/tmp/nexusgate-release/NexusGate bash /tmp/nexusgate-release/NexusGate/scripts/install.sh +``` + +域名可留空:留空则不配置 Caddy,面板直接监听内网 `http://<路由器 LAN IP>:8787`(OpenWrt 防火墙默认拦截 WAN 入站);填域名则走 Caddy 反代 + 自动 HTTPS。OpenWrt 上数据文件放在 `/etc/nexusgate/data/`(`/var` 是内存盘,重启会丢数据),`ng` 管理命令用法不变(状态/日志走 `logread`)。 + ## 升级 控制面一键升级会先自动备份,再下载 GitHub `main` 分支并执行健康检查: @@ -180,7 +191,7 @@ curl -fsSL https://raw.githubusercontent.com/a2899882/NexusGate/main/scripts/age ```bash ng-agent-update ng-agent uninstall # 新版 Agent 在目标机交互卸载 -ng-agent doctor # 检查心跳、Xray 配置、服务及最近错误 +ng-agent doctor # 检查心跳、sing-box 配置、服务及最近错误 ng-agent cert # 交互式申请、续签并同步面板 ng-agent cert issue node.example.com admin@example.com ng-agent cert issue-cloudflare node.example.com admin@example.com /root/cloudflare.ini @@ -188,7 +199,7 @@ ng-agent cert import node.example.com /path/fullchain.pem /path/privkey.pem ng-agent cert status node.example.com ``` -如果某台设备已出现“上线后又离线”,先在该机运行 `ng-agent doctor`,核对控制面连通性、Xray 配置错误和服务日志。然后更新 Agent;必要时在控制面重新生成“注册 / 重装”命令并执行。注册后首次心跳失败会明确报错。 +如果某台设备已出现“上线后又离线”,先在该机运行 `ng-agent doctor`,核对控制面连通性、sing-box 配置错误和服务日志。然后更新 Agent;必要时在控制面重新生成“注册 / 重装”命令并执行。注册后首次心跳失败会明确报错。 老 Agent 没有 `ng-agent` 命令时,先运行 `ng-agent-update`,或在目标机运行: @@ -196,7 +207,7 @@ ng-agent cert status node.example.com curl -fsSL https://raw.githubusercontent.com/a2899882/NexusGate/main/scripts/agent-uninstall.sh | bash ``` -卸载仅删除 NexusGate 专属的 Agent/Xray 服务、`/etc/nexusgate` 配置与密钥、Agent 程序和日志。共用的 Node.js 和 `/usr/local/bin/xray` 不会被自动删除。卸载后回控制台删除设备;如果之前的清理任务尚未确认且设备已离线,用“遗忘离线设备”撤销登记。控制台遗忘不能远程删除旧机器资源。 +卸载仅删除 NexusGate 专属的 Agent/sing-box 服务、`/etc/nexusgate` 配置与密钥、Agent 程序和日志。共用的 Node.js 和其他服务不会被自动删除。卸载后回控制台删除设备;如果之前的清理任务尚未确认且设备已离线,用“遗忘离线设备”撤销登记。控制台遗忘不能远程删除旧机器资源。 ## `ng` 管理菜单 @@ -246,11 +257,11 @@ NexusGate 当前是单控制面、冷备恢复模型,不支持两台控制面 | 机器 | 可先试用 | 更稳妥的生产起点 | 注意 | | --- | --- | --- | --- | | 入口/中转 | 1 vCPU / 1 GB / 10 GB | 2 vCPU / 2 GB / 20 GB | 1 GB 仅限低并发试运行;HY2 UDP、AnyTLS、测速峰值和多协议并存时优先升级 | -| 落地/出口 | 1 vCPU / 1 GB / 10 GB | 2 vCPU / 2 GB / 20 GB | 合并多条线路时看同时连接数和出口总带宽;只需 Xray 的节点不安装 sing-box | +| 落地/出口 | 1 vCPU / 1 GB / 10 GB | 2 vCPU / 2 GB / 20 GB | 合并多条线路时看同时连接数和出口总带宽 | 这些是部署起点,不是“每台保证 30 人同时在线”的容量承诺。实际瓶颈常是 VPS 的共享 CPU、出入站带宽、服务商连接数限制、UDP 质量或端口安全组;应按最忙的时段观察服务器页面的可用内存/磁盘和负载,实测并发与吞吐后逐台扩容。若剩余内存持续低于 200 MB、系统盘低于 1 GB、1 分钟负载持续高于 vCPU 数,或出现 OOM、丢包与超时,就应分散线路或升级。面板不转发客户数据,40 台 Agent 空闲时约每秒 5 次任务轮询、每秒 1.3 次心跳;v0.6.8 的空轮询不再写盘,真实流量报告和客户订阅访问仍会持久化。 -节点日志每小时检查一次,超过 20 MB 提前轮转并保留 7 份;系统的 journald 有独立配额,仍需按发行版检查。`ng-agent doctor` 查看引擎/统计,`systemctl status nexusgate-agent-logrotate.timer` 查看轮转定时器。首次安装 AnyTLS 的 sing-box 统计构建需要 Go 编译资源和临时磁盘;1 GB 机器建议在有临时 swap 或更大内存的维护窗口进行,构建缓存完成后删除。控制面更新前自动备份保留最近 5 份,手动备份和恢复前快照由管理员自行归档清理。 +节点日志每小时检查一次,超过 20 MB 提前轮转并保留 7 份;系统的 journald 有独立配额,仍需按发行版检查。`ng-agent doctor` 查看引擎/统计,`systemctl status nexusgate-agent-logrotate.timer` 查看轮转定时器。首次安装的 sing-box 统计构建需要 Go 编译资源和临时磁盘;1 GB 机器建议在有临时 swap 或更大内存的维护窗口进行,构建缓存完成后删除。控制面更新前自动备份保留最近 5 份,手动备份和恢复前快照由管理员自行归档清理。 ## 轻量与安全设计 @@ -258,8 +269,8 @@ NexusGate 当前是单控制面、冷备恢复模型,不支持两台控制面 - Agent API Key 使用指纹快速定位,再用 scrypt 校验,避免大量心跳反复全表执行慢哈希。 - 数据文件、备份和 Agent 环境文件默认权限为 `0600`。 - 登录 Cookie 为 HttpOnly、SameSite=Strict,HTTPS 下启用 Secure;写操作需要 CSRF Token。 -- Xray 配置先执行 `run -test`,通过后才原子切换;失败会回滚资源文件。 -- Agent 重启会从持久化资源目录重建完整 Xray 配置。 +- sing-box 配置先执行 `check`,通过后才原子切换;失败会回滚资源文件。 +- Agent 重启会从持久化资源目录重建完整 sing-box 配置。 - 仅在你有权管理的服务器和网络上使用,并遵守所在地法律和服务商政策。 更完整的说明见 [架构](docs/ARCHITECTURE.md)、[安全模型](docs/SECURITY.md) 和 [路线图](docs/ROADMAP.md)。 @@ -277,4 +288,4 @@ npm start ## 许可 -[MIT](LICENSE)。NexusGate 不打包或重新许可 Xray-core;Agent 安装器从 Xray 官方 Release 下载独立二进制。 +[MIT](LICENSE)。 diff --git a/agent/agent.js b/agent/agent.js index 36c685b..05825ca 100644 --- a/agent/agent.js +++ b/agent/agent.js @@ -3,34 +3,25 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); +const net = require('node:net'); +const crypto = require('node:crypto'); const { spawnSync } = require('node:child_process'); const VERSION = '0.6.8'; const CONTROLLER = String(process.env.NG_CONTROLLER || '').replace(/\/+$/, ''); const AGENT_KEY = process.env.NG_AGENT_KEY || ''; -const XRAY_BIN = process.env.NG_XRAY_BIN || '/usr/local/bin/xray'; const SINGBOX_BIN = process.env.NG_SINGBOX_BIN || '/usr/local/bin/nexusgate-sing-box'; const SINGBOX_STATS_BIN = process.env.NG_SINGBOX_STATS_BIN || '/usr/local/bin/nexusgate-sing-box-stats'; -const ROOT = process.env.NG_CONFIG_DIR || '/etc/nexusgate/xray'; +const ROOT = process.env.NG_CONFIG_DIR || '/etc/nexusgate/agent'; const RESOURCE_DIR = path.join(ROOT, 'resources'); -const CONFIG_FILE = path.join(ROOT, 'config.json'); const SINGBOX_CONFIG = process.env.NG_SINGBOX_CONFIG || '/etc/nexusgate/sing-box/config.json'; const SINGBOX_LOG = process.env.NG_SINGBOX_LOG || '/var/log/nexusgate/sing-box-access.log'; const SINGBOX_CURSOR = process.env.NG_SINGBOX_CURSOR || '/etc/nexusgate/sing-box-cursor.json'; const KEY_FILE = process.env.NG_KEY_FILE || '/etc/nexusgate/keys.json'; -const ACCESS_LOG = process.env.NG_XRAY_ACCESS_LOG || '/var/log/nexusgate/xray-access.log'; -const CURSOR_FILE = process.env.NG_ACCESS_CURSOR || '/etc/nexusgate/access-cursor.json'; const POLL_MS = Math.max(3, Number(process.env.NG_POLL_SECONDS || 8)) * 1000; let lastEngineError = ''; let certificateCache = { fingerprint:'', checkedAt:0, domains:[] }; -function parseX25519(output) { - const privateKey = (output.match(/\bPrivate\s*Key\s*:\s*([A-Za-z0-9_-]+)/i) || [])[1]; - const publicKey = (output.match(/\b(?:Public\s*Key|Password\s*\(\s*Public\s*Key\s*\)|Password)\s*:\s*([A-Za-z0-9_-]+)/i) || [])[1]; - if (!privateKey || !publicKey) throw new Error('Unable to parse x25519 output (keys suppressed); update the Agent and Xray'); - return { privateKey, publicKey }; -} - function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function log(...args) { console.log(new Date().toISOString(), ...args); } function safeError(value) { @@ -71,11 +62,11 @@ function saveKeys(keys) { function realityKey(keyId) { const keys = loadKeys(); if (keys[keyId]) return keys[keyId]; - const result = spawnSync(XRAY_BIN, ['x25519'], { encoding: 'utf8', timeout: 15000 }); - if (result.status !== 0) throw new Error('xray x25519 failed; run ng-agent doctor to inspect Xray locally'); - const output = `${result.stdout}\n${result.stderr}`; - const { privateKey, publicKey } = parseX25519(output); - keys[keyId] = { privateKey, publicKey, createdAt: new Date().toISOString() }; + const kp = crypto.generateKeyPairSync('x25519'); + const priv = kp.privateKey.export({ format: 'jwk' }); + const pub = kp.publicKey.export({ format: 'jwk' }); + if (!priv.d || !pub.x) throw new Error('X25519 密钥生成失败'); + keys[keyId] = { privateKey: priv.d, publicKey: pub.x, createdAt: new Date().toISOString() }; saveKeys(keys); return keys[keyId]; } @@ -95,49 +86,33 @@ function readResources() { return fs.readdirSync(RESOURCE_DIR).filter((name) => name.endsWith('.json')).sort().map((name) => JSON.parse(fs.readFileSync(path.join(RESOURCE_DIR, name), 'utf8'))); } -function compatibleXrayInbound(inbound) { - if (inbound.protocol !== 'hysteria') return inbound; - // Older Agent resources contain only `method` and `users`. Xray 26.3.27 - // silently ignores both fields, falling back to TCP with no HY2 accounts. - // Repair the effective configuration without changing the saved resource, - // port, password or the customer's existing subscription link. - const accounts = inbound.settings?.clients || inbound.settings?.users; - if (!Array.isArray(accounts) || !accounts.length || accounts.some((user) => !user.auth)) - throw new Error('Hysteria 2 入口没有认证账户;拒绝启动未受保护的入口'); - return { ...inbound, - settings: { ...inbound.settings, clients: accounts, users: accounts }, - streamSettings: { ...inbound.streamSettings, network:'hysteria', method:'hysteria' } }; -} - -function combinedConfig(resources) { - const apiPort = Number(process.env.NG_XRAY_API_PORT || 10085); - const config = { - log: { loglevel: process.env.NG_XRAY_LOG_LEVEL || 'warning', access: '/var/log/nexusgate/xray-access.log', error: '/var/log/nexusgate/xray-error.log' }, - api: { tag: 'api', services: ['StatsService'] }, - stats: {}, - policy: { levels: { '0': { statsUserUplink: true, statsUserDownlink: true } }, system: { statsInboundUplink: true, statsInboundDownlink: true } }, - inbounds: [{ tag: 'api-in', listen: '127.0.0.1', port: apiPort, protocol: 'dokodemo-door', settings: { address: '127.0.0.1' } }], - outbounds: [], - routing: { domainStrategy: 'AsIs', rules: [{ type: 'field', inboundTag: ['api-in'], outboundTag: 'api' }] } - }; - for (const resource of resources) { - if (resource.engine === 'sing-box') continue; - config.inbounds.push(...(resource.inbounds || []).map(compatibleXrayInbound)); - config.outbounds.push(...(resource.outbounds || [])); - config.routing.rules.push(...(resource.routingRules || [])); +// 云机器的公网 IP 通常是 NAT 地址,不在本地网卡上;直接 bind 会 +// "cannot assign requested address" 导致引擎起不来。非本地地址回退到对应栈的通配符。 +function normalizeInboundListen(inbound) { + if (!inbound || typeof inbound !== 'object') return inbound; + const text = typeof inbound.listen === 'string' ? inbound.listen.trim() : ''; + if (!text || !net.isIP(text) || text === '0.0.0.0' || text === '::') return inbound; + let local = false; + for (const list of Object.values(os.networkInterfaces())) { + for (const info of list || []) if (info && info.address === text) { local = true; break; } + if (local) break; } - return config; + if (local) return inbound; + const fallback = net.isIPv6(text) ? '::' : '0.0.0.0'; + log(`Listen ${text} 不是本机地址,回退为 ${fallback}`); + return { ...inbound, listen: fallback }; } function combinedSingBoxConfig(resources) { - const selected = resources.filter((item) => item.engine === 'sing-box'); return { log: { level: 'info', output: SINGBOX_LOG, timestamp: true }, - inbounds: selected.flatMap((item) => item.inbounds || []), - outbounds: [{ type: 'direct', tag: 'ng-fallback-direct' }, ...selected.flatMap((item) => item.outbounds || [])], - route: { rules: selected.flatMap((item) => item.routingRules || []), final: 'ng-fallback-direct' }, + inbounds: resources.flatMap((item) => item.inbounds || []).map(normalizeInboundListen), + outbounds: [{ type: 'direct', tag: 'ng-fallback-direct' }, ...resources.flatMap((item) => item.outbounds || [])], + route: { rules: resources.flatMap((item) => item.routingRules || []), final: 'ng-fallback-direct' }, experimental: { v2ray_api: { listen: `127.0.0.1:${process.env.NG_SINGBOX_API_PORT || 10086}`, - stats: { enabled: true, inbounds: selected.flatMap((item) => item.inbounds || []).map((item) => item.tag) } } } + stats: { enabled: true, inbounds: resources.flatMap((item) => item.inbounds || []).map((item) => item.tag), + // sing-box 只对显式列出的用户维护 user>>> 计数器;AnyTLS/SS 用 name,VLESS 用 name,SOCKS5 用 username。 + users: resources.flatMap((item) => item.inbounds || []).flatMap((item) => item.users || []).map((item) => item.name || item.username).filter(Boolean) } } } }; } @@ -147,23 +122,17 @@ function run(command, args, timeout = 30000) { return result.stdout; } -function restartXray() { - if (fs.existsSync('/run/systemd/system')) return run('systemctl', ['restart', 'nexusgate-xray.service']); - return run('rc-service', ['nexusgate-xray', 'restart']); -} - function serviceCommand(action, service) { if (fs.existsSync('/run/systemd/system')) return run('systemctl', [action, `${service}.service`]); return run('rc-service', [service, action]); } function activateSingBox() { - const resources = readResources().filter((item) => item.engine === 'sing-box'); + const resources = readResources(); if (!resources.length) { - if (fs.existsSync(SINGBOX_CONFIG)) { - serviceCommand('stop', 'nexusgate-sing-box'); - fs.rmSync(SINGBOX_CONFIG, { force: true }); - } + // 无资源时必须确保引擎停下(不能只在配置文件存在时停,否则全新安装会陷入重启循环) + try { serviceCommand('stop', 'nexusgate-sing-box'); } catch { /* 已经停了 */ } + if (fs.existsSync(SINGBOX_CONFIG)) fs.rmSync(SINGBOX_CONFIG, { force: true }); return; } if (!fs.existsSync(SINGBOX_BIN) || !fs.existsSync(SINGBOX_STATS_BIN)) throw new Error('sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install'); @@ -186,56 +155,17 @@ function activateSingBox() { } } -function activateResourceEngine(engine) { return engine === 'sing-box' ? activateSingBox() : activateConfig(); } - -function activateConfig() { - // Xray detects JSON from the file extension; a .candidate suffix is rejected. - const candidate = `${CONFIG_FILE}.candidate.json`; - const previous = `${CONFIG_FILE}.previous`; - const resources = readResources(); - const nextConfig = `${JSON.stringify(combinedConfig(resources), null, 2)}\n`; - if (!resources.some((item) => item.engine !== 'sing-box')) { - // An AnyTLS-only entry does not need a resident Xray process. Keep a - // minimal, safe config so a manual service start cannot revive old ports. - if (!fs.existsSync(CONFIG_FILE) || fs.readFileSync(CONFIG_FILE, 'utf8') !== nextConfig) { - fs.writeFileSync(candidate, nextConfig, { mode: 0o600 }); - run(XRAY_BIN, ['run', '-test', '-config', candidate]); - fs.renameSync(candidate, CONFIG_FILE); - } - if (serviceHealth('nexusgate-xray').status === 'ready') serviceCommand('stop', 'nexusgate-xray'); - return; - } - // An Agent update must not restart a healthy Xray with identical configuration: - // its in-memory traffic counters would be discarded before the next report. - if (fs.existsSync(CONFIG_FILE) && fs.readFileSync(CONFIG_FILE, 'utf8') === nextConfig && - engineHealth().status === 'ready') return; - fs.writeFileSync(candidate, nextConfig, { mode: 0o600 }); - run(XRAY_BIN, ['run', '-test', '-config', candidate]); - if (fs.existsSync(CONFIG_FILE)) fs.copyFileSync(CONFIG_FILE, previous); - fs.renameSync(candidate, CONFIG_FILE); - try { - restartXray(); - assertHysteriaListeners(resources); - } catch (error) { - if (fs.existsSync(previous)) { - fs.copyFileSync(previous, CONFIG_FILE); - try { restartXray(); } catch { /* preserve original error */ } - } - throw error; - } -} - function safeResourcePath(resourceId) { if (!/^[a-zA-Z0-9_-]{8,100}$/.test(String(resourceId))) throw new Error('Invalid resource id'); return path.join(RESOURCE_DIR, `${resourceId}.json`); } -async function flushUsageBeforeChange(engine) { - const live = readResources().some((resource) => (resource.engine || 'xray') === engine && - resource.meta?.metricsTag && ['relay', 'direct'].includes(resource.meta.kind)); - if (!live || serviceHealth(engine === 'sing-box' ? 'nexusgate-sing-box' : 'nexusgate-xray').status !== 'ready') return; +async function flushUsageBeforeChange() { + const live = readResources().some((resource) => + resource.meta?.metricsTag && resource.meta.kind === 'node'); + if (!live || serviceHealth('nexusgate-sing-box').status !== 'ready') return; const report = queryUsage(); - if (report.engineErrors.includes(engine)) throw new Error(`${engine} 重载前无法读取入口计数;请先运行 ng-agent doctor`); + if (report.engineErrors.includes('sing-box')) throw new Error('sing-box 重载前无法读取入口计数;请先运行 ng-agent doctor'); // Await the controller's durable write before a restart destroys the // in-memory counters. The periodic loop can report concurrently; the // controller ignores repeated and out-of-order samples for one process. @@ -243,50 +173,50 @@ async function flushUsageBeforeChange(engine) { } async function flushAllUsageBeforeRestart() { - for (const engine of ['xray', 'sing-box']) await flushUsageBeforeChange(engine); + await flushUsageBeforeChange(); } async function applyResource(payload) { if (!payload || !payload.resource || !payload.resource.id) throw new Error('Missing resource payload'); const artifacts = {}; const resource = materialize(payload.resource, artifacts); - if (resource.engine && resource.engine !== 'sing-box') throw new Error(`Unsupported engine: ${resource.engine}`); + if (resource.engine && resource.engine !== 'sing-box') + throw new Error(`Unsupported engine: ${resource.engine}(Xray 已弃用,只支持 sing-box)`); for (const inbound of resource.inbounds || []) { - const tls = resource.engine === 'sing-box' ? inbound.tls : inbound.streamSettings && inbound.streamSettings.tlsSettings; + // Hysteria 2 无认证账户时拒绝启动未受保护的入口。 + if (inbound.type === 'hysteria2') { + const users = inbound.users || []; + if (!users.length || users.some((user) => !user.password)) + throw new Error('Hysteria 2 入口没有认证账户;拒绝启动未受保护的入口'); + } + const tls = inbound.tls; if (!tls) continue; - const domain = resource.engine === 'sing-box' ? tls.server_name : tls.serverName; + if (tls.reality && tls.reality.enabled) continue; // Reality 无证书,不走证书检查 + const domain = tls.server_name; if (!/^(?:[a-z0-9-]+\.)+[a-z]{2,63}$/.test(domain || '')) throw new Error('TLS 域名无效'); const expected = `/etc/nexusgate/tls/${domain}/`; - const certs = resource.engine === 'sing-box' - ? [{ certificateFile: tls.certificate_path, keyFile: tls.key_path }] : tls.certificates || []; - for (const cert of certs) { - if (!cert.certificateFile.startsWith(expected) || !cert.keyFile.startsWith(expected) || - !fs.existsSync(cert.certificateFile) || !fs.existsSync(cert.keyFile)) { - throw new Error(`TLS certificate for ${domain} is missing; run ng-agent cert issue ${domain} EMAIL or ng-agent cert import`); - } - run('openssl', ['x509', '-in', cert.certificateFile, '-noout', '-checkend', '86400']); - const nameCheck = run('openssl', ['x509', '-in', cert.certificateFile, '-noout', '-checkhost', domain]); - if (!nameCheck.includes('does match')) throw new Error(`TLS certificate does not match ${domain}`); + const cert = { certificateFile: tls.certificate_path, keyFile: tls.key_path }; + if (!cert.certificateFile || !cert.keyFile) + throw new Error(`TLS certificate for ${domain} is missing; run ng-agent cert issue ${domain} EMAIL or ng-agent cert import`); + if (!cert.certificateFile.startsWith(expected) || !cert.keyFile.startsWith(expected) || + !fs.existsSync(cert.certificateFile) || !fs.existsSync(cert.keyFile)) { + throw new Error(`TLS certificate for ${domain} is missing; run ng-agent cert issue ${domain} EMAIL or ng-agent cert import`); } + run('openssl', ['x509', '-in', cert.certificateFile, '-noout', '-checkend', '86400']); + const nameCheck = run('openssl', ['x509', '-in', cert.certificateFile, '-noout', '-checkhost', domain]); + if (!nameCheck.includes('does match')) throw new Error(`TLS certificate does not match ${domain}`); } const target = safeResourcePath(resource.id); const backup = fs.existsSync(target) ? fs.readFileSync(target) : null; - const oldEngine = backup ? JSON.parse(backup).engine || 'xray' : null; - const newEngine = resource.engine || 'xray'; - await flushUsageBeforeChange(newEngine); - if (oldEngine && oldEngine !== newEngine) await flushUsageBeforeChange(oldEngine); + await flushUsageBeforeChange(); fs.writeFileSync(target, `${JSON.stringify(resource, null, 2)}\n`, { mode: 0o600 }); try { - activateResourceEngine(newEngine); - if (oldEngine !== newEngine && oldEngine) activateResourceEngine(oldEngine); + activateSingBox(); lastEngineError = ''; } catch (error) { if (backup) fs.writeFileSync(target, backup, { mode: 0o600 }); else fs.rmSync(target, { force: true }); - try { activateResourceEngine(newEngine); } catch { /* preserve original error */ } - if (oldEngine && oldEngine !== newEngine) { - try { activateResourceEngine(oldEngine); } catch { /* preserve original error */ } - } + try { activateSingBox(); } catch { /* preserve original error */ } throw error; } return { artifacts }; @@ -296,10 +226,9 @@ async function deleteResource(payload) { const target = safeResourcePath(payload.resourceId); if (!fs.existsSync(target)) return { alreadyAbsent: true }; const backup = fs.readFileSync(target); - const engine = JSON.parse(backup).engine || 'xray'; - await flushUsageBeforeChange(engine); + await flushUsageBeforeChange(); fs.rmSync(target); - try { activateResourceEngine(engine); } + try { activateSingBox(); } catch (error) { fs.writeFileSync(target, backup, { mode: 0o600 }); throw error; } return { removed: true }; } @@ -343,28 +272,28 @@ function systemInfo() { function engineHealth() { const resources = readResources(); - const xray = resources.some((item) => item.engine !== 'sing-box') ? serviceHealth('nexusgate-xray') - : { status:'ready', detail:'无 Xray 资源,已停止空闲进程' }; - if (xray.status === 'ready') { + const installed = fs.existsSync(SINGBOX_BIN) && fs.existsSync(SINGBOX_STATS_BIN); + const service = serviceHealth('nexusgate-sing-box'); + const result = { + status: installed ? service.status : 'error', + detail: installed ? service.detail : 'sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install', + singBoxInstalled: installed, + certificates: installedCertificates(), + singBoxStatus: service.status, + }; + if (result.status === 'ready' && resources.length) { try { const missing = missingHysteriaListeners(resources); if (missing.length) { - xray.status = 'error'; - xray.detail = `Hysteria 2 UDP 未监听:${missing.join(', ')}`; + result.status = 'error'; + result.detail = `Hysteria 2 UDP 未监听:${missing.join(', ')}`; } } catch (error) { - xray.status = 'error'; - xray.detail = safeError(error.message); + result.status = 'error'; + result.detail = safeError(error.message); } } - xray.singBoxInstalled = fs.existsSync(SINGBOX_BIN) && fs.existsSync(SINGBOX_STATS_BIN); - xray.certificates = installedCertificates(); - if (resources.some((item) => item.engine === 'sing-box')) { - const singbox = serviceHealth('nexusgate-sing-box'); - xray.singBoxStatus = singbox.status; - if (singbox.status !== 'ready') return { ...xray, status: 'error', detail: `sing-box: ${singbox.detail}` }; - } - return xray; + return result; } function installedCertificates() { @@ -414,16 +343,18 @@ async function heartbeat() { catch (error) { log('Heartbeat reached controller but readiness file failed', error.message); } } -function parseUsageStats(output, resources) { +function parseUsageStats(output) { const parsed = JSON.parse(output); - if (!parsed || typeof parsed !== 'object' || (parsed.stat !== undefined && !Array.isArray(parsed.stat))) + // 零流量时 Go 端输出 {"stat":null},视为无数据而非格式错误。 + const statList = parsed && typeof parsed === 'object' ? (parsed.stat == null ? [] : parsed.stat) : null; + if (!Array.isArray(statList)) throw new Error('统计接口返回的 JSON stat 格式无效'); - const expected = new Set(resources.flatMap((resource) => ['uplink', 'downlink'] - .map((direction) => `inbound>>>${resource.meta.metricsTag}>>>traffic>>>${direction}`))); - const byName = new Map(); - for (const stat of parsed.stat || []) { - if (!expected.has(stat.name)) continue; - // Protobuf JSON omits a scalar with its default value (zero). Xray can + const inbounds = new Map(), users = new Map(); + for (const stat of statList) { + const inboundMatch = /^inbound>>>(.+)>>>traffic>>>(uplink|downlink)$/.exec(stat.name || ''); + const userMatch = inboundMatch ? null : /^user>>>(.+)>>>traffic>>>(uplink|downlink)$/.exec(stat.name || ''); + if (!inboundMatch && !userMatch) continue; + // Protobuf JSON omits a scalar with its default value (zero). The engine can // therefore return a named counter without a `value` field until traffic. const value = Object.hasOwn(stat, 'value') ? Number(stat.value) : 0; if (!Number.isSafeInteger(value) || value < 0 || stat.value === null || stat.value === '') { @@ -431,16 +362,20 @@ function parseUsageStats(output, resources) { ? (stat.value.startsWith('-') ? '负数' : '超出安全整数范围') : `格式 ${typeof stat.value}`; throw new Error(`入口计数异常(${reason}):${stat.name};请运行 ng-agent doctor 查看原始统计`); } - byName.set(stat.name, value); + const bucket = inboundMatch ? inbounds : users; + const key = (inboundMatch || userMatch)[1]; + const direction = (inboundMatch || userMatch)[2]; + let entry = bucket.get(key); + if (!entry) { entry = { uplink: 0, downlink: 0 }; bucket.set(key, entry); } + entry[direction] = value; } - return resources.flatMap((resource) => { - const prefix = `inbound>>>${resource.meta.metricsTag}>>>traffic>>>`; - if (!byName.has(`${prefix}uplink`) && !byName.has(`${prefix}downlink`)) return []; - return [{ resourceId: resource.id, uplink: byName.get(`${prefix}uplink`) || 0, downlink: byName.get(`${prefix}downlink`) || 0 }]; - }); + return { + inbounds: [...inbounds].map(([tag, counters]) => ({ tag, ...counters })), + users: [...users].map(([name, counters]) => ({ name, ...counters })), + }; } -function serviceEpoch(service = 'nexusgate-xray') { +function serviceEpoch(service = 'nexusgate-sing-box') { let pid; if (fs.existsSync('/run/systemd/system')) { const result = spawnSync('systemctl', ['show', '-p', 'MainPID', '--value', `${service}.service`], { encoding:'utf8', timeout:3000 }); @@ -450,7 +385,7 @@ function serviceEpoch(service = 'nexusgate-xray') { } if (!Number.isSafeInteger(pid) || pid <= 0) return null; try { - // PID alone can be reused; the Linux process start tick distinguishes a new Xray instance. + // PID alone can be reused; the Linux process start tick distinguishes a new sing-box instance. const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); const startTick = stat.slice(stat.lastIndexOf(') ') + 2).trim().split(/\s+/)[19]; return startTick ? `${pid}:${startTick}` : null; @@ -478,12 +413,11 @@ function udpPortsForPid(pid) { } function missingHysteriaListeners(resources) { - const expected = [...new Set(resources.filter((item) => item.engine !== 'sing-box') - .flatMap((item) => item.inbounds || []).filter((item) => item.protocol === 'hysteria') - .map((item) => item.port))]; + const expected = [...new Set(resources.flatMap((item) => item.inbounds || []) + .filter((item) => item.type === 'hysteria2').map((item) => item.listen_port))]; if (!expected.length) return []; - const epoch = serviceEpoch(); - if (!epoch) throw new Error('无法确认 Xray 进程,无法验证 Hysteria 2 UDP 监听'); + const epoch = serviceEpoch('nexusgate-sing-box'); + if (!epoch) throw new Error('无法确认 sing-box 进程,无法验证 Hysteria 2 UDP 监听'); const actual = udpPortsForPid(Number(epoch.split(':')[0])); return expected.filter((port) => !actual.has(port)); } @@ -498,33 +432,45 @@ function assertHysteriaListeners(resources) { if (attempt < 9) spawnSync('sleep', ['0.2']); } const missing = missingHysteriaListeners(resources); - throw new Error(`Hysteria 2 未监听 UDP ${missing.join(', ')};检查 Xray 配置与本机端口冲突`); + throw new Error(`Hysteria 2 未监听 UDP ${missing.join(', ')};检查 sing-box 配置与本机端口冲突`); } function queryUsage() { // Count client ingress once; counting the exit as well doubles forwarded traffic. const resources = readResources().filter((resource) => resource.meta && resource.meta.metricsTag && - ['relay', 'direct'].includes(resource.meta.kind)); + resource.meta.kind === 'node'); const samples = [], errors = [], engineErrors = []; - for (const engine of ['xray', 'sing-box']) { - const selected = resources.filter((item) => (item.engine || 'xray') === engine); - if (!selected.length) continue; - const service = engine === 'xray' ? 'nexusgate-xray' : 'nexusgate-sing-box'; - const before = serviceEpoch(service); - const port = engine === 'xray' ? (process.env.NG_XRAY_API_PORT || 10085) : (process.env.NG_SINGBOX_API_PORT || 10086); - const binary = engine === 'xray' ? XRAY_BIN : SINGBOX_STATS_BIN; - const args = engine === 'xray' ? ['api', 'statsquery', `--server=127.0.0.1:${port}`, '-pattern', 'inbound>>>'] - : [`--server=127.0.0.1:${port}`]; - const result = spawnSync(binary, args, - { encoding: 'utf8', timeout: 15000 }); - const after = serviceEpoch(service); - if (before && after && before !== after) { errors.push(`${engine} restarted during statistics query`); engineErrors.push(engine); continue; } - if (result.status !== 0) { errors.push(`${engine} statsquery failed: ${safeError(result.stderr || result.error?.message || result.stdout).slice(0, 160)}`); engineErrors.push(engine); continue; } + if (!resources.length) return { samples, error: null, engineErrors }; + const service = 'nexusgate-sing-box'; + const before = serviceEpoch(service); + const port = process.env.NG_SINGBOX_API_PORT || 10086; + const result = spawnSync(SINGBOX_STATS_BIN, [`--server=127.0.0.1:${port}`], { encoding: 'utf8', timeout: 15000 }); + const after = serviceEpoch(service); + let failed = false; + if (before && after && before !== after) { errors.push('sing-box restarted during statistics query'); engineErrors.push('sing-box'); failed = true; } + else if (result.status !== 0) { errors.push(`sing-box statsquery failed: ${safeError(result.stderr || result.error?.message || result.stdout).slice(0, 160)}`); engineErrors.push('sing-box'); failed = true; } + const parsed = { inbounds: [], users: [] }; + if (!failed) { try { - const parsed = parseUsageStats(result.stdout, selected); - samples.push(...parsed.map((item) => ({ ...item, epoch: `${engine}:${before || after || 'unknown'}` }))); - if (!parsed.length) errors.push(`${engine} 入口尚无统计记录`); - } catch (error) { errors.push(`${engine}: ${safeError(error.message)}`); engineErrors.push(engine); } + const part = parseUsageStats(result.stdout); + parsed.inbounds.push(...part.inbounds); + parsed.users.push(...part.users); + } catch (error) { errors.push(`sing-box: ${safeError(error.message)}`); engineErrors.push('sing-box'); failed = true; } + } + if (!failed) { + const epoch = `sing-box:${before || serviceEpoch(service) || 'unknown'}`; + const tagNodes = new Map(resources.map((item) => [item.meta.metricsTag, item.meta.nodeId])); + for (const inbound of parsed.inbounds) { + const nodeId = tagNodes.get(inbound.tag); + if (!nodeId) continue; + samples.push({ kind: 'inbound', id: nodeId, uplink: inbound.uplink, downlink: inbound.downlink, epoch }); + } + for (const user of parsed.users) { + const match = /^ng:(.+)$/.exec(user.name); + if (!match) continue; + samples.push({ kind: 'user', id: match[1], uplink: user.uplink, downlink: user.downlink, epoch }); + } + if (!parsed.inbounds.length && !parsed.users.length) errors.push('sing-box 入口尚无统计记录'); } return { samples, error: errors.join('; ') || null, engineErrors }; } @@ -542,59 +488,27 @@ function readSingBoxObservations() { try { fs.readSync(fd, buffer, 0, buffer.length, start); } finally { fs.closeSync(fd); } const end = buffer.lastIndexOf(10); if (end < 0) return { observations: [], offset: null }; - const tagCustomers = new Map(readResources().filter((item) => item.engine === 'sing-box' && - ['relay', 'direct'].includes(item.meta?.kind)).map((item) => [item.meta.metricsTag, item.meta.customerId])); + // AnyTLS 用户名本身就是 ng:,身份直接写在日志行里,无需 tag 映射。 + const entryTags = new Set(readResources().filter((item) => + item.meta?.kind === 'node').map((item) => item.meta.metricsTag)); const sourceById = new Map(), unique = new Map(); for (const line of buffer.subarray(0, end).toString('utf8').split('\n')) { const context = line.match(/\[(\d+)\s+\d+ms\]\s+inbound\/anytls\[([^\]]+)\]:\s+(.*)$/); - if (!context || !tagCustomers.has(context[2])) continue; + if (!context || !entryTags.has(context[2])) continue; const key = `${context[2]}:${context[1]}`; const source = context[3].match(/inbound connection from\s+(\[[^\]]+\]|[\d.]+):\d+/); if (source) sourceById.set(key, source[1].replace(/^\[|\]$/g, '')); // Only count authenticated AnyTLS sessions, never unauthenticated port probes. - if (/\[ng:[^\]]+\] inbound connection to /.test(context[3]) && sourceById.has(key)) { - const customerId = tagCustomers.get(context[2]); - if (context[3].includes(`[ng:${customerId}]`)) { - const ip = sourceById.get(key); - unique.set(`${customerId}|${ip}`, { customerId, ip }); - } + const authed = context[3].match(/\[ng:([^\]]+)\] inbound connection to /); + if (authed && sourceById.has(key)) { + const customerId = authed[1]; + const ip = sourceById.get(key); + unique.set(`${customerId}|${ip}`, { customerId, ip }); } } return { observations: [...unique.values()], offset: start + end + 1 }; } -function readObservations() { - if (!fs.existsSync(ACCESS_LOG)) return { observations: [], offset: null }; - let cursor = { offset: 0 }; - try { cursor = JSON.parse(fs.readFileSync(CURSOR_FILE, 'utf8')); } catch { /* first read */ } - const stat = fs.statSync(ACCESS_LOG); - if (stat.size < cursor.offset) cursor.offset = 0; - const start = Math.max(cursor.offset, stat.size - 2 * 1024 * 1024); - if (start >= stat.size) return { observations: [], offset: null }; - const length = stat.size - start; - const buffer = Buffer.alloc(length); - const fd = fs.openSync(ACCESS_LOG, 'r'); - try { fs.readSync(fd, buffer, 0, length, start); } finally { fs.closeSync(fd); } - const finalNewline = buffer.lastIndexOf(10); - if (finalNewline < 0) return { observations: [], offset: null }; - const offset = start + finalNewline + 1; - // The exit inbound sees relay server IPs, not client IPs. Never count those as customer devices. - const tagCustomers = new Map(readResources().filter((item) => item.meta && - ['relay', 'direct'].includes(item.meta.kind) && item.meta.metricsTag && item.meta.customerId) - .map((item) => [item.meta.metricsTag, item.meta.customerId])); - const unique = new Map(); - for (const line of buffer.subarray(0, finalNewline).toString('utf8').split('\n')) { - const source = line.match(/(?:from|accepted from)\s+(?:tcp|udp):(?:\[([^\]]+)\]|([^:\s]+)):\d+/i) - || line.match(/\s(?:tcp:)?(?:\[([^\]]+)\]|((?:\d{1,3}\.){3}\d{1,3})):\d+\s+accepted\s/i); - if (!source) continue; - const ip = source[1] || source[2]; - for (const [tag, customerId] of tagCustomers) { - if (line.includes(`[${tag} ->`) || line.includes(`[${tag}]`)) unique.set(`${customerId}|${ip}`, { customerId, ip }); - } - } - return { observations: [...unique.values()], offset }; -} - async function usageLoop() { while (true) { await sleep(60000); @@ -605,12 +519,9 @@ async function usageLoop() { await request('/api/agent/usage', { method: 'POST', body: JSON.stringify(report) }); } catch (error) { log('Usage report failed', safeError(error.message)); } try { - const { observations, offset } = readObservations(); - if (observations.length) await request('/api/agent/observations', { method: 'POST', body: JSON.stringify({ observations }) }); - // A failed upload must not advance the cursor; retry the same log lines next cycle. - if (offset !== null) fs.writeFileSync(CURSOR_FILE, `${JSON.stringify({ offset })}\n`, { mode: 0o600 }); const sing = readSingBoxObservations(); if (sing.observations.length) await request('/api/agent/observations', { method: 'POST', body: JSON.stringify({ observations: sing.observations }) }); + // A failed upload must not advance the cursor; retry the same log lines next cycle. if (sing.offset !== null) fs.writeFileSync(SINGBOX_CURSOR, `${JSON.stringify({ offset: sing.offset })}\n`, { mode: 0o600 }); } catch (error) { log('IP observation report failed', safeError(error.message)); } } @@ -632,15 +543,6 @@ async function main() { log(`NexusGate Agent v${VERSION} starting`); // Keep the control channel alive even when a stale node configuration cannot start. // The administrator can then see the engine error and a repair job can be claimed. - try { - // A first upgrade from the old installer can restart Xray before the new - // updater's preflight runs. Persist the old process counters here as well. - const resources = readResources(); - const next = `${JSON.stringify(combinedConfig(resources), null, 2)}\n`; - if (fs.existsSync(CONFIG_FILE) && fs.readFileSync(CONFIG_FILE, 'utf8') !== next) - await flushUsageBeforeChange('xray'); - activateConfig(); - } catch (error) { lastEngineError = safeError(error.message); log('Initial Xray activation failed', lastEngineError); } try { activateSingBox(); } catch (error) { lastEngineError = safeError(error.message); log('Initial sing-box activation failed', lastEngineError); } try { await heartbeat(); } catch (error) { log('Initial heartbeat failed', error.message); } setInterval(() => heartbeat().catch((error) => log('Heartbeat failed', error.message)), 30000).unref(); @@ -653,6 +555,6 @@ if (require.main === module) { action.catch((error) => { console.error(safeError(error.message)); process.exitCode = 1; }); } -module.exports = { parseX25519, activateConfig, activateSingBox, combinedConfig, combinedSingBoxConfig, applyResource, - readObservations, readSingBoxObservations, parseUsageStats, queryUsage, installedCertificates, +module.exports = { activateSingBox, combinedSingBoxConfig, applyResource, realityKey, + readSingBoxObservations, parseUsageStats, queryUsage, installedCertificates, udpPortsForPid, missingHysteriaListeners, systemInfo }; diff --git a/agent/stats-query.go b/agent/stats-query.go index f1a1f81..e701f94 100644 --- a/agent/stats-query.go +++ b/agent/stats-query.go @@ -24,8 +24,10 @@ func main() { if err != nil { fail(err) } defer conn.Close() var response v2rayapi.QueryStatsResponse + // Query both inbound-level and user-level stats (sing-box requires users + // to be explicitly listed in the v2ray_api stats config). err = conn.Invoke(ctx, "/v2ray.core.app.stats.command.StatsService/QueryStats", - &v2rayapi.QueryStatsRequest{Patterns: []string{"inbound>>>"}}, &response) + &v2rayapi.QueryStatsRequest{Patterns: []string{"inbound>>>", "user>>>"}}, &response) if err != nil { fail(err) } result := struct { Stat []struct { Name string `json:"name"`; Value string `json:"value"` } `json:"stat"` }{} for _, stat := range response.Stat { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2e6bc94..3ec1360 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,8 +6,8 @@ 2. 编排器为每位客户生成独立凭据,并从各服务器端口池分配端口。 3. 转发线路为每位客户生成一个出口资源和一个或多个入口资源;单机直连只生成入口资源。 4. Agent 通过 HTTPS 主动轮询任务,控制面无需登录目标机。 -5. Agent 把资源片段写入 `/etc/nexusgate/xray/resources/`,合并为完整配置。 -6. Agent 运行 `xray run -test`;只有通过校验才替换生效配置并重启 Xray。 +5. Agent 把资源片段写入 `/etc/nexusgate/agent/resources/`,合并为完整 sing-box 配置。 +6. Agent 运行 `sing-box check`;只有通过校验才替换生效配置并重启 sing-box。 7. Agent 回报 Reality 公钥、任务结果、心跳、流量增量和 IP 观察。 ## 数据路径 @@ -37,7 +37,7 @@ flowchart LR ## 故障行为 - 新配置校验失败:删除或恢复刚修改的资源片段,不切换运行配置。 -- Xray 重启失败:恢复上一份完整配置并再次尝试启动。 +- sing-box 重启失败:恢复上一份完整配置并再次尝试启动。 - Agent 离线:控制面保留任务;运行任务租约超时后自动重试,最多 3 次。 - Agent 重新注册:撤销旧密钥,为该设备重新生成已有部署的对账任务。 - 任务失败:部署标记为失败、线路标记为异常,可只修复失败项或完整重建。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 455c4d6..74252d9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -22,7 +22,7 @@ ## v0.4 — 订阅、诊断与 TLS(已完成) -- Agent 心跳与 Xray 启动故障隔离;首次心跳验证、`ng-agent doctor`、部署前在线检查 +- Agent 心跳与 sing-box 启动故障隔离;首次心跳验证、`ng-agent doctor`、部署前在线检查 - 紧凑管理列表与本地二维码;Mihomo 智能分流和 Surge 兼容节点输出 - VLESS WS TLS:节点域名、证书签发或导入、自动续签钩子和下发前验证 @@ -31,7 +31,7 @@ - sing-box 独立服务及 `with_v2ray_api` 构建安装器 - AnyTLS 直连、AnyTLS → VLESS TCP / SS2022 等转发及客户端订阅 - TLS 证书校验与续签、独立双向计数和经认证来源 IP 观察 -- Xray 与 sing-box 分别下发及失败回滚,原资源保留 +- sing-box 下发及失败回滚,原资源保留 ## 后续:节点协议、凭据和规模 @@ -39,7 +39,7 @@ - 可编辑的自定义订阅模板 - Hysteria2 和 AnyTLS 的真实客户端兼容性与跨发行版压力测试 - VLESS XHTTP/gRPC、Trojan、TUIC 等协议适配器 -- Xray/sing-box Release 校验和验证与分批升级 +- sing-box Release 校验和验证与分批升级 - 控制面敏感字段静态加密 - 出口池、延迟/可用性探测、加权分流与故障转移 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 414a4cb..ec7b539 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -4,7 +4,7 @@ - 控制面管理员是高权限主体,可以查看客户链接并下发配置。 - Agent 拥有其绑定服务器的配置权限,但不能领取其他服务器任务。 -- Xray-core 与 sing-box 分别运行;控制面不承载客户数据流量。 +- sing-box 独立运行;控制面不承载客户数据流量。 - 服务器 root 用户仍可读取本机凭据和流量日志,这是自托管系统无法消除的权限边界。 ## 已实现控制 @@ -24,12 +24,12 @@ ## 当前限制 - 客户协议凭据和待执行配置任务保存在权限为 `0600` 的控制面数据文件中,尚未做字段级静态加密。备份同样包含这些凭据。 -- 节点 IP 上限依赖 Xray 访问日志及 sing-box 已认证会话日志的滚动观察,并非精确的同时在线连接数;修改日志格式、关闭访问日志、UDP-only 流量或客户端经过同一 NAT 会影响结果。 +- 节点 IP 上限依赖 sing-box 已认证会话日志的滚动观察,并非精确的同时在线连接数;修改日志格式、关闭日志、UDP-only 流量或客户端经过同一 NAT 会影响结果。 - 设备上限按订阅请求的 `X-Device-ID`、`X-Client-ID` 或 User-Agent 估计,只拒绝新的订阅下载。已导入的节点凭据可以继续使用,客户端也可自行更换标识;尚无每设备凭据与撤销机制。 - 订阅令牌是高熵随机值,但控制面数据/备份中为便于管理员查看而保存明文;必须保护备份及管理员会话。重置订阅地址不会撤销客户已经导入的节点凭据。 - IP/流量限制依赖 Agent 周期性日志与上报,不能作为瞬时硬限制;共享 NAT、动态 IP 会影响统计。需要严格设备隔离时应给每台设备发不同凭据。 - Agent 到控制面依赖公开 CA 的 HTTPS 校验,尚未增加双向 TLS。 -- Xray Release 下载目前依赖 GitHub HTTPS,尚未校验发布包的签名或预期哈希。 +- sing-box 构建产物目前随发布包分发;未来如改走 Release 下载,需校验发布包的签名或预期哈希。 ## 部署建议 @@ -38,4 +38,4 @@ - 缩小服务器端口池,并仅在云安全组/防火墙中放行实际需要的 TCP/UDP 范围。 - 定期离线保存 `ng backup` 生成的文件,并对备份再次加密。 - 不要在聊天、工单或公开仓库中粘贴注册令牌、Agent Key、备份或客户链接。 -- 首次上线前在测试链路验证 Xray 版本、Reality SNI、UDP 和回滚行为。 +- 首次上线前在测试链路验证 sing-box 版本、Reality SNI、UDP 和回滚行为。 diff --git a/lib/orchestrator.js b/lib/orchestrator.js index 0e0389b..024ed2a 100644 --- a/lib/orchestrator.js +++ b/lib/orchestrator.js @@ -3,8 +3,9 @@ const crypto = require('node:crypto'); const { redactSecrets } = require('./redact'); const { - newCredentialSet, buildExitResource, buildRelayResource, buildDirectResource, buildClientUri, - validateEntryProtocol, validateProtocolPair, isRealityProtocol + buildNodeResource, buildNodeExitResource, nodeEmail, + newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, resolveBindAddress, + SHADOWSOCKS_METHODS, } = require('./protocols'); const nowIso = () => new Date().toISOString(); @@ -19,31 +20,42 @@ function assertFound(value, label) { return value; } +function forwardNode(node) { return (node.topology || 'forward') === 'forward'; } + +function tagForNode(node) { + return `ng-${node.id.replace(/[^a-zA-Z0-9]/g, '').slice(-18)}`; +} + +function nodePortInUse(data, serverId, port, excludeNodeId = null) { + return data.nodes.some((n) => n.id !== excludeNodeId && + ((n.serverId === serverId && Number(n.port) === Number(port)) || + (n.exitServerId === serverId && Number(n.exitPort) === Number(port)))); +} + function allocatePort(data, server) { const start = Number(server.portRangeStart || 20000); const end = Number(server.portRangeEnd || 50000); if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1024 || end > 65535 || start > end) throw new Error(`服务器 ${server.name} 的端口范围无效`); - const used = new Set(data.deployments.filter((item) => item.serverId === server.id && item.status !== 'deleted').map((item) => item.port)); const span = end - start + 1; const offset = crypto.randomInt(span); for (let i = 0; i < span; i += 1) { const candidate = start + ((offset + i) % span); - if (!used.has(candidate)) return candidate; + if (!nodePortInUse(data, server.id, candidate)) return candidate; } throw new Error(`服务器 ${server.name} 没有可用端口`); } -function selectPort(data, server, mode, requested) { +function selectPort(data, server, mode, requested, excludeNodeId = null) { if (mode !== 'fixed' || !requested) return allocatePort(data, server); const port = Number(requested); - const conflict = data.deployments.some((item) => item.serverId === server.id && item.status !== 'deleted' && Number(item.port) === port); - if (conflict) { const error = new Error(`服务器 ${server.name} 的端口 ${port} 已被占用`); error.statusCode = 409; throw error; } + if (!Number.isInteger(port) || port < 1024 || port > 65535) { const error = new Error(`服务器 ${server.name} 的固定端口无效`); error.statusCode = 400; throw error; } + if (nodePortInUse(data, server.id, port, excludeNodeId)) { const error = new Error(`服务器 ${server.name} 的端口 ${port} 已被占用`); error.statusCode = 409; throw error; } return port; } -function queueJob(data, serverId, deploymentId, action, payload) { +function queueJob(data, serverId, nodeId, action, payload) { const job = { - id: id('job'), serverId, deploymentId, action, payload, status: 'queued', attempts: 0, + id: id('job'), serverId, nodeId, action, payload, status: 'queued', attempts: 0, createdAt: nowIso(), updatedAt: nowIso(), error: null, leaseUntil: null }; data.jobs.push(job); @@ -55,263 +67,287 @@ function audit(data, actor, action, target, detail = {}) { data.activity = data.activity.slice(0, 2000); } -function tagFor(deployment) { - return `ng-${deployment.id.replace(/[^a-zA-Z0-9]/g, '').slice(-18)}`; +function activeNodeClients(data, node) { + return data.nodeClients.filter((nc) => nc.nodeId === node.id && nc.status === 'active'); } -function currentDeployments(data, chain) { - const all = data.deployments.filter((item) => item.chainId === chain.id); - if (!chain.generation) return all; - return all.filter((item) => Number(item.generation || 0) === Number(chain.generation)); -} - -function suspendCustomerResources(data, customer, actor = 'system') { - let count = 0; - for (const deployment of data.deployments.filter((item) => item.customerId === customer.id && !item.archived && - item.status !== 'deleted' && (item.status !== 'removing' || item.suspendedByCustomer))) { - if (deployment.status === 'active') { - const pending = data.jobs.some((job) => job.deploymentId === deployment.id && job.action === 'delete_resource' && ['queued','running'].includes(job.status)); - if (!pending) queueJob(data, deployment.serverId, deployment.id, 'delete_resource', { resourceId:deployment.resourceId }); - deployment.status = 'removing'; deployment.updatedAt = nowIso(); count += 1; - } - deployment.suspendedByCustomer = true; - } - for (const chain of data.chains.filter((item) => item.customerIds.includes(customer.id))) { - if (['active','deploying','degraded','changes_pending'].includes(chain.status)) { - if (chain.status === 'changes_pending') chain.pausedChangesPending = true; - chain.status = 'suspending'; chain.updatedAt = nowIso(); - } - } - audit(data, actor, 'suspend_customer_resources', customer.id, { count }); - return count; -} - -function resumeCustomerResources(data, customer, actor = 'admin', includeLegacy = false, onlyChainId = null) { - let count = 0; - for (const chain of data.chains.filter((item) => item.customerIds.includes(customer.id) && (!onlyChainId || item.id === onlyChainId))) { - if (chain.pausedChangesPending || ['removing','redeploying','redeploy_pending','changes_pending'].includes(chain.status)) continue; - const deployments = currentDeployments(data, chain).filter((item) => item.customerId === customer.id && !item.archived && - (item.suspendedByCustomer || (includeLegacy && ['deleted','suspended'].includes(item.status)))); - // A previously released port may now belong to another route. Never overwrite it. - for (const deployment of deployments) { - const occupied = data.deployments.some((item) => item.id !== deployment.id && item.serverId === deployment.serverId && - item.status !== 'deleted' && Number(item.port) === Number(deployment.port)); - if (occupied) { const error = new Error(`原端口 ${deployment.port} 已被其他线路使用,请编辑线路并重新部署`); error.statusCode = 409; throw error; } - } - for (const deployment of deployments) { - for (const job of data.jobs.filter((item) => item.deploymentId === deployment.id && item.action === 'delete_resource' && item.status === 'queued')) { - job.status = 'failed'; job.error = '客户恢复,清理任务已取消'; job.updatedAt = nowIso(); - } - const removing = data.jobs.some((item) => item.deploymentId === deployment.id && item.action === 'delete_resource' && item.status === 'running'); - deployment.suspendedByCustomer = false; - if (deployment.status === 'removing' && !removing) deployment.status = 'active'; - else if (['deleted','suspended','removing','failed'].includes(deployment.status)) { - queueJob(data, deployment.serverId, deployment.id, 'apply_resource', { resource:resourceForDeployment(data, deployment) }); - deployment.status = 'queued'; deployment.error = null; count += 1; - } - deployment.updatedAt = nowIso(); - } - if (deployments.length) { - const related = currentDeployments(data, chain); - chain.status = related.every((item) => item.status === 'active') ? 'active' : 'deploying'; - chain.updatedAt = nowIso(); - } - } - if (count) audit(data, actor, 'resume_customer_resources', customer.id, { count }); - return count; -} - -function resourceForDeployment(data, deployment) { - const chain = assertFound(data.chains.find((item) => item.id === deployment.chainId), '线路'); - const customer = assertFound(data.customers.find((item) => item.id === deployment.customerId), '客户'); - const server = assertFound(data.servers.find((item) => item.id === deployment.serverId), '服务器'); - const common = { - resourceId: deployment.resourceId, tagPrefix: tagFor(deployment), port: deployment.port, - credentials: deployment.credentials, customer, networkMode: chain.networkMode || 'ipv4', tlsDomain: server.tlsDomain - }; - if (deployment.role === 'exit') return buildExitResource({ ...common, protocol: deployment.protocol }); - const reality = deployment.clientTemplate && deployment.clientTemplate.reality; - if (deployment.role === 'direct') return buildDirectResource({ ...common, protocol: deployment.protocol, reality }); - const exitDeployment = assertFound(data.deployments.find((item) => item.id === deployment.exitDeploymentId), '出口部署'); - const exitServer = assertFound(data.servers.find((item) => item.id === exitDeployment.serverId), '出口服务器'); - return buildRelayResource({ - ...common, protocol: deployment.protocol, exitProtocol: exitDeployment.protocol, - exitServer, exitPort: exitDeployment.port, reality +async function resourceForNode(data, node, clients) { + const server = assertFound(data.servers.find((s) => s.id === node.serverId), '服务器'); + const networkMode = node.networkMode || 'ipv4'; + const bindAddress = await resolveBindAddress(networkMode, server); + const exitServer = forwardNode(node) + ? assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器') + : null; + return buildNodeResource({ + resourceId: node.resourceId, tagPrefix: tagForNode(node), node, + nodeClients: clients || activeNodeClients(data, node), server, bindAddress, exitServer, }); } -function createEntryDeployment(data, chain, customer, entryServer, credentials, exitDeployment) { - const port = selectPort(data, entryServer, chain.relayPortMode, chain.relayPort); - const direct = (chain.topology || 'forward') === 'direct'; - const deployment = { - id: id('dep'), chainId: chain.id, generation: chain.generation, customerId: customer.id, - serverId: entryServer.id, role: direct ? 'direct' : 'relay', protocol: chain.relayProtocol, - port, status: 'queued', resourceId: id('res'), credentials, - ...(exitDeployment ? { exitDeploymentId: exitDeployment.id } : {}), - createdAt: nowIso(), updatedAt: nowIso(), clientUri: null - }; - if (isRealityProtocol(chain.relayProtocol)) { - deployment.clientTemplate = { reality: { - keyId: `reality-${deployment.id}`, serverName: chain.realityServerName || 'www.tesla.com', - destPort: Number(chain.realityDestPort || 443) - } }; +async function resourceForNodeExit(data, node) { + const server = assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器'); + const networkMode = node.networkMode || 'ipv4'; + const bindAddress = await resolveBindAddress(networkMode, server); + return buildNodeExitResource({ + resourceId: node.exitResourceId, tagPrefix: tagForNode(node), node, server, bindAddress, + }); +} + +function cancelQueuedDeletes(data, nodeId, reason) { + let cancelled = false; + for (const job of data.jobs.filter((item) => item.nodeId === nodeId && item.action === 'delete_resource' && item.status === 'queued')) { + job.status = 'failed'; job.error = reason; job.updatedAt = nowIso(); cancelled = true; } - data.deployments.push(deployment); - queueJob(data, entryServer.id, deployment.id, 'apply_resource', { resource: resourceForDeployment(data, deployment) }); - return deployment; + return cancelled; +} + +// Rebuild a node's entry resource from its current client list and queue the +// apply job. With no active clients the resource is removed and the node +// returns to draft instead of shipping an empty (and for some protocols +// invalid) inbound. autoRemoving distinguishes this automatic teardown from an +// explicit user undeploy so resume/assign can revive it without a full cycle. +async function rebuildNode(data, node, { includeExit = false } = {}) { + const clients = activeNodeClients(data, node); + if (!clients.length) { + if (node.status !== 'draft' && node.status !== 'removing') { + queueJob(data, node.serverId, node.id, 'delete_resource', { resourceId: node.resourceId, kind: 'node' }); + if (forwardNode(node) && node.exitResourceId) { + queueJob(data, node.exitServerId, node.id, 'delete_resource', { resourceId: node.exitResourceId, kind: 'node-exit' }); + } + node.status = 'removing'; node.autoRemoving = true; node.pendingDelete = false; node.error = null; + } + node.updatedAt = nowIso(); + return; + } + node.autoRemoving = false; + queueJob(data, node.serverId, node.id, 'apply_resource', { resource: await resourceForNode(data, node, clients), kind: 'node' }); + if (forwardNode(node) && includeExit) { + queueJob(data, node.exitServerId, node.id, 'apply_resource', { resource: await resourceForNodeExit(data, node), kind: 'node-exit' }); + } + node.status = 'deploying'; node.error = null; node.updatedAt = nowIso(); +} + +function deriveNodeStatus(node) { + if (!node.entryReady) { if (node.status !== 'failed') node.status = 'deploying'; return; } + if (forwardNode(node) && !node.exitReady) { if (node.status !== 'degraded') node.status = 'deploying'; return; } + node.status = 'active'; node.error = null; +} + +async function suspendCustomerResources(data, customer, actor = 'system') { + const affected = new Set(); + for (const nc of data.nodeClients.filter((nc) => nc.customerId === customer.id && nc.status === 'active')) { + nc.status = 'suspended'; nc.updatedAt = nowIso(); affected.add(nc.nodeId); + } + for (const nodeId of affected) { + const node = data.nodes.find((n) => n.id === nodeId); + if (node && node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node); + } + audit(data, actor, 'suspend_customer_resources', customer.id, { nodes: affected.size }); + return affected.size; +} + +async function resumeCustomerResources(data, customer, actor = 'admin') { + const affected = new Set(); + for (const nc of data.nodeClients.filter((nc) => nc.customerId === customer.id && nc.status === 'suspended')) { + nc.status = 'active'; nc.updatedAt = nowIso(); affected.add(nc.nodeId); + } + for (const nodeId of affected) { + const node = data.nodes.find((n) => n.id === nodeId); + if (!node) continue; + // A manual undeploy is respected; only automatic teardowns are revived. + if (node.status === 'removing' && !node.autoRemoving) continue; + if (node.status === 'draft' && !node.autoRemoving) continue; + if (node.status === 'removing' && node.autoRemoving) { + const deleteRunning = data.jobs.some((job) => job.nodeId === nodeId && job.action === 'delete_resource' && job.status === 'running'); + cancelQueuedDeletes(data, nodeId, '客户恢复,清理任务已取消'); + node.autoRemoving = false; + if (!deleteRunning) { + // The resource never left the agent; restore service without re-applying. + deriveNodeStatus(node); + node.updatedAt = nowIso(); + continue; + } + // A delete is already running; queue the apply behind it. + } + await rebuildNode(data, node, { includeExit: forwardNode(node) && !node.exitReady }); + } + audit(data, actor, 'resume_customer_resources', customer.id, { nodes: affected.size }); + return affected.size; +} + +// Shared delta/epoch accounting used for per-customer and per-node counters. +// holder: { usageCounters, retiredUsageEpochs }. Returns null when the sample +// must be skipped (retired epoch or out-of-order replay). +function applyDelta(holder, uplink, downlink, sampleEpoch) { + const previous = holder.usageCounters || { uplink: 0, downlink: 0 }; + const retiredEpochs = Array.isArray(holder.retiredUsageEpochs) ? holder.retiredUsageEpochs : []; + if (sampleEpoch && retiredEpochs.includes(sampleEpoch)) return null; + const restarted = Boolean(sampleEpoch && previous.epoch && sampleEpoch !== previous.epoch); + if (!restarted && sampleEpoch && previous.epoch && !sampleEpoch.endsWith(':unknown') && + (uplink < previous.uplink || downlink < previous.downlink)) return null; + // Counters reset when the engine restarts. Replayed reports have zero delta. + const upDelta = !restarted && uplink >= previous.uplink ? uplink - previous.uplink : uplink; + const downDelta = !restarted && downlink >= previous.downlink ? downlink - previous.downlink : downlink; + if (restarted && previous.epoch) { + holder.retiredUsageEpochs = [...retiredEpochs, previous.epoch].slice(-8); + } + holder.usageCounters = { uplink, downlink, epoch: sampleEpoch || null }; + return { upDelta, downDelta }; +} + +function validateNodeDeploy(data, node) { + const direct = !forwardNode(node); + if (direct) validateEntryProtocol(node.protocol); + else validateProtocolPair(node.protocol, node.exitProtocol); + const server = assertFound(data.servers.find((s) => s.id === node.serverId), '服务器'); + const exitServer = direct ? null : assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器'); + if (!activeNodeClients(data, node).length) { const error = new Error('请先给节点分配至少一个客户再部署'); error.statusCode = 400; throw error; } + if (['vless-ws-tls', 'hysteria2', 'anytls'].includes(node.protocol)) { + if (!server.tlsDomain) { const error = new Error(`${node.protocol} 需要先填写设备 TLS 域名并在节点上安装证书`); error.statusCode = 400; throw error; } + if (!server.engine?.certificates?.includes(server.tlsDomain)) { + const error = new Error('入口证书未由 Agent 确认。在入口机运行 ng-agent update && ng-agent cert,证书申请成功后刷新设备状态'); + error.statusCode = 409; throw error; + } + } + if (node.protocol === 'anytls') { + const agent = data.agents.find((item) => item.serverId === server.id && item.status === 'active'); + const parts = String(agent?.version || '').split('.').map(Number); + if (!agent || !(parts[0] > 0 || (parts[0] === 0 && (parts[1] > 6 || (parts[1] === 6 && parts[2] >= 1))))) { + const error = new Error(`AnyTLS 入口需要新版 Agent:请在 ${server.name} 运行 ng-agent update`); error.statusCode = 409; throw error; + } + if (!server.engine?.singBoxInstalled) { + const error = new Error(`AnyTLS 入口缺少 sing-box 及统计组件:请在 ${server.name} 运行 ng-agent engine install,完成后等待 Agent 心跳并刷新页面`); + error.statusCode = 409; throw error; + } + } + const servers = direct ? [server] : [server, exitServer]; + const unavailable = servers.filter((s) => s.status !== 'online' || Date.now() - Date.parse(s.lastSeenAt || 0) > 120000); + if (unavailable.length) { + const error = new Error(`目标设备尚未就绪:${unavailable.map((s) => `${s.name}(Agent 离线;请在该机器运行 ng-agent doctor)`).join('、')}`); + error.statusCode = 409; throw error; + } + if ((node.networkMode || 'ipv4') === 'ipv6') { + const missing = servers.filter((s) => !s.publicAddressV6); + if (missing.length) { const error = new Error(`IPv6 模式需要先为这些服务器填写 IPv6 地址:${missing.map((s) => s.name).join('、')}`); error.statusCode = 400; throw error; } + } + if (nodePortInUse(data, node.serverId, node.port, node.id)) { const error = new Error(`端口 ${node.port} 已被该服务器上的其他节点占用`); error.statusCode = 409; throw error; } + if (!direct && nodePortInUse(data, node.exitServerId, node.exitPort, node.id)) { const error = new Error(`出口端口 ${node.exitPort} 已被该服务器上的其他节点占用`); error.statusCode = 409; throw error; } } class Orchestrator { constructor(store) { this.store = store; } - async restoreChain(chainId, actor = 'admin') { - return this.store.transaction((data) => { - const chain = assertFound(data.chains.find((item) => item.id === chainId), '线路'); - const old = currentDeployments(data, chain).filter((item) => !item.archived); - if (!old.length || !old.every((item) => ['deleted','suspended'].includes(item.status))) { - const error = new Error('没有可恢复的原节点,请使用部署或等待当前任务完成'); error.statusCode = 409; throw error; - } - let count = 0; - for (const customerId of chain.customerIds) { - const customer = data.customers.find((item) => item.id === customerId); - if (customer && customer.status === 'active' && - (!customer.expiresAt || Date.parse(customer.expiresAt) > Date.now()) && - (!(customer.trafficLimitBytes > 0) || customer.usedBytes < customer.trafficLimitBytes)) { - count += resumeCustomerResources(data, customer, actor, true, chain.id); - } - } - if (!count) { const error = new Error('客户仍受使用限制,或线路没有可恢复的部署'); error.statusCode = 409; throw error; } - audit(data, actor, 'restore_route_resources', chain.id, { count }); - return count; + async deployNode(nodeId, actor = 'admin') { + return this.store.transaction(async (data) => { + const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点'); + validateNodeDeploy(data, node); + await rebuildNode(data, node, { includeExit: true }); + audit(data, actor, 'deploy_node', node.id, { name: node.name }); + return structuredClone(node); }); } - async deployChain(chainId, actor = 'admin') { + // Undeploy (keep the node record) or delete it entirely once agents confirm cleanup. + async removeNode(nodeId, actor = 'admin', deleteRecord = false) { return this.store.transaction((data) => { - const chain = assertFound(data.chains.find((item) => item.id === chainId), '线路'); - const direct = (chain.topology || 'forward') === 'direct'; - if (direct) validateEntryProtocol(chain.relayProtocol); - else validateProtocolPair(chain.relayProtocol, chain.exitProtocol); - const entryServers = chain.relayServerIds.map((serverId) => assertFound(data.servers.find((item) => item.id === serverId), '入口服务器')); - if (['vless-ws-tls', 'hysteria2', 'anytls'].includes(chain.relayProtocol)) { - const missing = entryServers.filter((server) => !server.tlsDomain); - if (missing.length) { const error = new Error(`${chain.relayProtocol} 需要先填写设备 TLS 域名并在节点上安装证书:${missing.map((server) => server.name).join('、')}`); error.statusCode = 400; throw error; } - const unready = entryServers.filter((server) => !server.engine?.certificates?.includes(server.tlsDomain)); - if (unready.length) { - const error = new Error(`入口证书未由 Agent 确认:${unready.map((server) => server.name).join('、')}。在入口机运行 ng-agent update && ng-agent cert,证书申请成功后刷新设备状态`); - error.statusCode = 409; throw error; + const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点'); + node.pendingDelete = deleteRecord; + node.autoRemoving = false; + if (node.status !== 'draft') { + queueJob(data, node.serverId, node.id, 'delete_resource', { resourceId: node.resourceId, kind: 'node' }); + if (forwardNode(node) && node.exitResourceId) { + queueJob(data, node.exitServerId, node.id, 'delete_resource', { resourceId: node.exitResourceId, kind: 'node-exit' }); } + node.status = 'removing'; node.error = null; + } else if (deleteRecord) { + data.nodes = data.nodes.filter((n) => n.id !== node.id); + data.nodeClients = data.nodeClients.filter((nc) => nc.nodeId !== node.id); } - const exitServer = direct ? null : assertFound(data.servers.find((item) => item.id === chain.exitServerId), '出口服务器'); - if (chain.relayProtocol === 'anytls') { - const outdated = entryServers.filter((server) => { - const agent = data.agents.find((item) => item.serverId === server.id && item.status === 'active'); - const parts = String(agent?.version || '').split('.').map(Number); - return !agent || !(parts[0] > 0 || (parts[0] === 0 && (parts[1] > 6 || (parts[1] === 6 && parts[2] >= 1)))); - }); - if (outdated.length) { - const error = new Error(`AnyTLS 入口需要新版 Agent:请在 ${outdated.map((server) => server.name).join('、')} 运行 ng-agent update`); - error.statusCode = 409; throw error; - } - const missing = entryServers.filter((server) => !server.engine?.singBoxInstalled); - if (missing.length) { - const error = new Error(`AnyTLS 入口缺少 sing-box 及统计组件:请在 ${missing.map((server) => server.name).join('、')} 运行 ng-agent engine install,完成后等待 Agent 心跳并刷新页面`); - error.statusCode = 409; throw error; - } - } - const unavailable = [...new Map([...entryServers, ...(exitServer ? [exitServer] : [])].map((server) => [server.id, server])).values()] - .filter((server) => server.status !== 'online' || Date.now() - Date.parse(server.lastSeenAt || 0) > 120000); - if (unavailable.length) { - const detail = unavailable.map((server) => `${server.name}(Agent 离线;请在该机器运行 ng-agent doctor)`).join('、'); - const error = new Error(`目标设备尚未就绪:${detail}`); error.statusCode = 409; throw error; - } - const customers = chain.customerIds.map((customerId) => assertFound(data.customers.find((item) => item.id === customerId), '客户')); - if (!entryServers.length || !customers.length) throw new Error('至少选择一台入口服务器和一个客户'); - if (!customers.some((customer) => customer.status === 'active')) { const error = new Error('所选客户均已停用,无法部署'); error.statusCode = 409; throw error; } - if ((chain.networkMode || 'ipv4') === 'ipv6') { - const missing = [...entryServers, ...(exitServer ? [exitServer] : [])].filter((server) => !server.publicAddressV6); - if (missing.length) { const error = new Error(`IPv6 模式需要先为这些服务器填写 IPv6 地址:${missing.map((item) => item.name).join('、')}`); error.statusCode = 400; throw error; } - } - if (customers.length > 1 && (chain.relayPortMode === 'fixed' || (!direct && chain.exitPortMode === 'fixed'))) { - const error = new Error('多客户批量部署需要使用随机端口;固定端口仅支持单一客户'); error.statusCode = 400; throw error; - } - const duplicate = data.deployments.some((item) => item.chainId === chain.id && !['deleted', 'failed'].includes(item.status)); - if (duplicate) { const error = new Error('该线路已有部署,请使用“重新部署”或先停用'); error.statusCode = 409; throw error; } - - chain.generation = Number(chain.generation || 0) + 1; - chain.redeployPending = false; - chain.pausedChangesPending = false; - const created = []; - for (const customer of customers) { - if (customer.status !== 'active') continue; - const credentials = newCredentialSet(chain.relayProtocol, chain.exitProtocol); - let exitDeployment = null; - if (!direct) { - const exitPort = selectPort(data, exitServer, chain.exitPortMode, chain.exitPort); - exitDeployment = { - id: id('dep'), chainId: chain.id, generation: chain.generation, customerId: customer.id, serverId: exitServer.id, - role: 'exit', protocol: chain.exitProtocol, port: exitPort, status: 'queued', resourceId: id('res'), credentials, - createdAt: nowIso(), updatedAt: nowIso(), clientUri: null - }; - data.deployments.push(exitDeployment); - queueJob(data, exitServer.id, exitDeployment.id, 'apply_resource', { resource: resourceForDeployment(data, exitDeployment) }); - created.push(exitDeployment); - } - for (const entryServer of entryServers) created.push(createEntryDeployment(data, chain, customer, entryServer, credentials, exitDeployment)); - } - chain.status = 'deploying'; chain.updatedAt = nowIso(); - audit(data, actor, 'deploy_route', chain.id, { deployments: created.length, topology: direct ? 'direct' : 'forward' }); - return structuredClone(created); + node.updatedAt = nowIso(); + audit(data, actor, deleteRecord ? 'delete_node' : 'remove_node', node.id, { name: node.name }); + return true; }); } - async removeChain(chainId, actor = 'admin', redeploy = false) { - return this.store.transaction((data) => { - const chain = assertFound(data.chains.find((item) => item.id === chainId), '线路'); - const deployments = data.deployments.filter((item) => item.chainId === chainId && item.status !== 'deleted'); - for (const deployment of deployments) { - deployment.suspendedByCustomer = false; - const existing = data.jobs.some((job) => job.deploymentId === deployment.id && job.action === 'delete_resource' && ['queued', 'running'].includes(job.status)); - if (!existing) queueJob(data, deployment.serverId, deployment.id, 'delete_resource', { resourceId: deployment.resourceId }); - deployment.status = 'removing'; deployment.updatedAt = nowIso(); - } - chain.redeployPending = Boolean(redeploy); - chain.pausedChangesPending = false; - chain.status = redeploy ? (deployments.length ? 'redeploying' : 'redeploy_pending') : (deployments.length ? 'removing' : 'draft'); chain.updatedAt = nowIso(); - audit(data, actor, redeploy ? 'redeploy_route' : 'remove_route', chain.id, { deployments: deployments.length }); - return deployments.length; + async repairNode(nodeId, actor = 'admin') { + return this.store.transaction(async (data) => { + const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点'); + if (!['failed', 'degraded'].includes(node.status)) { const error = new Error('节点状态正常,无需修复'); error.statusCode = 409; throw error; } + await rebuildNode(data, node, { includeExit: true }); + audit(data, actor, 'repair_node', node.id, { name: node.name }); + return true; }); } - async repairChain(chainId, actor = 'admin') { - return this.store.transaction((data) => { - const chain = assertFound(data.chains.find((item) => item.id === chainId), '线路'); - const failed = currentDeployments(data, chain).filter((item) => item.status === 'failed'); - if (!failed.length) { const error = new Error('当前线路没有可修复的失败部署'); error.statusCode = 409; throw error; } - for (const deployment of failed) { - queueJob(data, deployment.serverId, deployment.id, 'apply_resource', { resource: resourceForDeployment(data, deployment) }); - deployment.status = 'queued'; deployment.error = null; deployment.updatedAt = nowIso(); + async assignCustomer(nodeId, customerId, actor = 'admin') { + return this.store.transaction(async (data) => { + const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点'); + const customer = assertFound(data.customers.find((c) => c.id === customerId), '客户'); + if (customer.status !== 'active') { const error = new Error('只能给状态正常的客户分配节点'); error.statusCode = 409; throw error; } + let nc = data.nodeClients.find((entry) => entry.nodeId === nodeId && entry.customerId === customerId); + if (nc && nc.status === 'active') { const error = new Error('该客户已分配到此节点'); error.statusCode = 409; throw error; } + if (nc) { + nc.status = 'active'; nc.updatedAt = nowIso(); + } else { + nc = { + id: id('ncl'), nodeId, customerId, credentials: newNodeClientCredentials(node.protocol), + status: 'active', createdAt: nowIso(), updatedAt: nowIso(), + }; + data.nodeClients.push(nc); } - chain.status = 'deploying'; chain.updatedAt = nowIso(); - audit(data, actor, 'repair_route', chain.id, { deployments: failed.length }); - return failed.length; + // A manually undeployed node stays draft until explicitly deployed. + // Automatic teardowns (suspend) are revived when clients return. + if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) { + // record the assignment only + } else { + if (node.status === 'removing' && node.autoRemoving) { + cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务'); + node.autoRemoving = false; + } + await rebuildNode(data, node); + } + audit(data, actor, 'assign_customer', node.id, { customerId, customer: customer.name }); + return structuredClone(nc); + }); + } + + async unassignCustomer(nodeId, customerId, actor = 'admin') { + return this.store.transaction(async (data) => { + const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点'); + const index = data.nodeClients.findIndex((entry) => entry.nodeId === nodeId && entry.customerId === customerId); + if (index < 0) { const error = new Error('该客户未分配到此节点'); error.statusCode = 404; throw error; } + data.nodeClients.splice(index, 1); + if (node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node); + audit(data, actor, 'unassign_customer', node.id, { customerId }); + return true; }); } async reconcileServer(serverId, actor = 'system') { - return this.store.transaction((data) => { + return this.store.transaction(async (data) => { const server = assertFound(data.servers.find((item) => item.id === serverId), '服务器'); for (const job of data.jobs.filter((item) => item.serverId === serverId && ['queued', 'running'].includes(item.status))) { job.status = 'failed'; job.error = 'Agent 重新注册,旧任务已由对账任务替代'; job.updatedAt = nowIso(); } let count = 0; - for (const deployment of data.deployments.filter((item) => item.serverId === serverId && item.status !== 'deleted')) { - const removing = deployment.archived || deployment.status === 'removing'; - if (removing) queueJob(data, serverId, deployment.id, 'delete_resource', { resourceId: deployment.resourceId }); - else queueJob(data, serverId, deployment.id, 'apply_resource', { resource: resourceForDeployment(data, deployment) }); - deployment.status = removing ? 'removing' : 'queued'; deployment.updatedAt = nowIso(); count += 1; + for (const node of data.nodes.filter((n) => n.serverId === serverId || n.exitServerId === serverId)) { + const isEntry = node.serverId === serverId; + const isExit = node.exitServerId === serverId && forwardNode(node); + if (!isEntry && !isExit) continue; + if (node.status === 'draft') continue; + if (node.status === 'removing') { + if (isEntry) queueJob(data, serverId, node.id, 'delete_resource', { resourceId: node.resourceId, kind: 'node' }); + if (isExit && node.exitResourceId) queueJob(data, serverId, node.id, 'delete_resource', { resourceId: node.exitResourceId, kind: 'node-exit' }); + } else { + const clients = activeNodeClients(data, node); + if (!clients.length) continue; + if (isEntry) queueJob(data, serverId, node.id, 'apply_resource', { resource: await resourceForNode(data, node, clients), kind: 'node' }); + if (isExit) queueJob(data, serverId, node.id, 'apply_resource', { resource: await resourceForNodeExit(data, node), kind: 'node-exit' }); + node.status = 'deploying'; + } + node.updatedAt = nowIso(); count += 1; } - audit(data, actor, 'reconcile_server', server.id, { deployments: count }); + audit(data, actor, 'reconcile_server', server.id, { nodes: count }); return count; }); } @@ -325,49 +361,29 @@ class Orchestrator { } job.status = success ? 'completed' : 'failed'; job.updatedAt = nowIso(); job.completedAt = nowIso(); job.leaseUntil = null; job.error = success ? null : redactSecrets(errorMessage || 'Agent operation failed').slice(0, 1800); - const deployment = data.deployments.find((item) => item.id === job.deploymentId); - if (!deployment) return structuredClone(job); - const chain = data.chains.find((item) => item.id === deployment.chainId); - if (success && job.action === 'apply_resource' && (deployment.archived || !chain || - ['removing','redeploying'].includes(chain.status) || (deployment.status === 'removing' && !deployment.suspendedByCustomer))) { - const cleanupQueued = data.jobs.some((item) => item.deploymentId === deployment.id && item.action === 'delete_resource' && ['queued', 'running'].includes(item.status)); - if (!cleanupQueued) queueJob(data, deployment.serverId, deployment.id, 'delete_resource', { resourceId: deployment.resourceId }); - deployment.status = 'removing'; deployment.clientUri = null; - } else if (success && job.action === 'apply_resource') { - deployment.status = 'active'; deployment.error = null; deployment.artifacts = result.artifacts || {}; - if (deployment.role === 'relay' || deployment.role === 'direct') { - const server = data.servers.find((item) => item.id === deployment.serverId); - const customer = data.customers.find((item) => item.id === deployment.customerId); - deployment.clientUri = buildClientUri({ - protocol: deployment.protocol, relayServer: server, relayPort: deployment.port, credentials: deployment.credentials, - reality: deployment.clientTemplate && deployment.clientTemplate.reality, - publicKey: result.artifacts && result.artifacts.realityPublicKey, - name: chain.name, - tlsDomain: server && server.tlsDomain, - networkMode: chain.networkMode || 'ipv4' - }); - } - const customer = data.customers.find((item) => item.id === deployment.customerId); - if (customer && (customer.status !== 'active' || (customer.expiresAt && Date.parse(customer.expiresAt) <= Date.now()) || - (customer.trafficLimitBytes > 0 && customer.usedBytes >= customer.trafficLimitBytes))) { - queueJob(data, deployment.serverId, deployment.id, 'delete_resource', { resourceId: deployment.resourceId }); - deployment.status = 'removing'; deployment.suspendedByCustomer = true; - } + const node = data.nodes.find((n) => n.id === job.nodeId); + if (!node) return structuredClone(job); + const kind = job.payload && (job.payload.kind || (job.payload.resource && job.payload.resource.meta && job.payload.resource.meta.kind)); + if (success && job.action === 'apply_resource') { + if (kind === 'node-exit') node.exitReady = true; + else { node.entryReady = true; node.artifacts = result.artifacts || {}; } + deriveNodeStatus(node); } else if (success && job.action === 'delete_resource') { - const restoring = data.jobs.some((item) => item.deploymentId === deployment.id && item.action === 'apply_resource' && item.status === 'queued'); - deployment.status = restoring ? 'queued' : deployment.suspendedByCustomer && chain && !deployment.archived ? 'suspended' : 'deleted'; - } - else if (!success) { deployment.status = 'failed'; deployment.error = job.error; } - deployment.updatedAt = nowIso(); - if (chain) { - const related = currentDeployments(data, chain); - if (related.length && related.every((item) => item.status === 'active')) chain.status = chain.pausedChangesPending ? 'changes_pending' : 'active'; - else if (related.length && related.every((item) => item.status === 'deleted')) chain.status = chain.redeployPending ? 'redeploy_pending' : 'draft'; - else if (related.length && related.every((item) => ['suspended','deleted'].includes(item.status))) chain.status = chain.pausedChangesPending ? 'changes_pending' : 'suspended'; - else if (related.some((item) => item.status === 'active') && related.some((item) => item.status === 'suspended')) chain.status = 'partially_suspended'; - else if (related.some((item) => item.status === 'failed')) chain.status = 'degraded'; - chain.updatedAt = nowIso(); + if (kind === 'node-exit') node.exitReady = false; else node.entryReady = false; + const pending = data.jobs.some((item) => item.nodeId === node.id && item.action === 'delete_resource' && ['queued', 'running'].includes(item.status)); + if (!pending && node.status === 'removing') { + if (node.pendingDelete) { + data.nodes = data.nodes.filter((n) => n.id !== node.id); + data.nodeClients = data.nodeClients.filter((nc) => nc.nodeId !== node.id); + } else { + node.status = 'draft'; node.error = null; + } + } + } else if (!success) { + node.status = kind === 'node-exit' ? 'degraded' : 'failed'; + node.error = job.error; } + node.updatedAt = nowIso(); return structuredClone(job); }); } @@ -388,43 +404,41 @@ class Orchestrator { if (server) server.usage = { lastReportAt: reportedAt, sampleCount: Array.isArray(samples) ? samples.length : 0, error: error ? String(error).slice(0, 240) : null }; for (const sample of Array.isArray(samples) ? samples.slice(0, 1000) : []) { - const deployment = data.deployments.find((item) => item.serverId === serverId && item.resourceId === sample.resourceId && - !item.archived && ['relay', 'direct'].includes(item.role)); - if (!deployment) continue; - const customer = data.customers.find((item) => item.id === deployment.customerId); - if (!customer) continue; const uplink = Number(sample.uplink); const downlink = Number(sample.downlink); if (![uplink, downlink].every((value) => Number.isSafeInteger(value) && value >= 0)) continue; - const previous = deployment.usageCounters || { uplink: 0, downlink: 0 }; const sampleEpoch = typeof sample.epoch === 'string' ? sample.epoch.slice(0, 80) : epoch; - // A periodic report may finish after a resource job has flushed the old - // process and started a new one. Never charge an already retired epoch - // a second time if its HTTP response arrives out of order. - const retiredEpochs = Array.isArray(deployment.retiredUsageEpochs) ? deployment.retiredUsageEpochs : []; - if (sampleEpoch && retiredEpochs.includes(sampleEpoch)) continue; - const restarted = Boolean(sampleEpoch && previous.epoch && sampleEpoch !== previous.epoch); - if (!restarted && sampleEpoch && previous.epoch && !sampleEpoch.endsWith(':unknown') && - (uplink < previous.uplink || downlink < previous.downlink)) continue; - // Counters reset when Xray restarts. Replayed reports have zero delta. - const upDelta = !restarted && uplink >= previous.uplink ? uplink - previous.uplink : uplink; - const downDelta = !restarted && downlink >= previous.downlink ? downlink - previous.downlink : downlink; - if (restarted && previous.epoch) { - deployment.retiredUsageEpochs = [...retiredEpochs, previous.epoch].slice(-8); + if (sample.kind === 'user') { + const customer = data.customers.find((item) => item.id === sample.id); + if (!customer) continue; + // Counters are per engine process, hence per server. Traffic itself + // accumulates on the customer across all servers. + const key = `srv:${serverId}`; + customer.serverUsage = customer.serverUsage || {}; + const slot = customer.serverUsage[key] || (customer.serverUsage[key] = {}); + const delta = applyDelta(slot, uplink, downlink, sampleEpoch); + if (!delta) continue; + customer.usedUplinkBytes = (customer.usedUplinkBytes || 0) + delta.upDelta; + customer.usedDownlinkBytes = (customer.usedDownlinkBytes || 0) + delta.downDelta; + customer.usedBytes = (customer.usedBytes || 0) + delta.upDelta + delta.downDelta; + customer.lastUsageAt = reportedAt; + customer.updatedAt = reportedAt; + } else if (sample.kind === 'inbound') { + // Node-level traffic comes from entry inbounds only; the exit leg is + // transport and is never double counted (same rule as before). + const node = data.nodes.find((item) => item.id === sample.id && item.serverId === serverId); + if (!node) continue; + const delta = applyDelta(node, uplink, downlink, sampleEpoch); + if (!delta) continue; + const metered = node.meteredTraffic || { uplink: 0, downlink: 0 }; + node.meteredTraffic = { uplink: metered.uplink + delta.upDelta, downlink: metered.downlink + delta.downDelta }; + node.lastUsageAt = reportedAt; } - deployment.usageCounters = { uplink, downlink, epoch: sampleEpoch || null }; - const metered = deployment.meteredTraffic || { uplink: 0, downlink: 0 }; - deployment.meteredTraffic = { uplink: metered.uplink + upDelta, downlink: metered.downlink + downDelta }; - deployment.lastUsageAt = reportedAt; - customer.usedUplinkBytes = (customer.usedUplinkBytes || 0) + upDelta; - customer.usedDownlinkBytes = (customer.usedDownlinkBytes || 0) + downDelta; - customer.usedBytes = (customer.usedBytes || 0) + upDelta + downDelta; - customer.lastUsageAt = reportedAt; - customer.updatedAt = reportedAt; } }); } } -module.exports = { Orchestrator, allocatePort, selectPort, queueJob, audit, id, nowIso, resourceForDeployment, +module.exports = { Orchestrator, allocatePort, selectPort, nodePortInUse, queueJob, audit, id, nowIso, cancelQueuedDeletes, resourceForNode, + resourceForNodeExit, rebuildNode, applyDelta, forwardNode, activeNodeClients, suspendCustomerResources, resumeCustomerResources }; diff --git a/lib/protocols.js b/lib/protocols.js index abecfa0..542a27a 100644 --- a/lib/protocols.js +++ b/lib/protocols.js @@ -2,6 +2,7 @@ const crypto = require('node:crypto'); const net = require('node:net'); +const dns = require('node:dns/promises'); const REALITY_PRESETS = Object.freeze([ { id: 'tesla', label: 'Tesla', serverName: 'www.tesla.com', destPort: 443 }, @@ -12,18 +13,17 @@ const REALITY_PRESETS = Object.freeze([ ]); const PROFILE_CATALOG = Object.freeze([ - { id: 'vless-reality-vision', role: 'relay-ingress', name: 'VLESS · Reality · Vision', status: 'stable', deployable: true, capabilities: ['reality'], description: '推荐公网入口,无需在节点机部署证书。' }, - { id: 'vless-reality', role: 'relay-ingress', name: 'VLESS · Reality', status: 'stable', deployable: true, capabilities: ['reality'], description: '不启用 Vision 流控的 Reality 入口。' }, - { id: 'vless-ws', role: 'relay-ingress', name: 'VLESS · WebSocket', status: 'beta', deployable: true, capabilities: ['websocket'], description: '无 TLS 的兼容模式,建议仅在可信或外层 TLS 环境使用。' }, - { id: 'vmess-ws', role: 'relay-ingress', name: 'VMess · WebSocket', status: 'beta', deployable: true, capabilities: ['websocket'], description: '兼容旧客户端,建议仅在外层 TLS 环境使用。' }, - { id: 'shadowsocks-2022-aes128', role: 'relay-ingress', name: 'SS 2022 · AES-128', status: 'stable', deployable: true, capabilities: [], description: '轻量、推荐的 AEAD 2022 入口。' }, - { id: 'shadowsocks-2022-aes256', role: 'relay-ingress', name: 'SS 2022 · AES-256', status: 'stable', deployable: true, capabilities: [], description: '32 字节密钥的 AEAD 2022 入口。' }, - { id: 'shadowsocks-aes128-gcm', role: 'relay-ingress', name: 'SS · AES-128-GCM', status: 'stable', deployable: true, capabilities: [], description: '兼容主流旧版 Shadowsocks 客户端。' }, - { id: 'shadowsocks-aes256-gcm', role: 'relay-ingress', name: 'SS · AES-256-GCM', status: 'stable', deployable: true, capabilities: [], description: '兼容主流旧版 Shadowsocks 客户端。' }, - { id: 'socks5-auth', role: 'relay-ingress', name: 'SOCKS5 · 用户密码', status: 'stable', deployable: true, capabilities: [], description: '适合受控网络或程序级代理。' }, - { id: 'vless-ws-tls', role: 'relay-ingress', name: 'VLESS · WS · TLS', status: 'beta', deployable: true, capabilities: ['tls-domain'], description: '需为每台入口设备填写 TLS 域名,并在 Agent 上安装对应证书。' }, - { id: 'hysteria2', role: 'relay-ingress', name: 'Hysteria 2 · UDP', status: 'beta', deployable: true, capabilities: ['udp', 'tls-domain'], description: '由新版 Xray 承载;入口须安装有效节点证书并放行所选 UDP 端口。' }, - { id: 'anytls', role: 'relay-ingress', name: 'AnyTLS · TLS', status: 'beta', deployable: true, capabilities: ['tls-domain', 'sing-box'], description: '由 sing-box 独立承载,需入口设备的有效节点证书;支持直连或转发到现有出口。' }, + { id: 'vless-reality-vision', role: 'relay-ingress', name: 'VLESS · Reality · Vision', status: 'stable', deployable: true, capabilities: ['sing-box', 'reality'], description: '推荐公网入口,无需在节点机部署证书。' }, + { id: 'vless-reality', role: 'relay-ingress', name: 'VLESS · Reality', status: 'stable', deployable: true, capabilities: ['sing-box', 'reality'], description: '不启用 Vision 流控的 Reality 入口。' }, + { id: 'vless-ws', role: 'relay-ingress', name: 'VLESS · WebSocket', status: 'beta', deployable: true, capabilities: ['sing-box', 'websocket'], description: '无 TLS 的兼容模式,建议仅在可信或外层 TLS 环境使用。' }, + { id: 'shadowsocks-2022-aes128', role: 'relay-ingress', name: 'SS 2022 · AES-128', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '轻量、推荐的 AEAD 2022 入口。' }, + { id: 'shadowsocks-2022-aes256', role: 'relay-ingress', name: 'SS 2022 · AES-256', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '32 字节密钥的 AEAD 2022 入口。' }, + { id: 'shadowsocks-aes128-gcm', role: 'relay-ingress', name: 'SS · AES-128-GCM', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '兼容主流旧版 Shadowsocks 客户端。' }, + { id: 'shadowsocks-aes256-gcm', role: 'relay-ingress', name: 'SS · AES-256-GCM', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '兼容主流旧版 Shadowsocks 客户端。' }, + { id: 'socks5-auth', role: 'relay-ingress', name: 'SOCKS5 · 用户密码', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '适合受控网络或程序级代理。' }, + { id: 'vless-ws-tls', role: 'relay-ingress', name: 'VLESS · WS · TLS', status: 'beta', deployable: true, capabilities: ['sing-box', 'tls-domain'], description: '需为每台入口设备填写 TLS 域名,并在 Agent 上安装对应证书。' }, + { id: 'hysteria2', role: 'relay-ingress', name: 'Hysteria 2 · UDP', status: 'beta', deployable: true, capabilities: ['sing-box', 'udp', 'tls-domain'], description: '由 sing-box 承载;入口须安装有效节点证书并放行所选 UDP 端口。' }, + { id: 'anytls', role: 'relay-ingress', name: 'AnyTLS · TLS', status: 'beta', deployable: true, capabilities: ['sing-box', 'tls-domain'], description: '由 sing-box 独立承载,需入口设备的有效节点证书;支持直连或转发到现有出口。' }, { id: 'shadowsocks-2022-aes128', role: 'exit-transport', name: 'SS 2022 · AES-128', status: 'stable', deployable: true, capabilities: ['encrypted'], description: '推荐的转发到出口传输。' }, { id: 'shadowsocks-2022-aes256', role: 'exit-transport', name: 'SS 2022 · AES-256', status: 'stable', deployable: true, capabilities: ['encrypted'], description: '增强密钥长度的转发传输。' }, { id: 'shadowsocks-aes128-gcm', role: 'exit-transport', name: 'SS · AES-128-GCM', status: 'stable', deployable: true, capabilities: ['encrypted'], description: '兼容旧环境的转发传输。' }, @@ -42,176 +42,240 @@ const SHADOWSOCKS_METHODS = Object.freeze({ function b64(bytes) { return crypto.randomBytes(bytes).toString('base64'); } function shortId() { return crypto.randomBytes(8).toString('hex'); } -function newCredentialSet(relayProtocol, exitProtocol) { - const relayCipher = SHADOWSOCKS_METHODS[relayProtocol]; - const exitCipher = SHADOWSOCKS_METHODS[exitProtocol]; +function listenAddress(networkMode, server, bindAddress) { + // bindAddress 是控制面按所选网络栈解析好的字面 IP(登记的域名已在下发前解析),优先使用。 + if (bindAddress) return bindAddress; + // 只绑定所选网络栈对应的具体 IP,避免通配符地址跨栈泄漏。 + // ipv6 绑定 `::` 时 Linux 默认是双栈 socket(bindv6only=0),IPv4 也能连进来。 + if (networkMode === 'ipv6') return (server && server.publicAddressV6) || '::'; + // dual 单个 inbound 只能填一个 listen 地址,`::` 即双栈语义;缺地址时回退通配符。 + if (networkMode === 'dual') return '::'; + return (server && server.publicAddress) || '0.0.0.0'; +} + +// 把服务器登记的地址解析成字面 IP,供 inbound listen 绑定。 +// v4/v6 各自登记了单栈域名时,按所选网络栈只取对应记录(A / AAAA),避免绑到错误的栈。 +async function resolveBindAddress(networkMode, server) { + if (networkMode === 'dual') return null; + const raw = networkMode === 'ipv6' ? server?.publicAddressV6 : server?.publicAddress; + if (!raw) return null; + const text = String(raw).trim(); + const family = networkMode === 'ipv6' ? 6 : 4; + if (net.isIP(text)) return text; + try { + const { address } = await dns.lookup(text, { family }); + return address; + } catch (err) { + const error = new Error(`绑定地址 DNS 解析失败:${text} 没有 ${family === 6 ? 'AAAA' : 'A'} 记录(${err.code || err.message}),请检查该服务器登记的地址`); + error.statusCode = 400; + throw error; + } +} + +// ---------- Shared nodes: one inbound, many clients (3x-ui style) ---------- + +// 统计与日志里的用户身份统一为 ng:: +// VLESS/HY2/SS 用 email,SOCKS 用用户名,AnyTLS 用 sing-box 用户名。 +function nodeEmail(customerId) { return `ng:${customerId}`; } + +function newNodeClientCredentials(protocol) { + const cipher = SHADOWSOCKS_METHODS[protocol]; return { clientId: crypto.randomUUID(), - relayPassword: b64(relayCipher ? relayCipher.keyBytes : 24), - exitPassword: b64(exitCipher ? exitCipher.keyBytes : 24), - socksUser: `ng_${crypto.randomBytes(5).toString('hex')}`, + relayPassword: b64(cipher ? cipher.keyBytes : 24), shortId: shortId(), - wsPath: `/${crypto.randomBytes(8).toString('hex')}` }; } -function listenAddress(networkMode) { return networkMode === 'ipv6' || networkMode === 'dual' ? '::' : '0.0.0.0'; } - -function inboundBase(tag, port, protocol, settings, streamSettings, networkMode = 'ipv4') { - return { - tag, listen: listenAddress(networkMode), port, protocol, settings, - sniffing: { enabled: true, destOverride: ['http', 'tls', 'quic'], routeOnly: true }, - ...(streamSettings ? { streamSettings } : {}) - }; -} - -function shadowsocksInbound(tag, port, protocol, password, networkMode) { - const cipher = SHADOWSOCKS_METHODS[protocol]; - if (!cipher) return null; - return inboundBase(tag, port, 'shadowsocks', { method: cipher.method, password, network: 'tcp,udp' }, null, networkMode); -} - -function socksInbound(tag, port, credentials, password, networkMode) { - return inboundBase(tag, port, 'socks', { - auth: 'password', accounts: [{ user: credentials.socksUser, pass: password }], udp: true, ip: '127.0.0.1', userLevel: 0 - }, null, networkMode); -} - -function buildExitResource({ resourceId, tagPrefix, port, protocol, credentials, customer, networkMode }) { - let inbound = shadowsocksInbound(`${tagPrefix}-in`, port, protocol, credentials.exitPassword, networkMode); - if (protocol === 'socks5-auth') inbound = socksInbound(`${tagPrefix}-in`, port, credentials, credentials.exitPassword, networkMode); - if (protocol === 'vless-tcp') { - inbound = inboundBase(`${tagPrefix}-in`, port, 'vless', { - clients: [{ id: credentials.clientId, email: `ng:${customer.id}` }], decryption: 'none' - }, { network: 'raw', security: 'none' }, networkMode); +// Fill in node-level secrets that are missing for the node's current +// protocol/topology. Called on node create and on node edit. +function ensureNodeSecrets(node) { + node.wsPath ||= `/${crypto.randomBytes(9).toString('hex')}`; + const cipher = SHADOWSOCKS_METHODS[node.protocol]; + if (cipher && !node.ssServerKey) node.ssServerKey = b64(cipher.keyBytes); + if (isRealityProtocol(node.protocol)) { + node.reality ||= { keyId: `reality-${node.id}` }; + node.reality.serverName = node.realityServerName || 'www.tesla.com'; + node.reality.destPort = Number(node.realityDestPort || 443); } - if (!inbound) throw new Error(`Unsupported exit transport: ${protocol}`); - const directTag = `${tagPrefix}-direct`; - return { - id: resourceId, meta: { kind: 'exit', customerId: customer.id, metricsTag: inbound.tag }, inbounds: [inbound], - outbounds: [{ tag: directTag, protocol: 'freedom', settings: {} }], - routingRules: [{ type: 'field', inboundTag: [inbound.tag], outboundTag: directTag }] - }; + if ((node.topology || 'forward') !== 'direct' && !node.exitCredentials) { + const exitCipher = SHADOWSOCKS_METHODS[node.exitProtocol]; + node.exitCredentials = { + clientId: crypto.randomUUID(), + password: b64(exitCipher ? exitCipher.keyBytes : 24), + socksUser: `ng_${crypto.randomBytes(5).toString('hex')}`, + }; + } + return node; } -function buildRelayInbound({ tag, port, protocol, credentials, customer, reality, networkMode, tlsDomain }) { - const email = `ng:${customer.id}`; - if (protocol === 'hysteria2') { - if (!tlsDomain) throw new Error('Hysteria 2 入口缺少有效证书域名'); - return inboundBase(tag, port, 'hysteria', { - // Xray 26.3.27 reads `clients`; newer cores read `users`. - // Keep both until all supported cores accept the same spelling. - version: 2, - clients: [{ auth: credentials.relayPassword, level: 0, email }], - users: [{ auth: credentials.relayPassword, level: 0, email }] - }, { - // Older Xray reads `network`; newer cores accept `method`. - network: 'hysteria', method: 'hysteria', security: 'tls', hysteriaSettings: { version: 2 }, - tlsSettings: { serverName: tlsDomain, alpn: ['h3'], certificates: [{ - certificateFile: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`, - keyFile: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem` - }] } - }, networkMode); - } +// sing-box 全协议 inbound 构建器 +function buildSingBoxInbound({ tag, node, nodeClients, server, bindAddress }) { + const protocol = node.protocol; + const port = node.port; + const networkMode = node.networkMode || 'ipv4'; + const listen = listenAddress(networkMode, server, bindAddress); + const tlsDomain = server.tlsDomain; + + // VLESS Reality (Vision / 普通) if (protocol === 'vless-reality-vision' || protocol === 'vless-reality') { - const client = { id: credentials.clientId, email }; - if (protocol === 'vless-reality-vision') client.flow = 'xtls-rprx-vision'; - return inboundBase(tag, port, 'vless', { clients: [client], decryption: 'none' }, { - network: 'raw', security: 'reality', - realitySettings: { - show: false, target: `${reality.serverName}:${reality.destPort}`, xver: 0, - serverNames: [reality.serverName], privateKey: `\${REALITY_PRIVATE:${reality.keyId}}`, shortIds: [credentials.shortId] - } - }, networkMode); + if (!node.reality) throw new Error('Reality 节点缺少密钥配置'); + const users = nodeClients.map((nc) => ({ + name: nodeEmail(nc.customerId), + uuid: nc.credentials.clientId, + ...(protocol === 'vless-reality-vision' ? { flow: 'xtls-rprx-vision' } : {}), + })); + return { + type: 'vless', tag, listen, listen_port: port, users, + tls: { + enabled: true, server_name: node.reality.serverName, + reality: { + enabled: true, + handshake: { server: node.reality.serverName, server_port: node.reality.destPort || 443 }, + private_key: `\${REALITY_PRIVATE:${node.reality.keyId}}`, + short_id: nodeClients.map((nc) => nc.credentials.shortId), + }, + }, + }; } - if (SHADOWSOCKS_METHODS[protocol]) return shadowsocksInbound(tag, port, protocol, credentials.relayPassword, networkMode); - if (protocol === 'socks5-auth') return socksInbound(tag, port, credentials, credentials.relayPassword, networkMode); + + // VLESS WS / WS+TLS if (protocol === 'vless-ws' || protocol === 'vless-ws-tls') { if (protocol === 'vless-ws-tls' && !tlsDomain) throw new Error('TLS 节点缺少有效证书域名'); - return inboundBase(tag, port, 'vless', { clients: [{ id: credentials.clientId, email }], decryption: 'none' }, { - network: 'ws', security: protocol === 'vless-ws-tls' ? 'tls' : 'none', wsSettings: { path: credentials.wsPath }, - ...(protocol === 'vless-ws-tls' ? { tlsSettings: { serverName: tlsDomain, - certificates: [{ certificateFile: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`, keyFile: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem` }] } } : {}) - }, networkMode); + const users = nodeClients.map((nc) => ({ name: nodeEmail(nc.customerId), uuid: nc.credentials.clientId })); + const inbound = { + type: 'vless', tag, listen, listen_port: port, users, + transport: { type: 'ws', path: node.wsPath || '/' }, + }; + if (protocol === 'vless-ws-tls') { + inbound.tls = { + enabled: true, server_name: tlsDomain, + certificate_path: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`, + key_path: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem`, + }; + } + return inbound; } - if (protocol === 'vmess-ws') { - return inboundBase(tag, port, 'vmess', { clients: [{ id: credentials.clientId, email }] }, { - network: 'ws', security: 'none', wsSettings: { path: credentials.wsPath } - }, networkMode); + + // Shadowsocks (2022 全系 + 旧版) + if (SHADOWSOCKS_METHODS[protocol]) { + return { + type: 'shadowsocks', tag, listen, listen_port: port, + method: SHADOWSOCKS_METHODS[protocol].method, + password: node.ssServerKey, + users: nodeClients.map((nc) => ({ name: nodeEmail(nc.customerId), password: nc.credentials.relayPassword })), + }; } - throw new Error(`Unsupported relay ingress: ${protocol}`); + + // SOCKS5 + if (protocol === 'socks5-auth') { + return { + type: 'socks', tag, listen, listen_port: port, + users: nodeClients.map((nc) => ({ username: nodeEmail(nc.customerId), password: nc.credentials.relayPassword })), + }; + } + + // Hysteria2 + if (protocol === 'hysteria2') { + if (!tlsDomain) throw new Error('Hysteria 2 入口缺少有效证书域名'); + return { + type: 'hysteria2', tag, listen, listen_port: port, + users: nodeClients.map((nc) => ({ name: nodeEmail(nc.customerId), password: nc.credentials.relayPassword })), + tls: { + enabled: true, server_name: tlsDomain, alpn: ['h3'], + certificate_path: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`, + key_path: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem`, + }, + }; + } + + // AnyTLS 走专用构建器 + if (protocol === 'anytls') { + if (!tlsDomain) throw new Error('AnyTLS 入口缺少有效证书域名'); + return { + type: 'anytls', tag, listen, listen_port: port, + users: nodeClients.map((nc) => ({ name: nodeEmail(nc.customerId), password: nc.credentials.relayPassword })), + tls: { + enabled: true, server_name: tlsDomain, + certificate_path: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`, + key_path: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem`, + }, + }; + } + + throw new Error(`sing-box 不支持的协议: ${protocol}`); +} + +function buildSingBoxNodeResource({ resourceId, tagPrefix, node, nodeClients, server, bindAddress, exitServer }) { + const tag = `${tagPrefix}-in`; + const forward = (node.topology || 'forward') === 'forward'; + const outboundTag = `${tagPrefix}-${forward ? 'exit' : 'direct'}`; + const networkMode = node.networkMode || 'ipv4'; + let outbound; + if (!forward) { + outbound = { type: 'direct', tag: outboundTag }; + } else { + const base = { tag: outboundTag, server: serverAddress(exitServer, networkMode), server_port: node.exitPort }; + const creds = node.exitCredentials; + const cipher = SHADOWSOCKS_METHODS[node.exitProtocol]; + if (cipher) outbound = { type: 'shadowsocks', ...base, method: cipher.method, password: creds.password }; + else if (node.exitProtocol === 'socks5-auth') outbound = { type: 'socks', ...base, version: '5', username: creds.socksUser, password: creds.password }; + else if (node.exitProtocol === 'vless-tcp') outbound = { type: 'vless', ...base, uuid: creds.clientId }; + else throw new Error(`Unsupported exit transport: ${node.exitProtocol}`); + } + return { + id: resourceId, engine: 'sing-box', + meta: { kind: 'node', nodeId: node.id, metricsTag: tag, protocol: node.protocol }, + inbounds: [buildSingBoxInbound({ tag, node, nodeClients, server, bindAddress })], + outbounds: [outbound], + routingRules: [{ inbound: [tag], action: 'route', outbound: outboundTag }], + }; +} + +function buildNodeResource({ resourceId, tagPrefix, node, nodeClients, server, bindAddress, exitServer }) { + // 全部走 sing-box,不再生成 Xray 配置 + return buildSingBoxNodeResource({ resourceId, tagPrefix, node, nodeClients, server, bindAddress, exitServer }); +} + +function buildNodeExitResource({ resourceId, tagPrefix, node, server, bindAddress }) { + const tag = `${tagPrefix}-exit-in`; + const directTag = `${tagPrefix}-exit-direct`; + const protocol = node.exitProtocol; + const creds = node.exitCredentials; + const networkMode = node.networkMode || 'ipv4'; + const listen = listenAddress(networkMode, server, bindAddress); + const cipher = SHADOWSOCKS_METHODS[protocol]; + let inbound; + if (cipher) { + inbound = { type: 'shadowsocks', tag, listen, listen_port: node.exitPort, + method: cipher.method, password: creds.password }; + } else if (protocol === 'vless-tcp') { + inbound = { type: 'vless', tag, listen, listen_port: node.exitPort, + users: [{ uuid: creds.clientId }] }; + } else if (protocol === 'socks5-auth') { + inbound = { type: 'socks', tag, listen, listen_port: node.exitPort, + users: [{ username: creds.socksUser, password: creds.password }] }; + } else { + throw new Error(`Unsupported exit transport: ${protocol}`); + } + return { + id: resourceId, engine: 'sing-box', + meta: { kind: 'node-exit', nodeId: node.id, metricsTag: tag }, + inbounds: [inbound], + outbounds: [{ type: 'direct', tag: directTag }], + routingRules: [{ inbound: [tag], action: 'route', outbound: directTag }], + }; } function serverAddress(server, networkMode) { - return networkMode === 'ipv6' && server.publicAddressV6 ? server.publicAddressV6 : server.publicAddress; -} - -function buildExitOutbound(tag, protocol, exitServer, exitPort, credentials, networkMode) { - const address = serverAddress(exitServer, networkMode); - const cipher = SHADOWSOCKS_METHODS[protocol]; - if (cipher) return { tag, protocol: 'shadowsocks', settings: { servers: [{ address, port: exitPort, method: cipher.method, password: credentials.exitPassword }] } }; - if (protocol === 'socks5-auth') return { tag, protocol: 'socks', settings: { servers: [{ address, port: exitPort, users: [{ user: credentials.socksUser, pass: credentials.exitPassword }] }] } }; - if (protocol === 'vless-tcp') { - return { - tag, protocol: 'vless', settings: { vnext: [{ address, port: exitPort, users: [{ id: credentials.clientId, encryption: 'none' }] }] }, - streamSettings: { network: 'raw', security: 'none' } - }; - } - throw new Error(`Unsupported exit transport: ${protocol}`); -} - -function singBoxOutbound(tag, protocol, exitServer, exitPort, credentials, networkMode) { - const base = { tag, server: serverAddress(exitServer, networkMode), server_port: exitPort }; - const cipher = SHADOWSOCKS_METHODS[protocol]; - if (cipher) return { type: 'shadowsocks', ...base, method: cipher.method, password: credentials.exitPassword }; - if (protocol === 'socks5-auth') return { type: 'socks', ...base, version: '5', username: credentials.socksUser, password: credentials.exitPassword }; - if (protocol === 'vless-tcp') return { type: 'vless', ...base, uuid: credentials.clientId }; - throw new Error(`Unsupported exit transport: ${protocol}`); -} - -function anytlsResource({ resourceId, tagPrefix, port, credentials, customer, networkMode, tlsDomain, exitProtocol, exitServer, exitPort }) { - if (!tlsDomain) throw new Error('AnyTLS 入口缺少有效证书域名'); - const tag = `${tagPrefix}-in`; - const outboundTag = `${tagPrefix}-${exitServer ? 'exit' : 'direct'}`; - return { - id: resourceId, engine: 'sing-box', - meta: { kind: exitServer ? 'relay' : 'direct', customerId: customer.id, metricsTag: tag, protocol: 'anytls' }, - inbounds: [{ type: 'anytls', tag, listen: listenAddress(networkMode), listen_port: port, - users: [{ name: `ng:${customer.id}`, password: credentials.relayPassword }], - tls: { enabled: true, server_name: tlsDomain, - certificate_path: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`, - key_path: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem` } }], - outbounds: [exitServer ? singBoxOutbound(outboundTag, exitProtocol, exitServer, exitPort, credentials, networkMode) - : { type: 'direct', tag: outboundTag }], - routingRules: [{ inbound: [tag], action: 'route', outbound: outboundTag }] - }; -} - -function buildRelayResource({ resourceId, tagPrefix, port, protocol, exitProtocol, exitServer, exitPort, credentials, customer, reality, networkMode, tlsDomain }) { - if (protocol === 'anytls') return anytlsResource({ resourceId, tagPrefix, port, credentials, customer, networkMode, tlsDomain, exitProtocol, exitServer, exitPort }); - const inbound = buildRelayInbound({ tag: `${tagPrefix}-in`, port, protocol, credentials, customer, reality, networkMode, tlsDomain }); - const outboundTag = `${tagPrefix}-exit`; - return { - id: resourceId, meta: { kind: 'relay', customerId: customer.id, metricsTag: inbound.tag, protocol }, - inbounds: [inbound], outbounds: [buildExitOutbound(outboundTag, exitProtocol, exitServer, exitPort, credentials, networkMode)], - routingRules: [{ type: 'field', inboundTag: [inbound.tag], outboundTag }] - }; -} - -function buildDirectResource({ resourceId, tagPrefix, port, protocol, credentials, customer, reality, networkMode, tlsDomain }) { - if (protocol === 'anytls') return anytlsResource({ resourceId, tagPrefix, port, credentials, customer, networkMode, tlsDomain }); - const inbound = buildRelayInbound({ tag: `${tagPrefix}-in`, port, protocol, credentials, customer, reality, networkMode, tlsDomain }); - const outboundTag = `${tagPrefix}-direct`; - return { - id: resourceId, meta: { kind: 'direct', customerId: customer.id, metricsTag: inbound.tag, protocol }, - inbounds: [inbound], outbounds: [{ tag: outboundTag, protocol: 'freedom', settings: {} }], - routingRules: [{ type: 'field', inboundTag: [inbound.tag], outboundTag }] - }; + if (!server) throw new Error('Missing server for address selection'); + if (networkMode === 'ipv6') return server.publicAddressV6; + return server.publicAddress; } function uriHost(address) { return net.isIP(String(address)) === 6 ? `[${address}]` : address; } -function buildClientUri({ protocol, relayServer, relayPort, credentials, reality, publicKey, name, networkMode, tlsDomain }) { +function buildClientUri({ protocol, relayServer, relayPort, credentials, reality, publicKey, name, networkMode, tlsDomain, ssServerKey }) { const label = encodeURIComponent(name); const publicAddress = serverAddress(relayServer, networkMode); const host = uriHost(publicAddress); @@ -235,14 +299,18 @@ function buildClientUri({ protocol, relayServer, relayPort, credentials, reality return `vless://${credentials.clientId}@${host}:${relayPort}?${query}#${label}`; } if (SHADOWSOCKS_METHODS[protocol]) { - const user = Buffer.from(`${SHADOWSOCKS_METHODS[protocol].method}:${credentials.relayPassword}`).toString('base64url'); + const method = SHADOWSOCKS_METHODS[protocol].method; + // SS2022 multi-user needs the EIH identity header: sing-box derives the + // identity subkey from the node's main PSK, so the client password must be + // ":" or the server rejects with "invalid request". + // Classic AEAD methods identify users by trying keys: user password alone. + const password = method.startsWith('2022-blake3-') && ssServerKey + ? `${ssServerKey}:${credentials.relayPassword}` + : credentials.relayPassword; + const user = Buffer.from(`${method}:${password}`).toString('base64url'); return `ss://${user}@${host}:${relayPort}#${label}`; } if (protocol === 'socks5-auth') return `socks5://${encodeURIComponent(credentials.socksUser)}:${encodeURIComponent(credentials.relayPassword)}@${host}:${relayPort}#${label}`; - if (protocol === 'vmess-ws') { - const payload = { v: '2', ps: name, add: publicAddress, port: String(relayPort), id: credentials.clientId, aid: '0', scy: 'auto', net: 'ws', type: 'none', host: '', path: credentials.wsPath, tls: '' }; - return `vmess://${Buffer.from(JSON.stringify(payload)).toString('base64')}`; - } return null; } @@ -259,6 +327,7 @@ function validateProtocolPair(relayProtocol, exitProtocol) { function isRealityProtocol(protocol) { return ['vless-reality-vision', 'vless-reality'].includes(protocol); } module.exports = { - PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, newCredentialSet, buildExitResource, - buildRelayResource, buildDirectResource, buildClientUri, validateEntryProtocol, validateProtocolPair, isRealityProtocol + PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, nodeEmail, newNodeClientCredentials, + buildNodeResource, buildNodeExitResource, ensureNodeSecrets, buildClientUri, validateEntryProtocol, validateProtocolPair, isRealityProtocol, + resolveBindAddress }; diff --git a/lib/store.js b/lib/store.js index a72a6cf..d137270 100644 --- a/lib/store.js +++ b/lib/store.js @@ -4,8 +4,8 @@ const fs = require('node:fs'); const path = require('node:path'); const COLLECTIONS = [ - 'users', 'servers', 'agents', 'enrollmentTokens', 'customers', 'chains', - 'deployments', 'jobs', 'activity', 'observations', 'subscriptionAccess', 'subscriptionClients' + 'users', 'servers', 'agents', 'enrollmentTokens', 'customers', 'nodes', + 'nodeClients', 'jobs', 'activity', 'observations', 'subscriptionAccess', 'subscriptionClients' ]; function emptyData() { @@ -20,7 +20,7 @@ function emptyData() { completedJobRetentionDays: 7 }, users: [], servers: [], agents: [], enrollmentTokens: [], customers: [], - chains: [], deployments: [], jobs: [], activity: [], observations: [], subscriptionAccess: [], subscriptionClients: [] + nodes: [], nodeClients: [], jobs: [], activity: [], observations: [], subscriptionAccess: [], subscriptionClients: [] }; } diff --git a/lib/subscriptions.js b/lib/subscriptions.js index 74f032e..7f10e5a 100644 --- a/lib/subscriptions.js +++ b/lib/subscriptions.js @@ -1,36 +1,47 @@ 'use strict'; -// Client URLs are only issued for confirmed, running entry resources. -function entries(data, customerId) { - const active = data.deployments.filter((item) => item.customerId === customerId && !item.archived && item.status === 'active' && - ['relay', 'direct'].includes(item.role) && typeof item.clientUri === 'string' && item.clientUri.length > 0 && - (item.role === 'direct' || data.deployments.some((exit) => exit.id === item.exitDeploymentId && exit.status === 'active' && !exit.archived))); - const seen = new Map(); - return active.map((item) => { - const chainName = data.chains?.find((chain) => chain.id === item.chainId)?.name; - if (!chainName) return item; - // Use the current route name even for links generated by older Agents. - // Multiple entries under one route still need distinct names in Clash. - const index = (seen.get(chainName) || 0) + 1; - seen.set(chainName, index); - const name = index === 1 ? chainName : `${chainName} (${index})`; - if (item.clientUri.startsWith('vmess://')) { - const payload = JSON.parse(Buffer.from(item.clientUri.slice(8), 'base64').toString('utf8')); - return { ...item, clientUri:`vmess://${Buffer.from(JSON.stringify({ ...payload, ps:name })).toString('base64')}` }; - } - const separator = item.clientUri.lastIndexOf('#'); - return { ...item, clientUri:`${item.clientUri.slice(0, separator < 0 ? undefined : separator)}#${encodeURIComponent(name)}` }; +const { buildClientUri, nodeEmail } = require('./protocols'); + +// Client URLs are only issued for confirmed, running nodes. Each link is built +// on the fly from the node's current config and the customer's own credentials +// for that node (3x-ui style: shared inbound, per-client identity). +function nodeClientUri(data, nodeClient) { + const node = data.nodes.find((item) => item.id === nodeClient.nodeId); + if (!node || node.status !== 'active') return null; + const server = data.servers.find((item) => item.id === node.serverId); + if (!server) return null; + return buildClientUri({ + protocol: node.protocol, relayServer: server, relayPort: node.port, + credentials: { ...nodeClient.credentials, wsPath: node.wsPath, socksUser: nodeEmail(nodeClient.customerId) }, + reality: node.reality, publicKey: node.artifacts && node.artifacts.realityPublicKey, + name: node.name, networkMode: node.networkMode || 'ipv4', tlsDomain: server.tlsDomain, + ssServerKey: node.ssServerKey, }); } +function entries(data, customerId) { + const seen = new Map(); + const items = []; + for (const nodeClient of data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active')) { + const uri = nodeClientUri(data, nodeClient); + if (!uri) continue; + const node = data.nodes.find((item) => item.id === nodeClient.nodeId); + const nodeName = node ? node.name : nodeClient.nodeId; + // Use the current node name even for links generated by older Agents. + const index = (seen.get(nodeName) || 0) + 1; + seen.set(nodeName, index); + const name = index === 1 ? nodeName : `${nodeName} (${index})`; + const separator = uri.lastIndexOf('#'); + const clientUri = `${uri.slice(0, separator < 0 ? undefined : separator)}#${encodeURIComponent(name)}`; + items.push({ nodeId: nodeClient.nodeId, clientUri }); + } + return items; +} + function q(value) { return JSON.stringify(String(value)); } function clashProxy(item) { const uri = item.clientUri; - if (uri.startsWith('vmess://')) { - const value = JSON.parse(Buffer.from(uri.slice(8), 'base64').toString('utf8')); - return { name:value.ps, type:'vmess', server:value.add, port:Number(value.port), uuid:value.id, alterId:0, cipher:'auto', network:'ws', 'ws-opts':{ path:value.path } }; - } const name = decodeURIComponent(uri.slice(uri.lastIndexOf('#') + 1)); const url = new URL(uri); const base = { name, server:url.hostname.replace(/^\[|\]$/g, ''), port:Number(url.port) }; @@ -104,14 +115,13 @@ function makeClash(items, smart = false) { function makeSurge(items) { // Surge does not accept VLESS/Reality policies. Omit unsupported nodes rather // than handing clients an importable profile that cannot establish a tunnel. - const proxies = items.map(clashProxy).filter((item) => ['ss','socks5','vmess'].includes(item.type)); + const proxies = items.map(clashProxy).filter((item) => ['ss','socks5'].includes(item.type)); if (!proxies.length) return null; const safe = (value) => String(value).replace(/[\r\n,=]/g, '_'); const policies = proxies.map((proxy) => { const prefix = `${safe(proxy.name)} = `; if (proxy.type === 'ss') return `${prefix}ss, ${proxy.server}, ${proxy.port}, encrypt-method=${proxy.cipher}, password=${safe(proxy.password)}`; - if (proxy.type === 'socks5') return `${prefix}socks5, ${proxy.server}, ${proxy.port}, ${safe(proxy.username)}, ${safe(proxy.password)}`; - return `${prefix}vmess, ${proxy.server}, ${proxy.port}, username=${proxy.uuid}, vmess-aead=true, ws=true, ws-path=${safe(proxy['ws-opts'].path)}`; + return `${prefix}socks5, ${proxy.server}, ${proxy.port}, ${safe(proxy.username)}, ${safe(proxy.password)}`; }); const names = proxies.map((proxy) => safe(proxy.name)).join(', '); return `[General]\nloglevel = notify\n[Proxy]\n${policies.join('\n')}\n[Proxy Group]\n节点选择 = select, ${names}, DIRECT\n自动选择 = url-test, ${names}, url=http://www.gstatic.com/generate_204, interval=600\n[Rule]\nGEOIP,CN,DIRECT\nFINAL,节点选择\n`; @@ -124,7 +134,6 @@ function makeSingBox(items) { if (proxy.type === 'hysteria2') return { type:'hysteria2', ...base, password:proxy.password, tls:{ enabled:true, server_name:proxy.sni, alpn:['h3'] } }; if (proxy.type === 'anytls') return { type:'anytls', ...base, password:proxy.password, tls:{ enabled:true, server_name:proxy.sni } }; if (proxy.type === 'socks5') return { type:'socks', ...base, version:'5', username:proxy.username, password:proxy.password }; - if (proxy.type === 'vmess') return { type:'vmess', ...base, uuid:proxy.uuid, security:'auto', alter_id:0, transport:{ type:'ws', path:proxy['ws-opts'].path } }; const outbound = { type:'vless', ...base, uuid:proxy.uuid }; if (proxy.flow) outbound.flow = proxy.flow; if (proxy.network === 'ws') outbound.transport = { type:'ws', path:proxy['ws-opts'].path }; @@ -151,4 +160,4 @@ function formatSubscription(data, customer, format, userAgent = '') { return undefined; } -module.exports = { entries, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription }; +module.exports = { entries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription }; diff --git a/openrc/nexusgate b/openrc/nexusgate new file mode 100644 index 0000000..917b794 --- /dev/null +++ b/openrc/nexusgate @@ -0,0 +1,35 @@ +#!/bin/sh /etc/rc.common +# NexusGate 控制面 procd 服务(OpenWrt) +# 由 scripts/install.sh 安装到 /etc/init.d/nexusgate + +USE_PROCD=1 +START=99 +STOP=10 + +ENV_FILE=/etc/nexusgate.env +APP_DIR=/opt/nexusgate + +start_service() { + [ -f "$ENV_FILE" ] || return 1 + [ -x /usr/bin/node ] || return 1 + [ -f "$APP_DIR/server.js" ] || return 1 + procd_open_instance nexusgate + procd_set_param command /usr/bin/node --max-old-space-size=128 --optimize-for-size "$APP_DIR/server.js" + # 注意:procd 的 env 参数不支持多次调用累加(后一次覆盖前一次),必须把所有变量攒到一次调用里 + set -- + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + *=*) set -- "$@" "$line" ;; + esac + done < "$ENV_FILE" + [ $# -gt 0 ] && procd_set_param env "$@" + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +stop_service() { + return 0 +} diff --git a/openrc/nexusgate-agent b/openrc/nexusgate-agent index ceb5f7e..fe370ac 100755 --- a/openrc/nexusgate-agent +++ b/openrc/nexusgate-agent @@ -9,7 +9,7 @@ error_log="/var/log/nexusgate/agent-error.log" depend() { need net - use nexusgate-xray + use nexusgate-sing-box } start_pre() { diff --git a/openrc/nexusgate-xray b/openrc/nexusgate-xray deleted file mode 100755 index af23dd1..0000000 --- a/openrc/nexusgate-xray +++ /dev/null @@ -1,17 +0,0 @@ -#!/sbin/openrc-run - -description="NexusGate managed Xray core" -command="/usr/local/bin/xray" -command_args="run -config /etc/nexusgate/xray/config.json" -command_background="yes" -pidfile="/run/nexusgate-xray.pid" -output_log="/var/log/nexusgate/xray-service.log" -error_log="/var/log/nexusgate/xray-service-error.log" - -depend() { - need net -} - -start_pre() { - checkpath --directory --mode 0750 /var/log/nexusgate -} diff --git a/package.json b/package.json index bd04eea..7bc0c0f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "node": ">=18" }, "scripts": { - "start": "node server.js", + "start": "node --max-old-space-size=128 --optimize-for-size server.js", "test": "node --test", "check": "node --check server.js && node --check agent/agent.js && node --check public/app.js && node --check scripts/reset-password.js && bash -n scripts/nexusgate.sh" }, diff --git a/public/app.js b/public/app.js index fac35f8..7a53319 100644 --- a/public/app.js +++ b/public/app.js @@ -2,7 +2,7 @@ const state = { session: null, page: 'overview', overview: null, servers: [], customers: [], - chains: [], deployments: [], jobs: [], protocols: [], realityPresets: [], search: '', version: '0.6.8' + nodes: [], nodeClients: [], jobs: [], protocols: [], realityPresets: [], search: '', version: '0.6.8' }; const $ = (selector, root = document) => root.querySelector(selector); @@ -18,20 +18,43 @@ const fmtBytes = (value) => { }; const oldUsageAgent = (version) => { const parts = String(version || '').match(/^(\d+)\.(\d+)\.(\d+)/); return parts && Number(parts[1]) === 0 && (Number(parts[2]) < 6 || (Number(parts[2]) === 6 && Number(parts[3]) < 3)); }; +async function copyText(text) { + text = String(text ?? ''); + if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return; + } + // HTTP 下 clipboard API 不可用,降级用 execCommand。 + // 注意:弹窗是 模态的,此时 document.body 处于 inert 状态, + // textarea 必须插进弹窗内部,否则 select/execCommand 会复制走弹窗内的当前选区。 + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;'; + const dialog = $('#modal'); + const host = (dialog && dialog.open) ? dialog : document.body; + host.appendChild(ta); + ta.focus(); + ta.select(); + try { ta.setSelectionRange(0, ta.value.length); } catch (e) {} + let ok = false; + try { ok = document.execCommand('copy'); } catch (e) { ok = false; } + ta.remove(); + if (!ok) throw new Error('复制失败,请手动长按复制'); +} const splitList = (value) => String(value || '').split(',').map((item) => item.trim()).filter(Boolean); const statusText = { online:'在线', offline:'离线', pending:'待注册', active:'运行中', draft:'草稿', deploying:'部署中', queued:'排队中', - running:'执行中', completed:'已完成', failed:'失败', degraded:'异常', suspended:'已暂停', suspending:'暂停中', partially_suspended:'部分暂停', removing:'停用中', deleted:'已删除', - redeploying:'重建中', redeploy_pending:'等待重建', changes_pending:'待重新部署' + running:'执行中', completed:'已完成', failed:'失败', degraded:'异常', suspended:'已暂停', removing:'停用中', deleted:'已删除' }; const roleText = { relay:'入口 / 转发', exit:'出口 / 落地', hybrid:'综合节点' }; const topologyText = { forward:'转发线路', direct:'单机直连' }; -const deploymentRoleText = { relay:'客户端入口', exit:'出口传输', direct:'单机节点' }; const auditText = { create_server:'添加设备', update_server:'编辑设备', delete_server:'删除设备', create_enrollment:'生成注册令牌', enroll_agent:'Agent 注册', reconcile_server:'设备配置对账', set_tls_domain:'节点证书同步', create_customer:'添加客户', update_customer:'编辑客户', reset_customer_usage:'重置客户流量', delete_customer:'删除客户', - create_chain:'创建线路', update_route:'编辑线路', deploy_route:'部署线路', redeploy_route:'重新部署线路', repair_route:'修复线路', remove_route:'停用线路', - delete_chain:'删除线路', retry_cleanup:'重试遗留清理', force_forget_server:'强制遗忘离线设备', update_account:'修改管理员账号', suspend_ip_limit:'IP 超限停用', rotate_subscription:'重置订阅链接' + create_node:'创建节点', update_node:'编辑节点', deploy_node:'部署节点', repair_node:'修复节点', remove_node:'停用节点', + delete_node:'删除节点', assign_customer:'分配客户', unassign_customer:'移除分配', suspend_customer_resources:'暂停客户资源', resume_customer_resources:'恢复客户资源', + retry_cleanup:'重试遗留清理', force_forget_server:'强制遗忘离线设备', update_account:'修改管理员账号', suspend_ip_limit:'IP 超限停用', rotate_subscription:'重置订阅链接' }; function setTheme(theme) { @@ -73,17 +96,17 @@ function showApp() { async function load(page = state.page) { try { if (page === 'overview') { - const [overview, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/servers'), api('/api/customers'), api('/api/chains')]); - state.overview = overview; state.version = overview.version || state.version; state.servers = servers.servers; state.customers = customers.customers; state.chains = routes.chains; state.deployments = routes.deployments; + const [overview, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/servers'), api('/api/customers'), api('/api/nodes')]); + state.overview = overview; state.version = overview.version || state.version; state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; } else if (page === 'servers') state.servers = (await api('/api/servers')).servers; - else if (page === 'customers') state.customers = (await api('/api/customers')).customers; + else if (page === 'customers') { + const [customers, routes] = await Promise.all([api('/api/customers'), api('/api/nodes')]); + state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; + } else if (page === 'chains') { - const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/chains'), api('/api/protocols')]); - state.servers = servers.servers; state.customers = customers.customers; state.chains = routes.chains; state.deployments = routes.deployments; + const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/nodes'), api('/api/protocols')]); + state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || []; - } else if (page === 'deployments') { - const [servers, customers, routes] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/chains')]); - state.servers = servers.servers; state.customers = customers.customers; state.chains = routes.chains; state.deployments = routes.deployments; } else if (page === 'commands') { /* Command reference needs no API request. */ } else if (page === 'operations') { const [jobs, servers, overview] = await Promise.all([api('/api/jobs'), api('/api/servers'), api('/api/overview')]); @@ -96,7 +119,7 @@ async function load(page = state.page) { function setPage(page) { state.page = page; state.search = ''; closeSidebar(); $$('#nav button').forEach((button) => button.classList.toggle('active', button.dataset.page === page)); - const titles = { overview:'概览', servers:'服务器', customers:'客户与订阅', chains:'转发与节点', deployments:'部署与链接', commands:'安装与命令', operations:'设置与运维' }; + const titles = { overview:'概览', servers:'服务器', customers:'客户与订阅', chains:'转发与节点', commands:'安装与命令', operations:'设置与运维' }; $('#page-title').textContent = titles[page]; $('#breadcrumb').textContent = `NEXUSGATE / ${titles[page]}`; load(page); } @@ -112,15 +135,15 @@ function renderOverview() { const metrics = [ ['受管设备', counts.servers || 0, `${counts.online || 0} 台在线`, '#38bd91'], ['客户账户', counts.customers || 0, '额度与到期统一管理', '#5877f4'], - ['编排线路', counts.chains || 0, '转发与单机节点', '#9667e8'], - ['活动部署', counts.activeDeployments || 0, '实际运行资源', '#e8a63c'], + ['共享节点', counts.nodes || 0, '多客户共用入口', '#9667e8'], + ['运行中节点', counts.activeNodes || 0, '实际运行资源', '#e8a63c'], ['待办任务', counts.queuedJobs || 0, 'Agent 自动领取', '#e5667c'] ]; const serverRows = state.servers.slice(0, 7).map((server) => `
${esc(server.name)}${esc(roleText[server.role] || server.role)} · ${esc(server.region || server.publicAddress)}
${status(server.status)}
`).join(''); const events = ((state.overview && state.overview.activity) || []).map((event) => `
${esc(auditText[event.action] || event.action)}${esc(event.actor)} · ${esc(event.target)}
`).join(''); const failures = ((state.overview && state.overview.degraded) || []).length; return `
${metrics.map(([label,value,note,color]) => `
${label}${value}${note}
`).join('')}
- ${failures ? `
检测到 ${failures} 个失败部署。请到“转发与节点”查看错误后使用“修复失败项”。更新 Agent 后无需重建节点。
` : ''} + ${failures ? `
检测到 ${failures} 个异常节点。请到“转发与节点”查看错误后使用“修复”。更新 Agent 后无需重建节点。
` : ''}

设备健康

超过三分钟未上报将标记离线

${serverRows || empty('还没有设备','先添加入口、出口或综合节点')}

最近活动

重要操作审计记录

${events || empty('暂无活动','操作记录会显示在这里')}
`; } @@ -155,67 +178,84 @@ function renderCustomers() { return `${esc(item.name)}${esc(item.group || '未分组')}${status(item.status)}${item.status === 'suspended' ? `${esc(({ traffic_limit:'额度耗尽', expired:'已到期', ip_limit:'节点 IP 超限', manual:'手动停用' })[item.suspendReason] || '待恢复')}` : ''} ${fmtBytes(item.usedBytes)} / ${item.trafficLimitBytes ? fmtBytes(item.trafficLimitBytes) : '不限'}上行 ${fmtBytes(item.usedUplinkBytes)} · 下行 ${fmtBytes(item.usedDownlinkBytes)}
${item.lastUsageAt ? `最后计量 ${fmtDate(item.lastUsageAt)}` : '尚未收到入口流量统计'} ${item.expiresAt ? fmtDate(item.expiresAt) : '不限期'}节点 IP ${item.observedIpCount || 0} / ${item.ipLimit || '不限'}订阅客户端约 ${item.subscriptionClientCount || 0} / ${item.deviceLimit || '不限'}${tags(item.tags)} - ${rowActions(item.id, ``, [['reset-usage','流量清零'],['toggle-customer',item.status === 'active' ? '停用' : '启用'],['delete-customer','删除客户']])}${item.pendingCleanup ? `待清理 ${item.pendingCleanup} 项` : ''}`; + ${rowActions(item.id, ``, [['assign-nodes','分配节点'],['reset-usage','流量清零'],['toggle-customer',item.status === 'active' ? '停用' : '启用'],['delete-customer','删除客户']])}`; }).join(''); - return `

流量按入口上行+下行双向累计,Agent 约每分钟上报。额度、到期或节点 IP 触发暂停后,调整限制会自动恢复原节点;待 Agent 完成恢复任务,订阅才可更新。旧版已经变为草稿的线路可点“恢复原节点”。

+ return `

流量按入口上行+下行双向累计,Agent 约每分钟上报,按客户跨节点汇总。额度、到期或节点 IP 触发暂停后,调整限制会自动恢复;待 Agent 完成恢复任务,订阅才可更新。

${state.customers.length} 位
-
${rows || ``}
客户状态流量到期使用限制标签操作
${empty('还没有客户','先创建客户,再编排线路')}
`; +
${rows || ``}
客户状态流量到期使用限制标签操作
${empty('还没有客户','先创建客户,再到“转发与节点”分配')}
`; } -function chainActions(chain) { - const edit = ``; - const remove = ``; - if (chain.status === 'draft') return `${edit}${chain.generation > 0 ? `` : ``}${remove}`; - if (chain.status === 'suspended') return `${edit}${state.customers.some((item) => chain.customerIds.includes(item.id) && item.status === 'active') ? `` : ''}${remove}`; - if (chain.status === 'changes_pending') return `${edit}`; - if (chain.status === 'degraded') return `${edit}${remove}`; - if (['active','deploying','suspending','partially_suspended'].includes(chain.status)) return `${edit}`; - return `${edit}${remove}`; +function nodeActions(node) { + const edit = ``; + const clients = ``; + const remove = ``; + if (node.status === 'draft') return `${edit}${clients}${remove}`; + if (['failed', 'degraded'].includes(node.status)) return `${edit}${clients}${remove}`; + if (['active', 'deploying'].includes(node.status)) return `${edit}${clients}${remove}`; + return `${edit}${clients}${remove}`; } function rowActions(id, primary, more) { return `
${primary}
`; } -function compactChainActions(chain) { - const actions = chainActions(chain).match(/]*>[^<]*<\/button>/g) || []; +function compactNodeActions(node) { + const actions = nodeActions(node).match(/]*>[^<]*<\/button>/g) || []; const primary = actions.slice(0, 2).join(''); const more = actions.slice(2).map((html) => [(html.match(/data-action="([^"]+)"/) || [,''])[1], html.replace(/<[^>]+>/g, '')]); - return more.length ? rowActions(chain.id, primary, more) : `
${primary}
`; + return more.length ? rowActions(node.id, primary, more) : `
${primary}
`; } -function renderChains() { - const rows = state.chains.map((chain) => { - const direct = (chain.topology || 'forward') === 'direct'; - const path = direct ? protocolName(chain.relayProtocol, 'relay-ingress') : `${protocolName(chain.relayProtocol, 'relay-ingress')} → ${protocolName(chain.exitProtocol, 'exit-transport')}`; - const exit = direct ? '本机直出' : ((state.servers.find((item) => item.id === chain.exitServerId) || {}).name || '已删除'); - const applied = state.deployments.filter((item) => item.chainId === chain.id && ['active','queued','deploying'].includes(item.status) && item.role !== 'exit'); - const drift = applied.some((item) => item.protocol !== chain.relayProtocol); - return `${esc(chain.name)}${esc(topologyText[chain.topology || 'forward'])} · ${esc(path)}${drift ? '编辑的协议尚未应用,当前节点见“部署与链接”' : ''} - ${esc(names(chain.relayServerIds, state.servers))}${esc(chain.networkMode || 'ipv4').toUpperCase()}${esc(exit)} - ${esc(names(chain.customerIds, state.customers))}${status(chain.status)}${chain.lastError ? `${esc(chain.lastError)}` : ''}${compactChainActions(chain)}`; - }).join(''); - return `

“转发线路”把多个客户端入口汇聚到一个出口;“单机直连”无需出口机,直接在任意设备创建节点。编辑运行中线路后,点击“应用修改”安全重建。

-
${rows || ``}
线路入口 / 节点设备出口客户状态操作
${empty('还没有线路','准备好设备和客户后创建第一条线路')}
`; +function nodeClientCount(nodeId) { + return state.nodeClients.filter((nc) => nc.nodeId === nodeId && nc.status === 'active').length; } -function renderDeployments() { - const active = state.deployments.filter((item) => item.status !== 'deleted'); - const rows = active.map((item) => { - const server = state.servers.find((entry) => entry.id === item.serverId); - const customer = state.customers.find((entry) => entry.id === item.customerId); - const chain = state.chains.find((entry) => entry.id === item.chainId); - const canCopy = item.status === 'active' && item.clientUri && customer?.status === 'active' && - (!customer.expiresAt || Date.parse(customer.expiresAt) > Date.now()) && - (!(customer.trafficLimitBytes > 0) || customer.usedBytes < customer.trafficLimitBytes) && - (item.role === 'direct' || state.deployments.some((exit) => exit.id === item.exitDeploymentId && exit.status === 'active')); - return `${esc(chain ? chain.name : '已删除线路')}${esc(deploymentRoleText[item.role] || item.role)}${esc(customer ? customer.name : '已删除')} - ${esc(server ? server.name : '已删除')}${esc(server ? (server.publicAddressV6 && chain && chain.networkMode === 'ipv6' ? server.publicAddressV6 : server.publicAddress) : '')}:${item.port} - ${esc(protocolName(item.protocol, item.role === 'exit' ? 'exit-transport' : 'relay-ingress'))}${chain && item.role !== 'exit' && item.protocol !== chain.relayProtocol ? '编辑中的协议尚未部署' : ''}${status(item.status)}${item.error ? `${esc(item.error)}` : ''}${item.role !== 'exit' ? `${item.meteredTraffic ? `本部署计量:上行 ${fmtBytes(item.meteredTraffic.uplink)} · 下行 ${fmtBytes(item.meteredTraffic.downlink)}` : '等待入口计量上报'}${item.lastUsageAt ? `最后上报 ${fmtDate(item.lastUsageAt)}` : ''}` : ''} -
${canCopy ? `` : ''}${chain ? `` : ''}
`; +function renderNodes() { + const rows = state.nodes.map((node) => { + const direct = (node.topology || 'forward') === 'direct'; + const path = direct ? protocolName(node.protocol, 'relay-ingress') : `${protocolName(node.protocol, 'relay-ingress')} → ${protocolName(node.exitProtocol, 'exit-transport')}`; + const server = state.servers.find((item) => item.id === node.serverId); + const exit = direct ? '本机直出' : ((state.servers.find((item) => item.id === node.exitServerId) || {}).name || '已删除'); + const traffic = node.meteredTraffic ? `上行 ${fmtBytes(node.meteredTraffic.uplink)} · 下行 ${fmtBytes(node.meteredTraffic.downlink)}` : '等待入口计量上报'; + return `${esc(node.name)}${esc(topologyText[node.topology || 'forward'])} · ${esc(path)} + ${esc(server ? server.name : '已删除')}${esc(node.protocol)} :${node.port} · ${esc(node.networkMode || 'ipv4').toUpperCase()}${esc(exit)} + ${nodeClientCount(node.id)} 位${traffic}${node.lastUsageAt ? `最后上报 ${fmtDate(node.lastUsageAt)}` : ''} + ${status(node.status)}${node.error ? `${esc(node.error)}` : ''}${compactNodeActions(node)}`; }).join(''); - return `

显示线路生成的实际入口、出口和单机节点。客户端链接只在入口资源部署成功后生成;编辑请从对应线路统一完成。

-
${rows || ``}
线路 / 角色客户设备协议状态 / 错误操作
${empty('暂无部署','从“线路编排”页面发起部署')}
`; + return `

一个节点是一个共享入口(对标 3x-ui 的 inbound):多位客户共用同一端口,各自使用独立凭据。分配 / 移除客户后节点自动重建下发;编辑运行中的节点会立即重建应用。

+
${rows || ``}
节点服务器 / 入口出口客户端流量状态操作
${empty('还没有节点','准备好设备和客户后创建第一个共享节点')}
`; +} + +function renderNodeLinks() { + // 按节点分组:一个节点一行,客户收纳为可展开列表 + const byNode = new Map(); + for (const nc of state.nodeClients) { + if (!byNode.has(nc.nodeId)) byNode.set(nc.nodeId, []); + byNode.get(nc.nodeId).push(nc); + } + const rows = [...byNode].map(([nodeId, ncs]) => { + const node = state.nodes.find((item) => item.id === nodeId); + const server = node && state.servers.find((item) => item.id === node.serverId); + const nodeActive = node && node.status === 'active'; + const customerRows = ncs.map((nc) => { + const customer = state.customers.find((item) => item.id === nc.customerId); + const canCopy = nc.status === 'active' && nodeActive && nc.clientUri && customer && customer.status === 'active'; + const name = esc(customer ? customer.name : '已删除客户'); + const st = status(nc.status); + const link = canCopy ? `` : '未运行或已停用'; + return ``; + }).join(''); + const names = ncs.map((nc) => { + const c = state.customers.find((item) => item.id === nc.customerId); + return esc(c ? c.name : '?'); + }).join('、'); + return `${esc(node ? node.name : '已删除节点')}${esc(server ? server.name : '')}${node ? ` :${node.port}` : ''} +
${names}(${ncs.length})
+ ${node ? status(node.status) : status('draft')} + ${ncs.length} 个分配`; + }).join(''); + return `

每个客户在每个已分配节点上拥有独立凭据。客户端链接只在节点运行中且客户正常时生成;日常分发请使用客户页的订阅链接。

+
${rows || ``}
节点客户状态链接
${empty('暂无分配','先在“转发与节点”给客户分配节点')}
`; } function renderCommands() { @@ -228,12 +268,12 @@ function renderCommands() { ['入口机 / 中转机', [ ['更新 Agent', 'ng-agent update', '更新后自动恢复心跳;已有线路无需重建。'], ['申请或续签节点证书', 'ng-agent cert', '按提示输入已解析到本机的域名。公网 80/TCP 可用时选择 HTTP;否则选择 Cloudflare DNS 并输入 Zone DNS Edit API Token。已有有效证书不必重复申请。'], - ['安装 AnyTLS 引擎', 'ng-agent engine install', '仅在承载 AnyTLS 的入口执行。首次构建可能耗时,临时 Go 缓存完成后会清理。HY2 与 VLESS WS TLS 无需该引擎。'], - ['检查证书、引擎与双向计数', 'ng-agent doctor', '核对本机证书、Xray、sing-box 与入口统计。'] + ['安装 sing-box 引擎', 'ng-agent engine install', '所有入口/出口机都需要。首次构建可能耗时,临时 Go 缓存完成后会清理。'], + ['检查证书、引擎与双向计数', 'ng-agent doctor', '核对本机证书、sing-box 与入口统计。'] ]], ['出口机 / 落地机', [ - ['更新轻量 Agent', 'ng-agent update', '只使用出口传输时无需安装 sing-box,也无需申请入口证书。'], - ['检查运行状态', 'ng-agent doctor', '核对心跳和 Xray 配置。'] + ['更新 Agent', 'ng-agent update', '入口/出口机通用;出口机无需申请入口证书。'], + ['检查运行状态', 'ng-agent doctor', '核对心跳和 sing-box 配置。'] ]] ]; const sections = groups.map(([title, commands]) => `

${esc(title)}

${commands.map(([label, command, hint]) => `
${esc(label)}${esc(hint)}${esc(command)}
`).join('')}
`).join(''); @@ -251,7 +291,7 @@ function renderOperations() { } function render() { - const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderChains, deployments: renderDeployments, commands: renderCommands, operations: renderOperations }; + const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, commands: renderCommands, operations: renderOperations }; $('#content').innerHTML = views[state.page](); } @@ -313,8 +353,19 @@ function subscriptionModal(customer) { ['base64','通用 Base64'], ['clash','Mihomo / Clash 简洁版'], ['clash-smart','Mihomo / Clash 智能分流'], ['singbox','sing-box JSON'], ['surge','Surge(仅兼容节点)'], ['raw','原始节点链接'] ]; + // 该用户的各节点入站链接 + const infoUrl = `${location.origin}/s/${customer.subscriptionToken}/info`; + const nodeLinks = state.nodeClients.filter((nc) => nc.customerId === customer.id && nc.status === 'active').map((nc) => { + const node = state.nodes.find((n) => n.id === nc.nodeId); + if (!node || node.status !== 'active' || !nc.clientUri) return ''; + const server = state.servers.find((s) => s.id === node.serverId); + const label = `${node.name} (${server ? server.name : '?'}) :${node.port}`; + return `
${esc(label)}${esc(nc.clientUri.slice(0, 60))}...
`; + }).join(''); modal('CLIENT SUBSCRIPTION', `${customer.name} · 订阅链接`, `
仅展示已成功部署的入口节点。客户停用、到期或流量用尽时链接停止分发。重置令牌会立即使旧订阅地址失效;已复制的节点凭据仍需停用或重建线路才能撤销。
+
📱 订阅信息页(3x-ui 风格)${esc(infoUrl)}
${formats.map(([format,label]) => { const url = `${location.origin}/s/${customer.subscriptionToken}/${format}`; return `
${esc(label)}${esc(url)}
`; }).join('')} + ${nodeLinks ? `

各节点入站链接

${nodeLinks}` : ''}
二维码在当前浏览器本地生成,不向第三方图片服务发送订阅令牌。Surge 只包含它原生支持的节点协议。
订阅客户端数按客户端标识或 User-Agent 估算,不能证明物理设备数量;共享节点链接可复制,订阅请求被拒后已导入节点仍可连接。节点 IP 超限需要等待 Agent 上报和清理任务。
`); @@ -330,49 +381,73 @@ async function customerAccessModal(customer) {
`); } -function chainForm(item = null) { - const chain = item || { topology:'forward', relayProtocol:'vless-reality-vision', exitProtocol:'shadowsocks-2022-aes128', relayPortMode:'random', exitPortMode:'random', networkMode:'ipv4', realityServerName:'www.tesla.com', realityDestPort:443, relayServerIds:[], customerIds:[] }; - const preset = state.realityPresets.find((entry) => entry.serverName === chain.realityServerName) || { id:'custom' }; - modal(item ? 'EDIT ROUTE' : 'NEW ROUTE', item ? '编辑线路' : '新建线路', `
- - - -
入口与客户端节点
-
入口 / 节点设备${multiPicker('relayServerIds',state.servers,chain.relayServerIds,(server) => `${roleText[server.role]} · ${server.region || server.publicAddress} · ${server.publicAddress}`,'设备')}
- - +function nodeForm(item = null) { + const node = item || { topology:'forward', protocol:'vless-reality-vision', exitProtocol:'shadowsocks-2022-aes128', portMode:'random', exitPortMode:'random', networkMode:'ipv4', realityServerName:'www.tesla.com', realityDestPort:443 }; + const preset = state.realityPresets.find((entry) => entry.serverName === node.realityServerName) || { id:'custom' }; + modal(item ? 'EDIT NODE' : 'NEW NODE', item ? '编辑节点' : '新建节点', ` + + + + +
- + +
出口与转发传输
- - - -
客户分配
-
客户${multiPicker('customerIds',state.customers.filter((customer) => customer.status === 'active' || (chain.customerIds || []).includes(customer.id)),chain.customerIds,(customer) => `${customer.group || '未分组'}${customer.status !== 'active' ? ' · 已停用' : ''}`,'客户')}批量选择多个客户时请使用随机端口。
-
VLESS WS TLS、Hysteria 2 和 AnyTLS 需要入口证书。到入口机运行 ng-agent cert,按提示申请并同步域名;Hysteria 2 使用 UDP,其余使用 TCP。AnyTLS 由 sing-box 承载,缺少引擎时运行 ng-agent engine install。
- ${item && !['draft'].includes(item.status) ? '
保存运行中线路只会标记“待重新部署”,不会立即中断服务。确认后再点击“应用修改”。
' : ''} -
`); - syncChainForm(); + + + +
保存运行中的节点会立即重建下发(sing-box 重启一次)。VLESS WS TLS、Hysteria 2 和 AnyTLS 需要入口证书;Hysteria 2 使用 UDP,其余使用 TCP。全部由 sing-box 承载。
+
`); + syncNodeForm(); } -function syncChainForm() { - const form = $('#chain-form'); if (!form) return; +function syncNodeForm() { + const form = $('#node-form'); if (!form) return; const direct = form.elements.topology.value === 'direct'; $$('[data-forward-only]', form).forEach((element) => element.classList.toggle('hidden', direct)); form.elements.exitServerId.required = !direct; form.elements.exitProtocol.required = !direct; - const reality = ['vless-reality-vision','vless-reality'].includes(form.elements.relayProtocol.value); + const reality = ['vless-reality-vision','vless-reality'].includes(form.elements.protocol.value); $$('[data-reality-only]', form).forEach((element) => element.classList.toggle('hidden', !reality)); form.elements.realityServerName.required = reality; - const protocol = state.protocols.find((item) => item.id === form.elements.relayProtocol.value && item.role === 'relay-ingress'); + const protocol = state.protocols.find((item) => item.id === form.elements.protocol.value && item.role === 'relay-ingress'); $('[data-protocol-note]', form).textContent = protocol ? `入口:${protocol.description} ${reality ? 'Reality 默认使用 Tesla SNI,可从下拉菜单切换。' : '此协议不需要 Reality SNI。'}` : '请选择可部署的入口协议。'; - for (const [mode, port] of [['relayPortMode','relayPort'],['exitPortMode','exitPort']]) { - const fixed = form.elements[mode].value === 'fixed' && (mode === 'relayPortMode' || !direct); + for (const [mode, port] of [['portMode','port'],['exitPortMode','exitPort']]) { + const fixed = form.elements[mode].value === 'fixed' && (mode === 'portMode' || !direct); form.elements[port].disabled = !fixed; form.elements[port].required = fixed; } } +function nodeClientsModal(node) { + const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id); + const available = state.customers.filter((customer) => customer.status === 'active' && !assigned.some((nc) => nc.customerId === customer.id)); + const rows = assigned.map((nc) => { + const customer = state.customers.find((item) => item.id === nc.customerId); + return `${esc(customer ? customer.name : '已删除客户')}${esc(customer ? (customer.group || '未分组') : nc.customerId)} + ${status(nc.status)} +
${nc.clientUri ? `` : ''}
`; + }).join(''); + modal('NODE CLIENTS', `${node.name} · 客户端分配`, `
+
多位客户共用同一入口端口,各自使用独立凭据。分配 / 移除后节点自动重建下发(sing-box 重启一次)。
+
${rows || ``}
客户状态操作
${empty('暂无客户端','从下方选择客户分配')}
+ ${available.length ? `
` : '没有可分配的活跃客户。'} +
`); +} + +function assignNodesModal(customer) { + const assigned = new Set(state.nodeClients.filter((nc) => nc.customerId === customer.id).map((nc) => nc.nodeId)); + const rows = state.nodes.map((node) => { + const server = state.servers.find((item) => item.id === node.serverId); + return ``; + }).join(''); + modal('ASSIGN NODES', `${customer.name} · 分配节点`, `
+
勾选即分配,取消即移除。分配到运行中的节点会立即重建下发。
+
${rows || '还没有节点,先去“转发与节点”创建'}
+
`); +} + function expiryIso(formData) { const date = formData.get('expiryDate'); if (!date) return null; const time = formData.get('expiryTime') || '23:59'; @@ -399,11 +474,21 @@ document.addEventListener('submit', async (event) => { const payload = { name:data.get('name'), group:data.get('group'), trafficLimitBytes:Number(data.get('trafficGb') || 0) * 1024 ** 3, expiresAt:expiryIso(data), ipLimit:Number(data.get('ipLimit') || 0), deviceLimit:Number(data.get('deviceLimit') || 0), tags:splitList(data.get('tags')), notes:data.get('notes') }; await api(resourceId ? `/api/customers/${resourceId}` : '/api/customers', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) }); $('#modal').close(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers'); - } else if (form.id === 'chain-form') { + } else if (form.id === 'node-form') { const resourceId = data.get('resourceId'); - const payload = { name:data.get('name'), topology:data.get('topology'), networkMode:data.get('networkMode'), relayServerIds:data.getAll('relayServerIds'), exitServerId:data.get('exitServerId') || null, customerIds:data.getAll('customerIds'), relayProtocol:data.get('relayProtocol'), exitProtocol:data.get('exitProtocol') || null, relayPortMode:data.get('relayPortMode'), relayPort:data.get('relayPort') || null, exitPortMode:data.get('exitPortMode') || null, exitPort:data.get('exitPort') || null, realityServerName:data.get('realityServerName'), realityDestPort:Number(data.get('realityDestPort') || 443) }; - const result = await api(resourceId ? `/api/chains/${resourceId}` : '/api/chains', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) }); - $('#modal').close(); toast(result.requiresRedeploy ? '修改已保存;原节点仍按旧协议运行,请点击“应用修改”' : (resourceId ? '线路已更新' : '线路草稿已创建')); await load('chains'); + const payload = { name:data.get('name'), serverId:data.get('serverId'), protocol:data.get('protocol'), portMode:data.get('portMode'), port:data.get('port') || null, networkMode:data.get('networkMode'), topology:data.get('topology'), exitServerId:data.get('exitServerId') || null, exitProtocol:data.get('exitProtocol') || null, exitPortMode:data.get('exitPortMode') || null, exitPort:data.get('exitPort') || null, realityServerName:data.get('realityServerName'), realityDestPort:Number(data.get('realityDestPort') || 443) }; + const result = await api(resourceId ? `/api/nodes/${resourceId}` : '/api/nodes', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) }); + $('#modal').close(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,请分配客户后部署'); await load('chains'); + } else if (form.id === 'assign-form') { + await api(`/api/nodes/${data.get('nodeId')}/assign`, { method:'POST', body:JSON.stringify({ customerId:data.get('customerId') }) }); + toast('客户已分配'); const node = state.nodes.find((item) => item.id === data.get('nodeId')); await load('chains'); if (node) nodeClientsModal(state.nodes.find((item) => item.id === node.id)); + } else if (form.id === 'assign-nodes-form') { + const customerId = data.get('customerId'); + const wanted = new Set(data.getAll('nodeIds')); + const current = state.nodeClients.filter((nc) => nc.customerId === customerId).map((nc) => nc.nodeId); + for (const nodeId of current.filter((id) => !wanted.has(id))) await api(`/api/nodes/${nodeId}/unassign`, { method:'POST', body:JSON.stringify({ customerId }) }); + for (const nodeId of [...wanted].filter((id) => !current.includes(id))) await api(`/api/nodes/${nodeId}/assign`, { method:'POST', body:JSON.stringify({ customerId }) }); + $('#modal').close(); toast('节点分配已更新'); await load('customers'); } else if (form.id === 'account-form') { if (data.get('newPassword') !== data.get('confirmPassword')) throw new Error('两次输入的新密码不一致'); await api('/api/account', { method:'PATCH', body:JSON.stringify({ username:data.get('username'), currentPassword:data.get('currentPassword'), newPassword:data.get('newPassword') }) }); @@ -435,15 +520,20 @@ document.addEventListener('click', async (event) => { state.customers = state.customers.map((item) => item.id === itemId ? result.customer : item); subscriptionModal(result.customer); toast('订阅地址已重置'); } - else if (action === 'add-chain') chainForm(); - else if (action === 'edit-chain') { + else if (action === 'add-node') nodeForm(); + else if (action === 'edit-node') { if (!state.protocols.length) { const result = await api('/api/protocols'); state.protocols = result.profiles; state.realityPresets = result.realityPresets || []; } - chainForm(state.chains.find((item) => item.id === itemId)); + nodeForm(state.nodes.find((item) => item.id === itemId)); + } else if (action === 'node-clients') nodeClientsModal(state.nodes.find((item) => item.id === itemId)); + else if (action === 'assign-nodes') assignNodesModal(state.customers.find((item) => item.id === itemId)); + else if (action === 'unassign-customer' && confirm('移除该客户在此节点的分配?节点会自动重建。')) { + await api(`/api/nodes/${itemId}/unassign`, { method:'POST', body:JSON.stringify({ customerId:button.dataset.customer }) }); + toast('分配已移除'); const node = state.nodes.find((item) => item.id === itemId); await load('chains'); nodeClientsModal(state.nodes.find((item) => item.id === node.id)); } else if (action === 'enroll-server') { const result = await api(`/api/servers/${itemId}/enrollment-token`, { method:'POST', body:'{}' }); const command = `curl -fsSL https://raw.githubusercontent.com/a2899882/NexusGate/main/scripts/agent-install.sh | bash -s -- --server ${location.origin} --token ${result.token}`; modal('ONE-TIME ENROLLMENT', 'Agent 注册 / 重装命令', `
令牌 30 分钟内有效且只能使用一次。重装会保留本机资源文件,并在 Agent 重启后自动与控制面对账。
${esc(command)}
`); - } else if (action === 'copy-uri') { await navigator.clipboard.writeText(button.dataset.value); toast('已复制到剪贴板'); } + } else if (action === 'copy-uri') { await copyText(button.dataset.value); toast('已复制到剪贴板'); } else if (action === 'qr-uri') { const target = $('.subscription-qr', button.closest('.subscription-row')); if (!target.hidden) { target.hidden = true; target.replaceChildren(); return; } @@ -453,7 +543,7 @@ document.addEventListener('click', async (event) => { else if (action === 'agent-uninstall') { const command = 'ng-agent uninstall'; const fallback = 'curl -fsSL https://raw.githubusercontent.com/a2899882/NexusGate/main/scripts/agent-uninstall.sh | bash'; - modal('AGENT MAINTENANCE', '在目标服务器 SSH 卸载 Agent', `
先停用并删除关联线路,让在线 Agent 完成资源清理。卸载会停止 NexusGate 专属服务并移除密钥与节点配置。然后在控制台删除设备登记;离线且有待清理项时使用“遗忘离线设备”。遗忘不会远程卸载机器。
${esc(command)}
旧版 Agent 没有 ng-agent 命令时,使用下列兼容命令:
${esc(fallback)}
`); + modal('AGENT MAINTENANCE', '在目标服务器 SSH 卸载 Agent', `
先停用并删除关联节点,让在线 Agent 完成资源清理。卸载会停止 NexusGate 专属服务并移除密钥与节点配置。然后在控制台删除设备登记;离线且有待清理项时使用“遗忘离线设备”。遗忘不会远程卸载机器。
${esc(command)}
旧版 Agent 没有 ng-agent 命令时,使用下列兼容命令:
${esc(fallback)}
`); } else if (action === 'retry-cleanup') { const result = await api(`/api/servers/${itemId}/cleanup`, { method:'POST', body:'{}' }); toast(result.queued ? `已重新排队 ${result.queued} 个清理任务` : '清理任务已在队列中,请等待设备上线'); await load(); } else if (action === 'forget-server') { @@ -465,17 +555,15 @@ document.addEventListener('click', async (event) => { toast('已强制遗忘设备;请确保旧机器已销毁或手工清理'); await load(); } else if (action === 'delete-server' && confirm('删除控制台登记并吊销 Agent 密钥?如需要彻底卸载,请先在目标机 SSH 执行 ng-agent uninstall。')) { await api(`/api/servers/${itemId}`, { method:'DELETE' }); toast('设备登记已删除'); await load(); } - else if (action === 'delete-customer' && confirm('删除客户及订阅地址?须先删除关联线路;归档清理任务会在后台继续执行。')) { await api(`/api/customers/${itemId}`, { method:'DELETE' }); toast('客户已删除'); await load(); } + else if (action === 'delete-customer' && confirm('删除客户及订阅地址?须先移除全部节点分配。')) { await api(`/api/customers/${itemId}`, { method:'DELETE' }); toast('客户已删除'); await load(); } else if (action === 'reset-usage' && confirm('确认把该客户已用流量清零?')) { await api(`/api/customers/${itemId}/reset-usage`, { method:'POST', body:'{}' }); toast('客户流量已清零'); await load(); } else if (action === 'toggle-customer') { const item = state.customers.find((entry) => entry.id === itemId); await api(`/api/customers/${itemId}`, { method:'PATCH', body:JSON.stringify({ status:item.status === 'active' ? 'suspended' : 'active' }) }); toast('客户状态已更新'); await load(); - } else if (action === 'deploy-chain' && confirm('现在向所选设备下发这条线路?')) { await api(`/api/chains/${itemId}/deploy`, { method:'POST', body:'{}' }); toast('部署任务已进入队列'); await load(); } - else if (action === 'restore-chain' && confirm('使用原端口和节点凭据恢复这条线路?如端口已被其他线路占用,请改用全新部署。')) { const result = await api(`/api/chains/${itemId}/restore`, { method:'POST', body:'{}' }); toast(`已排队恢复 ${result.jobs} 项原资源`); await load(); } - else if (action === 'redeploy-chain' && confirm('重新部署会先移除旧资源,再自动创建新资源。确认继续?')) { await api(`/api/chains/${itemId}/redeploy`, { method:'POST', body:'{}' }); toast('线路已进入安全重建流程'); await load(); } - else if (action === 'repair-chain' && confirm('仅重试当前失败的部署项?')) { await api(`/api/chains/${itemId}/repair`, { method:'POST', body:'{}' }); toast('修复任务已进入队列'); await load(); } - else if (action === 'remove-chain' && confirm('停用会从相关设备移除配置,确认继续?')) { await api(`/api/chains/${itemId}/remove`, { method:'POST', body:'{}' }); toast('移除任务已进入队列'); await load(); } - else if (action === 'delete-chain' && confirm('确定删除这条线路?未完成的部署会取消;系统会给相关设备排队清理遗留资源。运行中的资源须先停用。')) { const result = await api(`/api/chains/${itemId}`, { method:'DELETE' }); toast(result.cleanupPending ? `线路已移除,${result.cleanupPending} 个设备资源待 Agent 确认清理` : '线路已删除'); await load(); } + } else if (action === 'deploy-node' && confirm('现在向所选设备下发这个节点?')) { await api(`/api/nodes/${itemId}/deploy`, { method:'POST', body:'{}' }); toast('部署任务已进入队列'); await load(); } + else if (action === 'repair-node' && confirm('重新下发该节点配置?')) { await api(`/api/nodes/${itemId}/repair`, { method:'POST', body:'{}' }); toast('修复任务已进入队列'); await load(); } + else if (action === 'remove-node' && confirm('停用会从相关设备移除配置,节点保留为草稿,确认继续?')) { await api(`/api/nodes/${itemId}/remove`, { method:'POST', body:'{}' }); toast('停用任务已进入队列'); await load(); } + else if (action === 'delete-node' && confirm('确定删除这个节点?会移除全部分配并从设备清理资源。')) { await api(`/api/nodes/${itemId}`, { method:'DELETE' }); toast('节点已删除,设备资源待 Agent 确认清理'); await load(); } else if (action === 'download-backup') location.href = '/api/backup'; else if (action === 'restore-backup') { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json'; input.onchange = async () => { @@ -497,7 +585,7 @@ document.addEventListener('change', (event) => { const picker = event.target.closest('[data-picker]'); $('[data-picker-count]', picker).textContent = `已选择 ${$$('input[type="checkbox"]:checked', picker).length} 项`; } - if (event.target.matches('[data-chain-sync]')) syncChainForm(); + if (event.target.matches('[data-node-sync]')) syncNodeForm(); if (event.target.matches('[data-reality-preset]')) { const option = event.target.selectedOptions[0]; const form = event.target.form; if (option && option.value !== 'custom') { form.elements.realityServerName.value = option.dataset.sni; form.elements.realityDestPort.value = option.dataset.port; } diff --git a/public/index.html b/public/index.html index 7a63334..4811986 100644 --- a/public/index.html +++ b/public/index.html @@ -37,7 +37,6 @@ - diff --git a/public/styles.css b/public/styles.css index 9ae5b51..70dcad5 100644 --- a/public/styles.css +++ b/public/styles.css @@ -105,3 +105,7 @@ table{font-size:13px}th{font-size:11px;letter-spacing:.02em}td strong{font-size: .command-item:last-child{border-bottom:0}.command-item>div{min-width:0;display:grid;gap:5px} .command-item small{color:var(--muted);font-size:11px;line-height:1.55}.command-item code{font-size:12px;color:var(--primary)} .command-item button{flex:0 0 auto} +.node-link-list{display:grid;gap:8px;margin-top:8px} +.node-link-customer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;background:var(--surface);border:1px solid var(--line);border-radius:8px} +.node-link-customer .actions{margin:0} +details summary{cursor:pointer;font-weight:600} diff --git a/scripts/agent-cert.sh b/scripts/agent-cert.sh index b86436e..aad6d25 100644 --- a/scripts/agent-cert.sh +++ b/scripts/agent-cert.sh @@ -46,10 +46,8 @@ validate() { activate() { flush_usage if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then - systemctl try-restart nexusgate-xray.service || printf 'Xray 重启失败,请运行 ng-agent doctor\n' >&2 systemctl try-restart nexusgate-sing-box.service || printf 'sing-box 重启失败,请运行 ng-agent doctor\n' >&2 elif command -v rc-service >/dev/null; then - rc-service nexusgate-xray status >/dev/null 2>&1 && rc-service nexusgate-xray restart || true rc-service nexusgate-sing-box status >/dev/null 2>&1 && rc-service nexusgate-sing-box restart || true fi printf '证书已安装:%s;有效期:' "$target" @@ -104,10 +102,8 @@ if [[ -r /etc/nexusgate/agent.env && -f /opt/nexusgate-agent/agent.js ]]; then fi fi if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then - systemctl try-restart nexusgate-xray.service systemctl try-restart nexusgate-sing-box.service elif command -v rc-service >/dev/null; then - rc-service nexusgate-xray status >/dev/null 2>&1 && rc-service nexusgate-xray restart || true rc-service nexusgate-sing-box status >/dev/null 2>&1 && rc-service nexusgate-sing-box restart || true fi EOF diff --git a/scripts/agent-doctor.sh b/scripts/agent-doctor.sh index adfab2e..66d5bc7 100644 --- a/scripts/agent-doctor.sh +++ b/scripts/agent-doctor.sh @@ -10,19 +10,13 @@ printf '已验证节点证书:' node -e 'const domains=require("/opt/nexusgate-agent/agent.js").installedCertificates();console.log(domains.length?domains.join(", "):"暂无;运行 ng-agent cert")' 2>&1 || true printf 'Agent 版本:' node -p "require('fs').readFileSync('/opt/nexusgate-agent/agent.js','utf8').match(/const VERSION = '([^']+)'/)[1]" 2>/dev/null || printf '无法读取\n' -has_xray_resources=1 -if ! node -e 'const fs=require("fs");const dir="/etc/nexusgate/xray/resources";process.exit(fs.readdirSync(dir).some(name=>name.endsWith(".json")&&JSON.parse(fs.readFileSync(`${dir}/${name}`)).engine!=="sing-box")?0:1)' 2>/dev/null; then - has_xray_resources=0 - printf '这台机器没有 Xray 资源,Xray 空闲服务停止属于正常(AnyTLS 由 sing-box 运行)。\n' -fi if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then - for name in nexusgate-agent nexusgate-xray nexusgate-sing-box; do + for name in nexusgate-agent nexusgate-sing-box; do printf '%s:' "$name" systemctl is-active "$name" 2>&1 || true done else rc-service nexusgate-agent status || true - rc-service nexusgate-xray status || true rc-service nexusgate-sing-box status || true fi printf '\nsing-box 构建与配置:\n' @@ -32,66 +26,37 @@ elif [[ -x /usr/local/bin/nexusgate-sing-box ]]; then /usr/local/bin/nexusgate-sing-box version | head -n 4 if [[ -f /etc/nexusgate/sing-box/config.json ]]; then /usr/local/bin/nexusgate-sing-box check -c /etc/nexusgate/sing-box/config.json 2>&1 | tail -n 12 - printf 'AnyTLS 入站流量统计(累计):\n' + printf '入站流量统计(累计):\n' /usr/local/bin/nexusgate-sing-box-stats --server="127.0.0.1:${NG_SINGBOX_API_PORT:-10086}" 2>&1 | head -c 900 printf '\n' else - printf '已安装,当前没有 AnyTLS 资源\n' + printf '已安装,当前没有节点资源\n' fi else printf '未安装;运行 ng-agent engine install\n' fi printf '控制面连通性:' curl --max-time 8 -fsS "${NG_CONTROLLER%/}/healthz" 2>&1 | head -c 350 || true -printf '\nXray 配置校验:\n' -if [[ -f /etc/nexusgate/xray/config.json ]]; then - /usr/local/bin/xray run -test -config /etc/nexusgate/xray/config.json 2>&1 | tail -n 12 -else - printf '配置文件不存在\n' -fi printf '\nHysteria 2 UDP 监听:\n' node <<'NODE' 2>&1 || true const fs = require('node:fs'); const agent = require('/opt/nexusgate-agent/agent.js'); -const dir = '/etc/nexusgate/xray/resources'; +const dir = '/etc/nexusgate/agent/resources'; const resources = fs.readdirSync(dir).filter((name) => name.endsWith('.json')) .map((name) => JSON.parse(fs.readFileSync(`${dir}/${name}`, 'utf8'))); const expected = resources.flatMap((resource) => resource.inbounds || []) - .filter((inbound) => inbound.protocol === 'hysteria').map((inbound) => inbound.port); + .filter((inbound) => inbound.type === 'hysteria2').map((inbound) => inbound.listen_port); if (!expected.length) console.log('没有 Hysteria 2 入站'); else { - const config = JSON.parse(fs.readFileSync('/etc/nexusgate/xray/config.json', 'utf8')); - for (const port of expected) { - const inbound = config.inbounds.find((item) => item.port === port && item.protocol === 'hysteria'); - console.log(`UDP ${port}: 传输 ${inbound?.streamSettings?.network || '未配置'},账户 ${(inbound?.settings?.clients || inbound?.settings?.users || []).length}`); - } try { const missing = agent.missingHysteriaListeners(resources); - console.log(missing.length ? `未监听 UDP: ${missing.join(', ')}` : '所有 Hysteria 2 UDP 端口均由 Xray 监听'); + console.log(missing.length ? `未监听 UDP: ${missing.join(', ')}` : '所有 Hysteria 2 UDP 端口均由 sing-box 监听'); } catch (error) { console.log(`监听检查失败: ${error.message}`); } } NODE -printf '\nXray 入站流量统计(累计,查询不会清零):\n' -if [[ "$has_xray_resources" == 0 ]]; then - printf '无 Xray 入口,跳过统计查询。\n' -else - stats_output="$(/usr/local/bin/xray api statsquery --server="127.0.0.1:${NG_XRAY_API_PORT:-10085}" -pattern 'inbound>>>' 2>&1)" - if [[ $? -ne 0 ]]; then - printf '查询失败:%.250s\n' "$stats_output" - else - STATS_OUTPUT="$stats_output" node -e ' - try { - const stats = JSON.parse(process.env.STATS_OUTPUT).stat || []; - const entries = stats.filter(item => /^inbound>>>ng-.*>>>traffic>>>(uplink|downlink)$/.test(item.name)); - if (!entries.length) console.log("尚无入口流量计数;请确认客户端确实连接本机节点后再测试"); - else for (const item of entries.slice(0, 24)) console.log(`${item.name}: ${item.value === undefined ? 0 : item.value} B`); - } catch { console.log("统计返回格式无效,请升级 Xray 和 Agent"); } - ' - fi -fi -printf '\n最近 Agent 与 Xray 日志:\n' +printf '\n最近 Agent 与 sing-box 日志:\n' if command -v journalctl >/dev/null && [[ -d /run/systemd/system ]]; then - journalctl -u nexusgate-agent -u nexusgate-xray -u nexusgate-sing-box -n 45 --no-pager -o short-iso 2>&1 | sed -E 's/(Bearer |NG_AGENT_KEY=)[^[:space:]]+/\1[REDACTED]/g; s/[Pp]rivate[[:space:]]*[Kk]ey:[[:space:]]*[^[:space:]]+/PrivateKey: [REDACTED]/g' + journalctl -u nexusgate-agent -u nexusgate-sing-box -n 45 --no-pager -o short-iso 2>&1 | sed -E 's/(Bearer |NG_AGENT_KEY=)[^[:space:]]+/\1[REDACTED]/g; s/[Pp]rivate[[:space:]]*[Kk]ey:[[:space:]]*[^[:space:]]+/PrivateKey: [REDACTED]/g' else - tail -n 45 /var/log/nexusgate/xray-error.log 2>/dev/null || true + tail -n 45 /var/log/nexusgate/sing-box-access.log 2>/dev/null || true fi diff --git a/scripts/agent-install.sh b/scripts/agent-install.sh index 4bc60f9..f80d25f 100755 --- a/scripts/agent-install.sh +++ b/scripts/agent-install.sh @@ -9,6 +9,16 @@ TOKEN="" die() { printf '错误:%s\n' "$*" >&2; exit 1; } info() { printf '\033[1;36m[NexusGate Agent]\033[0m %s\n' "$*"; } +# NG_LOCAL_SRC 指向本地源码目录时跳过 GitHub 下载(本地改动尚未 push 时用这个) +fetch() { + if [[ -n "${NG_LOCAL_SRC:-}" ]]; then + [[ -f "${NG_LOCAL_SRC}/$1" ]] || die "NG_LOCAL_SRC 目录缺少 $1" + cp -a "${NG_LOCAL_SRC}/$1" "$2" + else + curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/$1" -o "$2" + fi +} + while [[ $# -gt 0 ]]; do case "$1" in --server) CONTROLLER="${2:-}"; shift 2 ;; @@ -35,31 +45,19 @@ fi node_major="$(node -p 'process.versions.node.split(`.`)[0]')" [[ "$node_major" -ge 18 ]] || die "需要 Node.js 18 或更高版本" -case "$(uname -m)" in - x86_64|amd64) xray_arch="64" ;; - aarch64|arm64) xray_arch="arm64-v8a" ;; - armv7l) xray_arch="arm32-v7a" ;; - *) die "暂不支持的架构:$(uname -m)" ;; -esac - tmp_dir="$(mktemp -d /tmp/nexusgate-agent.XXXXXX)" trap 'rm -rf -- "$tmp_dir"' EXIT -info "下载 Xray-core" -xray_asset="Xray-linux-${xray_arch}.zip" -curl -fL --retry 3 "https://github.com/XTLS/Xray-core/releases/latest/download/${xray_asset}" -o "$tmp_dir/xray.zip" -unzip -q "$tmp_dir/xray.zip" xray -d "$tmp_dir/xray" -install -m 0755 "$tmp_dir/xray/xray" /usr/local/bin/xray install -d -m 0755 /opt/nexusgate-agent -curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/agent/agent.js" -o /opt/nexusgate-agent/agent.js -curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/agent/run.sh" -o /opt/nexusgate-agent/run.sh -curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/scripts/agent-update.sh" -o /usr/local/sbin/ng-agent-update -curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/scripts/agent-uninstall.sh" -o /usr/local/sbin/ng-agent-uninstall -curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/scripts/agent-doctor.sh" -o /usr/local/sbin/ng-agent-doctor -curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/scripts/agent-cert.sh" -o /usr/local/sbin/ng-agent-cert -curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/scripts/agent-singbox.sh" -o /usr/local/sbin/ng-agent-singbox -curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/scripts/agent-logrotate.conf" -o "$tmp_dir/logrotate.conf" -curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/scripts/agent-logrotate-setup.sh" -o "$tmp_dir/logrotate-setup.sh" +fetch agent/agent.js /opt/nexusgate-agent/agent.js +fetch agent/run.sh /opt/nexusgate-agent/run.sh +fetch scripts/agent-update.sh /usr/local/sbin/ng-agent-update +fetch scripts/agent-uninstall.sh /usr/local/sbin/ng-agent-uninstall +fetch scripts/agent-doctor.sh /usr/local/sbin/ng-agent-doctor +fetch scripts/agent-cert.sh /usr/local/sbin/ng-agent-cert +fetch scripts/agent-singbox.sh /usr/local/sbin/ng-agent-singbox +fetch scripts/agent-logrotate.conf "$tmp_dir/logrotate.conf" +fetch scripts/agent-logrotate-setup.sh "$tmp_dir/logrotate-setup.sh" cat > /usr/local/sbin/ng-agent <<'EOF' #!/usr/bin/env bash set -Eeuo pipefail @@ -74,19 +72,12 @@ esac EOF chmod 0644 /opt/nexusgate-agent/agent.js chmod 0755 /opt/nexusgate-agent/run.sh /usr/local/sbin/ng-agent-update /usr/local/sbin/ng-agent-uninstall /usr/local/sbin/ng-agent-doctor /usr/local/sbin/ng-agent-cert /usr/local/sbin/ng-agent-singbox /usr/local/sbin/ng-agent -install -d -m 0700 /etc/nexusgate /etc/nexusgate/xray /etc/nexusgate/xray/resources /etc/nexusgate/sing-box +install -d -m 0700 /etc/nexusgate /etc/nexusgate/agent /etc/nexusgate/agent/resources /etc/nexusgate/sing-box install -d -m 0750 /var/log/nexusgate install -m 0644 "$tmp_dir/logrotate.conf" /etc/nexusgate/agent-logrotate.conf install -m 0755 "$tmp_dir/logrotate-setup.sh" /usr/local/sbin/ng-agent-logrotate-setup -if [[ ! -f /etc/nexusgate/xray/config.json ]]; then - printf '{"log":{"loglevel":"warning"},"inbounds":[],"outbounds":[]}\n' > /etc/nexusgate/xray/config.json -fi -chmod 0600 /etc/nexusgate/xray/config.json -if [[ "${NG_INSTALL_ANYTLS:-0}" == 1 ]]; then - NG_REPO="$REPO" NG_BRANCH="$BRANCH" ng-agent-singbox install || die 'AnyTLS 引擎安装失败;修复后运行 ng-agent engine install' -else - info '基础节点只安装 Xray;需要 AnyTLS 时在入口机运行 ng-agent engine install。' -fi +# sing-box 是唯一的入口引擎,安装时直接构建。 +NG_REPO="$REPO" NG_BRANCH="$BRANCH" ng-agent-singbox install || die 'sing-box 引擎安装失败;修复后运行 ng-agent engine install' info "向控制面注册" enroll_json="$(TOKEN_VALUE="$TOKEN" node -e 'process.stdout.write(JSON.stringify({token:process.env.TOKEN_VALUE,hostname:require("node:os").hostname(),version:"0.6.8",system:{platform:process.platform,arch:process.arch}}))')" @@ -96,36 +87,33 @@ agent_key="$(RESPONSE_VALUE="$response" node -e 'const r=JSON.parse(process.env. { printf 'NG_CONTROLLER=%q\n' "${CONTROLLER%/}" printf 'NG_AGENT_KEY=%q\n' "$agent_key" - printf 'NG_XRAY_BIN=/usr/local/bin/xray\n' - printf 'NG_CONFIG_DIR=/etc/nexusgate/xray\n' + printf 'NG_CONFIG_DIR=/etc/nexusgate/agent\n' printf 'NG_KEY_FILE=/etc/nexusgate/keys.json\n' } > /etc/nexusgate/agent.env chmod 0600 /etc/nexusgate/agent.env if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then - curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/systemd/nexusgate-agent.service" -o /etc/systemd/system/nexusgate-agent.service - curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/systemd/nexusgate-xray.service" -o /etc/systemd/system/nexusgate-xray.service - curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/systemd/nexusgate-sing-box.service" -o /etc/systemd/system/nexusgate-sing-box.service + fetch systemd/nexusgate-agent.service /etc/systemd/system/nexusgate-agent.service + fetch systemd/nexusgate-sing-box.service /etc/systemd/system/nexusgate-sing-box.service systemctl daemon-reload - systemctl enable nexusgate-xray.service nexusgate-agent.service - systemctl restart nexusgate-xray.service + systemctl enable nexusgate-sing-box.service nexusgate-agent.service rm -f -- /etc/nexusgate/last-heartbeat.json + systemctl restart nexusgate-sing-box.service systemctl restart nexusgate-agent.service service_manager="systemd" elif command -v rc-service >/dev/null; then install -d -m 0755 /etc/init.d - curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/openrc/nexusgate-agent" -o /etc/init.d/nexusgate-agent - curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/openrc/nexusgate-xray" -o /etc/init.d/nexusgate-xray - curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/openrc/nexusgate-sing-box" -o /etc/init.d/nexusgate-sing-box - chmod 0755 /etc/init.d/nexusgate-agent /etc/init.d/nexusgate-xray /etc/init.d/nexusgate-sing-box - rc-update add nexusgate-xray default >/dev/null + fetch openrc/nexusgate-agent /etc/init.d/nexusgate-agent + fetch openrc/nexusgate-sing-box /etc/init.d/nexusgate-sing-box + chmod 0755 /etc/init.d/nexusgate-agent /etc/init.d/nexusgate-sing-box + rc-update add nexusgate-sing-box default >/dev/null rc-update add nexusgate-agent default >/dev/null # Alpine runs the dedicated hourly task through BusyBox crond. if [[ -e /etc/init.d/crond ]]; then rc-update add crond default >/dev/null 2>&1 || true rc-service crond start >/dev/null 2>&1 || true fi - rc-service nexusgate-xray restart + rc-service nexusgate-sing-box restart rm -f -- /etc/nexusgate/last-heartbeat.json rc-service nexusgate-agent restart service_manager="OpenRC" @@ -144,7 +132,7 @@ fi printf '\n\033[1;32mAgent 安装、注册和首次心跳完成(%s)。\033[0m\n' "$service_manager" printf '后续更新 Agent:ng-agent-update\n' printf '卸载 Agent:ng-agent uninstall\n' -printf '检查 Agent 与 Xray:ng-agent doctor\n' +printf '检查 Agent 与 sing-box:ng-agent doctor\n' if [[ "$service_manager" == systemd ]] && ! systemctl is-active --quiet nexusgate-agent.service; then journalctl -u nexusgate-agent.service -n 35 --no-pager || true die 'Agent 注册已完成,但服务没有运行;请执行 ng-agent doctor 检查原因' diff --git a/scripts/agent-uninstall.sh b/scripts/agent-uninstall.sh index fc2f3ce..4e2cfab 100644 --- a/scripts/agent-uninstall.sh +++ b/scripts/agent-uninstall.sh @@ -3,7 +3,7 @@ set -Eeuo pipefail [[ "${EUID}" -eq 0 ]] || { printf '请使用 root 运行\n' >&2; exit 1; } if [[ "${1:-}" != "--yes" ]]; then - printf '将停止并删除此机器上的 NexusGate Agent、专属 Xray 服务、节点资源和密钥。\n' + printf '将停止并删除此机器上的 NexusGate Agent、sing-box 服务、节点资源和密钥。\n' printf '请先在控制台停用/删除关联线路;如设备已离线,之后在控制台执行“强制遗忘”。\n' printf '输入 UNINSTALL 确认:' > /dev/tty read -r confirmation < /dev/tty @@ -12,20 +12,17 @@ fi if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then systemctl disable --now nexusgate-agent.service 2>/dev/null || true - systemctl disable --now nexusgate-xray.service 2>/dev/null || true systemctl disable --now nexusgate-sing-box.service 2>/dev/null || true systemctl disable --now nexusgate-cert-renew.timer 2>/dev/null || true systemctl disable --now nexusgate-agent-logrotate.timer 2>/dev/null || true - rm -f -- /etc/systemd/system/nexusgate-agent.service /etc/systemd/system/nexusgate-xray.service /etc/systemd/system/nexusgate-sing-box.service /etc/systemd/system/nexusgate-cert-renew.service /etc/systemd/system/nexusgate-cert-renew.timer /etc/systemd/system/nexusgate-agent-logrotate.service /etc/systemd/system/nexusgate-agent-logrotate.timer + rm -f -- /etc/systemd/system/nexusgate-agent.service /etc/systemd/system/nexusgate-sing-box.service /etc/systemd/system/nexusgate-cert-renew.service /etc/systemd/system/nexusgate-cert-renew.timer /etc/systemd/system/nexusgate-agent-logrotate.service /etc/systemd/system/nexusgate-agent-logrotate.timer systemctl daemon-reload fi if command -v rc-service >/dev/null; then rc-service nexusgate-agent stop 2>/dev/null || true - rc-service nexusgate-xray stop 2>/dev/null || true rc-service nexusgate-sing-box stop 2>/dev/null || true rc-update del nexusgate-agent default 2>/dev/null || true - rc-update del nexusgate-xray default 2>/dev/null || true - rm -f -- /etc/init.d/nexusgate-agent /etc/init.d/nexusgate-xray /etc/init.d/nexusgate-sing-box + rm -f -- /etc/init.d/nexusgate-agent /etc/init.d/nexusgate-sing-box rm -f -- /etc/periodic/daily/nexusgate-cert-renew rm -f -- /etc/periodic/hourly/nexusgate-agent-logrotate fi @@ -34,6 +31,6 @@ rm -rf -- /opt/nexusgate-agent /etc/nexusgate /var/log/nexusgate rm -f -- /etc/logrotate.d/nexusgate-agent rm -f -- /etc/letsencrypt/renewal-hooks/deploy/nexusgate-reload.sh rm -f -- /usr/local/sbin/ng-agent /usr/local/sbin/ng-agent-update /usr/local/sbin/ng-agent-uninstall /usr/local/sbin/ng-agent-doctor /usr/local/sbin/ng-agent-cert /usr/local/sbin/ng-agent-singbox /usr/local/sbin/ng-agent-logrotate-setup /usr/local/bin/nexusgate-sing-box /usr/local/bin/nexusgate-sing-box-stats -printf 'NexusGate Agent 与其资源已删除。系统共用的 Node.js、Xray 可执行文件和其他服务未删除。\n' +printf 'NexusGate Agent 与其资源已删除。系统共用的 Node.js 和其他服务未删除。\n' printf 'Certbot 管理的证书仍保留;若曾使用 CF DNS 验证,其令牌已删除,保留的续签任务需另行配置或在确认没有其他服务使用后用 certbot delete 清理。\n' printf '请在控制台删除/遗忘此设备,以撤销其 Agent 密钥和登记。\n' diff --git a/scripts/agent-update.sh b/scripts/agent-update.sh index c6aaf4c..5d2b328 100755 --- a/scripts/agent-update.sh +++ b/scripts/agent-update.sh @@ -21,8 +21,8 @@ curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/scripts/ curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/scripts/agent-logrotate-setup.sh" -o "$tmp_dir/logrotate-setup.sh" [[ -s "$tmp_dir/update.sh" ]] || die 'Agent 升级脚本下载不完整' node --check "$tmp_dir/agent.js" -# Updating can rewrite an older HY2 configuration and restart Xray. Persist -# its current bidirectional counters before the running process is replaced. +# Updating restarts sing-box. Persist its current bidirectional counters +# before the running process is replaced. if [[ -f /opt/nexusgate-agent/agent.js ]]; then ( set -a; source /etc/nexusgate/agent.env; set +a; node /opt/nexusgate-agent/agent.js flush-usage ) \ || die '更新前流量统计上报失败,保留原 Agent;检查控制面连接和 ng-agent doctor 后重试' @@ -50,16 +50,13 @@ case "${1:-}" in esac EOF chmod 0755 /usr/local/sbin/ng-agent -if [[ "${NG_INSTALL_ANYTLS:-0}" == 1 ]]; then - if ! NG_REPO="$REPO" NG_BRANCH="$BRANCH" ng-agent-singbox install; then - info 'sing-box 构建未完成;现有 Xray 节点正常。修复后运行 ng-agent engine install 重试。' - fi -elif [[ -x /usr/local/bin/nexusgate-sing-box && ! -x /usr/local/bin/nexusgate-sing-box-stats ]]; then - info '发现未完成的 sing-box 安装;更新 Agent 不会再次编译,请在需要 AnyTLS 的入口机运行 ng-agent engine install。' +# sing-box 是唯一的入口引擎;缺失时直接安装。 +if [[ ! -x /usr/local/bin/nexusgate-sing-box || ! -x /usr/local/bin/nexusgate-sing-box-stats ]]; then + NG_REPO="$REPO" NG_BRANCH="$BRANCH" ng-agent-singbox install \ + || die 'sing-box 引擎安装失败;修复后运行 ng-agent engine install 重试' fi if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/systemd/nexusgate-agent.service" -o /etc/systemd/system/nexusgate-agent.service - curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/systemd/nexusgate-xray.service" -o /etc/systemd/system/nexusgate-xray.service curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/systemd/nexusgate-sing-box.service" -o /etc/systemd/system/nexusgate-sing-box.service systemctl daemon-reload rm -f -- /etc/nexusgate/last-heartbeat.json @@ -67,9 +64,8 @@ if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then systemctl is-active --quiet nexusgate-agent.service || die "Agent 重启失败" elif command -v rc-service >/dev/null; then curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/openrc/nexusgate-agent" -o /etc/init.d/nexusgate-agent - curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/openrc/nexusgate-xray" -o /etc/init.d/nexusgate-xray curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/openrc/nexusgate-sing-box" -o /etc/init.d/nexusgate-sing-box - chmod 0755 /etc/init.d/nexusgate-agent /etc/init.d/nexusgate-xray /etc/init.d/nexusgate-sing-box + chmod 0755 /etc/init.d/nexusgate-agent /etc/init.d/nexusgate-sing-box if [[ -e /etc/init.d/crond ]]; then rc-update add crond default >/dev/null 2>&1 || true rc-service crond start >/dev/null 2>&1 || true diff --git a/scripts/hy2-smoke.sh b/scripts/hy2-smoke.sh deleted file mode 100644 index cceb705..0000000 --- a/scripts/hy2-smoke.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -xray="${NG_XRAY_BIN:-/usr/local/bin/xray}" -singbox="${NG_SINGBOX_BIN:-/usr/local/bin/nexusgate-sing-box}" -command -v "$xray" >/dev/null || { printf 'Xray binary missing\n' >&2; exit 1; } -command -v "$singbox" >/dev/null || { printf 'sing-box binary missing\n' >&2; exit 1; } -tmp="$(mktemp -d /tmp/nexusgate-hy2-smoke.XXXXXX)" -server_pid='' client_pid='' http_pid='' -cleanup() { - for pid in "$client_pid" "$server_pid" "$http_pid"; do - [[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true - done - rm -rf -- "$tmp" -} -trap cleanup EXIT - -openssl req -x509 -newkey rsa:2048 -nodes -days 1 -keyout "$tmp/key.pem" -out "$tmp/cert.pem" \ - -subj '/CN=node.test' -addext 'subjectAltName=DNS:node.test' >/dev/null 2>&1 -NG_SMOKE_DIR="$tmp" node <<'NODE' -const fs = require('node:fs'); -const path = require('node:path'); -const root = process.env.NG_SMOKE_DIR; -const { newCredentialSet, buildDirectResource } = require('./lib/protocols'); -const { combinedConfig } = require('./agent/agent'); -const credentials = newCredentialSet('hysteria2'); -const resource = buildDirectResource({ resourceId:'res_hy2_smoke', tagPrefix:'smoke', port:23460, - protocol:'hysteria2', credentials, customer:{ id:'smoke-customer' }, tlsDomain:'node.test' }); -resource.inbounds[0].listen = '127.0.0.1'; -const certificate = resource.inbounds[0].streamSettings.tlsSettings.certificates[0]; -certificate.certificateFile = path.join(root, 'cert.pem'); -certificate.keyFile = path.join(root, 'key.pem'); -const server = combinedConfig([resource]); -server.inbounds[0].port = 23461; -server.log.access = path.join(root, 'access.log'); -server.log.error = path.join(root, 'error.log'); -fs.writeFileSync(path.join(root, 'server.json'), JSON.stringify(server)); -const client = { log:{ level:'warn' }, - inbounds:[{ type:'mixed', listen:'127.0.0.1', listen_port:23462, tag:'local' }], - outbounds:[{ type:'hysteria2', tag:'server', server:'127.0.0.1', server_port:23460, - password:credentials.relayPassword, tls:{ enabled:true, server_name:'node.test', insecure:true, alpn:['h3'] } }], - route:{ final:'server' } }; -fs.writeFileSync(path.join(root, 'client.json'), JSON.stringify(client)); -NODE -"$xray" run -test -config "$tmp/server.json" -"$singbox" check -c "$tmp/client.json" -node -e 'require("http").createServer((_,res)=>res.end("NexusGate HY2 smoke OK")).listen(23463,"127.0.0.1")' & http_pid=$! -"$xray" run -config "$tmp/server.json" > "$tmp/server.out" 2>&1 & server_pid=$! -"$singbox" run -c "$tmp/client.json" > "$tmp/client.out" 2>&1 & client_pid=$! -for _ in {1..40}; do - if curl -fsS --max-time 2 --noproxy '' --socks5-hostname 127.0.0.1:23462 http://127.0.0.1:23463/ > "$tmp/response" 2>/dev/null; then break; fi - sleep 0.25 -done -if [[ "$(cat "$tmp/response" 2>/dev/null || true)" != 'NexusGate HY2 smoke OK' ]]; then - cat "$tmp/server.out" "$tmp/client.out" "$tmp/error.log" >&2 - exit 1 -fi -NG_SMOKE_PID="$server_pid" node <<'NODE' -const { udpPortsForPid } = require('./agent/agent'); -if (!udpPortsForPid(Number(process.env.NG_SMOKE_PID)).has(23460)) throw new Error('Xray has no UDP listener on the HY2 port'); -NODE -"$xray" api statsquery --server=127.0.0.1:23461 -pattern 'inbound>>>' > "$tmp/stats.json" -NG_SMOKE_STATS="$tmp/stats.json" node <<'NODE' -const stats = JSON.parse(require('node:fs').readFileSync(process.env.NG_SMOKE_STATS)); -const counters = new Map((stats.stat || []).map((item) => [item.name, Number(item.value)])); -for (const direction of ['uplink','downlink']) { - const value = counters.get(`inbound>>>smoke-in>>>traffic>>>${direction}`); - if (!(value > 0)) throw new Error(`missing HY2 ${direction} counter`); -} -console.log('HY2 UDP listener, sing-box handshake, round trip, and dual direction stats OK'); -NODE diff --git a/scripts/install.sh b/scripts/install.sh index 4a557cd..e03b9df 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -25,52 +25,107 @@ while [[ $# -gt 0 ]]; do done [[ "${EUID}" -eq 0 ]] || die "请使用 root 运行" -[[ -f /etc/debian_version ]] || die "控制面一键安装器当前支持 Debian 12 / Ubuntu 22.04+" [[ ! -e /opt/nexusgate ]] || die "检测到已有安装,请运行 nexusgate update" +IS_OPENWRT=false +if [[ -f /etc/openwrt_release ]]; then + IS_OPENWRT=true +elif [[ ! -f /etc/debian_version ]]; then + die "控制面一键安装器当前支持 Debian 12 / Ubuntu 22.04+ 和 OpenWrt" +fi -if [[ -z "$DOMAIN" ]]; then DOMAIN="$(ask '请输入面板域名(如 gate.example.com):')"; fi -[[ "$DOMAIN" =~ ^[A-Za-z0-9.-]+$ ]] || die "域名格式不正确" +if [[ -z "$DOMAIN" ]]; then + if $IS_OPENWRT; then + DOMAIN="$(ask '面板域名(OpenWrt 上可留空,留空则仅内网访问、不配置 Caddy):')" + else + DOMAIN="$(ask '请输入面板域名(如 gate.example.com):')" + fi +fi +if [[ -n "$DOMAIN" ]]; then + [[ "$DOMAIN" =~ ^[A-Za-z0-9.-]+$ ]] || die "域名格式不正确" +elif ! $IS_OPENWRT; then + die "域名不能为空" +fi if [[ -z "$EMAIL" ]]; then EMAIL="$(ask '证书邮箱(可留空):')"; fi info "安装系统依赖" -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq -apt-get install -y -qq ca-certificates curl tar openssl nodejs caddy +if $IS_OPENWRT; then + # 注意:OpenWrt 上需预先装好 bash(本脚本依赖 bash):opkg update && opkg install bash + opkg update + # OpenWrt 上 openssl 命令行工具在 openssl-util 包里;部分精简固件没有 install 命令,需 coreutils-install + opkg install node caddy curl ca-certificates openssl-util tar coreutils-install +else + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq ca-certificates curl tar openssl nodejs caddy +fi node_major="$(node -p 'process.versions.node.split(`.`)[0]')" [[ "$node_major" -ge 18 ]] || die "需要 Node.js 18 或更高版本" -tmp_dir="$(mktemp -d /tmp/nexusgate-install.XXXXXX)" -trap 'rm -rf -- "$tmp_dir"' EXIT -info "下载 ${REPO}@${BRANCH}" -curl -fL --retry 3 "https://github.com/${REPO}/archive/refs/heads/${BRANCH}.tar.gz" -o "$tmp_dir/source.tgz" -mkdir -p "$tmp_dir/source" -tar -xzf "$tmp_dir/source.tgz" -C "$tmp_dir/source" --strip-components=1 -[[ -f "$tmp_dir/source/server.js" ]] || die "安装包不完整" +# NG_LOCAL_SRC 指向本地源码目录时跳过 GitHub 下载(本地改动尚未 push 时的全新安装用这个) +src_dir="" +if [[ -n "${NG_LOCAL_SRC:-}" ]]; then + [[ -f "${NG_LOCAL_SRC}/server.js" ]] || die "NG_LOCAL_SRC 目录不完整(缺少 server.js)" + info "使用本地源码 ${NG_LOCAL_SRC}" + src_dir="${NG_LOCAL_SRC}" +else + tmp_dir="$(mktemp -d /tmp/nexusgate-install.XXXXXX)" + trap 'rm -rf -- "$tmp_dir"' EXIT + info "下载 ${REPO}@${BRANCH}" + curl -fL --retry 3 "https://github.com/${REPO}/archive/refs/heads/${BRANCH}.tar.gz" -o "$tmp_dir/source.tgz" + mkdir -p "$tmp_dir/source" + tar -xzf "$tmp_dir/source.tgz" -C "$tmp_dir/source" + # GitHub tarball 解包后只有一层顶层目录;不用 --strip-components(BusyBox tar 不支持) + src_dir="$(echo "$tmp_dir/source"/*/)" + [[ -f "${src_dir}server.js" ]] || die "安装包不完整" +fi -getent group nexusgate >/dev/null || groupadd --system nexusgate -id nexusgate >/dev/null 2>&1 || useradd --system --gid nexusgate --home-dir /var/lib/nexusgate --shell /usr/sbin/nologin nexusgate -install -d -o root -g root -m 0755 /opt/nexusgate -cp -a "$tmp_dir/source/." /opt/nexusgate/ -install -d -o nexusgate -g nexusgate -m 0700 /var/lib/nexusgate +if $IS_OPENWRT; then + # OpenWrt 是单用户系统,不建专用账号;/var 是内存盘,数据必须放闪存 + DATA_DIR=/etc/nexusgate/data + install -d -m 0755 /opt/nexusgate + cp -a "${src_dir}/." /opt/nexusgate/ + install -d -m 0700 "$DATA_DIR" +else + DATA_DIR=/var/lib/nexusgate + getent group nexusgate >/dev/null || groupadd --system nexusgate + id nexusgate >/dev/null 2>&1 || useradd --system --gid nexusgate --home-dir /var/lib/nexusgate --shell /usr/sbin/nologin nexusgate + install -d -o root -g root -m 0755 /opt/nexusgate + cp -a "${src_dir}/." /opt/nexusgate/ + install -d -o nexusgate -g nexusgate -m 0700 "$DATA_DIR" +fi +if $IS_OPENWRT && [[ -z "$DOMAIN" ]]; then + # 无域名:直接监听内网(OpenWrt 防火墙默认拦截 WAN 入站),纯 HTTP + NG_HOST=0.0.0.0 + NG_COOKIE_SECURE=false +else + NG_HOST=127.0.0.1 + NG_COOKIE_SECURE=true +fi admin_password="$(openssl rand -base64 24 | tr -d '\n')" session_secret="$(openssl rand -hex 32)" cat > /etc/nexusgate.env < /etc/caddy/Caddyfile.d/nexusgate.caddy </dev/null 2>&1; then healthy=true; break; fi sleep 1 done if [[ "$healthy" != true ]]; then - systemctl status nexusgate --no-pager || true - journalctl -u nexusgate -n 100 --no-pager || true + if $IS_OPENWRT; then + logread 2>/dev/null | grep -a nexusgate | tail -n 100 || true + else + systemctl status nexusgate --no-pager || true + journalctl -u nexusgate -n 100 --no-pager || true + fi die "控制面启动失败,诊断信息已输出" fi +if $IS_OPENWRT && [[ "$NG_HOST" == "0.0.0.0" ]] && ! netstat -tln 2>/dev/null | grep -q "0.0.0.0:8787"; then + die "控制面未监听 0.0.0.0:8787(环境变量未正确传递),请检查 /etc/init.d/nexusgate" +fi sed -i '/^NG_ADMIN_PASSWORD=/d' /etc/nexusgate.env -systemctl enable --now caddy -systemctl reload caddy +if [[ -n "$DOMAIN" ]]; then + if $IS_OPENWRT; then + /etc/init.d/caddy enable + /etc/init.d/caddy restart + else + systemctl enable --now caddy + systemctl reload caddy + fi +fi printf '\n\033[1;32m安装完成\033[0m\n' -printf '地址:https://%s\n账号:admin\n密码:%s\n' "$DOMAIN" "$admin_password" +if [[ -n "$DOMAIN" ]]; then + printf '地址:https://%s\n' "$DOMAIN" +else + printf '地址:http://<路由器 LAN IP>:8787(内网访问)\n' +fi +printf '账号:admin\n密码:%s\n' "$admin_password" printf '管理命令:nexusgate 或 ng\n请立即登录并妥善保存密码。\n' diff --git a/scripts/nexusgate.sh b/scripts/nexusgate.sh index 97f214c..af9a272 100755 --- a/scripts/nexusgate.sh +++ b/scripts/nexusgate.sh @@ -8,13 +8,25 @@ die() { printf '错误:%s\n' "$*" >&2; exit 1; } info() { printf '\033[1;36m[NexusGate]\033[0m %s\n' "$*"; } need_root() { [[ "${EUID}" -eq 0 ]] || die "请使用 root 运行"; } +IS_OPENWRT=false +[[ -f /etc/openwrt_release ]] && IS_OPENWRT=true +if $IS_OPENWRT; then DATA_DIR=/etc/nexusgate/data; else DATA_DIR=/var/lib/nexusgate; fi + +panel_stop() { if $IS_OPENWRT; then /etc/init.d/nexusgate stop; else systemctl stop nexusgate; fi } +panel_start() { if $IS_OPENWRT; then /etc/init.d/nexusgate start; else systemctl start nexusgate; fi } +panel_restart() { if $IS_OPENWRT; then /etc/init.d/nexusgate restart; else systemctl restart nexusgate; fi } +panel_status() { if $IS_OPENWRT; then /etc/init.d/nexusgate status; else systemctl status nexusgate --no-pager; fi } +panel_logs() { local n="${1:-120}"; if $IS_OPENWRT; then logread 2>/dev/null | grep -a nexusgate | tail -n "$n"; else journalctl -u nexusgate -n "$n" --no-pager; fi } +caddy_reload() { if $IS_OPENWRT; then /etc/init.d/caddy reload; else systemctl reload caddy; fi } +own_data() { if ! $IS_OPENWRT; then chown -R nexusgate:nexusgate "$DATA_DIR"; chmod 0700 "$DATA_DIR"; fi } + backup() { need_root local output="${1:-/root/nexusgate-backup-$(date +%Y%m%d-%H%M%S).tar.gz}" local stage stage="$(mktemp -d /tmp/nexusgate-backup.XXXXXX)" trap 'rm -rf -- "$stage"' RETURN - cp -a /var/lib/nexusgate "$stage/data" + cp -a "$DATA_DIR" "$stage/data" cp -a /etc/nexusgate.env "$stage/nexusgate.env" [[ -f /etc/caddy/Caddyfile.d/nexusgate.caddy ]] && cp -a /etc/caddy/Caddyfile.d/nexusgate.caddy "$stage/nexusgate.caddy" tar -czf "$output" -C "$stage" . @@ -35,15 +47,14 @@ restore() { [[ -f "$stage/data/nexusgate.json" && -f "$stage/nexusgate.env" ]] || die "备份包不完整" safety="/root/nexusgate-before-restore-$(date +%Y%m%d-%H%M%S).tar.gz" backup "$safety" - systemctl stop nexusgate - find /var/lib/nexusgate -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - cp -a "$stage/data/." /var/lib/nexusgate/ - chown -R nexusgate:nexusgate /var/lib/nexusgate - chmod 0700 /var/lib/nexusgate + panel_stop + find "$DATA_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + cp -a "$stage/data/." "$DATA_DIR/" + own_data cp -a "$stage/nexusgate.env" /etc/nexusgate.env && chmod 0600 /etc/nexusgate.env if [[ -f "$stage/nexusgate.caddy" ]]; then cp -a "$stage/nexusgate.caddy" /etc/caddy/Caddyfile.d/nexusgate.caddy; fi - systemctl start nexusgate - systemctl reload caddy || true + panel_start + caddy_reload || true info "恢复完成;恢复前快照:$safety" } @@ -58,22 +69,25 @@ update_panel() { curl -fL --retry 3 "https://github.com/${REPO}/archive/refs/heads/${BRANCH}.tar.gz" -o "$stage/source.tgz" mkdir -p "$stage/source" && tar -xzf "$stage/source.tgz" -C "$stage/source" --strip-components=1 [[ -f "$stage/source/server.js" ]] || die "更新包不完整" - systemctl stop nexusgate + panel_stop cp -a "$stage/source/." /opt/nexusgate/ - install -m 0644 /opt/nexusgate/systemd/nexusgate.service /etc/systemd/system/nexusgate.service + if $IS_OPENWRT; then + install -m 0755 /opt/nexusgate/openrc/nexusgate /etc/init.d/nexusgate + else + install -m 0644 /opt/nexusgate/systemd/nexusgate.service /etc/systemd/system/nexusgate.service + systemctl daemon-reload + fi install -m 0755 /opt/nexusgate/scripts/nexusgate.sh /usr/local/sbin/nexusgate - chown -R nexusgate:nexusgate /var/lib/nexusgate - chmod 0700 /var/lib/nexusgate - [[ ! -f /var/lib/nexusgate/nexusgate.json ]] || chmod 0600 /var/lib/nexusgate/nexusgate.json - systemctl daemon-reload && systemctl start nexusgate + own_data + [[ ! -f "$DATA_DIR/nexusgate.json" ]] || chmod 0600 "$DATA_DIR/nexusgate.json" + panel_start local healthy=false for _ in {1..30}; do if curl -fsS http://127.0.0.1:8787/healthz >/dev/null 2>&1; then healthy=true; break; fi sleep 1 done if [[ "$healthy" != true ]]; then - systemctl status nexusgate --no-pager || true - journalctl -u nexusgate -n 100 --no-pager || true + panel_logs 35 || true die "更新后控制面启动失败;可使用上方备份恢复" fi if [[ -f /etc/nexusgate/agent.env ]]; then @@ -94,16 +108,16 @@ change_domain() { [[ "$domain" =~ ^[A-Za-z0-9.-]+$ ]] || die "域名格式不正确" sed -i -E "1s/^[^ ]+/${domain}/" /etc/caddy/Caddyfile.d/nexusgate.caddy caddy fmt --overwrite /etc/caddy/Caddyfile.d/nexusgate.caddy - caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy + caddy validate --config /etc/caddy/Caddyfile && caddy_reload info "域名已更新为 https://${domain}" } certificate_status() { need_root caddy validate --config /etc/caddy/Caddyfile - systemctl reload caddy + caddy_reload info "Caddy 已校验并重新加载;证书会在到期前自动续签" - journalctl -u caddy -n 30 --no-pager + if $IS_OPENWRT; then logread 2>/dev/null | grep -a caddy | tail -n 30; else journalctl -u caddy -n 30 --no-pager; fi } change_account() { @@ -119,18 +133,18 @@ change_account() { [[ "$first" == "$second" ]] || die "两次输入不一致" [[ -n "$next" || -n "$first" ]] || die "账号与密码都没有更改" [[ -z "$first" || ${#first} -ge 10 ]] || die "密码至少需要 10 个字符" - systemctl stop nexusgate + panel_stop set +e - NG_DATA_FILE=/var/lib/nexusgate/nexusgate.json NG_NEW_USERNAME="$next" NG_NEW_PASSWORD="$first" node /opt/nexusgate/scripts/reset-password.js "$current" + NG_DATA_FILE="$DATA_DIR/nexusgate.json" NG_NEW_USERNAME="$next" NG_NEW_PASSWORD="$first" node /opt/nexusgate/scripts/reset-password.js "$current" local result=$? unset first second set -e if [[ $result -eq 0 ]]; then - chown nexusgate:nexusgate /var/lib/nexusgate/nexusgate.json - chmod 0600 /var/lib/nexusgate/nexusgate.json + if ! $IS_OPENWRT; then chown nexusgate:nexusgate "$DATA_DIR/nexusgate.json"; fi + chmod 0600 "$DATA_DIR/nexusgate.json" sed -i '/^NG_ADMIN_PASSWORD=/d' /etc/nexusgate.env fi - systemctl start nexusgate + panel_start [[ $result -eq 0 ]] || die "管理员账号更新失败" info "管理员账号已更新,现有登录会话将在服务重启后失效" } @@ -142,13 +156,20 @@ uninstall_panel() { [[ "$confirm" == "UNINSTALL" ]] || die "已取消卸载" archive="/root/nexusgate-before-uninstall-$(date +%Y%m%d-%H%M%S).tar.gz" backup "$archive" - systemctl disable --now nexusgate.service >/dev/null 2>&1 || true - rm -f -- /etc/systemd/system/nexusgate.service /etc/nexusgate.env /etc/caddy/Caddyfile.d/nexusgate.caddy /usr/local/sbin/ng /usr/local/sbin/nexusgate - rm -rf -- /opt/nexusgate /var/lib/nexusgate - systemctl daemon-reload - systemctl reload caddy >/dev/null 2>&1 || true - userdel nexusgate >/dev/null 2>&1 || true - groupdel nexusgate >/dev/null 2>&1 || true + if $IS_OPENWRT; then + /etc/init.d/nexusgate stop >/dev/null 2>&1 || true + /etc/init.d/nexusgate disable >/dev/null 2>&1 || true + rm -f -- /etc/init.d/nexusgate + else + systemctl disable --now nexusgate.service >/dev/null 2>&1 || true + rm -f -- /etc/systemd/system/nexusgate.service + systemctl daemon-reload + userdel nexusgate >/dev/null 2>&1 || true + groupdel nexusgate >/dev/null 2>&1 || true + fi + rm -f -- /etc/nexusgate.env /etc/caddy/Caddyfile.d/nexusgate.caddy /usr/local/sbin/ng /usr/local/sbin/nexusgate + rm -rf -- /opt/nexusgate "$DATA_DIR" + caddy_reload >/dev/null 2>&1 || true printf '\nNexusGate 已卸载。可恢复备份:%s\n' "$archive" } @@ -164,9 +185,9 @@ menu() { 4) backup ;; 5) restore ;; 6) change_account ;; - 7) systemctl status nexusgate --no-pager ;; - 8) need_root; systemctl restart nexusgate caddy; info '已重启' ;; - 9) journalctl -u nexusgate -n 120 --no-pager ;; + 7) panel_status ;; + 8) need_root; panel_restart; caddy_reload; info '已重启' ;; + 9) panel_logs 120 ;; 10) uninstall_panel ;; 0) exit 0 ;; *) die '无效选择' ;; @@ -174,9 +195,9 @@ menu() { } case "${1:-menu}" in - status) systemctl status nexusgate --no-pager ;; - restart) need_root; systemctl restart nexusgate caddy ;; - logs) journalctl -u nexusgate -n "${2:-120}" --no-pager ;; + status) panel_status ;; + restart) need_root; panel_restart; caddy_reload ;; + logs) panel_logs "${2:-120}" ;; backup) backup "${2:-}" ;; restore) restore "${2:-}" ;; update) update_panel ;; diff --git a/server.js b/server.js index 1da3330..35cf11a 100644 --- a/server.js +++ b/server.js @@ -7,9 +7,9 @@ const net = require('node:net'); const { Store } = require('./lib/store'); const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth'); const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http'); -const { PROFILE_CATALOG, REALITY_PRESETS, validateEntryProtocol, validateProtocolPair, isRealityProtocol } = require('./lib/protocols'); -const { Orchestrator, audit, id, nowIso, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator'); -const { formatSubscription } = require('./lib/subscriptions'); +const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol } = require('./lib/protocols'); +const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator'); +const { formatSubscription, nodeClientUri } = require('./lib/subscriptions'); const { redactSecrets } = require('./lib/redact'); const { clientIdentity, activeClients, observedIps, pruneAccess } = require('./lib/access'); @@ -83,47 +83,63 @@ function normalizeServer(body, current = {}) { return next; } -function normalizeChain(body, current = {}) { +function normalizeNode(body, current = {}, data) { const topology = 'topology' in body ? (body.topology === 'direct' ? 'direct' : 'forward') : (current.topology || 'forward'); + const direct = topology === 'direct'; + const protocol = 'protocol' in body ? cleanText(body.protocol, 64) : current.protocol; + const serverId = 'serverId' in body ? requiredText(body.serverId, '服务器') : current.serverId; + const server = data.servers.find((item) => item.id === serverId); + if (!server) { const error = new Error('服务器不存在'); error.statusCode = 404; throw error; } + const exitServerId = direct ? null : ('exitServerId' in body ? requiredText(body.exitServerId, '出口服务器 ID') : current.exitServerId); + if (!direct && !data.servers.some((item) => item.id === exitServerId)) { const error = new Error('出口服务器不存在'); error.statusCode = 404; throw error; } const next = { ...current, - name: 'name' in body ? requiredText(body.name, '线路名称') : current.name, - topology, - relayServerIds: 'relayServerIds' in body ? asIds(body.relayServerIds) : (current.relayServerIds || []), - exitServerId: topology === 'direct' ? null : ('exitServerId' in body ? requiredText(body.exitServerId, '出口服务器 ID') : current.exitServerId), - customerIds: 'customerIds' in body ? asIds(body.customerIds) : (current.customerIds || []), - relayProtocol: 'relayProtocol' in body ? cleanText(body.relayProtocol, 64) : current.relayProtocol, - exitProtocol: topology === 'direct' ? null : ('exitProtocol' in body ? cleanText(body.exitProtocol, 64) : current.exitProtocol), - relayPortMode: 'relayPortMode' in body ? (body.relayPortMode === 'fixed' ? 'fixed' : 'random') : (current.relayPortMode || 'random'), - relayPort: 'relayPort' in body ? (body.relayPort ? Number(body.relayPort) : null) : (current.relayPort || null), - exitPortMode: topology === 'direct' ? null : ('exitPortMode' in body ? (body.exitPortMode === 'fixed' ? 'fixed' : 'random') : (current.exitPortMode || 'random')), - exitPort: topology === 'direct' ? null : ('exitPort' in body ? (body.exitPort ? Number(body.exitPort) : null) : (current.exitPort || null)), + name: 'name' in body ? requiredText(body.name, '节点名称') : current.name, + serverId, protocol, topology, + exitServerId, + exitProtocol: direct ? null : ('exitProtocol' in body ? cleanText(body.exitProtocol, 64) : current.exitProtocol), networkMode: 'networkMode' in body && ['ipv4', 'ipv6', 'dual'].includes(body.networkMode) ? body.networkMode : (current.networkMode || 'ipv4'), realityServerName: 'realityServerName' in body ? cleanText(body.realityServerName, 255) : (current.realityServerName || 'www.tesla.com'), - realityDestPort: 'realityDestPort' in body ? Number(body.realityDestPort || 443) : Number(current.realityDestPort || 443) + realityDestPort: 'realityDestPort' in body ? Number(body.realityDestPort || 443) : Number(current.realityDestPort || 443), }; - if (!next.relayServerIds.length || !next.customerIds.length) { const error = new Error('请选择入口服务器和客户'); error.statusCode = 400; throw error; } - if (next.relayPortMode === 'fixed' && (!Number.isInteger(next.relayPort) || next.relayPort < 1024 || next.relayPort > 65535)) { const error = new Error('固定入口端口无效'); error.statusCode = 400; throw error; } - if (topology === 'forward' && next.exitPortMode === 'fixed' && (!Number.isInteger(next.exitPort) || next.exitPort < 1024 || next.exitPort > 65535)) { const error = new Error('固定出口端口无效'); error.statusCode = 400; throw error; } - if (topology === 'direct') validateEntryProtocol(next.relayProtocol); else validateProtocolPair(next.relayProtocol, next.exitProtocol); - if (isRealityProtocol(next.relayProtocol) && !next.realityServerName) { const error = new Error('Reality SNI 不能为空'); error.statusCode = 400; throw error; } - if (isRealityProtocol(next.relayProtocol) && (!Number.isInteger(next.realityDestPort) || next.realityDestPort < 1 || next.realityDestPort > 65535)) { const error = new Error('Reality 目标端口无效'); error.statusCode = 400; throw error; } + const portMode = 'portMode' in body ? (body.portMode === 'fixed' ? 'fixed' : 'random') : (current.portMode || 'random'); + next.portMode = portMode; + // Only touch the port when the request changes addressing; otherwise keep it. + // (portMode 在表单里总是存在,只有真正改变时才重新分配,避免编辑保存时端口乱跳) + const portModeChanged = ('portMode' in body) && portMode !== (current.portMode || 'random'); + if (portModeChanged || 'port' in body || 'serverId' in body || !current.port) { + next.port = selectPort(data, server, portMode, 'port' in body ? body.port : current.port, current.id || null); + } + if (!direct) { + const exitPortMode = 'exitPortMode' in body ? (body.exitPortMode === 'fixed' ? 'fixed' : 'random') : (current.exitPortMode || 'random'); + next.exitPortMode = exitPortMode; + const exitServer = data.servers.find((item) => item.id === exitServerId); + const exitPortModeChanged = ('exitPortMode' in body) && exitPortMode !== (current.exitPortMode || 'random'); + if (exitPortModeChanged || 'exitPort' in body || 'exitServerId' in body || !current.exitPort) { + next.exitPort = selectPort(data, exitServer, exitPortMode, 'exitPort' in body ? body.exitPort : current.exitPort, current.id || null); + } + } else { + next.exitPortMode = null; next.exitPort = null; + } + if (direct) validateEntryProtocol(protocol); else validateProtocolPair(protocol, next.exitProtocol); + if (isRealityProtocol(protocol) && !next.realityServerName) { const error = new Error('Reality SNI 不能为空'); error.statusCode = 400; throw error; } + if (isRealityProtocol(protocol) && (!Number.isInteger(next.realityDestPort) || next.realityDestPort < 1 || next.realityDestPort > 65535)) { const error = new Error('Reality 目标端口无效'); error.statusCode = 400; throw error; } return next; } -function publicDeployment(item) { - const { credentials, clientTemplate, ...safe } = item; +function publicNode(item) { + const { exitCredentials, ssServerKey, ...safe } = item; return { ...safe, ...(safe.error ? { error: redactSecrets(safe.error) } : {}) }; } function publicServer(item) { return { ...item, ...(item.engine ? { engine: { ...item.engine, detail: redactSecrets(item.engine.detail) } } : {}), agentVersion: store.data.agents.find((agent) => agent.serverId === item.id && agent.status === 'active')?.version || null, - pendingCleanup: store.data.deployments.filter((entry) => entry.serverId === item.id && entry.archived && entry.status !== 'deleted').length }; + pendingCleanup: store.data.nodes.filter((entry) => (entry.serverId === item.id || entry.exitServerId === item.id) && entry.status === 'removing').length }; } function publicCustomer(item) { - return { ...item, pendingCleanup: store.data.deployments.filter((entry) => entry.customerId === item.id && entry.archived && entry.status !== 'deleted').length, + return { ...item, observedIpCount: observedIps(store.data, item.id).length, subscriptionClientCount: activeClients(store.data, item.id).length }; } @@ -303,11 +319,10 @@ async function handleAgent(req, res, pathname) { const server = data.servers.find((item) => item.id === agent.serverId); if (!server) throw Object.assign(new Error('设备不存在'), { statusCode:404 }); normalizeServer({ tlsDomain:domain }, server); - if (server.tlsDomain && server.tlsDomain !== domain && data.deployments.some((item) => - item.serverId === server.id && ['relay','direct'].includes(item.role) && - ['anytls','hysteria2','vless-ws-tls'].includes(item.protocol) && - ['active','queued','applying'].includes(item.status) && !item.archived)) { - throw Object.assign(new Error('设备已有使用其他域名的 TLS 节点;请先处理现有线路'), { statusCode:409 }); + if (server.tlsDomain && server.tlsDomain !== domain && data.nodes.some((item) => + item.serverId === server.id && ['anytls','hysteria2','vless-ws-tls'].includes(item.protocol) && + ['active','deploying'].includes(item.status))) { + throw Object.assign(new Error('设备已有使用其他域名的 TLS 节点;请先处理现有节点'), { statusCode:409 }); } if (server.tlsDomain !== domain) { server.tlsDomain = domain; @@ -370,8 +385,8 @@ async function handleAgent(req, res, pathname) { for (const sample of Array.isArray(body.observations) ? body.observations.slice(0, 1000) : []) { const customerId = cleanText(sample.customerId, 100); const ip = cleanText(sample.ip, 64).replace(/^\[|\]$/g, ''); - if (!net.isIP(ip) || !data.deployments.some((item) => item.serverId === agent.serverId && - item.customerId === customerId && ['relay', 'direct'].includes(item.role) && item.status === 'active' && !item.archived)) continue; + if (!net.isIP(ip) || !data.nodeClients.some((nc) => nc.customerId === customerId && nc.status === 'active' && + data.nodes.some((node) => node.id === nc.nodeId && node.serverId === agent.serverId && node.status === 'active'))) continue; const existing = data.observations.find((item) => item.customerId === customerId && item.ip === ip); if (existing) existing.lastSeenAt = nowIso(); else data.observations.push({ id: id('obs'), customerId, ip, firstSeenAt: nowIso(), lastSeenAt: nowIso() }); @@ -408,12 +423,12 @@ async function handleAdminApi(req, res, pathname) { sendJson(res, 200, { counts: { servers: data.servers.length, online, customers: data.customers.length, - chains: data.chains.length, activeDeployments: data.deployments.filter((item) => item.status === 'active').length, + nodes: data.nodes.length, activeNodes: data.nodes.filter((item) => item.status === 'active').length, queuedJobs: data.jobs.filter((item) => ['queued', 'running'].includes(item.status)).length }, version: VERSION, activity: data.activity.slice(0, 12), - degraded: data.deployments.filter((item) => !item.archived && item.status === 'failed').map(publicDeployment).slice(0, 10) + degraded: data.nodes.filter((item) => ['failed', 'degraded'].includes(item.status)).map(publicNode).slice(0, 10) }); return true; } @@ -475,11 +490,12 @@ async function handleAdminApi(req, res, pathname) { await store.transaction((data) => { const index = data.servers.findIndex((item) => item.id === serverItem.id); if (index < 0) throw Object.assign(new Error('服务器不存在'), { statusCode: 404 }); - if (data.deployments.some((item) => item.serverId === serverItem.id && item.status !== 'deleted')) { - throw Object.assign(new Error('设备仍有待清理资源。先删除关联线路并等待 Agent 上线确认清理,再删除设备'), { statusCode: 409 }); + const referencing = data.nodes.filter((item) => item.serverId === serverItem.id || item.exitServerId === serverItem.id); + if (referencing.some((item) => item.status === 'removing')) { + throw Object.assign(new Error('设备仍有待清理资源。等待 Agent 上线确认清理后,再删除设备'), { statusCode: 409 }); } - if (data.chains.some((item) => item.exitServerId === serverItem.id || item.relayServerIds.includes(serverItem.id))) { - throw Object.assign(new Error('设备仍被线路引用。请先编辑或删除关联线路'), { statusCode: 409 }); + if (referencing.length) { + throw Object.assign(new Error('设备仍被节点引用。请先删除关联节点'), { statusCode: 409 }); } data.servers.splice(index, 1); for (const agent of data.agents.filter((item) => item.serverId === serverItem.id)) agent.status = 'revoked'; @@ -509,12 +525,17 @@ async function handleAdminApi(req, res, pathname) { const count = await store.transaction((data) => { if (!data.servers.some((item) => item.id === cleanup.id)) throw Object.assign(new Error('服务器不存在'), { statusCode: 404 }); let queued = 0; - for (const deployment of data.deployments.filter((item) => item.serverId === cleanup.id && item.archived && item.status !== 'deleted')) { - const pending = data.jobs.some((job) => job.deploymentId === deployment.id && job.action === 'delete_resource' && ['queued', 'running'].includes(job.status)); - if (pending) continue; - data.jobs.push({ id: id('job'), serverId: cleanup.id, deploymentId: deployment.id, action: 'delete_resource', - payload: { resourceId: deployment.resourceId }, status: 'queued', attempts: 0, createdAt: nowIso(), updatedAt: nowIso(), error: null, leaseUntil: null }); - deployment.status = 'removing'; deployment.updatedAt = nowIso(); queued += 1; + for (const node of data.nodes.filter((item) => (item.serverId === cleanup.id || item.exitServerId === cleanup.id) && item.status === 'removing')) { + const legs = []; + if (node.serverId === cleanup.id) legs.push(['node', node.resourceId]); + if (node.exitServerId === cleanup.id && forwardNode(node) && node.exitResourceId) legs.push(['node-exit', node.exitResourceId]); + for (const [kind, resourceId] of legs) { + const pending = data.jobs.some((job) => job.nodeId === node.id && job.action === 'delete_resource' && + job.payload && job.payload.resourceId === resourceId && ['queued', 'running'].includes(job.status)); + if (pending) continue; + queueJob(data, cleanup.id, node.id, 'delete_resource', { resourceId, kind }); + queued += 1; + } } audit(data, actor, 'retry_cleanup', cleanup.id, { queued }); return queued; @@ -530,17 +551,15 @@ async function handleAdminApi(req, res, pathname) { const server = data.servers[index]; if (body.confirm !== 'FORGET' || body.name !== server.name) throw Object.assign(new Error('请输入准确设备名称以确认'), { statusCode: 400 }); if (server.status === 'online') throw Object.assign(new Error('在线设备不能强制遗忘,请先正常停用并清理资源'), { statusCode: 409 }); - if (data.chains.some((item) => item.exitServerId === server.id || item.relayServerIds.includes(server.id))) { - throw Object.assign(new Error('设备仍被线路引用,请先删除关联线路'), { statusCode: 409 }); - } - const residual = data.deployments.filter((item) => item.serverId === server.id && item.status !== 'deleted'); - if (residual.some((item) => !item.archived)) throw Object.assign(new Error('仍有未归档部署,请先删除关联线路'), { statusCode: 409 }); + const residual = data.nodes.filter((item) => item.serverId === server.id || item.exitServerId === server.id); + if (residual.some((item) => item.status !== 'removing')) throw Object.assign(new Error('仍有未停用的节点,请先删除关联节点'), { statusCode: 409 }); const residualIds = new Set(residual.map((item) => item.id)); - if (data.jobs.some((item) => residualIds.has(item.deploymentId) && item.status === 'running')) { + if (data.jobs.some((item) => residualIds.has(item.nodeId) && item.status === 'running')) { throw Object.assign(new Error('设备任务尚在执行,不能强制遗忘'), { statusCode: 409 }); } - data.jobs = data.jobs.filter((item) => !residualIds.has(item.deploymentId)); - data.deployments = data.deployments.filter((item) => !residualIds.has(item.id)); + data.jobs = data.jobs.filter((item) => !residualIds.has(item.nodeId)); + data.nodeClients = data.nodeClients.filter((item) => !residualIds.has(item.nodeId)); + data.nodes = data.nodes.filter((item) => !residualIds.has(item.id)); for (const agent of data.agents.filter((item) => item.serverId === server.id)) agent.status = 'revoked'; data.servers.splice(index, 1); audit(data, actor, 'force_forget_server', server.id, { name: server.name, unconfirmedResources: residual.length }); @@ -593,7 +612,7 @@ async function handleAdminApi(req, res, pathname) { const customerItem = route('/api/customers/:id', pathname); if (customerItem && req.method === 'PATCH') { const body = await readJson(req); - const customer = await store.transaction((data) => { + const customer = await store.transaction(async (data) => { const item = data.customers.find((entry) => entry.id === customerItem.id); if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 }); const wasActive = item.status === 'active'; @@ -617,7 +636,7 @@ async function handleAdminApi(req, res, pathname) { item.status = 'active'; item.suspendReason = null; } if (wasActive && item.status === 'suspended') suspendCustomerResources(data, item, actor); - if (!wasActive && item.status === 'active') resumeCustomerResources(data, item, actor); + if (!wasActive && item.status === 'active') await resumeCustomerResources(data, item, actor); item.updatedAt = nowIso(); audit(data, actor, 'update_customer', item.id); return structuredClone(item); @@ -627,12 +646,12 @@ async function handleAdminApi(req, res, pathname) { } const resetUsage = route('/api/customers/:id/reset-usage', pathname); if (resetUsage && req.method === 'POST') { - await store.transaction((data) => { + await store.transaction(async (data) => { const item = data.customers.find((entry) => entry.id === resetUsage.id); if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 }); item.usedBytes = 0; item.usedUplinkBytes = 0; item.usedDownlinkBytes = 0; if (item.status === 'suspended' && item.suspendReason === 'traffic_limit' && !customerBlockReason(data, item)) { - item.status = 'active'; item.suspendReason = null; resumeCustomerResources(data, item, actor); + item.status = 'active'; item.suspendReason = null; await resumeCustomerResources(data, item, actor); } item.updatedAt = nowIso(); audit(data, actor, 'reset_customer_usage', item.id); }); @@ -654,100 +673,125 @@ async function handleAdminApi(req, res, pathname) { await store.transaction((data) => { const index = data.customers.findIndex((item) => item.id === customerItem.id); if (index < 0) throw Object.assign(new Error('客户不存在'), { statusCode: 404 }); - if (data.chains.some((item) => item.customerIds.includes(customerItem.id))) { - throw Object.assign(new Error('客户仍被线路引用。请先移除或删除关联线路'), { statusCode: 409 }); - } - if (data.deployments.some((item) => item.customerId === customerItem.id && !item.archived && item.status !== 'deleted')) { - throw Object.assign(new Error('客户仍有活动部署。请先停用并删除关联线路'), { statusCode: 409 }); + if (data.nodeClients.some((item) => item.customerId === customerItem.id && + data.nodes.some((node) => node.id === item.nodeId && !node.pendingDelete))) { + throw Object.assign(new Error('客户仍有节点分配。请先移除全部分配'), { statusCode: 409 }); } data.customers.splice(index, 1); data.subscriptionAccess = data.subscriptionAccess.filter((item) => item.customerId !== customerItem.id); data.subscriptionClients = data.subscriptionClients.filter((item) => item.customerId !== customerItem.id); data.observations = data.observations.filter((item) => item.customerId !== customerItem.id); - // Archived cleanup records contain resource IDs and can finish without a customer row. - audit(data, actor, 'delete_customer', customerItem.id, { pendingCleanup: data.deployments.filter((item) => item.customerId === customerItem.id && item.archived && item.status !== 'deleted').length }); + audit(data, actor, 'delete_customer', customerItem.id); }); sendJson(res, 200, { ok: true }); return true; } - if (req.method === 'GET' && pathname === '/api/chains') { - sendJson(res, 200, { chains: store.data.chains, deployments: store.data.deployments.filter((item) => !item.archived).map(publicDeployment) }); + if (req.method === 'GET' && pathname === '/api/nodes') { + const visibleNodes = store.data.nodes.filter((item) => !item.pendingDelete); + const visibleIds = new Set(visibleNodes.map((item) => item.id)); + const nodeClients = store.data.nodeClients.filter((nc) => visibleIds.has(nc.nodeId)).map((nc) => { + const { credentials, ...safe } = nc; + return { ...safe, clientUri: nodeClientUri(store.data, nc) }; + }); + sendJson(res, 200, { nodes: visibleNodes.map(publicNode), nodeClients }); return true; } - if (req.method === 'POST' && pathname === '/api/chains') { + if (req.method === 'POST' && pathname === '/api/nodes') { const body = await readJson(req); - const chain = await store.transaction((data) => { + const node = await store.transaction((data) => { + const normalized = normalizeNode(body, {}, data); const next = { - ...normalizeChain(body), id: id('chn'), status: 'draft', generation: 0, - createdAt: nowIso(), updatedAt: nowIso(), redeployPending: false + id: id('nod'), status: 'draft', entryReady: false, exitReady: false, + resourceId: id('res'), exitResourceId: id('res'), + ...normalized, + artifacts: {}, error: null, pendingDelete: false, + usageCounters: null, retiredUsageEpochs: [], meteredTraffic: null, lastUsageAt: null, + createdAt: nowIso(), updatedAt: nowIso(), }; - data.chains.push(next); - audit(data, actor, 'create_chain', next.id, { name: next.name }); + ensureNodeSecrets(next); + data.nodes.push(next); + audit(data, actor, 'create_node', next.id, { name: next.name }); return structuredClone(next); }); - sendJson(res, 201, { chain }); + sendJson(res, 201, { node: publicNode(node) }); return true; } - const deploy = route('/api/chains/:id/deploy', pathname); - if (deploy && req.method === 'POST') { - const deployments = await orchestrator.deployChain(deploy.id, actor); - sendJson(res, 202, { deployments: deployments.map(publicDeployment) }); - return true; - } - const remove = route('/api/chains/:id/remove', pathname); - if (remove && req.method === 'POST') { - const count = await orchestrator.removeChain(remove.id, actor); - sendJson(res, 202, { jobs: count }); - return true; - } - const redeploy = route('/api/chains/:id/redeploy', pathname); - if (redeploy && req.method === 'POST') { const count = await orchestrator.removeChain(redeploy.id, actor, true); sendJson(res, 202, { jobs: count }); return true; } - const repair = route('/api/chains/:id/repair', pathname); - if (repair && req.method === 'POST') { const count = await orchestrator.repairChain(repair.id, actor); sendJson(res, 202, { jobs: count }); return true; } - const restoreChain = route('/api/chains/:id/restore', pathname); - if (restoreChain && req.method === 'POST') { const count = await orchestrator.restoreChain(restoreChain.id, actor); sendJson(res, 202, { jobs: count }); return true; } - const chainItem = route('/api/chains/:id', pathname); - if (chainItem && req.method === 'PATCH') { + const nodeItem = route('/api/nodes/:id', pathname); + if (nodeItem && req.method === 'PATCH') { const body = await readJson(req); - const chain = await store.transaction((data) => { - const item = data.chains.find((entry) => entry.id === chainItem.id); - if (!item) throw Object.assign(new Error('线路不存在'), { statusCode: 404 }); - const hasActive = data.deployments.some((entry) => entry.chainId === item.id && !['deleted', 'failed'].includes(entry.status)); - Object.assign(item, normalizeChain(body, item), { updatedAt: nowIso() }); - item.status = hasActive ? 'changes_pending' : 'draft'; - audit(data, actor, 'update_route', item.id, { requiresRedeploy: hasActive }); - return { item: structuredClone(item), requiresRedeploy: hasActive }; - }); - sendJson(res, 200, { chain: chain.item, requiresRedeploy: chain.requiresRedeploy }); return true; - } - if (chainItem && req.method === 'DELETE') { - const cleanupPending = await store.transaction((data) => { - const index = data.chains.findIndex((item) => item.id === chainItem.id); - if (index < 0) throw Object.assign(new Error('链路不存在'), { statusCode: 404 }); - const deployments = data.deployments.filter((item) => item.chainId === chainItem.id && item.status !== 'deleted'); - if (deployments.some((item) => item.status === 'active')) { - throw Object.assign(new Error('线路仍有运行中的资源。请先停用并等待 Agent 确认'), { statusCode: 409 }); - } - if (data.jobs.some((job) => deployments.some((item) => item.id === job.deploymentId) && job.status === 'running')) { - throw Object.assign(new Error('部署任务正在设备上执行,请稍后刷新再删除'), { statusCode: 409 }); - } - // Retain hidden deployment tombstones until the agent confirms deletion. This prevents - // reusing occupied ports or silently leaving orphaned Xray resources on offline machines. - for (const deployment of deployments) { - for (const job of data.jobs.filter((entry) => entry.deploymentId === deployment.id && entry.status === 'queued')) { - job.status = 'failed'; job.error = '线路已删除,原任务取消'; job.updatedAt = nowIso(); + const node = await store.transaction(async (data) => { + const item = data.nodes.find((entry) => entry.id === nodeItem.id); + if (!item) throw Object.assign(new Error('节点不存在'), { statusCode: 404 }); + if (item.status === 'removing') throw Object.assign(new Error('节点正在移除中,请等待完成后再编辑'), { statusCode: 409 }); + const oldServerId = item.serverId; + const oldExitServerId = item.exitServerId; + const oldForward = forwardNode(item); + const oldProtocol = item.protocol; + Object.assign(item, normalizeNode(body, item, data)); + ensureNodeSecrets(item); + // 协议变更可能导致旧凭据长度非法(如 SS2022 密钥长度),重发全部客户端凭据。 + if (item.protocol !== oldProtocol) { + for (const nc of data.nodeClients.filter((entry) => entry.nodeId === item.id)) { + nc.credentials = newNodeClientCredentials(item.protocol); + nc.updatedAt = nowIso(); } - data.jobs.push({ id: id('job'), serverId: deployment.serverId, deploymentId: deployment.id, - action: 'delete_resource', payload: { resourceId: deployment.resourceId }, status: 'queued', attempts: 0, - createdAt: nowIso(), updatedAt: nowIso(), error: null, leaseUntil: null }); - deployment.status = 'removing'; deployment.archived = true; deployment.clientUri = null; deployment.updatedAt = nowIso(); } - data.chains.splice(index, 1); - audit(data, actor, 'delete_chain', chainItem.id, { cleanupPending: deployments.length }); - return deployments.length; + // 服务器变更:先在旧设备上清理资源。 + if (item.serverId !== oldServerId) { + queueJob(data, oldServerId, item.id, 'delete_resource', { resourceId: item.resourceId, kind: 'node' }); + item.entryReady = false; + } + if (oldForward && (!forwardNode(item) || item.exitServerId !== oldExitServerId)) { + queueJob(data, oldExitServerId, item.id, 'delete_resource', { resourceId: item.exitResourceId, kind: 'node-exit' }); + item.exitReady = false; + } + // 运行中的节点直接重建下发(对标 3x-ui 保存即生效)。 + if (['active', 'deploying', 'failed', 'degraded'].includes(item.status)) { + await rebuildNode(data, item, { includeExit: true }); + } + item.updatedAt = nowIso(); + audit(data, actor, 'update_node', item.id, { name: item.name }); + return structuredClone(item); }); - sendJson(res, 200, { ok: true, cleanupPending }); + sendJson(res, 200, { node: publicNode(node) }); + return true; + } + if (nodeItem && req.method === 'DELETE') { + if (store.data.jobs.some((job) => job.nodeId === nodeItem.id && job.status === 'running')) { + throw Object.assign(new Error('节点任务正在设备上执行,请稍后刷新再删除'), { statusCode: 409 }); + } + await orchestrator.removeNode(nodeItem.id, actor, true); + sendJson(res, 200, { ok: true }); + return true; + } + const deploy = route('/api/nodes/:id/deploy', pathname); + if (deploy && req.method === 'POST') { + await orchestrator.deployNode(deploy.id, actor); + sendJson(res, 202, { ok: true }); + return true; + } + const remove = route('/api/nodes/:id/remove', pathname); + if (remove && req.method === 'POST') { + await orchestrator.removeNode(remove.id, actor, false); + sendJson(res, 202, { ok: true }); + return true; + } + const repair = route('/api/nodes/:id/repair', pathname); + if (repair && req.method === 'POST') { await orchestrator.repairNode(repair.id, actor); sendJson(res, 202, { ok: true }); return true; } + const assign = route('/api/nodes/:id/assign', pathname); + if (assign && req.method === 'POST') { + const body = await readJson(req); + const nodeClient = await orchestrator.assignCustomer(assign.id, requiredText(body.customerId, '客户'), actor); + const { credentials, ...safe } = nodeClient; + sendJson(res, 200, { nodeClient: safe }); + return true; + } + const unassign = route('/api/nodes/:id/unassign', pathname); + if (unassign && req.method === 'POST') { + const body = await readJson(req); + await orchestrator.unassignCustomer(unassign.id, requiredText(body.customerId, '客户'), actor); + sendJson(res, 200, { ok: true }); return true; } @@ -779,8 +823,144 @@ async function handleAdminApi(req, res, pathname) { return false; } +function formatBytes(bytes) { + if (!bytes || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let v = bytes, i = 0; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v.toFixed(2)}${units[i]}`; +} + +function buildSubscriptionInfoPage(data, customer, req) { + const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const used = customer.usedBytes || 0; + const up = customer.usedUplinkBytes || 0; + const down = customer.usedDownlinkBytes || 0; + const total = customer.trafficLimitBytes || 0; + const remaining = total > 0 ? Math.max(0, total - used) : -1; + const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0; + const statusText = customer.status === 'active' ? '启用' : '停用'; + const statusClass = customer.status === 'active' ? 'on' : 'off'; + const expiresText = customer.expiresAt ? new Date(customer.expiresAt).toLocaleString('zh-CN') : '永不过期'; + const lastOnline = customer.lastOnlineAt ? new Date(customer.lastOnlineAt).toLocaleString('zh-CN') : '从未'; + + // 该用户的活跃节点分配 + const assignments = data.nodeClients.filter((nc) => nc.customerId === customer.id && nc.status === 'active'); + const host = req.headers.host || ''; + const proto = req.headers['x-forwarded-proto'] || 'http'; + const subUrl = `${proto}://${host}/s/${customer.subscriptionToken}/auto`; + + const nodeRows = assignments.map((nc) => { + const node = data.nodes.find((n) => n.id === nc.nodeId); + if (!node || node.status !== 'active') return ''; + const server = data.servers.find((s) => s.id === node.serverId); + // clientUri 只在 /api/nodes 接口里实时计算,DB 里没有存,这里必须同样实时算 + const uri = nodeClientUri(data, nc) || ''; + if (!uri) return ''; + // 协议标签 + const protoLabel = node.protocol.includes('vless') ? 'Vless' : node.protocol.includes('shadowsock') ? 'Shadowsocks' : node.protocol.includes('socks') ? 'Socks' : node.protocol.includes('hysteria') ? 'Hysteria2' : 'AnyTLS'; + const netLabel = 'TCP'; + const secLabel = node.protocol.includes('reality') ? 'REALITY' : node.protocol.includes('tls') ? 'TLS' : ''; + const nodeName = `${node.name}-${customer.name}`; + return `
+ ${esc(protoLabel)} + ${netLabel} + ${secLabel ? `${secLabel}` : ''} + ${esc(nodeName)} + + + +
`; + }).join(''); + + const allUris = assignments.map((nc) => nodeClientUri(data, nc)).filter(Boolean).join('\n'); + + return ` + +订阅信息 - ${esc(customer.name)} + +
+

订阅信息 ${esc(customer.subscriptionToken.slice(0,16))}

+ + + + + + + + + + + +
订阅 ID${esc(customer.subscriptionToken.slice(0,16))}
用户${esc(customer.name)}
状态${statusText}
已下载${formatBytes(down)}
已上传${formatBytes(up)}
使用情况${formatBytes(used)}
总配额${total > 0 ? formatBytes(total) : '无限制'}
剩余${remaining >= 0 ? formatBytes(remaining) : '无限制'}
上次在线${esc(lastOnline)}
到期${esc(expiresText)}
+${total > 0 ? `
+
${formatBytes(used)} / ${formatBytes(total)}
+
+
${formatBytes(remaining)} 剩余${pct.toFixed(1)}%
+
` : ''} +
订阅信息
+
SUB${esc(subUrl)} + +
+
复制链接
+
复制全部配置
+${nodeRows || '

暂无可用节点

'} +
+
+

+
+`; +} + async function handleSubscription(req, res, pathname) { - const match = /^\/s\/([A-Za-z0-9_-]{32,100})\/(auto|raw|base64|v2ray|shadowrocket|clash|clash-smart|mihomo|singbox|surge)$/.exec(pathname); + const match = /^\/s\/([A-Za-z0-9_-]{32,100})\/(auto|raw|base64|v2ray|shadowrocket|clash|clash-smart|mihomo|singbox|surge|info)$/.exec(pathname); if (!match) return false; const headers = { 'cache-control':'no-store, private', 'x-robots-tag':'noindex, nofollow', 'vary':'User-Agent' }; if (req.method !== 'GET') { sendError(res, 405, '只支持 GET 请求'); return true; } @@ -799,15 +979,22 @@ async function handleSubscription(req, res, pathname) { await store.transaction((data) => { data.subscriptionAccess.push(access); pruneAccess(data); }); sendJson(res, 403, { message:'客户已停用、到期或流量用尽' }, headers); return true; } + // 订阅信息 HTML 页面(3x-ui 风格) + if (match[2] === 'info') { + const html = buildSubscriptionInfoPage(store.data, customer, req); + res.writeHead(200, { ...headers, 'content-type': 'text/html; charset=utf-8' }); + res.end(html); + return true; + } const format = ({ v2ray:'base64', shadowrocket:'base64', mihomo:'clash-smart' })[match[2]] || match[2]; const result = formatSubscription(store.data, customer, format, String(req.headers['user-agent'] || '')); if (!result) { - const restoring = store.data.deployments.some((item) => item.customerId === customer.id && !item.archived && - ['relay','direct'].includes(item.role) && ['queued','removing'].includes(item.status) && - store.data.jobs.some((job) => job.deploymentId === item.id && ['queued','running'].includes(job.status))); + const restoring = store.data.nodeClients.some((nc) => nc.customerId === customer.id && nc.status === 'active' && + store.data.nodes.some((node) => node.id === nc.nodeId && ['deploying','removing'].includes(node.status)) && + store.data.jobs.some((job) => job.nodeId === nc.nodeId && ['queued','running'].includes(job.status))); access.status = 503; access.reason = restoring ? '入口节点恢复中' : '没有已部署的入口节点'; await store.transaction((data) => { data.subscriptionAccess.push(access); pruneAccess(data); }); - sendJson(res, 503, { message:restoring ? '节点正在恢复,请稍后更新订阅' : '当前没有部署成功的入口节点,请在“转发与节点”恢复原节点或部署线路' }, + sendJson(res, 503, { message:restoring ? '节点正在恢复,请稍后更新订阅' : '当前没有部署成功的入口节点,请在“转发与节点”部署节点' }, { ...headers, ...(restoring ? { 'retry-after':'15' } : {}) }); return true; } const body = Buffer.from(result.body); @@ -868,7 +1055,7 @@ async function requestHandler(req, res) { async function housekeeping() { try { - const redeployIds = await store.transaction((data) => { + await store.transaction(async (data) => { const now = Date.now(); for (const server of data.servers) { if (server.lastSeenAt && now - new Date(server.lastSeenAt).getTime() > 180000) server.status = 'offline'; @@ -878,11 +1065,11 @@ async function housekeeping() { if (Number(job.attempts || 0) < 3) { job.status = 'queued'; job.error = 'Agent 未在租约内确认,任务已自动重试'; } else { job.status = 'failed'; job.error = 'Agent 连续 3 次未在租约内确认'; - const deployment = data.deployments.find((item) => item.id === job.deploymentId); - if (deployment) { - deployment.status = 'failed'; deployment.error = job.error; deployment.updatedAt = nowIso(); - const chain = data.chains.find((item) => item.id === deployment.chainId); - if (chain) { chain.status = 'degraded'; chain.updatedAt = nowIso(); } + const node = data.nodes.find((item) => item.id === job.nodeId); + if (node) { + const kind = job.payload && (job.payload.kind || (job.payload.resource && job.payload.resource.meta && job.payload.resource.meta.kind)); + node.status = kind === 'node-exit' ? 'degraded' : 'failed'; + node.error = job.error; node.updatedAt = nowIso(); } } } @@ -893,10 +1080,10 @@ async function housekeeping() { customer.status = 'suspended'; customer.suspendReason = expired ? 'expired' : 'traffic_limit'; customer.updatedAt = nowIso(); - suspendCustomerResources(data, customer, 'system:quota'); + await suspendCustomerResources(data, customer, 'system:quota'); } else if (customer.status === 'suspended' && customer.suspendReason === 'ip_limit' && !customerBlockReason(data, customer)) { customer.status = 'active'; customer.suspendReason = null; customer.updatedAt = nowIso(); - resumeCustomerResources(data, customer, 'system:ip_window'); + await resumeCustomerResources(data, customer, 'system:ip_window'); } } const jobCutoff = now - (data.settings.completedJobRetentionDays || 7) * 86400000; @@ -904,18 +1091,8 @@ async function housekeeping() { const activityCutoff = now - (data.settings.activityRetentionDays || 30) * 86400000; data.activity = data.activity.filter((event) => new Date(event.at).getTime() > activityCutoff).slice(0, 2000); pruneAccess(data, now); - return data.chains.filter((item) => item.status === 'redeploy_pending').map((item) => item.id); + return null; }); - for (const chainId of redeployIds) { - try { await orchestrator.deployChain(chainId, 'system:redeploy'); } - catch (error) { - console.error(`Automatic redeploy failed for ${chainId}:`, error); - await store.transaction((data) => { - const chain = data.chains.find((item) => item.id === chainId); - if (chain) { chain.status = 'degraded'; chain.lastError = String(error.message || error); chain.updatedAt = nowIso(); } - }); - } - } sessions.prune(); } catch (error) { console.error('Housekeeping failed:', error); @@ -926,11 +1103,11 @@ async function main() { await store.init(); // Earlier Agents reported the full x25519 output on parse failures. // Remove any private key from persisted job/deployment history on upgrade. - if ([...store.data.jobs, ...store.data.deployments, ...store.data.chains, ...store.data.servers].some((item) => + if ([...store.data.jobs, ...store.data.nodes, ...store.data.servers].some((item) => item.error && item.error !== redactSecrets(item.error) || item.lastError && item.lastError !== redactSecrets(item.lastError) || item.engine?.detail && item.engine.detail !== redactSecrets(item.engine.detail))) { await store.transaction((data) => { - for (const item of [...data.jobs, ...data.deployments, ...data.chains, ...data.servers]) { + for (const item of [...data.jobs, ...data.nodes, ...data.servers]) { if (item.error) item.error = redactSecrets(item.error); if (item.lastError) item.lastError = redactSecrets(item.lastError); if (item.engine?.detail) item.engine.detail = redactSecrets(item.engine.detail); diff --git a/systemd/nexusgate-agent.service b/systemd/nexusgate-agent.service index b9c726e..1b334c5 100644 --- a/systemd/nexusgate-agent.service +++ b/systemd/nexusgate-agent.service @@ -1,12 +1,12 @@ [Unit] Description=NexusGate node agent -After=network-online.target nexusgate-xray.service +After=network-online.target nexusgate-sing-box.service Wants=network-online.target [Service] Type=simple EnvironmentFile=/etc/nexusgate/agent.env -ExecStart=/usr/bin/node /opt/nexusgate-agent/agent.js +ExecStart=/usr/bin/node --max-old-space-size=64 --optimize-for-size /opt/nexusgate-agent/agent.js Restart=always RestartSec=5 NoNewPrivileges=true diff --git a/systemd/nexusgate-sing-box.service b/systemd/nexusgate-sing-box.service index 057758f..37d3c4c 100644 --- a/systemd/nexusgate-sing-box.service +++ b/systemd/nexusgate-sing-box.service @@ -6,6 +6,8 @@ Wants=network-online.target [Service] Type=simple User=root +Environment=GOMEMLIMIT=64MiB +Environment=GOGC=50 ExecStart=/usr/local/bin/nexusgate-sing-box run -c /etc/nexusgate/sing-box/config.json Restart=on-failure RestartSec=3 diff --git a/systemd/nexusgate-xray.service b/systemd/nexusgate-xray.service deleted file mode 100644 index 76b945d..0000000 --- a/systemd/nexusgate-xray.service +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=NexusGate managed Xray core -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=root -ExecStart=/usr/local/bin/xray run -config /etc/nexusgate/xray/config.json -Restart=on-failure -RestartSec=3 -LimitNOFILE=1048576 - -[Install] -WantedBy=multi-user.target diff --git a/test/agent.test.js b/test/agent.test.js index 3569c20..1eef571 100644 --- a/test/agent.test.js +++ b/test/agent.test.js @@ -7,7 +7,7 @@ const os = require('node:os'); const path = require('node:path'); const dgram = require('node:dgram'); const { spawn } = require('node:child_process'); -const { parseX25519, parseUsageStats, combinedConfig, udpPortsForPid, systemInfo } = require('../agent/agent'); +const { parseUsageStats, combinedSingBoxConfig, applyResource, queryUsage, udpPortsForPid, systemInfo } = require('../agent/agent'); const { redactSecrets } = require('../lib/redact'); test('Agent reports usable memory and system disk without reading per-connection state', () => { @@ -17,46 +17,34 @@ test('Agent reports usable memory and system disk without reading per-connection if (info.diskTotal !== null) assert.ok(info.diskTotal >= info.diskAvailable && info.diskAvailable >= 0); }); -test('parses both current and older Xray x25519 output without including private keys in errors', () => { - const current = parseX25519('PrivateKey: current_private\nPassword (PublicKey): current_public\nHash32: ignored'); - assert.deepEqual(current, { privateKey:'current_private', publicKey:'current_public' }); - const older = parseX25519('Private key: older_private\nPublic key: older_public'); - assert.equal(older.publicKey, 'older_public'); - assert.throws(() => parseX25519('PrivateKey: very_secret\nUnknown: broken'), (error) => !error.message.includes('very_secret')); - assert.equal(redactSecrets('Unable to parse: PrivateKey: very_secret Password (PublicKey): public'), - 'Unable to parse: PrivateKey: [REDACTED] Password (PublicKey): public'); -}); - -test('Xray candidate configuration has a JSON extension at validation time', () => { - const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-agent-test-')); +test('realityKey generates an X25519 pair with Node crypto and caches it', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-reality-')); try { - const fakeXray = path.join(temp, 'fake-xray'); - const configDir = path.join(temp, 'xray'); - fs.mkdirSync(path.join(configDir, 'resources'), { recursive:true }); - fs.writeFileSync(fakeXray, '#!/bin/sh\nprintf "%s\\n" "$4" > "' + path.join(temp, 'argument') + '"\nexit 41\n', { mode:0o755 }); - // Module paths are read at import time; test the real activation logic in a child. + const keyFile = path.join(temp, 'keys.json'); const { spawnSync } = require('node:child_process'); - const result = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).activateConfig()`], { - env:{ ...process.env, NG_CONFIG_DIR:configDir, NG_XRAY_BIN:fakeXray }, encoding:'utf8' + const script = `const { realityKey } = require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}); +const a = realityKey('test-key'); +const b = realityKey('test-key'); +if (a.privateKey !== b.privateKey || a.publicKey !== b.publicKey) throw new Error('key was not cached'); +for (const k of [a.privateKey, a.publicKey]) if (!/^[A-Za-z0-9_-]{43}$/.test(k)) throw new Error('bad key format'); +process.stdout.write('OK');`; + const run = spawnSync(process.execPath, ['-e', script], { + env: { ...process.env, NG_KEY_FILE: keyFile }, encoding: 'utf8' }); - assert.notEqual(result.status, 0); - const candidate = fs.readFileSync(path.join(temp, 'argument'), 'utf8').trim(); - assert.equal(candidate, path.join(configDir, 'config.json.candidate.json')); - assert.equal(JSON.parse(fs.readFileSync(candidate, 'utf8')).inbounds[0].tag, 'api-in'); - } finally { fs.rmSync(temp, { recursive:true, force:true }); } + assert.equal(run.status, 0, run.stderr); + assert.equal(run.stdout, 'OK'); + } finally { fs.rmSync(temp, { recursive: true, force: true }); } }); -test('old Hysteria 2 resources are repaired without rotating authentication or changing saved resources', () => { - const legacy = { id:'hy2-entry', inbounds:[{ tag:'hy2-in', protocol:'hysteria', port:34537, - settings:{ version:2, users:[{ auth:'unchanged-password', email:'ng:customer' }] }, - streamSettings:{ method:'hysteria', security:'tls', hysteriaSettings:{ version:2 } } }] }; - const before = JSON.stringify(legacy); - const inbound = combinedConfig([legacy]).inbounds[1]; - assert.equal(inbound.streamSettings.network, 'hysteria'); - assert.equal(inbound.settings.clients[0].auth, 'unchanged-password'); - assert.deepEqual(inbound.settings.clients, inbound.settings.users); - assert.equal(JSON.stringify(legacy), before); - assert.throws(() => combinedConfig([{ inbounds:[{ protocol:'hysteria', settings:{ version:2 } }] }]), /认证账户/); +test('applyResource rejects Xray resources and unprotected Hysteria 2 inbounds', async () => { + const { applyResource } = require('../agent/agent'); + await assert.rejects( + applyResource({ resource: { id: 'xray-resource-1', engine: 'xray', inbounds: [] } }), + /只支持 sing-box/); + await assert.rejects( + applyResource({ resource: { id: 'hy2-noauth-1', engine: 'sing-box', + inbounds: [{ type: 'hysteria2', tag: 'hy2', users: [] }] } }), + /认证账户/); }); test('UDP readiness inspection finds only ports owned by the target process', async () => { @@ -69,81 +57,38 @@ test('UDP readiness inspection finds only ports owned by the target process', as } finally { socket.close(); other.kill(); } }); -test('Agent restart preserves a healthy Xray process when configuration is unchanged', () => { - const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-no-restart-')); - try { - const configDir = path.join(temp, 'xray'); - const binDir = path.join(temp, 'bin'); - fs.mkdirSync(path.join(configDir, 'resources'), { recursive:true }); - fs.mkdirSync(binDir); - const resource = { id:'entry-resource', meta:{ kind:'direct', metricsTag:'entry-in' }, - inbounds:[], outbounds:[], routingRules:[] }; - fs.writeFileSync(path.join(configDir, 'resources', 'entry.json'), JSON.stringify(resource)); - const agentFile = path.join(__dirname, '../agent/agent.js'); - const script = `const agent=require(${JSON.stringify(agentFile)});const fs=require('node:fs');fs.writeFileSync(${JSON.stringify(path.join(configDir,'config.json'))}, JSON.stringify(agent.combinedConfig([${JSON.stringify(resource)}]), null, 2)+'\\n');agent.activateConfig()`; - const { spawnSync } = require('node:child_process'); - const serviceCommand = fs.existsSync('/run/systemd/system') ? 'systemctl' : 'rc-service'; - fs.writeFileSync(path.join(binDir, serviceCommand), '#!/bin/sh\nexit 0\n', { mode:0o755 }); - const fakeXray = path.join(binDir, 'xray'); - fs.writeFileSync(fakeXray, '#!/bin/sh\nexit 91\n', { mode:0o755 }); - const result = spawnSync(process.execPath, ['-e', script], { - env:{ ...process.env, PATH:`${binDir}:${process.env.PATH}`, NG_CONFIG_DIR:configDir, NG_XRAY_BIN:fakeXray }, encoding:'utf8' - }); - assert.equal(result.status, 0, result.stderr); - assert.equal(fs.existsSync(path.join(configDir, 'config.json.candidate.json')), false); - } finally { fs.rmSync(temp, { recursive:true, force:true }); } -}); - -test('IP observations exclude exit hop addresses and leave partial log lines unread', () => { - const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-observations-')); - try { - const dir = path.join(temp, 'config'); - fs.mkdirSync(path.join(dir, 'resources'), { recursive:true }); - fs.writeFileSync(path.join(dir, 'resources', 'relay.json'), JSON.stringify({ id:'relay', meta:{ kind:'relay', metricsTag:'entry-tag', customerId:'customer-a' } })); - fs.writeFileSync(path.join(dir, 'resources', 'exit.json'), JSON.stringify({ id:'exit', meta:{ kind:'exit', metricsTag:'exit-tag', customerId:'customer-a' } })); - const accessLog = path.join(temp, 'access.log'); - const cursorFile = path.join(temp, 'cursor.json'); - const entry = '2026/09/23 from tcp:198.51.100.40:1234 accepted tcp:example.com:443 [entry-tag -> direct]'; - const exit = '2026/09/23 from tcp:192.0.2.20:3344 accepted tcp:example.com:443 [exit-tag -> direct]'; - fs.writeFileSync(accessLog, `${entry}\n${exit}\n${entry.slice(0, 28)}`); - const { spawnSync } = require('node:child_process'); - const script = `process.stdout.write(JSON.stringify(require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).readObservations()))`; - const run = spawnSync(process.execPath, ['-e', script], { - env:{ ...process.env, NG_CONFIG_DIR:dir, NG_XRAY_ACCESS_LOG:accessLog, NG_ACCESS_CURSOR:cursorFile }, encoding:'utf8' - }); - assert.equal(run.status, 0, run.stderr); - const result = JSON.parse(run.stdout); - assert.deepEqual(result.observations, [{ customerId:'customer-a', ip:'198.51.100.40' }]); - assert.equal(result.offset, Buffer.byteLength(`${entry}\n${exit}\n`)); - assert.equal(fs.existsSync(cursorFile), false); - } finally { fs.rmSync(temp, { recursive:true, force:true }); } -}); - -test('Xray JSON counters are matched by name, including quoted values in either order', () => { - const resources = [{ id:'resource-1', meta:{ kind:'direct', metricsTag:'ng-in' } }]; +test('sing-box JSON counters are matched by name, inbound and per-user, including quoted values', () => { const output = JSON.stringify({ stat:[ { name:'inbound>>>other>>>traffic>>>downlink', value:'8000' }, { name:'inbound>>>ng-in>>>traffic>>>downlink', value:'2048' }, - { name:'inbound>>>ng-in>>>traffic>>>uplink', value:'128' } + { name:'inbound>>>ng-in>>>traffic>>>uplink', value:'128' }, + { name:'user>>>ng:cus-a>>>traffic>>>uplink', value:'10' }, + { name:'user>>>ng:cus-a>>>traffic>>>downlink', value:'20' }, + { name:'user>>>unrelated>>>traffic>>>uplink', value:'5' } ] }); - assert.deepEqual(parseUsageStats(output, resources), [{ resourceId:'resource-1', uplink:128, downlink:2048 }]); - assert.deepEqual(parseUsageStats('{"stat":[]}', resources), []); - assert.deepEqual(parseUsageStats('{}', resources), []); - assert.deepEqual(parseUsageStats(JSON.stringify({ stat:[ - { name:'inbound>>>unrelated>>>traffic>>>uplink', value:'bad' }, - { name:'inbound>>>ng-in>>>traffic>>>uplink', value:'42' } - ] }), resources), [{ resourceId:'resource-1', uplink:42, downlink:0 }]); + assert.deepEqual(parseUsageStats(output), { + inbounds: [ + { tag:'other', uplink:0, downlink:8000 }, + { tag:'ng-in', uplink:128, downlink:2048 }, + ], + users: [ + { name:'ng:cus-a', uplink:10, downlink:20 }, + { name:'unrelated', uplink:5, downlink:0 }, + ], + }); + assert.deepEqual(parseUsageStats('{"stat":[]}'), { inbounds:[], users:[] }); + assert.deepEqual(parseUsageStats('{}'), { inbounds:[], users:[] }); assert.deepEqual(parseUsageStats(JSON.stringify({ stat:[ { name:'inbound>>>ng-in>>>traffic>>>uplink' }, { name:'inbound>>>ng-in>>>traffic>>>downlink', value:'1024' } - ] }), resources), [{ resourceId:'resource-1', uplink:0, downlink:1024 }]); + ] })), { inbounds:[{ tag:'ng-in', uplink:0, downlink:1024 }], users:[] }); assert.throws(() => parseUsageStats(JSON.stringify({ stat:[ - { name:'inbound>>>ng-in>>>traffic>>>uplink', value:'undefined' } - ] }), resources), /入口计数异常/); + { name:'user>>>ng:cus-a>>>traffic>>>uplink', value:'undefined' } + ] })), /入口计数异常/); assert.throws(() => parseUsageStats(JSON.stringify({ stat:[ { name:'inbound>>>ng-in>>>traffic>>>uplink', value:'-1' } - ] }), resources), /入口计数异常(负数)/); - assert.throws(() => parseUsageStats('not json', resources)); + ] })), /入口计数异常(负数)/); + assert.throws(() => parseUsageStats('not json')); }); test('Agent does not advertise an untrusted self-signed node certificate as ready', () => { @@ -175,35 +120,39 @@ test('Agent does not advertise an untrusted self-signed node certificate as read } finally { fs.rmSync(temp, { recursive:true, force:true }); } }); -test('Agent reads cumulative counters without resetting Xray', () => { +test('Agent reads cumulative sing-box counters without resetting them', () => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-usage-')); try { - const dir = path.join(temp, 'xray'); + const dir = path.join(temp, 'agent'); fs.mkdirSync(path.join(dir, 'resources'), { recursive:true }); - fs.writeFileSync(path.join(dir, 'resources', 'entry.json'), JSON.stringify({ id:'entry', meta:{ kind:'direct', metricsTag:'entry-in' } })); - const fakeXray = path.join(temp, 'xray-bin'); + fs.writeFileSync(path.join(dir, 'resources', 'entry.json'), JSON.stringify({ id:'entry', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'entry-in' } })); + const helper = path.join(temp, 'stats'); const argumentsFile = path.join(temp, 'args'); - fs.writeFileSync(fakeXray, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(argumentsFile)}\nprintf '{"stat":[{"name":"inbound>>>entry-in>>>traffic>>>uplink","value":"12"},{"name":"inbound>>>entry-in>>>traffic>>>downlink","value":"34"}]}'\n`, { mode:0o755 }); + fs.writeFileSync(helper, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(argumentsFile)}\nprintf '{"stat":[{"name":"user>>>ng:cus-a>>>traffic>>>uplink","value":"7"},{"name":"inbound>>>entry-in>>>traffic>>>uplink","value":"12"},{"name":"inbound>>>entry-in>>>traffic>>>downlink","value":"34"}]}'\n`, { mode:0o755 }); const { spawnSync } = require('node:child_process'); const script = `process.stdout.write(JSON.stringify(require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).queryUsage()))`; - const run = spawnSync(process.execPath, ['-e', script], { env:{ ...process.env, NG_CONFIG_DIR:dir, NG_XRAY_BIN:fakeXray }, encoding:'utf8' }); + const run = spawnSync(process.execPath, ['-e', script], { env:{ ...process.env, NG_CONFIG_DIR:dir, NG_SINGBOX_STATS_BIN:helper }, encoding:'utf8' }); assert.equal(run.status, 0, run.stderr); - assert.deepEqual(JSON.parse(run.stdout).samples, [{ resourceId:'entry', uplink:12, downlink:34, epoch:'xray:unknown' }]); + assert.deepEqual(JSON.parse(run.stdout).samples, [ + { kind:'inbound', id:'nod_1', uplink:12, downlink:34, epoch:'sing-box:unknown' }, + { kind:'user', id:'cus-a', uplink:7, downlink:0, epoch:'sing-box:unknown' }, + ]); assert.doesNotMatch(fs.readFileSync(argumentsFile, 'utf8'), /reset/); } finally { fs.rmSync(temp, { recursive:true, force:true }); } }); -test('adding an Xray resource submits old ingress counters before restarting the engine', async () => { +test('adding a sing-box resource submits old ingress counters before restarting the engine', async () => { const http = require('node:http'); const { spawn } = require('node:child_process'); const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-flush-')); - const dir = path.join(temp, 'xray'); + const dir = path.join(temp, 'agent'); const bin = path.join(temp, 'bin'); fs.mkdirSync(path.join(dir, 'resources'), { recursive:true }); fs.mkdirSync(bin); - const old = { id:'old-resource', meta:{ kind:'direct', metricsTag:'old-in' }, inbounds:[], outbounds:[], routingRules:[] }; + const old = { id:'old-resource', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'old-in' }, inbounds:[], outbounds:[], routingRules:[] }; fs.writeFileSync(path.join(dir, 'resources', 'old-resource.json'), JSON.stringify(old)); - fs.writeFileSync(path.join(bin, 'xray'), '#!/bin/sh\nif [ "$1" = api ]; then printf \'{"stat":[{"name":"inbound>>>old-in>>>traffic>>>uplink","value":"123"},{"name":"inbound>>>old-in>>>traffic>>>downlink","value":"456"}]}\'; fi\n', { mode:0o755 }); + fs.writeFileSync(path.join(bin, 'stats'), '#!/bin/sh\nprintf \'{"stat":[{"name":"inbound>>>old-in>>>traffic>>>uplink","value":"123"},{"name":"inbound>>>old-in>>>traffic>>>downlink","value":"456"}]}\'\n', { mode:0o755 }); + fs.writeFileSync(path.join(bin, 'singbox'), '#!/bin/sh\nexit 0\n', { mode:0o755 }); const manager = fs.existsSync('/run/systemd/system') ? 'systemctl' : 'rc-service'; fs.writeFileSync(path.join(bin, manager), `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(path.join(temp, 'service-commands'))}\nexit 0\n`, { mode:0o755 }); const seen = []; @@ -216,9 +165,11 @@ test('adding an Xray resource submits old ingress counters before restarting the }); try { await new Promise((resolve) => controller.listen(0, '127.0.0.1', resolve)); - const script = `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).applyResource({resource:${JSON.stringify({ id:'new-resource', meta:{ kind:'direct', metricsTag:'new-in' }, inbounds:[], outbounds:[], routingRules:[] })}}).catch(error=>{console.error(error);process.exitCode=1})`; + const script = `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).applyResource({resource:${JSON.stringify({ id:'new-resource', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'new-in' }, inbounds:[], outbounds:[], routingRules:[] })}}).catch(error=>{console.error(error);process.exitCode=1})`; const child = spawn(process.execPath, ['-e', script], { env:{ ...process.env, - NG_CONFIG_DIR:dir, NG_XRAY_BIN:path.join(bin, 'xray'), NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`, + NG_CONFIG_DIR:dir, NG_SINGBOX_BIN:path.join(bin, 'singbox'), NG_SINGBOX_STATS_BIN:path.join(bin, 'stats'), + NG_SINGBOX_CONFIG:path.join(temp, 'sing-box.json'), + NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`, NG_AGENT_KEY:'test-agent-key', PATH:`${bin}:${process.env.PATH}` }, stdio:['ignore','pipe','pipe'] }); let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; }); @@ -226,19 +177,23 @@ test('adding an Xray resource submits old ingress counters before restarting the assert.equal(exit, 0, stderr); assert.equal(seen.length, 1); assert.equal(seen[0].path, '/api/agent/usage'); - assert.deepEqual(seen[0].body.samples.map(({ resourceId, uplink, downlink }) => ({ resourceId, uplink, downlink })), - [{ resourceId:'old-resource', uplink:123, downlink:456 }]); + assert.deepEqual(seen[0].body.samples.map(({ kind, id, uplink, downlink }) => ({ kind, id, uplink, downlink })), + [{ kind:'inbound', id:'nod_1', uplink:123, downlink:456 }]); assert.equal(seen[0].newResourceAlreadyWritten, false); assert.match(fs.readFileSync(path.join(temp, 'service-commands'), 'utf8'), /restart/); const flush = spawn(process.execPath, [path.join(__dirname, '../agent/agent.js'), 'flush-usage'], { env:{ ...process.env, - NG_CONFIG_DIR:dir, NG_XRAY_BIN:path.join(bin, 'xray'), NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`, + NG_CONFIG_DIR:dir, NG_SINGBOX_BIN:path.join(bin, 'singbox'), NG_SINGBOX_STATS_BIN:path.join(bin, 'stats'), + NG_SINGBOX_CONFIG:path.join(temp, 'sing-box.json'), + NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`, NG_AGENT_KEY:'test-agent-key', PATH:`${bin}:${process.env.PATH}` }, stdio:'ignore' }); assert.equal(await new Promise((resolve) => flush.on('exit', resolve)), 0); assert.equal(seen[1].path, '/api/agent/usage'); - fs.writeFileSync(path.join(bin, 'xray'), '#!/bin/sh\nif [ "$1" = api ]; then printf \'{"stat":[{"name":"inbound>>>old-in>>>traffic>>>uplink","value":"invalid"}]}\'; fi\n', { mode:0o755 }); - const badScript = `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).applyResource({resource:${JSON.stringify({ id:'third-resource', meta:{ kind:'direct', metricsTag:'third-in' }, inbounds:[], outbounds:[], routingRules:[] })}}).catch(error=>{console.error(error);process.exitCode=1})`; + fs.writeFileSync(path.join(bin, 'stats'), '#!/bin/sh\nprintf \'{"stat":[{"name":"inbound>>>old-in>>>traffic>>>uplink","value":"invalid"}]}\'\n', { mode:0o755 }); + const badScript = `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).applyResource({resource:${JSON.stringify({ id:'third-resource', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'third-in' }, inbounds:[], outbounds:[], routingRules:[] })}}).catch(error=>{console.error(error);process.exitCode=1})`; const broken = spawn(process.execPath, ['-e', badScript], { env:{ ...process.env, - NG_CONFIG_DIR:dir, NG_XRAY_BIN:path.join(bin, 'xray'), NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`, + NG_CONFIG_DIR:dir, NG_SINGBOX_BIN:path.join(bin, 'singbox'), NG_SINGBOX_STATS_BIN:path.join(bin, 'stats'), + NG_SINGBOX_CONFIG:path.join(temp, 'sing-box.json'), + NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`, NG_AGENT_KEY:'test-agent-key', PATH:`${bin}:${process.env.PATH}` }, stdio:'ignore' }); assert.equal(await new Promise((resolve) => broken.on('exit', resolve)), 1); assert.equal(fs.existsSync(path.join(dir, 'resources', 'third-resource.json')), false); @@ -248,42 +203,46 @@ test('adding an Xray resource submits old ingress counters before restarting the } }); -test('sing-box config isolates AnyTLS from Xray and meters each entry once', () => { - const { combinedConfig, combinedSingBoxConfig } = require('../agent/agent'); - const entry = { id:'entry', engine:'sing-box', meta:{ kind:'direct', customerId:'customer-a', metricsTag:'any-in' }, +test('sing-box config carries AnyTLS entries and meters each entry once', () => { + const { combinedSingBoxConfig } = require('../agent/agent'); + const entry = { id:'entry', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'any-in' }, inbounds:[{ type:'anytls', tag:'any-in', listen:'0.0.0.0', listen_port:23000, users:[{ name:'ng:customer-a', password:'secret' }], tls:{ enabled:true, server_name:'entry.example.com', certificate_path:'/etc/nexusgate/tls/entry.example.com/fullchain.pem', key_path:'/etc/nexusgate/tls/entry.example.com/privkey.pem' } }], outbounds:[{ type:'direct', tag:'any-direct' }], routingRules:[{ inbound:['any-in'], action:'route', outbound:'any-direct' }] }; - const xray = combinedConfig([entry]); - assert.equal(xray.inbounds.length, 1); const sing = combinedSingBoxConfig([entry]); assert.equal(sing.inbounds[0].tag, 'any-in'); assert.deepEqual(sing.experimental.v2ray_api.stats.inbounds, ['any-in']); + // 按用户统计需要显式列出用户名 + assert.deepEqual(sing.experimental.v2ray_api.stats.users, ['ng:customer-a']); assert.equal(sing.route.rules[0].outbound, 'any-direct'); assert.deepEqual(parseUsageStats(JSON.stringify({ stat:[ { name:'inbound>>>any-in>>>traffic>>>uplink', value:'10' }, - { name:'inbound>>>any-in>>>traffic>>>downlink', value:'90' } ] }), [entry]), - [{ resourceId:'entry', uplink:10, downlink:90 }]); + { name:'inbound>>>any-in>>>traffic>>>downlink', value:'90' }, + { name:'user>>>ng:customer-a>>>traffic>>>uplink', value:'5' } ] })), + { inbounds:[{ tag:'any-in', uplink:10, downlink:90 }], users:[{ name:'ng:customer-a', uplink:5, downlink:0 }] }); }); test('Agent queries sing-box counters with its own helper and never resets them', () => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-singbox-usage-')); try { - const dir = path.join(temp, 'xray'); + const dir = path.join(temp, 'agent'); fs.mkdirSync(path.join(dir, 'resources'), { recursive:true }); fs.writeFileSync(path.join(dir, 'resources', 'anytls.json'), JSON.stringify({ - id:'anytls', engine:'sing-box', meta:{ kind:'direct', metricsTag:'any-in' } + id:'anytls', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'any-in' } })); const helper = path.join(temp, 'stats'); const argumentsFile = path.join(temp, 'args'); - fs.writeFileSync(helper, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(argumentsFile)}\nprintf '{"stat":[{"name":"inbound>>>any-in>>>traffic>>>uplink","value":"1024"},{"name":"inbound>>>any-in>>>traffic>>>downlink","value":"4096"}]}'\n`, { mode:0o755 }); + fs.writeFileSync(helper, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(argumentsFile)}\nprintf '{"stat":[{"name":"inbound>>>any-in>>>traffic>>>uplink","value":"1024"},{"name":"inbound>>>any-in>>>traffic>>>downlink","value":"4096"},{"name":"user>>>ng:cus-a>>>traffic>>>uplink","value":"7"}]}'\n`, { mode:0o755 }); const script = `process.stdout.write(JSON.stringify(require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).queryUsage()))`; const run = require('node:child_process').spawnSync(process.execPath, ['-e', script], { - env:{ ...process.env, NG_CONFIG_DIR:dir, NG_SINGBOX_STATS_BIN:helper, NG_XRAY_BIN:'/nonexistent/xray' }, encoding:'utf8' + env:{ ...process.env, NG_CONFIG_DIR:dir, NG_SINGBOX_STATS_BIN:helper }, encoding:'utf8' }); assert.equal(run.status, 0, run.stderr); - assert.deepEqual(JSON.parse(run.stdout).samples, [{ resourceId:'anytls', uplink:1024, downlink:4096, epoch:'sing-box:unknown' }]); + assert.deepEqual(JSON.parse(run.stdout).samples, [ + { kind:'inbound', id:'nod_1', uplink:1024, downlink:4096, epoch:'sing-box:unknown' }, + { kind:'user', id:'cus-a', uplink:7, downlink:0, epoch:'sing-box:unknown' }, + ]); assert.deepEqual(fs.readFileSync(argumentsFile, 'utf8').trim().split('\n'), ['--server=127.0.0.1:10086']); } finally { fs.rmSync(temp, { recursive:true, force:true }); } }); @@ -294,7 +253,7 @@ test('AnyTLS IP observations require matching authenticated session and preserve const dir = path.join(temp, 'config'); fs.mkdirSync(path.join(dir, 'resources'), { recursive:true }); fs.writeFileSync(path.join(dir, 'resources', 'entry.json'), JSON.stringify({ id:'entry', engine:'sing-box', - meta:{ kind:'relay', metricsTag:'any-in', customerId:'customer-a' } })); + meta:{ kind:'node', nodeId:'nod_1', metricsTag:'any-in' } })); const file = path.join(temp, 'sing-box.log'); const source = '+0000 2026-09-23 18:00:00 INFO [31200 0ms] inbound/anytls[any-in]: inbound connection from 198.51.100.12:50000'; const authenticated = '+0000 2026-09-23 18:00:01 INFO [31200 1ms] inbound/anytls[any-in]: [ng:customer-a] inbound connection to example.com:443'; diff --git a/test/api.test.js b/test/api.test.js index 53d5c5a..15f8e97 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -24,7 +24,7 @@ async function waitFor(url) { throw new Error('test server did not start'); } -test('admin can create resources and queue a mixed-protocol chain', async (t) => { +test('admin can create nodes and manage shared-client lifecycle', async (t) => { const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nexusgate-api-')); const port = await freePort(); const child = spawn(process.execPath, ['server.js'], { @@ -56,98 +56,136 @@ test('admin can create resources and queue a mixed-protocol chain', async (t) => assert.equal(editedRelay.name, '新加坡入口 01'); const editedCustomer = (await request(`/api/customers/${customer.id}`, 'PATCH', { expiresAt: '2030-12-31T23:59:00+08:00', tags: ['VIP'] })).customer; assert.equal(editedCustomer.expiresAt, '2030-12-31T15:59:00.000Z'); - const chain = (await request('/api/chains', 'POST', { name: 'SG → JP', relayServerIds: [relay.id], exitServerId: exit.id, customerIds: [customer.id], relayProtocol: 'vless-reality-vision', exitProtocol: 'shadowsocks-2022-aes128' })).chain; - const premature = await fetch(`${base}/api/chains/${chain.id}/deploy`, { method:'POST', headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:'{}' }); + + // Shared node: one entry inbound serves many customers; deploy needs a client first. + const node = (await request('/api/nodes', 'POST', { name: 'SG → JP', serverId: relay.id, protocol: 'vless-reality-vision', + topology: 'forward', exitServerId: exit.id, exitProtocol: 'shadowsocks-2022-aes128' })).node; + const prematureDeploy = await fetch(`${base}/api/nodes/${node.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' }); + assert.equal(prematureDeploy.status, 400); + assert.match((await prematureDeploy.json()).message, /至少一个客户/); + await request(`/api/nodes/${node.id}/assign`, 'POST', { customerId: customer.id }); + const premature = await fetch(`${base}/api/nodes/${node.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' }); assert.equal(premature.status, 409); + const agentKeys = {}; for (const server of [relay, exit]) { const ticket = await request(`/api/servers/${server.id}/enrollment-token`, 'POST', {}); - const response = await fetch(`${base}/api/agent/enroll`, { method:'POST', headers:{ 'content-type':'application/json' }, body:JSON.stringify({ token:ticket.token, hostname:'test-agent' }) }); + const response = await fetch(`${base}/api/agent/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: ticket.token, hostname: 'test-agent' }) }); assert.equal(response.status, 201); agentKeys[server.id] = (await response.json()).agentKey; } - const certSync = await fetch(`${base}/api/agent/tls-domain`, { method:'POST', headers:{ authorization:`Bearer ${agentKeys[relay.id]}`, 'content-type':'application/json' }, - body:JSON.stringify({ domain:'entry.example.com' }) }); + const certSync = await fetch(`${base}/api/agent/tls-domain`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' }, + body: JSON.stringify({ domain: 'entry.example.com' }) }); assert.equal(certSync.status, 200); assert.equal((await request('/api/servers')).servers.find((item) => item.id === relay.id).tlsDomain, 'entry.example.com'); const idleDatabase = await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8'); for (let count = 0; count < 10; count += 1) { for (const server of [relay, exit]) { - const headers = { authorization:`Bearer ${agentKeys[server.id]}`, 'content-type':'application/json' }; - const poll = await fetch(`${base}/api/agent/poll`, { method:'POST', headers, body:'{}' }); - assert.deepEqual(await poll.json(), { job:null }); - const heartbeat = await fetch(`${base}/api/agent/heartbeat`, { method:'POST', headers, - body:JSON.stringify({ version:'0.6.8', system:{ memoryAvailable:512 * 1024 * 1024 } }) }); + const headers = { authorization: `Bearer ${agentKeys[server.id]}`, 'content-type': 'application/json' }; + const poll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers, body: '{}' }); + assert.deepEqual(await poll.json(), { job: null }); + const heartbeat = await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers, + body: JSON.stringify({ version: '0.6.8', system: { memoryAvailable: 512 * 1024 * 1024 } }) }); assert.equal(heartbeat.status, 200); } } assert.equal(await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8'), idleDatabase); - const badCertSync = await fetch(`${base}/api/agent/tls-domain`, { method:'POST', headers:{ authorization:`Bearer ${agentKeys[relay.id]}`, 'content-type':'application/json' }, - body:JSON.stringify({ domain:'invalid/domain' }) }); + const badCertSync = await fetch(`${base}/api/agent/tls-domain`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' }, + body: JSON.stringify({ domain: 'invalid/domain' }) }); assert.equal(badCertSync.status, 400); - await fetch(`${base}/api/agent/heartbeat`, { method:'POST', headers:{ authorization:`Bearer ${agentKeys[relay.id]}`, 'content-type':'application/json' }, - body:JSON.stringify({ version:'0.4.0', engine:{ status:'error', detail:'Xray config validation failed' } }) }); + + await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' }, + body: JSON.stringify({ version: '0.4.0', engine: { status: 'error', detail: 'Xray config validation failed' } }) }); // A broken core can be repaired by applying a new resource; keep the Agent job queue available. - const engineBlocked = await fetch(`${base}/api/chains/${chain.id}/deploy`, { method:'POST', headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:'{}' }); + const engineBlocked = await fetch(`${base}/api/nodes/${node.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' }); assert.equal(engineBlocked.status, 202); - const deployed = await engineBlocked.json(); - await fetch(`${base}/api/agent/heartbeat`, { method:'POST', headers:{ authorization:`Bearer ${agentKeys[relay.id]}`, 'content-type':'application/json' }, - body:JSON.stringify({ version:'0.4.0', engine:{ status:'ready', detail:'active' } }) }); - assert.equal(deployed.deployments.length, 2); + await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' }, + body: JSON.stringify({ version: '0.4.0', engine: { status: 'ready', detail: 'active' } }) }); const jobs = await request('/api/jobs'); assert.equal(jobs.jobs.length, 2); assert.ok(jobs.jobs.every((job) => job.status === 'queued')); - const exitPoll = await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKeys[exit.id]}` } }); + + const exitPoll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[exit.id]}` } }); const exitJob = (await exitPoll.json()).job; - const failed = await fetch(`${base}/api/agent/jobs/${exitJob.id}/complete`, { method:'POST', - headers:{ authorization:`Bearer ${agentKeys[exit.id]}`, 'content-type':'application/json' }, - body:JSON.stringify({ success:false, error:'PrivateKey: example_secret Password (PublicKey): example_public' }) }); + const failed = await fetch(`${base}/api/agent/jobs/${exitJob.id}/complete`, { method: 'POST', + headers: { authorization: `Bearer ${agentKeys[exit.id]}`, 'content-type': 'application/json' }, + body: JSON.stringify({ success: false, error: 'PrivateKey: example_secret Password (PublicKey): example_public' }) }); assert.equal(failed.status, 200); assert.doesNotMatch(JSON.stringify(await request('/api/jobs')), /example_secret/); - assert.doesNotMatch(JSON.stringify(await request('/api/chains')), /example_secret/); - const changed = await request(`/api/chains/${chain.id}`, 'PATCH', { realityServerName: 'www.tesla.com' }); - assert.equal(changed.requiresRedeploy, true); - assert.equal(changed.chain.status, 'changes_pending'); + assert.doesNotMatch(JSON.stringify(await request('/api/nodes')), /example_secret/); + + // Editing a live node rebuilds immediately (3x-ui style); protocol changes reissue client credentials. + const changed = await request(`/api/nodes/${node.id}`, 'PATCH', { realityServerName: 'www.tesla.com' }); + assert.equal(changed.node.status, 'deploying'); + const customerB = (await request('/api/customers', 'POST', { name: '客户 B', ipLimit: 1, deviceLimit: 1 })).customer; - const direct = (await request('/api/chains', 'POST', { name: 'IPv6 单机节点', topology: 'direct', networkMode: 'ipv6', relayServerIds: [relay.id], customerIds: [customerB.id], relayProtocol: 'shadowsocks-2022-aes256' })).chain; - const directDeploy = await request(`/api/chains/${direct.id}/deploy`, 'POST', {}); - assert.equal(directDeploy.deployments.length, 1); - assert.equal(directDeploy.deployments[0].role, 'direct'); + const direct = (await request('/api/nodes', 'POST', { name: 'IPv6 单机节点', serverId: relay.id, protocol: 'shadowsocks-2022-aes256', + networkMode: 'ipv6', topology: 'direct' })).node; + await request(`/api/nodes/${direct.id}/assign`, 'POST', { customerId: customerB.id }); + await request(`/api/nodes/${direct.id}/deploy`, 'POST', {}); const noActive = await fetch(`${base}/s/${customerB.subscriptionToken}/base64`); assert.equal(noActive.status, 503); const enrollment = await request(`/api/servers/${relay.id}/enrollment-token`, 'POST', {}); - const enrolled = await fetch(`${base}/api/agent/enroll`, { method:'POST', headers:{ 'content-type':'application/json' }, body:JSON.stringify({ token:enrollment.token, hostname:'test-agent' }) }); + const enrolled = await fetch(`${base}/api/agent/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: enrollment.token, hostname: 'test-agent' }) }); const { agentKey } = await enrolled.json(); assert.equal(enrolled.status, 201); - for (let count = 0; count < 4; count += 1) { - const polled = await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } }); + agentKeys[relay.id] = agentKey; // re-enrollment revokes the previous agent key + const completeNext = async (key) => { + const polled = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${key}` } }); const { job } = await polled.json(); - if (!job) break; - await fetch(`${base}/api/agent/jobs/${job.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true, result:{ artifacts:{ realityPublicKey:'test-public-key' } } }) }); + assert.ok(job); + const completed = await fetch(`${base}/api/agent/jobs/${job.id}/complete`, { method: 'POST', + headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, + body: JSON.stringify({ success: true, result: { artifacts: { realityPublicKey: 'test-public-key' } } }) }); + assert.equal(completed.status, 200); + return job; + }; + // Drain every queued job on both agents (entry + exit + rebuild from the PATCH above). + for (let i = 0; i < 8; i += 1) { + let drained = true; + for (const key of Object.values(agentKeys)) { + const polled = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${key}` } }); + const { job } = await polled.json(); + if (job) { + drained = false; + await fetch(`${base}/api/agent/jobs/${job.id}/complete`, { method: 'POST', + headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, + body: JSON.stringify({ success: true, result: { artifacts: { realityPublicKey: 'test-public-key' } } }) }); + } + } + if (drained) break; } - const usageReport = async (uplink, downlink, epoch = 'xray-start-1') => { - const response = await fetch(`${base}/api/agent/usage`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, - body: JSON.stringify({ epoch, samples:[{ resourceId:directDeploy.deployments[0].resourceId, uplink, downlink }] }) }); + const activeNodes = (await request('/api/nodes')).nodes; + assert.equal(activeNodes.find((item) => item.id === node.id).status, 'active'); + assert.equal(activeNodes.find((item) => item.id === direct.id).status, 'active'); + + const usageReport = async (uplink, downlink, epoch = 'sing-box-start-1') => { + const response = await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, + body: JSON.stringify({ epoch, samples: [{ kind: 'user', id: customerB.id, uplink, downlink }] }) }); assert.equal(response.status, 200); }; await usageReport(150, 350); await usageReport(150, 350); // Retry after a lost HTTP response must not double count. await usageReport(250, 900); await usageReport(150, 350); // A late report from the same process must not count a false reset. - await usageReport(10, 20, 'xray-start-2'); // Xray restarted, and its counters began from zero. - await usageReport(250, 900, 'xray-start-1'); // The old request can arrive after the new process reported. + await usageReport(10, 20, 'sing-box-start-2'); // sing-box restarted, and its counters began from zero. + await usageReport(250, 900, 'sing-box-start-1'); // The old request can arrive after the new process reported. const metered = (await request('/api/customers')).customers.find((item) => item.id === customerB.id); assert.equal(metered.usedBytes, 1180); assert.equal(metered.usedUplinkBytes, 260); assert.equal(metered.usedDownlinkBytes, 920); - const meteredDeployment = (await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id); - assert.deepEqual(meteredDeployment.meteredTraffic, { uplink:260, downlink:920 }); + // Inbound-level traffic lands on the node, not the customer. + await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, + body: JSON.stringify({ epoch: 'sing-box-start-2', samples: [{ kind: 'inbound', id: direct.id, uplink: 260, downlink: 920 }] }) }); + const meteredNode = (await request('/api/nodes')).nodes.find((item) => item.id === direct.id); + assert.deepEqual(meteredNode.meteredTraffic, { uplink: 260, downlink: 920 }); + const raw = await fetch(`${base}/s/${customerB.subscriptionToken}/raw`); assert.equal(raw.status, 200); assert.match(raw.headers.get('subscription-userinfo'), /upload=260; download=920;/); - await usageReport(1000, 2000, 'xray-start-3'); // A fast restart can exceed previous counters before polling. - await usageReport(1000, 2000, 'xray-start-3'); + await usageReport(1000, 2000, 'sing-box-start-3'); // A fast restart can exceed previous counters before polling. + await usageReport(1000, 2000, 'sing-box-start-3'); assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).usedBytes, 4180); assert.match(await raw.text(), /^ss:\/\//); assert.equal(raw.headers.get('cache-control'), 'no-store, private'); @@ -181,170 +219,170 @@ test('admin can create resources and queue a mixed-protocol chain', async (t) => assert.equal((await request(`/api/customers/${customerB.id}/access`)).events.length, 0); assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`, { headers: { 'x-device-id': 'another-client' } })).status, 200); - const originalUri = (await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id).clientUri; - await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:2000 }); + const nodeClients = (await request('/api/nodes')).nodeClients; + const originalUri = nodeClients.find((item) => item.nodeId === direct.id && item.customerId === customerB.id).clientUri; + await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 2000 }); assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`)).status, 403); - const prematureResume = await fetch(`${base}/api/customers/${customerB.id}`, { method:'PATCH', - headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:JSON.stringify({ status:'active' }) }); + const prematureResume = await fetch(`${base}/api/customers/${customerB.id}`, { method: 'PATCH', + headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: JSON.stringify({ status: 'active' }) }); assert.equal(prematureResume.status, 409); - const deletePoll = await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } }); + const deletePoll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } }); const deleteJob = (await deletePoll.json()).job; assert.equal(deleteJob.action, 'delete_resource'); - await fetch(`${base}/api/agent/jobs/${deleteJob.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true }) }); - assert.equal((await request('/api/chains')).chains.find((item) => item.id === direct.id).status, 'suspended'); - await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:10000 }); + await fetch(`${base}/api/agent/jobs/${deleteJob.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) }); + assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft'); + assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).status, 'suspended'); + await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 }); const restoringSubscription = await fetch(`${base}/s/${rotated.subscriptionToken}/raw`); assert.equal(restoringSubscription.status, 503); assert.equal(restoringSubscription.headers.get('retry-after'), '15'); assert.match((await restoringSubscription.json()).message, /正在恢复/); - const applyPoll = await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } }); + const applyPoll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } }); const applyJob = (await applyPoll.json()).job; assert.equal(applyJob.action, 'apply_resource'); - await fetch(`${base}/api/agent/jobs/${applyJob.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true }) }); - assert.equal((await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id).clientUri, originalUri); - assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`, { headers:{ 'x-device-id':'another-client' } })).status, 200); + await fetch(`${base}/api/agent/jobs/${applyJob.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) }); + const revivedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === direct.id && item.customerId === customerB.id).clientUri; + assert.equal(revivedUri, originalUri); + assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`, { headers: { 'x-device-id': 'another-client' } })).status, 200); // Raising the limit while a delete is already running still applies only after cleanup. - await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:3000 }); - const inFlight = (await (await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } })).json()).job; + await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 3000 }); + const inFlight = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job; assert.equal(inFlight.action, 'delete_resource'); - await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:10000 }); - await fetch(`${base}/api/agent/jobs/${inFlight.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true }) }); - const afterDelete = (await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id); - assert.equal(afterDelete.status, 'queued'); - const reapply = (await (await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } })).json()).job; + await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 }); + await fetch(`${base}/api/agent/jobs/${inFlight.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) }); + const afterDelete = (await request('/api/nodes')).nodes.find((item) => item.id === direct.id); + assert.equal(afterDelete.status, 'deploying'); // resume queued the apply behind the running delete + const reapply = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job; assert.equal(reapply.action, 'apply_resource'); - await fetch(`${base}/api/agent/jobs/${reapply.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true }) }); + await fetch(`${base}/api/agent/jobs/${reapply.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) }); // A queued deletion can be cancelled before it touches the existing node. - await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:3000 }); - await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:10000 }); - assert.equal((await request('/api/chains')).chains.find((item) => item.id === direct.id).status, 'active'); - assert.equal((await (await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } })).json()).job, null); + await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 3000 }); + await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 }); + assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'active'); + assert.equal((await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job, null); - // Routes deleted by older versions can recover the original URI with one explicit action. - await request(`/api/chains/${direct.id}/remove`, 'POST', {}); - const oldDelete = (await (await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } })).json()).job; - await fetch(`${base}/api/agent/jobs/${oldDelete.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true }) }); - assert.equal((await request('/api/chains')).chains.find((item) => item.id === direct.id).status, 'draft'); - await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:3000 }); - await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:10000 }); - assert.equal((await request('/api/chains')).chains.find((item) => item.id === direct.id).status, 'draft'); - await request(`/api/chains/${direct.id}/restore`, 'POST', {}); - const oldApply = (await (await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } })).json()).job; - await fetch(`${base}/api/agent/jobs/${oldApply.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true }) }); - assert.equal((await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id).clientUri, originalUri); + // A removed node keeps its client assignments; redeploying restores the same URI. + await request(`/api/nodes/${direct.id}/remove`, 'POST', {}); + const oldDelete = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job; + await fetch(`${base}/api/agent/jobs/${oldDelete.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) }); + assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft'); + await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 3000 }); + await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 }); + assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft'); + await request(`/api/nodes/${direct.id}/deploy`, 'POST', {}); + const oldApply = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job; + await fetch(`${base}/api/agent/jobs/${oldApply.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) }); + const redeployedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === direct.id && item.customerId === customerB.id).clientUri; + assert.equal(redeployedUri, originalUri); - // A forward route restores both the exit and the entry with the same ports and client URI. - const customerC = (await request('/api/customers', 'POST', { name:'配额恢复测试', trafficLimitBytes:100 })).customer; - const forward = (await request('/api/chains', 'POST', { name:'保留节点', relayServerIds:[relay.id], exitServerId:exit.id, - customerIds:[customerC.id], relayProtocol:'vless-reality-vision', exitProtocol:'shadowsocks-2022-aes128' })).chain; - const forwardDeploy = (await request(`/api/chains/${forward.id}/deploy`, 'POST', {})).deployments; - const completeNext = async (key) => { - const polled = await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${key}` } }); - const { job } = await polled.json(); - assert.ok(job); - const completed = await fetch(`${base}/api/agent/jobs/${job.id}/complete`, { method:'POST', - headers:{ authorization:`Bearer ${key}`, 'content-type':'application/json' }, - body:JSON.stringify({ success:true, result:{ artifacts:{ realityPublicKey:'test-public-key' } } }) }); - assert.equal(completed.status, 200); - return job; - }; + // A forward node suspends both exit and entry, and restores them with the same URI. + const customerC = (await request('/api/customers', 'POST', { name: '配额恢复测试', trafficLimitBytes: 100 })).customer; + const forward = (await request('/api/nodes', 'POST', { name: '保留节点', serverId: relay.id, protocol: 'vless-reality-vision', + topology: 'forward', exitServerId: exit.id, exitProtocol: 'shadowsocks-2022-aes128' })).node; + await request(`/api/nodes/${forward.id}/assign`, 'POST', { customerId: customerC.id }); + await request(`/api/nodes/${forward.id}/deploy`, 'POST', {}); await completeNext(agentKeys[exit.id]); await completeNext(agentKey); - const forwardEntry = forwardDeploy.find((item) => item.role === 'relay'); - const forwardUri = (await request('/api/chains')).deployments.find((item) => item.id === forwardEntry.id).clientUri; - const forwardUsage = await fetch(`${base}/api/agent/usage`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, - body:JSON.stringify({ epoch:'fwd-1', samples:[{ resourceId:forwardEntry.resourceId, uplink:2, downlink:5 }] }) }); + const forwardUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === forward.id && item.customerId === customerC.id).clientUri; + const forwardUsage = await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, + body: JSON.stringify({ epoch: 'fwd-1', samples: [{ kind: 'user', id: customerC.id, uplink: 2, downlink: 5 }] }) }); assert.equal(forwardUsage.status, 200); - await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes:1 }); + await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes: 1 }); assert.equal((await completeNext(agentKeys[exit.id])).action, 'delete_resource'); assert.equal((await completeNext(agentKey)).action, 'delete_resource'); - assert.equal((await request('/api/chains')).chains.find((item) => item.id === forward.id).status, 'suspended'); - await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes:100 }); + assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === forward.id).status, 'draft'); + await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes: 100 }); assert.equal((await completeNext(agentKey)).action, 'apply_resource'); assert.equal((await fetch(`${base}/s/${customerC.subscriptionToken}/raw`)).status, 503); assert.equal((await completeNext(agentKeys[exit.id])).action, 'apply_resource'); - assert.equal((await request('/api/chains')).deployments.find((item) => item.id === forwardEntry.id).clientUri, forwardUri); + const forwardRevivedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === forward.id && item.customerId === customerC.id).clientUri; + assert.equal(forwardRevivedUri, forwardUri); assert.equal((await fetch(`${base}/s/${customerC.subscriptionToken}/raw`)).status, 200); - // AnyTLS is a separate entry engine, but follows the same quota and restore lifecycle. - await request(`/api/servers/${relay.id}`, 'PATCH', { tlsDomain:'entry.example.com' }); - const anyCustomer = (await request('/api/customers', 'POST', { name:'AnyTLS 客户', trafficLimitBytes:500 })).customer; - const anyChain = (await request('/api/chains', 'POST', { name:'AnyTLS → VLESS', relayServerIds:[relay.id], exitServerId:exit.id, - customerIds:[anyCustomer.id], relayProtocol:'anytls', exitProtocol:'vless-tcp' })).chain; - const oldAgent = await fetch(`${base}/api/chains/${anyChain.id}/deploy`, { method:'POST', - headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:'{}' }); + // AnyTLS is a separate entry engine, but follows the same quota lifecycle. + await request(`/api/servers/${relay.id}`, 'PATCH', { tlsDomain: 'entry.example.com' }); + const anyCustomer = (await request('/api/customers', 'POST', { name: 'AnyTLS 客户', trafficLimitBytes: 500 })).customer; + const anyNode = (await request('/api/nodes', 'POST', { name: 'AnyTLS → VLESS', serverId: relay.id, protocol: 'anytls', + topology: 'forward', exitServerId: exit.id, exitProtocol: 'vless-tcp' })).node; + await request(`/api/nodes/${anyNode.id}/assign`, 'POST', { customerId: anyCustomer.id }); + const oldAgent = await fetch(`${base}/api/nodes/${anyNode.id}/deploy`, { method: 'POST', + headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' }); assert.equal(oldAgent.status, 409); assert.match((await oldAgent.json()).message, /入口证书未由 Agent 确认/); - await fetch(`${base}/api/agent/heartbeat`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, - body:JSON.stringify({ version:'0.6.5', engine:{ status:'ready', singBoxInstalled:false, certificates:['entry.example.com'] } }) }); - const missingEngine = await fetch(`${base}/api/chains/${anyChain.id}/deploy`, { method:'POST', - headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:'{}' }); + await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, + body: JSON.stringify({ version: '0.6.5', engine: { status: 'ready', singBoxInstalled: false, certificates: ['entry.example.com'] } }) }); + const missingEngine = await fetch(`${base}/api/nodes/${anyNode.id}/deploy`, { method: 'POST', + headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' }); assert.equal(missingEngine.status, 409); assert.match((await missingEngine.json()).message, /ng-agent engine install/); - await fetch(`${base}/api/agent/heartbeat`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, - body:JSON.stringify({ version:'0.6.5', engine:{ status:'ready', singBoxInstalled:true, certificates:['entry.example.com'] } }) }); - const anyDeploy = (await request(`/api/chains/${anyChain.id}/deploy`, 'POST', {})).deployments; + await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, + body: JSON.stringify({ version: '0.6.5', engine: { status: 'ready', singBoxInstalled: true, certificates: ['entry.example.com'] } }) }); + await request(`/api/nodes/${anyNode.id}/deploy`, 'POST', {}); const anyExitJob = await completeNext(agentKeys[exit.id]); - assert.equal(anyExitJob.payload.resource.inbounds[0].protocol, 'vless'); + assert.equal(anyExitJob.payload.resource.inbounds[0].type, 'vless'); const anyEntryJob = await completeNext(agentKey); assert.equal(anyEntryJob.payload.resource.engine, 'sing-box'); assert.equal(anyEntryJob.payload.resource.outbounds[0].type, 'vless'); - const anyEntry = anyDeploy.find((item) => item.role === 'relay'); - const anyUri = (await request('/api/chains')).deployments.find((item) => item.id === anyEntry.id).clientUri; + const anyUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === anyNode.id && item.customerId === anyCustomer.id).clientUri; assert.match(anyUri, /^anytls:\/\//); assert.equal(decodeURIComponent(anyUri.split('#')[1]), 'AnyTLS → VLESS'); assert.match(await (await fetch(`${base}/s/${anyCustomer.subscriptionToken}/clash`)).text(), /type: "anytls"/); - const anyUsage = await fetch(`${base}/api/agent/usage`, { method:'POST', - headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, - body:JSON.stringify({ samples:[{ resourceId:anyEntry.resourceId, uplink:120, downlink:400, epoch:'sing-box:123:456' }] }) }); + const anyUsage = await fetch(`${base}/api/agent/usage`, { method: 'POST', + headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, + body: JSON.stringify({ samples: [{ kind: 'inbound', id: anyNode.id, uplink: 120, downlink: 400, epoch: 'sing-box:123:456' }] }) }); assert.equal(anyUsage.status, 200); - assert.deepEqual((await request('/api/chains')).deployments.find((item) => item.id === anyEntry.id).meteredTraffic, - { uplink:120, downlink:400 }); - await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes:500 }); + assert.deepEqual((await request('/api/nodes')).nodes.find((item) => item.id === anyNode.id).meteredTraffic, + { uplink: 120, downlink: 400 }); + // Customer-level usage for the quota test below. + await fetch(`${base}/api/agent/usage`, { method: 'POST', + headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, + body: JSON.stringify({ epoch: 'sb-user-1', samples: [{ kind: 'user', id: anyCustomer.id, uplink: 300, downlink: 300 }] }) }); + await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes: 500 }); assert.equal((await completeNext(agentKeys[exit.id])).action, 'delete_resource'); assert.equal((await completeNext(agentKey)).action, 'delete_resource'); assert.equal((await fetch(`${base}/s/${anyCustomer.subscriptionToken}/raw`)).status, 403); - await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes:1000 }); - assert.equal((await completeNext(agentKey)).payload.resource.engine, 'sing-box'); + await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes: 1000 }); + const anyReapply = await completeNext(agentKey); + assert.equal(anyReapply.payload.resource.engine, 'sing-box'); assert.equal((await completeNext(agentKeys[exit.id])).action, 'apply_resource'); - assert.equal((await request('/api/chains')).deployments.find((item) => item.id === anyEntry.id).clientUri, anyUri); + const anyRevivedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === anyNode.id && item.customerId === anyCustomer.id).clientUri; + assert.equal(anyRevivedUri, anyUri); // Exit-server source addresses belong to relay machines, never to client IP quota. - await fetch(`${base}/api/agent/observations`, { method:'POST', headers:{ authorization:`Bearer ${agentKeys[exit.id]}`, 'content-type':'application/json' }, - body: JSON.stringify({ observations: [{ customerId:customerB.id, ip:'198.51.100.1' }] }) }); + await fetch(`${base}/api/agent/observations`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[exit.id]}`, 'content-type': 'application/json' }, + body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '198.51.100.1' }] }) }); assert.equal((await request(`/api/customers/${customerB.id}/access`)).observedIps.length, 0); - const observed = await fetch(`${base}/api/agent/observations`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, - body: JSON.stringify({ observations: [{ customerId:customerB.id, ip:'203.0.113.1' }] }) }); + const observed = await fetch(`${base}/api/agent/observations`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, + body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '203.0.113.1' }] }) }); assert.equal(observed.status, 200); assert.equal((await request(`/api/customers/${customerB.id}/access`)).observedIps.length, 1); - const excessIp = await fetch(`${base}/api/agent/observations`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, - body: JSON.stringify({ observations: [{ customerId:customerB.id, ip:'203.0.113.2' }] }) }); + const excessIp = await fetch(`${base}/api/agent/observations`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, + body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '203.0.113.2' }] }) }); assert.equal(excessIp.status, 200); assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).suspendReason, 'ip_limit'); - // A failed / not-yet-applied route can disappear from the UI immediately, - // while cleanup tombstones retain the ports until agents acknowledge removal. - await request(`/api/chains/${chain.id}/remove`, 'POST', {}); - const deleted = await request(`/api/chains/${chain.id}`, 'DELETE'); - assert.equal(deleted.cleanupPending, 2); - const afterChainDelete = await request('/api/chains'); - assert.equal(afterChainDelete.chains.some((item) => item.id === chain.id), false); - assert.equal(afterChainDelete.deployments.some((item) => item.chainId === chain.id), false); + // Deleting a node removes it from the UI at once and hard-deletes after agents confirm. + await request(`/api/nodes/${node.id}/remove`, 'POST', {}); + const deleted = await request(`/api/nodes/${node.id}`, 'DELETE'); + assert.equal(deleted.ok, true); + const afterNodeDelete = await request('/api/nodes'); + assert.equal(afterNodeDelete.nodes.some((item) => item.id === node.id), false); + assert.equal(afterNodeDelete.nodeClients.some((item) => item.nodeId === node.id), false); const source = JSON.parse(await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8')); - assert.equal(source.deployments.filter((item) => item.chainId === chain.id && item.archived && item.status === 'removing').length, 2); + assert.equal(source.nodes.some((item) => item.id === node.id), true); const removedCustomer = await request(`/api/customers/${customer.id}`, 'DELETE'); assert.equal(removedCustomer.ok, true); assert.equal((await fetch(`${base}/s/${customer.subscriptionToken}/raw`)).status, 404); - const blocked = await fetch(`${base}/api/servers/${exit.id}`, { method:'DELETE', headers:{ cookie, 'x-csrf-token':session.csrf } }); + const blocked = await fetch(`${base}/api/servers/${exit.id}`, { method: 'DELETE', headers: { cookie, 'x-csrf-token': session.csrf } }); assert.equal(blocked.status, 409); const retry = await request(`/api/servers/${exit.id}/cleanup`, 'POST', {}); assert.equal(retry.queued, 0); - const unsafeForget = await fetch(`${base}/api/servers/${exit.id}/forget`, { method:'POST', headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:JSON.stringify({ confirm:'FORGET', name:'落地 01' }) }); + const unsafeForget = await fetch(`${base}/api/servers/${exit.id}/forget`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: JSON.stringify({ confirm: 'FORGET', name: '落地 01' }) }); assert.equal(unsafeForget.status, 409); - const renamed = await request('/api/account', 'PATCH', { username:'owner_2', currentPassword:'test-password', newPassword:'new-password-456' }); + const renamed = await request('/api/account', 'PATCH', { username: 'owner_2', currentPassword: 'test-password', newPassword: 'new-password-456' }); assert.equal(renamed.reauthenticate, true); - const relogin = await fetch(`${base}/api/auth/login`, { method:'POST', headers:{ 'content-type':'application/json' }, body:JSON.stringify({ username:'owner_2', password:'new-password-456' }) }); + const relogin = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'owner_2', password: 'new-password-456' }) }); assert.equal(relogin.status, 200); }); diff --git a/test/jobs.test.js b/test/jobs.test.js index 6b6303f..64ab39c 100644 --- a/test/jobs.test.js +++ b/test/jobs.test.js @@ -13,9 +13,9 @@ test('job failures redact a Reality secret and stale completions cannot overwrit try { const store = await new Store(path.join(dir, 'data.json')).init(); await store.transaction((data) => { - data.jobs.push({ id:'job_running', status:'running', deploymentId:'dep_missing', action:'apply_resource', + data.jobs.push({ id:'job_running', status:'running', nodeId:'nod_test', action:'apply_resource', serverId:'srv_test', payload:{}, attempts:1 }); - data.jobs.push({ id:'job_requeued', status:'queued', deploymentId:'dep_missing', action:'apply_resource', + data.jobs.push({ id:'job_requeued', status:'queued', nodeId:'nod_test', action:'apply_resource', serverId:'srv_test', payload:{}, attempts:2 }); }); const orchestrator = new Orchestrator(store); diff --git a/test/protocols.test.js b/test/protocols.test.js index 9cc388e..ce4156b 100644 --- a/test/protocols.test.js +++ b/test/protocols.test.js @@ -3,26 +3,82 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { - newCredentialSet, buildExitResource, buildRelayResource, buildDirectResource, buildClientUri, - validateEntryProtocol, validateProtocolPair + newNodeClientCredentials, ensureNodeSecrets, nodeEmail, + buildNodeResource, buildNodeExitResource, buildClientUri, + validateEntryProtocol, validateProtocolPair, resolveBindAddress, } = require('../lib/protocols'); -const customer = { id: 'cus_test', name: '测试客户' }; -const relay = { id: 'srv_relay', publicAddress: 'relay.example.com' }; -const exit = { id: 'srv_exit', publicAddress: 'exit.example.com' }; +const entryServer = { id: 'srv_entry', publicAddress: 'entry.example.com', tlsDomain: 'node.example.com' }; +const exitServer = { id: 'srv_exit', publicAddress: 'exit.example.com' }; -test('builds a VLESS Reality to Shadowsocks 2022 chain', () => { - const credentials = newCredentialSet('vless-reality-vision', 'shadowsocks-2022-aes128'); - const reality = { keyId: 'test-key', serverName: 'www.microsoft.com', destPort: 443 }; - const exitResource = buildExitResource({ resourceId: 'res_exit_123', tagPrefix: 'exit-test', port: 32001, protocol: 'shadowsocks-2022-aes128', credentials, customer }); - const relayResource = buildRelayResource({ resourceId: 'res_relay_123', tagPrefix: 'relay-test', port: 21001, protocol: 'vless-reality-vision', exitProtocol: 'shadowsocks-2022-aes128', exitServer: exit, exitPort: 32001, credentials, customer, reality }); - assert.equal(exitResource.inbounds[0].protocol, 'shadowsocks'); - assert.equal(relayResource.inbounds[0].protocol, 'vless'); - assert.equal(relayResource.outbounds[0].settings.servers[0].address, 'exit.example.com'); - assert.equal(relayResource.inbounds[0].streamSettings.realitySettings.privateKey, '${REALITY_PRIVATE:test-key}'); - const uri = buildClientUri({ protocol: 'vless-reality-vision', relayServer: relay, relayPort: 21001, credentials, reality, publicKey: 'public-key', name: '测试' }); +function makeNode(overrides = {}) { + const node = { + id: 'nod_test1', name: '测试节点', serverId: entryServer.id, + protocol: 'vless-reality-vision', port: 21001, portMode: 'random', + networkMode: 'ipv4', topology: 'forward', + exitServerId: exitServer.id, exitProtocol: 'shadowsocks-2022-aes128', exitPort: 32001, exitPortMode: 'random', + realityServerName: 'www.microsoft.com', realityDestPort: 443, + resourceId: 'res_entry', exitResourceId: 'res_exit', + ...overrides, + }; + return ensureNodeSecrets(node); +} + +function makeClients(node, customerIds) { + return customerIds.map((customerId) => ({ customerId, credentials: newNodeClientCredentials(node.protocol) })); +} + +test('builds a shared VLESS Reality node with multiple clients and an SS2022 exit', () => { + const node = makeNode(); + const clients = makeClients(node, ['cus_a', 'cus_b']); + const exitResource = buildNodeExitResource({ resourceId: node.exitResourceId, tagPrefix: 'exit-test', node, server: exitServer }); + const entryResource = buildNodeResource({ resourceId: node.resourceId, tagPrefix: 'relay-test', node, nodeClients: clients, server: entryServer, exitServer }); + assert.equal(exitResource.meta.kind, 'node-exit'); + assert.equal(exitResource.engine, 'sing-box'); + assert.equal(exitResource.inbounds[0].type, 'shadowsocks'); + assert.equal(entryResource.meta.kind, 'node'); + assert.equal(entryResource.inbounds[0].type, 'vless'); + // 两个客户共享一个 inbound,各自独立 uuid,且带 ng: 用户名供按用户统计 + assert.equal(entryResource.inbounds[0].users.length, 2); + assert.notEqual(entryResource.inbounds[0].users[0].uuid, entryResource.inbounds[0].users[1].uuid); + assert.equal(entryResource.inbounds[0].users[0].name, 'ng:cus_a'); + assert.equal(entryResource.inbounds[0].users[1].name, 'ng:cus_b'); + assert.deepEqual(entryResource.inbounds[0].tls.reality.short_id, clients.map((c) => c.credentials.shortId)); + assert.equal(entryResource.inbounds[0].tls.reality.private_key, '${REALITY_PRIVATE:reality-nod_test1}'); + assert.equal(entryResource.outbounds[0].server, 'exit.example.com'); + assert.equal(entryResource.outbounds[0].password, node.exitCredentials.password); + // 客户端 URI 按客户凭据生成 + const uri = buildClientUri({ protocol: 'vless-reality-vision', relayServer: entryServer, relayPort: 21001, + credentials: { ...clients[0].credentials, wsPath: node.wsPath }, reality: node.reality, publicKey: 'public-key', name: '测试' }); assert.match(uri, /^vless:\/\//); assert.match(uri, /pbk=public-key/); + assert.match(uri, new RegExp(clients[0].credentials.clientId)); +}); + +test('nodeEmail unifies the per-user identity across protocols', () => { + assert.equal(nodeEmail('cus_1'), 'ng:cus_1'); +}); + +test('every inbound protocol carries a per-user identity for usage stats', () => { + // sing-box 只给 stats.users 里列名的用户记 user>>> 计数器;每个协议的 users 必须带可识别身份。 + const cases = [ + { protocol: 'vless-reality-vision', field: 'name' }, + { protocol: 'vless-ws-tls', field: 'name' }, + { protocol: 'shadowsocks-2022-aes128', field: 'name' }, + { protocol: 'shadowsocks-aes256-gcm', field: 'name' }, + { protocol: 'socks5-auth', field: 'username' }, + { protocol: 'hysteria2', field: 'name' }, + { protocol: 'anytls', field: 'name' }, + ]; + for (const { protocol, field } of cases) { + const node = makeNode({ protocol, topology: 'direct' }); + const clients = makeClients(node, ['cus_audit']); + const server = protocol === 'hysteria2' || protocol === 'anytls' || protocol === 'vless-ws-tls' + ? { ...entryServer, tlsDomain: 'node.example.com' } : entryServer; + const resource = buildNodeResource({ resourceId: `res_audit_${protocol}`, tagPrefix: 'audit', node, nodeClients: clients, server }); + const user = resource.inbounds[0].users[0]; + assert.equal(user[field], 'ng:cus_audit', `${protocol} users 缺少 ${field},按用户统计会失效`); + } }); test('rejects unsupported protocol pairs', () => { @@ -30,77 +86,117 @@ test('rejects unsupported protocol pairs', () => { assert.doesNotThrow(() => validateProtocolPair('anytls', 'vless-tcp')); }); -test('AnyTLS runs in sing-box with a separate TLS entry and VLESS or Shadowsocks exit', () => { +test('AnyTLS node runs in sing-box with per-user names and a VLESS exit', () => { const { makeClash, makeSingBox, makeSurge } = require('../lib/subscriptions'); - const credentials = newCredentialSet('anytls', 'vless-tcp'); - assert.throws(() => buildDirectResource({ resourceId:'res_any_no_cert', tagPrefix:'any', port:23001, - protocol:'anytls', credentials, customer }), /证书域名/); - const relayResource = buildRelayResource({ resourceId:'res_any_relay', tagPrefix:'any', port:23001, - protocol:'anytls', exitProtocol:'vless-tcp', exitServer:exit, exitPort:32001, - credentials, customer, tlsDomain:'entry.example.com' }); - assert.equal(relayResource.engine, 'sing-box'); - assert.equal(relayResource.inbounds[0].type, 'anytls'); - assert.equal(relayResource.inbounds[0].tls.certificate_path, '/etc/nexusgate/tls/entry.example.com/fullchain.pem'); - assert.equal(relayResource.outbounds[0].type, 'vless'); - assert.equal(relayResource.outbounds[0].uuid, credentials.clientId); - const direct = buildDirectResource({ resourceId:'res_any_direct', tagPrefix:'direct-any', port:23002, - protocol:'anytls', credentials, customer, tlsDomain:'entry.example.com' }); + const node = makeNode({ protocol: 'anytls', topology: 'forward', exitProtocol: 'vless-tcp' }); + const clients = makeClients(node, ['cus_a']); + assert.throws(() => buildNodeResource({ resourceId: 'res_any_no_cert', tagPrefix: 'any', node, + nodeClients: clients, server: { ...entryServer, tlsDomain: null }, exitServer }), /证书域名/); + const resource = buildNodeResource({ resourceId: 'res_any', tagPrefix: 'any', node, nodeClients: clients, server: entryServer, exitServer }); + assert.equal(resource.engine, 'sing-box'); + assert.equal(resource.inbounds[0].type, 'anytls'); + assert.equal(resource.inbounds[0].users[0].name, 'ng:cus_a'); + assert.equal(resource.inbounds[0].users[0].password, clients[0].credentials.relayPassword); + assert.equal(resource.inbounds[0].tls.certificate_path, '/etc/nexusgate/tls/node.example.com/fullchain.pem'); + assert.equal(resource.outbounds[0].type, 'vless'); + assert.equal(resource.outbounds[0].uuid, node.exitCredentials.clientId); + const direct = buildNodeResource({ resourceId: 'res_any_direct', tagPrefix: 'direct-any', + node: makeNode({ protocol: 'anytls', topology: 'direct' }), nodeClients: clients, server: entryServer }); assert.equal(direct.outbounds[0].type, 'direct'); assert.equal(direct.routingRules[0].outbound, 'direct-any-direct'); - const uri = buildClientUri({ protocol:'anytls', relayServer:relay, relayPort:23001, - credentials, tlsDomain:'entry.example.com', name:'AnyTLS 测试' }); + const uri = buildClientUri({ protocol: 'anytls', relayServer: entryServer, relayPort: 23001, + credentials: clients[0].credentials, tlsDomain: 'node.example.com', name: 'AnyTLS 测试' }); assert.match(uri, /^anytls:\/\//); - assert.match(makeClash([{ clientUri:uri }]), /type: "anytls"/); - assert.match(makeSingBox([{ clientUri:uri }]), /"type": "anytls"/); - assert.equal(makeSurge([{ clientUri:uri }]), null); + assert.match(makeClash([{ clientUri: uri }]), /type: "anytls"/); + assert.match(makeSingBox([{ clientUri: uri }]), /"type": "anytls"/); + assert.equal(makeSurge([{ clientUri: uri }]), null); }); -test('Hysteria 2 reuses Xray route and emits importable Clash and sing-box credentials', () => { +test('Hysteria 2 node carries multiple clients and emits importable credentials', () => { const { makeClash, makeSingBox } = require('../lib/subscriptions'); - const credentials = newCredentialSet('hysteria2', 'shadowsocks-2022-aes128'); - const node = buildRelayResource({ resourceId:'res_hy2_123', tagPrefix:'hy2-test', port:23000, - protocol:'hysteria2', exitProtocol:'shadowsocks-2022-aes128', exitServer:exit, exitPort:32001, - credentials, customer, tlsDomain:'node.example.com' }); - assert.equal(node.inbounds[0].protocol, 'hysteria'); - assert.equal(node.inbounds[0].streamSettings.method, 'hysteria'); - assert.equal(node.inbounds[0].streamSettings.network, 'hysteria'); - assert.equal(node.inbounds[0].settings.clients[0].auth, credentials.relayPassword); - assert.equal(node.inbounds[0].settings.users[0].auth, credentials.relayPassword); - assert.equal(node.inbounds[0].streamSettings.tlsSettings.certificates[0].keyFile, '/etc/nexusgate/tls/node.example.com/privkey.pem'); - const uri = buildClientUri({ protocol:'hysteria2', relayServer:relay, relayPort:23000, - credentials, tlsDomain:'node.example.com', name:'H2 test' }); + const node = makeNode({ protocol: 'hysteria2', topology: 'direct' }); + const clients = makeClients(node, ['cus_a', 'cus_b']); + const resource = buildNodeResource({ resourceId: 'res_hy2', tagPrefix: 'hy2-test', node, nodeClients: clients, server: entryServer }); + assert.equal(resource.inbounds[0].type, 'hysteria2'); + assert.equal(resource.inbounds[0].users.length, 2); + assert.equal(resource.inbounds[0].users[0].password, clients[0].credentials.relayPassword); + assert.equal(resource.inbounds[0].tls.key_path, '/etc/nexusgate/tls/node.example.com/privkey.pem'); + const uri = buildClientUri({ protocol: 'hysteria2', relayServer: entryServer, relayPort: 23000, + credentials: clients[0].credentials, tlsDomain: 'node.example.com', name: 'H2 test' }); assert.match(uri, /^hysteria2:\/\//); - assert.match(makeClash([{ clientUri:uri }]), /type: "hysteria2"/); - assert.match(makeSingBox([{ clientUri:uri }]), /"type": "hysteria2"/); + assert.match(makeClash([{ clientUri: uri }]), /type: "hysteria2"/); + assert.match(makeSingBox([{ clientUri: uri }]), /"type": "hysteria2"/); }); -test('builds direct Shadowsocks, SOCKS5 and IPv6 client resources', () => { - const ssCredentials = newCredentialSet('shadowsocks-2022-aes256', null); - const direct = buildDirectResource({ resourceId: 'res_direct_123', tagPrefix: 'direct-test', port: 24001, protocol: 'shadowsocks-2022-aes256', credentials: ssCredentials, customer, networkMode: 'ipv6' }); - assert.equal(direct.meta.kind, 'direct'); - assert.equal(direct.inbounds[0].listen, '::'); - assert.equal(direct.inbounds[0].settings.method, '2022-blake3-aes-256-gcm'); - const ipv6Relay = { ...relay, publicAddressV6: '2001:db8::10' }; - const uri = buildClientUri({ protocol: 'shadowsocks-2022-aes256', relayServer: ipv6Relay, relayPort: 24001, credentials: ssCredentials, name: 'IPv6 节点', networkMode: 'ipv6' }); +test('builds shared Shadowsocks, SOCKS5 and IPv6 node inbounds', () => { + const node = makeNode({ protocol: 'shadowsocks-2022-aes256', topology: 'direct', networkMode: 'ipv6' }); + const clients = makeClients(node, ['cus_a', 'cus_b']); + const resource = buildNodeResource({ resourceId: 'res_ss', tagPrefix: 'ss-test', node, nodeClients: clients, server: entryServer }); + assert.equal(resource.meta.kind, 'node'); + assert.equal(resource.inbounds[0].method, '2022-blake3-aes-256-gcm'); + assert.equal(resource.inbounds[0].users.length, 2); + assert.equal(resource.inbounds[0].users[0].name, 'ng:cus_a'); + // SS2022 每个用户的密钥长度符合方法要求 + assert.equal(Buffer.from(clients[0].credentials.relayPassword, 'base64').length, 32); + const ipv6Server = { ...entryServer, publicAddressV6: '2001:db8::10' }; + const v6 = buildNodeResource({ resourceId: 'res_ss_v6', tagPrefix: 'ss-v6', node: makeNode({ protocol: 'shadowsocks-2022-aes256', topology: 'direct', networkMode: 'ipv6' }), + nodeClients: clients, server: ipv6Server, bindAddress: '2001:db8::99' }); + assert.equal(v6.inbounds[0].listen, '2001:db8::99'); + const uri = buildClientUri({ protocol: 'shadowsocks-2022-aes256', relayServer: ipv6Server, relayPort: 24001, + credentials: clients[0].credentials, name: 'IPv6 节点', networkMode: 'ipv6' }); assert.match(uri, /@\[2001:db8::10\]:24001/); - const socksCredentials = newCredentialSet('socks5-auth', null); - const socks = buildDirectResource({ resourceId: 'res_direct_456', tagPrefix: 'socks-test', port: 24002, protocol: 'socks5-auth', credentials: socksCredentials, customer, networkMode: 'ipv4' }); - assert.equal(socks.inbounds[0].protocol, 'socks'); + const socksNode = makeNode({ protocol: 'socks5-auth', topology: 'direct' }); + const socksClients = makeClients(socksNode, ['cus_a']); + const socks = buildNodeResource({ resourceId: 'res_socks', tagPrefix: 'socks-test', node: socksNode, nodeClients: socksClients, server: entryServer }); + assert.equal(socks.inbounds[0].type, 'socks'); + // SOCKS 用户名即 ng: + assert.equal(socks.inbounds[0].users[0].username, 'ng:cus_a'); assert.doesNotThrow(() => validateEntryProtocol('socks5-auth')); }); -test('VLESS WebSocket TLS requires a node certificate domain and exports TLS clients', () => { - const credentials = newCredentialSet('vless-ws-tls', null); +test('VLESS WebSocket TLS node requires a certificate domain and exports TLS clients', () => { + const node = makeNode({ protocol: 'vless-ws-tls', topology: 'direct' }); + const clients = makeClients(node, ['cus_a']); assert.doesNotThrow(() => validateEntryProtocol('vless-ws-tls')); - assert.throws(() => buildDirectResource({ resourceId:'res_tls_missing', tagPrefix:'tls', port:24444, - protocol:'vless-ws-tls', credentials, customer }), /证书域名/); - const resource = buildDirectResource({ resourceId:'res_tls_123', tagPrefix:'tls', port:24444, - protocol:'vless-ws-tls', credentials, customer, tlsDomain:'node.example.com' }); - const stream = resource.inbounds[0].streamSettings; - assert.equal(stream.security, 'tls'); - assert.equal(stream.tlsSettings.certificates[0].certificateFile, '/etc/nexusgate/tls/node.example.com/fullchain.pem'); - const uri = buildClientUri({ protocol:'vless-ws-tls', relayServer:relay, relayPort:24444, - credentials, tlsDomain:'node.example.com', name:'TLS 节点' }); + assert.throws(() => buildNodeResource({ resourceId: 'res_tls_missing', tagPrefix: 'tls', node, nodeClients: clients, + server: { ...entryServer, tlsDomain: null } }), /证书域名/); + const resource = buildNodeResource({ resourceId: 'res_tls', tagPrefix: 'tls', node, nodeClients: clients, server: entryServer }); + assert.equal(resource.inbounds[0].tls.enabled, true); + assert.equal(resource.inbounds[0].tls.certificate_path, '/etc/nexusgate/tls/node.example.com/fullchain.pem'); + assert.equal(resource.inbounds[0].transport.path, node.wsPath); + assert.equal(resource.inbounds[0].users[0].uuid, clients[0].credentials.clientId); + const uri = buildClientUri({ protocol: 'vless-ws-tls', relayServer: entryServer, relayPort: 24444, + credentials: { ...clients[0].credentials, wsPath: node.wsPath }, tlsDomain: 'node.example.com', name: 'TLS 节点' }); assert.match(uri, /security=tls/); assert.match(uri, /sni=node.example.com/); }); + +test('ensureNodeSecrets fills node-level secrets per protocol', () => { + const ss = ensureNodeSecrets({ id: 'n1', protocol: 'shadowsocks-2022-aes128', topology: 'direct' }); + assert.equal(Buffer.from(ss.ssServerKey, 'base64').length, 16); + assert.match(ss.wsPath, /^\//); + const hy2 = ensureNodeSecrets({ id: 'n2', protocol: 'hysteria2', topology: 'forward', exitProtocol: 'vless-tcp' }); + assert.ok(hy2.exitCredentials.clientId); + assert.ok(hy2.exitCredentials.password); + const direct = ensureNodeSecrets({ id: 'n3', protocol: 'vless-reality-vision', topology: 'direct' }); + assert.equal(direct.exitCredentials, undefined); +}); + +test('resolveBindAddress resolves per-stack and passes literal IPs through', async () => { + assert.equal(await resolveBindAddress('ipv4', { publicAddress: '203.0.113.10' }), '203.0.113.10'); + assert.equal(await resolveBindAddress('ipv6', { publicAddressV6: '2001:db8::10' }), '2001:db8::10'); + assert.equal(await resolveBindAddress('dual', { publicAddress: '203.0.113.10', publicAddressV6: '2001:db8::10' }), null); + assert.equal(await resolveBindAddress('ipv4', {}), null); + assert.equal(await resolveBindAddress('ipv6', {}), null); + assert.equal(await resolveBindAddress('ipv4', { publicAddress: 'localhost' }), '127.0.0.1'); + await assert.rejects(() => resolveBindAddress('ipv4', { publicAddress: 'not a host!' }), /DNS 解析失败/); +}); + +test('builders prefer the resolved bindAddress for listen', () => { + const node = makeNode({ protocol: 'shadowsocks-2022-aes256', topology: 'direct', networkMode: 'ipv6' }); + const clients = makeClients(node, ['cus_a']); + const server = { id: 'srv1', publicAddress: 'v4.example.com', publicAddressV6: 'v6.example.com' }; + const resource = buildNodeResource({ resourceId: 'res_bind', tagPrefix: 'bind-test', node, nodeClients: clients, + server, bindAddress: '2001:db8::99' }); + assert.equal(resource.inbounds[0].listen, '2001:db8::99'); +}); diff --git a/test/subscriptions.test.js b/test/subscriptions.test.js index 74c8f19..0fa1002 100644 --- a/test/subscriptions.test.js +++ b/test/subscriptions.test.js @@ -2,57 +2,93 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { entries, formatSubscription, makeSingBox, clashProxy } = require('../lib/subscriptions'); +const { entries, nodeClientUri, formatSubscription, makeSingBox, clashProxy } = require('../lib/subscriptions'); -test('formats active Reality and Shadowsocks entries, excluding exit and incomplete resources', () => { - const customer = { id:'cus-1' }; - const reality = 'vless://00000000-0000-4000-8000-000000000001@[2001:db8::1]:443?encryption=none&security=reality&sni=www.tesla.com&fp=chrome&pbk=public-key&sid=abcdef&type=tcp&flow=xtls-rprx-vision#Reality'; - const ss = `ss://${Buffer.from('2022-blake3-aes-128-gcm:secret').toString('base64url')}@example.com:1443#SS`; - const data = { deployments:[ - { customerId:customer.id, role:'relay', status:'active', clientUri:reality }, - { customerId:customer.id, role:'direct', status:'active', clientUri:ss }, - { customerId:customer.id, role:'exit', status:'active', clientUri:'should-not-export' }, - { customerId:customer.id, role:'relay', status:'failed', clientUri:'should-not-export' }, - { customerId:customer.id, role:'relay', status:'active', archived:true, clientUri:'should-not-export' } - ] }; - assert.equal(entries(data, customer.id).length, 2); - const proxy = clashProxy(data.deployments[0]); +// 最小可用数据集:节点 + 服务器 + 分配,entries() 现场生成客户端链接。 +function makeData() { + const servers = [ + { id: 'srv_1', publicAddress: '2001:db8::1', tlsDomain: null }, + { id: 'srv_2', publicAddress: 'example.com', tlsDomain: 'entry.example.com' }, + ]; + const nodes = [ + { id: 'nod_reality', name: 'Reality', protocol: 'vless-reality-vision', port: 443, serverId: 'srv_1', + networkMode: 'ipv4', topology: 'direct', status: 'active', + reality: { keyId: 'k1', serverName: 'www.tesla.com', destPort: 443 }, + artifacts: { realityPublicKey: 'public-key' } }, + { id: 'nod_ss', name: 'SS', protocol: 'shadowsocks-2022-aes128', port: 1443, serverId: 'srv_2', + networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} }, + { id: 'nod_down', name: '故障节点', protocol: 'shadowsocks-2022-aes128', port: 1444, serverId: 'srv_2', + networkMode: 'ipv4', topology: 'direct', status: 'failed', artifacts: {} }, + ]; + const nodeClients = [ + { nodeId: 'nod_reality', customerId: 'cus-1', status: 'active', + credentials: { clientId: '00000000-0000-4000-8000-000000000001', relayPassword: 'secret', shortId: 'abcdef' } }, + { nodeId: 'nod_ss', customerId: 'cus-1', status: 'active', + credentials: { clientId: '00000000-0000-4000-8000-000000000002', relayPassword: 'c2VjcmV0', shortId: '123456' } }, + { nodeId: 'nod_down', customerId: 'cus-1', status: 'active', + credentials: { clientId: '00000000-0000-4000-8000-000000000003', relayPassword: 'c2VjcmV0', shortId: '123456' } }, + { nodeId: 'nod_ss', customerId: 'cus-1', status: 'suspended', + credentials: { clientId: '00000000-0000-4000-8000-000000000004', relayPassword: 'c2VjcmV0', shortId: '123456' } }, + ]; + return { servers, nodes, nodeClients }; +} + +test('formats active Reality and Shadowsocks entries, excluding failed nodes and suspended assignments', () => { + const data = makeData(); + const items = entries(data, 'cus-1'); + assert.equal(items.length, 2); + assert.ok(items[0].clientUri.startsWith('vless://')); + assert.ok(items[1].clientUri.startsWith('ss://')); + const proxy = clashProxy(items[0]); assert.equal(proxy.server, '2001:db8::1'); assert.equal(proxy['reality-opts']['public-key'], 'public-key'); - const config = JSON.parse(makeSingBox(entries(data, customer.id))); + const config = JSON.parse(makeSingBox(items)); assert.equal(config.outbounds[1].tls.reality.short_id, 'abcdef'); assert.equal(config.outbounds[2].method, '2022-blake3-aes-128-gcm'); - assert.equal(formatSubscription(data, customer, 'auto', 'Mihomo').contentType, 'text/yaml; charset=utf-8'); - assert.equal(formatSubscription(data, customer, 'auto', 'Other').contentType, 'text/plain; charset=utf-8'); - assert.doesNotMatch(formatSubscription(data, customer, 'raw').body, /should-not-export/); + assert.equal(formatSubscription(data, { id: 'cus-1' }, 'auto', 'Mihomo').contentType, 'text/yaml; charset=utf-8'); + assert.equal(formatSubscription(data, { id: 'cus-1' }, 'auto', 'Other').contentType, 'text/plain; charset=utf-8'); + assert.doesNotMatch(formatSubscription(data, { id: 'cus-1' }, 'raw').body, /故障节点/); +}); + +test('nodeClientUri returns null for inactive nodes', () => { + const data = makeData(); + const nc = data.nodeClients.find((item) => item.nodeId === 'nod_down'); + assert.equal(nodeClientUri(data, nc), null); }); test('TLS WebSocket exports valid Mihomo and sing-box TLS settings', () => { - const customer = { id:'cus-tls' }; - const data = { deployments:[{ customerId:customer.id, role:'direct', status:'active', clientUri: - 'vless://00000000-0000-4000-8000-000000000001@node.example.com:24444?encryption=none&security=tls&sni=node.example.com&type=ws&path=%2Ftest#TLS' }] }; - const proxy = clashProxy(data.deployments[0]); + const data = makeData(); + data.nodes.push({ id: 'nod_tls', name: 'TLS', protocol: 'vless-ws-tls', port: 24444, serverId: 'srv_2', + networkMode: 'ipv4', topology: 'direct', status: 'active', wsPath: '/test', artifacts: {} }); + data.nodeClients.push({ nodeId: 'nod_tls', customerId: 'cus-tls', status: 'active', + credentials: { clientId: '00000000-0000-4000-8000-000000000001', relayPassword: 'secret', shortId: 'abcdef' } }); + const items = entries(data, 'cus-tls'); + assert.equal(items.length, 1); + const proxy = clashProxy(items[0]); assert.equal(proxy.tls, true); - assert.equal(proxy.servername, 'node.example.com'); + assert.equal(proxy.servername, 'entry.example.com'); assert.equal(proxy['reality-opts'], undefined); - const outbound = JSON.parse(makeSingBox(entries(data, customer.id))).outbounds[1]; + const outbound = JSON.parse(makeSingBox(items)).outbounds[1]; assert.equal(outbound.tls.enabled, true); assert.equal(outbound.tls.reality, undefined); - assert.equal(formatSubscription(data, customer, 'surge'), null); + assert.equal(formatSubscription(data, { id: 'cus-tls' }, 'surge'), null); }); -test('adding HY2 to an AnyTLS subscription keeps ALPN on one valid YAML line and route names clean', () => { - const data = { chains:[{ id:'any-route', name:'US-rak-CN2' }, { id:'hy-route', name:'hy2 cn2' }], deployments:[ - { chainId:'any-route', customerId:'55', role:'direct', status:'active', - clientUri:'anytls://secret@entry.example.com:48924?sni=entry.example.com#US-rak-CN2%20%C2%B7%20rak%20cn2%3A48924' }, - { chainId:'hy-route', customerId:'55', role:'direct', status:'active', - clientUri:'hysteria2://secret@entry.example.com:40003?sni=entry.example.com&alpn=h3#hy2%20cn2%20%C2%B7%20rak%20cn2%3A40003' } - ] }; - const customer = { id:'55' }; +test('adding HY2 to an AnyTLS subscription keeps ALPN on one valid YAML line and node names clean', () => { + const data = makeData(); + data.nodes.push( + { id: 'nod_any', name: 'US-rak-CN2', protocol: 'anytls', port: 48924, serverId: 'srv_2', + networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} }, + { id: 'nod_hy', name: 'hy2 cn2', protocol: 'hysteria2', port: 40003, serverId: 'srv_2', + networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} }); + for (const [nodeId, password] of [['nod_any', 'secret'], ['nod_hy', 'secret']]) { + data.nodeClients.push({ nodeId, customerId: '55', status: 'active', + credentials: { clientId: '00000000-0000-4000-8000-000000000001', relayPassword: password, shortId: 'abcdef' } }); + } + const customer = { id: '55' }; const raw = formatSubscription(data, customer, 'raw').body; assert.match(raw, /#US-rak-CN2\n/); assert.match(raw, /#hy2%20cn2\n/); - assert.doesNotMatch(raw, /rak%20cn2%3A/); const yaml = formatSubscription(data, customer, 'clash').body; assert.match(yaml, /^ alpn: \["h3"\]$/m); assert.match(yaml, /^proxy-groups:$/m); @@ -61,25 +97,48 @@ test('adding HY2 to an AnyTLS subscription keeps ALPN on one valid YAML line and assert.match(yaml, /^ - name: "hy2 cn2"$/m); }); -test('legacy VMess subscriptions also use the route name instead of a base64 label', () => { - const payload = { v:'2', ps:'old · entry:20000', add:'entry.example.com', port:'20000', id:'uuid', path:'/ws' }; - const data = { chains:[{ id:'vmess-route', name:'VMess US' }], deployments:[{ - chainId:'vmess-route', customerId:'vmess-customer', role:'direct', status:'active', - clientUri:`vmess://${Buffer.from(JSON.stringify(payload)).toString('base64')}` - }] }; - const exported = entries(data, 'vmess-customer'); - assert.equal(clashProxy(exported[0]).name, 'VMess US'); - assert.match(formatSubscription(data, { id:'vmess-customer' }, 'clash').body, /^ - name: "VMess US"$/m); -}); - -test('Clash aliases only route names that collide with built-in groups or policies', () => { - const data = { chains:[{ id:'route-a', name:'节点选择' }, { id:'route-b', name:'DIRECT' }], deployments:[ - { chainId:'route-a', customerId:'one', role:'direct', status:'active', clientUri:'anytls://password@entry.example.com:443?sni=entry.example.com#old' }, - { chainId:'route-b', customerId:'one', role:'direct', status:'active', clientUri:'hysteria2://password@entry.example.com:444?sni=entry.example.com#old' } - ] }; - const yaml = formatSubscription(data, { id:'one' }, 'clash-smart').body; +test('Clash aliases only node names that collide with built-in groups or policies', () => { + const data = makeData(); + data.nodes.push( + { id: 'nod_a', name: '节点选择', protocol: 'anytls', port: 443, serverId: 'srv_2', + networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} }, + { id: 'nod_b', name: 'DIRECT', protocol: 'hysteria2', port: 444, serverId: 'srv_2', + networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} }); + for (const nodeId of ['nod_a', 'nod_b']) { + data.nodeClients.push({ nodeId, customerId: 'one', status: 'active', + credentials: { clientId: '00000000-0000-4000-8000-000000000001', relayPassword: 'password', shortId: 'abcdef' } }); + } + const yaml = formatSubscription(data, { id: 'one' }, 'clash-smart').body; assert.match(yaml, /^ - name: "节点选择 \(节点\)"$/m); assert.match(yaml, /^ - name: "DIRECT \(节点\)"$/m); assert.match(yaml, /^ - "节点选择 \(节点\)"$/m); - assert.match(formatSubscription(data, { id:'one' }, 'raw').body, /#%E8%8A%82%E7%82%B9%E9%80%89%E6%8B%A9/); + assert.match(formatSubscription(data, { id: 'one' }, 'raw').body, /#%E8%8A%82%E7%82%B9%E9%80%89%E6%8B%A9/); +}); + +test('SS2022 client links carry ":" for the EIH identity header', () => { + const data = makeData(); + // 2022 node with a server PSK: link password must be colon-joined so the + // sing-box multi-user server can attribute the connection (EIH). + data.nodes.push({ id: 'nod_ss2022', name: 'SS2022', protocol: 'shadowsocks-2022-aes128', + port: 1445, serverId: 'srv_2', networkMode: 'ipv4', topology: 'direct', status: 'active', + ssServerKey: 'c2VydmVyLW1haW4tcHNr', artifacts: {} }); + // Classic AEAD multi-user needs no EIH: user password alone. + data.nodes.push({ id: 'nod_ssold', name: 'SSold', protocol: 'shadowsocks-aes128-gcm', + port: 1446, serverId: 'srv_2', networkMode: 'ipv4', topology: 'direct', status: 'active', + ssServerKey: 'c2VydmVyLW1haW4tcHNr', artifacts: {} }); + for (const nodeId of ['nod_ss2022', 'nod_ssold']) { + data.nodeClients.push({ nodeId, customerId: 'cus-eih', status: 'active', + credentials: { clientId: '00000000-0000-4000-8000-000000000001', relayPassword: 'dXNlci1wYXNzd29yZA', shortId: 'abcdef' } }); + } + const decodeSs = (uri) => { + const payload = Buffer.from(new URL(uri).username, 'base64url').toString('utf8'); + return payload.slice(payload.indexOf(':') + 1); + }; + const items = entries(data, 'cus-eih'); + assert.equal(items.length, 2); + assert.equal(decodeSs(items[0].clientUri), 'c2VydmVyLW1haW4tcHNr:dXNlci1wYXNzd29yZA'); + assert.equal(decodeSs(items[1].clientUri), 'dXNlci1wYXNzd29yZA'); + // The sing-box subscription outbound inherits the joined password verbatim. + const config = JSON.parse(makeSingBox(items)); + assert.equal(config.outbounds[1].password, 'c2VydmVyLW1haW4tcHNr:dXNlci1wYXNzd29yZA'); });