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:
Muse
2026-10-19 22:23:40 +08:00
parent 1678f766ac
commit 084afeb8f4
35 changed files with 2142 additions and 1749 deletions
-11
View File
@@ -37,14 +37,3 @@ jobs:
sudo env PATH="$PATH" NG_STATS_SOURCE="$GITHUB_WORKSPACE/agent/stats-query.go" bash scripts/agent-singbox.sh install 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 - name: AnyTLS handshake and two-way traffic counters
run: bash scripts/anytls-smoke.sh 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
+33 -22
View File
@@ -10,7 +10,7 @@
**v0.6.6 修复:** 控制面记住入口进程最近退出的计量周期,忽略重载后才到达的旧上报,避免双向流量重复扣额。Agent 在解析入口计数失败时会阻止配置重载,并保留正在运行的资源供排查;证书安装和自动续签触发核心重启前,也先尝试上报当前计数。过期的节点 IP 观察记录会随后台清理删除,避免长期堆积。Clash 线路名与内置策略组或 `DIRECT` 撞名时才添加“(节点)”区分,其余线路保持原名。 **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 份,手工备份不受影响。 **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。 - 部署前检查目标 Agent 最近心跳;即使核心服务报错,仍可下发修复资源,具体失败由任务日志返回。面板机可以兼任出口/单机节点,`ng update` 会同步升级本机已安装的 Agent。
- 任务 5 分钟租约、超时自动重试,连续 3 次失败才标记异常。 - 任务 5 分钟租约、超时自动重试,连续 3 次失败才标记异常。
- 客户到期使用日期选择器、常用期限下拉和时间下拉,不要求手写日期格式。 - 客户到期使用日期选择器、常用期限下拉和时间下拉,不要求手写日期格式。
- 客户独立凭据、流量额度、到期、滚动 IP 上限、用量清零及启停。 - 客户独立凭据、流量额度、到期、滚动 IP 上限、用量清零及启停。
- 流量、到期或节点 IP 上限触发暂停后,会移除入口和出口资源以阻断已导入的节点。调整额度、续期或提高 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 条,可为单个客户清空订阅访问窗口。 - 客户列表显示滚动节点 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 私钥只在节点机生成;控制面只接收客户端公钥。升级时旧部署错误中的误报私钥会从历史记录中脱敏。 - Reality 私钥只在节点机生成;控制面只接收客户端公钥。升级时旧部署错误中的误报私钥会从历史记录中脱敏。
- Tesla、Amazon、Apple、Intel、AMD Reality 目标预设,也可自定义。 - Tesla、Amazon、Apple、Intel、AMD Reality 目标预设,也可自定义。
- IPv4、IPv6 和双栈监听;IPv6 客户端链接会自动使用方括号格式。 - IPv4、IPv6 和双栈监听;IPv6 客户端链接会自动使用方括号格式。
@@ -69,7 +69,7 @@ flowchart TB
- Agent 支持 Debian/Ubuntu、RHEL 系 systemd,以及 Alpine OpenRC。 - Agent 支持 Debian/Ubuntu、RHEL 系 systemd,以及 Alpine OpenRC。
- Agent 访问与错误日志由 logrotate 每日检查、最多保留 7 份,达到 20 MB 也会轮转;订阅访问记录仍按控制面 30 天/全局 10000 条清理。 - Agent 访问与错误日志由 logrotate 每日检查、最多保留 7 份,达到 20 MB 也会轮转;订阅访问记录仍按控制面 30 天/全局 10000 条清理。
- 节点证书一键申请:入口机运行 `ng-agent cert`,输入域名(邮箱可选);优先选 CF DNS 验证或有公网 80 端口时选 HTTP,设置续签、检查证书并把域名同步到该设备。CF Token 在安装 Certbot DNS 插件前验证,仅 root 可读;面板部署前确认入口 Agent 已上报证书状态。 - 节点证书一键申请:入口机运行 `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 + Reality | 可部署 |
| 客户入口 / 单机节点 | VLESS + WebSocket(无 TLS) | 可部署,建议仅配合可信网络或外层 TLS | | 客户入口 / 单机节点 | VLESS + WebSocket(无 TLS) | 可部署,建议仅配合可信网络或外层 TLS |
| 客户入口 / 单机节点 | VLESS + WebSocket + TLS | 可部署;入口设备需填写 TLS 域名并安装有效证书 | | 客户入口 / 单机节点 | VLESS + WebSocket + TLS | 可部署;入口设备需填写 TLS 域名并安装有效证书 |
| 客户入口 / 单机节点 | Hysteria 2 | 可部署(测试阶段);入口证书、UDP 入站端口和新版 Xray 必需 | | 客户入口 / 单机节点 | Hysteria 2 | 可部署(测试阶段);入口证书、UDP 入站端口和 sing-box 必需 |
| 客户入口 / 单机节点 | AnyTLS | 可部署(测试阶段);入口证书、TCP 端口和带统计接口的 sing-box 必需 | | 客户入口 / 单机节点 | AnyTLS | 可部署(测试阶段);入口证书、TCP 端口和带统计接口的 sing-box 必需 |
| 客户入口 / 单机节点 | VMess + WebSocket(无 TLS) | 可部署,兼容模式 |
| 客户入口 / 单机节点 | Shadowsocks 2022 AES-128 / AES-256 | 可部署 | | 客户入口 / 单机节点 | Shadowsocks 2022 AES-128 / AES-256 | 可部署 |
| 客户入口 / 单机节点 | Shadowsocks AES-128-GCM / AES-256-GCM | 可部署 | | 客户入口 / 单机节点 | Shadowsocks AES-128-GCM / AES-256-GCM | 可部署 |
| 客户入口 / 单机节点 | SOCKS5 用户密码 | 可部署,仅建议可信网络或外层隧道 | | 客户入口 / 单机节点 | SOCKS5 用户密码 | 可部署,仅建议可信网络或外层隧道 |
@@ -89,7 +88,7 @@ flowchart TB
| 入口 → 出口 | VLESS TCP | 可部署,建议可信网络或外层隧道 | | 入口 → 出口 | VLESS TCP | 可部署,建议可信网络或外层隧道 |
| 入口 → 出口 | SOCKS5 用户密码 | 可部署,仅建议可信网络或外层隧道 | | 入口 → 出口 | 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 域名和节点证书 ### Cloudflare 域名和节点证书
@@ -110,17 +109,17 @@ Hysteria 2 复用当前 Xray Agent 的任务、统计及出口路由。AnyTLS
Reality 默认目标/SNI 为 `www.tesla.com:443`,也可选 Amazon、Apple、Intel、AMD 或自定义。目标必须实际接受所选 SNI,预设本身不是防共享或防盗用手段;访问控制依赖每客户随机 UUID、shortId 等凭据。 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。 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. 在“转发与节点”选择转发线路或单机直连,选择协议、设备、客户和端口策略。 4. 在“转发与节点”选择转发线路或单机直连,选择协议、设备、客户和端口策略。
5. 点击部署,在“部署与链接”复制单条客户端链接,或在“客户与订阅 → 订阅链接”获取各客户端订阅和二维码。 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`,生产环境建议按设备缩小范围。 > 云安全组和系统防火墙必须放行实际使用的 TCP/UDP 端口。默认端口池为 `20000–50000`,生产环境建议按设备缩小范围。
@@ -161,6 +160,18 @@ apk add --no-cache bash curl
然后运行面板生成的同一条注册命令。安装器会自动使用 OpenRC。 然后运行面板生成的同一条注册命令。安装器会自动使用 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` 分支并执行健康检查: 控制面一键升级会先自动备份,再下载 GitHub `main` 分支并执行健康检查:
@@ -180,7 +191,7 @@ curl -fsSL https://raw.githubusercontent.com/a2899882/NexusGate/main/scripts/age
```bash ```bash
ng-agent-update ng-agent-update
ng-agent uninstall # 新版 Agent 在目标机交互卸载 ng-agent uninstall # 新版 Agent 在目标机交互卸载
ng-agent doctor # 检查心跳、Xray 配置、服务及最近错误 ng-agent doctor # 检查心跳、sing-box 配置、服务及最近错误
ng-agent cert # 交互式申请、续签并同步面板 ng-agent cert # 交互式申请、续签并同步面板
ng-agent cert issue node.example.com admin@example.com ng-agent cert issue node.example.com admin@example.com
ng-agent cert issue-cloudflare node.example.com admin@example.com /root/cloudflare.ini 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 cert status node.example.com
``` ```
如果某台设备已出现“上线后又离线”,先在该机运行 `ng-agent doctor`,核对控制面连通性、Xray 配置错误和服务日志。然后更新 Agent;必要时在控制面重新生成“注册 / 重装”命令并执行。注册后首次心跳失败会明确报错。 如果某台设备已出现“上线后又离线”,先在该机运行 `ng-agent doctor`,核对控制面连通性、sing-box 配置错误和服务日志。然后更新 Agent;必要时在控制面重新生成“注册 / 重装”命令并执行。注册后首次心跳失败会明确报错。
老 Agent 没有 `ng-agent` 命令时,先运行 `ng-agent-update`,或在目标机运行: 老 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 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` 管理菜单 ## `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 | 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 的空轮询不再写盘,真实流量报告和客户订阅访问仍会持久化。 这些是部署起点,不是“每台保证 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 API Key 使用指纹快速定位,再用 scrypt 校验,避免大量心跳反复全表执行慢哈希。
- 数据文件、备份和 Agent 环境文件默认权限为 `0600`。 - 数据文件、备份和 Agent 环境文件默认权限为 `0600`。
- 登录 Cookie 为 HttpOnly、SameSite=Strict,HTTPS 下启用 Secure;写操作需要 CSRF Token。 - 登录 Cookie 为 HttpOnly、SameSite=Strict,HTTPS 下启用 Secure;写操作需要 CSRF Token。
- Xray 配置先执行 `run -test`,通过后才原子切换;失败会回滚资源文件。 - sing-box 配置先执行 `check`,通过后才原子切换;失败会回滚资源文件。
- Agent 重启会从持久化资源目录重建完整 Xray 配置。 - Agent 重启会从持久化资源目录重建完整 sing-box 配置。
- 仅在你有权管理的服务器和网络上使用,并遵守所在地法律和服务商政策。 - 仅在你有权管理的服务器和网络上使用,并遵守所在地法律和服务商政策。
更完整的说明见 [架构](docs/ARCHITECTURE.md)、[安全模型](docs/SECURITY.md) 和 [路线图](docs/ROADMAP.md)。 更完整的说明见 [架构](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
View File
@@ -3,34 +3,25 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const net = require('node:net');
const crypto = require('node:crypto');
const { spawnSync } = require('node:child_process'); const { spawnSync } = require('node:child_process');
const VERSION = '0.6.8'; const VERSION = '0.6.8';
const CONTROLLER = String(process.env.NG_CONTROLLER || '').replace(/\/+$/, ''); const CONTROLLER = String(process.env.NG_CONTROLLER || '').replace(/\/+$/, '');
const AGENT_KEY = process.env.NG_AGENT_KEY || ''; 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_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 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 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_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_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 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 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; const POLL_MS = Math.max(3, Number(process.env.NG_POLL_SECONDS || 8)) * 1000;
let lastEngineError = ''; let lastEngineError = '';
let certificateCache = { fingerprint:'', checkedAt:0, domains:[] }; 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 sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
function log(...args) { console.log(new Date().toISOString(), ...args); } function log(...args) { console.log(new Date().toISOString(), ...args); }
function safeError(value) { function safeError(value) {
@@ -71,11 +62,11 @@ function saveKeys(keys) {
function realityKey(keyId) { function realityKey(keyId) {
const keys = loadKeys(); const keys = loadKeys();
if (keys[keyId]) return keys[keyId]; if (keys[keyId]) return keys[keyId];
const result = spawnSync(XRAY_BIN, ['x25519'], { encoding: 'utf8', timeout: 15000 }); const kp = crypto.generateKeyPairSync('x25519');
if (result.status !== 0) throw new Error('xray x25519 failed; run ng-agent doctor to inspect Xray locally'); const priv = kp.privateKey.export({ format: 'jwk' });
const output = `${result.stdout}\n${result.stderr}`; const pub = kp.publicKey.export({ format: 'jwk' });
const { privateKey, publicKey } = parseX25519(output); if (!priv.d || !pub.x) throw new Error('X25519 密钥生成失败');
keys[keyId] = { privateKey, publicKey, createdAt: new Date().toISOString() }; keys[keyId] = { privateKey: priv.d, publicKey: pub.x, createdAt: new Date().toISOString() };
saveKeys(keys); saveKeys(keys);
return keys[keyId]; 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'))); 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) { // 云机器的公网 IP 通常是 NAT 地址,不在本地网卡上;直接 bind 会
if (inbound.protocol !== 'hysteria') return inbound; // "cannot assign requested address" 导致引擎起不来。非本地地址回退到对应栈的通配符。
// Older Agent resources contain only `method` and `users`. Xray 26.3.27 function normalizeInboundListen(inbound) {
// silently ignores both fields, falling back to TCP with no HY2 accounts. if (!inbound || typeof inbound !== 'object') return inbound;
// Repair the effective configuration without changing the saved resource, const text = typeof inbound.listen === 'string' ? inbound.listen.trim() : '';
// port, password or the customer's existing subscription link. if (!text || !net.isIP(text) || text === '0.0.0.0' || text === '::') return inbound;
const accounts = inbound.settings?.clients || inbound.settings?.users; let local = false;
if (!Array.isArray(accounts) || !accounts.length || accounts.some((user) => !user.auth)) for (const list of Object.values(os.networkInterfaces())) {
throw new Error('Hysteria 2 入口没有认证账户;拒绝启动未受保护的入口'); for (const info of list || []) if (info && info.address === text) { local = true; break; }
return { ...inbound, if (local) break;
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 || []));
} }
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) { function combinedSingBoxConfig(resources) {
const selected = resources.filter((item) => item.engine === 'sing-box');
return { return {
log: { level: 'info', output: SINGBOX_LOG, timestamp: true }, log: { level: 'info', output: SINGBOX_LOG, timestamp: true },
inbounds: selected.flatMap((item) => item.inbounds || []), inbounds: resources.flatMap((item) => item.inbounds || []).map(normalizeInboundListen),
outbounds: [{ type: 'direct', tag: 'ng-fallback-direct' }, ...selected.flatMap((item) => item.outbounds || [])], outbounds: [{ type: 'direct', tag: 'ng-fallback-direct' }, ...resources.flatMap((item) => item.outbounds || [])],
route: { rules: selected.flatMap((item) => item.routingRules || []), final: 'ng-fallback-direct' }, 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}`, 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; 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) { function serviceCommand(action, service) {
if (fs.existsSync('/run/systemd/system')) return run('systemctl', [action, `${service}.service`]); if (fs.existsSync('/run/systemd/system')) return run('systemctl', [action, `${service}.service`]);
return run('rc-service', [service, action]); return run('rc-service', [service, action]);
} }
function activateSingBox() { function activateSingBox() {
const resources = readResources().filter((item) => item.engine === 'sing-box'); const resources = readResources();
if (!resources.length) { if (!resources.length) {
if (fs.existsSync(SINGBOX_CONFIG)) { // 无资源时必须确保引擎停下(不能只在配置文件存在时停,否则全新安装会陷入重启循环)
serviceCommand('stop', 'nexusgate-sing-box'); try { serviceCommand('stop', 'nexusgate-sing-box'); } catch { /* 已经停了 */ }
fs.rmSync(SINGBOX_CONFIG, { force: true }); if (fs.existsSync(SINGBOX_CONFIG)) fs.rmSync(SINGBOX_CONFIG, { force: true });
}
return; return;
} }
if (!fs.existsSync(SINGBOX_BIN) || !fs.existsSync(SINGBOX_STATS_BIN)) throw new Error('sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install'); 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) { function safeResourcePath(resourceId) {
if (!/^[a-zA-Z0-9_-]{8,100}$/.test(String(resourceId))) throw new Error('Invalid resource id'); if (!/^[a-zA-Z0-9_-]{8,100}$/.test(String(resourceId))) throw new Error('Invalid resource id');
return path.join(RESOURCE_DIR, `${resourceId}.json`); return path.join(RESOURCE_DIR, `${resourceId}.json`);
} }
async function flushUsageBeforeChange(engine) { async function flushUsageBeforeChange() {
const live = readResources().some((resource) => (resource.engine || 'xray') === engine && const live = readResources().some((resource) =>
resource.meta?.metricsTag && ['relay', 'direct'].includes(resource.meta.kind)); resource.meta?.metricsTag && resource.meta.kind === 'node');
if (!live || serviceHealth(engine === 'sing-box' ? 'nexusgate-sing-box' : 'nexusgate-xray').status !== 'ready') return; if (!live || serviceHealth('nexusgate-sing-box').status !== 'ready') return;
const report = queryUsage(); 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 // Await the controller's durable write before a restart destroys the
// in-memory counters. The periodic loop can report concurrently; the // in-memory counters. The periodic loop can report concurrently; the
// controller ignores repeated and out-of-order samples for one process. // controller ignores repeated and out-of-order samples for one process.
@@ -243,50 +173,50 @@ async function flushUsageBeforeChange(engine) {
} }
async function flushAllUsageBeforeRestart() { async function flushAllUsageBeforeRestart() {
for (const engine of ['xray', 'sing-box']) await flushUsageBeforeChange(engine); await flushUsageBeforeChange();
} }
async function applyResource(payload) { async function applyResource(payload) {
if (!payload || !payload.resource || !payload.resource.id) throw new Error('Missing resource payload'); if (!payload || !payload.resource || !payload.resource.id) throw new Error('Missing resource payload');
const artifacts = {}; const artifacts = {};
const resource = materialize(payload.resource, 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 || []) { 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; 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 域名无效'); if (!/^(?:[a-z0-9-]+\.)+[a-z]{2,63}$/.test(domain || '')) throw new Error('TLS 域名无效');
const expected = `/etc/nexusgate/tls/${domain}/`; const expected = `/etc/nexusgate/tls/${domain}/`;
const certs = resource.engine === 'sing-box' const cert = { certificateFile: tls.certificate_path, keyFile: tls.key_path };
? [{ certificateFile: tls.certificate_path, keyFile: tls.key_path }] : tls.certificates || []; if (!cert.certificateFile || !cert.keyFile)
for (const cert of certs) { 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) || if (!cert.certificateFile.startsWith(expected) || !cert.keyFile.startsWith(expected) ||
!fs.existsSync(cert.certificateFile) || !fs.existsSync(cert.keyFile)) { !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`); 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}`);
} }
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 target = safeResourcePath(resource.id);
const backup = fs.existsSync(target) ? fs.readFileSync(target) : null; const backup = fs.existsSync(target) ? fs.readFileSync(target) : null;
const oldEngine = backup ? JSON.parse(backup).engine || 'xray' : null; await flushUsageBeforeChange();
const newEngine = resource.engine || 'xray';
await flushUsageBeforeChange(newEngine);
if (oldEngine && oldEngine !== newEngine) await flushUsageBeforeChange(oldEngine);
fs.writeFileSync(target, `${JSON.stringify(resource, null, 2)}\n`, { mode: 0o600 }); fs.writeFileSync(target, `${JSON.stringify(resource, null, 2)}\n`, { mode: 0o600 });
try { try {
activateResourceEngine(newEngine); activateSingBox();
if (oldEngine !== newEngine && oldEngine) activateResourceEngine(oldEngine);
lastEngineError = ''; lastEngineError = '';
} }
catch (error) { catch (error) {
if (backup) fs.writeFileSync(target, backup, { mode: 0o600 }); else fs.rmSync(target, { force: true }); if (backup) fs.writeFileSync(target, backup, { mode: 0o600 }); else fs.rmSync(target, { force: true });
try { activateResourceEngine(newEngine); } catch { /* preserve original error */ } try { activateSingBox(); } catch { /* preserve original error */ }
if (oldEngine && oldEngine !== newEngine) {
try { activateResourceEngine(oldEngine); } catch { /* preserve original error */ }
}
throw error; throw error;
} }
return { artifacts }; return { artifacts };
@@ -296,10 +226,9 @@ async function deleteResource(payload) {
const target = safeResourcePath(payload.resourceId); const target = safeResourcePath(payload.resourceId);
if (!fs.existsSync(target)) return { alreadyAbsent: true }; if (!fs.existsSync(target)) return { alreadyAbsent: true };
const backup = fs.readFileSync(target); const backup = fs.readFileSync(target);
const engine = JSON.parse(backup).engine || 'xray'; await flushUsageBeforeChange();
await flushUsageBeforeChange(engine);
fs.rmSync(target); fs.rmSync(target);
try { activateResourceEngine(engine); } try { activateSingBox(); }
catch (error) { fs.writeFileSync(target, backup, { mode: 0o600 }); throw error; } catch (error) { fs.writeFileSync(target, backup, { mode: 0o600 }); throw error; }
return { removed: true }; return { removed: true };
} }
@@ -343,28 +272,28 @@ function systemInfo() {
function engineHealth() { function engineHealth() {
const resources = readResources(); const resources = readResources();
const xray = resources.some((item) => item.engine !== 'sing-box') ? serviceHealth('nexusgate-xray') const installed = fs.existsSync(SINGBOX_BIN) && fs.existsSync(SINGBOX_STATS_BIN);
: { status:'ready', detail:'无 Xray 资源,已停止空闲进程' }; const service = serviceHealth('nexusgate-sing-box');
if (xray.status === 'ready') { 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 { try {
const missing = missingHysteriaListeners(resources); const missing = missingHysteriaListeners(resources);
if (missing.length) { if (missing.length) {
xray.status = 'error'; result.status = 'error';
xray.detail = `Hysteria 2 UDP 未监听:${missing.join(', ')}`; result.detail = `Hysteria 2 UDP 未监听:${missing.join(', ')}`;
} }
} catch (error) { } catch (error) {
xray.status = 'error'; result.status = 'error';
xray.detail = safeError(error.message); result.detail = safeError(error.message);
} }
} }
xray.singBoxInstalled = fs.existsSync(SINGBOX_BIN) && fs.existsSync(SINGBOX_STATS_BIN); return result;
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;
} }
function installedCertificates() { function installedCertificates() {
@@ -414,16 +343,18 @@ async function heartbeat() {
catch (error) { log('Heartbeat reached controller but readiness file failed', error.message); } catch (error) { log('Heartbeat reached controller but readiness file failed', error.message); }
} }
function parseUsageStats(output, resources) { function parseUsageStats(output) {
const parsed = JSON.parse(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 格式无效'); throw new Error('统计接口返回的 JSON stat 格式无效');
const expected = new Set(resources.flatMap((resource) => ['uplink', 'downlink'] const inbounds = new Map(), users = new Map();
.map((direction) => `inbound>>>${resource.meta.metricsTag}>>>traffic>>>${direction}`))); for (const stat of statList) {
const byName = new Map(); const inboundMatch = /^inbound>>>(.+)>>>traffic>>>(uplink|downlink)$/.exec(stat.name || '');
for (const stat of parsed.stat || []) { const userMatch = inboundMatch ? null : /^user>>>(.+)>>>traffic>>>(uplink|downlink)$/.exec(stat.name || '');
if (!expected.has(stat.name)) continue; if (!inboundMatch && !userMatch) continue;
// Protobuf JSON omits a scalar with its default value (zero). Xray can // Protobuf JSON omits a scalar with its default value (zero). The engine can
// therefore return a named counter without a `value` field until traffic. // therefore return a named counter without a `value` field until traffic.
const value = Object.hasOwn(stat, 'value') ? Number(stat.value) : 0; const value = Object.hasOwn(stat, 'value') ? Number(stat.value) : 0;
if (!Number.isSafeInteger(value) || value < 0 || stat.value === null || stat.value === '') { 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}`; ? (stat.value.startsWith('-') ? '负数' : '超出安全整数范围') : `格式 ${typeof stat.value}`;
throw new Error(`入口计数异常(${reason}):${stat.name};请运行 ng-agent doctor 查看原始统计`); 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) => { return {
const prefix = `inbound>>>${resource.meta.metricsTag}>>>traffic>>>`; inbounds: [...inbounds].map(([tag, counters]) => ({ tag, ...counters })),
if (!byName.has(`${prefix}uplink`) && !byName.has(`${prefix}downlink`)) return []; users: [...users].map(([name, counters]) => ({ name, ...counters })),
return [{ resourceId: resource.id, uplink: byName.get(`${prefix}uplink`) || 0, downlink: byName.get(`${prefix}downlink`) || 0 }]; };
});
} }
function serviceEpoch(service = 'nexusgate-xray') { function serviceEpoch(service = 'nexusgate-sing-box') {
let pid; let pid;
if (fs.existsSync('/run/systemd/system')) { if (fs.existsSync('/run/systemd/system')) {
const result = spawnSync('systemctl', ['show', '-p', 'MainPID', '--value', `${service}.service`], { encoding:'utf8', timeout:3000 }); 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; if (!Number.isSafeInteger(pid) || pid <= 0) return null;
try { 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 stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
const startTick = stat.slice(stat.lastIndexOf(') ') + 2).trim().split(/\s+/)[19]; const startTick = stat.slice(stat.lastIndexOf(') ') + 2).trim().split(/\s+/)[19];
return startTick ? `${pid}:${startTick}` : null; return startTick ? `${pid}:${startTick}` : null;
@@ -478,12 +413,11 @@ function udpPortsForPid(pid) {
} }
function missingHysteriaListeners(resources) { function missingHysteriaListeners(resources) {
const expected = [...new Set(resources.filter((item) => item.engine !== 'sing-box') const expected = [...new Set(resources.flatMap((item) => item.inbounds || [])
.flatMap((item) => item.inbounds || []).filter((item) => item.protocol === 'hysteria') .filter((item) => item.type === 'hysteria2').map((item) => item.listen_port))];
.map((item) => item.port))];
if (!expected.length) return []; if (!expected.length) return [];
const epoch = serviceEpoch(); const epoch = serviceEpoch('nexusgate-sing-box');
if (!epoch) throw new Error('无法确认 Xray 进程,无法验证 Hysteria 2 UDP 监听'); if (!epoch) throw new Error('无法确认 sing-box 进程,无法验证 Hysteria 2 UDP 监听');
const actual = udpPortsForPid(Number(epoch.split(':')[0])); const actual = udpPortsForPid(Number(epoch.split(':')[0]));
return expected.filter((port) => !actual.has(port)); return expected.filter((port) => !actual.has(port));
} }
@@ -498,33 +432,45 @@ function assertHysteriaListeners(resources) {
if (attempt < 9) spawnSync('sleep', ['0.2']); if (attempt < 9) spawnSync('sleep', ['0.2']);
} }
const missing = missingHysteriaListeners(resources); 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() { function queryUsage() {
// Count client ingress once; counting the exit as well doubles forwarded traffic. // Count client ingress once; counting the exit as well doubles forwarded traffic.
const resources = readResources().filter((resource) => resource.meta && resource.meta.metricsTag && const resources = readResources().filter((resource) => resource.meta && resource.meta.metricsTag &&
['relay', 'direct'].includes(resource.meta.kind)); resource.meta.kind === 'node');
const samples = [], errors = [], engineErrors = []; const samples = [], errors = [], engineErrors = [];
for (const engine of ['xray', 'sing-box']) { if (!resources.length) return { samples, error: null, engineErrors };
const selected = resources.filter((item) => (item.engine || 'xray') === engine); const service = 'nexusgate-sing-box';
if (!selected.length) continue; const before = serviceEpoch(service);
const service = engine === 'xray' ? 'nexusgate-xray' : 'nexusgate-sing-box'; const port = process.env.NG_SINGBOX_API_PORT || 10086;
const before = serviceEpoch(service); const result = spawnSync(SINGBOX_STATS_BIN, [`--server=127.0.0.1:${port}`], { encoding: 'utf8', timeout: 15000 });
const port = engine === 'xray' ? (process.env.NG_XRAY_API_PORT || 10085) : (process.env.NG_SINGBOX_API_PORT || 10086); const after = serviceEpoch(service);
const binary = engine === 'xray' ? XRAY_BIN : SINGBOX_STATS_BIN; let failed = false;
const args = engine === 'xray' ? ['api', 'statsquery', `--server=127.0.0.1:${port}`, '-pattern', 'inbound>>>'] if (before && after && before !== after) { errors.push('sing-box restarted during statistics query'); engineErrors.push('sing-box'); failed = true; }
: [`--server=127.0.0.1:${port}`]; 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 result = spawnSync(binary, args, const parsed = { inbounds: [], users: [] };
{ encoding: 'utf8', timeout: 15000 }); if (!failed) {
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; }
try { try {
const parsed = parseUsageStats(result.stdout, selected); const part = parseUsageStats(result.stdout);
samples.push(...parsed.map((item) => ({ ...item, epoch: `${engine}:${before || after || 'unknown'}` }))); parsed.inbounds.push(...part.inbounds);
if (!parsed.length) errors.push(`${engine} 入口尚无统计记录`); parsed.users.push(...part.users);
} catch (error) { errors.push(`${engine}: ${safeError(error.message)}`); engineErrors.push(engine); } } 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 }; 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); } try { fs.readSync(fd, buffer, 0, buffer.length, start); } finally { fs.closeSync(fd); }
const end = buffer.lastIndexOf(10); const end = buffer.lastIndexOf(10);
if (end < 0) return { observations: [], offset: null }; if (end < 0) return { observations: [], offset: null };
const tagCustomers = new Map(readResources().filter((item) => item.engine === 'sing-box' && // AnyTLS 用户名本身就是 ng:<customerId>,身份直接写在日志行里,无需 tag 映射。
['relay', 'direct'].includes(item.meta?.kind)).map((item) => [item.meta.metricsTag, item.meta.customerId])); const entryTags = new Set(readResources().filter((item) =>
item.meta?.kind === 'node').map((item) => item.meta.metricsTag));
const sourceById = new Map(), unique = new Map(); const sourceById = new Map(), unique = new Map();
for (const line of buffer.subarray(0, end).toString('utf8').split('\n')) { for (const line of buffer.subarray(0, end).toString('utf8').split('\n')) {
const context = line.match(/\[(\d+)\s+\d+ms\]\s+inbound\/anytls\[([^\]]+)\]:\s+(.*)$/); 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 key = `${context[2]}:${context[1]}`;
const source = context[3].match(/inbound connection from\s+(\[[^\]]+\]|[\d.]+):\d+/); const source = context[3].match(/inbound connection from\s+(\[[^\]]+\]|[\d.]+):\d+/);
if (source) sourceById.set(key, source[1].replace(/^\[|\]$/g, '')); if (source) sourceById.set(key, source[1].replace(/^\[|\]$/g, ''));
// Only count authenticated AnyTLS sessions, never unauthenticated port probes. // Only count authenticated AnyTLS sessions, never unauthenticated port probes.
if (/\[ng:[^\]]+\] inbound connection to /.test(context[3]) && sourceById.has(key)) { const authed = context[3].match(/\[ng:([^\]]+)\] inbound connection to /);
const customerId = tagCustomers.get(context[2]); if (authed && sourceById.has(key)) {
if (context[3].includes(`[ng:${customerId}]`)) { const customerId = authed[1];
const ip = sourceById.get(key); const ip = sourceById.get(key);
unique.set(`${customerId}|${ip}`, { customerId, ip }); unique.set(`${customerId}|${ip}`, { customerId, ip });
}
} }
} }
return { observations: [...unique.values()], offset: start + end + 1 }; 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() { async function usageLoop() {
while (true) { while (true) {
await sleep(60000); await sleep(60000);
@@ -605,12 +519,9 @@ async function usageLoop() {
await request('/api/agent/usage', { method: 'POST', body: JSON.stringify(report) }); await request('/api/agent/usage', { method: 'POST', body: JSON.stringify(report) });
} catch (error) { log('Usage report failed', safeError(error.message)); } } catch (error) { log('Usage report failed', safeError(error.message)); }
try { 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(); const sing = readSingBoxObservations();
if (sing.observations.length) await request('/api/agent/observations', { method: 'POST', body: JSON.stringify({ observations: sing.observations }) }); 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 }); 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)); } } catch (error) { log('IP observation report failed', safeError(error.message)); }
} }
@@ -632,15 +543,6 @@ async function main() {
log(`NexusGate Agent v${VERSION} starting`); log(`NexusGate Agent v${VERSION} starting`);
// Keep the control channel alive even when a stale node configuration cannot start. // 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. // 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 { 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); } try { await heartbeat(); } catch (error) { log('Initial heartbeat failed', error.message); }
setInterval(() => heartbeat().catch((error) => log('Heartbeat failed', error.message)), 30000).unref(); 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; }); action.catch((error) => { console.error(safeError(error.message)); process.exitCode = 1; });
} }
module.exports = { parseX25519, activateConfig, activateSingBox, combinedConfig, combinedSingBoxConfig, applyResource, module.exports = { activateSingBox, combinedSingBoxConfig, applyResource, realityKey,
readObservations, readSingBoxObservations, parseUsageStats, queryUsage, installedCertificates, readSingBoxObservations, parseUsageStats, queryUsage, installedCertificates,
udpPortsForPid, missingHysteriaListeners, systemInfo }; udpPortsForPid, missingHysteriaListeners, systemInfo };
+3 -1
View File
@@ -24,8 +24,10 @@ func main() {
if err != nil { fail(err) } if err != nil { fail(err) }
defer conn.Close() defer conn.Close()
var response v2rayapi.QueryStatsResponse 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", 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) } if err != nil { fail(err) }
result := struct { Stat []struct { Name string `json:"name"`; Value string `json:"value"` } `json:"stat"` }{} result := struct { Stat []struct { Name string `json:"name"`; Value string `json:"value"` } `json:"stat"` }{}
for _, stat := range response.Stat { for _, stat := range response.Stat {
+3 -3
View File
@@ -6,8 +6,8 @@
2. 编排器为每位客户生成独立凭据,并从各服务器端口池分配端口。 2. 编排器为每位客户生成独立凭据,并从各服务器端口池分配端口。
3. 转发线路为每位客户生成一个出口资源和一个或多个入口资源;单机直连只生成入口资源。 3. 转发线路为每位客户生成一个出口资源和一个或多个入口资源;单机直连只生成入口资源。
4. Agent 通过 HTTPS 主动轮询任务,控制面无需登录目标机。 4. Agent 通过 HTTPS 主动轮询任务,控制面无需登录目标机。
5. Agent 把资源片段写入 `/etc/nexusgate/xray/resources/`,合并为完整配置。 5. Agent 把资源片段写入 `/etc/nexusgate/agent/resources/`,合并为完整 sing-box 配置。
6. Agent 运行 `xray run -test`;只有通过校验才替换生效配置并重启 Xray。 6. Agent 运行 `sing-box check`;只有通过校验才替换生效配置并重启 sing-box。
7. Agent 回报 Reality 公钥、任务结果、心跳、流量增量和 IP 观察。 7. Agent 回报 Reality 公钥、任务结果、心跳、流量增量和 IP 观察。
## 数据路径 ## 数据路径
@@ -37,7 +37,7 @@ flowchart LR
## 故障行为 ## 故障行为
- 新配置校验失败:删除或恢复刚修改的资源片段,不切换运行配置。 - 新配置校验失败:删除或恢复刚修改的资源片段,不切换运行配置。
- Xray 重启失败:恢复上一份完整配置并再次尝试启动。 - sing-box 重启失败:恢复上一份完整配置并再次尝试启动。
- Agent 离线:控制面保留任务;运行任务租约超时后自动重试,最多 3 次。 - Agent 离线:控制面保留任务;运行任务租约超时后自动重试,最多 3 次。
- Agent 重新注册:撤销旧密钥,为该设备重新生成已有部署的对账任务。 - Agent 重新注册:撤销旧密钥,为该设备重新生成已有部署的对账任务。
- 任务失败:部署标记为失败、线路标记为异常,可只修复失败项或完整重建。 - 任务失败:部署标记为失败、线路标记为异常,可只修复失败项或完整重建。
+3 -3
View File
@@ -22,7 +22,7 @@
## v0.4 — 订阅、诊断与 TLS(已完成) ## v0.4 — 订阅、诊断与 TLS(已完成)
- Agent 心跳与 Xray 启动故障隔离;首次心跳验证、`ng-agent doctor`、部署前在线检查 - Agent 心跳与 sing-box 启动故障隔离;首次心跳验证、`ng-agent doctor`、部署前在线检查
- 紧凑管理列表与本地二维码;Mihomo 智能分流和 Surge 兼容节点输出 - 紧凑管理列表与本地二维码;Mihomo 智能分流和 Surge 兼容节点输出
- VLESS WS TLS:节点域名、证书签发或导入、自动续签钩子和下发前验证 - VLESS WS TLS:节点域名、证书签发或导入、自动续签钩子和下发前验证
@@ -31,7 +31,7 @@
- sing-box 独立服务及 `with_v2ray_api` 构建安装器 - sing-box 独立服务及 `with_v2ray_api` 构建安装器
- AnyTLS 直连、AnyTLS → VLESS TCP / SS2022 等转发及客户端订阅 - AnyTLS 直连、AnyTLS → VLESS TCP / SS2022 等转发及客户端订阅
- TLS 证书校验与续签、独立双向计数和经认证来源 IP 观察 - TLS 证书校验与续签、独立双向计数和经认证来源 IP 观察
- Xray 与 sing-box 分别下发及失败回滚,原资源保留 - sing-box 下发及失败回滚,原资源保留
## 后续:节点协议、凭据和规模 ## 后续:节点协议、凭据和规模
@@ -39,7 +39,7 @@
- 可编辑的自定义订阅模板 - 可编辑的自定义订阅模板
- Hysteria2 和 AnyTLS 的真实客户端兼容性与跨发行版压力测试 - Hysteria2 和 AnyTLS 的真实客户端兼容性与跨发行版压力测试
- VLESS XHTTP/gRPC、Trojan、TUIC 等协议适配器 - VLESS XHTTP/gRPC、Trojan、TUIC 等协议适配器
- Xray/sing-box Release 校验和验证与分批升级 - sing-box Release 校验和验证与分批升级
- 控制面敏感字段静态加密 - 控制面敏感字段静态加密
- 出口池、延迟/可用性探测、加权分流与故障转移 - 出口池、延迟/可用性探测、加权分流与故障转移
+4 -4
View File
@@ -4,7 +4,7 @@
- 控制面管理员是高权限主体,可以查看客户链接并下发配置。 - 控制面管理员是高权限主体,可以查看客户链接并下发配置。
- Agent 拥有其绑定服务器的配置权限,但不能领取其他服务器任务。 - Agent 拥有其绑定服务器的配置权限,但不能领取其他服务器任务。
- Xray-core 与 sing-box 分别运行;控制面不承载客户数据流量。 - sing-box 独立运行;控制面不承载客户数据流量。
- 服务器 root 用户仍可读取本机凭据和流量日志,这是自托管系统无法消除的权限边界。 - 服务器 root 用户仍可读取本机凭据和流量日志,这是自托管系统无法消除的权限边界。
## 已实现控制 ## 已实现控制
@@ -24,12 +24,12 @@
## 当前限制 ## 当前限制
- 客户协议凭据和待执行配置任务保存在权限为 `0600` 的控制面数据文件中,尚未做字段级静态加密。备份同样包含这些凭据。 - 客户协议凭据和待执行配置任务保存在权限为 `0600` 的控制面数据文件中,尚未做字段级静态加密。备份同样包含这些凭据。
- 节点 IP 上限依赖 Xray 访问日志及 sing-box 已认证会话日志的滚动观察,并非精确的同时在线连接数;修改日志格式、关闭访问日志、UDP-only 流量或客户端经过同一 NAT 会影响结果。 - 节点 IP 上限依赖 sing-box 已认证会话日志的滚动观察,并非精确的同时在线连接数;修改日志格式、关闭日志、UDP-only 流量或客户端经过同一 NAT 会影响结果。
- 设备上限按订阅请求的 `X-Device-ID`、`X-Client-ID` 或 User-Agent 估计,只拒绝新的订阅下载。已导入的节点凭据可以继续使用,客户端也可自行更换标识;尚无每设备凭据与撤销机制。 - 设备上限按订阅请求的 `X-Device-ID`、`X-Client-ID` 或 User-Agent 估计,只拒绝新的订阅下载。已导入的节点凭据可以继续使用,客户端也可自行更换标识;尚无每设备凭据与撤销机制。
- 订阅令牌是高熵随机值,但控制面数据/备份中为便于管理员查看而保存明文;必须保护备份及管理员会话。重置订阅地址不会撤销客户已经导入的节点凭据。 - 订阅令牌是高熵随机值,但控制面数据/备份中为便于管理员查看而保存明文;必须保护备份及管理员会话。重置订阅地址不会撤销客户已经导入的节点凭据。
- IP/流量限制依赖 Agent 周期性日志与上报,不能作为瞬时硬限制;共享 NAT、动态 IP 会影响统计。需要严格设备隔离时应给每台设备发不同凭据。 - IP/流量限制依赖 Agent 周期性日志与上报,不能作为瞬时硬限制;共享 NAT、动态 IP 会影响统计。需要严格设备隔离时应给每台设备发不同凭据。
- Agent 到控制面依赖公开 CA 的 HTTPS 校验,尚未增加双向 TLS。 - Agent 到控制面依赖公开 CA 的 HTTPS 校验,尚未增加双向 TLS。
- Xray Release 下载目前依赖 GitHub HTTPS,尚未校验发布包的签名或预期哈希。 - sing-box 构建产物目前随发布包分发;未来如改走 Release 下载,需校验发布包的签名或预期哈希。
## 部署建议 ## 部署建议
@@ -38,4 +38,4 @@
- 缩小服务器端口池,并仅在云安全组/防火墙中放行实际需要的 TCP/UDP 范围。 - 缩小服务器端口池,并仅在云安全组/防火墙中放行实际需要的 TCP/UDP 范围。
- 定期离线保存 `ng backup` 生成的文件,并对备份再次加密。 - 定期离线保存 `ng backup` 生成的文件,并对备份再次加密。
- 不要在聊天、工单或公开仓库中粘贴注册令牌、Agent Key、备份或客户链接。 - 不要在聊天、工单或公开仓库中粘贴注册令牌、Agent Key、备份或客户链接。
- 首次上线前在测试链路验证 Xray 版本、Reality SNI、UDP 和回滚行为。 - 首次上线前在测试链路验证 sing-box 版本、Reality SNI、UDP 和回滚行为。
+316 -302
View File
@@ -3,8 +3,9 @@
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { redactSecrets } = require('./redact'); const { redactSecrets } = require('./redact');
const { const {
newCredentialSet, buildExitResource, buildRelayResource, buildDirectResource, buildClientUri, buildNodeResource, buildNodeExitResource, nodeEmail,
validateEntryProtocol, validateProtocolPair, isRealityProtocol newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, resolveBindAddress,
SHADOWSOCKS_METHODS,
} = require('./protocols'); } = require('./protocols');
const nowIso = () => new Date().toISOString(); const nowIso = () => new Date().toISOString();
@@ -19,31 +20,42 @@ function assertFound(value, label) {
return value; 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) { function allocatePort(data, server) {
const start = Number(server.portRangeStart || 20000); const start = Number(server.portRangeStart || 20000);
const end = Number(server.portRangeEnd || 50000); const end = Number(server.portRangeEnd || 50000);
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1024 || end > 65535 || start > end) throw new Error(`服务器 ${server.name} 的端口范围无效`); 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 span = end - start + 1;
const offset = crypto.randomInt(span); const offset = crypto.randomInt(span);
for (let i = 0; i < span; i += 1) { for (let i = 0; i < span; i += 1) {
const candidate = start + ((offset + i) % span); 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} 没有可用端口`); 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); if (mode !== 'fixed' || !requested) return allocatePort(data, server);
const port = Number(requested); const port = Number(requested);
const conflict = data.deployments.some((item) => item.serverId === server.id && item.status !== 'deleted' && Number(item.port) === port); if (!Number.isInteger(port) || port < 1024 || port > 65535) { const error = new Error(`服务器 ${server.name} 的固定端口无效`); error.statusCode = 400; throw error; }
if (conflict) { const error = new Error(`服务器 ${server.name} 的端口 ${port} 已被占用`); error.statusCode = 409; throw error; } if (nodePortInUse(data, server.id, port, excludeNodeId)) { const error = new Error(`服务器 ${server.name} 的端口 ${port} 已被占用`); error.statusCode = 409; throw error; }
return port; return port;
} }
function queueJob(data, serverId, deploymentId, action, payload) { function queueJob(data, serverId, nodeId, action, payload) {
const job = { 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 createdAt: nowIso(), updatedAt: nowIso(), error: null, leaseUntil: null
}; };
data.jobs.push(job); data.jobs.push(job);
@@ -55,263 +67,287 @@ function audit(data, actor, action, target, detail = {}) {
data.activity = data.activity.slice(0, 2000); data.activity = data.activity.slice(0, 2000);
} }
function tagFor(deployment) { function activeNodeClients(data, node) {
return `ng-${deployment.id.replace(/[^a-zA-Z0-9]/g, '').slice(-18)}`; return data.nodeClients.filter((nc) => nc.nodeId === node.id && nc.status === 'active');
} }
function currentDeployments(data, chain) { async function resourceForNode(data, node, clients) {
const all = data.deployments.filter((item) => item.chainId === chain.id); const server = assertFound(data.servers.find((s) => s.id === node.serverId), '服务器');
if (!chain.generation) return all; const networkMode = node.networkMode || 'ipv4';
return all.filter((item) => Number(item.generation || 0) === Number(chain.generation)); const bindAddress = await resolveBindAddress(networkMode, server);
} const exitServer = forwardNode(node)
? assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器')
function suspendCustomerResources(data, customer, actor = 'system') { : null;
let count = 0; return buildNodeResource({
for (const deployment of data.deployments.filter((item) => item.customerId === customer.id && !item.archived && resourceId: node.resourceId, tagPrefix: tagForNode(node), node,
item.status !== 'deleted' && (item.status !== 'removing' || item.suspendedByCustomer))) { nodeClients: clients || activeNodeClients(data, node), server, bindAddress, exitServer,
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
}); });
} }
function createEntryDeployment(data, chain, customer, entryServer, credentials, exitDeployment) { async function resourceForNodeExit(data, node) {
const port = selectPort(data, entryServer, chain.relayPortMode, chain.relayPort); const server = assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器');
const direct = (chain.topology || 'forward') === 'direct'; const networkMode = node.networkMode || 'ipv4';
const deployment = { const bindAddress = await resolveBindAddress(networkMode, server);
id: id('dep'), chainId: chain.id, generation: chain.generation, customerId: customer.id, return buildNodeExitResource({
serverId: entryServer.id, role: direct ? 'direct' : 'relay', protocol: chain.relayProtocol, resourceId: node.exitResourceId, tagPrefix: tagForNode(node), node, server, bindAddress,
port, status: 'queued', resourceId: id('res'), credentials, });
...(exitDeployment ? { exitDeploymentId: exitDeployment.id } : {}), }
createdAt: nowIso(), updatedAt: nowIso(), clientUri: null
}; function cancelQueuedDeletes(data, nodeId, reason) {
if (isRealityProtocol(chain.relayProtocol)) { let cancelled = false;
deployment.clientTemplate = { reality: { for (const job of data.jobs.filter((item) => item.nodeId === nodeId && item.action === 'delete_resource' && item.status === 'queued')) {
keyId: `reality-${deployment.id}`, serverName: chain.realityServerName || 'www.tesla.com', job.status = 'failed'; job.error = reason; job.updatedAt = nowIso(); cancelled = true;
destPort: Number(chain.realityDestPort || 443)
} };
} }
data.deployments.push(deployment); return cancelled;
queueJob(data, entryServer.id, deployment.id, 'apply_resource', { resource: resourceForDeployment(data, deployment) }); }
return deployment;
// 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 { class Orchestrator {
constructor(store) { this.store = store; } constructor(store) { this.store = store; }
async restoreChain(chainId, actor = 'admin') { async deployNode(nodeId, actor = 'admin') {
return this.store.transaction((data) => { return this.store.transaction(async (data) => {
const chain = assertFound(data.chains.find((item) => item.id === chainId), '线路'); const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const old = currentDeployments(data, chain).filter((item) => !item.archived); validateNodeDeploy(data, node);
if (!old.length || !old.every((item) => ['deleted','suspended'].includes(item.status))) { await rebuildNode(data, node, { includeExit: true });
const error = new Error('没有可恢复的原节点,请使用部署或等待当前任务完成'); error.statusCode = 409; throw error; audit(data, actor, 'deploy_node', node.id, { name: node.name });
} return structuredClone(node);
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 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) => { return this.store.transaction((data) => {
const chain = assertFound(data.chains.find((item) => item.id === chainId), '线路'); const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const direct = (chain.topology || 'forward') === 'direct'; node.pendingDelete = deleteRecord;
if (direct) validateEntryProtocol(chain.relayProtocol); node.autoRemoving = false;
else validateProtocolPair(chain.relayProtocol, chain.exitProtocol); if (node.status !== 'draft') {
const entryServers = chain.relayServerIds.map((serverId) => assertFound(data.servers.find((item) => item.id === serverId), '入口服务器')); queueJob(data, node.serverId, node.id, 'delete_resource', { resourceId: node.resourceId, kind: 'node' });
if (['vless-ws-tls', 'hysteria2', 'anytls'].includes(chain.relayProtocol)) { if (forwardNode(node) && node.exitResourceId) {
const missing = entryServers.filter((server) => !server.tlsDomain); queueJob(data, node.exitServerId, node.id, 'delete_resource', { resourceId: node.exitResourceId, kind: 'node-exit' });
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;
} }
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), '出口服务器'); node.updatedAt = nowIso();
if (chain.relayProtocol === 'anytls') { audit(data, actor, deleteRecord ? 'delete_node' : 'remove_node', node.id, { name: node.name });
const outdated = entryServers.filter((server) => { return true;
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);
}); });
} }
async removeChain(chainId, actor = 'admin', redeploy = false) { async repairNode(nodeId, actor = 'admin') {
return this.store.transaction((data) => { return this.store.transaction(async (data) => {
const chain = assertFound(data.chains.find((item) => item.id === chainId), '线路'); const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const deployments = data.deployments.filter((item) => item.chainId === chainId && item.status !== 'deleted'); if (!['failed', 'degraded'].includes(node.status)) { const error = new Error('节点状态正常,无需修复'); error.statusCode = 409; throw error; }
for (const deployment of deployments) { await rebuildNode(data, node, { includeExit: true });
deployment.suspendedByCustomer = false; audit(data, actor, 'repair_node', node.id, { name: node.name });
const existing = data.jobs.some((job) => job.deploymentId === deployment.id && job.action === 'delete_resource' && ['queued', 'running'].includes(job.status)); return true;
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 repairChain(chainId, actor = 'admin') { async assignCustomer(nodeId, customerId, actor = 'admin') {
return this.store.transaction((data) => { return this.store.transaction(async (data) => {
const chain = assertFound(data.chains.find((item) => item.id === chainId), '线路'); const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const failed = currentDeployments(data, chain).filter((item) => item.status === 'failed'); const customer = assertFound(data.customers.find((c) => c.id === customerId), '客户');
if (!failed.length) { const error = new Error('当前线路没有可修复的失败部署'); error.statusCode = 409; throw error; } if (customer.status !== 'active') { const error = new Error('只能给状态正常的客户分配节点'); error.statusCode = 409; throw error; }
for (const deployment of failed) { let nc = data.nodeClients.find((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
queueJob(data, deployment.serverId, deployment.id, 'apply_resource', { resource: resourceForDeployment(data, deployment) }); if (nc && nc.status === 'active') { const error = new Error('该客户已分配到此节点'); error.statusCode = 409; throw error; }
deployment.status = 'queued'; deployment.error = null; deployment.updatedAt = nowIso(); 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(); // A manually undeployed node stays draft until explicitly deployed.
audit(data, actor, 'repair_route', chain.id, { deployments: failed.length }); // Automatic teardowns (suspend) are revived when clients return.
return failed.length; 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') { 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), '服务器'); 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))) { 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(); job.status = 'failed'; job.error = 'Agent 重新注册,旧任务已由对账任务替代'; job.updatedAt = nowIso();
} }
let count = 0; let count = 0;
for (const deployment of data.deployments.filter((item) => item.serverId === serverId && item.status !== 'deleted')) { for (const node of data.nodes.filter((n) => n.serverId === serverId || n.exitServerId === serverId)) {
const removing = deployment.archived || deployment.status === 'removing'; const isEntry = node.serverId === serverId;
if (removing) queueJob(data, serverId, deployment.id, 'delete_resource', { resourceId: deployment.resourceId }); const isExit = node.exitServerId === serverId && forwardNode(node);
else queueJob(data, serverId, deployment.id, 'apply_resource', { resource: resourceForDeployment(data, deployment) }); if (!isEntry && !isExit) continue;
deployment.status = removing ? 'removing' : 'queued'; deployment.updatedAt = nowIso(); count += 1; 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; return count;
}); });
} }
@@ -325,49 +361,29 @@ class Orchestrator {
} }
job.status = success ? 'completed' : 'failed'; job.updatedAt = nowIso(); job.completedAt = nowIso(); job.leaseUntil = null; 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); job.error = success ? null : redactSecrets(errorMessage || 'Agent operation failed').slice(0, 1800);
const deployment = data.deployments.find((item) => item.id === job.deploymentId); const node = data.nodes.find((n) => n.id === job.nodeId);
if (!deployment) return structuredClone(job); if (!node) return structuredClone(job);
const chain = data.chains.find((item) => item.id === deployment.chainId); 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' && (deployment.archived || !chain || if (success && job.action === 'apply_resource') {
['removing','redeploying'].includes(chain.status) || (deployment.status === 'removing' && !deployment.suspendedByCustomer))) { if (kind === 'node-exit') node.exitReady = true;
const cleanupQueued = data.jobs.some((item) => item.deploymentId === deployment.id && item.action === 'delete_resource' && ['queued', 'running'].includes(item.status)); else { node.entryReady = true; node.artifacts = result.artifacts || {}; }
if (!cleanupQueued) queueJob(data, deployment.serverId, deployment.id, 'delete_resource', { resourceId: deployment.resourceId }); deriveNodeStatus(node);
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;
}
} else if (success && job.action === 'delete_resource') { } else if (success && job.action === 'delete_resource') {
const restoring = data.jobs.some((item) => item.deploymentId === deployment.id && item.action === 'apply_resource' && item.status === 'queued'); if (kind === 'node-exit') node.exitReady = false; else node.entryReady = false;
deployment.status = restoring ? 'queued' : deployment.suspendedByCustomer && chain && !deployment.archived ? 'suspended' : 'deleted'; const pending = data.jobs.some((item) => item.nodeId === node.id && item.action === 'delete_resource' && ['queued', 'running'].includes(item.status));
} if (!pending && node.status === 'removing') {
else if (!success) { deployment.status = 'failed'; deployment.error = job.error; } if (node.pendingDelete) {
deployment.updatedAt = nowIso(); data.nodes = data.nodes.filter((n) => n.id !== node.id);
if (chain) { data.nodeClients = data.nodeClients.filter((nc) => nc.nodeId !== node.id);
const related = currentDeployments(data, chain); } else {
if (related.length && related.every((item) => item.status === 'active')) chain.status = chain.pausedChangesPending ? 'changes_pending' : 'active'; node.status = 'draft'; node.error = null;
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 (!success) {
else if (related.some((item) => item.status === 'failed')) chain.status = 'degraded'; node.status = kind === 'node-exit' ? 'degraded' : 'failed';
chain.updatedAt = nowIso(); node.error = job.error;
} }
node.updatedAt = nowIso();
return structuredClone(job); return structuredClone(job);
}); });
} }
@@ -388,43 +404,41 @@ class Orchestrator {
if (server) server.usage = { lastReportAt: reportedAt, sampleCount: Array.isArray(samples) ? samples.length : 0, if (server) server.usage = { lastReportAt: reportedAt, sampleCount: Array.isArray(samples) ? samples.length : 0,
error: error ? String(error).slice(0, 240) : null }; error: error ? String(error).slice(0, 240) : null };
for (const sample of Array.isArray(samples) ? samples.slice(0, 1000) : []) { 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 uplink = Number(sample.uplink);
const downlink = Number(sample.downlink); const downlink = Number(sample.downlink);
if (![uplink, downlink].every((value) => Number.isSafeInteger(value) && value >= 0)) continue; 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; 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 if (sample.kind === 'user') {
// process and started a new one. Never charge an already retired epoch const customer = data.customers.find((item) => item.id === sample.id);
// a second time if its HTTP response arrives out of order. if (!customer) continue;
const retiredEpochs = Array.isArray(deployment.retiredUsageEpochs) ? deployment.retiredUsageEpochs : []; // Counters are per engine process, hence per server. Traffic itself
if (sampleEpoch && retiredEpochs.includes(sampleEpoch)) continue; // accumulates on the customer across all servers.
const restarted = Boolean(sampleEpoch && previous.epoch && sampleEpoch !== previous.epoch); const key = `srv:${serverId}`;
if (!restarted && sampleEpoch && previous.epoch && !sampleEpoch.endsWith(':unknown') && customer.serverUsage = customer.serverUsage || {};
(uplink < previous.uplink || downlink < previous.downlink)) continue; const slot = customer.serverUsage[key] || (customer.serverUsage[key] = {});
// Counters reset when Xray restarts. Replayed reports have zero delta. const delta = applyDelta(slot, uplink, downlink, sampleEpoch);
const upDelta = !restarted && uplink >= previous.uplink ? uplink - previous.uplink : uplink; if (!delta) continue;
const downDelta = !restarted && downlink >= previous.downlink ? downlink - previous.downlink : downlink; customer.usedUplinkBytes = (customer.usedUplinkBytes || 0) + delta.upDelta;
if (restarted && previous.epoch) { customer.usedDownlinkBytes = (customer.usedDownlinkBytes || 0) + delta.downDelta;
deployment.retiredUsageEpochs = [...retiredEpochs, previous.epoch].slice(-8); 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 }; suspendCustomerResources, resumeCustomerResources };
+236 -167
View File
@@ -2,6 +2,7 @@
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const net = require('node:net'); const net = require('node:net');
const dns = require('node:dns/promises');
const REALITY_PRESETS = Object.freeze([ const REALITY_PRESETS = Object.freeze([
{ id: 'tesla', label: 'Tesla', serverName: 'www.tesla.com', destPort: 443 }, { id: 'tesla', label: 'Tesla', serverName: 'www.tesla.com', destPort: 443 },
@@ -12,18 +13,17 @@ const REALITY_PRESETS = Object.freeze([
]); ]);
const PROFILE_CATALOG = 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-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: ['reality'], description: '不启用 Vision 流控的 Reality 入口。' }, { 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: ['websocket'], description: '无 TLS 的兼容模式,建议仅在可信或外层 TLS 环境使用。' }, { id: 'vless-ws', role: 'relay-ingress', name: 'VLESS · WebSocket', status: 'beta', deployable: true, capabilities: ['sing-box', '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: ['sing-box', ], description: '轻量、推荐的 AEAD 2022 入口。' },
{ 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: ['sing-box', ], description: '32 字节密钥的 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: ['sing-box', ], description: '兼容主流旧版 Shadowsocks 客户端。' },
{ 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: ['sing-box', ], 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: ['sing-box', ], description: '适合受控网络或程序级代理。' },
{ 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: ['sing-box', 'tls-domain'], description: '需为每台入口设备填写 TLS 域名,并在 Agent 上安装对应证书。' },
{ 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: ['sing-box', 'udp', 'tls-domain'], description: '由 sing-box 承载;入口须安装有效节点证书并放行所选 UDP 端口。' },
{ 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: ['sing-box', 'tls-domain'], description: '由 sing-box 独立承载,需入口设备的有效节点证书;支持直连或转发到现有出口。' },
{ id: 'anytls', role: 'relay-ingress', name: 'AnyTLS · TLS', status: 'beta', deployable: true, capabilities: ['tls-domain', 'sing-box'], 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-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-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: '兼容旧环境的转发传输。' }, { 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 b64(bytes) { return crypto.randomBytes(bytes).toString('base64'); }
function shortId() { return crypto.randomBytes(8).toString('hex'); } function shortId() { return crypto.randomBytes(8).toString('hex'); }
function newCredentialSet(relayProtocol, exitProtocol) { function listenAddress(networkMode, server, bindAddress) {
const relayCipher = SHADOWSOCKS_METHODS[relayProtocol]; // bindAddress 是控制面按所选网络栈解析好的字面 IP(登记的域名已在下发前解析),优先使用。
const exitCipher = SHADOWSOCKS_METHODS[exitProtocol]; 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 { return {
clientId: crypto.randomUUID(), clientId: crypto.randomUUID(),
relayPassword: b64(relayCipher ? relayCipher.keyBytes : 24), relayPassword: b64(cipher ? cipher.keyBytes : 24),
exitPassword: b64(exitCipher ? exitCipher.keyBytes : 24),
socksUser: `ng_${crypto.randomBytes(5).toString('hex')}`,
shortId: shortId(), shortId: shortId(),
wsPath: `/${crypto.randomBytes(8).toString('hex')}`
}; };
} }
function listenAddress(networkMode) { return networkMode === 'ipv6' || networkMode === 'dual' ? '::' : '0.0.0.0'; } // Fill in node-level secrets that are missing for the node's current
// protocol/topology. Called on node create and on node edit.
function inboundBase(tag, port, protocol, settings, streamSettings, networkMode = 'ipv4') { function ensureNodeSecrets(node) {
return { node.wsPath ||= `/${crypto.randomBytes(9).toString('hex')}`;
tag, listen: listenAddress(networkMode), port, protocol, settings, const cipher = SHADOWSOCKS_METHODS[node.protocol];
sniffing: { enabled: true, destOverride: ['http', 'tls', 'quic'], routeOnly: true }, if (cipher && !node.ssServerKey) node.ssServerKey = b64(cipher.keyBytes);
...(streamSettings ? { streamSettings } : {}) 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);
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);
} }
if (!inbound) throw new Error(`Unsupported exit transport: ${protocol}`); if ((node.topology || 'forward') !== 'direct' && !node.exitCredentials) {
const directTag = `${tagPrefix}-direct`; const exitCipher = SHADOWSOCKS_METHODS[node.exitProtocol];
return { node.exitCredentials = {
id: resourceId, meta: { kind: 'exit', customerId: customer.id, metricsTag: inbound.tag }, inbounds: [inbound], clientId: crypto.randomUUID(),
outbounds: [{ tag: directTag, protocol: 'freedom', settings: {} }], password: b64(exitCipher ? exitCipher.keyBytes : 24),
routingRules: [{ type: 'field', inboundTag: [inbound.tag], outboundTag: directTag }] socksUser: `ng_${crypto.randomBytes(5).toString('hex')}`,
}; };
}
return node;
} }
function buildRelayInbound({ tag, port, protocol, credentials, customer, reality, networkMode, tlsDomain }) { // sing-box 全协议 inbound 构建器
const email = `ng:${customer.id}`; function buildSingBoxInbound({ tag, node, nodeClients, server, bindAddress }) {
if (protocol === 'hysteria2') { const protocol = node.protocol;
if (!tlsDomain) throw new Error('Hysteria 2 入口缺少有效证书域名'); const port = node.port;
return inboundBase(tag, port, 'hysteria', { const networkMode = node.networkMode || 'ipv4';
// Xray 26.3.27 reads `clients`; newer cores read `users`. const listen = listenAddress(networkMode, server, bindAddress);
// Keep both until all supported cores accept the same spelling. const tlsDomain = server.tlsDomain;
version: 2,
clients: [{ auth: credentials.relayPassword, level: 0, email }], // VLESS Reality (Vision / 普通)
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);
}
if (protocol === 'vless-reality-vision' || protocol === 'vless-reality') { if (protocol === 'vless-reality-vision' || protocol === 'vless-reality') {
const client = { id: credentials.clientId, email }; if (!node.reality) throw new Error('Reality 节点缺少密钥配置');
if (protocol === 'vless-reality-vision') client.flow = 'xtls-rprx-vision'; const users = nodeClients.map((nc) => ({
return inboundBase(tag, port, 'vless', { clients: [client], decryption: 'none' }, { name: nodeEmail(nc.customerId),
network: 'raw', security: 'reality', uuid: nc.credentials.clientId,
realitySettings: { ...(protocol === 'vless-reality-vision' ? { flow: 'xtls-rprx-vision' } : {}),
show: false, target: `${reality.serverName}:${reality.destPort}`, xver: 0, }));
serverNames: [reality.serverName], privateKey: `\${REALITY_PRIVATE:${reality.keyId}}`, shortIds: [credentials.shortId] return {
} type: 'vless', tag, listen, listen_port: port, users,
}, networkMode); 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' || protocol === 'vless-ws-tls') {
if (protocol === 'vless-ws-tls' && !tlsDomain) throw new Error('TLS 节点缺少有效证书域名'); if (protocol === 'vless-ws-tls' && !tlsDomain) throw new Error('TLS 节点缺少有效证书域名');
return inboundBase(tag, port, 'vless', { clients: [{ id: credentials.clientId, email }], decryption: 'none' }, { const users = nodeClients.map((nc) => ({ name: nodeEmail(nc.customerId), uuid: nc.credentials.clientId }));
network: 'ws', security: protocol === 'vless-ws-tls' ? 'tls' : 'none', wsSettings: { path: credentials.wsPath }, const inbound = {
...(protocol === 'vless-ws-tls' ? { tlsSettings: { serverName: tlsDomain, type: 'vless', tag, listen, listen_port: port, users,
certificates: [{ certificateFile: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`, keyFile: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem` }] } } : {}) transport: { type: 'ws', path: node.wsPath || '/' },
}, networkMode); };
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 }] }, { // Shadowsocks (2022 全系 + 旧版)
network: 'ws', security: 'none', wsSettings: { path: credentials.wsPath } if (SHADOWSOCKS_METHODS[protocol]) {
}, networkMode); 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) { function serverAddress(server, networkMode) {
return networkMode === 'ipv6' && server.publicAddressV6 ? server.publicAddressV6 : server.publicAddress; if (!server) throw new Error('Missing server for address selection');
} if (networkMode === 'ipv6') return server.publicAddressV6;
return 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 }]
};
} }
function uriHost(address) { return net.isIP(String(address)) === 6 ? `[${address}]` : address; } 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 label = encodeURIComponent(name);
const publicAddress = serverAddress(relayServer, networkMode); const publicAddress = serverAddress(relayServer, networkMode);
const host = uriHost(publicAddress); const host = uriHost(publicAddress);
@@ -235,14 +299,18 @@ function buildClientUri({ protocol, relayServer, relayPort, credentials, reality
return `vless://${credentials.clientId}@${host}:${relayPort}?${query}#${label}`; return `vless://${credentials.clientId}@${host}:${relayPort}?${query}#${label}`;
} }
if (SHADOWSOCKS_METHODS[protocol]) { 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}`; return `ss://${user}@${host}:${relayPort}#${label}`;
} }
if (protocol === 'socks5-auth') return `socks5://${encodeURIComponent(credentials.socksUser)}:${encodeURIComponent(credentials.relayPassword)}@${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; return null;
} }
@@ -259,6 +327,7 @@ function validateProtocolPair(relayProtocol, exitProtocol) {
function isRealityProtocol(protocol) { return ['vless-reality-vision', 'vless-reality'].includes(protocol); } function isRealityProtocol(protocol) { return ['vless-reality-vision', 'vless-reality'].includes(protocol); }
module.exports = { module.exports = {
PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, newCredentialSet, buildExitResource, PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, nodeEmail, newNodeClientCredentials,
buildRelayResource, buildDirectResource, buildClientUri, validateEntryProtocol, validateProtocolPair, isRealityProtocol buildNodeResource, buildNodeExitResource, ensureNodeSecrets, buildClientUri, validateEntryProtocol, validateProtocolPair, isRealityProtocol,
resolveBindAddress
}; };
+3 -3
View File
@@ -4,8 +4,8 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const COLLECTIONS = [ const COLLECTIONS = [
'users', 'servers', 'agents', 'enrollmentTokens', 'customers', 'chains', 'users', 'servers', 'agents', 'enrollmentTokens', 'customers', 'nodes',
'deployments', 'jobs', 'activity', 'observations', 'subscriptionAccess', 'subscriptionClients' 'nodeClients', 'jobs', 'activity', 'observations', 'subscriptionAccess', 'subscriptionClients'
]; ];
function emptyData() { function emptyData() {
@@ -20,7 +20,7 @@ function emptyData() {
completedJobRetentionDays: 7 completedJobRetentionDays: 7
}, },
users: [], servers: [], agents: [], enrollmentTokens: [], customers: [], users: [], servers: [], agents: [], enrollmentTokens: [], customers: [],
chains: [], deployments: [], jobs: [], activity: [], observations: [], subscriptionAccess: [], subscriptionClients: [] nodes: [], nodeClients: [], jobs: [], activity: [], observations: [], subscriptionAccess: [], subscriptionClients: []
}; };
} }
+38 -29
View File
@@ -1,36 +1,47 @@
'use strict'; 'use strict';
// Client URLs are only issued for confirmed, running entry resources. const { buildClientUri, nodeEmail } = require('./protocols');
function entries(data, customerId) {
const active = data.deployments.filter((item) => item.customerId === customerId && !item.archived && item.status === 'active' && // Client URLs are only issued for confirmed, running nodes. Each link is built
['relay', 'direct'].includes(item.role) && typeof item.clientUri === 'string' && item.clientUri.length > 0 && // on the fly from the node's current config and the customer's own credentials
(item.role === 'direct' || data.deployments.some((exit) => exit.id === item.exitDeploymentId && exit.status === 'active' && !exit.archived))); // for that node (3x-ui style: shared inbound, per-client identity).
const seen = new Map(); function nodeClientUri(data, nodeClient) {
return active.map((item) => { const node = data.nodes.find((item) => item.id === nodeClient.nodeId);
const chainName = data.chains?.find((chain) => chain.id === item.chainId)?.name; if (!node || node.status !== 'active') return null;
if (!chainName) return item; const server = data.servers.find((item) => item.id === node.serverId);
// Use the current route name even for links generated by older Agents. if (!server) return null;
// Multiple entries under one route still need distinct names in Clash. return buildClientUri({
const index = (seen.get(chainName) || 0) + 1; protocol: node.protocol, relayServer: server, relayPort: node.port,
seen.set(chainName, index); credentials: { ...nodeClient.credentials, wsPath: node.wsPath, socksUser: nodeEmail(nodeClient.customerId) },
const name = index === 1 ? chainName : `${chainName} (${index})`; reality: node.reality, publicKey: node.artifacts && node.artifacts.realityPublicKey,
if (item.clientUri.startsWith('vmess://')) { name: node.name, networkMode: node.networkMode || 'ipv4', tlsDomain: server.tlsDomain,
const payload = JSON.parse(Buffer.from(item.clientUri.slice(8), 'base64').toString('utf8')); ssServerKey: node.ssServerKey,
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)}` };
}); });
} }
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 q(value) { return JSON.stringify(String(value)); }
function clashProxy(item) { function clashProxy(item) {
const uri = item.clientUri; 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 name = decodeURIComponent(uri.slice(uri.lastIndexOf('#') + 1));
const url = new URL(uri); const url = new URL(uri);
const base = { name, server:url.hostname.replace(/^\[|\]$/g, ''), port:Number(url.port) }; const base = { name, server:url.hostname.replace(/^\[|\]$/g, ''), port:Number(url.port) };
@@ -104,14 +115,13 @@ function makeClash(items, smart = false) {
function makeSurge(items) { function makeSurge(items) {
// Surge does not accept VLESS/Reality policies. Omit unsupported nodes rather // Surge does not accept VLESS/Reality policies. Omit unsupported nodes rather
// than handing clients an importable profile that cannot establish a tunnel. // 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; if (!proxies.length) return null;
const safe = (value) => String(value).replace(/[\r\n,=]/g, '_'); const safe = (value) => String(value).replace(/[\r\n,=]/g, '_');
const policies = proxies.map((proxy) => { const policies = proxies.map((proxy) => {
const prefix = `${safe(proxy.name)} = `; 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 === '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}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)}`;
}); });
const names = proxies.map((proxy) => safe(proxy.name)).join(', '); 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`; 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 === '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 === '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 === '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 }; const outbound = { type:'vless', ...base, uuid:proxy.uuid };
if (proxy.flow) outbound.flow = proxy.flow; if (proxy.flow) outbound.flow = proxy.flow;
if (proxy.network === 'ws') outbound.transport = { type:'ws', path:proxy['ws-opts'].path }; 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; return undefined;
} }
module.exports = { entries, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription }; module.exports = { entries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };
+35
View File
@@ -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
}
+1 -1
View File
@@ -9,7 +9,7 @@ error_log="/var/log/nexusgate/agent-error.log"
depend() { depend() {
need net need net
use nexusgate-xray use nexusgate-sing-box
} }
start_pre() { start_pre() {
-17
View File
@@ -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
View File
@@ -8,7 +8,7 @@
"node": ">=18" "node": ">=18"
}, },
"scripts": { "scripts": {
"start": "node server.js", "start": "node --max-old-space-size=128 --optimize-for-size server.js",
"test": "node --test", "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" "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
View File
@@ -2,7 +2,7 @@
const state = { const state = {
session: null, page: 'overview', overview: null, servers: [], customers: [], 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); 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 && 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)); }; 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 splitList = (value) => String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
const statusText = { const statusText = {
online:'在线', offline:'离线', pending:'待注册', active:'运行中', draft:'草稿', deploying:'部署中', queued:'排队中', online:'在线', offline:'离线', pending:'待注册', active:'运行中', draft:'草稿', deploying:'部署中', queued:'排队中',
running:'执行中', completed:'已完成', failed:'失败', degraded:'异常', suspended:'已暂停', suspending:'暂停中', partially_suspended:'部分暂停', removing:'停用中', deleted:'已删除', running:'执行中', completed:'已完成', failed:'失败', degraded:'异常', suspended:'已暂停', removing:'停用中', deleted:'已删除'
redeploying:'重建中', redeploy_pending:'等待重建', changes_pending:'待重新部署'
}; };
const roleText = { relay:'入口 / 转发', exit:'出口 / 落地', hybrid:'综合节点' }; const roleText = { relay:'入口 / 转发', exit:'出口 / 落地', hybrid:'综合节点' };
const topologyText = { forward:'转发线路', direct:'单机直连' }; const topologyText = { forward:'转发线路', direct:'单机直连' };
const deploymentRoleText = { relay:'客户端入口', exit:'出口传输', direct:'单机节点' };
const auditText = { const auditText = {
create_server:'添加设备', update_server:'编辑设备', delete_server:'删除设备', create_enrollment:'生成注册令牌', enroll_agent:'Agent 注册', 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:'删除客户', 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:'停用线路', create_node:'创建节点', update_node:'编辑节点', deploy_node:'部署节点', repair_node:'修复节点', remove_node:'停用节点',
delete_chain:'删除线路', retry_cleanup:'重试遗留清理', force_forget_server:'强制遗忘离线设备', update_account:'修改管理员账号', suspend_ip_limit:'IP 超限停用', rotate_subscription:'重置订阅链接' 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) { function setTheme(theme) {
@@ -73,17 +96,17 @@ function showApp() {
async function load(page = state.page) { async function load(page = state.page) {
try { try {
if (page === 'overview') { if (page === 'overview') {
const [overview, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/servers'), api('/api/customers'), api('/api/chains')]); 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.chains = routes.chains; state.deployments = routes.deployments; 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 === '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') { else if (page === 'chains') {
const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/chains'), api('/api/protocols')]); 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.chains = routes.chains; state.deployments = routes.deployments; state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || []; 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 === 'commands') { /* Command reference needs no API request. */ }
else if (page === 'operations') { else if (page === 'operations') {
const [jobs, servers, overview] = await Promise.all([api('/api/jobs'), api('/api/servers'), api('/api/overview')]); 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) { function setPage(page) {
state.page = page; state.search = ''; closeSidebar(); state.page = page; state.search = ''; closeSidebar();
$$('#nav button').forEach((button) => button.classList.toggle('active', button.dataset.page === page)); $$('#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]}`; $('#page-title').textContent = titles[page]; $('#breadcrumb').textContent = `NEXUSGATE / ${titles[page]}`;
load(page); load(page);
} }
@@ -112,15 +135,15 @@ function renderOverview() {
const metrics = [ const metrics = [
['受管设备', counts.servers || 0, `${counts.online || 0} 台在线`, '#38bd91'], ['受管设备', counts.servers || 0, `${counts.online || 0} 台在线`, '#38bd91'],
['客户账户', counts.customers || 0, '额度与到期统一管理', '#5877f4'], ['客户账户', counts.customers || 0, '额度与到期统一管理', '#5877f4'],
['编排线路', counts.chains || 0, '转发与单机节点', '#9667e8'], ['共享节点', counts.nodes || 0, '多客户共用入口', '#9667e8'],
['活动部署', counts.activeDeployments || 0, '实际运行资源', '#e8a63c'], ['运行中节点', counts.activeNodes || 0, '实际运行资源', '#e8a63c'],
['待办任务', counts.queuedJobs || 0, 'Agent 自动领取', '#e5667c'] ['待办任务', 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 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 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; 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> 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> <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>`; <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> 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><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>${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(''); }).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> <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) { function nodeActions(node) {
const edit = `<button data-action="edit-chain" data-id="${esc(chain.id)}">编辑</button>`; const edit = `<button data-action="edit-node" data-id="${esc(node.id)}">编辑</button>`;
const remove = `<button class="danger" data-action="delete-chain" data-id="${esc(chain.id)}">删除</button>`; const clients = `<button data-action="node-clients" data-id="${esc(node.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}`; const remove = `<button class="danger" data-action="delete-node" data-id="${esc(node.id)}">删除</button>`;
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 (node.status === 'draft') return `${edit}${clients}<button class="primary" data-action="deploy-node" data-id="${esc(node.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 (['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 (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'].includes(node.status)) return `${edit}${clients}<button data-action="remove-node" data-id="${esc(node.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}${clients}${remove}`;
return `${edit}${remove}`;
} }
function rowActions(id, primary, more) { 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>`; 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) { function compactNodeActions(node) {
const actions = chainActions(chain).match(/<button\b[^>]*>[^<]*<\/button>/g) || []; const actions = nodeActions(node).match(/<button\b[^>]*>[^<]*<\/button>/g) || [];
const primary = actions.slice(0, 2).join(''); const primary = actions.slice(0, 2).join('');
const more = actions.slice(2).map((html) => [(html.match(/data-action="([^"]+)"/) || [,''])[1], html.replace(/<[^>]+>/g, '')]); 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() { function nodeClientCount(nodeId) {
const rows = state.chains.map((chain) => { return state.nodeClients.filter((nc) => nc.nodeId === nodeId && nc.status === 'active').length;
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 renderDeployments() { function renderNodes() {
const active = state.deployments.filter((item) => item.status !== 'deleted'); const rows = state.nodes.map((node) => {
const rows = active.map((item) => { const direct = (node.topology || 'forward') === 'direct';
const server = state.servers.find((entry) => entry.id === item.serverId); const path = direct ? protocolName(node.protocol, 'relay-ingress') : `${protocolName(node.protocol, 'relay-ingress')} → ${protocolName(node.exitProtocol, 'exit-transport')}`;
const customer = state.customers.find((entry) => entry.id === item.customerId); const server = state.servers.find((item) => item.id === node.serverId);
const chain = state.chains.find((entry) => entry.id === item.chainId); const exit = direct ? '本机直出' : ((state.servers.find((item) => item.id === node.exitServerId) || {}).name || '已删除');
const canCopy = item.status === 'active' && item.clientUri && customer?.status === 'active' && const traffic = node.meteredTraffic ? `上行 ${fmtBytes(node.meteredTraffic.uplink)} · 下行 ${fmtBytes(node.meteredTraffic.downlink)}` : '等待入口计量上报';
(!customer.expiresAt || Date.parse(customer.expiresAt) > Date.now()) && return `<tr><td><strong>${esc(node.name)}</strong><small>${esc(topologyText[node.topology || 'forward'])} · ${esc(path)}</small></td>
(!(customer.trafficLimitBytes > 0) || customer.usedBytes < customer.trafficLimitBytes) && <td>${esc(server ? server.name : '已删除')}<small>${esc(node.protocol)} :${node.port} · ${esc(node.networkMode || 'ipv4').toUpperCase()}</small></td><td>${esc(exit)}</td>
(item.role === 'direct' || state.deployments.some((exit) => exit.id === item.exitDeploymentId && exit.status === 'active')); <td>${nodeClientCount(node.id)} 位</td><td><small>${traffic}</small>${node.lastUsageAt ? `<small>最后上报 ${fmtDate(node.lastUsageAt)}</small>` : ''}</td>
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>${status(node.status)}${node.error ? `<small class="error-detail" title="${esc(node.error)}">${esc(node.error)}</small>` : ''}</td><td>${compactNodeActions(node)}</td></tr>`;
<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>`;
}).join(''); }).join('');
return `<div class="page-intro"><p>显示线路生成的实际入口、出口和单机节点。客户端链接只在入口资源部署成功后生成;编辑请从对应线路统一完成。</p></div> 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></tr></thead><tbody>${rows || `<tr><td colspan="6">${empty('暂无部署','从“线路编排”页面发起部署')}</td></tr>`}</tbody></table></div></section>`; <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() { function renderCommands() {
@@ -228,12 +268,12 @@ function renderCommands() {
['入口机 / 中转机', [ ['入口机 / 中转机', [
['更新 Agent', 'ng-agent update', '更新后自动恢复心跳;已有线路无需重建。'], ['更新 Agent', 'ng-agent update', '更新后自动恢复心跳;已有线路无需重建。'],
['申请或续签节点证书', 'ng-agent cert', '按提示输入已解析到本机的域名。公网 80/TCP 可用时选择 HTTP;否则选择 Cloudflare DNS 并输入 Zone DNS Edit API Token。已有有效证书不必重复申请。'], ['申请或续签节点证书', 'ng-agent cert', '按提示输入已解析到本机的域名。公网 80/TCP 可用时选择 HTTP;否则选择 Cloudflare DNS 并输入 Zone DNS Edit API Token。已有有效证书不必重复申请。'],
['安装 AnyTLS 引擎', 'ng-agent engine install', '仅在承载 AnyTLS 的入口执行。首次构建可能耗时,临时 Go 缓存完成后会清理。HY2 与 VLESS WS TLS 无需该引擎。'], ['安装 sing-box 引擎', 'ng-agent engine install', '所有入口/出口机都需要。首次构建可能耗时,临时 Go 缓存完成后会清理。'],
['检查证书、引擎与双向计数', 'ng-agent doctor', '核对本机证书、Xray、sing-box 与入口统计。'] ['检查证书、引擎与双向计数', 'ng-agent doctor', '核对本机证书、sing-box 与入口统计。']
]], ]],
['出口机 / 落地机', [ ['出口机 / 落地机', [
['更新轻量 Agent', 'ng-agent update', '只使用出口传输时无需安装 sing-box,也无需申请入口证书。'], ['更新 Agent', 'ng-agent update', '入口/出口机通用;出口机无需申请入口证书。'],
['检查运行状态', 'ng-agent doctor', '核对心跳和 Xray 配置。'] ['检查运行状态', '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(''); 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() { 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](); $('#content').innerHTML = views[state.page]();
} }
@@ -313,8 +353,19 @@ function subscriptionModal(customer) {
['base64','通用 Base64'], ['clash','Mihomo / Clash 简洁版'], ['clash-smart','Mihomo / Clash 智能分流'], ['base64','通用 Base64'], ['clash','Mihomo / Clash 简洁版'], ['clash-smart','Mihomo / Clash 智能分流'],
['singbox','sing-box JSON'], ['surge','Surge(仅兼容节点)'], ['raw','原始节点链接'] ['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> 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('')} ${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="field-note">二维码在当前浏览器本地生成,不向第三方图片服务发送订阅令牌。Surge 只包含它原生支持的节点协议。</div>
<div class="notice warning">订阅客户端数按客户端标识或 User-Agent 估算,不能证明物理设备数量;共享节点链接可复制,订阅请求被拒后已导入节点仍可连接。节点 IP 超限需要等待 Agent 上报和清理任务。</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>`); <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>`); <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) { function nodeForm(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 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 === chain.realityServerName) || { id:'custom' }; const preset = state.realityPresets.find((entry) => entry.serverName === node.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 : '')}"> 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(chain.name || '')}" placeholder="新加坡入口 → 日本出口" required></label> <label class="wide">节点名称<input name="name" value="${esc(node.name || '')}" placeholder="新加坡入口 01" 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="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',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> <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>
<div class="section-title">入口与客户端节点</div> <label>入口协议<select name="protocol" data-node-sync>${protocolOptions('relay-ingress', node.protocol)}</select></label>
<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>入口端口<div class="inline-fields"><select name="portMode" data-node-sync><option value="random"${selected('random',node.portMode)}>范围内随机</option><option value="fixed"${selected('fixed',node.portMode)}>固定端口</option></select><input name="port" type="number" value="${esc(node.port || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
<label>客户端入口协议<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>
<div class="notice wide" data-protocol-note></div> <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 目标预设<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> <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="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-chain-sync>${protocolOptions('exit-transport', chain.exitProtocol)}</select><small>出口可独立选 SS / SS2022 / VLESS TCP / SOCKS5,不要求与入口同协议。</small></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-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> <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="section-title">客户分配</div> <div class="notice wide">保存运行中的节点会立即重建下发(sing-box 重启一次)。VLESS WS TLS、Hysteria 2 和 AnyTLS 需要入口证书;Hysteria 2 使用 UDP,其余使用 TCP。全部由 sing-box 承载。</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="form-actions"><button type="button" data-close>取消</button><button class="primary" type="submit">${item ? '保存修改' : '创建节点'}</button></div></form>`);
<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> syncNodeForm();
${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();
} }
function syncChainForm() { function syncNodeForm() {
const form = $('#chain-form'); if (!form) return; const form = $('#node-form'); if (!form) return;
const direct = form.elements.topology.value === 'direct'; const direct = form.elements.topology.value === 'direct';
$$('[data-forward-only]', form).forEach((element) => element.classList.toggle('hidden', direct)); $$('[data-forward-only]', form).forEach((element) => element.classList.toggle('hidden', direct));
form.elements.exitServerId.required = !direct; form.elements.exitProtocol.required = !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)); $$('[data-reality-only]', form).forEach((element) => element.classList.toggle('hidden', !reality));
form.elements.realityServerName.required = 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。'}` : '请选择可部署的入口协议。'; $('[data-protocol-note]', form).textContent = protocol ? `入口:${protocol.description} ${reality ? 'Reality 默认使用 Tesla SNI,可从下拉菜单切换。' : '此协议不需要 Reality SNI。'}` : '请选择可部署的入口协议。';
for (const [mode, port] of [['relayPortMode','relayPort'],['exitPortMode','exitPort']]) { for (const [mode, port] of [['portMode','port'],['exitPortMode','exitPort']]) {
const fixed = form.elements[mode].value === 'fixed' && (mode === 'relayPortMode' || !direct); const fixed = form.elements[mode].value === 'fixed' && (mode === 'portMode' || !direct);
form.elements[port].disabled = !fixed; form.elements[port].disabled = !fixed;
form.elements[port].required = 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) { function expiryIso(formData) {
const date = formData.get('expiryDate'); if (!date) return null; const date = formData.get('expiryDate'); if (!date) return null;
const time = formData.get('expiryTime') || '23:59'; 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') }; 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) }); await api(resourceId ? `/api/customers/${resourceId}` : '/api/customers', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) });
$('#modal').close(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers'); $('#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 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 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/chains/${resourceId}` : '/api/chains', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) }); const result = await api(resourceId ? `/api/nodes/${resourceId}` : '/api/nodes', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) });
$('#modal').close(); toast(result.requiresRedeploy ? '修改已保存;原节点仍按旧协议运行,请点击“应用修改”' : (resourceId ? '线路已更新' : '线路草稿已创建')); await load('chains'); $('#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') { } else if (form.id === 'account-form') {
if (data.get('newPassword') !== data.get('confirmPassword')) throw new Error('两次输入的新密码不一致'); if (data.get('newPassword') !== data.get('confirmPassword')) throw new Error('两次输入的新密码不一致');
await api('/api/account', { method:'PATCH', body:JSON.stringify({ username:data.get('username'), currentPassword:data.get('currentPassword'), newPassword:data.get('newPassword') }) }); await api('/api/account', { method:'PATCH', body:JSON.stringify({ username:data.get('username'), currentPassword:data.get('currentPassword'), newPassword:data.get('newPassword') }) });
@@ -435,15 +520,20 @@ document.addEventListener('click', async (event) => {
state.customers = state.customers.map((item) => item.id === itemId ? result.customer : item); state.customers = state.customers.map((item) => item.id === itemId ? result.customer : item);
subscriptionModal(result.customer); toast('订阅地址已重置'); subscriptionModal(result.customer); toast('订阅地址已重置');
} }
else if (action === 'add-chain') chainForm(); else if (action === 'add-node') nodeForm();
else if (action === 'edit-chain') { else if (action === 'edit-node') {
if (!state.protocols.length) { const result = await api('/api/protocols'); state.protocols = result.profiles; state.realityPresets = result.realityPresets || []; } 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') { } else if (action === 'enroll-server') {
const result = await api(`/api/servers/${itemId}/enrollment-token`, { method:'POST', body:'{}' }); 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}`; 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>`); 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') { else if (action === 'qr-uri') {
const target = $('.subscription-qr', button.closest('.subscription-row')); const target = $('.subscription-qr', button.closest('.subscription-row'));
if (!target.hidden) { target.hidden = true; target.replaceChildren(); return; } if (!target.hidden) { target.hidden = true; target.replaceChildren(); return; }
@@ -453,7 +543,7 @@ document.addEventListener('click', async (event) => {
else if (action === 'agent-uninstall') { else if (action === 'agent-uninstall') {
const command = 'ng-agent uninstall'; const command = 'ng-agent uninstall';
const fallback = 'curl -fsSL https://raw.githubusercontent.com/a2899882/NexusGate/main/scripts/agent-uninstall.sh | bash'; 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 === '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') { else if (action === 'forget-server') {
@@ -465,17 +555,15 @@ document.addEventListener('click', async (event) => {
toast('已强制遗忘设备;请确保旧机器已销毁或手工清理'); await load(); 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-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 === 'reset-usage' && confirm('确认把该客户已用流量清零?')) { await api(`/api/customers/${itemId}/reset-usage`, { method:'POST', body:'{}' }); toast('客户流量已清零'); await load(); }
else if (action === 'toggle-customer') { else if (action === 'toggle-customer') {
const item = state.customers.find((entry) => entry.id === itemId); 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(); 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 === 'deploy-node' && confirm('现在向所选设备下发这个节点?')) { await api(`/api/nodes/${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 === 'repair-node' && confirm('重新下发该节点配置?')) { await api(`/api/nodes/${itemId}/repair`, { method:'POST', body:'{}' }); toast('修复任务已进入队列'); await load(); }
else if (action === 'redeploy-chain' && confirm('重新部署会先移除旧资源,再自动创建新资源。确认继续?')) { await api(`/api/chains/${itemId}/redeploy`, { 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 === 'repair-chain' && confirm('仅重试当前失败的部署项?')) { await api(`/api/chains/${itemId}/repair`, { 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 === '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 === 'download-backup') location.href = '/api/backup'; else if (action === 'download-backup') location.href = '/api/backup';
else if (action === 'restore-backup') { else if (action === 'restore-backup') {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json'; input.onchange = async () => { 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]'); const picker = event.target.closest('[data-picker]');
$('[data-picker-count]', picker).textContent = `已选择 ${$$('input[type="checkbox"]:checked', picker).length} 项`; $('[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]')) { if (event.target.matches('[data-reality-preset]')) {
const option = event.target.selectedOptions[0]; const form = event.target.form; 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; } if (option && option.value !== 'custom') { form.elements.realityServerName.value = option.dataset.sni; form.elements.realityDestPort.value = option.dataset.port; }
-1
View File
@@ -37,7 +37,6 @@
<button data-page="servers"><span class="nav-icon">◫</span><span class="nav-label">服务器</span></button> <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="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="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> <button data-page="commands"><span class="nav-icon">⌘</span><span class="nav-label">安装与命令</span></button>
<p class="nav-caption nav-group">安全与系统</p> <p class="nav-caption nav-group">安全与系统</p>
<button data-page="operations"><span class="nav-icon">⚙</span><span class="nav-label">设置与运维</span></button> <button data-page="operations"><span class="nav-icon">⚙</span><span class="nav-label">设置与运维</span></button>
+4
View File
@@ -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: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 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} .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}
-4
View File
@@ -46,10 +46,8 @@ validate() {
activate() { activate() {
flush_usage flush_usage
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then 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 systemctl try-restart nexusgate-sing-box.service || printf 'sing-box 重启失败,请运行 ng-agent doctor\n' >&2
elif command -v rc-service >/dev/null; then 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 rc-service nexusgate-sing-box status >/dev/null 2>&1 && rc-service nexusgate-sing-box restart || true
fi fi
printf '证书已安装:%s;有效期:' "$target" printf '证书已安装:%s;有效期:' "$target"
@@ -104,10 +102,8 @@ if [[ -r /etc/nexusgate/agent.env && -f /opt/nexusgate-agent/agent.js ]]; then
fi fi
fi fi
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then
systemctl try-restart nexusgate-xray.service
systemctl try-restart nexusgate-sing-box.service systemctl try-restart nexusgate-sing-box.service
elif command -v rc-service >/dev/null; then 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 rc-service nexusgate-sing-box status >/dev/null 2>&1 && rc-service nexusgate-sing-box restart || true
fi fi
EOF EOF
+9 -44
View File
@@ -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 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 版本:' printf 'Agent 版本:'
node -p "require('fs').readFileSync('/opt/nexusgate-agent/agent.js','utf8').match(/const VERSION = '([^']+)'/)[1]" 2>/dev/null || printf '无法读取\n' 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 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" printf '%s:' "$name"
systemctl is-active "$name" 2>&1 || true systemctl is-active "$name" 2>&1 || true
done done
else else
rc-service nexusgate-agent status || true rc-service nexusgate-agent status || true
rc-service nexusgate-xray status || true
rc-service nexusgate-sing-box status || true rc-service nexusgate-sing-box status || true
fi fi
printf '\nsing-box 构建与配置:\n' 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 /usr/local/bin/nexusgate-sing-box version | head -n 4
if [[ -f /etc/nexusgate/sing-box/config.json ]]; then 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 /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 /usr/local/bin/nexusgate-sing-box-stats --server="127.0.0.1:${NG_SINGBOX_API_PORT:-10086}" 2>&1 | head -c 900
printf '\n' printf '\n'
else else
printf '已安装,当前没有 AnyTLS 资源\n' printf '已安装,当前没有节点资源\n'
fi fi
else else
printf '未安装;运行 ng-agent engine install\n' printf '未安装;运行 ng-agent engine install\n'
fi fi
printf '控制面连通性:' printf '控制面连通性:'
curl --max-time 8 -fsS "${NG_CONTROLLER%/}/healthz" 2>&1 | head -c 350 || true 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' printf '\nHysteria 2 UDP 监听:\n'
node <<'NODE' 2>&1 || true node <<'NODE' 2>&1 || true
const fs = require('node:fs'); const fs = require('node:fs');
const agent = require('/opt/nexusgate-agent/agent.js'); 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')) const resources = fs.readdirSync(dir).filter((name) => name.endsWith('.json'))
.map((name) => JSON.parse(fs.readFileSync(`${dir}/${name}`, 'utf8'))); .map((name) => JSON.parse(fs.readFileSync(`${dir}/${name}`, 'utf8')));
const expected = resources.flatMap((resource) => resource.inbounds || []) 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 入站'); if (!expected.length) console.log('没有 Hysteria 2 入站');
else { 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 { try {
const missing = agent.missingHysteriaListeners(resources); 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}`); } } catch (error) { console.log(`监听检查失败: ${error.message}`); }
} }
NODE NODE
printf '\nXray 入站流量统计(累计,查询不会清零):\n' printf '\n最近 Agent 与 sing-box 日志:\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'
if command -v journalctl >/dev/null && [[ -d /run/systemd/system ]]; then 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 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 fi
+33 -45
View File
@@ -9,6 +9,16 @@ TOKEN=""
die() { printf '错误:%s\n' "$*" >&2; exit 1; } die() { printf '错误:%s\n' "$*" >&2; exit 1; }
info() { printf '\033[1;36m[NexusGate Agent]\033[0m %s\n' "$*"; } 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 while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--server) CONTROLLER="${2:-}"; shift 2 ;; --server) CONTROLLER="${2:-}"; shift 2 ;;
@@ -35,31 +45,19 @@ fi
node_major="$(node -p 'process.versions.node.split(`.`)[0]')" node_major="$(node -p 'process.versions.node.split(`.`)[0]')"
[[ "$node_major" -ge 18 ]] || die "需要 Node.js 18 或更高版本" [[ "$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)" tmp_dir="$(mktemp -d /tmp/nexusgate-agent.XXXXXX)"
trap 'rm -rf -- "$tmp_dir"' EXIT 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 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 fetch agent/agent.js /opt/nexusgate-agent/agent.js
curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/agent/run.sh" -o /opt/nexusgate-agent/run.sh fetch agent/run.sh /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 fetch scripts/agent-update.sh /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 fetch scripts/agent-uninstall.sh /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 fetch scripts/agent-doctor.sh /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 fetch scripts/agent-cert.sh /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 fetch scripts/agent-singbox.sh /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" fetch scripts/agent-logrotate.conf "$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 scripts/agent-logrotate-setup.sh "$tmp_dir/logrotate-setup.sh"
cat > /usr/local/sbin/ng-agent <<'EOF' cat > /usr/local/sbin/ng-agent <<'EOF'
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
@@ -74,19 +72,12 @@ esac
EOF EOF
chmod 0644 /opt/nexusgate-agent/agent.js 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 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 -d -m 0750 /var/log/nexusgate
install -m 0644 "$tmp_dir/logrotate.conf" /etc/nexusgate/agent-logrotate.conf 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 install -m 0755 "$tmp_dir/logrotate-setup.sh" /usr/local/sbin/ng-agent-logrotate-setup
if [[ ! -f /etc/nexusgate/xray/config.json ]]; then # sing-box 是唯一的入口引擎,安装时直接构建。
printf '{"log":{"loglevel":"warning"},"inbounds":[],"outbounds":[]}\n' > /etc/nexusgate/xray/config.json NG_REPO="$REPO" NG_BRANCH="$BRANCH" ng-agent-singbox install || die 'sing-box 引擎安装失败;修复后运行 ng-agent engine install'
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
info "向控制面注册" 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}}))')" 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_CONTROLLER=%q\n' "${CONTROLLER%/}"
printf 'NG_AGENT_KEY=%q\n' "$agent_key" printf 'NG_AGENT_KEY=%q\n' "$agent_key"
printf 'NG_XRAY_BIN=/usr/local/bin/xray\n' printf 'NG_CONFIG_DIR=/etc/nexusgate/agent\n'
printf 'NG_CONFIG_DIR=/etc/nexusgate/xray\n'
printf 'NG_KEY_FILE=/etc/nexusgate/keys.json\n' printf 'NG_KEY_FILE=/etc/nexusgate/keys.json\n'
} > /etc/nexusgate/agent.env } > /etc/nexusgate/agent.env
chmod 0600 /etc/nexusgate/agent.env chmod 0600 /etc/nexusgate/agent.env
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then 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 fetch systemd/nexusgate-agent.service /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 fetch systemd/nexusgate-sing-box.service /etc/systemd/system/nexusgate-sing-box.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 systemctl daemon-reload
systemctl enable nexusgate-xray.service nexusgate-agent.service systemctl enable nexusgate-sing-box.service nexusgate-agent.service
systemctl restart nexusgate-xray.service
rm -f -- /etc/nexusgate/last-heartbeat.json rm -f -- /etc/nexusgate/last-heartbeat.json
systemctl restart nexusgate-sing-box.service
systemctl restart nexusgate-agent.service systemctl restart nexusgate-agent.service
service_manager="systemd" service_manager="systemd"
elif command -v rc-service >/dev/null; then elif command -v rc-service >/dev/null; then
install -d -m 0755 /etc/init.d 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 fetch openrc/nexusgate-agent /etc/init.d/nexusgate-agent
curl -fL --retry 3 "https://raw.githubusercontent.com/${REPO}/${BRANCH}/openrc/nexusgate-xray" -o /etc/init.d/nexusgate-xray fetch openrc/nexusgate-sing-box /etc/init.d/nexusgate-sing-box
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-sing-box
chmod 0755 /etc/init.d/nexusgate-agent /etc/init.d/nexusgate-xray /etc/init.d/nexusgate-sing-box rc-update add nexusgate-sing-box default >/dev/null
rc-update add nexusgate-xray default >/dev/null
rc-update add nexusgate-agent default >/dev/null rc-update add nexusgate-agent default >/dev/null
# Alpine runs the dedicated hourly task through BusyBox crond. # Alpine runs the dedicated hourly task through BusyBox crond.
if [[ -e /etc/init.d/crond ]]; then if [[ -e /etc/init.d/crond ]]; then
rc-update add crond default >/dev/null 2>&1 || true rc-update add crond default >/dev/null 2>&1 || true
rc-service crond start >/dev/null 2>&1 || true rc-service crond start >/dev/null 2>&1 || true
fi fi
rc-service nexusgate-xray restart rc-service nexusgate-sing-box restart
rm -f -- /etc/nexusgate/last-heartbeat.json rm -f -- /etc/nexusgate/last-heartbeat.json
rc-service nexusgate-agent restart rc-service nexusgate-agent restart
service_manager="OpenRC" service_manager="OpenRC"
@@ -144,7 +132,7 @@ fi
printf '\n\033[1;32mAgent 安装、注册和首次心跳完成(%s)。\033[0m\n' "$service_manager" printf '\n\033[1;32mAgent 安装、注册和首次心跳完成(%s)。\033[0m\n' "$service_manager"
printf '后续更新 Agent:ng-agent-update\n' printf '后续更新 Agent:ng-agent-update\n'
printf '卸载 Agent:ng-agent uninstall\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 if [[ "$service_manager" == systemd ]] && ! systemctl is-active --quiet nexusgate-agent.service; then
journalctl -u nexusgate-agent.service -n 35 --no-pager || true journalctl -u nexusgate-agent.service -n 35 --no-pager || true
die 'Agent 注册已完成,但服务没有运行;请执行 ng-agent doctor 检查原因' die 'Agent 注册已完成,但服务没有运行;请执行 ng-agent doctor 检查原因'
+4 -7
View File
@@ -3,7 +3,7 @@ set -Eeuo pipefail
[[ "${EUID}" -eq 0 ]] || { printf '请使用 root 运行\n' >&2; exit 1; } [[ "${EUID}" -eq 0 ]] || { printf '请使用 root 运行\n' >&2; exit 1; }
if [[ "${1:-}" != "--yes" ]]; then if [[ "${1:-}" != "--yes" ]]; then
printf '将停止并删除此机器上的 NexusGate Agent、专属 Xray 服务、节点资源和密钥。\n' printf '将停止并删除此机器上的 NexusGate Agent、sing-box 服务、节点资源和密钥。\n'
printf '请先在控制台停用/删除关联线路;如设备已离线,之后在控制台执行“强制遗忘”。\n' printf '请先在控制台停用/删除关联线路;如设备已离线,之后在控制台执行“强制遗忘”。\n'
printf '输入 UNINSTALL 确认:' > /dev/tty printf '输入 UNINSTALL 确认:' > /dev/tty
read -r confirmation < /dev/tty read -r confirmation < /dev/tty
@@ -12,20 +12,17 @@ fi
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then 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-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-sing-box.service 2>/dev/null || true
systemctl disable --now nexusgate-cert-renew.timer 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 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 systemctl daemon-reload
fi fi
if command -v rc-service >/dev/null; then if command -v rc-service >/dev/null; then
rc-service nexusgate-agent stop 2>/dev/null || true 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-service nexusgate-sing-box stop 2>/dev/null || true
rc-update del nexusgate-agent default 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-sing-box
rm -f -- /etc/init.d/nexusgate-agent /etc/init.d/nexusgate-xray /etc/init.d/nexusgate-sing-box
rm -f -- /etc/periodic/daily/nexusgate-cert-renew rm -f -- /etc/periodic/daily/nexusgate-cert-renew
rm -f -- /etc/periodic/hourly/nexusgate-agent-logrotate rm -f -- /etc/periodic/hourly/nexusgate-agent-logrotate
fi 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/logrotate.d/nexusgate-agent
rm -f -- /etc/letsencrypt/renewal-hooks/deploy/nexusgate-reload.sh 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 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 'Certbot 管理的证书仍保留;若曾使用 CF DNS 验证,其令牌已删除,保留的续签任务需另行配置或在确认没有其他服务使用后用 certbot delete 清理。\n'
printf '请在控制台删除/遗忘此设备,以撤销其 Agent 密钥和登记。\n' printf '请在控制台删除/遗忘此设备,以撤销其 Agent 密钥和登记。\n'
+7 -11
View File
@@ -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" 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 升级脚本下载不完整' [[ -s "$tmp_dir/update.sh" ]] || die 'Agent 升级脚本下载不完整'
node --check "$tmp_dir/agent.js" node --check "$tmp_dir/agent.js"
# Updating can rewrite an older HY2 configuration and restart Xray. Persist # Updating restarts sing-box. Persist its current bidirectional counters
# its current bidirectional counters before the running process is replaced. # before the running process is replaced.
if [[ -f /opt/nexusgate-agent/agent.js ]]; then 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 ) \ ( set -a; source /etc/nexusgate/agent.env; set +a; node /opt/nexusgate-agent/agent.js flush-usage ) \
|| die '更新前流量统计上报失败,保留原 Agent;检查控制面连接和 ng-agent doctor 后重试' || die '更新前流量统计上报失败,保留原 Agent;检查控制面连接和 ng-agent doctor 后重试'
@@ -50,16 +50,13 @@ case "${1:-}" in
esac esac
EOF EOF
chmod 0755 /usr/local/sbin/ng-agent chmod 0755 /usr/local/sbin/ng-agent
if [[ "${NG_INSTALL_ANYTLS:-0}" == 1 ]]; then # sing-box 是唯一的入口引擎;缺失时直接安装。
if ! NG_REPO="$REPO" NG_BRANCH="$BRANCH" ng-agent-singbox install; then if [[ ! -x /usr/local/bin/nexusgate-sing-box || ! -x /usr/local/bin/nexusgate-sing-box-stats ]]; then
info 'sing-box 构建未完成;现有 Xray 节点正常。修复后运行 ng-agent engine install 重试。' NG_REPO="$REPO" NG_BRANCH="$BRANCH" ng-agent-singbox install \
fi || die 'sing-box 引擎安装失败;修复后运行 ng-agent engine install 重试'
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。'
fi fi
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then 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-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 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 systemctl daemon-reload
rm -f -- /etc/nexusgate/last-heartbeat.json 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 重启失败" systemctl is-active --quiet nexusgate-agent.service || die "Agent 重启失败"
elif command -v rc-service >/dev/null; then 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-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 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 if [[ -e /etc/init.d/crond ]]; then
rc-update add crond default >/dev/null 2>&1 || true rc-update add crond default >/dev/null 2>&1 || true
rc-service crond start >/dev/null 2>&1 || true rc-service crond start >/dev/null 2>&1 || true
-71
View File
@@ -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
View File
@@ -25,52 +25,107 @@ while [[ $# -gt 0 ]]; do
done done
[[ "${EUID}" -eq 0 ]] || die "请使用 root 运行" [[ "${EUID}" -eq 0 ]] || die "请使用 root 运行"
[[ -f /etc/debian_version ]] || die "控制面一键安装器当前支持 Debian 12 / Ubuntu 22.04+"
[[ ! -e /opt/nexusgate ]] || die "检测到已有安装,请运行 nexusgate update" [[ ! -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 if [[ -z "$DOMAIN" ]]; then
[[ "$DOMAIN" =~ ^[A-Za-z0-9.-]+$ ]] || die "域名格式不正确" 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 if [[ -z "$EMAIL" ]]; then EMAIL="$(ask '证书邮箱(可留空):')"; fi
info "安装系统依赖" info "安装系统依赖"
export DEBIAN_FRONTEND=noninteractive if $IS_OPENWRT; then
apt-get update -qq # 注意:OpenWrt 上需预先装好 bash(本脚本依赖 bash):opkg update && opkg install bash
apt-get install -y -qq ca-certificates curl tar openssl nodejs caddy 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="$(node -p 'process.versions.node.split(`.`)[0]')"
[[ "$node_major" -ge 18 ]] || die "需要 Node.js 18 或更高版本" [[ "$node_major" -ge 18 ]] || die "需要 Node.js 18 或更高版本"
tmp_dir="$(mktemp -d /tmp/nexusgate-install.XXXXXX)" # NG_LOCAL_SRC 指向本地源码目录时跳过 GitHub 下载(本地改动尚未 push 时的全新安装用这个)
trap 'rm -rf -- "$tmp_dir"' EXIT src_dir=""
info "下载 ${REPO}@${BRANCH}" if [[ -n "${NG_LOCAL_SRC:-}" ]]; then
curl -fL --retry 3 "https://github.com/${REPO}/archive/refs/heads/${BRANCH}.tar.gz" -o "$tmp_dir/source.tgz" [[ -f "${NG_LOCAL_SRC}/server.js" ]] || die "NG_LOCAL_SRC 目录不完整(缺少 server.js)"
mkdir -p "$tmp_dir/source" info "使用本地源码 ${NG_LOCAL_SRC}"
tar -xzf "$tmp_dir/source.tgz" -C "$tmp_dir/source" --strip-components=1 src_dir="${NG_LOCAL_SRC}"
[[ -f "$tmp_dir/source/server.js" ]] || die "安装包不完整" 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 if $IS_OPENWRT; then
id nexusgate >/dev/null 2>&1 || useradd --system --gid nexusgate --home-dir /var/lib/nexusgate --shell /usr/sbin/nologin nexusgate # OpenWrt 是单用户系统,不建专用账号;/var 是内存盘,数据必须放闪存
install -d -o root -g root -m 0755 /opt/nexusgate DATA_DIR=/etc/nexusgate/data
cp -a "$tmp_dir/source/." /opt/nexusgate/ install -d -m 0755 /opt/nexusgate
install -d -o nexusgate -g nexusgate -m 0700 /var/lib/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')" admin_password="$(openssl rand -base64 24 | tr -d '\n')"
session_secret="$(openssl rand -hex 32)" session_secret="$(openssl rand -hex 32)"
cat > /etc/nexusgate.env <<EOF cat > /etc/nexusgate.env <<EOF
NODE_ENV=production NODE_ENV=production
NG_HOST=127.0.0.1 NG_HOST=${NG_HOST}
NG_PORT=8787 NG_PORT=8787
NG_DATA_FILE=/var/lib/nexusgate/nexusgate.json NG_DATA_FILE=${DATA_DIR}/nexusgate.json
NG_COOKIE_SECURE=true NG_COOKIE_SECURE=${NG_COOKIE_SECURE}
NG_ADMIN_USERNAME=admin NG_ADMIN_USERNAME=admin
NG_ADMIN_PASSWORD=${admin_password} NG_ADMIN_PASSWORD=${admin_password}
NG_SESSION_SECRET=${session_secret} NG_SESSION_SECRET=${session_secret}
EOF EOF
chmod 0600 /etc/nexusgate.env 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 install -m 0755 /opt/nexusgate/scripts/nexusgate.sh /usr/local/sbin/nexusgate
ln -sf /usr/local/sbin/nexusgate /usr/local/sbin/ng 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 install -d -m 0755 /etc/caddy/Caddyfile.d
cat > /etc/caddy/Caddyfile.d/nexusgate.caddy <<EOF cat > /etc/caddy/Caddyfile.d/nexusgate.caddy <<EOF
${DOMAIN} { ${DOMAIN} {
@@ -96,23 +151,48 @@ fi
caddy fmt --overwrite /etc/caddy/Caddyfile caddy fmt --overwrite /etc/caddy/Caddyfile
caddy fmt --overwrite /etc/caddy/Caddyfile.d/nexusgate.caddy caddy fmt --overwrite /etc/caddy/Caddyfile.d/nexusgate.caddy
caddy validate --config /etc/caddy/Caddyfile caddy validate --config /etc/caddy/Caddyfile
fi
systemctl daemon-reload if $IS_OPENWRT; then
systemctl enable --now nexusgate.service /etc/init.d/nexusgate enable
/etc/init.d/nexusgate start
else
systemctl daemon-reload
systemctl enable --now nexusgate.service
fi
healthy=false healthy=false
for _ in {1..30}; do for _ in {1..30}; do
if curl -fsS http://127.0.0.1:8787/healthz >/dev/null 2>&1; then healthy=true; break; fi if curl -fsS http://127.0.0.1:8787/healthz >/dev/null 2>&1; then healthy=true; break; fi
sleep 1 sleep 1
done done
if [[ "$healthy" != true ]]; then if [[ "$healthy" != true ]]; then
systemctl status nexusgate --no-pager || true if $IS_OPENWRT; then
journalctl -u nexusgate -n 100 --no-pager || true 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 "控制面启动失败,诊断信息已输出" die "控制面启动失败,诊断信息已输出"
fi 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 sed -i '/^NG_ADMIN_PASSWORD=/d' /etc/nexusgate.env
systemctl enable --now caddy if [[ -n "$DOMAIN" ]]; then
systemctl reload caddy 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 '\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' printf '管理命令:nexusgate 或 ng\n请立即登录并妥善保存密码。\n'
+58 -37
View File
@@ -8,13 +8,25 @@ die() { printf '错误:%s\n' "$*" >&2; exit 1; }
info() { printf '\033[1;36m[NexusGate]\033[0m %s\n' "$*"; } info() { printf '\033[1;36m[NexusGate]\033[0m %s\n' "$*"; }
need_root() { [[ "${EUID}" -eq 0 ]] || die "请使用 root 运行"; } 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() { backup() {
need_root need_root
local output="${1:-/root/nexusgate-backup-$(date +%Y%m%d-%H%M%S).tar.gz}" local output="${1:-/root/nexusgate-backup-$(date +%Y%m%d-%H%M%S).tar.gz}"
local stage local stage
stage="$(mktemp -d /tmp/nexusgate-backup.XXXXXX)" stage="$(mktemp -d /tmp/nexusgate-backup.XXXXXX)"
trap 'rm -rf -- "$stage"' RETURN 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" 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" [[ -f /etc/caddy/Caddyfile.d/nexusgate.caddy ]] && cp -a /etc/caddy/Caddyfile.d/nexusgate.caddy "$stage/nexusgate.caddy"
tar -czf "$output" -C "$stage" . tar -czf "$output" -C "$stage" .
@@ -35,15 +47,14 @@ restore() {
[[ -f "$stage/data/nexusgate.json" && -f "$stage/nexusgate.env" ]] || die "备份包不完整" [[ -f "$stage/data/nexusgate.json" && -f "$stage/nexusgate.env" ]] || die "备份包不完整"
safety="/root/nexusgate-before-restore-$(date +%Y%m%d-%H%M%S).tar.gz" safety="/root/nexusgate-before-restore-$(date +%Y%m%d-%H%M%S).tar.gz"
backup "$safety" backup "$safety"
systemctl stop nexusgate panel_stop
find /var/lib/nexusgate -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + find "$DATA_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
cp -a "$stage/data/." /var/lib/nexusgate/ cp -a "$stage/data/." "$DATA_DIR/"
chown -R nexusgate:nexusgate /var/lib/nexusgate own_data
chmod 0700 /var/lib/nexusgate
cp -a "$stage/nexusgate.env" /etc/nexusgate.env && chmod 0600 /etc/nexusgate.env 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 if [[ -f "$stage/nexusgate.caddy" ]]; then cp -a "$stage/nexusgate.caddy" /etc/caddy/Caddyfile.d/nexusgate.caddy; fi
systemctl start nexusgate panel_start
systemctl reload caddy || true caddy_reload || true
info "恢复完成;恢复前快照:$safety" 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" 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 mkdir -p "$stage/source" && tar -xzf "$stage/source.tgz" -C "$stage/source" --strip-components=1
[[ -f "$stage/source/server.js" ]] || die "更新包不完整" [[ -f "$stage/source/server.js" ]] || die "更新包不完整"
systemctl stop nexusgate panel_stop
cp -a "$stage/source/." /opt/nexusgate/ 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 install -m 0755 /opt/nexusgate/scripts/nexusgate.sh /usr/local/sbin/nexusgate
chown -R nexusgate:nexusgate /var/lib/nexusgate own_data
chmod 0700 /var/lib/nexusgate [[ ! -f "$DATA_DIR/nexusgate.json" ]] || chmod 0600 "$DATA_DIR/nexusgate.json"
[[ ! -f /var/lib/nexusgate/nexusgate.json ]] || chmod 0600 /var/lib/nexusgate/nexusgate.json panel_start
systemctl daemon-reload && systemctl start nexusgate
local healthy=false local healthy=false
for _ in {1..30}; do for _ in {1..30}; do
if curl -fsS http://127.0.0.1:8787/healthz >/dev/null 2>&1; then healthy=true; break; fi if curl -fsS http://127.0.0.1:8787/healthz >/dev/null 2>&1; then healthy=true; break; fi
sleep 1 sleep 1
done done
if [[ "$healthy" != true ]]; then if [[ "$healthy" != true ]]; then
systemctl status nexusgate --no-pager || true panel_logs 35 || true
journalctl -u nexusgate -n 100 --no-pager || true
die "更新后控制面启动失败;可使用上方备份恢复" die "更新后控制面启动失败;可使用上方备份恢复"
fi fi
if [[ -f /etc/nexusgate/agent.env ]]; then if [[ -f /etc/nexusgate/agent.env ]]; then
@@ -94,16 +108,16 @@ change_domain() {
[[ "$domain" =~ ^[A-Za-z0-9.-]+$ ]] || die "域名格式不正确" [[ "$domain" =~ ^[A-Za-z0-9.-]+$ ]] || die "域名格式不正确"
sed -i -E "1s/^[^ ]+/${domain}/" /etc/caddy/Caddyfile.d/nexusgate.caddy sed -i -E "1s/^[^ ]+/${domain}/" /etc/caddy/Caddyfile.d/nexusgate.caddy
caddy fmt --overwrite /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}" info "域名已更新为 https://${domain}"
} }
certificate_status() { certificate_status() {
need_root need_root
caddy validate --config /etc/caddy/Caddyfile caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy caddy_reload
info "Caddy 已校验并重新加载;证书会在到期前自动续签" 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() { change_account() {
@@ -119,18 +133,18 @@ change_account() {
[[ "$first" == "$second" ]] || die "两次输入不一致" [[ "$first" == "$second" ]] || die "两次输入不一致"
[[ -n "$next" || -n "$first" ]] || die "账号与密码都没有更改" [[ -n "$next" || -n "$first" ]] || die "账号与密码都没有更改"
[[ -z "$first" || ${#first} -ge 10 ]] || die "密码至少需要 10 个字符" [[ -z "$first" || ${#first} -ge 10 ]] || die "密码至少需要 10 个字符"
systemctl stop nexusgate panel_stop
set +e 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=$? local result=$?
unset first second unset first second
set -e set -e
if [[ $result -eq 0 ]]; then if [[ $result -eq 0 ]]; then
chown nexusgate:nexusgate /var/lib/nexusgate/nexusgate.json if ! $IS_OPENWRT; then chown nexusgate:nexusgate "$DATA_DIR/nexusgate.json"; fi
chmod 0600 /var/lib/nexusgate/nexusgate.json chmod 0600 "$DATA_DIR/nexusgate.json"
sed -i '/^NG_ADMIN_PASSWORD=/d' /etc/nexusgate.env sed -i '/^NG_ADMIN_PASSWORD=/d' /etc/nexusgate.env
fi fi
systemctl start nexusgate panel_start
[[ $result -eq 0 ]] || die "管理员账号更新失败" [[ $result -eq 0 ]] || die "管理员账号更新失败"
info "管理员账号已更新,现有登录会话将在服务重启后失效" info "管理员账号已更新,现有登录会话将在服务重启后失效"
} }
@@ -142,13 +156,20 @@ uninstall_panel() {
[[ "$confirm" == "UNINSTALL" ]] || die "已取消卸载" [[ "$confirm" == "UNINSTALL" ]] || die "已取消卸载"
archive="/root/nexusgate-before-uninstall-$(date +%Y%m%d-%H%M%S).tar.gz" archive="/root/nexusgate-before-uninstall-$(date +%Y%m%d-%H%M%S).tar.gz"
backup "$archive" backup "$archive"
systemctl disable --now nexusgate.service >/dev/null 2>&1 || true if $IS_OPENWRT; then
rm -f -- /etc/systemd/system/nexusgate.service /etc/nexusgate.env /etc/caddy/Caddyfile.d/nexusgate.caddy /usr/local/sbin/ng /usr/local/sbin/nexusgate /etc/init.d/nexusgate stop >/dev/null 2>&1 || true
rm -rf -- /opt/nexusgate /var/lib/nexusgate /etc/init.d/nexusgate disable >/dev/null 2>&1 || true
systemctl daemon-reload rm -f -- /etc/init.d/nexusgate
systemctl reload caddy >/dev/null 2>&1 || true else
userdel nexusgate >/dev/null 2>&1 || true systemctl disable --now nexusgate.service >/dev/null 2>&1 || true
groupdel nexusgate >/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" printf '\nNexusGate 已卸载。可恢复备份:%s\n' "$archive"
} }
@@ -164,9 +185,9 @@ menu() {
4) backup ;; 4) backup ;;
5) restore ;; 5) restore ;;
6) change_account ;; 6) change_account ;;
7) systemctl status nexusgate --no-pager ;; 7) panel_status ;;
8) need_root; systemctl restart nexusgate caddy; info '已重启' ;; 8) need_root; panel_restart; caddy_reload; info '已重启' ;;
9) journalctl -u nexusgate -n 120 --no-pager ;; 9) panel_logs 120 ;;
10) uninstall_panel ;; 10) uninstall_panel ;;
0) exit 0 ;; 0) exit 0 ;;
*) die '无效选择' ;; *) die '无效选择' ;;
@@ -174,9 +195,9 @@ menu() {
} }
case "${1:-menu}" in case "${1:-menu}" in
status) systemctl status nexusgate --no-pager ;; status) panel_status ;;
restart) need_root; systemctl restart nexusgate caddy ;; restart) need_root; panel_restart; caddy_reload ;;
logs) journalctl -u nexusgate -n "${2:-120}" --no-pager ;; logs) panel_logs "${2:-120}" ;;
backup) backup "${2:-}" ;; backup) backup "${2:-}" ;;
restore) restore "${2:-}" ;; restore) restore "${2:-}" ;;
update) update_panel ;; update) update_panel ;;
+331 -154
View File
@@ -7,9 +7,9 @@ const net = require('node:net');
const { Store } = require('./lib/store'); const { Store } = require('./lib/store');
const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth'); const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth');
const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http'); const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http');
const { PROFILE_CATALOG, REALITY_PRESETS, validateEntryProtocol, validateProtocolPair, isRealityProtocol } = require('./lib/protocols'); const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol } = require('./lib/protocols');
const { Orchestrator, audit, id, nowIso, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator'); const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator');
const { formatSubscription } = require('./lib/subscriptions'); const { formatSubscription, nodeClientUri } = require('./lib/subscriptions');
const { redactSecrets } = require('./lib/redact'); const { redactSecrets } = require('./lib/redact');
const { clientIdentity, activeClients, observedIps, pruneAccess } = require('./lib/access'); const { clientIdentity, activeClients, observedIps, pruneAccess } = require('./lib/access');
@@ -83,47 +83,63 @@ function normalizeServer(body, current = {}) {
return next; 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 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 = { const next = {
...current, ...current,
name: 'name' in body ? requiredText(body.name, '线路名称') : current.name, name: 'name' in body ? requiredText(body.name, '节点名称') : current.name,
topology, serverId, protocol, topology,
relayServerIds: 'relayServerIds' in body ? asIds(body.relayServerIds) : (current.relayServerIds || []), exitServerId,
exitServerId: topology === 'direct' ? null : ('exitServerId' in body ? requiredText(body.exitServerId, '出口服务器 ID') : current.exitServerId), exitProtocol: direct ? null : ('exitProtocol' in body ? cleanText(body.exitProtocol, 64) : current.exitProtocol),
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)),
networkMode: 'networkMode' in body && ['ipv4', 'ipv6', 'dual'].includes(body.networkMode) ? body.networkMode : (current.networkMode || 'ipv4'), 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'), 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; } const portMode = 'portMode' in body ? (body.portMode === 'fixed' ? 'fixed' : 'random') : (current.portMode || 'random');
if (next.relayPortMode === 'fixed' && (!Number.isInteger(next.relayPort) || next.relayPort < 1024 || next.relayPort > 65535)) { const error = new Error('固定入口端口无效'); error.statusCode = 400; throw error; } next.portMode = portMode;
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; } // Only touch the port when the request changes addressing; otherwise keep it.
if (topology === 'direct') validateEntryProtocol(next.relayProtocol); else validateProtocolPair(next.relayProtocol, next.exitProtocol); // (portMode 在表单里总是存在,只有真正改变时才重新分配,避免编辑保存时端口乱跳)
if (isRealityProtocol(next.relayProtocol) && !next.realityServerName) { const error = new Error('Reality SNI 不能为空'); error.statusCode = 400; throw error; } const portModeChanged = ('portMode' in body) && portMode !== (current.portMode || 'random');
if (isRealityProtocol(next.relayProtocol) && (!Number.isInteger(next.realityDestPort) || next.realityDestPort < 1 || next.realityDestPort > 65535)) { const error = new Error('Reality 目标端口无效'); error.statusCode = 400; throw error; } 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; return next;
} }
function publicDeployment(item) { function publicNode(item) {
const { credentials, clientTemplate, ...safe } = item; const { exitCredentials, ssServerKey, ...safe } = item;
return { ...safe, ...(safe.error ? { error: redactSecrets(safe.error) } : {}) }; return { ...safe, ...(safe.error ? { error: redactSecrets(safe.error) } : {}) };
} }
function publicServer(item) { function publicServer(item) {
return { ...item, ...(item.engine ? { engine: { ...item.engine, detail: redactSecrets(item.engine.detail) } } : {}), 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, 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) { 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, observedIpCount: observedIps(store.data, item.id).length,
subscriptionClientCount: activeClients(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); const server = data.servers.find((item) => item.id === agent.serverId);
if (!server) throw Object.assign(new Error('设备不存在'), { statusCode:404 }); if (!server) throw Object.assign(new Error('设备不存在'), { statusCode:404 });
normalizeServer({ tlsDomain:domain }, server); normalizeServer({ tlsDomain:domain }, server);
if (server.tlsDomain && server.tlsDomain !== domain && data.deployments.some((item) => if (server.tlsDomain && server.tlsDomain !== domain && data.nodes.some((item) =>
item.serverId === server.id && ['relay','direct'].includes(item.role) && item.serverId === server.id && ['anytls','hysteria2','vless-ws-tls'].includes(item.protocol) &&
['anytls','hysteria2','vless-ws-tls'].includes(item.protocol) && ['active','deploying'].includes(item.status))) {
['active','queued','applying'].includes(item.status) && !item.archived)) { throw Object.assign(new Error('设备已有使用其他域名的 TLS 节点;请先处理现有节点'), { statusCode:409 });
throw Object.assign(new Error('设备已有使用其他域名的 TLS 节点;请先处理现有线路'), { statusCode:409 });
} }
if (server.tlsDomain !== domain) { if (server.tlsDomain !== domain) {
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) : []) { for (const sample of Array.isArray(body.observations) ? body.observations.slice(0, 1000) : []) {
const customerId = cleanText(sample.customerId, 100); const customerId = cleanText(sample.customerId, 100);
const ip = cleanText(sample.ip, 64).replace(/^\[|\]$/g, ''); const ip = cleanText(sample.ip, 64).replace(/^\[|\]$/g, '');
if (!net.isIP(ip) || !data.deployments.some((item) => item.serverId === agent.serverId && if (!net.isIP(ip) || !data.nodeClients.some((nc) => nc.customerId === customerId && nc.status === 'active' &&
item.customerId === customerId && ['relay', 'direct'].includes(item.role) && item.status === 'active' && !item.archived)) continue; 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); const existing = data.observations.find((item) => item.customerId === customerId && item.ip === ip);
if (existing) existing.lastSeenAt = nowIso(); if (existing) existing.lastSeenAt = nowIso();
else data.observations.push({ id: id('obs'), customerId, ip, firstSeenAt: nowIso(), 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, { sendJson(res, 200, {
counts: { counts: {
servers: data.servers.length, online, customers: data.customers.length, 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 queuedJobs: data.jobs.filter((item) => ['queued', 'running'].includes(item.status)).length
}, },
version: VERSION, version: VERSION,
activity: data.activity.slice(0, 12), 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; return true;
} }
@@ -475,11 +490,12 @@ async function handleAdminApi(req, res, pathname) {
await store.transaction((data) => { await store.transaction((data) => {
const index = data.servers.findIndex((item) => item.id === serverItem.id); const index = data.servers.findIndex((item) => item.id === serverItem.id);
if (index < 0) throw Object.assign(new Error('服务器不存在'), { statusCode: 404 }); if (index < 0) throw Object.assign(new Error('服务器不存在'), { statusCode: 404 });
if (data.deployments.some((item) => item.serverId === serverItem.id && item.status !== 'deleted')) { const referencing = data.nodes.filter((item) => item.serverId === serverItem.id || item.exitServerId === serverItem.id);
throw Object.assign(new Error('设备仍有待清理资源。先删除关联线路并等待 Agent 上线确认清理,再删除设备'), { statusCode: 409 }); 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))) { if (referencing.length) {
throw Object.assign(new Error('设备仍被线路引用。请先编辑或删除关联线路'), { statusCode: 409 }); throw Object.assign(new Error('设备仍被节点引用。请先删除关联节点'), { statusCode: 409 });
} }
data.servers.splice(index, 1); data.servers.splice(index, 1);
for (const agent of data.agents.filter((item) => item.serverId === serverItem.id)) agent.status = 'revoked'; 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) => { const count = await store.transaction((data) => {
if (!data.servers.some((item) => item.id === cleanup.id)) throw Object.assign(new Error('服务器不存在'), { statusCode: 404 }); if (!data.servers.some((item) => item.id === cleanup.id)) throw Object.assign(new Error('服务器不存在'), { statusCode: 404 });
let queued = 0; let queued = 0;
for (const deployment of data.deployments.filter((item) => item.serverId === cleanup.id && item.archived && item.status !== 'deleted')) { for (const node of data.nodes.filter((item) => (item.serverId === cleanup.id || item.exitServerId === cleanup.id) && item.status === 'removing')) {
const pending = data.jobs.some((job) => job.deploymentId === deployment.id && job.action === 'delete_resource' && ['queued', 'running'].includes(job.status)); const legs = [];
if (pending) continue; if (node.serverId === cleanup.id) legs.push(['node', node.resourceId]);
data.jobs.push({ id: id('job'), serverId: cleanup.id, deploymentId: deployment.id, action: 'delete_resource', if (node.exitServerId === cleanup.id && forwardNode(node) && node.exitResourceId) legs.push(['node-exit', node.exitResourceId]);
payload: { resourceId: deployment.resourceId }, status: 'queued', attempts: 0, createdAt: nowIso(), updatedAt: nowIso(), error: null, leaseUntil: null }); for (const [kind, resourceId] of legs) {
deployment.status = 'removing'; deployment.updatedAt = nowIso(); queued += 1; 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 }); audit(data, actor, 'retry_cleanup', cleanup.id, { queued });
return queued; return queued;
@@ -530,17 +551,15 @@ async function handleAdminApi(req, res, pathname) {
const server = data.servers[index]; const server = data.servers[index];
if (body.confirm !== 'FORGET' || body.name !== server.name) throw Object.assign(new Error('请输入准确设备名称以确认'), { statusCode: 400 }); 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 (server.status === 'online') throw Object.assign(new Error('在线设备不能强制遗忘,请先正常停用并清理资源'), { statusCode: 409 });
if (data.chains.some((item) => item.exitServerId === server.id || item.relayServerIds.includes(server.id))) { const residual = data.nodes.filter((item) => item.serverId === server.id || item.exitServerId === server.id);
throw Object.assign(new Error('设备仍被线路引用,请先删除关联线路'), { statusCode: 409 }); if (residual.some((item) => item.status !== 'removing')) 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 residualIds = new Set(residual.map((item) => item.id)); 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 }); throw Object.assign(new Error('设备任务尚在执行,不能强制遗忘'), { statusCode: 409 });
} }
data.jobs = data.jobs.filter((item) => !residualIds.has(item.deploymentId)); data.jobs = data.jobs.filter((item) => !residualIds.has(item.nodeId));
data.deployments = data.deployments.filter((item) => !residualIds.has(item.id)); 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'; for (const agent of data.agents.filter((item) => item.serverId === server.id)) agent.status = 'revoked';
data.servers.splice(index, 1); data.servers.splice(index, 1);
audit(data, actor, 'force_forget_server', server.id, { name: server.name, unconfirmedResources: residual.length }); 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); const customerItem = route('/api/customers/:id', pathname);
if (customerItem && req.method === 'PATCH') { if (customerItem && req.method === 'PATCH') {
const body = await readJson(req); 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); const item = data.customers.find((entry) => entry.id === customerItem.id);
if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 }); if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
const wasActive = item.status === 'active'; const wasActive = item.status === 'active';
@@ -617,7 +636,7 @@ async function handleAdminApi(req, res, pathname) {
item.status = 'active'; item.suspendReason = null; item.status = 'active'; item.suspendReason = null;
} }
if (wasActive && item.status === 'suspended') suspendCustomerResources(data, item, actor); 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(); item.updatedAt = nowIso();
audit(data, actor, 'update_customer', item.id); audit(data, actor, 'update_customer', item.id);
return structuredClone(item); return structuredClone(item);
@@ -627,12 +646,12 @@ async function handleAdminApi(req, res, pathname) {
} }
const resetUsage = route('/api/customers/:id/reset-usage', pathname); const resetUsage = route('/api/customers/:id/reset-usage', pathname);
if (resetUsage && req.method === 'POST') { if (resetUsage && req.method === 'POST') {
await store.transaction((data) => { await store.transaction(async (data) => {
const item = data.customers.find((entry) => entry.id === resetUsage.id); const item = data.customers.find((entry) => entry.id === resetUsage.id);
if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 }); if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
item.usedBytes = 0; item.usedUplinkBytes = 0; item.usedDownlinkBytes = 0; item.usedBytes = 0; item.usedUplinkBytes = 0; item.usedDownlinkBytes = 0;
if (item.status === 'suspended' && item.suspendReason === 'traffic_limit' && !customerBlockReason(data, item)) { 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); 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) => { await store.transaction((data) => {
const index = data.customers.findIndex((item) => item.id === customerItem.id); const index = data.customers.findIndex((item) => item.id === customerItem.id);
if (index < 0) throw Object.assign(new Error('客户不存在'), { statusCode: 404 }); if (index < 0) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
if (data.chains.some((item) => item.customerIds.includes(customerItem.id))) { if (data.nodeClients.some((item) => item.customerId === customerItem.id &&
throw Object.assign(new Error('客户仍被线路引用。请先移除或删除关联线路'), { statusCode: 409 }); data.nodes.some((node) => node.id === item.nodeId && !node.pendingDelete))) {
} 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 });
} }
data.customers.splice(index, 1); data.customers.splice(index, 1);
data.subscriptionAccess = data.subscriptionAccess.filter((item) => item.customerId !== customerItem.id); data.subscriptionAccess = data.subscriptionAccess.filter((item) => item.customerId !== customerItem.id);
data.subscriptionClients = data.subscriptionClients.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); 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);
audit(data, actor, 'delete_customer', customerItem.id, { pendingCleanup: data.deployments.filter((item) => item.customerId === customerItem.id && item.archived && item.status !== 'deleted').length });
}); });
sendJson(res, 200, { ok: true }); sendJson(res, 200, { ok: true });
return true; return true;
} }
if (req.method === 'GET' && pathname === '/api/chains') { if (req.method === 'GET' && pathname === '/api/nodes') {
sendJson(res, 200, { chains: store.data.chains, deployments: store.data.deployments.filter((item) => !item.archived).map(publicDeployment) }); 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; return true;
} }
if (req.method === 'POST' && pathname === '/api/chains') { if (req.method === 'POST' && pathname === '/api/nodes') {
const body = await readJson(req); const body = await readJson(req);
const chain = await store.transaction((data) => { const node = await store.transaction((data) => {
const normalized = normalizeNode(body, {}, data);
const next = { const next = {
...normalizeChain(body), id: id('chn'), status: 'draft', generation: 0, id: id('nod'), status: 'draft', entryReady: false, exitReady: false,
createdAt: nowIso(), updatedAt: nowIso(), redeployPending: 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); ensureNodeSecrets(next);
audit(data, actor, 'create_chain', next.id, { name: next.name }); data.nodes.push(next);
audit(data, actor, 'create_node', next.id, { name: next.name });
return structuredClone(next); return structuredClone(next);
}); });
sendJson(res, 201, { chain }); sendJson(res, 201, { node: publicNode(node) });
return true; return true;
} }
const deploy = route('/api/chains/:id/deploy', pathname); const nodeItem = route('/api/nodes/:id', pathname);
if (deploy && req.method === 'POST') { if (nodeItem && req.method === 'PATCH') {
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 body = await readJson(req); const body = await readJson(req);
const chain = await store.transaction((data) => { const node = await store.transaction(async (data) => {
const item = data.chains.find((entry) => entry.id === chainItem.id); const item = data.nodes.find((entry) => entry.id === nodeItem.id);
if (!item) throw Object.assign(new Error('线路不存在'), { statusCode: 404 }); if (!item) throw Object.assign(new Error('节点不存在'), { statusCode: 404 });
const hasActive = data.deployments.some((entry) => entry.chainId === item.id && !['deleted', 'failed'].includes(entry.status)); if (item.status === 'removing') throw Object.assign(new Error('节点正在移除中,请等待完成后再编辑'), { statusCode: 409 });
Object.assign(item, normalizeChain(body, item), { updatedAt: nowIso() }); const oldServerId = item.serverId;
item.status = hasActive ? 'changes_pending' : 'draft'; const oldExitServerId = item.exitServerId;
audit(data, actor, 'update_route', item.id, { requiresRedeploy: hasActive }); const oldForward = forwardNode(item);
return { item: structuredClone(item), requiresRedeploy: hasActive }; const oldProtocol = item.protocol;
}); Object.assign(item, normalizeNode(body, item, data));
sendJson(res, 200, { chain: chain.item, requiresRedeploy: chain.requiresRedeploy }); return true; ensureNodeSecrets(item);
} // 协议变更可能导致旧凭据长度非法(如 SS2022 密钥长度),重发全部客户端凭据。
if (chainItem && req.method === 'DELETE') { if (item.protocol !== oldProtocol) {
const cleanupPending = await store.transaction((data) => { for (const nc of data.nodeClients.filter((entry) => entry.nodeId === item.id)) {
const index = data.chains.findIndex((item) => item.id === chainItem.id); nc.credentials = newNodeClientCredentials(item.protocol);
if (index < 0) throw Object.assign(new Error('链路不存在'), { statusCode: 404 }); nc.updatedAt = nowIso();
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();
} }
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 }); if (item.serverId !== oldServerId) {
return deployments.length; 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; return true;
} }
@@ -779,8 +823,144 @@ async function handleAdminApi(req, res, pathname) {
return false; 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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) { 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; if (!match) return false;
const headers = { 'cache-control':'no-store, private', 'x-robots-tag':'noindex, nofollow', 'vary':'User-Agent' }; 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; } 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); }); await store.transaction((data) => { data.subscriptionAccess.push(access); pruneAccess(data); });
sendJson(res, 403, { message:'客户已停用、到期或流量用尽' }, headers); return true; 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 format = ({ v2ray:'base64', shadowrocket:'base64', mihomo:'clash-smart' })[match[2]] || match[2];
const result = formatSubscription(store.data, customer, format, String(req.headers['user-agent'] || '')); const result = formatSubscription(store.data, customer, format, String(req.headers['user-agent'] || ''));
if (!result) { if (!result) {
const restoring = store.data.deployments.some((item) => item.customerId === customer.id && !item.archived && const restoring = store.data.nodeClients.some((nc) => nc.customerId === customer.id && nc.status === 'active' &&
['relay','direct'].includes(item.role) && ['queued','removing'].includes(item.status) && store.data.nodes.some((node) => node.id === nc.nodeId && ['deploying','removing'].includes(node.status)) &&
store.data.jobs.some((job) => job.deploymentId === item.id && ['queued','running'].includes(job.status))); store.data.jobs.some((job) => job.nodeId === nc.nodeId && ['queued','running'].includes(job.status)));
access.status = 503; access.reason = restoring ? '入口节点恢复中' : '没有已部署的入口节点'; access.status = 503; access.reason = restoring ? '入口节点恢复中' : '没有已部署的入口节点';
await store.transaction((data) => { data.subscriptionAccess.push(access); pruneAccess(data); }); 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; { ...headers, ...(restoring ? { 'retry-after':'15' } : {}) }); return true;
} }
const body = Buffer.from(result.body); const body = Buffer.from(result.body);
@@ -868,7 +1055,7 @@ async function requestHandler(req, res) {
async function housekeeping() { async function housekeeping() {
try { try {
const redeployIds = await store.transaction((data) => { await store.transaction(async (data) => {
const now = Date.now(); const now = Date.now();
for (const server of data.servers) { for (const server of data.servers) {
if (server.lastSeenAt && now - new Date(server.lastSeenAt).getTime() > 180000) server.status = 'offline'; 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 未在租约内确认,任务已自动重试'; } if (Number(job.attempts || 0) < 3) { job.status = 'queued'; job.error = 'Agent 未在租约内确认,任务已自动重试'; }
else { else {
job.status = 'failed'; job.error = 'Agent 连续 3 次未在租约内确认'; job.status = 'failed'; job.error = 'Agent 连续 3 次未在租约内确认';
const deployment = data.deployments.find((item) => item.id === job.deploymentId); const node = data.nodes.find((item) => item.id === job.nodeId);
if (deployment) { if (node) {
deployment.status = 'failed'; deployment.error = job.error; deployment.updatedAt = nowIso(); const kind = job.payload && (job.payload.kind || (job.payload.resource && job.payload.resource.meta && job.payload.resource.meta.kind));
const chain = data.chains.find((item) => item.id === deployment.chainId); node.status = kind === 'node-exit' ? 'degraded' : 'failed';
if (chain) { chain.status = 'degraded'; chain.updatedAt = nowIso(); } node.error = job.error; node.updatedAt = nowIso();
} }
} }
} }
@@ -893,10 +1080,10 @@ async function housekeeping() {
customer.status = 'suspended'; customer.status = 'suspended';
customer.suspendReason = expired ? 'expired' : 'traffic_limit'; customer.suspendReason = expired ? 'expired' : 'traffic_limit';
customer.updatedAt = nowIso(); 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)) { } else if (customer.status === 'suspended' && customer.suspendReason === 'ip_limit' && !customerBlockReason(data, customer)) {
customer.status = 'active'; customer.suspendReason = null; customer.updatedAt = nowIso(); 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; const jobCutoff = now - (data.settings.completedJobRetentionDays || 7) * 86400000;
@@ -904,18 +1091,8 @@ async function housekeeping() {
const activityCutoff = now - (data.settings.activityRetentionDays || 30) * 86400000; const activityCutoff = now - (data.settings.activityRetentionDays || 30) * 86400000;
data.activity = data.activity.filter((event) => new Date(event.at).getTime() > activityCutoff).slice(0, 2000); data.activity = data.activity.filter((event) => new Date(event.at).getTime() > activityCutoff).slice(0, 2000);
pruneAccess(data, now); 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(); sessions.prune();
} catch (error) { } catch (error) {
console.error('Housekeeping failed:', error); console.error('Housekeeping failed:', error);
@@ -926,11 +1103,11 @@ async function main() {
await store.init(); await store.init();
// Earlier Agents reported the full x25519 output on parse failures. // Earlier Agents reported the full x25519 output on parse failures.
// Remove any private key from persisted job/deployment history on upgrade. // 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.error && item.error !== redactSecrets(item.error) || item.lastError && item.lastError !== redactSecrets(item.lastError) ||
item.engine?.detail && item.engine.detail !== redactSecrets(item.engine.detail))) { item.engine?.detail && item.engine.detail !== redactSecrets(item.engine.detail))) {
await store.transaction((data) => { 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.error) item.error = redactSecrets(item.error);
if (item.lastError) item.lastError = redactSecrets(item.lastError); if (item.lastError) item.lastError = redactSecrets(item.lastError);
if (item.engine?.detail) item.engine.detail = redactSecrets(item.engine.detail); if (item.engine?.detail) item.engine.detail = redactSecrets(item.engine.detail);
+2 -2
View File
@@ -1,12 +1,12 @@
[Unit] [Unit]
Description=NexusGate node agent Description=NexusGate node agent
After=network-online.target nexusgate-xray.service After=network-online.target nexusgate-sing-box.service
Wants=network-online.target Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
EnvironmentFile=/etc/nexusgate/agent.env 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 Restart=always
RestartSec=5 RestartSec=5
NoNewPrivileges=true NoNewPrivileges=true
+2
View File
@@ -6,6 +6,8 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=root User=root
Environment=GOMEMLIMIT=64MiB
Environment=GOGC=50
ExecStart=/usr/local/bin/nexusgate-sing-box run -c /etc/nexusgate/sing-box/config.json ExecStart=/usr/local/bin/nexusgate-sing-box run -c /etc/nexusgate/sing-box/config.json
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
-15
View File
@@ -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
View File
@@ -7,7 +7,7 @@ const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const dgram = require('node:dgram'); const dgram = require('node:dgram');
const { spawn } = require('node:child_process'); 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'); const { redactSecrets } = require('../lib/redact');
test('Agent reports usable memory and system disk without reading per-connection state', () => { 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); 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', () => { test('realityKey generates an X25519 pair with Node crypto and caches it', () => {
const current = parseX25519('PrivateKey: current_private\nPassword (PublicKey): current_public\nHash32: ignored'); const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-reality-'));
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-'));
try { try {
const fakeXray = path.join(temp, 'fake-xray'); const keyFile = path.join(temp, 'keys.json');
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 { spawnSync } = require('node:child_process'); const { spawnSync } = require('node:child_process');
const result = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).activateConfig()`], { const script = `const { realityKey } = require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))});
env:{ ...process.env, NG_CONFIG_DIR:configDir, NG_XRAY_BIN:fakeXray }, encoding:'utf8' 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); assert.equal(run.status, 0, run.stderr);
const candidate = fs.readFileSync(path.join(temp, 'argument'), 'utf8').trim(); assert.equal(run.stdout, 'OK');
assert.equal(candidate, path.join(configDir, 'config.json.candidate.json')); } finally { fs.rmSync(temp, { recursive: true, force: true }); }
assert.equal(JSON.parse(fs.readFileSync(candidate, 'utf8')).inbounds[0].tag, 'api-in');
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
}); });
test('old Hysteria 2 resources are repaired without rotating authentication or changing saved resources', () => { test('applyResource rejects Xray resources and unprotected Hysteria 2 inbounds', async () => {
const legacy = { id:'hy2-entry', inbounds:[{ tag:'hy2-in', protocol:'hysteria', port:34537, const { applyResource } = require('../agent/agent');
settings:{ version:2, users:[{ auth:'unchanged-password', email:'ng:customer' }] }, await assert.rejects(
streamSettings:{ method:'hysteria', security:'tls', hysteriaSettings:{ version:2 } } }] }; applyResource({ resource: { id: 'xray-resource-1', engine: 'xray', inbounds: [] } }),
const before = JSON.stringify(legacy); /只支持 sing-box/);
const inbound = combinedConfig([legacy]).inbounds[1]; await assert.rejects(
assert.equal(inbound.streamSettings.network, 'hysteria'); applyResource({ resource: { id: 'hy2-noauth-1', engine: 'sing-box',
assert.equal(inbound.settings.clients[0].auth, 'unchanged-password'); inbounds: [{ type: 'hysteria2', tag: 'hy2', users: [] }] } }),
assert.deepEqual(inbound.settings.clients, inbound.settings.users); /认证账户/);
assert.equal(JSON.stringify(legacy), before);
assert.throws(() => combinedConfig([{ inbounds:[{ protocol:'hysteria', settings:{ version:2 } }] }]), /认证账户/);
}); });
test('UDP readiness inspection finds only ports owned by the target process', async () => { 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(); } } finally { socket.close(); other.kill(); }
}); });
test('Agent restart preserves a healthy Xray process when configuration is unchanged', () => { test('sing-box JSON counters are matched by name, inbound and per-user, including quoted values', () => {
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' } }];
const output = JSON.stringify({ stat:[ const output = JSON.stringify({ stat:[
{ name:'inbound>>>other>>>traffic>>>downlink', value:'8000' }, { name:'inbound>>>other>>>traffic>>>downlink', value:'8000' },
{ name:'inbound>>>ng-in>>>traffic>>>downlink', value:'2048' }, { 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(output), {
assert.deepEqual(parseUsageStats('{"stat":[]}', resources), []); inbounds: [
assert.deepEqual(parseUsageStats('{}', resources), []); { tag:'other', uplink:0, downlink:8000 },
assert.deepEqual(parseUsageStats(JSON.stringify({ stat:[ { tag:'ng-in', uplink:128, downlink:2048 },
{ name:'inbound>>>unrelated>>>traffic>>>uplink', value:'bad' }, ],
{ name:'inbound>>>ng-in>>>traffic>>>uplink', value:'42' } users: [
] }), resources), [{ resourceId:'resource-1', uplink:42, downlink:0 }]); { 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:[ assert.deepEqual(parseUsageStats(JSON.stringify({ stat:[
{ name:'inbound>>>ng-in>>>traffic>>>uplink' }, { name:'inbound>>>ng-in>>>traffic>>>uplink' },
{ name:'inbound>>>ng-in>>>traffic>>>downlink', value:'1024' } { 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:[ assert.throws(() => parseUsageStats(JSON.stringify({ stat:[
{ name:'inbound>>>ng-in>>>traffic>>>uplink', value:'undefined' } { name:'user>>>ng:cus-a>>>traffic>>>uplink', value:'undefined' }
] }), resources), /入口计数异常/); ] })), /入口计数异常/);
assert.throws(() => parseUsageStats(JSON.stringify({ stat:[ assert.throws(() => parseUsageStats(JSON.stringify({ stat:[
{ name:'inbound>>>ng-in>>>traffic>>>uplink', value:'-1' } { 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', () => { 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 }); } } 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-')); const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-usage-'));
try { try {
const dir = path.join(temp, 'xray'); const dir = path.join(temp, 'agent');
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true }); 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' } })); fs.writeFileSync(path.join(dir, 'resources', 'entry.json'), JSON.stringify({ id:'entry', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'entry-in' } }));
const fakeXray = path.join(temp, 'xray-bin'); const helper = path.join(temp, 'stats');
const argumentsFile = path.join(temp, 'args'); 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 { spawnSync } = require('node:child_process');
const script = `process.stdout.write(JSON.stringify(require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).queryUsage()))`; 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.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/); assert.doesNotMatch(fs.readFileSync(argumentsFile, 'utf8'), /reset/);
} finally { fs.rmSync(temp, { recursive:true, force:true }); } } 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 http = require('node:http');
const { spawn } = require('node:child_process'); const { spawn } = require('node:child_process');
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-flush-')); 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'); const bin = path.join(temp, 'bin');
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true }); fs.mkdirSync(path.join(dir, 'resources'), { recursive:true });
fs.mkdirSync(bin); 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(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'; 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 }); 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 = []; const seen = [];
@@ -216,9 +165,11 @@ test('adding an Xray resource submits old ingress counters before restarting the
}); });
try { try {
await new Promise((resolve) => controller.listen(0, '127.0.0.1', resolve)); 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, 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'] }); NG_AGENT_KEY:'test-agent-key', PATH:`${bin}:${process.env.PATH}` }, stdio:['ignore','pipe','pipe'] });
let stderr = ''; let stderr = '';
child.stderr.on('data', (chunk) => { stderr += chunk; }); 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(exit, 0, stderr);
assert.equal(seen.length, 1); assert.equal(seen.length, 1);
assert.equal(seen[0].path, '/api/agent/usage'); assert.equal(seen[0].path, '/api/agent/usage');
assert.deepEqual(seen[0].body.samples.map(({ resourceId, uplink, downlink }) => ({ resourceId, uplink, downlink })), assert.deepEqual(seen[0].body.samples.map(({ kind, id, uplink, downlink }) => ({ kind, id, uplink, downlink })),
[{ resourceId:'old-resource', uplink:123, downlink:456 }]); [{ kind:'inbound', id:'nod_1', uplink:123, downlink:456 }]);
assert.equal(seen[0].newResourceAlreadyWritten, false); assert.equal(seen[0].newResourceAlreadyWritten, false);
assert.match(fs.readFileSync(path.join(temp, 'service-commands'), 'utf8'), /restart/); 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, 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' }); 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(await new Promise((resolve) => flush.on('exit', resolve)), 0);
assert.equal(seen[1].path, '/api/agent/usage'); 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 }); 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', meta:{ kind:'direct', metricsTag:'third-in' }, inbounds:[], outbounds:[], routingRules:[] })}}).catch(error=>{console.error(error);process.exitCode=1})`; 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, 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' }); 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(await new Promise((resolve) => broken.on('exit', resolve)), 1);
assert.equal(fs.existsSync(path.join(dir, 'resources', 'third-resource.json')), false); 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', () => { test('sing-box config carries AnyTLS entries and meters each entry once', () => {
const { combinedConfig, combinedSingBoxConfig } = require('../agent/agent'); const { combinedSingBoxConfig } = require('../agent/agent');
const entry = { id:'entry', engine:'sing-box', meta:{ kind:'direct', customerId:'customer-a', metricsTag:'any-in' }, 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, 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', 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' } }], 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' }] }; 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]); const sing = combinedSingBoxConfig([entry]);
assert.equal(sing.inbounds[0].tag, 'any-in'); 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.inbounds, ['any-in']);
// 按用户统计需要显式列出用户名
assert.deepEqual(sing.experimental.v2ray_api.stats.users, ['ng:customer-a']);
assert.equal(sing.route.rules[0].outbound, 'any-direct'); assert.equal(sing.route.rules[0].outbound, 'any-direct');
assert.deepEqual(parseUsageStats(JSON.stringify({ stat:[ assert.deepEqual(parseUsageStats(JSON.stringify({ stat:[
{ name:'inbound>>>any-in>>>traffic>>>uplink', value:'10' }, { name:'inbound>>>any-in>>>traffic>>>uplink', value:'10' },
{ name:'inbound>>>any-in>>>traffic>>>downlink', value:'90' } ] }), [entry]), { name:'inbound>>>any-in>>>traffic>>>downlink', value:'90' },
[{ resourceId:'entry', uplink:10, downlink: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', () => { 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-')); const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-singbox-usage-'));
try { try {
const dir = path.join(temp, 'xray'); const dir = path.join(temp, 'agent');
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true }); fs.mkdirSync(path.join(dir, 'resources'), { recursive:true });
fs.writeFileSync(path.join(dir, 'resources', 'anytls.json'), JSON.stringify({ 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 helper = path.join(temp, 'stats');
const argumentsFile = path.join(temp, 'args'); 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 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], { 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.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']); assert.deepEqual(fs.readFileSync(argumentsFile, 'utf8').trim().split('\n'), ['--server=127.0.0.1:10086']);
} finally { fs.rmSync(temp, { recursive:true, force:true }); } } 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'); const dir = path.join(temp, 'config');
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true }); fs.mkdirSync(path.join(dir, 'resources'), { recursive:true });
fs.writeFileSync(path.join(dir, 'resources', 'entry.json'), JSON.stringify({ id:'entry', engine:'sing-box', 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 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 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'; 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
View File
@@ -24,7 +24,7 @@ async function waitFor(url) {
throw new Error('test server did not start'); 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 dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nexusgate-api-'));
const port = await freePort(); const port = await freePort();
const child = spawn(process.execPath, ['server.js'], { 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'); 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; 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'); 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); assert.equal(premature.status, 409);
const agentKeys = {}; const agentKeys = {};
for (const server of [relay, exit]) { for (const server of [relay, exit]) {
const ticket = await request(`/api/servers/${server.id}/enrollment-token`, 'POST', {}); 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); assert.equal(response.status, 201);
agentKeys[server.id] = (await response.json()).agentKey; 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' }, 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' }) }); body: JSON.stringify({ domain: 'entry.example.com' }) });
assert.equal(certSync.status, 200); assert.equal(certSync.status, 200);
assert.equal((await request('/api/servers')).servers.find((item) => item.id === relay.id).tlsDomain, 'entry.example.com'); 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'); const idleDatabase = await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8');
for (let count = 0; count < 10; count += 1) { for (let count = 0; count < 10; count += 1) {
for (const server of [relay, exit]) { for (const server of [relay, exit]) {
const headers = { authorization:`Bearer ${agentKeys[server.id]}`, 'content-type':'application/json' }; const headers = { authorization: `Bearer ${agentKeys[server.id]}`, 'content-type': 'application/json' };
const poll = await fetch(`${base}/api/agent/poll`, { method:'POST', headers, body:'{}' }); const poll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers, body: '{}' });
assert.deepEqual(await poll.json(), { job:null }); assert.deepEqual(await poll.json(), { job: null });
const heartbeat = await fetch(`${base}/api/agent/heartbeat`, { method:'POST', headers, const heartbeat = await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers,
body:JSON.stringify({ version:'0.6.8', system:{ memoryAvailable:512 * 1024 * 1024 } }) }); body: JSON.stringify({ version: '0.6.8', system: { memoryAvailable: 512 * 1024 * 1024 } }) });
assert.equal(heartbeat.status, 200); assert.equal(heartbeat.status, 200);
} }
} }
assert.equal(await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8'), idleDatabase); 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' }, 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' }) }); body: JSON.stringify({ domain: 'invalid/domain' }) });
assert.equal(badCertSync.status, 400); 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. // 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); 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' },
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' } }) });
body:JSON.stringify({ version:'0.4.0', engine:{ status:'ready', detail:'active' } }) });
assert.equal(deployed.deployments.length, 2);
const jobs = await request('/api/jobs'); const jobs = await request('/api/jobs');
assert.equal(jobs.jobs.length, 2); assert.equal(jobs.jobs.length, 2);
assert.ok(jobs.jobs.every((job) => job.status === 'queued')); 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 exitJob = (await exitPoll.json()).job;
const failed = await fetch(`${base}/api/agent/jobs/${exitJob.id}/complete`, { method:'POST', const failed = await fetch(`${base}/api/agent/jobs/${exitJob.id}/complete`, { method: 'POST',
headers:{ authorization:`Bearer ${agentKeys[exit.id]}`, 'content-type':'application/json' }, headers: { authorization: `Bearer ${agentKeys[exit.id]}`, 'content-type': 'application/json' },
body:JSON.stringify({ success:false, error:'PrivateKey: example_secret Password (PublicKey): example_public' }) }); body: JSON.stringify({ success: false, error: 'PrivateKey: example_secret Password (PublicKey): example_public' }) });
assert.equal(failed.status, 200); assert.equal(failed.status, 200);
assert.doesNotMatch(JSON.stringify(await request('/api/jobs')), /example_secret/); assert.doesNotMatch(JSON.stringify(await request('/api/jobs')), /example_secret/);
assert.doesNotMatch(JSON.stringify(await request('/api/chains')), /example_secret/); assert.doesNotMatch(JSON.stringify(await request('/api/nodes')), /example_secret/);
const changed = await request(`/api/chains/${chain.id}`, 'PATCH', { realityServerName: 'www.tesla.com' });
assert.equal(changed.requiresRedeploy, true); // Editing a live node rebuilds immediately (3x-ui style); protocol changes reissue client credentials.
assert.equal(changed.chain.status, 'changes_pending'); 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 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 direct = (await request('/api/nodes', 'POST', { name: 'IPv6 单机节点', serverId: relay.id, protocol: 'shadowsocks-2022-aes256',
const directDeploy = await request(`/api/chains/${direct.id}/deploy`, 'POST', {}); networkMode: 'ipv6', topology: 'direct' })).node;
assert.equal(directDeploy.deployments.length, 1); await request(`/api/nodes/${direct.id}/assign`, 'POST', { customerId: customerB.id });
assert.equal(directDeploy.deployments[0].role, 'direct'); await request(`/api/nodes/${direct.id}/deploy`, 'POST', {});
const noActive = await fetch(`${base}/s/${customerB.subscriptionToken}/base64`); const noActive = await fetch(`${base}/s/${customerB.subscriptionToken}/base64`);
assert.equal(noActive.status, 503); assert.equal(noActive.status, 503);
const enrollment = await request(`/api/servers/${relay.id}/enrollment-token`, 'POST', {}); 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(); const { agentKey } = await enrolled.json();
assert.equal(enrolled.status, 201); assert.equal(enrolled.status, 201);
for (let count = 0; count < 4; count += 1) { agentKeys[relay.id] = agentKey; // re-enrollment revokes the previous agent key
const polled = await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } }); const completeNext = async (key) => {
const polled = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${key}` } });
const { job } = await polled.json(); const { job } = await polled.json();
if (!job) break; assert.ok(job);
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' } } }) }); 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 activeNodes = (await request('/api/nodes')).nodes;
const response = await fetch(`${base}/api/agent/usage`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, assert.equal(activeNodes.find((item) => item.id === node.id).status, 'active');
body: JSON.stringify({ epoch, samples:[{ resourceId:directDeploy.deployments[0].resourceId, uplink, downlink }] }) }); 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); assert.equal(response.status, 200);
}; };
await usageReport(150, 350); await usageReport(150, 350);
await usageReport(150, 350); // Retry after a lost HTTP response must not double count. await usageReport(150, 350); // Retry after a lost HTTP response must not double count.
await usageReport(250, 900); await usageReport(250, 900);
await usageReport(150, 350); // A late report from the same process must not count a false reset. 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(10, 20, 'sing-box-start-2'); // sing-box 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(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); const metered = (await request('/api/customers')).customers.find((item) => item.id === customerB.id);
assert.equal(metered.usedBytes, 1180); assert.equal(metered.usedBytes, 1180);
assert.equal(metered.usedUplinkBytes, 260); assert.equal(metered.usedUplinkBytes, 260);
assert.equal(metered.usedDownlinkBytes, 920); assert.equal(metered.usedDownlinkBytes, 920);
const meteredDeployment = (await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id); // Inbound-level traffic lands on the node, not the customer.
assert.deepEqual(meteredDeployment.meteredTraffic, { uplink:260, downlink:920 }); 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`); const raw = await fetch(`${base}/s/${customerB.subscriptionToken}/raw`);
assert.equal(raw.status, 200); assert.equal(raw.status, 200);
assert.match(raw.headers.get('subscription-userinfo'), /upload=260; download=920;/); 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, 'sing-box-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');
assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).usedBytes, 4180); assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).usedBytes, 4180);
assert.match(await raw.text(), /^ss:\/\//); assert.match(await raw.text(), /^ss:\/\//);
assert.equal(raw.headers.get('cache-control'), 'no-store, private'); 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 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); 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; const nodeClients = (await request('/api/nodes')).nodeClients;
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:2000 }); 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); assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`)).status, 403);
const prematureResume = await fetch(`${base}/api/customers/${customerB.id}`, { method:'PATCH', 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' }) }); headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: JSON.stringify({ status: 'active' }) });
assert.equal(prematureResume.status, 409); 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; const deleteJob = (await deletePoll.json()).job;
assert.equal(deleteJob.action, 'delete_resource'); 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 }) }); 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'); assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft');
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:10000 }); 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`); const restoringSubscription = await fetch(`${base}/s/${rotated.subscriptionToken}/raw`);
assert.equal(restoringSubscription.status, 503); assert.equal(restoringSubscription.status, 503);
assert.equal(restoringSubscription.headers.get('retry-after'), '15'); assert.equal(restoringSubscription.headers.get('retry-after'), '15');
assert.match((await restoringSubscription.json()).message, /正在恢复/); 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; const applyJob = (await applyPoll.json()).job;
assert.equal(applyJob.action, 'apply_resource'); 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 }) }); 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); const revivedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === direct.id && item.customerId === customerB.id).clientUri;
assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`, { headers:{ 'x-device-id':'another-client' } })).status, 200); 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. // Raising the limit while a delete is already running still applies only after cleanup.
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:3000 }); 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; const inFlight = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job;
assert.equal(inFlight.action, 'delete_resource'); assert.equal(inFlight.action, 'delete_resource');
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:10000 }); 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 }) }); 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); const afterDelete = (await request('/api/nodes')).nodes.find((item) => item.id === direct.id);
assert.equal(afterDelete.status, 'queued'); 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; const reapply = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job;
assert.equal(reapply.action, 'apply_resource'); 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. // 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: 3000 });
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:10000 }); 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 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); 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. // A removed node keeps its client assignments; redeploying restores the same URI.
await request(`/api/chains/${direct.id}/remove`, 'POST', {}); 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; 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 }) }); 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'); 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: 3000 });
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:10000 }); await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 });
assert.equal((await request('/api/chains')).chains.find((item) => item.id === direct.id).status, 'draft'); assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft');
await request(`/api/chains/${direct.id}/restore`, 'POST', {}); 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; 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 }) }); 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); 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. // 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 customerC = (await request('/api/customers', 'POST', { name: '配额恢复测试', trafficLimitBytes: 100 })).customer;
const forward = (await request('/api/chains', 'POST', { name:'保留节点', relayServerIds:[relay.id], exitServerId:exit.id, const forward = (await request('/api/nodes', 'POST', { name: '保留节点', serverId: relay.id, protocol: 'vless-reality-vision',
customerIds:[customerC.id], relayProtocol:'vless-reality-vision', exitProtocol:'shadowsocks-2022-aes128' })).chain; topology: 'forward', exitServerId: exit.id, exitProtocol: 'shadowsocks-2022-aes128' })).node;
const forwardDeploy = (await request(`/api/chains/${forward.id}/deploy`, 'POST', {})).deployments; await request(`/api/nodes/${forward.id}/assign`, 'POST', { customerId: customerC.id });
const completeNext = async (key) => { await request(`/api/nodes/${forward.id}/deploy`, 'POST', {});
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;
};
await completeNext(agentKeys[exit.id]); await completeNext(agentKeys[exit.id]);
await completeNext(agentKey); await completeNext(agentKey);
const forwardEntry = forwardDeploy.find((item) => item.role === 'relay'); const forwardUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === forward.id && item.customerId === customerC.id).clientUri;
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' },
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 }] }) });
body:JSON.stringify({ epoch:'fwd-1', samples:[{ resourceId:forwardEntry.resourceId, uplink:2, downlink:5 }] }) });
assert.equal(forwardUsage.status, 200); 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(agentKeys[exit.id])).action, 'delete_resource');
assert.equal((await completeNext(agentKey)).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'); assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === forward.id).status, 'draft');
await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes:100 }); await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes: 100 });
assert.equal((await completeNext(agentKey)).action, 'apply_resource'); assert.equal((await completeNext(agentKey)).action, 'apply_resource');
assert.equal((await fetch(`${base}/s/${customerC.subscriptionToken}/raw`)).status, 503); assert.equal((await fetch(`${base}/s/${customerC.subscriptionToken}/raw`)).status, 503);
assert.equal((await completeNext(agentKeys[exit.id])).action, 'apply_resource'); 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); 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. // AnyTLS is a separate entry engine, but follows the same quota lifecycle.
await request(`/api/servers/${relay.id}`, 'PATCH', { tlsDomain:'entry.example.com' }); await request(`/api/servers/${relay.id}`, 'PATCH', { tlsDomain: 'entry.example.com' });
const anyCustomer = (await request('/api/customers', 'POST', { name:'AnyTLS 客户', trafficLimitBytes:500 })).customer; 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, const anyNode = (await request('/api/nodes', 'POST', { name: 'AnyTLS → VLESS', serverId: relay.id, protocol: 'anytls',
customerIds:[anyCustomer.id], relayProtocol:'anytls', exitProtocol:'vless-tcp' })).chain; topology: 'forward', exitServerId: exit.id, exitProtocol: 'vless-tcp' })).node;
const oldAgent = await fetch(`${base}/api/chains/${anyChain.id}/deploy`, { method:'POST', await request(`/api/nodes/${anyNode.id}/assign`, 'POST', { customerId: anyCustomer.id });
headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:'{}' }); 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.equal(oldAgent.status, 409);
assert.match((await oldAgent.json()).message, /入口证书未由 Agent 确认/); assert.match((await oldAgent.json()).message, /入口证书未由 Agent 确认/);
await fetch(`${base}/api/agent/heartbeat`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, 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'] } }) }); 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', const missingEngine = await fetch(`${base}/api/nodes/${anyNode.id}/deploy`, { method: 'POST',
headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:'{}' }); headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' });
assert.equal(missingEngine.status, 409); assert.equal(missingEngine.status, 409);
assert.match((await missingEngine.json()).message, /ng-agent engine install/); 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' }, 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'] } }) }); 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 request(`/api/nodes/${anyNode.id}/deploy`, 'POST', {});
const anyExitJob = await completeNext(agentKeys[exit.id]); 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); const anyEntryJob = await completeNext(agentKey);
assert.equal(anyEntryJob.payload.resource.engine, 'sing-box'); assert.equal(anyEntryJob.payload.resource.engine, 'sing-box');
assert.equal(anyEntryJob.payload.resource.outbounds[0].type, 'vless'); assert.equal(anyEntryJob.payload.resource.outbounds[0].type, 'vless');
const anyEntry = anyDeploy.find((item) => item.role === 'relay'); const anyUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === anyNode.id && item.customerId === anyCustomer.id).clientUri;
const anyUri = (await request('/api/chains')).deployments.find((item) => item.id === anyEntry.id).clientUri;
assert.match(anyUri, /^anytls:\/\//); assert.match(anyUri, /^anytls:\/\//);
assert.equal(decodeURIComponent(anyUri.split('#')[1]), 'AnyTLS → VLESS'); assert.equal(decodeURIComponent(anyUri.split('#')[1]), 'AnyTLS → VLESS');
assert.match(await (await fetch(`${base}/s/${anyCustomer.subscriptionToken}/clash`)).text(), /type: "anytls"/); assert.match(await (await fetch(`${base}/s/${anyCustomer.subscriptionToken}/clash`)).text(), /type: "anytls"/);
const anyUsage = await fetch(`${base}/api/agent/usage`, { method:'POST', const anyUsage = await fetch(`${base}/api/agent/usage`, { method: 'POST',
headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body:JSON.stringify({ samples:[{ resourceId:anyEntry.resourceId, uplink:120, downlink:400, epoch:'sing-box:123:456' }] }) }); body: JSON.stringify({ samples: [{ kind: 'inbound', id: anyNode.id, uplink: 120, downlink: 400, epoch: 'sing-box:123:456' }] }) });
assert.equal(anyUsage.status, 200); assert.equal(anyUsage.status, 200);
assert.deepEqual((await request('/api/chains')).deployments.find((item) => item.id === anyEntry.id).meteredTraffic, assert.deepEqual((await request('/api/nodes')).nodes.find((item) => item.id === anyNode.id).meteredTraffic,
{ uplink:120, downlink:400 }); { uplink: 120, downlink: 400 });
await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes:500 }); // 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(agentKeys[exit.id])).action, 'delete_resource');
assert.equal((await completeNext(agentKey)).action, 'delete_resource'); assert.equal((await completeNext(agentKey)).action, 'delete_resource');
assert.equal((await fetch(`${base}/s/${anyCustomer.subscriptionToken}/raw`)).status, 403); assert.equal((await fetch(`${base}/s/${anyCustomer.subscriptionToken}/raw`)).status, 403);
await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes:1000 }); await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes: 1000 });
assert.equal((await completeNext(agentKey)).payload.resource.engine, 'sing-box'); 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 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. // 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' }, 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' }] }) }); body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '198.51.100.1' }] }) });
assert.equal((await request(`/api/customers/${customerB.id}/access`)).observedIps.length, 0); 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' }, 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' }] }) }); body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '203.0.113.1' }] }) });
assert.equal(observed.status, 200); assert.equal(observed.status, 200);
assert.equal((await request(`/api/customers/${customerB.id}/access`)).observedIps.length, 1); 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' }, 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' }] }) }); body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '203.0.113.2' }] }) });
assert.equal(excessIp.status, 200); assert.equal(excessIp.status, 200);
assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).suspendReason, 'ip_limit'); 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, // Deleting a node removes it from the UI at once and hard-deletes after agents confirm.
// while cleanup tombstones retain the ports until agents acknowledge removal. await request(`/api/nodes/${node.id}/remove`, 'POST', {});
await request(`/api/chains/${chain.id}/remove`, 'POST', {}); const deleted = await request(`/api/nodes/${node.id}`, 'DELETE');
const deleted = await request(`/api/chains/${chain.id}`, 'DELETE'); assert.equal(deleted.ok, true);
assert.equal(deleted.cleanupPending, 2); const afterNodeDelete = await request('/api/nodes');
const afterChainDelete = await request('/api/chains'); assert.equal(afterNodeDelete.nodes.some((item) => item.id === node.id), false);
assert.equal(afterChainDelete.chains.some((item) => item.id === chain.id), false); assert.equal(afterNodeDelete.nodeClients.some((item) => item.nodeId === node.id), false);
assert.equal(afterChainDelete.deployments.some((item) => item.chainId === chain.id), false);
const source = JSON.parse(await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8')); 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'); const removedCustomer = await request(`/api/customers/${customer.id}`, 'DELETE');
assert.equal(removedCustomer.ok, true); assert.equal(removedCustomer.ok, true);
assert.equal((await fetch(`${base}/s/${customer.subscriptionToken}/raw`)).status, 404); 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); assert.equal(blocked.status, 409);
const retry = await request(`/api/servers/${exit.id}/cleanup`, 'POST', {}); const retry = await request(`/api/servers/${exit.id}/cleanup`, 'POST', {});
assert.equal(retry.queued, 0); 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); 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); 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); assert.equal(relogin.status, 200);
}); });
+2 -2
View File
@@ -13,9 +13,9 @@ test('job failures redact a Reality secret and stale completions cannot overwrit
try { try {
const store = await new Store(path.join(dir, 'data.json')).init(); const store = await new Store(path.join(dir, 'data.json')).init();
await store.transaction((data) => { 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 }); 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 }); serverId:'srv_test', payload:{}, attempts:2 });
}); });
const orchestrator = new Orchestrator(store); const orchestrator = new Orchestrator(store);
+167 -71
View File
@@ -3,26 +3,82 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { const {
newCredentialSet, buildExitResource, buildRelayResource, buildDirectResource, buildClientUri, newNodeClientCredentials, ensureNodeSecrets, nodeEmail,
validateEntryProtocol, validateProtocolPair buildNodeResource, buildNodeExitResource, buildClientUri,
validateEntryProtocol, validateProtocolPair, resolveBindAddress,
} = require('../lib/protocols'); } = require('../lib/protocols');
const customer = { id: 'cus_test', name: '测试客户' }; const entryServer = { id: 'srv_entry', publicAddress: 'entry.example.com', tlsDomain: 'node.example.com' };
const relay = { id: 'srv_relay', publicAddress: 'relay.example.com' }; const exitServer = { id: 'srv_exit', publicAddress: 'exit.example.com' };
const exit = { id: 'srv_exit', publicAddress: 'exit.example.com' };
test('builds a VLESS Reality to Shadowsocks 2022 chain', () => { function makeNode(overrides = {}) {
const credentials = newCredentialSet('vless-reality-vision', 'shadowsocks-2022-aes128'); const node = {
const reality = { keyId: 'test-key', serverName: 'www.microsoft.com', destPort: 443 }; id: 'nod_test1', name: '测试节点', serverId: entryServer.id,
const exitResource = buildExitResource({ resourceId: 'res_exit_123', tagPrefix: 'exit-test', port: 32001, protocol: 'shadowsocks-2022-aes128', credentials, customer }); protocol: 'vless-reality-vision', port: 21001, portMode: 'random',
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 }); networkMode: 'ipv4', topology: 'forward',
assert.equal(exitResource.inbounds[0].protocol, 'shadowsocks'); exitServerId: exitServer.id, exitProtocol: 'shadowsocks-2022-aes128', exitPort: 32001, exitPortMode: 'random',
assert.equal(relayResource.inbounds[0].protocol, 'vless'); realityServerName: 'www.microsoft.com', realityDestPort: 443,
assert.equal(relayResource.outbounds[0].settings.servers[0].address, 'exit.example.com'); resourceId: 'res_entry', exitResourceId: 'res_exit',
assert.equal(relayResource.inbounds[0].streamSettings.realitySettings.privateKey, '${REALITY_PRIVATE:test-key}'); ...overrides,
const uri = buildClientUri({ protocol: 'vless-reality-vision', relayServer: relay, relayPort: 21001, credentials, reality, publicKey: 'public-key', name: '测试' }); };
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, /^vless:\/\//);
assert.match(uri, /pbk=public-key/); 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', () => { test('rejects unsupported protocol pairs', () => {
@@ -30,77 +86,117 @@ test('rejects unsupported protocol pairs', () => {
assert.doesNotThrow(() => validateProtocolPair('anytls', 'vless-tcp')); 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 { makeClash, makeSingBox, makeSurge } = require('../lib/subscriptions');
const credentials = newCredentialSet('anytls', 'vless-tcp'); const node = makeNode({ protocol: 'anytls', topology: 'forward', exitProtocol: 'vless-tcp' });
assert.throws(() => buildDirectResource({ resourceId:'res_any_no_cert', tagPrefix:'any', port:23001, const clients = makeClients(node, ['cus_a']);
protocol:'anytls', credentials, customer }), /证书域名/); assert.throws(() => buildNodeResource({ resourceId: 'res_any_no_cert', tagPrefix: 'any', node,
const relayResource = buildRelayResource({ resourceId:'res_any_relay', tagPrefix:'any', port:23001, nodeClients: clients, server: { ...entryServer, tlsDomain: null }, exitServer }), /证书域名/);
protocol:'anytls', exitProtocol:'vless-tcp', exitServer:exit, exitPort:32001, const resource = buildNodeResource({ resourceId: 'res_any', tagPrefix: 'any', node, nodeClients: clients, server: entryServer, exitServer });
credentials, customer, tlsDomain:'entry.example.com' }); assert.equal(resource.engine, 'sing-box');
assert.equal(relayResource.engine, 'sing-box'); assert.equal(resource.inbounds[0].type, 'anytls');
assert.equal(relayResource.inbounds[0].type, 'anytls'); assert.equal(resource.inbounds[0].users[0].name, 'ng:cus_a');
assert.equal(relayResource.inbounds[0].tls.certificate_path, '/etc/nexusgate/tls/entry.example.com/fullchain.pem'); assert.equal(resource.inbounds[0].users[0].password, clients[0].credentials.relayPassword);
assert.equal(relayResource.outbounds[0].type, 'vless'); assert.equal(resource.inbounds[0].tls.certificate_path, '/etc/nexusgate/tls/node.example.com/fullchain.pem');
assert.equal(relayResource.outbounds[0].uuid, credentials.clientId); assert.equal(resource.outbounds[0].type, 'vless');
const direct = buildDirectResource({ resourceId:'res_any_direct', tagPrefix:'direct-any', port:23002, assert.equal(resource.outbounds[0].uuid, node.exitCredentials.clientId);
protocol:'anytls', credentials, customer, tlsDomain:'entry.example.com' }); 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.outbounds[0].type, 'direct');
assert.equal(direct.routingRules[0].outbound, 'direct-any-direct'); assert.equal(direct.routingRules[0].outbound, 'direct-any-direct');
const uri = buildClientUri({ protocol:'anytls', relayServer:relay, relayPort:23001, const uri = buildClientUri({ protocol: 'anytls', relayServer: entryServer, relayPort: 23001,
credentials, tlsDomain:'entry.example.com', name:'AnyTLS 测试' }); credentials: clients[0].credentials, tlsDomain: 'node.example.com', name: 'AnyTLS 测试' });
assert.match(uri, /^anytls:\/\//); assert.match(uri, /^anytls:\/\//);
assert.match(makeClash([{ clientUri:uri }]), /type: "anytls"/); assert.match(makeClash([{ clientUri: uri }]), /type: "anytls"/);
assert.match(makeSingBox([{ clientUri:uri }]), /"type": "anytls"/); assert.match(makeSingBox([{ clientUri: uri }]), /"type": "anytls"/);
assert.equal(makeSurge([{ clientUri:uri }]), null); 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 { makeClash, makeSingBox } = require('../lib/subscriptions');
const credentials = newCredentialSet('hysteria2', 'shadowsocks-2022-aes128'); const node = makeNode({ protocol: 'hysteria2', topology: 'direct' });
const node = buildRelayResource({ resourceId:'res_hy2_123', tagPrefix:'hy2-test', port:23000, const clients = makeClients(node, ['cus_a', 'cus_b']);
protocol:'hysteria2', exitProtocol:'shadowsocks-2022-aes128', exitServer:exit, exitPort:32001, const resource = buildNodeResource({ resourceId: 'res_hy2', tagPrefix: 'hy2-test', node, nodeClients: clients, server: entryServer });
credentials, customer, tlsDomain:'node.example.com' }); assert.equal(resource.inbounds[0].type, 'hysteria2');
assert.equal(node.inbounds[0].protocol, 'hysteria'); assert.equal(resource.inbounds[0].users.length, 2);
assert.equal(node.inbounds[0].streamSettings.method, 'hysteria'); assert.equal(resource.inbounds[0].users[0].password, clients[0].credentials.relayPassword);
assert.equal(node.inbounds[0].streamSettings.network, 'hysteria'); assert.equal(resource.inbounds[0].tls.key_path, '/etc/nexusgate/tls/node.example.com/privkey.pem');
assert.equal(node.inbounds[0].settings.clients[0].auth, credentials.relayPassword); const uri = buildClientUri({ protocol: 'hysteria2', relayServer: entryServer, relayPort: 23000,
assert.equal(node.inbounds[0].settings.users[0].auth, credentials.relayPassword); credentials: clients[0].credentials, tlsDomain: 'node.example.com', name: 'H2 test' });
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' });
assert.match(uri, /^hysteria2:\/\//); assert.match(uri, /^hysteria2:\/\//);
assert.match(makeClash([{ clientUri:uri }]), /type: "hysteria2"/); assert.match(makeClash([{ clientUri: uri }]), /type: "hysteria2"/);
assert.match(makeSingBox([{ clientUri:uri }]), /"type": "hysteria2"/); assert.match(makeSingBox([{ clientUri: uri }]), /"type": "hysteria2"/);
}); });
test('builds direct Shadowsocks, SOCKS5 and IPv6 client resources', () => { test('builds shared Shadowsocks, SOCKS5 and IPv6 node inbounds', () => {
const ssCredentials = newCredentialSet('shadowsocks-2022-aes256', null); const node = makeNode({ protocol: 'shadowsocks-2022-aes256', topology: 'direct', networkMode: 'ipv6' });
const direct = buildDirectResource({ resourceId: 'res_direct_123', tagPrefix: 'direct-test', port: 24001, protocol: 'shadowsocks-2022-aes256', credentials: ssCredentials, customer, networkMode: 'ipv6' }); const clients = makeClients(node, ['cus_a', 'cus_b']);
assert.equal(direct.meta.kind, 'direct'); const resource = buildNodeResource({ resourceId: 'res_ss', tagPrefix: 'ss-test', node, nodeClients: clients, server: entryServer });
assert.equal(direct.inbounds[0].listen, '::'); assert.equal(resource.meta.kind, 'node');
assert.equal(direct.inbounds[0].settings.method, '2022-blake3-aes-256-gcm'); assert.equal(resource.inbounds[0].method, '2022-blake3-aes-256-gcm');
const ipv6Relay = { ...relay, publicAddressV6: '2001:db8::10' }; assert.equal(resource.inbounds[0].users.length, 2);
const uri = buildClientUri({ protocol: 'shadowsocks-2022-aes256', relayServer: ipv6Relay, relayPort: 24001, credentials: ssCredentials, name: 'IPv6 节点', networkMode: 'ipv6' }); 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/); assert.match(uri, /@\[2001:db8::10\]:24001/);
const socksCredentials = newCredentialSet('socks5-auth', null); const socksNode = makeNode({ protocol: 'socks5-auth', topology: 'direct' });
const socks = buildDirectResource({ resourceId: 'res_direct_456', tagPrefix: 'socks-test', port: 24002, protocol: 'socks5-auth', credentials: socksCredentials, customer, networkMode: 'ipv4' }); const socksClients = makeClients(socksNode, ['cus_a']);
assert.equal(socks.inbounds[0].protocol, 'socks'); 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')); assert.doesNotThrow(() => validateEntryProtocol('socks5-auth'));
}); });
test('VLESS WebSocket TLS requires a node certificate domain and exports TLS clients', () => { test('VLESS WebSocket TLS node requires a certificate domain and exports TLS clients', () => {
const credentials = newCredentialSet('vless-ws-tls', null); const node = makeNode({ protocol: 'vless-ws-tls', topology: 'direct' });
const clients = makeClients(node, ['cus_a']);
assert.doesNotThrow(() => validateEntryProtocol('vless-ws-tls')); assert.doesNotThrow(() => validateEntryProtocol('vless-ws-tls'));
assert.throws(() => buildDirectResource({ resourceId:'res_tls_missing', tagPrefix:'tls', port:24444, assert.throws(() => buildNodeResource({ resourceId: 'res_tls_missing', tagPrefix: 'tls', node, nodeClients: clients,
protocol:'vless-ws-tls', credentials, customer }), /证书域名/); server: { ...entryServer, tlsDomain: null } }), /证书域名/);
const resource = buildDirectResource({ resourceId:'res_tls_123', tagPrefix:'tls', port:24444, const resource = buildNodeResource({ resourceId: 'res_tls', tagPrefix: 'tls', node, nodeClients: clients, server: entryServer });
protocol:'vless-ws-tls', credentials, customer, tlsDomain:'node.example.com' }); assert.equal(resource.inbounds[0].tls.enabled, true);
const stream = resource.inbounds[0].streamSettings; assert.equal(resource.inbounds[0].tls.certificate_path, '/etc/nexusgate/tls/node.example.com/fullchain.pem');
assert.equal(stream.security, 'tls'); assert.equal(resource.inbounds[0].transport.path, node.wsPath);
assert.equal(stream.tlsSettings.certificates[0].certificateFile, '/etc/nexusgate/tls/node.example.com/fullchain.pem'); assert.equal(resource.inbounds[0].users[0].uuid, clients[0].credentials.clientId);
const uri = buildClientUri({ protocol:'vless-ws-tls', relayServer:relay, relayPort:24444, const uri = buildClientUri({ protocol: 'vless-ws-tls', relayServer: entryServer, relayPort: 24444,
credentials, tlsDomain:'node.example.com', name:'TLS 节点' }); credentials: { ...clients[0].credentials, wsPath: node.wsPath }, tlsDomain: 'node.example.com', name: 'TLS 节点' });
assert.match(uri, /security=tls/); assert.match(uri, /security=tls/);
assert.match(uri, /sni=node.example.com/); 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
View File
@@ -2,57 +2,93 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); 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', () => { // 最小可用数据集:节点 + 服务器 + 分配,entries() 现场生成客户端链接。
const customer = { id:'cus-1' }; function makeData() {
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 servers = [
const ss = `ss://${Buffer.from('2022-blake3-aes-128-gcm:secret').toString('base64url')}@example.com:1443#SS`; { id: 'srv_1', publicAddress: '2001:db8::1', tlsDomain: null },
const data = { deployments:[ { id: 'srv_2', publicAddress: 'example.com', tlsDomain: 'entry.example.com' },
{ customerId:customer.id, role:'relay', status:'active', clientUri:reality }, ];
{ customerId:customer.id, role:'direct', status:'active', clientUri:ss }, const nodes = [
{ customerId:customer.id, role:'exit', status:'active', clientUri:'should-not-export' }, { id: 'nod_reality', name: 'Reality', protocol: 'vless-reality-vision', port: 443, serverId: 'srv_1',
{ customerId:customer.id, role:'relay', status:'failed', clientUri:'should-not-export' }, networkMode: 'ipv4', topology: 'direct', status: 'active',
{ customerId:customer.id, role:'relay', status:'active', archived:true, clientUri:'should-not-export' } reality: { keyId: 'k1', serverName: 'www.tesla.com', destPort: 443 },
] }; artifacts: { realityPublicKey: 'public-key' } },
assert.equal(entries(data, customer.id).length, 2); { id: 'nod_ss', name: 'SS', protocol: 'shadowsocks-2022-aes128', port: 1443, serverId: 'srv_2',
const proxy = clashProxy(data.deployments[0]); 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.server, '2001:db8::1');
assert.equal(proxy['reality-opts']['public-key'], 'public-key'); 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[1].tls.reality.short_id, 'abcdef');
assert.equal(config.outbounds[2].method, '2022-blake3-aes-128-gcm'); 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, { id: 'cus-1' }, 'auto', 'Mihomo').contentType, 'text/yaml; charset=utf-8');
assert.equal(formatSubscription(data, customer, 'auto', 'Other').contentType, 'text/plain; charset=utf-8'); assert.equal(formatSubscription(data, { id: 'cus-1' }, 'auto', 'Other').contentType, 'text/plain; charset=utf-8');
assert.doesNotMatch(formatSubscription(data, customer, 'raw').body, /should-not-export/); 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', () => { test('TLS WebSocket exports valid Mihomo and sing-box TLS settings', () => {
const customer = { id:'cus-tls' }; const data = makeData();
const data = { deployments:[{ customerId:customer.id, role:'direct', status:'active', clientUri: data.nodes.push({ id: 'nod_tls', name: 'TLS', protocol: 'vless-ws-tls', port: 24444, serverId: 'srv_2',
'vless://00000000-0000-4000-8000-000000000001@node.example.com:24444?encryption=none&security=tls&sni=node.example.com&type=ws&path=%2Ftest#TLS' }] }; networkMode: 'ipv4', topology: 'direct', status: 'active', wsPath: '/test', artifacts: {} });
const proxy = clashProxy(data.deployments[0]); 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.tls, true);
assert.equal(proxy.servername, 'node.example.com'); assert.equal(proxy.servername, 'entry.example.com');
assert.equal(proxy['reality-opts'], undefined); 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.enabled, true);
assert.equal(outbound.tls.reality, undefined); 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', () => { test('adding HY2 to an AnyTLS subscription keeps ALPN on one valid YAML line and node names clean', () => {
const data = { chains:[{ id:'any-route', name:'US-rak-CN2' }, { id:'hy-route', name:'hy2 cn2' }], deployments:[ const data = makeData();
{ chainId:'any-route', customerId:'55', role:'direct', status:'active', data.nodes.push(
clientUri:'anytls://secret@entry.example.com:48924?sni=entry.example.com#US-rak-CN2%20%C2%B7%20rak%20cn2%3A48924' }, { id: 'nod_any', name: 'US-rak-CN2', protocol: 'anytls', port: 48924, serverId: 'srv_2',
{ chainId:'hy-route', customerId:'55', role:'direct', status:'active', networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} },
clientUri:'hysteria2://secret@entry.example.com:40003?sni=entry.example.com&alpn=h3#hy2%20cn2%20%C2%B7%20rak%20cn2%3A40003' } { id: 'nod_hy', name: 'hy2 cn2', protocol: 'hysteria2', port: 40003, serverId: 'srv_2',
] }; networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} });
const customer = { id:'55' }; 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; const raw = formatSubscription(data, customer, 'raw').body;
assert.match(raw, /#US-rak-CN2\n/); assert.match(raw, /#US-rak-CN2\n/);
assert.match(raw, /#hy2%20cn2\n/); assert.match(raw, /#hy2%20cn2\n/);
assert.doesNotMatch(raw, /rak%20cn2%3A/);
const yaml = formatSubscription(data, customer, 'clash').body; const yaml = formatSubscription(data, customer, 'clash').body;
assert.match(yaml, /^ alpn: \["h3"\]$/m); assert.match(yaml, /^ alpn: \["h3"\]$/m);
assert.match(yaml, /^proxy-groups:$/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); assert.match(yaml, /^ - name: "hy2 cn2"$/m);
}); });
test('legacy VMess subscriptions also use the route name instead of a base64 label', () => { test('Clash aliases only node names that collide with built-in groups or policies', () => {
const payload = { v:'2', ps:'old · entry:20000', add:'entry.example.com', port:'20000', id:'uuid', path:'/ws' }; const data = makeData();
const data = { chains:[{ id:'vmess-route', name:'VMess US' }], deployments:[{ data.nodes.push(
chainId:'vmess-route', customerId:'vmess-customer', role:'direct', status:'active', { id: 'nod_a', name: '节点选择', protocol: 'anytls', port: 443, serverId: 'srv_2',
clientUri:`vmess://${Buffer.from(JSON.stringify(payload)).toString('base64')}` networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} },
}] }; { id: 'nod_b', name: 'DIRECT', protocol: 'hysteria2', port: 444, serverId: 'srv_2',
const exported = entries(data, 'vmess-customer'); networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} });
assert.equal(clashProxy(exported[0]).name, 'VMess US'); for (const nodeId of ['nod_a', 'nod_b']) {
assert.match(formatSubscription(data, { id:'vmess-customer' }, 'clash').body, /^ - name: "VMess US"$/m); data.nodeClients.push({ nodeId, customerId: 'one', status: 'active',
}); credentials: { clientId: '00000000-0000-4000-8000-000000000001', relayPassword: 'password', shortId: 'abcdef' } });
}
test('Clash aliases only route names that collide with built-in groups or policies', () => { const yaml = formatSubscription(data, { id: 'one' }, 'clash-smart').body;
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;
assert.match(yaml, /^ - name: "节点选择 \(节点\)"$/m); assert.match(yaml, /^ - name: "节点选择 \(节点\)"$/m);
assert.match(yaml, /^ - name: "DIRECT \(节点\)"$/m); assert.match(yaml, /^ - name: "DIRECT \(节点\)"$/m);
assert.match(yaml, /^ - "节点选择 \(节点\)"$/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');
}); });