3x-ui 式共享节点改造:nodes 状态机 + nodeClients 各自凭据
- 移除 VMess 协议 - VLESS/SS2022 按用户流量统计(AnyTLS 日志解析,SS 节点级) - sing-box v2ray_api stats 支持 - 内存优化:面板 128M,agent 64M,sing-box 64M
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)。
|
||||
|
||||
+148
-246
@@ -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:<customerId>,身份直接写在日志行里,无需 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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 重新注册:撤销旧密钥,为该设备重新生成已有部署的对账任务。
|
||||
- 任务失败:部署标记为失败、线路标记为异常,可只修复失败项或完整重建。
|
||||
|
||||
+3
-3
@@ -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 校验和验证与分批升级
|
||||
- 控制面敏感字段静态加密
|
||||
|
||||
- 出口池、延迟/可用性探测、加权分流与故障转移
|
||||
|
||||
+4
-4
@@ -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 和回滚行为。
|
||||
|
||||
+316
-302
@@ -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 };
|
||||
|
||||
+236
-167
@@ -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:<customerId>:
|
||||
// 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
|
||||
// "<server PSK>:<user PSK>" 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
|
||||
};
|
||||
|
||||
+3
-3
@@ -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: []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+38
-29
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -9,7 +9,7 @@ error_log="/var/log/nexusgate/agent-error.log"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
use nexusgate-xray
|
||||
use nexusgate-sing-box
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+1
-1
@@ -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"
|
||||
},
|
||||
|
||||
+199
-111
@@ -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。
|
||||
// 注意:弹窗是 <dialog> 模态的,此时 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) => `<div class="health-row"><div><b>${esc(server.name)}</b><span>${esc(roleText[server.role] || server.role)} · ${esc(server.region || server.publicAddress)}</span></div>${status(server.status)}</div>`).join('');
|
||||
const events = ((state.overview && state.overview.activity) || []).map((event) => `<div class="event"><i class="event-dot"></i><div><b>${esc(auditText[event.action] || event.action)}</b><small>${esc(event.actor)} · ${esc(event.target)}</small></div><time>${fmtDate(event.at)}</time></div>`).join('');
|
||||
const failures = ((state.overview && state.overview.degraded) || []).length;
|
||||
return `<section class="metrics">${metrics.map(([label,value,note,color]) => `<article class="metric" style="--accent:${color}"><span>${label}</span><strong>${value}</strong><small>${note}</small></article>`).join('')}</section>
|
||||
${failures ? `<div class="notice warning" style="margin-bottom:16px">检测到 ${failures} 个失败部署。请到“转发与节点”查看错误后使用“修复失败项”。更新 Agent 后无需重建节点。</div>` : ''}
|
||||
${failures ? `<div class="notice warning" style="margin-bottom:16px">检测到 ${failures} 个异常节点。请到“转发与节点”查看错误后使用“修复”。更新 Agent 后无需重建节点。</div>` : ''}
|
||||
<section class="grid-2"><article class="panel"><div class="panel-head"><div><h2>设备健康</h2><p>超过三分钟未上报将标记离线</p></div><button class="ghost" data-page-jump="servers">查看全部</button></div><div class="panel-body health-list">${serverRows || empty('还没有设备','先添加入口、出口或综合节点')}</div></article>
|
||||
<article class="panel"><div class="panel-head"><div><h2>最近活动</h2><p>重要操作审计记录</p></div></div><div class="panel-body timeline">${events || empty('暂无活动','操作记录会显示在这里')}</div></article></section>`;
|
||||
}
|
||||
@@ -155,67 +178,84 @@ function renderCustomers() {
|
||||
return `<tr><td><strong>${esc(item.name)}</strong><small>${esc(item.group || '未分组')}</small></td><td>${status(item.status)}${item.status === 'suspended' ? `<small>${esc(({ traffic_limit:'额度耗尽', expired:'已到期', ip_limit:'节点 IP 超限', manual:'手动停用' })[item.suspendReason] || '待恢复')}</small>` : ''}</td>
|
||||
<td><strong>${fmtBytes(item.usedBytes)} / ${item.trafficLimitBytes ? fmtBytes(item.trafficLimitBytes) : '不限'}</strong><small>上行 ${fmtBytes(item.usedUplinkBytes)} · 下行 ${fmtBytes(item.usedDownlinkBytes)}</small><div class="progress"><i style="width:${percent}%"></i></div><small>${item.lastUsageAt ? `最后计量 ${fmtDate(item.lastUsageAt)}` : '尚未收到入口流量统计'}</small></td>
|
||||
<td>${item.expiresAt ? fmtDate(item.expiresAt) : '不限期'}</td><td>节点 IP ${item.observedIpCount || 0} / ${item.ipLimit || '不限'}<small>订阅客户端约 ${item.subscriptionClientCount || 0} / ${item.deviceLimit || '不限'}</small></td><td>${tags(item.tags)}</td>
|
||||
<td>${rowActions(item.id, `<button data-action="edit-customer" data-id="${esc(item.id)}">编辑</button><button data-action="customer-subscription" data-id="${esc(item.id)}">订阅</button><button data-action="customer-access" data-id="${esc(item.id)}">访问</button>`, [['reset-usage','流量清零'],['toggle-customer',item.status === 'active' ? '停用' : '启用'],['delete-customer','删除客户']])}${item.pendingCleanup ? `<small>待清理 ${item.pendingCleanup} 项</small>` : ''}</td></tr>`;
|
||||
<td>${rowActions(item.id, `<button data-action="edit-customer" data-id="${esc(item.id)}">编辑</button><button data-action="customer-subscription" data-id="${esc(item.id)}">订阅</button><button data-action="customer-access" data-id="${esc(item.id)}">访问</button>`, [['assign-nodes','分配节点'],['reset-usage','流量清零'],['toggle-customer',item.status === 'active' ? '停用' : '启用'],['delete-customer','删除客户']])}</td></tr>`;
|
||||
}).join('');
|
||||
return `<div class="page-intro"><p>流量按入口上行+下行双向累计,Agent 约每分钟上报。额度、到期或节点 IP 触发暂停后,调整限制会自动恢复原节点;待 Agent 完成恢复任务,订阅才可更新。旧版已经变为草稿的线路可点“恢复原节点”。</p><button class="primary" data-action="add-customer">+ 添加客户</button></div>
|
||||
return `<div class="page-intro"><p>流量按入口上行+下行双向累计,Agent 约每分钟上报,按客户跨节点汇总。额度、到期或节点 IP 触发暂停后,调整限制会自动恢复;待 Agent 完成恢复任务,订阅才可更新。</p><button class="primary" data-action="add-customer">+ 添加客户</button></div>
|
||||
<section class="panel"><div class="panel-head"><div class="toolbar"><input class="search" data-search placeholder="搜索客户、分组或标签" value="${esc(state.search)}"><span class="tag">${state.customers.length} 位</span></div></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>客户</th><th>状态</th><th>流量</th><th>到期</th><th>使用限制</th><th>标签</th><th>操作</th></tr></thead><tbody>${rows || `<tr><td colspan="7">${empty('还没有客户','先创建客户,再编排线路')}</td></tr>`}</tbody></table></div></section>`;
|
||||
<div class="table-wrap"><table><thead><tr><th>客户</th><th>状态</th><th>流量</th><th>到期</th><th>使用限制</th><th>标签</th><th>操作</th></tr></thead><tbody>${rows || `<tr><td colspan="7">${empty('还没有客户','先创建客户,再到“转发与节点”分配')}</td></tr>`}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function chainActions(chain) {
|
||||
const edit = `<button data-action="edit-chain" data-id="${esc(chain.id)}">编辑</button>`;
|
||||
const remove = `<button class="danger" data-action="delete-chain" data-id="${esc(chain.id)}">删除</button>`;
|
||||
if (chain.status === 'draft') return `${edit}${chain.generation > 0 ? `<button class="primary" data-action="restore-chain" data-id="${esc(chain.id)}">恢复原节点</button><button data-action="deploy-chain" data-id="${esc(chain.id)}">全新部署</button>` : `<button class="primary" data-action="deploy-chain" data-id="${esc(chain.id)}">部署</button>`}${remove}`;
|
||||
if (chain.status === 'suspended') return `${edit}${state.customers.some((item) => chain.customerIds.includes(item.id) && item.status === 'active') ? `<button class="primary" data-action="restore-chain" data-id="${esc(chain.id)}">恢复原节点</button>` : ''}<button data-action="remove-chain" data-id="${esc(chain.id)}">停用线路</button>${remove}`;
|
||||
if (chain.status === 'changes_pending') return `${edit}<button class="primary" data-action="redeploy-chain" data-id="${esc(chain.id)}">应用修改</button><button data-action="remove-chain" data-id="${esc(chain.id)}">停用</button>`;
|
||||
if (chain.status === 'degraded') return `${edit}<button class="primary" data-action="repair-chain" data-id="${esc(chain.id)}">修复失败项</button><button data-action="remove-chain" data-id="${esc(chain.id)}">停用</button>${remove}`;
|
||||
if (['active','deploying','suspending','partially_suspended'].includes(chain.status)) return `${edit}<button data-action="redeploy-chain" data-id="${esc(chain.id)}">重新部署</button><button data-action="remove-chain" data-id="${esc(chain.id)}">停用</button>`;
|
||||
return `${edit}${remove}`;
|
||||
function nodeActions(node) {
|
||||
const edit = `<button data-action="edit-node" data-id="${esc(node.id)}">编辑</button>`;
|
||||
const clients = `<button data-action="node-clients" data-id="${esc(node.id)}">客户端</button>`;
|
||||
const remove = `<button class="danger" data-action="delete-node" data-id="${esc(node.id)}">删除</button>`;
|
||||
if (node.status === 'draft') return `${edit}${clients}<button class="primary" data-action="deploy-node" data-id="${esc(node.id)}">部署</button>${remove}`;
|
||||
if (['failed', 'degraded'].includes(node.status)) return `${edit}${clients}<button class="primary" data-action="repair-node" data-id="${esc(node.id)}">修复</button><button data-action="remove-node" data-id="${esc(node.id)}">停用</button>${remove}`;
|
||||
if (['active', 'deploying'].includes(node.status)) return `${edit}${clients}<button data-action="remove-node" data-id="${esc(node.id)}">停用</button>${remove}`;
|
||||
return `${edit}${clients}${remove}`;
|
||||
}
|
||||
|
||||
function rowActions(id, primary, more) {
|
||||
return `<div class="actions compact-actions">${primary}<select data-row-actions data-id="${esc(id)}" aria-label="更多操作"><option value="">更多操作</option>${more.map(([action,label]) => `<option value="${esc(action)}">${esc(label)}</option>`).join('')}</select></div>`;
|
||||
}
|
||||
|
||||
function compactChainActions(chain) {
|
||||
const actions = chainActions(chain).match(/<button\b[^>]*>[^<]*<\/button>/g) || [];
|
||||
function compactNodeActions(node) {
|
||||
const actions = nodeActions(node).match(/<button\b[^>]*>[^<]*<\/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) : `<div class="actions compact-actions">${primary}</div>`;
|
||||
return more.length ? rowActions(node.id, primary, more) : `<div class="actions compact-actions">${primary}</div>`;
|
||||
}
|
||||
|
||||
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 `<tr><td><strong>${esc(chain.name)}</strong><small>${esc(topologyText[chain.topology || 'forward'])} · ${esc(path)}</small>${drift ? '<small class="error-detail">编辑的协议尚未应用,当前节点见“部署与链接”</small>' : ''}</td>
|
||||
<td>${esc(names(chain.relayServerIds, state.servers))}<small>${esc(chain.networkMode || 'ipv4').toUpperCase()}</small></td><td>${esc(exit)}</td>
|
||||
<td>${esc(names(chain.customerIds, state.customers))}</td><td>${status(chain.status)}${chain.lastError ? `<small class="error-detail" title="${esc(chain.lastError)}">${esc(chain.lastError)}</small>` : ''}</td><td>${compactChainActions(chain)}</td></tr>`;
|
||||
}).join('');
|
||||
return `<div class="page-intro"><p>“转发线路”把多个客户端入口汇聚到一个出口;“单机直连”无需出口机,直接在任意设备创建节点。编辑运行中线路后,点击“应用修改”安全重建。</p><button class="primary" data-action="add-chain">+ 新建线路</button></div>
|
||||
<section class="panel"><div class="table-wrap"><table><thead><tr><th>线路</th><th>入口 / 节点设备</th><th>出口</th><th>客户</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows || `<tr><td colspan="6">${empty('还没有线路','准备好设备和客户后创建第一条线路')}</td></tr>`}</tbody></table></div></section>`;
|
||||
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 `<tr><td><strong>${esc(chain ? chain.name : '已删除线路')}</strong><small>${esc(deploymentRoleText[item.role] || item.role)}</small></td><td>${esc(customer ? customer.name : '已删除')}</td>
|
||||
<td>${esc(server ? server.name : '已删除')}<small>${esc(server ? (server.publicAddressV6 && chain && chain.networkMode === 'ipv6' ? server.publicAddressV6 : server.publicAddress) : '')}:${item.port}</small></td>
|
||||
<td>${esc(protocolName(item.protocol, item.role === 'exit' ? 'exit-transport' : 'relay-ingress'))}${chain && item.role !== 'exit' && item.protocol !== chain.relayProtocol ? '<small class="error-detail">编辑中的协议尚未部署</small>' : ''}</td><td>${status(item.status)}${item.error ? `<small>${esc(item.error)}</small>` : ''}${item.role !== 'exit' ? `<small>${item.meteredTraffic ? `本部署计量:上行 ${fmtBytes(item.meteredTraffic.uplink)} · 下行 ${fmtBytes(item.meteredTraffic.downlink)}` : '等待入口计量上报'}</small>${item.lastUsageAt ? `<small>最后上报 ${fmtDate(item.lastUsageAt)}</small>` : ''}` : ''}</td>
|
||||
<td><div class="actions">${canCopy ? `<button data-action="copy-uri" data-value="${esc(item.clientUri)}">复制链接</button>` : ''}${chain ? `<button data-action="edit-chain" data-id="${esc(chain.id)}">编辑线路</button>` : ''}</div></td></tr>`;
|
||||
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 `<tr><td><strong>${esc(node.name)}</strong><small>${esc(topologyText[node.topology || 'forward'])} · ${esc(path)}</small></td>
|
||||
<td>${esc(server ? server.name : '已删除')}<small>${esc(node.protocol)} :${node.port} · ${esc(node.networkMode || 'ipv4').toUpperCase()}</small></td><td>${esc(exit)}</td>
|
||||
<td>${nodeClientCount(node.id)} 位</td><td><small>${traffic}</small>${node.lastUsageAt ? `<small>最后上报 ${fmtDate(node.lastUsageAt)}</small>` : ''}</td>
|
||||
<td>${status(node.status)}${node.error ? `<small class="error-detail" title="${esc(node.error)}">${esc(node.error)}</small>` : ''}</td><td>${compactNodeActions(node)}</td></tr>`;
|
||||
}).join('');
|
||||
return `<div class="page-intro"><p>显示线路生成的实际入口、出口和单机节点。客户端链接只在入口资源部署成功后生成;编辑请从对应线路统一完成。</p></div>
|
||||
<section class="panel"><div class="table-wrap"><table><thead><tr><th>线路 / 角色</th><th>客户</th><th>设备</th><th>协议</th><th>状态 / 错误</th><th>操作</th></tr></thead><tbody>${rows || `<tr><td colspan="6">${empty('暂无部署','从“线路编排”页面发起部署')}</td></tr>`}</tbody></table></div></section>`;
|
||||
return `<div class="page-intro"><p>一个节点是一个共享入口(对标 3x-ui 的 inbound):多位客户共用同一端口,各自使用独立凭据。分配 / 移除客户后节点自动重建下发;编辑运行中的节点会立即重建应用。</p><button class="primary" data-action="add-node">+ 新建节点</button></div>
|
||||
<section class="panel"><div class="table-wrap"><table><thead><tr><th>节点</th><th>服务器 / 入口</th><th>出口</th><th>客户端</th><th>流量</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows || `<tr><td colspan="7">${empty('还没有节点','准备好设备和客户后创建第一个共享节点')}</td></tr>`}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
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 ? `<button data-action="copy-uri" data-value="${esc(nc.clientUri)}">复制链接</button>` : '<small>未运行或已停用</small>';
|
||||
return `<div class="node-link-customer"><span><strong>${name}</strong> ${st}</span><span class="actions">${link}</span></div>`;
|
||||
}).join('');
|
||||
const names = ncs.map((nc) => {
|
||||
const c = state.customers.find((item) => item.id === nc.customerId);
|
||||
return esc(c ? c.name : '?');
|
||||
}).join('、');
|
||||
return `<tr><td><strong>${esc(node ? node.name : '已删除节点')}</strong><small>${esc(server ? server.name : '')}${node ? ` :${node.port}` : ''}</small></td>
|
||||
<td><details><summary>${names}(${ncs.length})</summary><div class="node-link-list">${customerRows}</div></details></td>
|
||||
<td>${node ? status(node.status) : status('draft')}</td>
|
||||
<td><small>${ncs.length} 个分配</small></td></tr>`;
|
||||
}).join('');
|
||||
return `<div class="page-intro"><p>每个客户在每个已分配节点上拥有独立凭据。客户端链接只在节点运行中且客户正常时生成;日常分发请使用客户页的订阅链接。</p></div>
|
||||
<section class="panel"><div class="table-wrap"><table><thead><tr><th>节点</th><th>客户</th><th>状态</th><th>链接</th></tr></thead><tbody>${rows || `<tr><td colspan="4">${empty('暂无分配','先在“转发与节点”给客户分配节点')}</td></tr>`}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
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]) => `<section class="panel"><div class="panel-head"><h2>${esc(title)}</h2></div><div class="panel-body stack">${commands.map(([label, command, hint]) => `<div class="command-item"><div><b>${esc(label)}</b><small>${esc(hint)}</small><code>${esc(command)}</code></div><button data-action="copy-uri" data-value="${esc(command)}">复制命令</button></div>`).join('')}</div></section>`).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 `<div class="subscription-row"><div><b>${esc(label)}</b><code title="${esc(nc.clientUri)}">${esc(nc.clientUri.slice(0, 60))}...</code></div><div class="subscription-actions"><button type="button" data-action="copy-uri" data-value="${esc(nc.clientUri)}">复制</button><button type="button" data-action="qr-uri" data-value="${esc(nc.clientUri)}">二维码</button></div><div class="subscription-qr" hidden></div></div>`;
|
||||
}).join('');
|
||||
modal('CLIENT SUBSCRIPTION', `${customer.name} · 订阅链接`, `<div class="stack"><div class="notice">仅展示已成功部署的入口节点。客户停用、到期或流量用尽时链接停止分发。重置令牌会立即使旧订阅地址失效;已复制的节点凭据仍需停用或重建线路才能撤销。</div>
|
||||
<div class="subscription-row" style="background:#1a2e1a"><div><b>📱 订阅信息页(3x-ui 风格)</b><code title="${esc(infoUrl)}">${esc(infoUrl)}</code></div><div class="subscription-actions"><button type="button" data-action="copy-uri" data-value="${esc(infoUrl)}">复制</button><a href="${esc(infoUrl)}" target="_blank"><button type="button">打开</button></a></div></div>
|
||||
${formats.map(([format,label]) => { const url = `${location.origin}/s/${customer.subscriptionToken}/${format}`; return `<div class="subscription-row"><div><b>${esc(label)}</b><code title="${esc(url)}">${esc(url)}</code></div><div class="subscription-actions"><button type="button" data-action="copy-uri" data-value="${esc(url)}">复制</button><button type="button" data-action="qr-uri" data-value="${esc(url)}" aria-label="显示${esc(label)}二维码">二维码</button></div><div class="subscription-qr" hidden></div></div>`; }).join('')}
|
||||
${nodeLinks ? `<h4 style="margin:16px 0 8px">各节点入站链接</h4>${nodeLinks}` : ''}
|
||||
<div class="field-note">二维码在当前浏览器本地生成,不向第三方图片服务发送订阅令牌。Surge 只包含它原生支持的节点协议。</div>
|
||||
<div class="notice warning">订阅客户端数按客户端标识或 User-Agent 估算,不能证明物理设备数量;共享节点链接可复制,订阅请求被拒后已导入节点仍可连接。节点 IP 超限需要等待 Agent 上报和清理任务。</div>
|
||||
<div class="form-actions"><button class="danger" data-action="rotate-subscription" data-id="${esc(customer.id)}">重置订阅地址</button><button data-close>关闭</button></div></div>`);
|
||||
@@ -330,49 +381,73 @@ async function customerAccessModal(customer) {
|
||||
<div class="form-actions"><button class="danger" data-action="reset-access" data-id="${esc(customer.id)}">清空订阅访问窗口</button><button data-close>关闭</button></div></div>`);
|
||||
}
|
||||
|
||||
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 ? '编辑线路' : '新建线路', `<form id="chain-form" class="form-grid"><input type="hidden" name="resourceId" value="${esc(item ? item.id : '')}">
|
||||
<label class="wide">线路名称<input name="name" value="${esc(chain.name || '')}" placeholder="新加坡入口 → 日本出口" required></label>
|
||||
<label>线路类型<select name="topology" data-chain-sync><option value="forward"${selected('forward',chain.topology || 'forward')}>转发线路(入口 → 出口)</option><option value="direct"${selected('direct',chain.topology)}>单机直连(无需出口)</option></select></label>
|
||||
<label>网络栈<select name="networkMode"><option value="ipv4"${selected('ipv4',chain.networkMode || 'ipv4')}>IPv4</option><option value="ipv6"${selected('ipv6',chain.networkMode)}>IPv6</option><option value="dual"${selected('dual',chain.networkMode)}>双栈监听</option></select><small>IPv6 模式要求设备已填写公网 IPv6。</small></label>
|
||||
<div class="section-title">入口与客户端节点</div>
|
||||
<div class="wide picker-field"><span>入口 / 节点设备</span>${multiPicker('relayServerIds',state.servers,chain.relayServerIds,(server) => `${roleText[server.role]} · ${server.region || server.publicAddress} · ${server.publicAddress}`,'设备')}</div>
|
||||
<label>客户端入口协议<select name="relayProtocol" data-chain-sync>${protocolOptions('relay-ingress', chain.relayProtocol)}</select></label>
|
||||
<label>入口端口<div class="inline-fields"><select name="relayPortMode" data-chain-sync><option value="random"${selected('random',chain.relayPortMode)}>范围内随机</option><option value="fixed"${selected('fixed',chain.relayPortMode)}>固定端口</option></select><input name="relayPort" type="number" value="${esc(chain.relayPort || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
|
||||
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 ? '编辑节点' : '新建节点', `<form id="node-form" class="form-grid"><input type="hidden" name="resourceId" value="${esc(item ? item.id : '')}">
|
||||
<label class="wide">节点名称<input name="name" value="${esc(node.name || '')}" placeholder="新加坡入口 01" required></label>
|
||||
<label>服务器<select name="serverId" required>${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id,node.serverId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
|
||||
<label>网络栈<select name="networkMode"><option value="ipv4"${selected('ipv4',node.networkMode || 'ipv4')}>IPv4</option><option value="ipv6"${selected('ipv6',node.networkMode)}>IPv6</option><option value="dual"${selected('dual',node.networkMode)}>双栈监听</option></select><small>IPv6 模式要求设备已填写公网 IPv6。</small></label>
|
||||
<label>入口协议<select name="protocol" data-node-sync>${protocolOptions('relay-ingress', node.protocol)}</select></label>
|
||||
<label>入口端口<div class="inline-fields"><select name="portMode" data-node-sync><option value="random"${selected('random',node.portMode)}>范围内随机</option><option value="fixed"${selected('fixed',node.portMode)}>固定端口</option></select><input name="port" type="number" value="${esc(node.port || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
|
||||
<div class="notice wide" data-protocol-note></div>
|
||||
<label data-reality-only>Reality 目标预设<select name="realityPreset" data-reality-preset>${state.realityPresets.map((entry) => `<option value="${esc(entry.id)}" data-sni="${esc(entry.serverName)}" data-port="${entry.destPort}"${selected(entry.id,preset.id)}>${esc(entry.label)} · ${esc(entry.serverName)}</option>`).join('')}<option value="custom"${selected('custom',preset.id)}>自定义</option></select></label>
|
||||
<label data-reality-only>Reality SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(chain.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(chain.realityDestPort || 443)}" min="1" max="65535"></div><small>默认 Tesla。预设仅提供填写便利,不是防盗用功能;目标站点须支持所选 SNI。</small></label>
|
||||
<label data-reality-only>Reality SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(node.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(node.realityDestPort || 443)}" min="1" max="65535"></div><small>默认 Tesla。预设仅提供填写便利;目标站点须支持所选 SNI。</small></label>
|
||||
<label>拓扑<select name="topology" data-node-sync><option value="forward"${selected('forward',node.topology || 'forward')}>转发(入口 → 出口)</option><option value="direct"${selected('direct',node.topology)}>单机直连(无需出口)</option></select></label>
|
||||
<div class="section-title" data-forward-only>出口与转发传输</div>
|
||||
<label data-forward-only>出口 / 落地设备<select name="exitServerId">${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id,chain.exitServerId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
|
||||
<label data-forward-only>入口到出口协议<select name="exitProtocol" data-chain-sync>${protocolOptions('exit-transport', chain.exitProtocol)}</select><small>出口可独立选 SS / SS2022 / VLESS TCP / SOCKS5,不要求与入口同协议。</small></label>
|
||||
<label data-forward-only>出口端口<div class="inline-fields"><select name="exitPortMode" data-chain-sync><option value="random"${selected('random',chain.exitPortMode)}>范围内随机</option><option value="fixed"${selected('fixed',chain.exitPortMode)}>固定端口</option></select><input name="exitPort" type="number" value="${esc(chain.exitPort || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
|
||||
<div class="section-title">客户分配</div>
|
||||
<div class="wide picker-field"><span>客户</span>${multiPicker('customerIds',state.customers.filter((customer) => customer.status === 'active' || (chain.customerIds || []).includes(customer.id)),chain.customerIds,(customer) => `${customer.group || '未分组'}${customer.status !== 'active' ? ' · 已停用' : ''}`,'客户')}<small>批量选择多个客户时请使用随机端口。</small></div>
|
||||
<div class="notice wide">VLESS WS TLS、Hysteria 2 和 AnyTLS 需要入口证书。到入口机运行 ng-agent cert,按提示申请并同步域名;Hysteria 2 使用 UDP,其余使用 TCP。AnyTLS 由 sing-box 承载,缺少引擎时运行 ng-agent engine install。</div>
|
||||
${item && !['draft'].includes(item.status) ? '<div class="notice warning wide">保存运行中线路只会标记“待重新部署”,不会立即中断服务。确认后再点击“应用修改”。</div>' : ''}
|
||||
<div class="form-actions"><button type="button" data-close>取消</button><button class="primary" type="submit">${item ? '保存修改' : '创建线路'}</button></div></form>`);
|
||||
syncChainForm();
|
||||
<label data-forward-only>出口 / 落地设备<select name="exitServerId">${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id,node.exitServerId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
|
||||
<label data-forward-only>入口到出口协议<select name="exitProtocol" data-node-sync>${protocolOptions('exit-transport', node.exitProtocol)}</select><small>出口可独立选 SS / SS2022 / VLESS TCP / SOCKS5,不要求与入口同协议。</small></label>
|
||||
<label data-forward-only>出口端口<div class="inline-fields"><select name="exitPortMode" data-node-sync><option value="random"${selected('random',node.exitPortMode)}>范围内随机</option><option value="fixed"${selected('fixed',node.exitPortMode)}>固定端口</option></select><input name="exitPort" type="number" value="${esc(node.exitPort || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
|
||||
<div class="notice wide">保存运行中的节点会立即重建下发(sing-box 重启一次)。VLESS WS TLS、Hysteria 2 和 AnyTLS 需要入口证书;Hysteria 2 使用 UDP,其余使用 TCP。全部由 sing-box 承载。</div>
|
||||
<div class="form-actions"><button type="button" data-close>取消</button><button class="primary" type="submit">${item ? '保存修改' : '创建节点'}</button></div></form>`);
|
||||
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 `<tr><td><strong>${esc(customer ? customer.name : '已删除客户')}</strong><small>${esc(customer ? (customer.group || '未分组') : nc.customerId)}</small></td>
|
||||
<td>${status(nc.status)}</td>
|
||||
<td><div class="actions">${nc.clientUri ? `<button data-action="copy-uri" data-value="${esc(nc.clientUri)}">复制链接</button>` : ''}<button class="danger" data-action="unassign-customer" data-id="${esc(node.id)}" data-customer="${esc(nc.customerId)}">移除</button></div></td></tr>`;
|
||||
}).join('');
|
||||
modal('NODE CLIENTS', `${node.name} · 客户端分配`, `<div class="stack">
|
||||
<div class="notice">多位客户共用同一入口端口,各自使用独立凭据。分配 / 移除后节点自动重建下发(sing-box 重启一次)。</div>
|
||||
<div class="table-wrap"><table><thead><tr><th>客户</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows || `<tr><td colspan="3">${empty('暂无客户端','从下方选择客户分配')}</td></tr>`}</tbody></table></div>
|
||||
${available.length ? `<form id="assign-form" class="inline-fields"><input type="hidden" name="nodeId" value="${esc(node.id)}"><select name="customerId" required>${available.map((customer) => `<option value="${esc(customer.id)}">${esc(customer.name)}${customer.group ? ` · ${esc(customer.group)}` : ''}</option>`).join('')}</select><button class="primary" type="submit">分配客户</button></form>` : '<small>没有可分配的活跃客户。</small>'}
|
||||
<div class="form-actions"><button data-close>关闭</button></div></div>`);
|
||||
}
|
||||
|
||||
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 `<label class="picker-option"><input type="checkbox" name="nodeIds" value="${esc(node.id)}"${checked(node.id, [...assigned])}><span><b>${esc(node.name)}</b><small>${esc(server ? server.name : '已删除')} · ${esc(protocolName(node.protocol, 'relay-ingress'))} :${node.port} · ${esc(statusText[node.status] || node.status)}</small></span></label>`;
|
||||
}).join('');
|
||||
modal('ASSIGN NODES', `${customer.name} · 分配节点`, `<form id="assign-nodes-form" class="stack"><input type="hidden" name="customerId" value="${esc(customer.id)}">
|
||||
<div class="notice">勾选即分配,取消即移除。分配到运行中的节点会立即重建下发。</div>
|
||||
<div class="picker-options">${rows || '<span class="muted">还没有节点,先去“转发与节点”创建</span>'}</div>
|
||||
<div class="form-actions"><button type="button" data-close>取消</button><button class="primary" type="submit">保存分配</button></div></form>`);
|
||||
}
|
||||
|
||||
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 注册 / 重装命令', `<div class="stack"><div class="notice">令牌 30 分钟内有效且只能使用一次。重装会保留本机资源文件,并在 Agent 重启后自动与控制面对账。</div><div class="codebox">${esc(command)}</div><button class="primary" data-action="copy-uri" data-value="${esc(command)}">复制命令</button></div>`);
|
||||
} 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', `<div class="stack"><div class="notice warning">先停用并删除关联线路,让在线 Agent 完成资源清理。卸载会停止 NexusGate 专属服务并移除密钥与节点配置。然后在控制台删除设备登记;离线且有待清理项时使用“遗忘离线设备”。遗忘不会远程卸载机器。</div><div class="codebox">${esc(command)}</div><button data-action="copy-uri" data-value="${esc(command)}">复制命令</button><small>旧版 Agent 没有 ng-agent 命令时,使用下列兼容命令:</small><div class="codebox">${esc(fallback)}</div><button data-action="copy-uri" data-value="${esc(fallback)}">复制兼容命令</button></div>`);
|
||||
modal('AGENT MAINTENANCE', '在目标服务器 SSH 卸载 Agent', `<div class="stack"><div class="notice warning">先停用并删除关联节点,让在线 Agent 完成资源清理。卸载会停止 NexusGate 专属服务并移除密钥与节点配置。然后在控制台删除设备登记;离线且有待清理项时使用“遗忘离线设备”。遗忘不会远程卸载机器。</div><div class="codebox">${esc(command)}</div><button data-action="copy-uri" data-value="${esc(command)}">复制命令</button><small>旧版 Agent 没有 ng-agent 命令时,使用下列兼容命令:</small><div class="codebox">${esc(fallback)}</div><button data-action="copy-uri" data-value="${esc(fallback)}">复制兼容命令</button></div>`);
|
||||
}
|
||||
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; }
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
<button data-page="servers"><span class="nav-icon">◫</span><span class="nav-label">服务器</span></button>
|
||||
<button data-page="customers"><span class="nav-icon">◎</span><span class="nav-label">客户与订阅</span></button>
|
||||
<button data-page="chains"><span class="nav-icon">⇄</span><span class="nav-label">转发与节点</span></button>
|
||||
<button data-page="deployments"><span class="nav-icon">◇</span><span class="nav-label">部署与链接</span></button>
|
||||
<button data-page="commands"><span class="nav-icon">⌘</span><span class="nav-label">安装与命令</span></button>
|
||||
<p class="nav-caption nav-group">安全与系统</p>
|
||||
<button data-page="operations"><span class="nav-icon">⚙</span><span class="nav-label">设置与运维</span></button>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-44
@@ -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
|
||||
|
||||
+33
-45
@@ -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 检查原因'
|
||||
|
||||
@@ -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'
|
||||
|
||||
+7
-11
@@ -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
|
||||
|
||||
@@ -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
|
||||
+109
-29
@@ -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 <<EOF
|
||||
NODE_ENV=production
|
||||
NG_HOST=127.0.0.1
|
||||
NG_HOST=${NG_HOST}
|
||||
NG_PORT=8787
|
||||
NG_DATA_FILE=/var/lib/nexusgate/nexusgate.json
|
||||
NG_COOKIE_SECURE=true
|
||||
NG_DATA_FILE=${DATA_DIR}/nexusgate.json
|
||||
NG_COOKIE_SECURE=${NG_COOKIE_SECURE}
|
||||
NG_ADMIN_USERNAME=admin
|
||||
NG_ADMIN_PASSWORD=${admin_password}
|
||||
NG_SESSION_SECRET=${session_secret}
|
||||
EOF
|
||||
chmod 0600 /etc/nexusgate.env
|
||||
|
||||
install -m 0644 /opt/nexusgate/systemd/nexusgate.service /etc/systemd/system/nexusgate.service
|
||||
install -d -m 0755 /usr/local/sbin
|
||||
install -m 0755 /opt/nexusgate/scripts/nexusgate.sh /usr/local/sbin/nexusgate
|
||||
ln -sf /usr/local/sbin/nexusgate /usr/local/sbin/ng
|
||||
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
|
||||
fi
|
||||
|
||||
if [[ -n "$DOMAIN" ]]; then
|
||||
install -d -m 0755 /etc/caddy/Caddyfile.d
|
||||
cat > /etc/caddy/Caddyfile.d/nexusgate.caddy <<EOF
|
||||
${DOMAIN} {
|
||||
@@ -96,23 +151,48 @@ fi
|
||||
caddy fmt --overwrite /etc/caddy/Caddyfile
|
||||
caddy fmt --overwrite /etc/caddy/Caddyfile.d/nexusgate.caddy
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now nexusgate.service
|
||||
if $IS_OPENWRT; then
|
||||
/etc/init.d/nexusgate enable
|
||||
/etc/init.d/nexusgate start
|
||||
else
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now nexusgate.service
|
||||
fi
|
||||
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
|
||||
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'
|
||||
|
||||
+58
-37
@@ -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 ;;
|
||||
|
||||
@@ -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 `<div class="node-row">
|
||||
<span class="badge proto">${esc(protoLabel)}</span>
|
||||
<span class="badge net">${netLabel}</span>
|
||||
${secLabel ? `<span class="badge sec">${secLabel}</span>` : ''}
|
||||
<span class="node-name">${esc(nodeName)}</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="icon-btn" onclick="copyText('${esc(uri)}', this)" title="复制链接">📋</button>
|
||||
<button class="icon-btn" onclick="showQR('${esc(uri)}')" title="二维码">🔳</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const allUris = assignments.map((nc) => nodeClientUri(data, nc)).filter(Boolean).join('\n');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>订阅信息 - ${esc(customer.name)}</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#0f1115;color:#e8eaf0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;min-height:100vh;display:flex;justify-content:center;padding:24px 12px}
|
||||
.card{width:100%;max-width:640px;background:#161a22;border:1px solid #262c38;border-radius:16px;padding:28px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
|
||||
.card h1{font-size:20px;margin-bottom:4px;display:flex;align-items:center;gap:10px}
|
||||
.card h1 .tag{background:#2a3040;color:#9fb0d0;font-size:12px;padding:3px 10px;border-radius:6px;font-weight:400}
|
||||
.info-table{width:100%;border-collapse:collapse;margin:20px 0;border:1px solid #2a3040;border-radius:12px;overflow:hidden}
|
||||
.info-table td{padding:12px 16px;border-bottom:1px solid #232936;font-size:14px}
|
||||
.info-table tr:last-child td{border-bottom:none}
|
||||
.info-table td:first-child{color:#8b94a7;width:140px;background:#1a1f2a}
|
||||
.badge-on{background:#123a24;color:#4ade80;font-size:12px;padding:3px 12px;border-radius:6px}
|
||||
.badge-off{background:#3a1a1a;color:#f87171;font-size:12px;padding:3px 12px;border-radius:6px}
|
||||
.progress-wrap{background:#1a1f2a;border:1px solid #2a3040;border-radius:12px;padding:18px;margin:16px 0}
|
||||
.progress-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
|
||||
.progress-top .used{font-size:22px;font-weight:700}
|
||||
.progress-top .total{color:#8b94a7;font-size:14px}
|
||||
.progress-top .days{background:#1e2a4a;color:#6ea8fe;font-size:12px;padding:4px 12px;border-radius:8px}
|
||||
.progress-bar{height:10px;background:#2a3040;border-radius:5px;overflow:hidden}
|
||||
.progress-bar .fill{height:100%;background:linear-gradient(90deg,#34d399,#10b981);border-radius:5px;transition:width .3s}
|
||||
.progress-bottom{display:flex;justify-content:space-between;color:#8b94a7;font-size:13px;margin-top:8px}
|
||||
.section-title{text-align:center;color:#c8d0e0;font-size:15px;margin:24px 0 12px;position:relative}
|
||||
.section-title::before,.section-title::after{content:'';position:absolute;top:50%;width:32%;height:1px;background:#2a3040}
|
||||
.section-title::before{left:0}.section-title::after{right:0}
|
||||
.sub-row{display:flex;align-items:center;gap:10px;background:#1a1f2a;border:1px solid #2a3040;border-radius:10px;padding:12px 14px;margin-bottom:10px}
|
||||
.sub-row .sub-badge{background:#123a24;color:#4ade80;font-size:12px;font-weight:700;padding:4px 10px;border-radius:6px}
|
||||
.sub-row .sub-url{flex:1;font-size:13px;color:#c8d0e0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.icon-btn{background:#2a3040;border:none;color:#c8d0e0;width:34px;height:34px;border-radius:8px;cursor:pointer;font-size:16px}
|
||||
.icon-btn:hover{background:#354052}
|
||||
.node-row{display:flex;align-items:center;gap:8px;background:#1a1f2a;border:1px solid #2a3040;border-radius:10px;padding:12px 14px;margin-bottom:10px}
|
||||
.badge{font-size:11px;font-weight:700;padding:4px 10px;border-radius:6px}
|
||||
.badge.proto{background:#1e2a4a;color:#6ea8fe}
|
||||
.badge.net{background:#3a2f1a;color:#fbbf24}
|
||||
.badge.sec{background:#2a1a3a;color:#c084fc}
|
||||
.node-name{font-size:14px;color:#e8eaf0}
|
||||
.spacer{flex:1}
|
||||
.copy-all{display:flex;align-items:center;justify-content:space-between;background:#1a1f2a;border:1px solid #2a3040;border-radius:10px;padding:12px 14px;margin-bottom:10px;font-size:14px}
|
||||
.client-btns{display:flex;gap:16px;justify-content:center;margin-top:20px}
|
||||
.client-btns button{background:#2563eb;color:#fff;border:none;padding:12px 32px;border-radius:10px;font-size:15px;cursor:pointer}
|
||||
.client-btns button:hover{background:#1d4ed8}
|
||||
#qr-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);align-items:center;justify-content:center;z-index:100}
|
||||
#qr-modal .box{background:#fff;padding:24px;border-radius:12px;text-align:center}
|
||||
#qr-modal img{width:220px;height:220px}
|
||||
#toast{position:fixed;bottom:32px;left:50%;transform:translateX(-50%);background:#1f2937;color:#fff;padding:10px 24px;border-radius:8px;display:none;z-index:200;font-size:14px}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h1>订阅信息 <span class="tag">${esc(customer.subscriptionToken.slice(0,16))}</span></h1>
|
||||
<table class="info-table">
|
||||
<tr><td>订阅 ID</td><td>${esc(customer.subscriptionToken.slice(0,16))}</td></tr>
|
||||
<tr><td>用户</td><td>${esc(customer.name)}</td></tr>
|
||||
<tr><td>状态</td><td><span class="badge-${statusClass}">${statusText}</span></td></tr>
|
||||
<tr><td>已下载</td><td>${formatBytes(down)}</td></tr>
|
||||
<tr><td>已上传</td><td>${formatBytes(up)}</td></tr>
|
||||
<tr><td>使用情况</td><td>${formatBytes(used)}</td></tr>
|
||||
<tr><td>总配额</td><td>${total > 0 ? formatBytes(total) : '无限制'}</td></tr>
|
||||
<tr><td>剩余</td><td>${remaining >= 0 ? formatBytes(remaining) : '无限制'}</td></tr>
|
||||
<tr><td>上次在线</td><td>${esc(lastOnline)}</td></tr>
|
||||
<tr><td>到期</td><td>${esc(expiresText)}</td></tr>
|
||||
</table>
|
||||
${total > 0 ? `<div class="progress-wrap">
|
||||
<div class="progress-top"><div><span class="used">${formatBytes(used)}</span> <span class="total">/ ${formatBytes(total)}</span></div></div>
|
||||
<div class="progress-bar"><div class="fill" style="width:${pct.toFixed(1)}%"></div></div>
|
||||
<div class="progress-bottom"><span>${formatBytes(remaining)} 剩余</span><span>${pct.toFixed(1)}%</span></div>
|
||||
</div>` : ''}
|
||||
<div class="section-title">订阅信息</div>
|
||||
<div class="sub-row"><span class="sub-badge">SUB</span><span class="sub-url">${esc(subUrl)}</span>
|
||||
<button class="icon-btn" onclick="copyText('${esc(subUrl)}', this)">📋</button>
|
||||
<button class="icon-btn" onclick="showQR('${esc(subUrl)}')">🔳</button></div>
|
||||
<div class="section-title">复制链接</div>
|
||||
<div class="copy-all"><span>复制全部配置</span><button class="icon-btn" onclick="copyText(\`${esc(allUris)}\`, this)">📋</button></div>
|
||||
${nodeRows || '<p style="text-align:center;color:#8b94a7;padding:20px">暂无可用节点</p>'}
|
||||
<div class="client-btns"><button onclick="alert('请在应用商店搜索 Shadowrocket / Clash 等客户端')">📱 Android ▾</button><button onclick="alert('请在 App Store 搜索 Shadowrocket / Stash 等客户端')">🍎 iOS ▾</button></div>
|
||||
</div>
|
||||
<div id="qr-modal"><div class="box"><div id="qr-code"></div><br><button onclick="document.getElementById('qr-modal').style.display='none'" style="padding:8px 24px;border-radius:8px;border:none;background:#2563eb;color:#fff;cursor:pointer">关闭</button></div></div>
|
||||
<div id="toast"></div>
|
||||
<script>
|
||||
function copyText(t, btn){(navigator.clipboard?navigator.clipboard.writeText(t):Promise.reject()).then(()=>toast('已复制')).catch(()=>{const ta=document.createElement('textarea');ta.value=t;document.body.appendChild(ta);ta.select();try{document.execCommand('copy');toast('已复制')}catch(e){toast('复制失败')}document.body.removeChild(ta)})}
|
||||
function toast(m){const e=document.getElementById('toast');e.textContent=m;e.style.display='block';setTimeout(()=>e.style.display='none',1800)}
|
||||
function showQR(t){const m=document.getElementById('qr-modal');document.getElementById('qr-code').innerHTML='<img src="https://api.qrserver.com/v1/create-qr-code/?size=220x220&data='+encodeURIComponent(t)+'">';m.style.display='flex';m.onclick=(e)=>{if(e.target===m)m.style.display='none'}}
|
||||
</script></body></html>`;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+92
-133
@@ -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';
|
||||
|
||||
+188
-150
@@ -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);
|
||||
});
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+167
-71
@@ -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:<customerId>
|
||||
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');
|
||||
});
|
||||
|
||||
+111
-52
@@ -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 "<server PSK>:<user PSK>" 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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user