29 Commits
Author SHA1 Message Date
Muse 17be1ad5ae 修复二开 agent 流量增量丢失: 基线改上报确认后推进并落盘持久化
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI for Docker / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
- xray 新增快照/确认接口 GetTrafficSnapshot/CommitTrafficSnapshot/
  RestoreTrafficBaseline, 与面板 GetTraffic 的 StatsLastValues 完全隔离
- agent 改走快照: 仅主控 HTTP 200 后才推进基线, 失败轮增量由累计
  计数做差在下轮自动补齐; 已确认基线原子落盘, agent 重启恢复续差
- 核心重启计数归零的从零规则保留; Onlines 仍每轮新鲜采集直报
- 新增 11 个测试覆盖断网恢复/连续失败补齐/重启恢复/计数归零/
  面板语义隔离, xray 与 agent 两包全绿
2026-10-21 01:24:14 +08:00
Muse a654a96c38 精简版第四轮补裁(P4): 页面层摘除 + Xray更新端点下线 + 本机核心监管下线
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
页面层(用户真机截图发现前两轮漏裁):
- 设置: 摘除 Telegram机器人/邮件菜单与页面, 及页内残留入口(通知页/外部流量/LDAP/2FA设置页)
- Xray配置: 摘除负载均衡/DNS/高级配置菜单与页面; 基础配置(出站+路由)完好
- 总览: 摘除 Xray更新弹窗(VersionModal+GeodataSection)全部入口, 版本号改为静态展示
- 13个locale同步摘除461个已删功能专属翻译键(逐键核查前后端引用为零; i18n-dead-keys测试全绿)

Xray更新后端(经调查确认仅服务本机bin/xray, 与agent下发/订阅无关):
- 删除 getXrayVersion/installXray/updateGeofile 路由与handler(访问返回404)
- 删除 ServerService 对应实现与校验和函数; 同步api-docs/monitor白名单/openapi

本机核心监管下线(设计: 主控永不运行本机xray, 入站全在agent):
- XrayService.RestartXray 降级为no-op(一次性Warning说明日志); StopXray无进程时幂等返回
- web.go 摘除启动拉起本机核心/CheckXrayRunning看门狗(1s)/ApplyPendingRestart cron(30s)/XrayTrafficJob/CheckClientIpJob 注册
- ApplyPendingRestart 降级为消费即清, 其测试更新为新语义
- XrayMetricsService.Sample 短路: 不再每2s探本机/debug/vars, 状态优雅置为不可用
- 配置生成(纯JSON)/agent链路/订阅/入站CRUD未动; 状态读数优雅归零(stop/Unknown)

注: TestGetXrayConfig_EnabledClientsStillEmitted 在基线945b3a5同样失败(既有问题, 非本轮引入)
2026-10-20 23:39:38 +08:00
Muse 945b3a56a2 链接迁移: 前端用户可见链接与 .github 元数据改指本仓 Gitea
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Deploy Smoke Tests / noninteractive-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / noninteractive-install (ubuntu-latest) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-latest) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
- 前端: 侧边栏 REPO_URL 改指本仓 Gitea; 订阅设置帮助链接改指本仓
  Gitea 上的 docs/custom-subscription-templates.md (dist 产物未动, 下次构建自愈)
- FUNDING.yml: 删除上游作者的赞助句柄 (github/buy_me_a_coffee/custom 捐赠页),
  留注释说明; ISSUE_TEMPLATE: Wiki/搜索/最新版三个 contact_links 与
  question.yaml 的 Wiki 链接改指本仓 Gitea (仓库首页/Issues/Releases)
- .github/claude/repo-context.md: 安装命令一行改指本仓 install-master.sh
2026-10-20 12:41:56 +00:00
Muse b8e132fc0b 链接迁移: 6 个多语言 README 与 CONTRIBUTING 改指本仓 Gitea
- 快速开始主命令改指本仓 install-master.sh (Gitea raw); 无等价物的
  指定版本/dev 通道一键命令替换为指向主 README 的英文说明行 (小节结构保留)
- 删除 5 个绑定上游仓库的 shields/pkg.go.dev 徽章 (无 Gitea 等价物);
  Wiki 链接改指本仓首页; Docker 示例镜像由上游发布的 ghcr.io/mhsanaei/3x-ui
  改为本地构建的 3x-ui-agent:local (fork 不发布注册表镜像)
- 删除上游作者个人捐赠锚点 (buymeacoffee/nowpayments) 与 starchart 上游统计小节
- CONTRIBUTING: clone 地址与 Issues 链接改指本仓 Gitea
2026-10-20 12:40:52 +00:00
Muse c67f0cb0cf 链接迁移: fork 脚本复活指 Gitea、上游三件套中和、面板自更新链改指本仓
- install-master.sh/install-agent.sh: 二进制下载源由已封账号 aaaamoooo/3x-ui-diy
  改指本仓 Gitea Releases (tag 沿用 v3.7.0-agent.1); README 同步撤下'暂不可用'标注
- install.sh/update.sh: 未被 fork 部署链引用, 整文件替换为中英双语短桩 (exit 1),
  防旧习惯调用时静默装成/更新成上游原版
- x-ui.sh: 保留全部管理功能, 仅中和 install/update/update_dev/update_menu/
  legacy_version/update_shell 的上游下载分支与 uninstall 打印的上游命令,
  一律改为提示本仓 Gitea Releases 地址后退出该分支
- panel.go: 面板自更新运行时耦合处理 — panelUpdaterURL 与版本检查 API 由
  MHSanaei GitHub 改指本仓 Gitea (raw update.sh + /api/v1 releases, 响应字段
  tag_name/body/target_commitish 与原解析结构一致); 下载到的将是本仓 update.sh
  短桩, 面板更新会安全失败而非覆盖成原版
- deploy/cloud-init: curl 上游 install.sh 改为本仓 install-master.sh (Gitea raw);
  头部注明 XUI_* 非交互旋钮与 install-result.env 凭据回显在 fork 安装脚本下无等价物
2026-10-20 12:36:50 +00:00
Muse 8da3cde494 docs: README 改版为 fork 口径——删上游/已封 GitHub 的一键安装命令,改为 Gitea Releases 手动部署主控/agent 两模式,补卸载章节 2026-10-20 12:27:21 +00:00
Muse 5ca69dd9c1 精简P2: go mod tidy: xray-core 及其独占依赖树(quic-go/gvisor/reality/sing/pion/miekg-dns 等)全部移出 go.mod/go.sum; 连带清掉第一轮裁剪后孤儿化的 ldap/telego/qrcode/gotp/fasthttp 直接依赖
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
2026-10-20 11:08:20 +00:00
Muse 284040e994 精简P2: 下线 infra/conf 交叉验证类测试: golden fixtures 改为面板侧契约(全语料可解析+inbound-full 经 GenXrayInboundConfig 生成后身份保持), 文件更名 golden_fixtures_test.go; XMC 完整性测试由与核心对拍改为面板谓词自身规则表(用户名正则/uuid 可解析/textures 非空); healed-config 测试改为断言面板 heal 结果(version 钉 2/空 packet 被清). 核心接受性今后由节点核心启动时把关 2026-10-20 11:07:10 +00:00
Muse 70d24d2f15 精简P2: geodata 测试夹具改 protowire 手工编码(geodat.proto 字段号+proto3 零值省略语义原样复刻), 不再依赖 xray-core 生成类型写 .dat 夹具 2026-10-20 11:04:59 +00:00
Muse cb8981158f 精简P2: 剥离 xray-core API 客户端: StatsService 改 protowire 线缆级实现(QueryStats/GetUsersStats 字段号按核心 proto 复刻, agent 流量上报与本地流量任务不受影响); Handler/Routing 变更类 RPC 无法脱离核心类型化配置构建, 统一降级 ErrHotApplyUnavailable(调用方既有兜底: 热更新->重启核心, balancer 视图报未运行, 节点桥探测放弃, IP 限连临时断开仅记日志); ValidateOutboundConfig 改纯 JSON 实现, 精确复刻核心 v26.7.11 未加密 vless/trojan 公网禁令(含私有 IP/域名匹配表与原文报错, 且同核心一样只查扁平 address 形式); 删除 buildUserAccount 及 SS/WG/e2e 旧测试, 流量增量测试改线缆级假服务器并新增 stock protobuf 服务器互操作测试 2026-10-20 11:03:45 +00:00
Muse c56a02c948 精简P2: geodata token 常量内联, 去掉对 xray-core common/geodata 的常量引用 2026-10-20 10:59:03 +00:00
Muse 4149a162b8 修复: mixed 协议可部署到节点: 后端 nodeEligibleProtocols 放开 Mixed(inbound.go 两处校验点), 前端 NODE_ELIGIBLE_PROTOCOLS 同步; 重建前端 dist(含 Swagger 页/分组页裁剪) 2026-10-20 10:24:28 +00:00
Muse b9e1345b23 修复: Reality x25519 密钥改面板内置生成(crypto/ecdh), 不再 exec xray 二进制; 编码与 xray x25519 输出一致(base64url raw 32B), 附曲线自验单测 2026-10-20 10:18:37 +00:00
Muse 7d42b91ed6 精简: 订阅极简输出: 去掉分流规则注入(Happ/Clash/Incy 远程规则源+remote_routing 拉取缓存+RemoteRoutingJob、Clash 远程规则合并簇、subJsonRules 注入); 保留多格式转换与默认极简模板 2026-10-20 10:17:27 +00:00
Muse 3cbf9164a4 精简: 去掉分组管理功能: GroupController 路由与 client_groups 管理函数(ListGroups/Create/Rename/Delete/AddToGroup/ResetGroupTraffic 等); 保留 group 字段/表与删除流量基线 helper 2026-10-20 10:13:06 +00:00
Muse e42b5d770d 精简: 去掉 Telegram 机器人+通知: tgbot 包/stats-notify 与 hash 清理 job/登录通知/backuptotgbot/testTgBot/全部 tg* 设置/locale Bot 本地化/SecretClears 机制; 连带清理 Swagger/外部流量/邮件/2FA 的残留测试 2026-10-20 10:11:35 +00:00
Muse 1498d58f14 精简: 去掉邮件模块(SMTP 通知): service/email 包、订阅者接线、testSmtp 端点、全部 smtp* 设置 2026-10-20 10:07:24 +00:00
Muse 981ba8abe1 精简: 去掉双重验证(2FA): 登录校验/设置守卫/VerifyTwoFactorCode/CLI resetTwoFactor 标志 2026-10-20 10:05:29 +00:00
Muse 7ece6e079b 精简: 去掉 LDAP(目录登录+用户同步): util/ldap 包、同步 job、登录分支、全部 ldap* 设置 2026-10-20 10:04:28 +00:00
Muse f9b0672d84 精简: 去掉外部流量上报(ExternalTrafficInform): 设置字段/校验/job 上报逻辑 2026-10-20 10:03:22 +00:00
Muse 2c0d9c1209 精简: 去掉 API 文档(Swagger)后端路由与 openapi.json 分发 handler 2026-10-20 10:02:05 +00:00
Muse 57474e0700 迁移到新账号 aaaamoooo 2026-10-20 12:35:03 +08:00
Muse bcd7b47c3d 安装脚本支持 arm64 + Alpine(架构自动检测) 2026-10-19 21:34:29 +08:00
Muse 9105f7f364 SQLite Backup 按 CGO build tag 隔离,支持静态编译(Alpine musl) 2026-10-19 21:20:36 +08:00
Muse 6381587307 Agent 节点免填地址/端口/API令牌(前后端校验)+ 补翻译 2026-10-19 21:00:13 +08:00
Muse fc59cb1032 Agent 节点创建弹窗:显示完整安装命令(主控地址可编辑) 2026-10-19 20:53:37 +08:00
Muse 2f2efdac42 README 重写为 agent 版安装说明 2026-10-19 20:44:10 +08:00
Muse 1701eb24e2 安装脚本默认下载地址指向 GitHub release 2026-10-19 20:37:42 +08:00
Muse 0ba1ec0596 3x-ui agent: 轻量节点 agent 化
- Agent 通过 token 向主控注册,心跳/拉取 xray 配置/上报流量
- 主控按节点下发完整 xray 配置(含出站+路由),agent 只应用不自建
- Agent 心跳同步更新节点在线状态;后台探针跳过 agent 托管节点
- 「添加节点」支持 Agent 类型,自动生成 token 并弹窗展示
- VLESS/SS2022 入站;同机测试支持 api_port 隔离
- 新增 install-master.sh / install-agent.sh 一键安装脚本
- systemd + OpenWrt procd 服务文件,docs/AGENT.md
2026-10-19 12:29:38 +00:00
189 changed files with 4201 additions and 27922 deletions
+5 -3
View File
@@ -1,6 +1,8 @@
# These are supported funding model platforms
# Fork note: the upstream author's funding handles (github / buy_me_a_coffee /
# custom donate page) were removed — they do not belong to this fork.
github: MHSanaei
github: # upstream author's handle removed in this fork
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
@@ -10,5 +12,5 @@ liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: mhsanaei
custom: https://donate.sanaei.dev/
buy_me_a_coffee: # upstream author's handle removed in this fork
custom: # upstream author's donation page removed in this fork
+4 -4
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 📖 Project Wiki
url: https://github.com/MHSanaei/3x-ui/wiki
- name: 📖 Project home (Gitea)
url: https://git.881218.xyz/rogue/3x-ui-agent
about: Setup, install, configuration, and "how do I…" guides live here. Please check before opening a question.
- name: 🔍 Search existing issues
url: https://github.com/MHSanaei/3x-ui/issues?q=is%3Aissue
url: https://git.881218.xyz/rogue/3x-ui-agent/issues
about: Your bug, feature, or question may already be tracked. Comment on the existing one rather than opening a duplicate.
- name: 🚀 Latest release
url: https://github.com/MHSanaei/3x-ui/releases/latest
url: https://git.881218.xyz/rogue/3x-ui-agent/releases
about: Reproduce on the latest version before reporting — many bugs are already fixed in the most recent release.
+2 -2
View File
@@ -9,7 +9,7 @@ body:
value: |
Before opening a question, please:
- Read the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki) —
- Read the [project home](https://git.881218.xyz/rogue/3x-ui-agent) —
most setup, install, and "how do I …" answers live there.
- Search [existing issues](../issues?q=is%3Aissue) — the question
may already have been asked.
@@ -85,7 +85,7 @@ body:
attributes:
label: Before submitting
options:
- label: I read the [Wiki](https://github.com/MHSanaei/3x-ui/wiki) and searched [existing issues](../issues?q=is%3Aissue).
- label: I read the [project home](https://git.881218.xyz/rogue/3x-ui-agent) and searched [existing issues](../issues?q=is%3Aissue).
required: true
- label: This is a usage question, not a bug report.
required: true
+1 -1
View File
@@ -153,7 +153,7 @@ test that cannot fail is invisible to CI. `make verify` is the local gate.
## Support facts reporters get wrong
- Linux install: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
- Linux install: `bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)`
- Install generates a RANDOM username, password and web base path — never
admin/admin. The `x-ui` menu on the server shows or resets them.
- The installer service environment file is DISTRO-DEPENDENT:
+3 -3
View File
@@ -50,8 +50,8 @@ Cross-building the Linux SQLite target from Windows (or vice versa) requires a s
## First-time setup
```bash
git clone https://github.com/MHSanaei/3x-ui.git
cd 3x-ui
git clone https://git.881218.xyz/rogue/3x-ui-agent.git
cd 3x-ui-agent
cp .env.example .env
@@ -315,6 +315,6 @@ must match the override, for example `XUI_PORT: "8080"` with `ports: ["8080:8080
## Issues
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
- Bug reports and feature requests: [Issues](https://git.881218.xyz/rogue/3x-ui-agent/issues)
Before filing a bug, include the OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.
+46
View File
@@ -0,0 +1,46 @@
# 3x-ui 路由器主控精简版 — 定版清单
> 2026-10-20 与用户逐项讨论中。本文档定版前不开工。
> 背景:AX6600 路由器 = **主控**(3x-ui 3.7.0,纯控制面,本机 xray 已 chmod -x 禁用,RSS ~130MB);
> Alpine 主控 = 备用,趋于退役。精简目标:二开构建压内存,验收线 RSS ≤ 90MB。
## 保留(已定)
- 入站/客户端管理、节点同步、内部流量统计(主控本职)
- 订阅服务,但**极简输出**:只给节点本身,不塞分流规则模板(geosite/geoip 规则集去掉);多格式转换保留
- VLESS、SS2022 入站(SS2022 为全网硬约束,不得移除)
- **API 令牌系统(暂保留)**:存量原版节点与主控的同步靠原版令牌机制(远程面板令牌 + node-sync scoped token);二开 agent 走自己的注册令牌是另一套。等存量节点全换二开 agent 后再议删除
- 数据库 schema 不动(回滚保险:换回原版二进制可读同一份库)
## 裁剪(已定)
- Telegram 机器人 + 面板设置里的「通知」(用户改手动备份;路由器上的 TG 备份 cron 绕行方案同步弃用)
- xray 配置模块**部分裁**:**出站 + 路由保留**(页面与生成逻辑都要;节点内网中转靠主控下发出站/路由,用户明确);其余模板部件(DNS、日志、策略等本机向设置)固定为内置默认值,去掉页面与生成/校验逻辑
- xray 可执行文件不再随包(x25519 内置生成后无硬依赖)
- **xray-core 库剥离**:配置生成改为纯 JSON 结构实现。最大一刀、风险最高,**最后动**
- API 文档(Swagger 页)
- 面板设置:外部流量(对外上报接口,未接任何外部系统;内部统计不受影响)
- 面板设置:LDAP
- 安全设置:双重验证(2FA,用户不用)
- 邮件模块(SMTP 发送:到期/流量提醒等,用户不用)
- 分组功能(客户端归类标签 + 按组批量操作/聚合订阅;用户从未用过,裁功能不影响已建客户端与订阅)
## 待用户确认
- 协议范围:是否只留 VLESS + SS2022,裁其余协议模板(VMess、Trojan、WireGuard、TUIC、Hysteria、MTProto 等)——建议裁
- IP 检查 / 测速类后台任务——建议裁
- 面板内置 ACME 签证书(走 CF Tunnel,不直签)——建议裁
- 多用户功能(单管理员使用)——建议裁
## 修复(非裁剪,已定)
- **x25519 内置生成**:原版生成 Reality 密钥靠调 `xray x25519`,主控禁用 xray 后建 VLESS+Reality 节点报"获取 x25519 证书错误"。改面板 Go 内置实现(crypto/ecdh),不再依赖 xray 二进制
- **mixed 协议节点部署 bug**(原版 bug):主控建入站选 mixed 协议时节点选择框消失,只能建本机;但节点本地建的 mixed 入站可同步回主控管理,证明后端/同步通道支持,纯前端对话框条件问题。修前端条件,允许 mixed 选节点部署
## 流程与验收(已定)
- 基线:3.7.0 二开版,不升级版本
- 顺序:边角裁剪 → 两个 bug 修复 → 最后剥 xray-core
- 交付:沙箱构建 → 路由器旁路端口冒烟 → 用户验收后替换;原版二进制保留,换回即回滚
- 验收线:RSS ≤ 90MB;订阅可拉;节点同步正常;Reality 建节点不报错(含 x25519 获取);mixed 可部署到节点;流量统计正常
- 内存预估(2026-10-20):基线 130.6MB → 预计 50-70MB。边角裁剪(TG/邮件/LDAP/2FA/文档/分组)对 RSS 贡献仅个位数 MB(多为休眠代码,主要瘦二进制);大头在剥 xray-core(库初始化/注册表/API 客户端栈)。若 xray-core 那刀不动,改完仍在 110-120MB。下限参考:框架(Gin+GORM+SQLite+Web UI+节点同步+订阅渲染)底盘约 40-50MB,到不了 NexusGate Go 版的 15-30MB(那是 stdlib 重写的量级)
+5 -22
View File
@@ -8,12 +8,7 @@
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
</p>
**3X-UI** هي لوحة تحكم ويب متقدمة ومفتوحة المصدر لإدارة خوادم [Xray-core](https://github.com/XTLS/Xray-core). توفّر واجهة نظيفة ومتعددة اللغات لنشر وتكوين ومراقبة مجموعة واسعة من بروتوكولات الوكيل وVPN — من خادم VPS واحد إلى عمليات النشر متعددة العقد.
@@ -69,24 +64,24 @@
## البدء السريع
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)
```
لتثبيت إصدار محدد، أضِف وسمه (مثل `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
# This fork provides no pinned-version one-click install; see the main README (README.md).
```
لتثبيت بنية **dev** المتجددة (أحدث إصدار أولي لكل التزام (commit) من `main`، وليس إصدارًا مستقرًا)، مرّر `dev-latest`:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
# This fork provides no dev-channel one-click install; see the main README (README.md).
```
أثناء التثبيت، يتم إنشاء اسم مستخدم وكلمة مرور ومسار وصول عشوائية. بعد التثبيت، شغّل `x-ui` لفتح قائمة الإدارة، حيث يمكنك بدء/إيقاف الخدمة، وعرض أو إعادة تعيين بيانات تسجيل الدخول، وإدارة شهادات SSL، والمزيد.
للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://github.com/MHSanaei/3x-ui/wiki).
للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://git.881218.xyz/rogue/3x-ui-agent).
### التثبيت غير التفاعلي
@@ -139,7 +134,7 @@ docker compose --profile postgres up -d
تتضمن الصورة Fail2ban (مُفعَّل افتراضيًا) لفرض **حدود IP** لكل عميل. يحظر Fail2ban المخالفين باستخدام `iptables`، الذي يتطلب صلاحية `NET_ADMIN`. يمنح `docker-compose.yml` هذه الصلاحية مسبقًا عبر `cap_add`؛ إذا شغّلت الحاوية باستخدام `docker run` بدلاً من ذلك، فأضِف الصلاحيات بنفسك، وإلا فسيتم تسجيل عمليات الحظر دون تطبيقها أبدًا:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... 3x-ui-agent:local
```
## متغيرات البيئة
@@ -191,15 +186,3 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
## دعم المشروع
**إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## النجوم عبر الزمن
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+5 -23
View File
@@ -8,12 +8,7 @@
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
</p>
**3X-UI** es un panel de control web avanzado y de código abierto para gestionar servidores [Xray-core](https://github.com/XTLS/Xray-core). Ofrece una interfaz limpia y multilingüe para desplegar, configurar y monitorear una amplia gama de protocolos de proxy y VPN — desde un único VPS hasta despliegues multinodo.
@@ -69,24 +64,24 @@ Construido como un fork mejorado del proyecto X-UI original, 3X-UI añade un sop
## Inicio Rápido
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)
```
Para instalar una versión específica, añade su etiqueta (p. ej. `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
# This fork provides no pinned-version one-click install; see the main README (README.md).
```
Para instalar la versión **dev** continua (la última prelanzamiento por commit desde `main`, no una versión estable), pasa `dev-latest`:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
# This fork provides no dev-channel one-click install; see the main README (README.md).
```
Durante la instalación se generan un nombre de usuario, una contraseña y una ruta de acceso aleatorios. Tras la instalación, ejecuta `x-ui` para abrir el menú de gestión, donde puedes iniciar/detener el servicio, ver o restablecer tus credenciales de acceso, gestionar certificados SSL y mucho más.
Para la documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki).
Para la documentación completa, visita la [Wiki del proyecto](https://git.881218.xyz/rogue/3x-ui-agent).
### Instalación desatendida
@@ -139,7 +134,7 @@ docker compose --profile postgres up -d
La imagen incluye Fail2ban (habilitado de forma predeterminada) para aplicar **límites de IP** por cliente. Fail2ban banea a los infractores con `iptables`, lo que requiere la capacidad `NET_ADMIN`. `docker-compose.yml` ya la concede mediante `cap_add`; si en su lugar inicias el contenedor con `docker run`, añade tú mismo las capacidades, de lo contrario los baneos se registran pero nunca se aplican:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... 3x-ui-agent:local
```
## Variables de Entorno
@@ -191,16 +186,3 @@ Herramientas e integraciones construidas por la comunidad alrededor de 3x-ui.
## Apoyar el Proyecto
**Si este proyecto te es útil, puedes darle una**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Estrellas a lo Largo del Tiempo
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+5 -23
View File
@@ -8,12 +8,7 @@
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
</p>
**3X-UI** یک پنل کنترل وب پیشرفته و متن‌باز برای مدیریت سرورهای [Xray-core](https://github.com/XTLS/Xray-core) است. این پنل یک رابط کاربری تمیز و چندزبانه برای استقرار، پیکربندی و نظارت بر طیف گسترده‌ای از پروتکل‌های پراکسی و VPN ارائه می‌دهد — از یک VPS تکی تا استقرارهای چندنودی.
@@ -69,24 +64,24 @@
## شروع سریع
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)
```
برای نصب یک نسخه‌ی مشخص، تگ آن را در انتها اضافه کنید (مثلاً `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
# This fork provides no pinned-version one-click install; see the main README (README.md).
```
برای نصب نسخه‌ی غلتانِ **dev** (آخرین پیش‌انتشار به‌ازای هر کامیت از شاخه‌ی `main`، نه یک انتشار پایدار)، مقدار `dev-latest` را پاس دهید:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
# This fork provides no dev-channel one-click install; see the main README (README.md).
```
در حین نصب، یک نام کاربری، رمز عبور و مسیر دسترسی تصادفی تولید می‌شود. پس از نصب، دستور `x-ui` را اجرا کنید تا منوی مدیریت باز شود؛ در آنجا می‌توانید سرویس را شروع/متوقف کنید، اطلاعات ورود خود را ببینید یا بازنشانی کنید، گواهی‌های SSL را مدیریت کنید و کارهای دیگری انجام دهید.
برای مستندات کامل، لطفاً به [ویکی پروژه](https://github.com/MHSanaei/3x-ui/wiki) مراجعه کنید.
برای مستندات کامل، لطفاً به [ویکی پروژه](https://git.881218.xyz/rogue/3x-ui-agent) مراجعه کنید.
### نصب بدون نظارت
@@ -139,7 +134,7 @@ docker compose --profile postgres up -d
این ایمیج، Fail2ban را (که به‌صورت پیش‌فرض فعال است) برای اعمال **محدودیت‌های IP** به‌ازای هر کلاینت همراه دارد. ‏Fail2ban متخلفان را با `iptables` مسدود می‌کند که به مجوز `NET_ADMIN` نیاز دارد. فایل `docker-compose.yml` این مجوز را از قبل از طریق `cap_add` می‌دهد؛ اگر به‌جای آن کانتینر را با `docker run` اجرا می‌کنید، خودتان مجوزها را اضافه کنید، در غیر این صورت مسدودسازی‌ها فقط ثبت می‌شوند اما هرگز اعمال نمی‌شوند:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... 3x-ui-agent:local
```
## متغیرهای محیطی
@@ -191,16 +186,3 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
## پشتیبانی از پروژه
**اگر این پروژه برای شما مفید است، می‌توانید به آن یک**:star2: بدهید
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## ستاره‌ها در طول زمان
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+83 -172
View File
@@ -1,206 +1,117 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) | [Türkçe](/README.tr_TR.md)
# 3x-ui-agent
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
<img alt="3x-ui" src="./media/3x-ui-light.png">
</picture>
</p>
基于上游 3x-ui v3.7.0 的二次开发 fork:节点 agent 化。节点鸡不再跑完整面板,只跑一个轻量 agent(约 60MB 内存),由主控统一管理、下发 xray 配置。
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
</p>
本仓库托管在自建 Gitea:<https://git.881218.xyz/rogue/3x-ui-agent>,二进制从本仓库的 Releases 下载。**不要**使用上游的一键安装脚本/命令(`install.sh`、`update.sh` 等仍指向原版,会装成上游原版,与本 fork 不兼容)。
**3X-UI** is an advanced, open-source web control panel for managing [Xray-core](https://github.com/XTLS/Xray-core) servers. It provides a clean, multi-language interface for deploying, configuring, and monitoring a wide range of proxy and VPN protocols — from a single VPS to multi-node deployments.
## 架构
Built as an enhanced fork of the original X-UI project, 3X-UI adds broader protocol support, improved stability, per-client traffic accounting, and many quality-of-life features.
- **主控**:完整 3x-ui 面板,唯一的配置可信源。管理节点、入站、客户端,下发完整 xray 配置(含出站、路由)到各 agent。
- **Agent**:跑在节点鸡上,通过注册 token 向主控注册。定时拉取主控下发的 xray 配置并应用,上报心跳和流量。节点下发仅支持 VLESS / SS2022 两类入站(本 fork 的取向)。
> [!IMPORTANT]
> This project is intended for personal use only. Please do not use it for illegal purposes or in a production environment.
同一个二进制、两种角色:不带参数运行是主控面板,`x-ui agent --config /etc/x-ui/agent.json` 运行是 agent。
## Features
## 安装
- **Multi-protocol inbounds** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel, and TUN.
- **Modern transports & security** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade, and XHTTP, secured with TLS, XTLS, and REALITY.
- **Fallbacks** — serve multiple protocols on a single port (e.g. VLESS and Trojan on 443) using Xray's fallback support.
- **Per-client management** — traffic quotas, expiry dates, IP limits, live online status, and one-click share links, QR codes, and subscriptions.
- **Traffic statistics** — per inbound, per client, and per outbound, with reset controls.
- **Multi-node support** — manage and scale across multiple servers from a single panel.
- **Outbound & routing** — WARP, NordVPN, custom routing rules, load balancers, and outbound proxy chaining.
- **Built-in subscription server** with multiple output formats and [custom page templates](docs/custom-subscription-templates.md).
- **Telegram bot** for remote monitoring and management.
- **RESTful API** with in-panel Swagger documentation.
- **Flexible storage** — SQLite (default) or PostgreSQL.
- **13 UI languages** with dark and light themes.
- **Fail2ban integration** for enforcing per-client IP limits.
二进制下载:本仓库 [Releases](https://git.881218.xyz/rogue/3x-ui-agent/releases),按架构选 `x-ui-linux-amd64` 或 `x-ui-linux-arm64`。
## Screenshots
> 仓库根目录的 `install-master.sh` / `install-agent.sh` 是一键脚本,下载源已迁至本仓 Gitea Releases(tag `v3.7.0-agent.1`),可直接使用;也可按下面步骤手动部署。
<details>
<summary>Click to expand</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="Overview" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
<img alt="Inbounds" src="./media/02-add-inbound-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
<img alt="Add client" src="./media/03-add-client-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
<img alt="Configs" src="./media/05-add-nodes-light.png">
</picture>
</details>
## Quick Start
### 主控
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
mkdir -p /usr/local/x-ui/bin /etc/x-ui
# 1. 放主控二进制(Releases 下载的 x-ui-linux-<架构>)
cp x-ui-linux-amd64 /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui
# 2. 放 xray 二进制与 geo 文件到 bin/ 子目录
# 纯控制面部署(节点全走 agent)可禁用本机 xray:不放,或放了之后 chmod -x
cp xray-linux-amd64 /usr/local/x-ui/bin/
cp geoip.dat geosite.dat /usr/local/x-ui/bin/
```
To install a specific version, append its tag (e.g. `v3.4.0`):
启动(二选一):
- Debian/Ubuntu(systemd):把仓库的 `x-ui.service.debian` 复制为 `/etc/systemd/system/x-ui.service`,然后 `systemctl daemon-reload && systemctl enable --now x-ui`。
- OpenWrt(procd):用 init 脚本以 `/usr/local/x-ui/x-ui` 为命令启动,工作目录 `/usr/local/x-ui`。
数据库在 `/etc/x-ui/x-ui.db`。面板端口按设置,默认 2053,首次登录默认账号 `admin` / `admin`,请登录后立即修改。
### Agent(节点鸡)
先在主控侧为该节点生成注册 token(建节点 → 用面板 API Token 签发绑定该节点的 agent token,完整步骤见 [docs/AGENT.md](docs/AGENT.md)),然后在节点鸡上:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
mkdir -p /usr/local/x-ui/bin /etc/x-ui
# 1. 放同一个 x-ui 二进制(与主控同版本)
cp x-ui-linux-amd64 /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,需手动放置)
cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray
cp geoip.dat geosite.dat /usr/local/x-ui/bin/
# 3. 写 agent 配置
cat > /etc/x-ui/agent.json <<'EOF'
{
"master_url": "http://主控地址:2053",
"token": "<主控签发的注册 token>",
"node_name": "节点名"
}
EOF
chmod 600 /etc/x-ui/agent.json
```
To install the rolling **dev** build (latest per-commit pre-release from `main`, not a stable release), pass `dev-latest`:
- `master_url` 走内网用 `http://`;走 `https://` 需可信证书(自签连不上)。
- agent 另有独立的运行时配置 `config-agent.json`(生成于 `bin/`);`agent.json` 可选 `api_port` 指定 xray stats API 端口(部署口径 62790,metrics 端口为其下一位 62791)。
- 启动:Debian 把仓库的 `systemd/x-ui-agent.service` 复制到 `/etc/systemd/system/` 后 `systemctl enable --now x-ui-agent`;OpenRC 用仓库的 `openrc/x-ui-agent` 放到 `/etc/init.d/x-ui-agent`。
agent 约 30 秒后拉到配置并启动 xray,节点在面板显示在线。更细的 agent 说明(出站/路由下发、验证、故障排查)见 [docs/AGENT.md](docs/AGENT.md)。
## 卸载
### 主控
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
# 先停服务:systemctl disable --now x-ui(或 /etc/init.d/x-ui stop)
rm -f /etc/systemd/system/x-ui.service # 或删掉对应的 procd init 脚本
rm -rf /usr/local/x-ui /etc/x-ui
```
During installation a random username, password, and access path are generated. After installation, run `x-ui` to open the management menu, where you can start/stop the service, view or reset your login credentials, manage SSL certificates, and more.
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
### Unattended install
The installer also runs **non-interactively** for cloud-init.
Set `XUI_NONINTERACTIVE=1` (or pipe with no TTY) and it installs end-to-end with
zero prompts, generating random credentials and writing them to
`/etc/x-ui/install-result.env`. See [`deploy/`](deploy/) for:
- [Cloud-init user-data](deploy/cloud-init/) — unattended install on any cloud (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
- [Hetzner Cloud notes](deploy/marketplace/hetzner/) — cloud-init deployment on Hetzner
## Supported Platforms
**Operating systems:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine, and Windows.
**Architectures:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
## Database Options
3X-UI supports two backends, chosen during the install:
- **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small and medium deployments.
- **PostgreSQL** — recommended for high client counts or multi-node setups. The installer can install PostgreSQL locally for you, or accept a DSN to an existing server.
At runtime the backend is selected via environment variables (the installer writes these to `/etc/default/x-ui` for you):
```
XUI_DB_TYPE=postgres
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
```
### Migrating an existing SQLite install to PostgreSQL
### Agent
```bash
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
# then set XUI_DB_TYPE and XUI_DB_DSN in /etc/default/x-ui and restart:
systemctl restart x-ui
# 先停服务:systemctl disable --now x-ui-agent(或 rc-service x-ui-agent stop)
rm -f /etc/systemd/system/x-ui-agent.service # 或 /etc/init.d/x-ui-agent
rm -rf /usr/local/x-ui /etc/x-ui
```
The source SQLite file is left untouched; remove it manually once you have verified the new backend.
(`/etc/x-ui` 里含数据库与 token 配置,确认不再需要再删。)
### Docker
## 使用流程
The default `docker compose up -d` keeps using SQLite. To run with the bundled PostgreSQL service, uncomment the two `XUI_DB_*` env lines in `docker-compose.yml` and start with the profile:
1. 主控侧建节点并签发 agent 注册 token(见 docs/AGENT.md)
2. 节点鸡按上面步骤部署 agent,填入主控地址和 token
3. 在「入站列表」创建入站(VLESS / SS2022),「部署到」选择在线的 agent 节点
4. 主控自动把完整 xray 配置下发到节点,客户端即可连接
```bash
docker compose --profile postgres up -d
```
## 内存占用(实测)
The image bundles Fail2ban (enabled by default) to enforce per-client **IP limits**. Fail2ban bans offenders with `iptables`, which requires the `NET_ADMIN` capability. `docker-compose.yml` already grants it via `cap_add`; if you start the container with `docker run` instead, add the capabilities yourself, otherwise bans are logged but never applied:
| | 内存 |
|---|---|
| agent + xray | 约 63MB |
| 完整 3x-ui 面板 | 130–160MB |
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
```
另有路由器主控精简版构建(剥离 xray-core 库,见仓库内 PANEL-LITE-SPEC.md),面向纯控制面部署,内存显著更低。
## Environment Variables
## 与原版的区别
| Variable | Description | Default |
| --- | --- | --- |
| `XUI_DB_TYPE` | Database backend: `sqlite` or `postgres` | `sqlite` |
| `XUI_DB_DSN` | PostgreSQL connection string (when `XUI_DB_TYPE=postgres`) | — |
| `XUI_DB_FOLDER` | Directory for the SQLite database file | `/etc/x-ui` |
| `XUI_DB_MAX_OPEN_CONNS` | Maximum open connections (PostgreSQL pool) | — |
| `XUI_DB_MAX_IDLE_CONNS` | Maximum idle connections (PostgreSQL pool) | — |
| `XUI_INIT_WEB_BASE_PATH` | The initial URI path for the web panel | `/` |
| `XUI_ENABLE_FAIL2BAN` | Enable Fail2ban-based IP-limit enforcement | `true` |
| `XUI_LOG_LEVEL` | Log verbosity (`debug`, `info`, `warning`, `error`) | `info` |
| `XUI_DEBUG` | Enable debug mode | `false` |
| `XUI_TUNNEL_HEALTH_MONITOR` | Enable the tunnel health monitor (probes a URL and restarts xray after repeated failures; a restart drops all clients) | `false` |
| `XUI_TUNNEL_HEALTH_PROXY` | Proxy the probe is sent through; point it at a local xray inbound so the probe tests the tunnel (e.g. `socks5://127.0.0.1:1080`). Empty means the probe only checks host connectivity | — |
| `XUI_TUNNEL_HEALTH_URL` | URL probed for tunnel health | `https://www.cloudflare.com/cdn-cgi/trace` |
| `XUI_TUNNEL_HEALTH_INTERVAL` | Interval between probes | `30s` |
| `XUI_TUNNEL_HEALTH_TIMEOUT` | Per-probe timeout | `10s` |
| `XUI_TUNNEL_HEALTH_FAILURES` | Consecutive failures before a restart is triggered | `3` |
| `XUI_TUNNEL_HEALTH_COOLDOWN` | Minimum delay between consecutive restarts | `5m` |
- 节点不再需要安装完整面板
- 主控是唯一可信源,节点配置全部由主控下发
- 节点在线状态由 agent 心跳维持
- 添加节点支持 Agent 类型,使用注册 token 接入
## Supported Languages
## License
The panel UI is available in 13 languages:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
## Contributing
Contributions are welcome. Please read the [Contributing Guide](/CONTRIBUTING.md) before opening an issue or pull request.
## A Special Thanks to
- [alireza0](https://github.com/alireza0/)
## Acknowledgment
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
## Community Tools
Tools and integrations built by the community around 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (License: **MIT**): _Manage inbounds, clients, panel settings, and Xray configuration as code with Terraform / OpenTofu._
## Support project
**If this project is helpful to you, you may wish to give it a**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Stargazers over Time
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
GPL-3.0(继承上游 3x-ui)
+5 -23
View File
@@ -8,12 +8,7 @@
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
</p>
**3X-UI** — продвинутая веб-панель управления с открытым исходным кодом для управления серверами [Xray-core](https://github.com/XTLS/Xray-core). Она предоставляет аккуратный многоязычный интерфейс для развёртывания, настройки и мониторинга широкого спектра протоколов прокси и VPN — от одного VPS до развёртываний с несколькими узлами.
@@ -69,24 +64,24 @@
## Быстрый старт
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)
```
Чтобы установить конкретную версию, добавьте её тег (например, `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
# This fork provides no pinned-version one-click install; see the main README (README.md).
```
Чтобы установить скользящую **dev**-сборку (новейший предварительный релиз по каждому коммиту из ветки `main`, а не стабильный релиз), передайте `dev-latest`:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
# This fork provides no dev-channel one-click install; see the main README (README.md).
```
Во время установки генерируются случайные имя пользователя, пароль и путь доступа. После установки выполните `x-ui`, чтобы открыть меню управления, где можно запускать/останавливать сервис, просматривать или сбрасывать учётные данные для входа, управлять SSL-сертификатами и многое другое.
Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
Полную документацию смотрите в [вики проекта](https://git.881218.xyz/rogue/3x-ui-agent).
### Автоматическая установка
@@ -139,7 +134,7 @@ docker compose --profile postgres up -d
Образ включает Fail2ban (включён по умолчанию) для применения **лимитов IP** по каждому клиенту. Fail2ban блокирует нарушителей с помощью `iptables`, что требует возможности `NET_ADMIN`. `docker-compose.yml` уже предоставляет её через `cap_add`; если вы вместо этого запускаете контейнер через `docker run`, добавьте возможности самостоятельно, иначе блокировки будут регистрироваться, но никогда не применяться:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... 3x-ui-agent:local
```
## Переменные окружения
@@ -191,16 +186,3 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
## Поддержка проекта
**Если этот проект полезен для вас, вы можете поставить ему**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Звезды с течением времени
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+5 -23
View File
@@ -8,12 +8,7 @@
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
</p>
**3X-UI**, [Xray-core](https://github.com/XTLS/Xray-core) sunucularını yönetmek için geliştirilmiş profesyonel, açık kaynaklı bir web kontrol panelidir. Tek bir sanal sunucudan (VPS) çok düğümlü (multi-node) dağıtımlara kadar çok çeşitli proxy ve VPN protokollerini kurmak, yapılandırmak ve izlemek için temiz, çok dilli bir arayüz sağlar.
@@ -69,24 +64,24 @@ Orijinal X-UI projesinin geliştirilmiş bir çatallaması (fork) olarak inşa e
## Hızlı Başlangıç
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)
```
Belirli bir sürümü kurmak için, etiketini (ör. `v3.4.0`) ekleyin:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
# This fork provides no pinned-version one-click install; see the main README (README.md).
```
Sürekli güncellenen **dev** sürümünü (kararlı bir sürüm değil; `main` dalından her commit'te oluşturulan en son ön sürüm) kurmak için `dev-latest` değerini geçirin:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
# This fork provides no dev-channel one-click install; see the main README (README.md).
```
Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu oluşturulur. Kurulumdan sonra, hizmeti başlatabileceğiniz/durdurabileceğiniz, giriş bilgilerinizi görüntüleyebileceğiniz veya sıfırlayabileceğiniz, SSL sertifikalarını yönetebileceğiniz ve çok daha fazlasını yapabileceğiniz yönetim menüsünü açmak için terminalde `x-ui` komutunu çalıştırın.
Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHSanaei/3x-ui/wiki) ziyaret edin.
Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://git.881218.xyz/rogue/3x-ui-agent) ziyaret edin.
### Etkileşimsiz kurulum
@@ -139,7 +134,7 @@ docker compose --profile postgres up -d
Docker imajı, kullanıcı başına **IP limitlerini** zorunlu kılmak için Fail2ban ile (varsayılan olarak etkindir) paketlenmiştir. Fail2ban, ihlalcileri `iptables` ile engeller ve bunun için `NET_ADMIN` yetkisine ihtiyaç duyar. `docker-compose.yml` bunu zaten `cap_add` üzerinden vermektedir; ancak konteyneri bunun yerine `docker run` ile başlatırsanız bu yetkileri kendiniz eklemelisiniz, aksi takdirde yasaklamalar günlüğe kaydedilir ancak uygulanmaz:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... 3x-ui-agent:local
```
## Ortam Değişkenleri (Environment Variables)
@@ -191,16 +186,3 @@ Katkılarınızı her zaman bekliyoruz. Bir sorun (issue) açmadan veya pull req
## Projeyi Destekleyin
**Eğer bu proje size faydalı olduysa, bir yıldız verebilirsiniz**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Bana Bir Kahve Ismarla" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="NOWPayments üzerinden Kripto Bağış Butonu">
</a>
## Yıldız Tablosu
[![Zaman içerisindeki yıldız sayısı](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+6 -23
View File
@@ -8,12 +8,8 @@
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
</p>
**3X-UI** 是一个先进的开源 Web 控制面板,用于管理 [Xray-core](https://github.com/XTLS/Xray-core) 服务器。它提供简洁、多语言的界面,用于部署、配置和监控各种代理与 VPN 协议——从单台 VPS 到多节点部署。
@@ -69,24 +65,24 @@
## 快速开始
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
bash <(curl -Ls https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh)
```
若要安装特定版本,请在命令后附加对应的标签(例如 `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
# This fork provides no pinned-version one-click install; see the main README (README.md).
```
若要安装滚动更新的 **dev** 版本(来自 `main` 的最新逐次提交预发布版本,而非稳定版本),请传入 `dev-latest`:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
# This fork provides no dev-channel one-click install; see the main README (README.md).
```
安装过程中会生成随机的用户名、密码和访问路径。安装完成后,运行 `x-ui` 打开管理菜单,您可以在其中启动/停止服务、查看或重置登录凭据、管理 SSL 证书等。
完整文档请参阅 [项目Wiki](https://github.com/MHSanaei/3x-ui/wiki)。
完整文档请参阅 [项目Wiki](https://git.881218.xyz/rogue/3x-ui-agent)。
### 无人值守安装
@@ -139,7 +135,7 @@ docker compose --profile postgres up -d
该镜像捆绑了 Fail2ban(默认启用),用于强制执行按客户端的 **IP 限制**。Fail2ban 使用 `iptables` 封禁违规者,这需要 `NET_ADMIN` 权限。`docker-compose.yml` 已通过 `cap_add` 授予该权限;如果您改用 `docker run` 启动容器,请自行添加这些权限,否则封禁只会被记录而永远不会生效:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... 3x-ui-agent:local
```
## 环境变量
@@ -191,16 +187,3 @@ English · فارسی · العربية · 中文(简体) · 中文(繁體
## 支持项目
**如果这个项目对您有帮助,您可以给它一个**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## 随时间变化的星标数
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+6 -3
View File
@@ -10,8 +10,11 @@
# serial console (owner-only); /etc/motd (world-readable) shows just the access
# URL + username. Nothing is baked in advance — every instance is unique.
#
# Requires the non-interactive install.sh (3x-ui with XUI_NONINTERACTIVE support).
# Edit the exported XUI_* knobs in /opt/xui-bootstrap.sh below to customise.
# Fork note (3x-ui-agent): the bootstrap below installs this fork's
# install-master.sh from its own Gitea repository. The fork installer does
# NOT consume the XUI_* non-interactive knobs and does NOT write
# /etc/x-ui/install-result.env, so the XUI_* exports and the credential-
# surfacing block below are inert leftovers from the upstream installer.
# ---------------------------------------------------------------------------
package_update: true
@@ -44,7 +47,7 @@ write_files:
# export XUI_DB_DSN="postgres://user:pass@host:5432/db?sslmode=disable"
# ------------------------------------------------------------------------
curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh | bash
curl -fsSL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-master.sh | bash
# Surface the generated credentials. Full creds (incl. password + API token)
# go ONLY to the serial console (/dev/console, owner-only). /etc/motd is
+125
View File
@@ -0,0 +1,125 @@
# 3x-ui Agent 模式安装说明
节点鸡不再跑完整 3x-ui 面板,只跑一个轻量 agent:用 token 向主控注册,
主控下发完整 xray 配置(含入站、出站、路由规则),agent 写配置并管理 xray 进程,
每 60 秒向主控上报流量/在线数。
## 架构
- 主控:完整 3x-ui(v3.7.0+agent 补丁),负责生成配置、下发、管理出站与路由规则。
- 节点:`x-ui agent` 二进制 + xray 二进制 + geo 文件,无面板、无数据库。
- 出站/路由规则只能在主控上建和改(面板是唯一可信源),agent 只管应用下发
的完整配置,不上报本地配置。
## 一、主控准备:生成 agent token
先在主控的「节点管理」里建好(或复用)该节点,记下节点 id。然后用面板
API token 调用(面板 → 设置 → API Token 新建一个):
```bash
# 生成 token,绑定到节点 id=3
curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"node_id":3,"name":"hk-node-1"}' \
https://主控地址:2053/panel/api/agent/tokens
# 返回 {"success":true,"obj":{"id":1,"token":"...","token_prefix":"...","node_id":3}}
```
`token` 只返回一次,请保存好。查看已发 token(不含明文):
```bash
curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" \
https://主控地址:2053/panel/api/agent/tokens
```
## 二、节点安装
```bash
mkdir -p /usr/local/x-ui/bin /etc/x-ui
# 1. 放 x-ui 二进制(agent 补丁版,与主控同版本)
cp x-ui /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,手动放置)
cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray
cp geoip.dat geosite.dat /usr/local/x-ui/bin/
# 3. 写 agent 配置
cat > /etc/x-ui/agent.json <<'EOF'
{
"master_url": "http://主控内网地址:2053",
"token": "<上一步生成的 token>",
"node_name": "hk-node-1"
}
EOF
chmod 600 /etc/x-ui/agent.json
```
说明:
- `master_url` 用 `http://`(内网)或 `https://`(需可信证书;自签证书连不上,
agent 默认校验 TLS)。
- `poll_interval` 可省略,默认 30 秒轮询一次配置。
- 不要在节点上再启动完整面板(`x-ui` 不带 `agent` 参数的模式)。
## 三、启动 agent
systemd(Debian/Ubuntu):
```bash
cp systemd/x-ui-agent.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now x-ui-agent
journalctl -u x-ui-agent -f
```
OpenWrt(procd):
```bash
cp openrc/x-ui-agent /etc/init.d/x-ui-agent
chmod +x /etc/init.d/x-ui-agent
/etc/init.d/x-ui-agent enable
/etc/init.d/x-ui-agent start
logread -f | grep x-ui-agent
```
## 四、主控管理该节点的出站与路由规则
出站/路由规则按 agent 存(节点特异的内网地址如 `10.240.64.2:23183`
必须按节点配,不能做全局模板)。`<TOKEN>` 用第一步生成的完整 token:
```bash
# 查看当前 egress(未设置时返回空数组,走模板默认 direct/blocked)
curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" \
https://主控地址:2053/panel/api/agent/egress/<TOKEN>
# 写入出站+路由规则(完整数组,直接替换模板默认)
curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"outbounds":[{"tag":"PPFL3-L1","protocol":"socks","settings":{"servers":[{"address":"10.240.64.2","port":23183}]}}],
"routing_rules":[{"type":"field","inboundTag":["in-18692-tcp"],"outboundTag":"PPFL3-L1"}]}' \
-X PUT https://主控地址:2053/panel/api/agent/egress/<TOKEN>
```
- 下发配置里的 stats API 路由规则(`inboundTag: ["api"]`)会自动补齐,
不用手写,写了也不会重复。
- 传空数组会清空该项、回退到模板默认。
## 五、验证
1. agent 日志出现 `Xray config applied successfully`。
2. 节点上 `ss -tlnp | grep xray` 能看到入站端口在监听。
3. 主控 `GET /panel/api/agent/tokens` 里该 token 的 `last_seen` 在更新。
4. 客户端用入站链接连通;约 60 秒后主控面板上该节点入站/用户的流量开始涨。
5. 吊销 token:`curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" -X DELETE https://主控地址:2053/panel/api/agent/tokens/<id>`,
agent 下次心跳/取配置会收到 401 并在日志报错。
## 故障排查
| 现象 | 查 |
|---|---|
| `register failed ... 401` | token 贴错,或 token 没在主控生成 |
| `config fetch failed ... 409` | token 未绑定节点(生成时没传 `node_id`) |
| xray 起不来 | `/usr/local/x-ui/bin/xray` 是否存在可执行;`xray -test -c /usr/local/x-ui/bin/config.json` 看配置报错 |
| 流量不上报 | xray 的 api 入站端口是否被占用;agent 日志看 `Traffic report failed` |
| 主控连不上 | `master_url` 拼错;https 自签证书改走 `http://` 内网地址 |
-160
View File
@@ -5042,44 +5042,6 @@
}
}
},
"/panel/api/server/getXrayVersion": {
"get": {
"tags": [
"Server"
],
"summary": "List Xray binary versions available for install on this host.",
"operationId": "get_panel_api_server_getXrayVersion",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"obj": [
"v25.10.31",
"v25.9.15",
"v25.8.1"
]
}
}
}
}
}
}
},
"/panel/api/server/getPanelUpdateInfo": {
"get": {
"tags": [
@@ -5613,47 +5575,6 @@
}
}
},
"/panel/api/server/installXray/{version}": {
"post": {
"tags": [
"Server"
],
"summary": "Download and install the specified Xray version. Pass \"latest\" for the newest release.",
"operationId": "post_panel_api_server_installXray_version",
"parameters": [
{
"name": "version",
"in": "path",
"required": true,
"description": "Xray tag (e.g. v25.10.31) or \"latest\".",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/server/updatePanel": {
"post": {
"tags": [
@@ -5730,87 +5651,6 @@
}
}
},
"/panel/api/server/updateGeofile": {
"post": {
"tags": [
"Server"
],
"summary": "Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.",
"operationId": "post_panel_api_server_updateGeofile",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/server/updateGeofile/{fileName}": {
"post": {
"tags": [
"Server"
],
"summary": "Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).",
"operationId": "post_panel_api_server_updateGeofile_fileName",
"parameters": [
{
"name": "fileName",
"in": "path",
"required": true,
"description": "Filename of the data file to refresh.",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/server/logs/{count}": {
"post": {
"tags": [
+2 -16
View File
@@ -12,16 +12,13 @@ import {
ClusterOutlined,
CodeOutlined,
DashboardOutlined,
DatabaseOutlined,
ExportOutlined,
GithubOutlined,
GlobalOutlined,
HeartOutlined,
ImportOutlined,
LogoutOutlined,
MailOutlined,
MenuOutlined,
MessageOutlined,
MoonFilled,
MoonOutlined,
PushpinFilled,
@@ -44,7 +41,7 @@ import './AppSidebar.css';
const DONATE_URL = 'https://donate.sanaei.dev/';
const DOCS_URL = 'https://docs.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const REPO_URL = 'https://git.881218.xyz/rogue/3x-ui-agent';
const LOGOUT_KEY = '__logout__';
const RAIL_WIDTH = 72;
const SIDER_WIDTH = 220;
@@ -245,12 +242,6 @@ export default function AppSidebar() {
icon: <SafetyOutlined />,
label: t('pages.settings.securitySettings'),
},
{
key: '/settings#telegram',
icon: <MessageOutlined />,
label: t('pages.settings.TGBotSettings'),
},
{ key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') },
{
key: '/settings#subscription',
icon: <CloudServerOutlined />,
@@ -275,12 +266,7 @@ export default function AppSidebar() {
}, [t, showSubFormats, showSubBalancers]);
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
() => [
{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
{ key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
{ key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
{ key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
],
() => [{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') }],
[t],
);
+5 -1
View File
@@ -2,7 +2,10 @@ import { Protocols } from '@/schemas/primitives';
/*
* Protocols whose inbounds can live on a sub-node (the "Deploy To" set).
* Everything else (http, mixed, tunnel, tun, mtproto) is panel-local only.
* Everything else (http, tunnel, tun, mtproto) is panel-local only. Mixed
* is eligible: it is a plain xray inbound, and node-local mixed inbounds
* already sync back to the panel — hiding the picker only blocked creating
* them from the master.
* Shared by the inbound form's Deploy To selector and the clone dialog's
* target picker so the two surfaces can never drift apart.
*/
@@ -13,4 +16,5 @@ export const NODE_ELIGIBLE_PROTOCOLS: Readonly<Record<string, true>> = {
[Protocols.SHADOWSOCKS]: true,
[Protocols.HYSTERIA]: true,
[Protocols.WIREGUARD]: true,
[Protocols.MIXED]: true,
};
-48
View File
@@ -396,12 +396,6 @@ export const sections: readonly Section[] = [
},
],
},
{
method: 'GET',
path: '/panel/api/server/getXrayVersion',
summary: 'List Xray binary versions available for install on this host.',
response: '{\n "success": true,\n "obj": ["v25.10.31", "v25.9.15", "v25.8.1"]\n}',
},
{
method: 'GET',
path: '/panel/api/server/getPanelUpdateInfo',
@@ -499,20 +493,6 @@ export const sections: readonly Section[] = [
'Reload Xray with the current config. Typically required after structural inbound or routing changes.',
errorResponse: '{\n "success": false,\n "msg": "Xray config is invalid: ..."\n}',
},
{
method: 'POST',
path: '/panel/api/server/installXray/:version',
summary:
'Download and install the specified Xray version. Pass "latest" for the newest release.',
params: [
{
name: 'version',
in: 'path',
type: 'string',
desc: 'Xray tag (e.g. v25.10.31) or "latest".',
},
],
},
{
method: 'POST',
path: '/panel/api/server/updatePanel',
@@ -534,34 +514,6 @@ export const sections: readonly Section[] = [
],
body: 'dev=true',
},
{
method: 'POST',
path: '/panel/api/server/updateGeofile',
summary:
'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
params: [
{
name: 'fileName',
in: 'body (form)',
type: 'string',
desc: 'Filename to update (e.g. geoip.dat, geosite.dat). Omit to update all defaults.',
},
],
body: 'fileName=geoip.dat',
},
{
method: 'POST',
path: '/panel/api/server/updateGeofile/:fileName',
summary: 'Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).',
params: [
{
name: 'fileName',
in: 'path',
type: 'string',
desc: 'Filename of the data file to refresh.',
},
],
},
{
method: 'POST',
path: '/panel/api/server/logs/:count',
-228
View File
@@ -1,228 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, message } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
interface GeodataAssetRow {
url: string;
file: string;
}
interface GeodataSectionProps {
active: boolean;
onBusy: (e: { busy: boolean; tip?: string }) => void;
onClose: () => void;
}
const DEFAULT_CRON = '0 4 * * *';
// Xray resolves `file` inside its asset directory; plain file names only.
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
function fileNameFromUrl(url: string): string {
try {
const seg = new URL(url).pathname.split('/').filter(Boolean).pop() || '';
return FILE_NAME_PATTERN.test(seg) ? seg : '';
} catch {
return '';
}
}
export default function GeodataSection({ active, onBusy, onClose }: GeodataSectionProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [cron, setCron] = useState(DEFAULT_CRON);
const [outbound, setOutbound] = useState<string | undefined>(undefined);
const [rows, setRows] = useState<GeodataAssetRow[]>([]);
const [outboundTags, setOutboundTags] = useState<string[]>([]);
const [template, setTemplate] = useState<Record<string, unknown> | null>(null);
const outboundTestUrlRef = useRef('');
const load = useCallback(async () => {
try {
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
if (!msg?.success || typeof msg.obj !== 'string') return;
const payload = JSON.parse(msg.obj) as Record<string, unknown>;
const next = (payload.xraySetting || {}) as Record<string, unknown>;
setTemplate(next);
outboundTestUrlRef.current =
typeof payload.outboundTestUrl === 'string' ? payload.outboundTestUrl : '';
const geodata = (next.geodata || {}) as Record<string, unknown>;
const assets = Array.isArray(geodata.assets) ? geodata.assets : [];
setRows(
assets
.filter((a): a is Record<string, unknown> => !!a && typeof a === 'object')
.map((a) => ({ url: String(a.url ?? ''), file: String(a.file ?? '') })),
);
setCron(typeof geodata.cron === 'string' && geodata.cron ? geodata.cron : DEFAULT_CRON);
setOutbound(
typeof geodata.outbound === 'string' && geodata.outbound ? geodata.outbound : undefined,
);
// Download outbound candidates: template outbounds + subscription outbounds.
// Skip blackhole outbounds — routing a download through one just drops it.
const tags = new Set<string>();
const outbounds = Array.isArray(next.outbounds) ? next.outbounds : [];
for (const o of outbounds) {
if (!o || typeof o !== 'object') continue;
const rec = o as Record<string, unknown>;
if (rec.protocol === 'blackhole') continue;
const tag = rec.tag;
if (typeof tag === 'string' && tag) tags.add(tag);
}
const subTags = Array.isArray(payload.subscriptionOutboundTags)
? payload.subscriptionOutboundTags
: [];
for (const tag of subTags) {
if (typeof tag === 'string' && tag) tags.add(tag);
}
setOutboundTags([...tags]);
} finally {
setLoading(false);
}
}, []);
const [wasActive, setWasActive] = useState(false);
if (active !== wasActive) {
setWasActive(active);
if (active) setLoading(true);
}
useEffect(() => {
if (active) void load();
}, [active, load]);
function setRow(index: number, patch: Partial<GeodataAssetRow>) {
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
}
function onUrlBlur(index: number) {
setRows((prev) =>
prev.map((r, i) => (i === index && !r.file ? { ...r, file: fileNameFromUrl(r.url) } : r)),
);
}
function save() {
if (!template) return;
const assets = rows
.map((r) => ({ url: r.url.trim(), file: r.file.trim() }))
.filter((r) => r.url || r.file);
for (const a of assets) {
// Xray's geodata downloader accepts HTTPS URLs only.
if (!/^https:\/\/\S+$/i.test(a.url)) {
messageApi.error(t('pages.index.geodataInvalidUrl'));
return;
}
if (!FILE_NAME_PATTERN.test(a.file)) {
messageApi.error(t('pages.index.geodataInvalidFile'));
return;
}
}
const cronValue = cron.trim();
if (assets.length > 0 && cronValue && cronValue.split(/\s+/).length !== 5) {
messageApi.error(t('pages.index.geodataInvalidCron'));
return;
}
modal.confirm({
title: t('pages.index.geodataConfirmTitle'),
content: t('pages.index.geodataConfirmContent'),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
const next: Record<string, unknown> = { ...template };
if (assets.length === 0) {
delete next.geodata;
} else {
const geodata: Record<string, unknown> = { assets };
if (cronValue) geodata.cron = cronValue;
if (outbound) geodata.outbound = outbound;
next.geodata = geodata;
}
onClose();
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
try {
const msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: JSON.stringify(next, null, 2),
outboundTestUrl: outboundTestUrlRef.current,
});
if (msg?.success) {
await HttpUtil.post('/panel/api/server/restartXrayService');
}
} finally {
onBusy({ busy: false });
}
},
});
}
return (
<div>
{modalContextHolder}
{messageContextHolder}
<Spin spinning={loading}>
<Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
<Form layout="vertical">
<Form.Item label={t('pages.index.geodataCron')} style={{ marginBottom: 8 }}>
<Input
value={cron}
placeholder={DEFAULT_CRON}
onChange={(e) => setCron(e.target.value)}
/>
</Form.Item>
<Form.Item label={t('pages.index.geodataOutbound')} style={{ marginBottom: 8 }}>
<Select
style={{ width: '100%' }}
allowClear
value={outbound}
onChange={(v) => setOutbound(v)}
options={outboundTags.map((tag) => ({ label: tag, value: tag }))}
/>
</Form.Item>
</Form>
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
{rows.length === 0 && (
<Typography.Text type="secondary">{t('pages.index.geodataEmpty')}</Typography.Text>
)}
{rows.map((row, i) => (
<Space.Compact key={i} style={{ width: '100%' }}>
<Input
style={{ width: '60%' }}
placeholder="https://example.com/geosite_custom.dat"
value={row.url}
onChange={(e) => setRow(i, { url: e.target.value })}
onBlur={() => onUrlBlur(i)}
/>
<Input
style={{ width: '40%' }}
placeholder={t('pages.index.geodataFile')}
value={row.file}
onChange={(e) => setRow(i, { file: e.target.value })}
/>
<Button
aria-label={t('delete')}
icon={<DeleteOutlined />}
onClick={() => setRows((p) => p.filter((_, j) => j !== i))}
/>
</Space.Compact>
))}
<div className="actions-row">
<Button
icon={<PlusOutlined />}
onClick={() => setRows((p) => [...p, { url: '', file: '' }])}
>
{t('pages.index.geodataAddFile')}
</Button>
<Button type="primary" onClick={save} disabled={loading || !template}>
{t('pages.index.geodataSaveRestart')}
</Button>
</div>
</Space>
</Spin>
</div>
);
}
-12
View File
@@ -38,7 +38,6 @@ const SystemHistoryModal = lazy(() => import('./SystemHistoryModal'));
const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
const XrayLogModal = lazy(() => import('./XrayLogModal'));
const AmneziaWGLogModal = lazy(() => import('./AmneziaWGLogModal'));
const VersionModal = lazy(() => import('./VersionModal'));
import './IndexPage.css';
export default function IndexPage() {
@@ -69,7 +68,6 @@ export default function IndexPage() {
const [xrayMetricsOpen, setXrayMetricsOpen] = useState(false);
const [xrayLogsOpen, setXrayLogsOpen] = useState(false);
const [amneziawgLogsOpen, setAmneziawgLogsOpen] = useState(false);
const [versionOpen, setVersionOpen] = useState(false);
const [configTextOpen, setConfigTextOpen] = useState(false);
const [configText, setConfigText] = useState('');
const [loading, setLoading] = useState(false);
@@ -210,7 +208,6 @@ export default function IndexPage() {
onOpenSystemHistory={() => setSysHistoryOpen(true)}
onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
onOpenVersionSwitch={() => setVersionOpen(true)}
/>
{health && (
@@ -334,15 +331,6 @@ export default function IndexPage() {
<LazyMount when={amneziawgLogsOpen}>
<AmneziaWGLogModal open={amneziawgLogsOpen} onClose={() => setAmneziawgLogsOpen(false)} />
</LazyMount>
<LazyMount when={versionOpen}>
<VersionModal
open={versionOpen}
status={status}
onClose={() => setVersionOpen(false)}
onBusy={setBusy}
/>
</LazyMount>
<LazyMount when={configTextOpen}>
<Modal
open={configTextOpen}
@@ -35,7 +35,6 @@ interface OverviewActionBarProps {
onOpenSystemHistory: () => void;
onOpenXrayMetrics: () => void;
onOpenPanelUpdate: () => void;
onOpenVersionSwitch: () => void;
}
interface BarAction {
@@ -69,7 +68,6 @@ export default function OverviewActionBar({
onOpenSystemHistory,
onOpenXrayMetrics,
onOpenPanelUpdate,
onOpenVersionSwitch,
}: OverviewActionBarProps) {
const { t } = useTranslation();
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
@@ -147,13 +145,7 @@ export default function OverviewActionBar({
<span className="ov-state" data-state={status.xray.state}>
<span className="ov-state-dot" style={{ color: status.xray.color }} />
<span>{`${t('pages.index.xrayStatus')} · ${stateText}`}</span>
{hasVersion && (
<Tooltip title={t('pages.index.xraySwitch')}>
<button type="button" className="ov-state-version" onClick={onOpenVersionSwitch}>
{`v${status.xray.version}`}
</button>
</Tooltip>
)}
{hasVersion && <span className="ov-state-version">{`v${status.xray.version}`}</span>}
</span>
);
-30
View File
@@ -1,30 +0,0 @@
.version-list {
width: 100%;
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
overflow: hidden;
}
.version-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.version-list-item:last-child {
border-bottom: 0;
}
.reload-icon {
cursor: pointer;
font-size: 16px;
margin-right: 8px;
}
.actions-row {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
-179
View File
@@ -1,179 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Collapse, Modal, Radio, Spin, Tag, Tooltip } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import type { Status } from '@/models/status';
import GeodataSection from './GeodataSection';
import './VersionModal.css';
interface BusyEvent {
busy: boolean;
tip?: string;
}
interface VersionModalProps {
open: boolean;
status: Status;
onClose: () => void;
onBusy: (e: BusyEvent) => void;
}
const GEOFILES = [
'geosite.dat',
'geoip.dat',
'geosite_IR.dat',
'geoip_IR.dat',
'geosite_RU.dat',
'geoip_RU.dat',
];
export default function VersionModal({ open, status, onClose, onBusy }: VersionModalProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [activeKey, setActiveKey] = useState<string | string[]>('1');
const [versions, setVersions] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const fetchVersions = useCallback(async () => {
try {
const msg = await HttpUtil.get<string[]>('/panel/api/server/getXrayVersion');
if (msg?.success) setVersions(msg.obj || []);
} finally {
setLoading(false);
}
}, []);
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setLoading(true);
}
useEffect(() => {
if (open) void fetchVersions();
}, [open, fetchVersions]);
function switchXrayVersion(version: string) {
modal.confirm({
title: t('pages.index.xraySwitchVersionDialog'),
content: t('pages.index.xraySwitchVersionDialogDesc').replace('#version#', version),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
onClose();
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
try {
await HttpUtil.post(`/panel/api/server/installXray/${version}`);
} finally {
onBusy({ busy: false });
}
},
});
}
function updateGeofile(fileName: string) {
const isSingle = !!fileName;
modal.confirm({
title: t('pages.index.geofileUpdateDialog'),
content: isSingle
? t('pages.index.geofileUpdateDialogDesc').replace('#filename#', fileName)
: t('pages.index.geofilesUpdateDialogDesc'),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
onClose();
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
const url = isSingle
? `/panel/api/server/updateGeofile/${fileName}`
: '/panel/api/server/updateGeofile';
try {
await HttpUtil.post(url);
} finally {
onBusy({ busy: false });
}
},
});
}
const activeKeyStr = Array.isArray(activeKey) ? activeKey[0] : activeKey;
return (
<Modal open={open} title={t('pages.index.xrayUpdates')} footer={null} onCancel={onClose}>
{modalContextHolder}
<Spin spinning={loading}>
<Collapse
accordion
activeKey={activeKey}
onChange={setActiveKey}
items={[
{
key: '1',
label: 'Xray',
children: (
<>
<Alert
type="warning"
className="mb-12"
title={t('pages.index.xraySwitchClickDesk')}
showIcon
/>
<div className="version-list">
{versions.map((version, index) => (
<div key={version} className="version-list-item">
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{version}</Tag>
<Radio
checked={version === `v${status?.xray?.version}`}
onClick={() => switchXrayVersion(version)}
/>
</div>
))}
</div>
</>
),
},
{
key: '2',
label: 'Geofiles',
children: (
<>
<div className="version-list">
{GEOFILES.map((file, index) => (
<div key={file} className="version-list-item">
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{file}</Tag>
<Tooltip title={t('update')}>
<ReloadOutlined
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('update')}
onClick={() => updateGeofile(file)}
onKeyDown={activateOnKey(() => updateGeofile(file))}
/>
</Tooltip>
</div>
))}
</div>
<div className="actions-row">
<Button onClick={() => updateGeofile('')}>
{t('pages.index.geofilesUpdateAll')}
</Button>
</div>
</>
),
},
{
key: '3',
label: t('pages.index.geodataTitle'),
children: (
<GeodataSection active={activeKeyStr === '3'} onBusy={onBusy} onClose={onClose} />
),
},
]}
/>
</Spin>
</Modal>
);
}
-232
View File
@@ -1,232 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import type { AllSetting } from '@/models/setting';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import SecretInput from './SecretInput';
interface EmailTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
interface SmtpTestResult {
success: boolean;
stage?: string;
msg: string;
}
export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [testLoading, setTestLoading] = useState(false);
const [testResult, setTestResult] = useState<SmtpTestResult | null>(null);
const stageLabel: Record<string, string> = {
connect: t('pages.settings.smtpStageConnect'),
auth: t('pages.settings.smtpStageAuth'),
send: t('pages.settings.smtpStageSend'),
};
async function handleTestSmtp() {
setTestLoading(true);
setTestResult(null);
try {
const res = (await HttpUtil.post('/panel/api/setting/testSmtp')) as SmtpTestResult;
setTestResult(res);
} catch (e: unknown) {
setTestResult({
success: false,
msg: e instanceof Error ? e.message : t('pages.settings.requestFailed'),
});
} finally {
setTestLoading(false);
}
}
return (
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.smtpSettings'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpEnable')}
description={t('pages.settings.smtpEnableDesc')}
>
<Switch
checked={allSetting.smtpEnable}
onChange={(v) => updateSetting({ smtpEnable: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpHost')}
description={t('pages.settings.smtpHostDesc')}
>
<Input
value={allSetting.smtpHost}
placeholder="smtp.gmail.com"
onChange={(e) => updateSetting({ smtpHost: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpPort')}
badge={<DefaultSettingTag settingKey="smtpPort" value={allSetting.smtpPort} />}
description={t('pages.settings.smtpPortDesc')}
>
<InputNumber
value={allSetting.smtpPort}
min={1}
max={65535}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ smtpPort: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpUsername')}
description={t('pages.settings.smtpUsernameDesc')}
>
<Input
value={allSetting.smtpUsername}
placeholder="user@gmail.com"
onChange={(e) => updateSetting({ smtpUsername: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpPassword')}
description={
allSetting.hasSmtpPassword && !allSetting.clearSmtpPassword
? t('pages.settings.smtpPasswordConfigured')
: t('pages.settings.smtpPasswordDesc')
}
>
<SecretInput
value={allSetting.smtpPassword}
configured={allSetting.hasSmtpPassword}
clearArmed={allSetting.clearSmtpPassword}
placeholder={t('pages.settings.smtpPasswordPlaceholder')}
onChange={(v) => updateSetting({ smtpPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpFrom')}
description={t('pages.settings.smtpFromDesc')}
>
<Input
value={allSetting.smtpFrom}
placeholder="user@gmail.com"
onChange={(e) => updateSetting({ smtpFrom: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpFromName')}
description={t('pages.settings.smtpFromNameDesc')}
>
<Input
value={allSetting.smtpFromName}
placeholder="3x-ui"
onChange={(e) => updateSetting({ smtpFromName: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpTo')}
description={t('pages.settings.smtpToDesc')}
>
<Input
value={allSetting.smtpTo}
placeholder="admin@example.com, ops@example.com"
onChange={(e) => updateSetting({ smtpTo: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpEncryption')}
description={t('pages.settings.smtpEncryptionDesc')}
>
<Select
value={allSetting.smtpEncryptionType}
onChange={(v) => updateSetting({ smtpEncryptionType: v })}
options={[
{ value: 'none', label: t('pages.settings.smtpEncryptionNone') },
{ value: 'starttls', label: t('pages.settings.smtpEncryptionStartTLS') },
{ value: 'tls', label: t('pages.settings.smtpEncryptionTLS') },
]}
style={{ width: '100%' }}
/>
</SettingListItem>
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button
type="primary"
icon={<SendOutlined />}
loading={testLoading}
onClick={handleTestSmtp}
>
{t('pages.settings.testSmtp')}
</Button>
{testResult && (
<Alert
type={testResult.success ? 'success' : 'error'}
title={
testResult.success ? (
t('pages.settings.' + testResult.msg)
) : (
<span>
<b>{stageLabel[testResult.stage || ''] || testResult.stage}:</b>{' '}
{t('pages.settings.' + testResult.msg)}
</span>
)
}
showIcon
closable={{ onClose: () => setTestResult(null) }}
/>
)}
</Space>
</>
),
},
{
key: '2',
label: catTabLabel(<MailOutlined />, t('pages.settings.emailNotifications'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpEventBusNotify')}
description={t('pages.settings.smtpEventBusNotifyDesc')}
>
<EmailNotifications allSetting={allSetting} updateSetting={updateSetting} />
</SettingListItem>
</>
),
},
]}
/>
);
}
+1 -338
View File
@@ -1,14 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Switch, Tabs } from 'antd';
import {
ApartmentOutlined,
BellOutlined,
ClockCircleOutlined,
GlobalOutlined,
SafetyCertificateOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { ClockCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { HttpUtil, LanguageManager } from '@/utils';
import { onNumber } from '@/utils/onNumber';
@@ -16,7 +9,6 @@ import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath } from './uriPath';
import SecretInput from './SecretInput';
interface ApiMsg<T = unknown> {
success?: boolean;
@@ -33,39 +25,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
const { isMobile } = useMediaQuery();
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
const [outboundTagList, setOutboundTagList] = useState<string[]>([]);
const [balancerTagList, setBalancerTagList] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
// /options is the slim picker-shaped endpoint — it skips the heavy
// per-client settings and clientStats payloads that /list ships.
const msg = (await HttpUtil.get('/panel/api/inbounds/options')) as ApiMsg<
{
tag: string;
protocol: string;
port: number;
}[]
>;
if (cancelled) return;
if (msg?.success && Array.isArray(msg.obj)) {
setInboundOptions(
msg.obj.map((ib) => ({
label: `${ib.tag} (${ib.protocol}@${ib.port})`,
value: ib.tag,
})),
);
} else {
setInboundOptions([]);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
@@ -136,20 +98,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
];
}, [outboundTagList, balancerTagList, t]);
const ldapInboundTagList = useMemo(() => {
const csv = allSetting.ldapInboundTags || '';
return csv.length
? csv
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
}, [allSetting.ldapInboundTags]);
function setLdapInboundTagList(list: string[]) {
updateSetting({ ldapInboundTags: Array.isArray(list) ? list.join(',') : '' });
}
function onLangChange(value: string) {
setLang(value);
LanguageManager.setLanguage(value);
@@ -327,43 +275,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
</>
),
},
{
key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.expireTimeDiff')}
badge={<DefaultSettingTag settingKey="expireDiff" value={allSetting.expireDiff} />}
description={t('pages.settings.expireTimeDiffDesc')}
>
<InputNumber
value={allSetting.expireDiff}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ expireDiff: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trafficDiff')}
badge={
<DefaultSettingTag settingKey="trafficDiff" value={allSetting.trafficDiff} />
}
description={t('pages.settings.trafficDiffDesc')}
>
<InputNumber
value={allSetting.trafficDiff}
min={0}
max={100}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))}
/>
</SettingListItem>
</>
),
},
{
key: '3',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
@@ -392,35 +303,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
</>
),
},
{
key: '4',
label: catTabLabel(<GlobalOutlined />, t('pages.settings.externalTraffic'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.externalTrafficInformEnable')}
description={t('pages.settings.externalTrafficInformEnableDesc')}
>
<Switch
checked={allSetting.externalTrafficInformEnable}
onChange={(v) => updateSetting({ externalTrafficInformEnable: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.externalTrafficInformURI')}
description={t('pages.settings.externalTrafficInformURIDesc')}
>
<Input
value={allSetting.externalTrafficInformURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ externalTrafficInformURI: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '5',
label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.dateAndTime'), isMobile),
@@ -454,225 +336,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
</>
),
},
{
key: '6',
label: catTabLabel(<ApartmentOutlined />, 'LDAP', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.ldap.enable')}>
<Switch
checked={allSetting.ldapEnable}
onChange={(v) => updateSetting({ ldapEnable: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
<Input
value={allSetting.ldapHost}
onChange={(e) => updateSetting({ ldapHost: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.port')}
badge={<DefaultSettingTag settingKey="ldapPort" value={allSetting.ldapPort} />}
>
<InputNumber
value={allSetting.ldapPort}
min={1}
max={65535}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapPort: v }))}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
<Switch
checked={allSetting.ldapUseTLS}
onChange={(v) => updateSetting({ ldapUseTLS: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.skipTlsVerify')}
description={t('pages.settings.ldap.skipTlsVerifyDesc')}
>
<Switch
checked={allSetting.ldapInsecureSkipVerify}
disabled={!allSetting.ldapUseTLS}
onChange={(v) => updateSetting({ ldapInsecureSkipVerify: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.bindDn')}>
<Input
value={allSetting.ldapBindDN}
onChange={(e) => updateSetting({ ldapBindDN: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('password')}
description={
allSetting.hasLdapPassword && !allSetting.clearLdapPassword
? t('pages.settings.ldap.passwordConfigured')
: t('pages.settings.ldap.passwordUnconfigured')
}
>
<SecretInput
value={allSetting.ldapPassword}
configured={allSetting.hasLdapPassword}
clearArmed={allSetting.clearLdapPassword}
placeholder={t('pages.settings.ldap.passwordPlaceholder')}
onChange={(v) => updateSetting({ ldapPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.baseDn')}>
<Input
value={allSetting.ldapBaseDN}
onChange={(e) => updateSetting({ ldapBaseDN: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.userFilter')}>
<Input
value={allSetting.ldapUserFilter}
onChange={(e) => updateSetting({ ldapUserFilter: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.userAttr')}>
<Input
value={allSetting.ldapUserAttr}
onChange={(e) => updateSetting({ ldapUserAttr: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.vlessField')}>
<Input
value={allSetting.ldapVlessField}
onChange={(e) => updateSetting({ ldapVlessField: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.flagField')}
description={t('pages.settings.ldap.flagFieldDesc')}
>
<Input
value={allSetting.ldapFlagField}
onChange={(e) => updateSetting({ ldapFlagField: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.truthyValues')}
description={t('pages.settings.ldap.truthyValuesDesc')}
>
<Input
value={allSetting.ldapTruthyValues}
onChange={(e) => updateSetting({ ldapTruthyValues: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.invertFlag')}
description={t('pages.settings.ldap.invertFlagDesc')}
>
<Switch
checked={allSetting.ldapInvertFlag}
onChange={(v) => updateSetting({ ldapInvertFlag: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.syncSchedule')}
description={t('pages.settings.ldap.syncScheduleDesc')}
>
<Input
value={allSetting.ldapSyncCron}
onChange={(e) => updateSetting({ ldapSyncCron: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.inboundTags')}
description={t('pages.settings.ldap.inboundTagsDesc')}
>
<>
<Select
mode="multiple"
value={ldapInboundTagList}
onChange={setLdapInboundTagList}
style={{ width: '100%' }}
options={inboundOptions}
/>
{inboundOptions.length === 0 && (
<div className="ldap-no-inbounds">{t('pages.settings.ldap.noInbounds')}</div>
)}
</>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoCreate')}>
<Switch
checked={allSetting.ldapAutoCreate}
onChange={(v) => updateSetting({ ldapAutoCreate: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoDelete')}>
<Switch
checked={allSetting.ldapAutoDelete}
onChange={(v) => updateSetting({ ldapAutoDelete: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.defaultTotalGb')}
badge={
<DefaultSettingTag
settingKey="ldapDefaultTotalGB"
value={allSetting.ldapDefaultTotalGB}
/>
}
>
<InputNumber
value={allSetting.ldapDefaultTotalGB}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.defaultExpiryDays')}
badge={
<DefaultSettingTag
settingKey="ldapDefaultExpiryDays"
value={allSetting.ldapDefaultExpiryDays}
/>
}
>
<InputNumber
value={allSetting.ldapDefaultExpiryDays}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.defaultIpLimit')}
badge={
<DefaultSettingTag
settingKey="ldapDefaultLimitIP"
value={allSetting.ldapDefaultLimitIP}
/>
}
>
<InputNumber
value={allSetting.ldapDefaultLimitIP}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))}
/>
</SettingListItem>
</>
),
},
]}
/>
);
@@ -1,45 +0,0 @@
import { Button, Input, Space } from 'antd';
import { useTranslation } from 'react-i18next';
interface SecretInputProps {
value: string;
configured: boolean;
clearArmed: boolean;
placeholder: string;
onChange: (value: string) => void;
onClearArmedChange: (armed: boolean) => void;
}
export default function SecretInput({
value,
configured,
clearArmed,
placeholder,
onChange,
onClearArmedChange,
}: SecretInputProps) {
const { t } = useTranslation();
return (
<Space.Compact style={{ width: '100%' }}>
<Input.Password
value={value}
placeholder={configured && !clearArmed ? placeholder : ''}
onChange={(e) => {
onChange(e.target.value);
if (clearArmed) onClearArmedChange(false);
}}
/>
{configured && (
<Button
danger={clearArmed}
onClick={() => {
onChange('');
onClearArmedChange(!clearArmed);
}}
>
{clearArmed ? t('pages.settings.secretClearUndo') : t('pages.settings.secretClear')}
</Button>
)}
</Space.Compact>
);
}
+3 -123
View File
@@ -1,13 +1,11 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Empty, Form, Input, Modal, Space, Spin, Switch, Tabs, message } from 'antd';
import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, IntlUtil, RandomUtil } from '@/utils';
import type { AllSetting } from '@/models/setting';
import { ApiOutlined, UserOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, IntlUtil } from '@/utils';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import TwoFactorModal from './TwoFactorModal';
import './SecurityTab.css';
interface ApiMsg<T = unknown> {
@@ -25,45 +23,18 @@ interface ApiTokenRow {
expiresAt: number;
}
interface SecurityTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
saveSetting: (payload: Partial<AllSetting> & Record<string, unknown>) => Promise<unknown>;
}
const UNIX_MILLISECONDS_THRESHOLD = 100_000_000_000;
function apiTokenCreatedAtMilliseconds(createdAt: number): number {
return createdAt < UNIX_MILLISECONDS_THRESHOLD ? createdAt * 1000 : createdAt;
}
type TfaType = 'set' | 'confirm';
interface TfaState {
open: boolean;
title: string;
description: string;
token: string;
type: TfaType;
onConfirm: (success: boolean, code?: string) => void;
}
const TFA_INITIAL: TfaState = {
open: false,
title: '',
description: '',
token: '',
type: 'set',
onConfirm: () => {},
};
export default function SecurityTab({ allSetting, updateSetting, saveSetting }: SecurityTabProps) {
export default function SecurityTab() {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [tfa, setTfa] = useState<TfaState>(TFA_INITIAL);
const [user, setUser] = useState({
oldUsername: '',
oldPassword: '',
@@ -79,17 +50,6 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
const [creating, setCreating] = useState(false);
const [createdToken, setCreatedToken] = useState<{ name: string; token: string } | null>(null);
const openTfa = useCallback((opts: Omit<TfaState, 'open'>) => {
setTfa({ ...opts, open: true });
}, []);
const onTfaConfirm = useCallback(
(success: boolean, code?: string) => {
tfa.onConfirm(success, code);
},
[tfa],
);
function updateUserField<K extends keyof typeof user>(key: K, value: string) {
setUser((prev) => ({ ...prev, [key]: value }));
}
@@ -115,20 +75,8 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
);
function onUpdateUserClick() {
if (allSetting.twoFactorEnable) {
openTfa({
title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
token: '',
type: 'confirm',
onConfirm: (ok: boolean, code?: string) => {
if (ok) sendUpdateUser(code || '');
},
});
} else {
sendUpdateUser();
}
}
const fetchApiTokens = useCallback(async () => {
try {
@@ -217,47 +165,6 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
return IntlUtil.formatDate(apiTokenCreatedAtMilliseconds(ts));
}
function toggleTwoFactor() {
if (!allSetting.twoFactorEnable) {
const newToken = RandomUtil.randomBase32String();
openTfa({
title: t('pages.settings.security.twoFactorModalSetTitle'),
description: '',
token: newToken,
type: 'set',
onConfirm: (ok: boolean) => {
if (ok) {
messageApi.success(t('pages.settings.security.twoFactorModalSetSuccess'));
updateSetting({ twoFactorToken: newToken, twoFactorEnable: true });
} else {
updateSetting({ twoFactorEnable: false });
}
},
});
} else {
openTfa({
title: t('pages.settings.security.twoFactorModalDeleteTitle'),
description: t('pages.settings.security.twoFactorModalRemoveStep'),
token: '',
type: 'confirm',
onConfirm: async (ok: boolean, code?: string) => {
if (!ok) return;
const next = {
...allSetting,
twoFactorEnable: false,
twoFactorToken: '',
twoFactorCode: code || '',
};
const msg = (await saveSetting(next)) as ApiMsg;
if (msg?.success) {
messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
updateSetting({ twoFactorEnable: false, twoFactorToken: '', hasTwoFactorToken: false });
}
},
});
}
}
return (
<>
{messageContextHolder}
@@ -307,23 +214,6 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
</>
),
},
{
key: '2',
label: catTabLabel(
<SafetyOutlined />,
t('pages.settings.security.twoFactor'),
isMobile,
),
children: (
<SettingListItem
paddings="small"
title={t('pages.settings.security.twoFactorEnable')}
description={t('pages.settings.security.twoFactorEnableDesc')}
>
<Switch checked={allSetting.twoFactorEnable} onClick={toggleTwoFactor} />
</SettingListItem>
),
},
{
key: '3',
label: catTabLabel(<ApiOutlined />, t('pages.nodes.apiToken'), isMobile),
@@ -422,16 +312,6 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
</Button>
</div>
</Modal>
<TwoFactorModal
open={tfa.open}
title={tfa.title}
description={tfa.description}
token={tfa.token}
type={tfa.type}
onConfirm={onTfaConfirm}
onOpenChange={(open) => setTfa((prev) => ({ ...prev, open }))}
/>
</>
);
}
+4 -26
View File
@@ -25,8 +25,6 @@ import { AllSettingSchema } from '@/schemas/setting';
import AppSidebar from '@/layouts/AppSidebar';
import GeneralTab from './GeneralTab';
import SecurityTab from './SecurityTab';
import TelegramTab from './TelegramTab';
import EmailTab from './EmailTab';
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
import SubscriptionBalancersTab from './SubscriptionBalancersTab';
@@ -39,8 +37,6 @@ interface ApiMsg {
const tabSlugs = [
'general',
'security',
'telegram',
'email',
'subscription',
'subscription-formats',
'subscription-balancers',
@@ -77,16 +73,8 @@ export default function SettingsPage() {
setMessageInstance(messageApi);
}, [messageApi]);
const {
allSetting,
updateSetting,
fetched,
spinning,
setSpinning,
saveDisabled,
saveAll,
savePayload,
} = useAllSettings();
const { allSetting, updateSetting, fetched, spinning, setSpinning, saveDisabled, saveAll } =
useAllSettings();
const [entryHost] = useState(() => window.location.hostname);
const [entryPort] = useState(() => window.location.port);
@@ -206,17 +194,7 @@ export default function SettingsPage() {
const categoryBody = useMemo(() => {
switch (activeSlug) {
case 'security':
return (
<SecurityTab
allSetting={allSetting}
updateSetting={updateSetting}
saveSetting={savePayload}
/>
);
case 'telegram':
return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'email':
return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
return <SecurityTab />;
case 'subscription':
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-formats':
@@ -226,7 +204,7 @@ export default function SettingsPage() {
default:
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
}
}, [activeSlug, allSetting, updateSetting, savePayload]);
}, [activeSlug, allSetting, updateSetting]);
return (
<ConfigProvider theme={antdThemeConfig}>
@@ -259,7 +259,7 @@ export default function SubscriptionGeneralTab({
<>
{t('pages.settings.subThemeDirDesc')}{' '}
<a
href="https://github.com/MHSanaei/3x-ui/blob/main/docs/custom-subscription-templates.md"
href="https://git.881218.xyz/rogue/3x-ui-agent/src/branch/main/docs/custom-subscription-templates.md"
target="_blank"
rel="noopener noreferrer"
>
-337
View File
@@ -1,337 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { LanguageManager } from '@/utils';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import SecretInput from './SecretInput';
interface TelegramTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
// The notification schedule is fed straight to robfig/cron's AddJob (see
// web.go startTask), which accepts @every <duration>, the @hourly/@daily/...
// macros, and full crontab expressions. This builder covers the common cases
// with dropdowns so users don't have to memorise the syntax, while "Custom"
// preserves the raw crontab escape hatch.
type Unit = 's' | 'm' | 'h';
type Macro = '@hourly' | '@daily' | '@weekly' | '@monthly';
type Mode = 'every' | Macro | 'custom';
const MACROS: Macro[] = ['@hourly', '@daily', '@weekly', '@monthly'];
const EVERY_RE = /^@every\s+(\d+)\s*([smh])$/i;
interface RunTime {
mode: Mode;
num: number;
unit: Unit;
custom: string;
}
function parseRunTime(raw: string): RunTime {
const v = (raw ?? '').trim();
const m = v.match(EVERY_RE);
if (m) {
return {
mode: 'every',
num: Math.max(1, Number(m[1]) || 1),
unit: m[2].toLowerCase() as Unit,
custom: '',
};
}
if ((MACROS as string[]).includes(v)) {
return { mode: v as Macro, num: 1, unit: 'h', custom: '' };
}
return { mode: 'custom', num: 1, unit: 'h', custom: v };
}
function composeRunTime(s: RunTime): string {
if (s.mode === 'every') return `@every ${Math.max(1, s.num || 1)}${s.unit}`;
if (s.mode === 'custom') return s.custom;
return s.mode;
}
// The panel's cron runs with seconds enabled (cron.WithSeconds() in web.go), so
// crontab expressions are 6-field: "second minute hour day month weekday". When
// the user drops into Custom we seed the box with the crontab equivalent of the
// current selection rather than a bare @macro, so they get a real expression to
// edit (and one that the 6-field parser accepts).
function toCrontab(s: RunTime): string {
switch (s.mode) {
case '@hourly':
return '0 0 * * * *';
case '@daily':
return '0 0 0 * * *';
case '@weekly':
return '0 0 0 * * 0';
case '@monthly':
return '0 0 0 1 * *';
case 'every': {
const n = Math.max(1, s.num || 1);
if (s.unit === 's') return `*/${n} * * * * *`;
if (s.unit === 'm') return `0 */${n} * * * *`;
return `0 0 */${n} * * *`;
}
default:
return s.custom;
}
}
function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { t } = useTranslation();
// Init once: the Settings tabs only mount after settings are fetched, so the
// incoming value is already the persisted one.
const [state, setState] = useState<RunTime>(() => parseRunTime(value));
function update(patch: Partial<RunTime>) {
const next = { ...state, ...patch };
setState(next);
onChange(composeRunTime(next));
}
function onModeChange(mode: Mode) {
// Seed Custom with the crontab equivalent of the current selection so the
// box starts from a real expression (e.g. "0 0 0 * * *", not "@daily").
if (mode === 'custom' && !state.custom.trim()) {
update({ mode, custom: toCrontab(state) });
} else {
update({ mode });
}
}
const modeOptions = [
{ value: 'every', label: t('pages.settings.notifyTime.every') },
{ value: '@hourly', label: t('pages.settings.notifyTime.hourly') },
{ value: '@daily', label: t('pages.settings.notifyTime.daily') },
{ value: '@weekly', label: t('pages.settings.notifyTime.weekly') },
{ value: '@monthly', label: t('pages.settings.notifyTime.monthly') },
{ value: 'custom', label: t('pages.settings.notifyTime.custom') },
];
const unitOptions = [
{ value: 's', label: t('pages.settings.notifyTime.seconds') },
{ value: 'm', label: t('pages.settings.notifyTime.minutes') },
{ value: 'h', label: t('pages.settings.notifyTime.hours') },
];
return (
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Select<Mode>
style={{ width: '100%' }}
value={state.mode}
options={modeOptions}
onChange={onModeChange}
aria-label={t('pages.settings.telegramNotifyTime')}
/>
{state.mode === 'every' && (
<Space.Compact style={{ width: '100%' }}>
<InputNumber
min={1}
precision={0}
style={{ width: '50%' }}
value={state.num}
onChange={onNumber((v) => update({ num: Math.max(1, v) }))}
aria-label={t('pages.settings.notifyTime.interval')}
/>
<Select<Unit>
style={{ width: '50%' }}
value={state.unit}
options={unitOptions}
onChange={(unit) => update({ unit })}
aria-label={t('pages.settings.notifyTime.unit')}
/>
</Space.Compact>
)}
{state.mode === 'custom' && (
<Input
value={state.custom}
placeholder="0 30 8 * * *"
onChange={(e) => update({ custom: e.target.value })}
aria-label={t('pages.settings.notifyTime.custom')}
/>
)}
</Space>
);
}
export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [testLoading, setTestLoading] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; msg: string } | null>(null);
async function handleTestTgBot() {
setTestLoading(true);
setTestResult(null);
try {
const res = (await HttpUtil.post('/panel/api/setting/testTgBot')) as {
success?: boolean;
msg?: string;
};
setTestResult({ success: !!res.success, msg: res.msg || '' });
} catch (e: unknown) {
setTestResult({
success: false,
msg: e instanceof Error ? e.message : t('pages.settings.requestFailed'),
});
} finally {
setTestLoading(false);
}
}
const langOptions = useMemo(
() =>
LanguageManager.supportedLanguages.map(
(l: { value: string; name: string; icon: string }) => ({
value: l.value,
label: (
<>
<span role="img" aria-label={l.name}>
{l.icon}
</span>
&nbsp;&nbsp;<span>{l.name}</span>
</>
),
}),
),
[],
);
return (
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramBotEnable')}
description={t('pages.settings.telegramBotEnableDesc')}
>
<Switch
checked={allSetting.tgBotEnable}
onChange={(v) => updateSetting({ tgBotEnable: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramToken')}
description={
allSetting.hasTgBotToken && !allSetting.clearTgBotToken
? t('pages.settings.telegramTokenConfigured')
: t('pages.settings.telegramTokenDesc')
}
>
<SecretInput
value={allSetting.tgBotToken}
configured={allSetting.hasTgBotToken}
clearArmed={allSetting.clearTgBotToken}
placeholder={t('pages.settings.telegramTokenPlaceholder')}
onChange={(v) => updateSetting({ tgBotToken: v })}
onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramChatId')}
description={t('pages.settings.telegramChatIdDesc')}
>
<Input
value={allSetting.tgBotChatId}
onChange={(e) => updateSetting({ tgBotChatId: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.telegramBotLanguage')}>
<Select
value={allSetting.tgLang}
onChange={(v) => updateSetting({ tgLang: v })}
style={{ width: '100%' }}
options={langOptions}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramAPIServer')}
description={t('pages.settings.telegramAPIServerDesc')}
>
<Input
value={allSetting.tgBotAPIServer}
placeholder="https://api.example.com"
onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })}
/>
</SettingListItem>
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button
type="primary"
icon={<SendOutlined />}
loading={testLoading}
onClick={handleTestTgBot}
>
{t('pages.settings.testTgBot')}
</Button>
{testResult && (
<Alert
type={testResult.success ? 'success' : 'error'}
title={testResult.msg}
showIcon
closable={{ onClose: () => setTestResult(null) }}
/>
)}
</Space>
</>
),
},
{
key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramNotifyTime')}
description={t('pages.settings.telegramNotifyTimeDesc')}
>
<NotifyTimeField
value={allSetting.tgRunTime}
onChange={(v) => updateSetting({ tgRunTime: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.tgNotifyBackup')}
description={t('pages.settings.tgNotifyBackupDesc')}
>
<Switch
checked={allSetting.tgBotBackup}
onChange={(v) => updateSetting({ tgBotBackup: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.tgEventBusNotify')}
description={t('pages.settings.tgEventBusNotifyDesc')}
>
<TelegramNotifications allSetting={allSetting} updateSetting={updateSetting} />
</SettingListItem>
</>
),
},
]}
/>
);
}
@@ -1,17 +0,0 @@
.qr-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.qr-code {
cursor: pointer;
}
.qr-token {
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
text-align: center;
}
@@ -1,161 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
import * as OTPAuth from 'otpauth';
import { ClipboardManager } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { TotpCodeSchema } from '@/schemas/login';
import './TwoFactorModal.css';
type Type = 'set' | 'confirm';
interface TwoFactorModalProps {
open: boolean;
title?: string;
description?: string;
token?: string;
type?: Type;
onConfirm: (success: boolean, code?: string) => void;
onOpenChange: (open: boolean) => void;
}
export default function TwoFactorModal({
open,
title = '',
description = '',
token = '',
type = 'set',
onConfirm,
onOpenChange,
}: TwoFactorModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [enteredCode, setEnteredCode] = useState('');
const totp = useMemo(() => {
if (!open || !token) return null;
return new OTPAuth.TOTP({
issuer: '3x-ui',
label: 'Administrator',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: token,
});
}, [open, token]);
const qrValue = totp ? totp.toString() : '';
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setEnteredCode('');
}
function close(success: boolean, code = '') {
onConfirm(success, code);
onOpenChange(false);
setEnteredCode('');
}
function onOk() {
const codeOk = TotpCodeSchema.safeParse(enteredCode);
if (!codeOk.success) {
messageApi.error(
t(codeOk.error.issues[0]?.message ?? 'pages.settings.security.twoFactorModalError'),
);
return;
}
if (type === 'confirm' && !token) {
close(true, codeOk.data);
return;
}
if (!totp) return;
if (totp.generate() === codeOk.data) {
close(true);
} else {
messageApi.error(t('pages.settings.security.twoFactorModalError'));
}
}
function onCancel() {
close(false);
}
async function copyToken() {
const ok = await ClipboardManager.copyText(token);
if (ok) messageApi.success(t('copied'));
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={title}
closable
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>
{t('cancel')}
</Button>,
<Button
key="ok"
type="primary"
disabled={!TotpCodeSchema.safeParse(enteredCode).success}
onClick={onOk}
>
{t('confirm')}
</Button>,
]}
>
{type === 'set' ? (
<>
<p>{t('pages.settings.security.twoFactorModalSteps')}</p>
<Divider />
<p>{t('pages.settings.security.twoFactorModalFirstStep')}</p>
<div
className="qr-wrap"
role="button"
tabIndex={0}
aria-label={t('copy')}
onClick={copyToken}
onKeyDown={activateOnKey(copyToken)}
>
<QRCode
className="qr-code"
value={qrValue}
size={180}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
errorLevel="L"
title={t('copy')}
/>
<span className="qr-token">{token}</span>
</div>
<Divider />
<p>{t('pages.settings.security.twoFactorModalSecondStep')}</p>
<Input
value={enteredCode}
onChange={(e) => setEnteredCode(e.target.value)}
style={{ width: '100%' }}
aria-label={t('twoFactorCode')}
/>
</>
) : (
<>
<p>{description}</p>
<Input
value={enteredCode}
onChange={(e) => setEnteredCode(e.target.value)}
style={{ width: '100%' }}
aria-label={t('twoFactorCode')}
/>
</>
)}
</Modal>
</>
);
}
+4 -101
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router';
import { useLocation } from 'react-router';
import {
Alert,
Button,
@@ -10,7 +10,6 @@ import {
FloatButton,
Layout,
message,
Radio,
Result,
Row,
Space,
@@ -22,26 +21,21 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useXraySetting } from '@/hooks/useXraySetting';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import AppSidebar from '@/layouts/AppSidebar';
import { JsonEditor } from '@/components/form';
import { setMessageInstance } from '@/utils/messageBus';
import { BasicsTab } from './basics';
import { propagateOutboundTagRename } from './basics/helpers';
import { RoutingTab } from './routing';
import { OutboundsTab } from './outbounds';
import { BalancersTab } from './balancers';
import {
cleanupOrphanedBalancerLoopbacks,
ensureMissingBalancerLoopbacks,
detectBalancerCycles,
} from './balancers/balancer-loopback';
import { DnsTab } from './dns';
import { WarpModal, NordModal, PiaModal } from './overrides';
import './XrayPage.css';
const SECTION_SLUGS = ['basic', 'routing', 'outbound', 'balancer', 'dns', 'advanced'];
type AdvKey = 'xraySetting' | 'inboundSettings' | 'outboundSettings' | 'routingRuleSettings';
const SECTION_SLUGS = ['basic', 'routing', 'outbound'];
export default function XrayPage() {
const { t } = useTranslation();
@@ -83,9 +77,7 @@ export default function XrayPage() {
const [warpOpen, setWarpOpen] = useState(false);
const [nordOpen, setNordOpen] = useState(false);
const [piaOpen, setPiaOpen] = useState(false);
const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
const location = useLocation();
const navigate = useNavigate();
const pathSection =
location.pathname === '/outbound'
? 'outbound'
@@ -157,59 +149,11 @@ export default function XrayPage() {
});
}
const advancedText = useMemo(() => {
if (advSettings === 'xraySetting') return xraySetting;
const tpl = templateSettings;
if (!tpl) return '';
try {
switch (advSettings) {
case 'inboundSettings':
return JSON.stringify(tpl.inbounds || [], null, 2);
case 'outboundSettings':
return JSON.stringify(tpl.outbounds || [], null, 2);
case 'routingRuleSettings':
return JSON.stringify(tpl.routing?.rules || [], null, 2);
default:
return '';
}
} catch {
return '';
}
}, [advSettings, xraySetting, templateSettings]);
function onAdvancedTextChange(next: string) {
if (advSettings === 'xraySetting') {
setXraySetting(next);
return;
}
let parsed;
try {
parsed = JSON.parse(next);
} catch {
return;
}
mutate((tt) => {
switch (advSettings) {
case 'inboundSettings':
tt.inbounds = parsed;
break;
case 'outboundSettings':
tt.outbounds = parsed;
break;
case 'routingRuleSettings':
if (!tt.routing) tt.routing = {};
tt.routing.rules = parsed;
break;
}
});
}
function onSaveAll() {
try {
JSON.parse(xraySetting);
} catch (e) {
messageApi.error(`Advanced JSON: ${(e as Error).message}`);
navigate('/xray#advanced');
messageApi.error(`Template JSON: ${(e as Error).message}`);
return;
}
if (templateSettings) {
@@ -269,47 +213,6 @@ export default function XrayPage() {
onRefreshXrayData={fetchAll}
/>
);
case 'balancer':
return (
<BalancersTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
clientReverseTags={clientReverseTags}
subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile}
/>
);
case 'dns':
return (
<DnsTab templateSettings={templateSettings} setTemplateSettings={setTemplateSettings} />
);
case 'advanced':
return (
<>
<div className="advanced-meta">
<h4>{t('pages.xray.Template')}</h4>
<p>{t('pages.xray.TemplateDesc')}</p>
</div>
<Radio.Group
value={advSettings}
buttonStyle="solid"
size={isMobile ? 'small' : 'middle'}
style={{ margin: '12px 0' }}
onChange={(e) => setAdvSettings(e.target.value)}
>
<Radio.Button value="xraySetting">{t('pages.xray.completeTemplate')}</Radio.Button>
<Radio.Button value="inboundSettings">{t('pages.xray.Inbounds')}</Radio.Button>
<Radio.Button value="outboundSettings">{t('pages.xray.Outbounds')}</Radio.Button>
<Radio.Button value="routingRuleSettings">{t('pages.xray.Routings')}</Radio.Button>
</Radio.Group>
<JsonEditor
value={advancedText}
onChange={onAdvancedTextChange}
minHeight="420px"
maxHeight="720px"
/>
</>
);
default:
return (
<BasicsTab
@@ -1,425 +0,0 @@
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import type { Path } from 'react-hook-form';
import { InputAddon } from '@/components/ui';
import { FormField } from '@/components/form/rhf';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { BalancerFormSchema, type BalancerFormValues } from '@/schemas/xray';
import {
BalancerStrategyTypeSchema,
type BalancerStrategyType,
type BalancerObject,
} from '@/schemas/routing';
import { isBalancerLoopbackTag } from './balancer-loopback';
export type BalancerFormValue = BalancerFormValues;
interface BalancerFormModalProps {
open: boolean;
balancer: BalancerFormValue | null;
outboundTags: string[];
balancerTags: string[];
balancers: BalancerObject[];
templateSettings: XraySettingsValue | null;
otherTags: string[];
onClose: () => void;
onConfirm: (value: BalancerFormValue) => void;
}
const STRATEGY_LABELS: Record<string, string> = {
random: 'Random',
roundRobin: 'Round robin',
leastLoad: 'Least load',
leastPing: 'Least ping',
};
const STRATEGIES = BalancerStrategyTypeSchema.options.map((value) => ({
value,
label: STRATEGY_LABELS[value] ?? value,
}));
function initialState(balancer: BalancerFormValue | null): BalancerFormValues {
if (!balancer) {
return { tag: '', strategy: 'random', selector: [], fallbackTag: '' };
}
return {
tag: balancer.tag ?? '',
strategy: (balancer.strategy ?? 'random') as BalancerStrategyType,
selector: [...(balancer.selector ?? [])],
fallbackTag: balancer.fallbackTag ?? '',
settings: balancer.settings,
};
}
export default function BalancerFormModal({
open,
balancer,
outboundTags,
balancerTags,
balancers,
templateSettings,
otherTags,
onClose,
onConfirm,
}: BalancerFormModalProps) {
const { t } = useTranslation();
const methods = useForm<BalancerFormValues>({ defaultValues: initialState(balancer) });
const [submitAttempted, setSubmitAttempted] = useState(false);
const isEdit = balancer != null;
const openBalancer = open ? (balancer ?? null) : undefined;
const [syncedBalancer, setSyncedBalancer] = useState<typeof openBalancer>(undefined);
if (openBalancer !== syncedBalancer) {
setSyncedBalancer(openBalancer);
if (open) {
methods.reset(initialState(balancer));
setSubmitAttempted(false);
}
}
const strategy = useWatch({ control: methods.control, name: 'strategy' });
const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? [];
const costs = useWatch({ control: methods.control, name: 'settings.costs' }) ?? [];
const tagValue = useWatch({ control: methods.control, name: 'tag' }) ?? '';
const fallbackTag = useWatch({ control: methods.control, name: 'fallbackTag' }) ?? '';
const currentTag = tagValue.trim();
const availableBalancerTags = useMemo(
() => balancerTags.filter((tg) => tg !== currentTag),
[balancerTags, currentTag],
);
const cycleInfo = useMemo(() => {
const rules = (templateSettings?.routing?.rules || []) as Array<{
inboundTag?: string[];
balancerTag?: string;
}>;
const resolveLoopback = (tag: string): string | null => {
for (const r of rules) {
if (Array.isArray(r.inboundTag) && r.inboundTag.includes(tag) && r.balancerTag) {
return r.balancerTag;
}
}
return null;
};
const fallbackOf: Record<string, string> = {};
for (const b of balancers) {
if (!b.tag || !b.fallbackTag || b.tag === currentTag) continue;
const target = isBalancerLoopbackTag(b.fallbackTag)
? resolveLoopback(b.fallbackTag)
: b.fallbackTag;
if (target) fallbackOf[b.tag] = target;
}
const result: Record<string, string[]> = {};
for (const tg of availableBalancerTags) {
const visited = new Set<string>();
let cursor = tg;
const path = [tg];
while (cursor && !visited.has(cursor)) {
if (cursor === currentTag) {
result[tg] = path;
break;
}
visited.add(cursor);
cursor = fallbackOf[cursor] || '';
if (cursor) path.push(cursor);
}
}
return result;
}, [currentTag, balancers, availableBalancerTags, templateSettings?.routing?.rules]);
const wouldCreateCycle = !!cycleInfo[fallbackTag];
const fallbackOptions = useMemo(() => {
const options: Array<{ value: string; label: ReactNode; disabled?: boolean; title?: string }> =
[{ value: '', label: `(${t('none')})` }];
for (const tg of outboundTags) {
options.push({ value: tg, label: tg });
}
for (const tg of availableBalancerTags) {
const cycle = cycleInfo[tg];
options.push({
value: tg,
disabled: !!cycle,
title: cycle
? t('pages.xray.balancer.cycleTooltip', { path: cycle.join(' → '), start: currentTag })
: undefined,
label: (
<span>
<Tag color="blue" style={{ marginRight: 4 }}>
{t('pages.xray.rules.balancer')}
</Tag>
{tg}
</span>
),
});
}
return options;
}, [outboundTags, availableBalancerTags, cycleInfo, currentTag, t]);
const isFallbackBalancer = useMemo(
() => balancerTags.includes(fallbackTag),
[balancerTags, fallbackTag],
);
function submit() {
const values = methods.getValues();
const parsed = BalancerFormSchema.safeParse(values);
const trimmedTag = (values.tag ?? '').trim();
const duplicateTag = !!trimmedTag && otherTags.includes(trimmedTag);
methods.clearErrors();
if (!parsed.success) {
const seen = new Set<string>();
for (const issue of parsed.error.issues) {
const key = String(issue.path[0] ?? '');
if (key && !seen.has(key)) {
seen.add(key);
methods.setError(key as Path<BalancerFormValues>, { message: issue.message });
}
}
}
if (!parsed.success || duplicateTag || wouldCreateCycle) {
setSubmitAttempted(true);
return;
}
const result: BalancerFormValues = { ...parsed.data };
if (result.strategy !== 'leastLoad') delete result.settings;
onConfirm(result);
}
const title = isEdit
? `${t('edit')} ${t('pages.xray.Balancers')}`
: `+ ${t('pages.xray.Balancers')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
okButtonProps={{ disabled: wouldCreateCycle }}
mask={{ closable: false }}
onOk={submit}
onCancel={onClose}
>
<FormProvider {...methods}>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Controller
control={methods.control}
name="tag"
render={({ field, fieldState }) => {
const trimmed = (field.value ?? '').trim();
const duplicate = !!trimmed && otherTags.includes(trimmed);
const errorMessage = fieldState.error?.message
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: '';
const showDuplicate =
!errorMessage && (submitAttempted || fieldState.isTouched) && duplicate;
return (
<Form.Item
label={t('pages.xray.balancer.tag')}
required
validateStatus={errorMessage ? 'error' : showDuplicate ? 'warning' : ''}
help={
errorMessage || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')
}
hasFeedback
>
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
ref={field.ref}
placeholder={t('pages.xray.balancer.tagPlaceholder')}
/>
</Form.Item>
);
}}
/>
<FormField name="strategy" label={t('pages.xray.balancer.balancerStrategy')}>
<Select options={STRATEGIES} />
</FormField>
<FormField name="selector" label={t('pages.xray.balancer.selector')} required>
<Select
mode="tags"
tokenSeparators={[',']}
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
/>
</FormField>
<FormField
name="fallbackTag"
label={t('pages.xray.balancer.fallback')}
extra={t('pages.xray.balancer.fallbackBalancerHint')}
transform={{ output: (v) => v ?? '' }}
>
<Select allowClear options={fallbackOptions} />
</FormField>
{isFallbackBalancer && !wouldCreateCycle && (
<Alert
type="info"
showIcon
title={t('pages.xray.balancer.balancerFallbackInfo')}
style={{ marginBottom: 16 }}
/>
)}
{wouldCreateCycle && (
<Alert
type="error"
showIcon
title={t('pages.xray.balancer.balancerFallbackCycle')}
style={{ marginBottom: 16 }}
/>
)}
{strategy === 'leastLoad' && (
<>
<FormField
name={['settings', 'expected']}
label={t('pages.xray.balancer.expected')}
transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
>
<InputNumber
min={0}
placeholder={t('pages.xray.balancer.expectedPlaceholder')}
style={{ width: '100%' }}
/>
</FormField>
<FormField
name={['settings', 'maxRTT']}
label={t('pages.xray.balancer.maxRtt')}
transform={{
input: (v) => v ?? '',
output: (v) => (typeof v === 'string' && v ? v : undefined),
}}
>
<Input placeholder="e.g. 1s" />
</FormField>
<FormField
name={['settings', 'tolerance']}
label={t('pages.xray.balancer.tolerance')}
transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
>
<InputNumber
min={0}
max={1}
step={0.01}
placeholder="0.01 = 1%"
style={{ width: '100%' }}
/>
</FormField>
<Form.Item label={t('pages.xray.balancer.baselines')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('settings.baselines', [...baselines, ''])}
/>
{baselines.map((b, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Input
value={b}
aria-label={t('pages.xray.balancer.baselines')}
placeholder="e.g. 1s"
onChange={(e) =>
methods.setValue(
'settings.baselines',
baselines.map((x, i) => (i === idx ? e.target.value : x)),
)
}
/>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'settings.baselines',
baselines.filter((_, i) => i !== idx),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.balancer.costs')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
methods.setValue('settings.costs', [
...costs,
{ regexp: false, match: '', value: 1 },
])
}
/>
{costs.map((c, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Switch
checked={c.regexp}
aria-label={t('pages.xray.balancer.costRegexp')}
checkedChildren="re"
unCheckedChildren="lit"
onChange={(v) =>
methods.setValue(
'settings.costs',
costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)),
)
}
/>
<Input
value={c.match}
aria-label={t('pages.xray.balancer.costMatch')}
placeholder="tag pattern"
onChange={(e) =>
methods.setValue(
'settings.costs',
costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)),
)
}
/>
<InputNumber
value={c.value}
aria-label={t('pages.xray.balancer.costValue')}
placeholder="weight"
style={{ width: 100 }}
onChange={(v) =>
methods.setValue(
'settings.costs',
costs.map((x, i) =>
i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x,
),
)
}
/>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'settings.costs',
costs.filter((_, i) => i !== idx),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
</>
)}
</Form>
</FormProvider>
</Modal>
);
}
@@ -1,549 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Dropdown,
Empty,
Modal,
Select,
Space,
Table,
Tabs,
Tag,
Tooltip,
message,
} from 'antd';
import {
PlusOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
SyncOutlined,
DeploymentUnitOutlined,
RadarChartOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import BalancerFormModal from './BalancerFormModal';
import type { BalancerFormValue } from './BalancerFormModal';
import { syncObservatories } from './balancer-helpers';
import {
isBalancerLoopbackTag,
loopbackTagFor,
resolveLoopbackFallback,
ensureBalancerLoopback,
removeBalancerLoopback,
removeBalancerLoopbackIfOrphaned,
propagateBalancerTagRename,
} from './balancer-loopback';
import { planBalancerDeletion, applyBalancerDeletion } from '../reference-cleanup';
import DeletionImpactList from '../DeletionImpactList';
import ObservatorySettingsTab from './ObservatorySettingsTab';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import { HttpUtil } from '@/utils';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import type {
BalancerObject,
BalancerStrategySettings,
BalancerStrategyType,
} from '@/schemas/routing';
// Live state of one balancer inside the running core, as reported by the
// panel's /xray/balancerStatus endpoint (RoutingService.GetBalancerInfo).
interface BalancerLiveStatus {
tag: string;
running: boolean;
override: string;
selected: string[];
}
interface BalancersTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
clientReverseTags: string[];
subscriptionOutboundTags?: string[];
isMobile: boolean;
}
type BalancerRecord = BalancerObject;
interface BalancerRow {
key: number;
tag: string;
strategy: BalancerStrategyType;
selector: string[];
fallbackTag: string;
displayFallbackTag: string;
settings?: BalancerStrategySettings;
}
const STRATEGY_LABELS: Record<string, string> = {
random: 'Random',
roundRobin: 'Round robin',
leastLoad: 'Least load',
leastPing: 'Least ping',
};
export default function BalancersTab({
templateSettings,
setTemplateSettings,
clientReverseTags,
subscriptionOutboundTags,
isMobile,
}: BalancersTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [modalOpen, setModalOpen] = useState(false);
const [editingBalancer, setEditingBalancer] = useState<BalancerFormValue | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const balancerObjects = useMemo(
() => (templateSettings?.routing?.balancers || []) as BalancerObject[],
[templateSettings?.routing?.balancers],
);
const rows: BalancerRow[] = useMemo(() => {
const list = balancerObjects;
return list.map((b, idx) => ({
key: idx,
tag: b.tag || '',
strategy: (b.strategy?.type ?? 'random') as BalancerStrategyType,
selector: b.selector || [],
fallbackTag: b.fallbackTag || '',
displayFallbackTag: resolveLoopbackFallback(templateSettings!, b.fallbackTag || ''),
settings: b.strategy?.settings,
}));
}, [balancerObjects, templateSettings]);
const outboundTags = useMemo(() => {
const tags = new Set<string>();
for (const o of templateSettings?.outbounds || []) {
if (o?.tag && !isBalancerLoopbackTag(o.tag)) tags.add(o.tag);
}
for (const tag of clientReverseTags || []) {
if (tag) tags.add(tag);
}
for (const tag of subscriptionOutboundTags || []) {
if (tag) tags.add(tag);
}
return [...tags];
}, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
const otherTags = useMemo(() => {
if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean);
return rows
.filter((b) => b.key !== editingIndex)
.map((b) => b.tag)
.filter(Boolean);
}, [rows, editingIndex]);
const balancerTags = useMemo(() => {
return otherTags.filter((tg) => !isBalancerLoopbackTag(tg));
}, [otherTags]);
const overrideOptions: Array<{ value: string; label: React.ReactNode }> = useMemo(() => {
return outboundTags.map((tag) => ({ value: tag, label: tag }));
}, [outboundTags]);
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
const [liveStatus, setLiveStatus] = useState<Record<string, BalancerLiveStatus>>({});
const [liveLoading, setLiveLoading] = useState(false);
const liveTags = useMemo(
() =>
rows
.map((r) => r.tag)
.filter(Boolean)
.join(','),
[rows],
);
const refreshLive = useCallback(async () => {
if (!liveTags) {
setLiveStatus({});
return;
}
setLiveLoading(true);
try {
const msg = await HttpUtil.post(
'/panel/api/xray/balancerStatus',
{ tags: liveTags },
{ silent: true },
);
if (msg?.success && msg.obj && typeof msg.obj === 'object') {
setLiveStatus(msg.obj as Record<string, BalancerLiveStatus>);
}
} finally {
setLiveLoading(false);
}
}, [liveTags]);
useEffect(() => {
let cancelled = false;
void (async () => {
await refreshLive();
if (cancelled) return;
})();
return () => {
cancelled = true;
};
}, [refreshLive]);
async function setOverride(tag: string, target: string) {
const msg = await HttpUtil.post('/panel/api/xray/balancerOverride', { tag, target });
if (msg?.success) await refreshLive();
}
function openAdd() {
setEditingBalancer(null);
setEditingIndex(null);
setModalOpen(true);
}
function openEdit(idx: number) {
const row = rows[idx];
const resolved: BalancerFormValue = {
...row,
fallbackTag: resolveLoopbackFallback(templateSettings!, row.fallbackTag),
};
setEditingBalancer(resolved);
setEditingIndex(idx);
setModalOpen(true);
}
function onConfirm(form: BalancerFormValue) {
mutate((tt) => {
if (!tt.routing) tt.routing = { rules: [], balancers: [] };
if (!Array.isArray(tt.routing.balancers)) tt.routing.balancers = [];
const list = tt.routing.balancers as BalancerRecord[];
const wire: BalancerRecord = {
tag: form.tag,
selector: [...form.selector],
fallbackTag: '',
};
if (form.strategy && form.strategy !== 'random') {
wire.strategy = { type: form.strategy };
if (form.strategy === 'leastLoad' && form.settings) {
wire.strategy.settings = form.settings;
}
}
const isFallbackABalancer = form.fallbackTag && balancerTags.includes(form.fallbackTag);
if (isFallbackABalancer) {
wire.fallbackTag = loopbackTagFor(form.fallbackTag);
} else {
wire.fallbackTag = form.fallbackTag || '';
}
if (editingIndex == null) {
list.push(wire);
if (isFallbackABalancer) {
ensureBalancerLoopback(tt, form.fallbackTag);
}
} else {
const oldTag = list[editingIndex]?.tag;
const oldFallback = list[editingIndex]?.fallbackTag || '';
list[editingIndex] = wire;
if (oldTag && oldTag !== wire.tag) {
const rules = tt.routing.rules || [];
for (const rule of rules) {
if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag;
}
propagateBalancerTagRename(tt, oldTag, wire.tag);
}
const oldTarget = isBalancerLoopbackTag(oldFallback) ? oldFallback.slice(4) : null;
if (oldTarget && oldTarget !== form.fallbackTag) {
removeBalancerLoopbackIfOrphaned(tt, oldTarget);
}
if (isFallbackABalancer) {
ensureBalancerLoopback(tt, form.fallbackTag);
}
}
syncObservatories(tt);
});
setModalOpen(false);
}
function confirmDelete(idx: number) {
const deletedTag = rows[idx]?.tag;
const lbTag = loopbackTagFor(deletedTag);
const dependents = (templateSettings?.routing?.balancers || [])
.filter((b) => b.tag !== deletedTag && b.fallbackTag === lbTag)
.map((b) => b.tag);
if (dependents.length > 0) {
messageApi.error(
t('pages.xray.balancer.balancerDeleteInUse', { names: dependents.join(', ') }),
);
return;
}
const impact = templateSettings
? planBalancerDeletion(templateSettings, idx)
: { rules: [], balancers: [], observatory: false, burst: false };
modal.confirm({
title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
content: <DeletionImpactList impact={impact} />,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () =>
mutate((tt) => {
const tag = tt.routing?.balancers?.[idx]?.tag ?? '';
removeBalancerLoopback(tt, tag);
applyBalancerDeletion(tt, idx);
}),
});
}
const columns: ColumnsType<BalancerRow> = [
{
title: '#',
key: 'action',
align: 'center',
width: 100,
render: (_v, _record, index) => (
<div className="action-cell">
<span className="row-index">{index + 1}</span>
<div className={!isMobile ? 'action-buttons' : ''}>
{!isMobile && (
<Button
aria-label={t('edit')}
shape="circle"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(index)}
/>
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => openEdit(index),
},
]
: []),
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => confirmDelete(index),
},
],
}}
>
<Button aria-label={t('more')} shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
),
},
{ title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
{
title: 'Strategy',
key: 'strategy',
align: 'center',
width: 140,
render: (_v, record) => (
<Tag color={record.strategy === 'random' ? 'purple' : 'green'}>
{STRATEGY_LABELS[record.strategy] || record.strategy}
</Tag>
),
},
{
title: 'Selector',
key: 'selector',
align: 'center',
render: (_v, record) =>
(record.selector || []).map((sel) => (
<Tag key={sel} className="info-large-tag" style={{ margin: 0, marginRight: 4 }}>
{sel}
</Tag>
)),
},
{
title: 'Fallback',
dataIndex: 'displayFallbackTag',
key: 'displayFallbackTag',
align: 'center',
width: 160,
},
{
title: t('pages.xray.balancerLive'),
key: 'live',
align: 'center',
width: 170,
render: (_v, record) => {
const live = liveStatus[record.tag];
if (!live?.running) {
return (
<Tooltip title={t('pages.xray.balancerNotRunning')}>
<Tag>—</Tag>
</Tooltip>
);
}
const resolve = (tag: string) =>
isBalancerLoopbackTag(tag) ? resolveLoopbackFallback(templateSettings!, tag) : tag;
const picked = live.override
? resolve(live.override)
: live.selected?.[0]
? resolve(live.selected[0])
: record.displayFallbackTag;
const tooltipText = live.override
? resolve(live.override)
: (live.selected || []).map(resolve).join(', ');
return (
<Tooltip title={tooltipText || undefined}>
<Tag color={live.override ? 'orange' : 'blue'}>{picked || '—'}</Tag>
</Tooltip>
);
},
},
{
title: t('pages.xray.balancerOverride'),
key: 'overrideTarget',
align: 'center',
width: 200,
render: (_v, record) => {
const live = liveStatus[record.tag];
const resolvedFB = record.displayFallbackTag;
let options = overrideOptions;
if (resolvedFB && !outboundTags.includes(resolvedFB)) {
options = [
...overrideOptions,
{
value: resolvedFB,
label: (
<span>
<Tag color="blue" style={{ marginRight: 4 }}>
{t('pages.xray.rules.balancer')}
</Tag>
{resolvedFB}
</span>
),
},
];
}
const rawOverride = live?.override || undefined;
const resolvedOverride =
rawOverride && isBalancerLoopbackTag(rawOverride)
? resolveLoopbackFallback(templateSettings!, rawOverride)
: rawOverride;
return (
<Select
size="small"
style={{ width: 170 }}
placeholder={t('pages.xray.balancerOverridePh')}
allowClear
disabled={!live?.running}
value={resolvedOverride}
options={options}
onChange={(v) => setOverride(record.tag, (v as string | undefined) || '')}
/>
);
},
},
];
const balancerSettingsTab = (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
{rows.length === 0 ? (
<Empty description={t('emptyBalancersDesc')}>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Balancers')}
</Button>
</Empty>
) : (
<>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Balancers')}
</Button>
<Tooltip title={t('pages.xray.balancerLiveRefresh')}>
<Button
aria-label={t('pages.xray.balancerLiveRefresh')}
icon={<SyncOutlined spin={liveLoading} />}
onClick={refreshLive}
/>
</Tooltip>
</Space>
<Table
columns={columns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
size="small"
scroll={{ x: 700 }}
/>
</>
)}
</Space>
);
return (
<>
{modalContextHolder}
{messageContextHolder}
<Tabs
items={[
{
key: 'balancers',
label: catTabLabel(
<DeploymentUnitOutlined />,
t('pages.xray.tabBalancerSettings'),
isMobile,
),
children: balancerSettingsTab,
},
{
key: 'observatory',
label: catTabLabel(<RadarChartOutlined />, t('pages.xray.tabObservatory'), isMobile),
children: (
<ObservatorySettingsTab templateSettings={templateSettings} mutate={mutate} />
),
},
]}
/>
<BalancerFormModal
key={modalOpen ? `${editingIndex ?? 'new'}-${editingBalancer?.tag ?? ''}` : 'closed'}
open={modalOpen}
balancer={editingBalancer}
outboundTags={outboundTags}
balancerTags={balancerTags}
balancers={balancerObjects}
templateSettings={templateSettings}
otherTags={otherTags}
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
</>
);
}
@@ -1,235 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import {
BurstObservatorySchema,
ObservatoryHttpMethodSchema,
ObservatorySchema,
type BurstObservatoryObject,
type ObservatoryHttpMethod,
type ObservatoryObject,
type PingConfigObject,
} from '@/schemas/observatory';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { settingsRequireBurstObservatory } from './balancer-helpers';
interface ObservatorySettingsTabProps {
templateSettings: XraySettingsValue | null;
mutate: (mutator: (next: XraySettingsValue) => void) => void;
}
const OBSERVATORY_DEFAULTS = ObservatorySchema.parse({});
const BURST_DEFAULTS = BurstObservatorySchema.parse({});
function asObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
}
function SelectorTags({ tags }: { tags: string[] }) {
if (!tags || tags.length === 0) return <Tag>—</Tag>;
return (
<>
{tags.map((sel) => (
<Tag
key={sel}
className="info-large-tag"
style={{ margin: 0, marginRight: 4, marginBottom: 4 }}
>
{sel}
</Tag>
))}
</>
);
}
export default function ObservatorySettingsTab({
templateSettings,
mutate,
}: ObservatorySettingsTabProps) {
const { t } = useTranslation();
const observatory = useMemo<ObservatoryObject | null>(() => {
const raw = templateSettings?.observatory;
if (raw == null) return null;
return { ...OBSERVATORY_DEFAULTS, ...asObject(raw) } as ObservatoryObject;
}, [templateSettings?.observatory]);
const burst = useMemo<BurstObservatoryObject | null>(() => {
const raw = templateSettings?.burstObservatory;
if (raw == null) return null;
const merged = { ...BURST_DEFAULTS, ...asObject(raw) } as BurstObservatoryObject;
merged.pingConfig = {
...BURST_DEFAULTS.pingConfig,
...asObject(merged.pingConfig),
} as PingConfigObject;
return merged;
}, [templateSettings?.burstObservatory]);
const hasObservatory = observatory != null;
const hasBurst = burst != null;
const hasMixedObservers = hasObservatory && hasBurst;
const activeView =
hasBurst && (!hasObservatory || settingsRequireBurstObservatory(templateSettings))
? 'burstObservatory'
: 'observatory';
function patchObservatory(patch: Partial<ObservatoryObject>) {
mutate((tt) => {
tt.observatory = { ...OBSERVATORY_DEFAULTS, ...asObject(tt.observatory), ...patch };
});
}
function patchPingConfig(patch: Partial<PingConfigObject>) {
mutate((tt) => {
const current = asObject(tt.burstObservatory);
const currentPing = asObject(current.pingConfig);
tt.burstObservatory = {
...BURST_DEFAULTS,
...current,
pingConfig: { ...BURST_DEFAULTS.pingConfig, ...currentPing, ...patch },
};
});
}
if (!hasObservatory && !hasBurst) {
return <Empty description={t('pages.xray.observatory.emptyHint')} />;
}
const observatorySection = observatory && (
<>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.subjectSelector')}
description={t('pages.xray.observatory.subjectSelectorDesc')}
>
<SelectorTags tags={observatory.subjectSelector} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.probeURL')}
description={t('pages.xray.observatory.probeURLDesc')}
>
<Input
value={observatory.probeURL}
onChange={(e) => patchObservatory({ probeURL: e.target.value })}
placeholder="https://www.google.com/generate_204"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.probeInterval')}
description={t('pages.xray.observatory.probeIntervalDesc')}
>
<Input
value={observatory.probeInterval}
onChange={(e) => patchObservatory({ probeInterval: e.target.value })}
placeholder="1m"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.enableConcurrency')}
description={t('pages.xray.observatory.enableConcurrencyDesc')}
>
<Switch
checked={observatory.enableConcurrency}
onChange={(v) => patchObservatory({ enableConcurrency: v })}
/>
</SettingListItem>
</>
);
const burstSection = burst && (
<>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.subjectSelector')}
description={t('pages.xray.observatory.subjectSelectorDesc')}
>
<SelectorTags tags={burst.subjectSelector} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.destination')}
description={t('pages.xray.observatory.destinationDesc')}
>
<Input
value={burst.pingConfig.destination}
onChange={(e) => patchPingConfig({ destination: e.target.value })}
placeholder="https://www.google.com/generate_204"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.connectivity')}
description={t('pages.xray.observatory.connectivityDesc')}
>
<Input
value={burst.pingConfig.connectivity}
allowClear
onChange={(e) => patchPingConfig({ connectivity: e.target.value })}
placeholder="http://connectivitycheck.platform.hicloud.com/generate_204"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.interval')}
description={t('pages.xray.observatory.intervalDesc')}
>
<Input
value={burst.pingConfig.interval}
onChange={(e) => patchPingConfig({ interval: e.target.value })}
placeholder="1m"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.timeout')}
description={t('pages.xray.observatory.timeoutDesc')}
>
<Input
value={burst.pingConfig.timeout}
onChange={(e) => patchPingConfig({ timeout: e.target.value })}
placeholder="5s"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.sampling')}
description={t('pages.xray.observatory.samplingDesc')}
>
<InputNumber
min={1}
value={burst.pingConfig.sampling}
onChange={onNumber((v) => patchPingConfig({ sampling: v }))}
style={{ width: '100%' }}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.httpMethod')}
description={t('pages.xray.observatory.httpMethodDesc')}
>
<Select<ObservatoryHttpMethod>
value={burst.pingConfig.httpMethod}
onChange={(v) => patchPingConfig({ httpMethod: v })}
options={ObservatoryHttpMethodSchema.options.map((m) => ({ value: m, label: m }))}
style={{ width: '100%' }}
/>
</SettingListItem>
</>
);
return (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Alert type="info" showIcon title={t('pages.xray.observatory.autoManaged')} />
{hasMixedObservers && (
<Alert type="warning" showIcon title={t('pages.xray.observatory.mixedLegacy')} />
)}
<div>{activeView === 'observatory' ? observatorySection : burstSection}</div>
</Space>
);
}
@@ -1,2 +0,0 @@
export { default as BalancersTab } from './BalancersTab';
export { default as BalancerFormModal } from './BalancerFormModal';
@@ -1,26 +0,0 @@
.preset-warning {
margin-bottom: 12px;
}
.preset-list {
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
overflow: hidden;
}
.preset-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 24px;
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.preset-row:last-child {
border-bottom: 0;
}
.preset-name {
font-weight: 500;
}
@@ -1,113 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Alert, Button, Modal, Space, Tag } from 'antd';
import './DnsPresetsModal.css';
interface DnsPresetsModalProps {
open: boolean;
onClose: () => void;
onInstall: (servers: string[]) => void;
}
export const PRESETS: { name: string; tags: string[]; data: string[] }[] = [
{
name: 'Google DNS',
tags: ['UDP'],
data: ['8.8.8.8', '8.8.4.4', '2001:4860:4860::8888', '2001:4860:4860::8844'],
},
{
name: 'Cloudflare DNS',
tags: ['UDP'],
data: ['1.1.1.1', '1.0.0.1', '2606:4700:4700::1111', '2606:4700:4700::1001'],
},
{
name: 'AdGuard DNS',
tags: ['UDP'],
data: ['94.140.14.14', '94.140.15.15', '2a10:50c0::ad1:ff', '2a10:50c0::ad2:ff'],
},
{
name: 'AdGuard Family DNS',
tags: ['UDP', 'Family'],
data: ['94.140.14.15', '94.140.15.16', '2a10:50c0::bad1:ff', '2a10:50c0::bad2:ff'],
},
{
name: 'Cloudflare Family DNS',
tags: ['UDP', 'Family'],
data: ['1.1.1.3', '1.0.0.3', '2606:4700:4700::1113', '2606:4700:4700::1003'],
},
{
name: 'Cloudflare DoH',
tags: ['DoH'],
data: ['https://cloudflare-dns.com/dns-query'],
},
{
name: 'Google DoH',
tags: ['DoH'],
data: ['https://dns.google/dns-query'],
},
{
name: 'Quad9 Secure DoH',
tags: ['DoH', 'Malware'],
data: ['https://dns.quad9.net/dns-query'],
},
{
name: 'AdGuard DoH + DoQ',
tags: ['DoH', 'DoQ', 'Ads'],
data: ['https://dns.adguard-dns.com/dns-query', 'quic+local://dns.adguard-dns.com'],
},
{
name: 'Control D Ads DoH + DoQ',
tags: ['DoH', 'DoQ', 'Ads'],
data: ['https://freedns.controld.com/p2', 'quic+local://p2.freedns.controld.com'],
},
{
name: 'Control D Family DoH + DoQ',
tags: ['DoH', 'DoQ', 'Family'],
data: ['https://freedns.controld.com/p4', 'quic+local://p4.freedns.controld.com'],
},
];
function tagLabel(tag: string, t: (key: string) => string): string {
if (tag === 'Family') return t('pages.xray.dns.dnsPresetFamily');
return tag;
}
export default function DnsPresetsModal({ open, onClose, onInstall }: DnsPresetsModalProps) {
const { t } = useTranslation();
return (
<Modal
open={open}
title={t('pages.xray.dns.dnsPresetTitle')}
footer={null}
mask={{ closable: false }}
onCancel={onClose}
>
<Alert
type="warning"
showIcon
className="preset-warning"
title={t('pages.xray.dns.dnsLeakWarning')}
/>
<div className="preset-list">
{PRESETS.map((preset) => (
<div key={preset.name} className="preset-row">
<Space size="small" align="center">
{preset.tags.map((tag) => (
<Tag
key={tag}
color={tag === 'Family' ? 'purple' : tag === 'UDP' ? 'orange' : 'green'}
>
{tagLabel(tag, t)}
</Tag>
))}
<span className="preset-name">{preset.name}</span>
</Space>
<Button type="primary" size="small" onClick={() => onInstall([...preset.data])}>
{t('install')}
</Button>
</div>
))}
</div>
</Modal>
);
}
@@ -1,313 +0,0 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { InputAddon } from '@/components/ui';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import {
DnsQueryStrategySchema,
DnsServerObjectInnerSchema,
DnsServerObjectSchema,
isEncryptedDnsAddress,
type DnsServerObject,
} from '@/schemas/dns';
export type DnsServerValue =
| string
| (DnsServerObject & {
expectIPs?: string[];
[key: string]: unknown;
});
interface DnsServerModalProps {
open: boolean;
server: DnsServerValue | null;
isEdit: boolean;
onClose: () => void;
onConfirm: (value: DnsServerValue) => void;
}
const STRATEGIES = DnsQueryStrategySchema.options;
type DnsServerForm = {
address: string;
port: number;
domains: string[];
expectedIPs: string[];
unexpectedIPs: string[];
queryStrategy: string;
skipFallback: boolean;
disableCache: boolean;
finalQuery: boolean;
tag: string;
clientIP: string;
serveStale: boolean;
serveExpiredTTL: number;
timeoutMs: number;
};
function defaultFormValues(): DnsServerForm {
return {
address: 'localhost',
port: 53,
domains: [],
expectedIPs: [],
unexpectedIPs: [],
queryStrategy: 'UseIP',
skipFallback: false,
disableCache: false,
finalQuery: false,
tag: '',
clientIP: '',
serveStale: false,
serveExpiredTTL: 0,
timeoutMs: 4000,
};
}
function valuesFromServer(server: DnsServerValue | null): DnsServerForm {
if (server == null) return defaultFormValues();
if (typeof server === 'string') return { ...defaultFormValues(), address: server };
const parsed = DnsServerObjectSchema.safeParse(server);
const data = parsed.success ? parsed.data : null;
return {
...defaultFormValues(),
...(data ?? {}),
address: (data?.address ?? server.address) || 'localhost',
domains: data?.domains ?? server.domains ?? [],
expectedIPs: data?.expectedIPs ?? server.expectedIPs ?? server.expectIPs ?? [],
unexpectedIPs: data?.unexpectedIPs ?? server.unexpectedIPs ?? [],
queryStrategy: data?.queryStrategy ?? server.queryStrategy ?? 'UseIP',
skipFallback: data?.skipFallback ?? server.skipFallback ?? false,
disableCache: data?.disableCache ?? server.disableCache ?? false,
finalQuery: data?.finalQuery ?? server.finalQuery ?? false,
tag: data?.tag ?? server.tag ?? '',
clientIP: data?.clientIP ?? server.clientIP ?? '',
serveStale: data?.serveStale ?? server.serveStale ?? false,
serveExpiredTTL: data?.serveExpiredTTL ?? server.serveExpiredTTL ?? 0,
timeoutMs: data?.timeoutMs ?? server.timeoutMs ?? 4000,
};
}
function valuesToWire(values: DnsServerForm): DnsServerValue {
const isPlain =
values.domains.length === 0 &&
values.expectedIPs.length === 0 &&
values.unexpectedIPs.length === 0 &&
values.port === 53 &&
values.queryStrategy === 'UseIP' &&
values.skipFallback === false &&
values.disableCache === false &&
values.finalQuery === false &&
!values.tag &&
!values.clientIP &&
values.serveStale === false &&
values.serveExpiredTTL === 0 &&
values.timeoutMs === 4000;
if (isPlain) return values.address;
const out: Record<string, unknown> = {
address: values.address,
domains: values.domains.filter(Boolean),
expectedIPs: values.expectedIPs.filter(Boolean),
unexpectedIPs: values.unexpectedIPs.filter(Boolean),
queryStrategy: values.queryStrategy,
skipFallback: values.skipFallback,
disableCache: values.disableCache,
finalQuery: values.finalQuery,
serveStale: values.serveStale,
serveExpiredTTL: values.serveExpiredTTL,
timeoutMs: values.timeoutMs,
};
if (!isEncryptedDnsAddress(values.address)) out.port = values.port;
if (values.tag) out.tag = values.tag;
if (values.clientIP) out.clientIP = values.clientIP;
return out as DnsServerValue;
}
const shape = DnsServerObjectInnerSchema.shape;
export default function DnsServerModal({
open,
server,
isEdit,
onClose,
onConfirm,
}: DnsServerModalProps) {
const { t } = useTranslation();
const methods = useForm<DnsServerForm>({ defaultValues: defaultFormValues() });
const domains = useWatch({ control: methods.control, name: 'domains' }) ?? [];
const expectedIPs = useWatch({ control: methods.control, name: 'expectedIPs' }) ?? [];
const unexpectedIPs = useWatch({ control: methods.control, name: 'unexpectedIPs' }) ?? [];
useEffect(() => {
if (!open) return;
methods.reset(valuesFromServer(server));
}, [open, server, methods]);
const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add');
return (
<Modal
open={open}
title={title}
okText={t('confirm')}
cancelText={t('close')}
mask={{ closable: false }}
onOk={methods.handleSubmit((values) => onConfirm(valuesToWire(values)))}
onCancel={onClose}
>
<FormProvider {...methods}>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<FormField
label={t('pages.inbounds.address')}
name="address"
rules={{ validate: rhfZodValidate(shape.address) }}
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.port')}
name="port"
rules={{ validate: rhfZodValidate(shape.port) }}
>
<InputNumber min={1} max={65535} />
</FormField>
<FormField label={t('pages.xray.dns.tag')} name="tag">
<Input />
</FormField>
<FormField label={t('pages.xray.dns.clientIp')} name="clientIP">
<Input />
</FormField>
<FormField label={t('pages.xray.dns.strategy')} name="queryStrategy">
<Select
style={{ width: '100%' }}
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
/>
</FormField>
<FormField
label={t('pages.xray.dns.timeoutMs')}
name="timeoutMs"
rules={{ validate: rhfZodValidate(shape.timeoutMs) }}
>
<InputNumber min={0} step={500} />
</FormField>
<Divider style={{ margin: '5px 0' }} />
<Form.Item label={t('pages.xray.dns.domains')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('domains', [...domains, ''])}
/>
{domains.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`domains.${i}`} noStyle>
<Input />
</FormField>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'domains',
domains.filter((__, idx) => idx !== i),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.dns.expectIPs')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('expectedIPs', [...expectedIPs, ''])}
/>
{expectedIPs.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`expectedIPs.${i}`} noStyle>
<Input />
</FormField>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'expectedIPs',
expectedIPs.filter((__, idx) => idx !== i),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('unexpectedIPs', [...unexpectedIPs, ''])}
/>
{unexpectedIPs.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`unexpectedIPs.${i}`} noStyle>
<Input />
</FormField>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'unexpectedIPs',
unexpectedIPs.filter((__, idx) => idx !== i),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Divider style={{ margin: '5px 0' }} />
<FormField
label={t('pages.xray.dns.skipFallback')}
name="skipFallback"
valueProp="checked"
>
<Switch />
</FormField>
<FormField label={t('pages.xray.dns.finalQuery')} name="finalQuery" valueProp="checked">
<Switch />
</FormField>
<FormField
label={t('pages.xray.dns.disableCache')}
name="disableCache"
valueProp="checked"
>
<Switch />
</FormField>
<FormField label={t('pages.xray.dns.serveStale')} name="serveStale" valueProp="checked">
<Switch />
</FormField>
<FormField label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
<InputNumber min={0} step={60} />
</FormField>
</Form>
</FormProvider>
</Modal>
);
}
-16
View File
@@ -1,16 +0,0 @@
.row-index {
font-weight: 500;
opacity: 0.7;
}
.muted {
opacity: 0.7;
word-break: break-all;
}
.hosts-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
-516
View File
@@ -1,516 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
Empty,
Input,
InputNumber,
Modal,
Select,
Space,
Switch,
Table,
Tabs,
} from 'antd';
import {
DatabaseOutlined,
DeleteOutlined,
ExperimentOutlined,
MenuOutlined,
PlusOutlined,
ProfileOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import DnsServerModal from './DnsServerModal';
import type { DnsServerValue } from './DnsServerModal';
import DnsPresetsModal from './DnsPresetsModal';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './DnsTab.css';
import { STRATEGIES, DEFAULT_FAKEDNS } from './helpers';
import type { DnsConfig, HostRow, FakednsRow } from './types';
import { useDnsServerColumns, useFakednsColumns } from './useDnsColumns';
interface DnsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
}
export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [hostsList, setHostsList] = useState<HostRow[]>([]);
const [serverModalOpen, setServerModalOpen] = useState(false);
const [editingServer, setEditingServer] = useState<DnsServerValue | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [presetsModalOpen, setPresetsModalOpen] = useState(false);
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
const dnsEnabled = !!dns;
const sourceHosts = dns?.hosts;
const incomingHosts = JSON.stringify(sourceHosts ?? {});
const lastWrittenHostsRef = useRef<string | null>(null);
useEffect(() => {
if (!dnsEnabled) {
lastWrittenHostsRef.current = '{}';
setHostsList([]);
return;
}
if (incomingHosts === lastWrittenHostsRef.current) return;
lastWrittenHostsRef.current = incomingHosts;
setHostsList(
Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
domain,
values: Array.isArray(values) ? [...values] : [String(values)],
})),
);
}, [dnsEnabled, incomingHosts, sourceHosts]);
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
function toggleDNS(enabled: boolean) {
mutate((next) => {
if (enabled) {
(next as { dns?: DnsConfig }).dns = {
tag: 'dns_inbound',
queryStrategy: 'UseIP',
disableCache: false,
disableFallback: false,
disableFallbackIfMatch: false,
useSystemHosts: false,
enableParallelQuery: false,
serveStale: false,
serveExpiredTTL: 0,
hosts: {},
servers: [],
};
next.fakedns = null;
} else {
delete next.dns;
delete next.fakedns;
}
});
}
function syncHosts(next: HostRow[]) {
const obj: Record<string, string | string[]> = {};
for (const row of next) {
if (!row.domain) continue;
const vals = (row.values || []).filter(Boolean);
if (vals.length === 0) continue;
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
}
lastWrittenHostsRef.current = JSON.stringify(obj);
setHostsList(next);
mutate((tt) => {
if (!tt.dns) return;
if (Object.keys(obj).length > 0) {
(tt.dns as DnsConfig).hosts = obj;
} else if ('hosts' in (tt.dns as DnsConfig)) {
delete (tt.dns as DnsConfig).hosts;
}
});
}
function setDnsField<K extends keyof DnsConfig>(key: K, value: DnsConfig[K], omit = false) {
mutate((tt) => {
if (!tt.dns) return;
if (omit && (value == null || (typeof value === 'string' && value.trim() === ''))) {
delete (tt.dns as Record<string, unknown>)[key as string];
} else {
(tt.dns as Record<string, unknown>)[key as string] = value;
}
});
}
const dnsServers = useMemo(() => {
const list = dns?.servers || [];
return list.map((server, idx) => ({ key: idx, server }));
}, [dns?.servers]);
// Stable callbacks: the column definitions in useDnsServerColumns are
// memoized, so they must be able to depend on these (see issue #5155)
const openEditServer = useCallback(
(idx: number) => {
setEditingServer((dns?.servers || [])[idx] || null);
setEditingIndex(idx);
setServerModalOpen(true);
},
[dns?.servers],
);
const deleteServer = useCallback(
(idx: number) => {
mutate((tt) => {
const cfg = tt.dns as DnsConfig | undefined;
if (cfg?.servers) cfg.servers.splice(idx, 1);
});
},
[mutate],
);
const dnsColumns = useDnsServerColumns({ openEditServer, deleteServer });
function openAddServer() {
setEditingServer(null);
setEditingIndex(null);
setServerModalOpen(true);
}
function onServerConfirm(value: DnsServerValue) {
mutate((tt) => {
if (!tt.dns) return;
const cfg = tt.dns as DnsConfig;
if (!Array.isArray(cfg.servers)) cfg.servers = [];
if (editingIndex == null) cfg.servers.push(value);
else cfg.servers[editingIndex] = value;
});
setServerModalOpen(false);
}
function clearAllServers() {
modal.confirm({
title: t('pages.xray.dns.clearAllTitle'),
content: t('pages.xray.dns.clearAllConfirm'),
okText: t('delete'),
okButtonProps: { danger: true },
cancelText: t('cancel'),
onOk: () =>
mutate((tt) => {
if (tt.dns) (tt.dns as DnsConfig).servers = [];
}),
});
}
function onPresetInstall(servers: string[]) {
mutate((tt) => {
if (tt.dns) (tt.dns as DnsConfig).servers = servers;
});
setPresetsModalOpen(false);
}
const fakeDnsList = useMemo<{ key: number; ipPool: string; poolSize: number }[]>(() => {
const list = Array.isArray(templateSettings?.fakedns)
? (templateSettings?.fakedns as FakednsRow[])
: [];
return list.map((entry, idx) => ({ key: idx, ...entry }));
}, [templateSettings?.fakedns]);
const deleteFakedns = useCallback(
(idx: number) => {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list) return;
list.splice(idx, 1);
if (list.length === 0) tt.fakedns = null;
});
},
[mutate],
);
const updateFakednsField = useCallback(
(idx: number, field: 'ipPool' | 'poolSize', value: string | number) => {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list?.[idx]) return;
(list[idx] as unknown as Record<string, unknown>)[field] = value;
});
},
[mutate],
);
const fakednsColumns = useFakednsColumns({ deleteFakedns, updateFakednsField });
function addFakedns() {
mutate((tt) => {
if (!Array.isArray(tt.fakedns)) tt.fakedns = [];
(tt.fakedns as FakednsRow[]).push(DEFAULT_FAKEDNS());
});
}
const items = useMemo(() => {
const out = [
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.enable')}
description={t('pages.xray.dns.enableDesc')}
control={<Switch checked={dnsEnabled} onChange={toggleDNS} />}
/>
{dnsEnabled && (
<>
<Alert
type="warning"
showIcon
title={t('pages.xray.dns.dnsLeakWarning')}
style={{ marginBottom: 12 }}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.tag')}
description={t('pages.xray.dns.tagDesc')}
control={
<Input
value={dns?.tag ?? 'dns_inbound'}
onChange={(e) => setDnsField('tag', e.target.value)}
/>
}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.clientIp')}
description={t('pages.xray.dns.clientIpDesc')}
control={
<Input
value={dns?.clientIp ?? ''}
onChange={(e) => setDnsField('clientIp', e.target.value, true)}
/>
}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.strategy')}
description={t('pages.xray.dns.strategyDesc')}
control={
<Select
value={dns?.queryStrategy ?? 'UseIP'}
style={{ width: '100%' }}
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
onChange={(v) => setDnsField('queryStrategy', v)}
/>
}
/>
{(
[
[
'disableCache',
'pages.xray.dns.disableCache',
'pages.xray.dns.disableCacheDesc',
],
[
'disableFallback',
'pages.xray.dns.disableFallback',
'pages.xray.dns.disableFallbackDesc',
],
[
'disableFallbackIfMatch',
'pages.xray.dns.disableFallbackIfMatch',
'pages.xray.dns.disableFallbackIfMatchDesc',
],
[
'enableParallelQuery',
'pages.xray.dns.enableParallelQuery',
'pages.xray.dns.enableParallelQueryDesc',
],
[
'useSystemHosts',
'pages.xray.dns.useSystemHosts',
'pages.xray.dns.useSystemHostsDesc',
],
['serveStale', 'pages.xray.dns.serveStale', 'pages.xray.dns.serveStaleDesc'],
] as const
).map(([field, titleKey, descKey]) => (
<SettingListItem
key={field}
paddings="small"
title={t(titleKey)}
description={t(descKey)}
control={
<Switch
checked={!!dns?.[field]}
onChange={(v) => setDnsField(field as keyof DnsConfig, v as never)}
/>
}
/>
))}
<SettingListItem
paddings="small"
title={t('pages.xray.dns.serveExpiredTTL')}
description={t('pages.xray.dns.serveExpiredTTLDesc')}
control={
<InputNumber
value={dns?.serveExpiredTTL ?? 0}
min={0}
step={60}
style={{ width: '100%' }}
onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
/>
}
/>
</>
)}
</>
),
},
];
if (dnsEnabled) {
out.push({
key: 'hosts',
label: catTabLabel(<ProfileOutlined />, t('pages.xray.dns.hosts'), isMobile),
children:
hostsList.length === 0 ? (
<Empty description={t('pages.xray.dns.hostsEmpty')}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}
>
{t('pages.xray.dns.hostsAdd')}
</Button>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}
>
{t('pages.xray.dns.hostsAdd')}
</Button>
{hostsList.map((row, idx) => (
<div key={`h${idx}`} className="hosts-row">
<Input
value={row.domain}
aria-label={t('pages.xray.dns.hostsDomain')}
placeholder={t('pages.xray.dns.hostsDomain')}
style={{ flex: '1 1 220px' }}
onChange={(e) => {
const next = hostsList.map((r, i) =>
i === idx ? { ...r, domain: e.target.value } : r,
);
syncHosts(next);
}}
/>
<Select
mode="tags"
value={row.values}
aria-label={t('pages.xray.dns.hostsValues')}
placeholder={t('pages.xray.dns.hostsValues')}
style={{ flex: '2 1 320px' }}
tokenSeparators={[',', ' ']}
onChange={(values) => {
const next = hostsList.map((r, i) => (i === idx ? { ...r, values } : r));
syncHosts(next);
}}
/>
<Button
danger
aria-label={t('delete')}
icon={<DeleteOutlined />}
onClick={() => syncHosts(hostsList.filter((_, i) => i !== idx))}
/>
</div>
))}
</Space>
),
});
out.push({
key: '2',
label: catTabLabel(<DatabaseOutlined />, 'DNS', isMobile),
children:
dnsServers.length === 0 ? (
<Empty description={t('emptyDnsDesc')}>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
{t('pages.xray.dns.add')}
</Button>
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
{t('pages.xray.dns.usePreset')}
</Button>
</Space>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
{t('pages.xray.dns.add')}
</Button>
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
{t('pages.xray.dns.usePreset')}
</Button>
<Button danger icon={<DeleteOutlined />} onClick={clearAllServers}>
{t('pages.xray.dns.clearAll')}
</Button>
</Space>
<Table
columns={dnsColumns}
dataSource={dnsServers}
rowKey={(r) => r.key}
pagination={false}
size="small"
bordered
/>
</Space>
),
});
out.push({
key: '3',
label: catTabLabel(<ExperimentOutlined />, 'Fake DNS', isMobile),
children:
fakeDnsList.length === 0 ? (
<Empty description={t('emptyFakeDnsDesc')}>
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
{t('pages.xray.fakedns.add')}
</Button>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
{t('pages.xray.fakedns.add')}
</Button>
<Table
columns={fakednsColumns}
dataSource={fakeDnsList}
rowKey={(r) => r.key}
pagination={false}
size="small"
bordered
/>
</Space>
),
});
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, isMobile, dnsEnabled, dns, hostsList, dnsServers, fakeDnsList]);
return (
<>
{modalContextHolder}
<Tabs defaultActiveKey="1" items={items} />
<DnsServerModal
open={serverModalOpen}
server={editingServer}
isEdit={editingIndex != null}
onClose={() => setServerModalOpen(false)}
onConfirm={onServerConfirm}
/>
<DnsPresetsModal
open={presetsModalOpen}
onClose={() => setPresetsModalOpen(false)}
onInstall={onPresetInstall}
/>
</>
);
}
-19
View File
@@ -1,19 +0,0 @@
import { DnsQueryStrategySchema } from '@/schemas/dns';
import type { DnsServerValue } from './DnsServerModal';
export const STRATEGIES = DnsQueryStrategySchema.options;
export const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
export function addrFor(server: DnsServerValue): string {
return typeof server === 'string' ? server : server?.address || '';
}
export function domainsFor(server: DnsServerValue): string {
return typeof server === 'object' && server !== null ? (server.domains || []).join(',') : '';
}
export function expectedIPsFor(server: DnsServerValue): string {
if (typeof server !== 'object' || !server) return '';
const list = server.expectedIPs || server.expectIPs || [];
return Array.isArray(list) ? list.join(',') : '';
}
-3
View File
@@ -1,3 +0,0 @@
export { default as DnsTab } from './DnsTab';
export { default as DnsServerModal } from './DnsServerModal';
export { default as DnsPresetsModal } from './DnsPresetsModal';
-14
View File
@@ -1,14 +0,0 @@
import type { DnsObject } from '@/schemas/dns';
import type { DnsServerValue } from './DnsServerModal';
export type DnsConfig = Omit<DnsObject, 'servers'> & { servers?: DnsServerValue[] };
export interface HostRow {
domain: string;
values: string[];
}
export interface FakednsRow {
ipPool: string;
poolSize: number;
}
@@ -1,149 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Input, InputNumber, Space } from 'antd';
import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { onNumber } from '@/utils/onNumber';
import { addrFor, domainsFor, expectedIPsFor } from './helpers';
import type { DnsServerValue } from './DnsServerModal';
export type DnsServerRow = { key: number; server: DnsServerValue };
export type FakednsTableRow = { key: number; ipPool: string; poolSize: number };
export function useDnsServerColumns({
openEditServer,
deleteServer,
}: {
openEditServer: (idx: number) => void;
deleteServer: (idx: number) => void;
}): ColumnsType<DnsServerRow> {
const { t } = useTranslation();
return useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 60,
render: (_v, _record, index) => (
<Space size={6}>
<span className="row-index">{index + 1}</span>
<Dropdown
trigger={['click']}
menu={{
items: [
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => openEditServer(index),
},
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => deleteServer(index),
},
],
}}
>
<Button aria-label={t('more')} shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</Space>
),
},
{
title: t('pages.inbounds.address'),
key: 'address',
align: 'left',
render: (_v, record) => addrFor(record.server),
},
{
title: t('pages.xray.dns.domains'),
key: 'domains',
align: 'left',
render: (_v, record) => <span className="muted">{domainsFor(record.server)}</span>,
},
{
title: t('pages.xray.dns.expectIPs'),
key: 'expectedIPs',
align: 'left',
render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>,
},
],
[t, openEditServer, deleteServer],
);
}
export function useFakednsColumns({
deleteFakedns,
updateFakednsField,
}: {
deleteFakedns: (idx: number) => void;
updateFakednsField: (idx: number, field: 'ipPool' | 'poolSize', value: string | number) => void;
}): ColumnsType<FakednsTableRow> {
const { t } = useTranslation();
return useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 60,
render: (_v, _record, index) => (
<Space size={6}>
<span className="row-index">{index + 1}</span>
<Button
aria-label={t('delete')}
shape="circle"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => deleteFakedns(index)}
/>
</Space>
),
},
{
title: 'IP pool',
dataIndex: 'ipPool',
key: 'ipPool',
align: 'left',
render: (_v, record, index) => (
<Input
value={record.ipPool}
aria-label={t('pages.xray.fakedns.ipPool')}
size="small"
onChange={(e) => updateFakednsField(index, 'ipPool', e.target.value)}
/>
),
},
{
title: 'Pool size',
dataIndex: 'poolSize',
key: 'poolSize',
align: 'right',
width: 120,
render: (_v, record, index) => (
<InputNumber
value={record.poolSize}
aria-label={t('pages.xray.fakedns.poolSize')}
min={1}
size="small"
onChange={onNumber((v) => updateFakednsField(index, 'poolSize', v))}
/>
),
},
],
[t, deleteFakedns, updateFakednsField],
);
}
+1 -4
View File
@@ -1,7 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { AllSetting } from '@/models/setting';
import SecurityTab from '@/pages/settings/SecurityTab';
import { HttpUtil } from '@/utils';
@@ -26,9 +25,7 @@ describe('API token creation date', () => {
],
});
render(
<SecurityTab allSetting={{} as AllSetting} updateSetting={vi.fn()} saveSetting={vi.fn()} />,
);
render(<SecurityTab />);
fireEvent.click(screen.getByRole('tab', { name: /API Token/ }));
expect(await screen.findByText('seconds-token')).toBeTruthy();
@@ -1,93 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { fireEvent } from '@testing-library/react';
import BalancerFormModal from '@/pages/xray/balancers/BalancerFormModal';
import type { BalancerFormValue } from '@/pages/xray/balancers/BalancerFormModal';
import type { BalancerObject } from '@/schemas/routing';
import { renderWithProviders } from './test-utils';
function renderModal(onConfirm = vi.fn()) {
renderWithProviders(
<BalancerFormModal
open
balancer={null}
outboundTags={['proxy', 'direct']}
balancerTags={[]}
balancers={[]}
templateSettings={null}
otherTags={['existing']}
onClose={() => {}}
onConfirm={onConfirm}
/>,
);
return { onConfirm };
}
function erroredItemCount(): number {
return document.querySelectorAll('.ant-form-item-has-error').length;
}
function explainText(): string {
return Array.from(document.querySelectorAll('.ant-form-item-explain'))
.map((el) => (el.textContent ?? '').trim())
.join(' | ');
}
function primaryButton(): HTMLElement {
const btn = document.querySelector('.ant-modal-footer .ant-btn-primary');
if (!btn) throw new Error('Primary button not found');
return btn as HTMLElement;
}
describe('BalancerFormModal', () => {
it('shows no validation errors when freshly opened in add mode', () => {
renderModal();
expect(document.querySelector('.ant-modal')).toBeTruthy();
expect(erroredItemCount()).toBe(0);
expect(explainText()).not.toContain('Tag is required');
expect(explainText()).not.toContain('Pick at least one outbound');
expect(primaryButton().hasAttribute('disabled')).toBe(false);
});
it('reveals required-field errors only after a save attempt, without confirming', () => {
const { onConfirm } = renderModal();
expect(erroredItemCount()).toBe(0);
fireEvent.click(primaryButton());
expect(erroredItemCount()).toBe(2);
expect(explainText()).toContain('Tag is required');
expect(explainText()).toContain('Pick at least one outbound');
expect(onConfirm).not.toHaveBeenCalled();
});
it('disables save and warns when the chosen fallback would create a balancer cycle', () => {
const editing: BalancerFormValue = {
tag: 'A',
strategy: 'random',
selector: ['proxy'],
fallbackTag: 'B',
};
const others: BalancerObject[] = [{ tag: 'B', selector: ['direct'], fallbackTag: 'A' }];
const onConfirm = vi.fn();
renderWithProviders(
<BalancerFormModal
open
balancer={editing}
outboundTags={['proxy', 'direct']}
balancerTags={['A', 'B']}
balancers={others}
templateSettings={null}
otherTags={['B']}
onClose={() => {}}
onConfirm={onConfirm}
/>,
);
expect(document.querySelector('.ant-alert-error')).toBeTruthy();
expect(primaryButton().hasAttribute('disabled')).toBe(true);
fireEvent.click(primaryButton());
expect(onConfirm).not.toHaveBeenCalled();
});
});
-19
View File
@@ -1,19 +0,0 @@
import { describe, expect, it } from 'vitest';
import { PRESETS } from '@/pages/xray/dns/DnsPresetsModal';
import { DnsObjectSchema, DnsServerObjectSchema } from '@/schemas/dns';
describe('DNS presets', () => {
it('include encrypted presets accepted by xray DNS config', () => {
const servers = PRESETS.flatMap((preset) => preset.data);
expect(servers).toContain('https://freedns.controld.com/p2');
expect(servers).toContain('quic+local://p2.freedns.controld.com');
expect(servers).toContain('https://dns.google/dns-query');
expect(servers.every((server) => !server.startsWith('tls://'))).toBe(true);
expect(DnsObjectSchema.parse({ servers }).servers).toEqual(servers);
for (const server of servers) {
expect(DnsServerObjectSchema.parse({ address: server }).address).toBe(server);
}
});
});
-130
View File
@@ -1,130 +0,0 @@
import { useState } from 'react';
import { describe, expect, it } from 'vitest';
import { fireEvent, screen } from '@testing-library/react';
import DnsTab from '@/pages/xray/dns/DnsTab';
import type { SetTemplate, XraySettingsValue } from '@/hooks/useXraySetting';
import { renderWithProviders } from './test-utils';
function withHosts(hosts: Record<string, string>): XraySettingsValue {
return {
dns: {
hosts,
servers: [],
},
} as unknown as XraySettingsValue;
}
describe('DnsTab', () => {
it('keeps an empty row after adding a host', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(
withHosts({ 'first.example': '1.1.1.1' }),
);
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
fireEvent.click(screen.getByRole('button', { name: /Add Host$/ }));
expect(screen.getAllByLabelText('Domain (e.g. domain:example.com)')).toHaveLength(2);
});
it('keeps a row visible while its domain is incomplete', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(
withHosts({ 'first.example': '1.1.1.1' }),
);
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), {
target: { value: '' },
});
expect(
(screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value,
).toBe('');
});
it('shows hosts from an externally refreshed configuration', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(
withHosts({ 'first.example': '1.1.1.1' }),
);
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return (
<>
<button
type="button"
onClick={() => setTemplateSettings(withHosts({ 'second.example': '2.2.2.2' }))}
>
Refresh hosts
</button>
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
</>
);
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
expect(
(screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value,
).toBe('first.example');
fireEvent.click(screen.getByRole('button', { name: 'Refresh hosts' }));
expect(
(screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value,
).toBe('second.example');
});
it('clears an incomplete host draft when DNS is disabled', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(
withHosts({ 'first.example': '1.1.1.1' }),
);
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return (
<>
<button type="button" onClick={() => setTemplateSettings({})}>
Disable DNS
</button>
<button type="button" onClick={() => setTemplateSettings(withHosts({}))}>
Enable DNS
</button>
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
</>
);
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), {
target: { value: '' },
});
fireEvent.click(screen.getByRole('button', { name: 'Disable DNS' }));
fireEvent.click(screen.getByRole('button', { name: 'Enable DNS' }));
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
expect(screen.queryByLabelText('Domain (e.g. domain:example.com)')).toBeNull();
});
});
@@ -1,48 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import ObservatorySettingsTab from '@/pages/xray/balancers/ObservatorySettingsTab';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { renderWithProviders } from './test-utils';
function renderTab(templateSettings: XraySettingsValue) {
renderWithProviders(
<ObservatorySettingsTab templateSettings={templateSettings} mutate={vi.fn()} />,
);
}
describe('ObservatorySettingsTab', () => {
it('shows one burst settings panel and warns for legacy mixed observers', () => {
renderTab({
routing: {
balancers: [{ tag: 'll', selector: ['proxy-a'], strategy: { type: 'leastLoad' } }],
},
observatory: { subjectSelector: ['stale-regular'] },
burstObservatory: { subjectSelector: ['proxy-a'] },
} as unknown as XraySettingsValue);
expect(
screen.getByText(/This config contains both Observatory and Burst Observatory/),
).toBeTruthy();
expect(document.querySelector('.ant-segmented')).toBeFalsy();
expect(screen.getByText('Probe Destination')).toBeTruthy();
expect(screen.queryByText('Probe URL')).toBeFalsy();
});
it('shows regular observatory settings for mixed legacy configs that normalize back to leastPing only', () => {
renderTab({
routing: {
balancers: [{ tag: 'lp', selector: ['proxy-b'], strategy: { type: 'leastPing' } }],
},
observatory: { subjectSelector: ['proxy-b'] },
burstObservatory: { subjectSelector: ['stale-burst'] },
} as unknown as XraySettingsValue);
expect(
screen.getByText(/This config contains both Observatory and Burst Observatory/),
).toBeTruthy();
expect(document.querySelector('.ant-segmented')).toBeFalsy();
expect(screen.getByText('Probe URL')).toBeTruthy();
expect(screen.queryByText('Probe Destination')).toBeFalsy();
});
});
+2 -31
View File
@@ -7,7 +7,6 @@ require (
github.com/gin-contrib/gzip v1.2.6
github.com/gin-contrib/sessions v1.1.0
github.com/gin-gonic/gin v1.12.0
github.com/go-ldap/ldap/v3 v3.4.14
github.com/go-playground/validator/v10 v10.30.3
github.com/goccy/go-json v0.10.6
github.com/goccy/go-yaml v1.19.2
@@ -16,16 +15,11 @@ require (
github.com/joho/godotenv v1.5.1
github.com/klauspost/compress v1.19.2
github.com/mattn/go-sqlite3 v1.14.50
github.com/mymmrac/telego v1.11.2
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.26.7
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.73.0
github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc
go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.55.0
golang.org/x/net v0.58.0
@@ -42,18 +36,14 @@ require (
)
require (
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/cloudflare/circl v1.6.5 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -61,7 +51,6 @@ require (
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/grbit/go-json v0.11.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
@@ -69,46 +58,28 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/juju/ratelimit v1.0.2 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/miekg/dns v1.1.73 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pion/dtls/v3 v3.1.5 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/stun/v3 v3.1.7 // indirect
github.com/pion/transport/v4 v4.1.0 // indirect
github.com/pires/go-proxyproto v0.15.0 // indirect
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.61.0 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/sagernet/sing v0.8.14 // indirect
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/arch v0.30.0 // indirect
golang.org/x/exp v0.0.0-20260820142414-ca536658362e // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
)
-94
View File
@@ -1,15 +1,9 @@
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814 h1:l2AhBD+sFycU8Im81n/bZORMxW7fWtlZJEuJ4Hh0+z0=
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I=
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
@@ -18,8 +12,6 @@ github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -29,8 +21,6 @@ github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
@@ -39,10 +29,6 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -62,8 +48,6 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
@@ -83,10 +67,6 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -95,18 +75,6 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -115,16 +83,10 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
@@ -133,33 +95,17 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mymmrac/telego v1.11.2 h1:f/CCSKHsXEHCEQPixOj6/l8WEns9YlXKvh9zi/Iinww=
github.com/mymmrac/telego v1.11.2/go.mod h1:wo7Y5Ux7xUZs04xzmP0SUFGvWVDJDcMDV9aJ1jwqFl0=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E=
github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8=
github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ=
github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
@@ -173,16 +119,8 @@ github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/sagernet/sing v0.8.14 h1:S6Netv4F61uNAuD/sUbHnGuNUEPwtL08Ouk0//CVYgM=
github.com/sagernet/sing v0.8.14/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -203,24 +141,6 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc h1:fkOkmgHWbF2Q8MdV9VxrsyxRz4OndcrUXUkh1ANBTg0=
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc/go.mod h1:wukQoBGnQ6GaLTGuKwv8rCTgf80QxPj+6iznDZHQEWo=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -245,8 +165,6 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs=
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
@@ -260,8 +178,6 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
@@ -270,10 +186,6 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4=
@@ -283,12 +195,8 @@ google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4J
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
@@ -299,7 +207,5 @@ gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk=
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
# 3x-ui-agent 节点一键安装脚本
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node]
# 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行
set -e
RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
[[ $EUID -ne 0 ]] && error "请用 root 运行"
MASTER=""; TOKEN=""; NAME="agent-node"
while [[ $# -gt 0 ]]; do
case $1 in
--master) MASTER="$2"; shift 2;;
--token) TOKEN="$2"; shift 2;;
--name) NAME="$2"; shift 2;;
*) error "未知参数: $1";;
esac
done
[[ -z "$MASTER" ]] && error "缺少 --master 参数"
[[ -z "$TOKEN" ]] && error "缺少 --token 参数 (从主控面板 API 获取)"
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) BIN_ARCH="amd64";;
aarch64|arm64) BIN_ARCH="arm64";;
*) error "不支持的架构: $ARCH (仅支持 amd64/arm64)";;
esac
# 二进制下载地址(本仓 Gitea Releases,tag 沿用 v3.7.0-agent.1)
XUI_URL="${XUI_URL:-https://git.881218.xyz/rogue/3x-ui-agent/releases/download/v3.7.0-agent.1/x-ui-linux-${BIN_ARCH}}"
XRAY_VER="26.6.27"
info "创建目录..."
mkdir -p /usr/local/x-ui/bin /etc/x-ui
info "下载 agent 二进制..."
curl -fL -o /usr/local/x-ui/x-ui "$XUI_URL" || error "agent 下载失败"
chmod +x /usr/local/x-ui/x-ui
info "下载 xray v${XRAY_VER}..."
cd /usr/local/x-ui/bin
case "$BIN_ARCH" in
amd64) XRAY_ZIP="Xray-linux-64.zip";;
arm64) XRAY_ZIP="Xray-linux-arm64-v8a.zip";;
esac
curl -fL -o "$XRAY_ZIP" "https://github.com/XTLS/Xray-core/releases/download/v${XRAY_VER}/${XRAY_ZIP}" || error "xray 下载失败"
unzip -o -q "$XRAY_ZIP" xray && mv xray "xray-linux-${BIN_ARCH}" && chmod +x "xray-linux-${BIN_ARCH}"
rm -f "$XRAY_ZIP"
info "下载 geo 文件..."
curl -fL -o geoip.dat "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" || error "geoip 下载失败"
curl -fL -o geosite.dat "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" || error "geosite 下载失败"
info "写入 agent 配置..."
cat > /etc/x-ui/agent.json <<EOF
{
"master_url": "$MASTER",
"token": "$TOKEN",
"node_name": "$NAME"
}
EOF
chmod 600 /etc/x-ui/agent.json
info "启动 agent..."
pkill -f "/usr/local/x-ui/x-ui agent" 2>/dev/null; sleep 1
nohup /usr/local/x-ui/x-ui agent > /tmp/agent.log 2>&1 &
sleep 8
if grep -q "Starting 3x-ui agent" /tmp/agent.log 2>/dev/null; then
info "agent 启动成功!"
echo ""
echo " 主控: $MASTER"
echo " 节点名: $NAME"
echo " 日志: tail -f /tmp/agent.log"
echo " agent 会在 30 秒内从主控拉取配置"
else
error "启动失败,查看日志: cat /tmp/agent.log"
fi
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash
# 3x-ui-agent 主控一键安装脚本
# 用法: bash install-master.sh
# 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行
set -e
RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
[[ $EUID -ne 0 ]] && error "请用 root 运行"
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) BIN_ARCH="amd64";;
aarch64|arm64) BIN_ARCH="arm64";;
*) error "不支持的架构: $ARCH (仅支持 amd64/arm64)";;
esac
# 二进制下载地址(本仓 Gitea Releases,tag 沿用 v3.7.0-agent.1)
XUI_URL="${XUI_URL:-https://git.881218.xyz/rogue/3x-ui-agent/releases/download/v3.7.0-agent.1/x-ui-linux-${BIN_ARCH}}"
XRAY_VER="26.6.27"
info "创建目录..."
mkdir -p /usr/local/x-ui/bin /etc/x-ui
info "下载主控二进制..."
curl -fL -o /usr/local/x-ui/x-ui "$XUI_URL" || error "主控下载失败"
chmod +x /usr/local/x-ui/x-ui
info "下载 xray v${XRAY_VER}..."
cd /usr/local/x-ui/bin
case "$BIN_ARCH" in
amd64) XRAY_ZIP="Xray-linux-64.zip";;
arm64) XRAY_ZIP="Xray-linux-arm64-v8a.zip";;
esac
curl -fL -o "$XRAY_ZIP" "https://github.com/XTLS/Xray-core/releases/download/v${XRAY_VER}/${XRAY_ZIP}" || error "xray 下载失败"
unzip -o -q "$XRAY_ZIP" xray && mv xray "xray-linux-${BIN_ARCH}" && chmod +x "xray-linux-${BIN_ARCH}"
rm -f "$XRAY_ZIP"
info "下载 geo 文件..."
curl -fL -o geoip.dat "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" || error "geoip 下载失败"
curl -fL -o geosite.dat "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" || error "geosite 下载失败"
info "启动主控..."
cd /usr/local/x-ui
pkill -f "/usr/local/x-ui/x-ui" 2>/dev/null; sleep 1
nohup ./x-ui > /tmp/xui.log 2>&1 &
sleep 5
if ss -tlnp 2>/dev/null | grep -q ":2053"; then
info "主控启动成功!"
echo ""
echo " 面板地址: http://$(hostname -I | awk '{print $1}'):2053"
echo " 默认账号: admin / admin"
echo " 请登录后立即修改密码"
else
error "启动失败,查看日志: cat /tmp/xui.log"
fi
+8 -1756
View File
File diff suppressed because it is too large Load Diff
+528
View File
@@ -0,0 +1,528 @@
// Package agent implements the lightweight 3x-ui node agent.
// In agent mode, the node does not run the web panel or database.
// It registers with the master via token, receives xray config, manages the
// xray process, and reports traffic back to the master.
package agent
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
const (
// Default poll interval for config updates
defaultPollInterval = 30 * time.Second
// Heartbeat interval
defaultHeartbeatInterval = 60 * time.Second
// Traffic report interval (piggybacks the heartbeat tick)
defaultTrafficInterval = 60 * time.Second
)
// DefaultBinFolder is used when XUI_BIN_FOLDER is unset. It matches the
// panel's directory layout: xray binary, geo files and config.json live here.
const DefaultBinFolder = "/usr/local/x-ui/bin"
// Config holds the agent configuration
type Config struct {
MasterURL string `json:"master_url"`
Token string `json:"token"`
NodeName string `json:"node_name"`
PollInterval time.Duration `json:"poll_interval"`
// ApiPort overrides the xray stats API inbound port from the master's
// config. Needed when agent and master share a host (e.g. testing),
// otherwise both xrays fight over the default 62789. 0 = use master's.
ApiPort int `json:"api_port"`
// BaselinePath is where the last master-acknowledged traffic counters
// are persisted, so an agent restart keeps diffing against them
// instead of swallowing unreported traffic into a fresh baseline.
// Empty: main derives it next to the agent config file; the agent
// falls back to the bin folder.
BaselinePath string `json:"baseline_path,omitempty"`
}
// Agent represents the node agent
type Agent struct {
config *Config
httpClient *http.Client
stopCh chan struct{}
lastConfigHash string
xrayProc *xray.Process
xrayAPI xray.XrayAPI
apiPort int
baselineLoaded bool
}
// New creates a new agent instance
func New(cfg *Config) *Agent {
if cfg.PollInterval == 0 {
cfg.PollInterval = defaultPollInterval
}
return &Agent{
config: cfg,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
},
},
stopCh: make(chan struct{}),
}
}
// Register registers the agent with the master
func (a *Agent) Register() error {
url := fmt.Sprintf("%s/panel/api/agent/register", a.config.MasterURL)
payload := map[string]string{
"token": a.config.Token,
"name": a.config.NodeName,
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.config.Token)
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("register failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("register failed with status %d: %s", resp.StatusCode, string(data))
}
logger.Info("Agent registered successfully with master")
return nil
}
// Run starts the agent main loop
func (a *Agent) Run() error {
logger.Info("Starting 3x-ui agent...")
logger.Infof("Master: %s, Node: %s", a.config.MasterURL, a.config.NodeName)
// Initial registration
if err := a.Register(); err != nil {
return fmt.Errorf("initial registration failed: %w", err)
}
// Start heartbeat ticker
heartbeatTicker := time.NewTicker(defaultHeartbeatInterval)
defer heartbeatTicker.Stop()
// Start config poll ticker
pollTicker := time.NewTicker(a.config.PollInterval)
defer pollTicker.Stop()
// Start traffic report ticker
trafficTicker := time.NewTicker(defaultTrafficInterval)
defer trafficTicker.Stop()
// Initial config fetch
if err := a.fetchAndApplyConfig(); err != nil {
logger.Warningf("Initial config fetch failed: %v", err)
}
for {
select {
case <-a.stopCh:
logger.Info("Agent stopping...")
a.stopXray()
return nil
case <-heartbeatTicker.C:
if err := a.sendHeartbeat(); err != nil {
logger.Warningf("Heartbeat failed: %v", err)
}
case <-pollTicker.C:
if err := a.fetchAndApplyConfig(); err != nil {
logger.Warningf("Config fetch failed: %v", err)
}
case <-trafficTicker.C:
if err := a.collectAndReportTraffic(); err != nil {
logger.Warningf("Traffic report failed: %v", err)
}
}
}
}
// Stop stops the agent
func (a *Agent) Stop() {
close(a.stopCh)
}
// sendHeartbeat sends a heartbeat to the master
func (a *Agent) sendHeartbeat() error {
url := fmt.Sprintf("%s/panel/api/agent/heartbeat", a.config.MasterURL)
payload := map[string]interface{}{
"token": a.config.Token,
"timestamp": time.Now().Unix(),
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.config.Token)
resp, err := a.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("heartbeat failed with status %d", resp.StatusCode)
}
return nil
}
// fetchAndApplyConfig fetches the xray config from master and applies it if changed
func (a *Agent) fetchAndApplyConfig() error {
url := fmt.Sprintf("%s/panel/api/agent/config", a.config.MasterURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+a.config.Token)
resp, err := a.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("config fetch failed with status %d: %s", resp.StatusCode, string(data))
}
var result struct {
ConfigHash string `json:"config_hash"`
XrayConfig json.RawMessage `json:"xray_config"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
// Skip if config hasn't changed
if result.ConfigHash == a.lastConfigHash && result.ConfigHash != "" {
return nil
}
logger.Info("New xray config received, applying...")
if err := a.applyXrayConfig(result.XrayConfig); err != nil {
return fmt.Errorf("failed to apply xray config: %w", err)
}
a.lastConfigHash = result.ConfigHash
logger.Info("Xray config applied successfully")
return nil
}
// ensureXrayBinary makes sure the xray binary exists at the panel-layout path.
// The install doc puts it at /usr/local/x-ui/bin/xray; the panel's process
// manager looks for the arch-specific name, so link one to the other.
func ensureXrayBinary() error {
binPath := xray.GetBinaryPath()
if _, err := os.Stat(binPath); err == nil {
return nil
}
plain := filepath.Join(config.GetBinFolderPath(), "xray")
if _, err := os.Stat(plain); err != nil {
return fmt.Errorf("xray binary not found at %s or %s", plain, binPath)
}
// Best effort: link the arch-specific name the process manager expects.
if err := os.Symlink(plain, binPath); err != nil && !os.IsExist(err) {
return fmt.Errorf("failed to link xray binary: %w", err)
}
return nil
}
// applyXrayConfig writes the xray config and (re)starts xray through the
// panel's process manager.
func (a *Agent) applyXrayConfig(configJSON json.RawMessage) error {
if err := ensureXrayBinary(); err != nil {
return err
}
var cfg xray.Config
if err := json.Unmarshal(configJSON, &cfg); err != nil {
return fmt.Errorf("invalid xray config: %w", err)
}
// Same-host override: rewrite the stats API inbound port so the agent's
// xray doesn't fight the master's xray over the default port.
if a.config.ApiPort > 0 {
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Tag == "api" {
logger.Infof("Overriding xray API port %d -> %d (agent config)",
cfg.InboundConfigs[i].Port, a.config.ApiPort)
cfg.InboundConfigs[i].Port = a.config.ApiPort
}
}
// Also move the prometheus metrics listener (default 127.0.0.1:11111)
// which would collide with the master's xray on the same host.
if len(cfg.Metrics) > 0 {
var m map[string]any
if err := json.Unmarshal(cfg.Metrics, &m); err == nil {
if ls, ok := m["listen"].(string); ok && ls != "" {
newListen := fmt.Sprintf("127.0.0.1:%d", a.config.ApiPort+1)
logger.Infof("Overriding xray metrics listen %s -> %s (agent config)", ls, newListen)
m["listen"] = newListen
if raw, err := json.Marshal(m); err == nil {
cfg.Metrics = raw
}
}
}
}
}
a.apiPort = apiPortFromConfig(&cfg)
a.stopXray()
// Use a dedicated config file so the agent's xray doesn't fight the
// master's xray over the shared config.json when both run on one host.
agentConfigPath := config.GetBinFolderPath() + "/config-agent.json"
proc := xray.NewTestProcess(&cfg, agentConfigPath)
if err := proc.Start(); err != nil {
return fmt.Errorf("failed to start xray: %w", err)
}
a.xrayProc = proc
return nil
}
func (a *Agent) stopXray() {
if a.xrayProc != nil {
_ = a.xrayProc.Stop()
a.xrayProc = nil
}
}
// apiPortFromConfig extracts the stats API port (the inbound tagged "api").
func apiPortFromConfig(cfg *xray.Config) int {
for _, inbound := range cfg.InboundConfigs {
if inbound.Tag == "api" {
return inbound.Port
}
}
return 0
}
type trafficReport struct {
Inbounds []trafficEntry `json:"inbounds"`
Users []userEntry `json:"users"`
Onlines []string `json:"onlines"`
}
type trafficEntry struct {
Tag string `json:"tag"`
Up int64 `json:"up"`
Down int64 `json:"down"`
}
type userEntry struct {
Email string `json:"email"`
Up int64 `json:"up"`
Down int64 `json:"down"`
}
// statsClient is the slice of the xray stats API the traffic reporter
// uses. The snapshot/commit pair is the loss-proofing contract: snapshots
// never advance the baseline, commits do.
type statsClient interface {
GetTrafficSnapshot() (*xray.TrafficSnapshot, error)
CommitTrafficSnapshot(*xray.TrafficSnapshot)
RestoreTrafficBaseline(map[string]int64)
GetOnlineUsers() ([]xray.OnlineUser, error)
}
// trafficBaselineFile is the on-disk form of the last committed baseline.
type trafficBaselineFile struct {
Version int `json:"version"`
Values map[string]int64 `json:"values"`
}
// baselinePath resolves where the acknowledged traffic baseline lives.
func (a *Agent) baselinePath() string {
if a.config.BaselinePath != "" {
return a.config.BaselinePath
}
return filepath.Join(config.GetBinFolderPath(), "agent-traffic-baseline.json")
}
// loadTrafficBaseline reads a persisted baseline. A missing file is a
// normal first run; a corrupt one is reported so the caller can log it —
// both yield no values and the reporter falls back to a baseline-only
// first snapshot rather than crashing the agent.
func loadTrafficBaseline(path string) (map[string]int64, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var f trafficBaselineFile
if err := json.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parse traffic baseline: %w", err)
}
return f.Values, nil
}
// saveTrafficBaseline persists the baseline atomically (write-then-rename)
// so a crash mid-write can never leave a torn file behind.
func saveTrafficBaseline(path string, values map[string]int64) error {
data, err := json.Marshal(trafficBaselineFile{Version: 1, Values: values})
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// buildTrafficReport shapes a snapshot plus the freshly polled online
// users into the wire report, dropping zero-traffic entries. Onlines are
// point-in-time data: they ride along with each report and are never part
// of the baseline bookkeeping.
func buildTrafficReport(snap *xray.TrafficSnapshot, onlineUsers []xray.OnlineUser) trafficReport {
report := trafficReport{}
for _, t := range snap.Traffics {
if t == nil || !t.IsInbound || t.Up+t.Down <= 0 {
continue
}
report.Inbounds = append(report.Inbounds, trafficEntry{Tag: t.Tag, Up: t.Up, Down: t.Down})
}
for _, ct := range snap.ClientTraffics {
if ct == nil || ct.Email == "" || ct.Up+ct.Down <= 0 {
continue
}
report.Users = append(report.Users, userEntry{Email: ct.Email, Up: ct.Up, Down: ct.Down})
}
for _, u := range onlineUsers {
report.Onlines = append(report.Onlines, u.Email)
}
return report
}
func (r trafficReport) empty() bool {
return len(r.Inbounds) == 0 && len(r.Users) == 0 && len(r.Onlines) == 0
}
// postTrafficReport sends one report to the master; only an HTTP 200
// counts as acknowledged.
func (a *Agent) postTrafficReport(report trafficReport) error {
url := fmt.Sprintf("%s/panel/api/agent/traffic", a.config.MasterURL)
body, _ := json.Marshal(report)
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.config.Token)
resp, err := a.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("traffic report failed with status %d", resp.StatusCode)
}
return nil
}
// reportTrafficWith runs one snapshot → report → commit cycle.
//
// The committed baseline advances ONLY after the master acknowledges the
// report (HTTP 200). Core counters are cumulative, so when a report fails
// nothing is lost: the next snapshot diffs against the same baseline and
// re-includes everything unreported, exactly once. The acknowledged
// baseline is persisted after every commit, and restored before the first
// snapshot of a restarted agent, so an agent restart cannot swallow the
// unreported tail into a fresh baseline either.
//
// A poll with nothing to send still commits: it establishes the very
// first baseline, and keeps the persisted copy fresh.
func (a *Agent) reportTrafficWith(sc statsClient, post func(trafficReport) error) error {
if !a.baselineLoaded {
a.baselineLoaded = true
if values, err := loadTrafficBaseline(a.baselinePath()); err != nil {
if !os.IsNotExist(err) {
logger.Warningf("Traffic baseline at %s unusable (%v); starting from a fresh baseline", a.baselinePath(), err)
}
} else if len(values) > 0 {
sc.RestoreTrafficBaseline(values)
}
}
snap, err := sc.GetTrafficSnapshot()
if err != nil {
return err
}
onlineUsers, _ := sc.GetOnlineUsers()
report := buildTrafficReport(snap, onlineUsers)
if !report.empty() {
if err := post(report); err != nil {
// No commit: the baseline stays where the master last
// acknowledged it, and these deltas are reported again
// (grown) on the next poll.
return err
}
}
sc.CommitTrafficSnapshot(snap)
if err := saveTrafficBaseline(a.baselinePath(), snap.BaselineValues()); err != nil {
// The in-memory commit stands — rolling it back would re-report
// traffic the master already counted. The stale file self-heals
// on the next successful poll; only a crash before that could
// re-report a single interval.
logger.Warningf("Failed to persist traffic baseline: %v", err)
return err
}
return nil
}
// collectAndReportTraffic snapshots the local xray counters and reports
// the deltas since the last master-acknowledged baseline. The XrayAPI
// instance is kept across polls so baselines survive; xray restarts are
// handled (counters resetting to zero are treated as fresh).
func (a *Agent) collectAndReportTraffic() error {
if a.xrayProc == nil || !a.xrayProc.IsRunning() {
return nil
}
if a.apiPort <= 0 {
return nil
}
if err := a.xrayAPI.Init(a.apiPort); err != nil {
return err
}
defer a.xrayAPI.Close()
return a.reportTrafficWith(&a.xrayAPI, a.postTrafficReport)
}
// LoadConfig loads agent config from file
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
+322
View File
@@ -0,0 +1,322 @@
package agent
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// fakeStatsClient implements the snapshot/commit contract over scripted
// cumulative counters: snapshots diff against the committed baseline with
// the same per-stat rule as the real client (unseen or moved-backwards
// counters count from zero), and only Commit moves the baseline. The
// delta math itself is proven against the wire-level fake in the xray
// package; here the fake exists so the agent's orchestration — commit
// only after a 200, persist, restore after restart — can be driven
// exactly.
type fakeStatsClient struct {
counters map[string]int64
committed map[string]int64
ready bool
}
func newFakeStatsClient() *fakeStatsClient {
return &fakeStatsClient{counters: map[string]int64{}}
}
func (f *fakeStatsClient) GetTrafficSnapshot() (*xray.TrafficSnapshot, error) {
snap := &xray.TrafficSnapshot{Values: map[string]int64{}}
clients := map[string]*xray.ClientTraffic{}
for name, value := range f.counters {
snap.Values[name] = value
if !f.ready {
continue
}
last, ok := f.committed[name]
if !ok || value < last {
last = 0
}
delta := value - last
parts := strings.Split(name, ">>>")
if len(parts) != 4 || parts[0] != "user" {
continue
}
ct, ok := clients[parts[1]]
if !ok {
ct = &xray.ClientTraffic{Email: parts[1]}
clients[parts[1]] = ct
}
if parts[3] == "downlink" {
ct.Down = delta
} else {
ct.Up = delta
}
}
for _, ct := range clients {
snap.ClientTraffics = append(snap.ClientTraffics, ct)
}
return snap, nil
}
func (f *fakeStatsClient) CommitTrafficSnapshot(s *xray.TrafficSnapshot) {
f.committed = s.BaselineValues()
f.ready = true
}
func (f *fakeStatsClient) RestoreTrafficBaseline(values map[string]int64) {
if len(values) == 0 {
return
}
f.committed = map[string]int64{}
for k, v := range values {
f.committed[k] = v
}
f.ready = true
}
func (f *fakeStatsClient) GetOnlineUsers() ([]xray.OnlineUser, error) { return nil, nil }
// fakeMaster is an HTTP master whose /panel/api/agent/traffic endpoint
// fails (500) for the first failTimes requests, then acknowledges and
// records everything it receives.
type fakeMaster struct {
srv *httptest.Server
mu sync.Mutex
failTimes int
posts int
userUp map[string]int64
userDown map[string]int64
}
func newFakeMaster(t *testing.T, failTimes int) *fakeMaster {
t.Helper()
m := &fakeMaster{failTimes: failTimes, userUp: map[string]int64{}, userDown: map[string]int64{}}
m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
m.posts++
if m.posts <= m.failTimes {
w.WriteHeader(http.StatusInternalServerError)
return
}
var report trafficReport
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
for _, u := range report.Users {
m.userUp[u.Email] += u.Up
m.userDown[u.Email] += u.Down
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(m.srv.Close)
return m
}
func newTestAgent(t *testing.T, masterURL string) *Agent {
t.Helper()
return New(&Config{
MasterURL: masterURL,
Token: "test-token",
NodeName: "test-node",
BaselinePath: filepath.Join(t.TempDir(), "agent-traffic-baseline.json"),
})
}
const aliceUp = "user>>>alice>>>traffic>>>uplink"
// TestReportOutageThenRecoveryConservesTotals is the acceptance scenario
// for the fix: the master is down for several polls while traffic accrues,
// then recovers. Every byte the core counted must reach the master exactly
// once — the recovery report carries the whole outage window, and later
// polls carry only new traffic.
func TestReportOutageThenRecoveryConservesTotals(t *testing.T) {
master := newFakeMaster(t, 2) // first two posts fail with 500
a := newTestAgent(t, master.srv.URL)
fake := newFakeStatsClient()
fake.counters[aliceUp] = 1000 // pre-existing, unattributable
// Poll 1 establishes the baseline: nothing is posted, the baseline
// file appears with the raw counter.
if err := a.reportTrafficWith(fake, a.postTrafficReport); err != nil {
t.Fatalf("baseline poll: %v", err)
}
if master.posts != 0 {
t.Fatalf("baseline poll posted %d times, want 0", master.posts)
}
values, err := loadTrafficBaseline(a.baselinePath())
if err != nil || values[aliceUp] != 1000 {
t.Fatalf("persisted baseline = %v (err %v), want alice at 1000", values, err)
}
// Outage: two polls accrue traffic but the master 500s.
fake.counters[aliceUp] = 1400
if err := a.reportTrafficWith(fake, a.postTrafficReport); err == nil {
t.Fatal("poll during outage returned nil, want the post error")
}
fake.counters[aliceUp] = 2000
if err := a.reportTrafficWith(fake, a.postTrafficReport); err == nil {
t.Fatal("second outage poll returned nil, want the post error")
}
// The failures advanced nothing: the persisted baseline still holds
// the last acknowledged counters.
values, err = loadTrafficBaseline(a.baselinePath())
if err != nil || values[aliceUp] != 1000 {
t.Fatalf("baseline after failed polls = %v (err %v), want still 1000", values, err)
}
// Recovery: one post carries the entire outage window (2000-1000).
if err := a.reportTrafficWith(fake, a.postTrafficReport); err != nil {
t.Fatalf("recovery poll: %v", err)
}
if got := master.userUp["alice"]; got != 1000 {
t.Fatalf("master received %d for alice after recovery, want exactly 1000", got)
}
// Steady state resumes: only fresh growth is reported.
fake.counters[aliceUp] = 2100
if err := a.reportTrafficWith(fake, a.postTrafficReport); err != nil {
t.Fatalf("steady poll: %v", err)
}
if got := master.userUp["alice"]; got != 1100 {
t.Fatalf("master total = %d, want 1100 (= 2100 counted - 1000 baseline)", got)
}
}
// TestAgentRestartRestoresBaselineFromDisk: a new agent process (fresh
// in-memory state everywhere) over the same baseline file must pick up
// diffing exactly where the old one stopped — the traffic that accrued
// while it was down is reported by its FIRST poll, and nothing is counted
// twice.
func TestAgentRestartRestoresBaselineFromDisk(t *testing.T) {
master := newFakeMaster(t, 0)
baselinePath := filepath.Join(t.TempDir(), "agent-traffic-baseline.json")
newAgent := func() *Agent {
return New(&Config{MasterURL: master.srv.URL, Token: "t", NodeName: "n", BaselinePath: baselinePath})
}
a1 := newAgent()
fake := newFakeStatsClient()
fake.counters[aliceUp] = 1000
if err := a1.reportTrafficWith(fake, a1.postTrafficReport); err != nil {
t.Fatalf("a1 baseline poll: %v", err)
}
fake.counters[aliceUp] = 1500
if err := a1.reportTrafficWith(fake, a1.postTrafficReport); err != nil {
t.Fatalf("a1 report poll: %v", err)
}
if got := master.userUp["alice"]; got != 500 {
t.Fatalf("master received %d before restart, want 500", got)
}
// "Restart": brand-new agent AND a brand-new stats client (its
// in-memory committed baseline is gone); only the file survives. The
// core kept counting meanwhile.
a2 := newAgent()
fake2 := newFakeStatsClient()
fake2.counters[aliceUp] = 1900
if err := a2.reportTrafficWith(fake2, a2.postTrafficReport); err != nil {
t.Fatalf("a2 first poll: %v", err)
}
if got := master.userUp["alice"]; got != 900 {
t.Fatalf("master total = %d, want 900 (500 + the 400 accrued across the restart)", got)
}
}
// TestCounterResetAfterCoreRestart: the core restarts while the agent
// keeps running; its counters fall below the committed baseline. The
// report must carry the new counter as fresh traffic — never a negative
// delta — and then resume normal diffing in the new epoch.
func TestCounterResetAfterCoreRestart(t *testing.T) {
master := newFakeMaster(t, 0)
a := newTestAgent(t, master.srv.URL)
fake := newFakeStatsClient()
fake.counters[aliceUp] = 5000
if err := a.reportTrafficWith(fake, a.postTrafficReport); err != nil {
t.Fatalf("baseline poll: %v", err)
}
fake.counters[aliceUp] = 300 // core restarted; counters began again
if err := a.reportTrafficWith(fake, a.postTrafficReport); err != nil {
t.Fatalf("post-reset poll: %v", err)
}
if got := master.userUp["alice"]; got != 300 {
t.Fatalf("master received %d after core restart, want the fresh 300", got)
}
fake.counters[aliceUp] = 450
if err := a.reportTrafficWith(fake, a.postTrafficReport); err != nil {
t.Fatalf("new-epoch poll: %v", err)
}
if got := master.userUp["alice"]; got != 450 {
t.Fatalf("master total = %d, want 450 (300 + 150 of new-epoch growth)", got)
}
}
// TestCorruptBaselineFileTolerated: a torn or foreign baseline file must
// not wedge the agent — it logs and falls back to a baseline-only first
// snapshot, then reports normally.
func TestCorruptBaselineFileTolerated(t *testing.T) {
master := newFakeMaster(t, 0)
a := newTestAgent(t, master.srv.URL)
if err := os.WriteFile(a.baselinePath(), []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
fake := newFakeStatsClient()
fake.counters[aliceUp] = 800
if err := a.reportTrafficWith(fake, a.postTrafficReport); err != nil {
t.Fatalf("poll with corrupt baseline: %v", err)
}
if master.posts != 0 {
t.Fatalf("corrupt-baseline first poll posted %d times, want baseline-only (0)", master.posts)
}
fake.counters[aliceUp] = 900
if err := a.reportTrafficWith(fake, a.postTrafficReport); err != nil {
t.Fatalf("poll after corrupt baseline: %v", err)
}
if got := master.userUp["alice"]; got != 100 {
t.Fatalf("master received %d, want only the post-baseline 100", got)
}
}
// TestSaveLoadBaselineRoundTrip pins the persistence helpers: atomic save,
// version field, exact values back.
func TestSaveLoadBaselineRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "baseline.json")
want := map[string]int64{aliceUp: 42, "inbound>>>in-443>>>traffic>>>downlink": 7}
if err := saveTrafficBaseline(path, want); err != nil {
t.Fatalf("save: %v", err)
}
got, err := loadTrafficBaseline(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(got) != len(want) || got[aliceUp] != 42 || got["inbound>>>in-443>>>traffic>>>downlink"] != 7 {
t.Fatalf("round trip = %v, want %v", got, want)
}
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Fatal("temp file left behind after atomic save")
}
if _, err := loadTrafficBaseline(filepath.Join(t.TempDir(), "missing.json")); !os.IsNotExist(err) {
t.Fatalf("missing file error = %v, want a not-exist error", err)
}
var parseErr error
_, parseErr = loadTrafficBaseline(path)
if parseErr != nil {
t.Fatalf("valid file failed to load: %v", parseErr)
}
bad := filepath.Join(t.TempDir(), "bad.json")
_ = os.WriteFile(bad, []byte("[]"), 0o600)
if _, err := loadTrafficBaseline(bad); err == nil || errors.Is(err, os.ErrNotExist) {
t.Fatalf("corrupt file error = %v, want a parse error", err)
}
}
+1 -82
View File
@@ -3,7 +3,6 @@ package database
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
@@ -26,7 +25,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/mattn/go-sqlite3"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@@ -85,6 +83,7 @@ func allModels() []any {
&model.ClientGlobalTraffic{},
&model.OutboundSubscription{},
&model.SubBalancer{},
&model.AgentNode{},
}
}
@@ -2280,86 +2279,6 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
return bytes.Equal(buf, signature), nil
}
func BackupSQLite(dstPath string) (err error) {
if IsPostgres() {
return errors.New("sqlite backup is unavailable for PostgreSQL")
}
if db == nil {
return errors.New("database is not initialized")
}
if _, err := os.Lstat(dstPath); err == nil {
return fmt.Errorf("sqlite backup destination already exists: %s", dstPath)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
defer func() {
if err != nil {
_ = os.Remove(dstPath)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), backupSQLiteTimeout)
defer cancel()
sourceDB, err := db.DB()
if err != nil {
return err
}
sourceConn, err := sourceDB.Conn(ctx)
if err != nil {
return err
}
defer sourceConn.Close()
destinationDB, err := sql.Open("sqlite3", dstPath)
if err != nil {
return err
}
defer destinationDB.Close()
destinationConn, err := destinationDB.Conn(ctx)
if err != nil {
return err
}
defer destinationConn.Close()
return sourceConn.Raw(func(sourceDriver any) error {
source, ok := sourceDriver.(*sqlite3.SQLiteConn)
if !ok {
return fmt.Errorf("unexpected SQLite source connection type %T", sourceDriver)
}
return destinationConn.Raw(func(destinationDriver any) error {
destination, ok := destinationDriver.(*sqlite3.SQLiteConn)
if !ok {
return fmt.Errorf("unexpected SQLite destination connection type %T", destinationDriver)
}
backup, err := destination.Backup("main", source, "main")
if err != nil {
return err
}
finished := false
defer func() {
if !finished {
_ = backup.Finish()
}
}()
for {
done, err := backup.Step(backupSQLiteStepPages())
if err != nil {
return err
}
if done {
finished = true
return backup.Finish()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
})
})
}
func ValidateSQLiteDB(dbPath string) error {
if _, err := os.Stat(dbPath); err != nil {
+96
View File
@@ -0,0 +1,96 @@
//go:build cgo
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"time"
"github.com/mattn/go-sqlite3"
)
func BackupSQLite(dstPath string) (err error) {
if IsPostgres() {
return errors.New("sqlite backup is unavailable for PostgreSQL")
}
if db == nil {
return errors.New("database is not initialized")
}
if _, err := os.Lstat(dstPath); err == nil {
return fmt.Errorf("sqlite backup destination already exists: %s", dstPath)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
defer func() {
if err != nil {
_ = os.Remove(dstPath)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), backupSQLiteTimeout)
defer cancel()
sourceDB, err := db.DB()
if err != nil {
return err
}
sourceConn, err := sourceDB.Conn(ctx)
if err != nil {
return err
}
defer sourceConn.Close()
destinationDB, err := sql.Open("sqlite3", dstPath)
if err != nil {
return err
}
defer destinationDB.Close()
destinationConn, err := destinationDB.Conn(ctx)
if err != nil {
return err
}
defer destinationConn.Close()
return sourceConn.Raw(func(sourceDriver any) error {
source, ok := sourceDriver.(*sqlite3.SQLiteConn)
if !ok {
return fmt.Errorf("unexpected SQLite source connection type %T", sourceDriver)
}
return destinationConn.Raw(func(destinationDriver any) error {
destination, ok := destinationDriver.(*sqlite3.SQLiteConn)
if !ok {
return fmt.Errorf("unexpected SQLite destination connection type %T", destinationDriver)
}
backup, err := destination.Backup("main", source, "main")
if err != nil {
return err
}
finished := false
defer func() {
if !finished {
_ = backup.Finish()
}
}()
for {
done, err := backup.Step(backupSQLiteStepPages())
if err != nil {
return err
}
if done {
finished = true
return backup.Finish()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
})
})
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !cgo
package database
import "errors"
func BackupSQLite(dstPath string) error {
return errors.New("sqlite backup requires CGO build")
}
+1
View File
@@ -58,6 +58,7 @@ func migrationModels() []any {
&model.ClientGlobalTraffic{},
&model.OutboundSubscription{},
&model.SubBalancer{},
&model.AgentNode{},
}
}
+25
View File
@@ -0,0 +1,25 @@
package model
// AgentNode binds a lightweight agent (token-registered, no web panel) to a
// 3x-ui node row and carries the per-agent egress configuration.
//
// Agents are the only writers of their own LastSeen; the panel owns everything
// else. Raw tokens are never stored — only the SHA-256 hex of the token.
type AgentNode struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
TokenHash string `json:"-" gorm:"size:64;uniqueIndex"`
TokenPrefix string `json:"tokenPrefix" gorm:"size:16;index"`
Name string `json:"name"`
// NodeID is the 3x-ui node whose inbounds this agent serves. Nil means the
// token was issued but not yet bound to a node.
NodeID *int `json:"nodeId" gorm:"index"`
// Outbounds is the full xray "outbounds" JSON array pushed to this agent,
// replacing the template defaults. Empty means "use template defaults".
Outbounds string `json:"outbounds" gorm:"type:text"`
// RoutingRules is the full xray "routing.rules" JSON array pushed to this
// agent, replacing the template defaults. Empty means "use template defaults".
RoutingRules string `json:"routingRules" gorm:"type:text"`
LastSeen int64 `json:"lastSeen"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
}
+2 -356
View File
@@ -1,26 +1,22 @@
package sub
import (
"errors"
"fmt"
"maps"
"strings"
"github.com/goccy/go-json"
yaml "github.com/goccy/go-yaml"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
type SubClashService struct {
enableRouting bool
clashRules string
SubService *SubService
}
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
func NewSubClashService(subService *SubService) *SubClashService {
return &SubClashService{SubService: subService}
}
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
@@ -98,19 +94,6 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
"rules": []string{"MATCH,PROXY"},
}
if s.enableRouting {
resolved, remoteDocument, remote, resolveErr := resolveClashRoutingSource(s.clashRules)
if resolveErr == nil && strings.TrimSpace(resolved) != "" {
if remote {
if err := mergeRemoteClashRules(config, remoteDocument); err != nil {
return "", "", err
}
} else if err := mergeClashRulesYAML(config, resolved); err != nil {
return "", "", err
}
}
}
finalYAML, err := marshalClashYAML(config)
if err != nil {
return "", "", err
@@ -789,340 +772,3 @@ func cloneMap(src map[string]any) map[string]any {
maps.Copy(dst, src)
return dst
}
func mergeClashRulesYAML(base map[string]any, raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var custom any
if err := yaml.Unmarshal([]byte(raw), &custom); err != nil {
mergeClashRules(base, linesToClashRules(raw))
return nil
}
switch typed := custom.(type) {
case []any:
mergeClashRules(base, typed)
case map[string]any:
for key, value := range typed {
if key == "rules" {
if ruleList, ok := asAnySlice(value); ok {
mergeClashRules(base, ruleList)
}
continue
}
base[key] = value
}
default:
mergeClashRules(base, linesToClashRules(raw))
}
return nil
}
// mergeRemoteClashRules lets remote update only the route graph (see
// remoteClashAllowedKey) and never mutates remote: cached documents are shared.
func mergeRemoteClashRules(base map[string]any, remote map[string]any) error {
if len(remote) == 0 {
return fmt.Errorf("remote Clash routing source must be a YAML map")
}
for key, value := range remote {
if !remoteClashAllowedKey(key) {
continue
}
if err := validateRemoteClashValue(key, value); err != nil {
return err
}
switch key {
case "rules":
rules, _ := asAnySlice(value)
mergeClashRules(base, rules)
case "proxy-groups":
groups, _ := asAnySlice(value)
base["proxy-groups"] = mergeClashProxyGroups(base["proxy-groups"], groups)
default:
base[key] = value
}
}
return validateClashRouteGraph(base)
}
func validateRemoteClashValue(key string, value any) error {
switch key {
case "rules":
rules, ok := asAnySlice(value)
if !ok {
return fmt.Errorf("remote Clash rules must be a list")
}
for _, rule := range rules {
text, ok := rule.(string)
if !ok || strings.TrimSpace(text) == "" {
return fmt.Errorf("remote Clash rules must contain non-empty strings")
}
}
case "proxy-groups":
groups, ok := asAnySlice(value)
if !ok {
return fmt.Errorf("remote Clash proxy-groups must be a list")
}
seen := make(map[string]struct{}, len(groups))
for _, groupValue := range groups {
group, ok := groupValue.(map[string]any)
if !ok {
return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type")
}
name, nameOK := group["name"].(string)
groupType, typeOK := group["type"].(string)
if !nameOK || !typeOK || strings.TrimSpace(name) == "" || strings.TrimSpace(groupType) == "" {
return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type")
}
name = strings.TrimSpace(name)
if _, duplicate := seen[name]; duplicate {
return fmt.Errorf("remote Clash proxy-group name %q is duplicated", name)
}
seen[name] = struct{}{}
if useValue, exists := group["use"]; exists {
use, ok := asAnySlice(useValue)
if !ok || len(use) > 0 {
return fmt.Errorf("remote Clash proxy-group %q cannot use proxy-providers", name)
}
}
}
case "rule-providers":
providers, ok := value.(map[string]any)
if !ok {
return fmt.Errorf("remote Clash rule-providers must be a map")
}
for name, provider := range providers {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("remote Clash rule-provider name must not be empty")
}
if _, ok := provider.(map[string]any); !ok {
return fmt.Errorf("remote Clash rule-provider %q must be a map", name)
}
}
}
return nil
}
func remoteClashAllowedKey(key string) bool {
switch key {
case "proxy-groups", "rule-providers", "rules":
return true
default:
return false
}
}
func validateClashRouteGraph(config map[string]any) error {
known := map[string]struct{}{
"DIRECT": {}, "REJECT": {}, "REJECT-DROP": {}, "REJECT-TINYGIF": {}, "PASS": {}, "GLOBAL": {},
}
if proxies, ok := asAnySlice(config["proxies"]); ok {
for _, value := range proxies {
proxy, ok := value.(map[string]any)
if !ok {
continue
}
if name, ok := proxy["name"].(string); ok && strings.TrimSpace(name) != "" {
known[strings.TrimSpace(name)] = struct{}{}
}
}
}
groups, _ := asAnySlice(config["proxy-groups"])
for _, value := range groups {
if name := clashProxyGroupName(value); name != "" {
known[name] = struct{}{}
}
}
for _, value := range groups {
group, ok := value.(map[string]any)
if !ok {
continue
}
name := clashProxyGroupName(group)
refs, exists := group["proxies"]
if !exists {
continue
}
proxies, ok := asAnySlice(refs)
if !ok {
return fmt.Errorf("Clash proxy-group %q proxies must be a list", name)
}
for _, refValue := range proxies {
ref, ok := refValue.(string)
if !ok || strings.TrimSpace(ref) == "" {
return fmt.Errorf("Clash proxy-group %q contains an invalid proxy reference", name)
}
ref = strings.TrimSpace(ref)
if _, exists := known[ref]; !exists {
return fmt.Errorf("Clash proxy-group %q references unknown proxy or group %q", name, ref)
}
}
}
providers, _ := config["rule-providers"].(map[string]any)
for providerName, value := range providers {
provider, ok := value.(map[string]any)
if !ok {
continue
}
via, ok := provider["proxy"].(string)
if !ok || strings.TrimSpace(via) == "" {
continue
}
via = strings.TrimSpace(via)
if _, exists := known[via]; !exists {
return fmt.Errorf("Clash rule-provider %q references unknown proxy or group %q", providerName, via)
}
}
rules, _ := asAnySlice(config["rules"])
for _, value := range rules {
rule, ok := value.(string)
if !ok || strings.TrimSpace(rule) == "" {
return errors.New("Clash rules must contain non-empty strings")
}
parts := strings.Split(rule, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
if len(parts) < 2 {
return fmt.Errorf("invalid Clash rule %q", rule)
}
if strings.EqualFold(parts[0], "RULE-SET") {
if len(parts) < 3 {
return fmt.Errorf("invalid Clash RULE-SET rule %q", rule)
}
if _, exists := providers[parts[1]]; !exists {
return fmt.Errorf("Clash rule references unknown rule-provider %q", parts[1])
}
}
targetIndex := len(parts) - 1
// Mihomo IP rules may carry trailing no-resolve / src option flags.
for targetIndex >= 1 && (strings.EqualFold(parts[targetIndex], "no-resolve") || strings.EqualFold(parts[targetIndex], "src")) {
targetIndex--
}
if targetIndex < 1 {
return fmt.Errorf("invalid Clash rule target in %q", rule)
}
target := parts[targetIndex]
if _, exists := known[target]; !exists {
return fmt.Errorf("Clash rule references unknown proxy or group %q", target)
}
}
return nil
}
func mergeClashProxyGroups(baseValue any, remoteGroups []any) []any {
baseGroups, _ := asAnySlice(baseValue)
baseByName := make(map[string]any, len(baseGroups))
baseOrder := make([]string, 0, len(baseGroups))
for _, group := range baseGroups {
name := clashProxyGroupName(group)
if name == "" {
continue
}
baseByName[name] = group
baseOrder = append(baseOrder, name)
}
merged := make([]any, 0, len(remoteGroups)+len(baseGroups))
seen := make(map[string]struct{}, len(remoteGroups)+len(baseGroups))
for _, group := range remoteGroups {
name := clashProxyGroupName(group)
if name == "" {
continue
}
if _, duplicate := seen[name]; duplicate {
continue
}
seen[name] = struct{}{}
merged = append(merged, group)
}
for _, name := range baseOrder {
if _, replaced := seen[name]; replaced {
continue
}
merged = append(merged, baseByName[name])
}
return merged
}
func clashProxyGroupName(value any) string {
group, ok := value.(map[string]any)
if !ok {
return ""
}
name, _ := group["name"].(string)
return strings.TrimSpace(name)
}
func mergeClashRules(base map[string]any, customRules []any) {
if len(customRules) == 0 {
return
}
baseRules, _ := asAnySlice(base["rules"])
if hasClashMatchRule(customRules) {
base["rules"] = customRules
return
}
merged := make([]any, 0, len(customRules)+len(baseRules))
merged = append(merged, customRules...)
merged = append(merged, baseRules...)
base["rules"] = merged
}
func asAnySlice(value any) ([]any, bool) {
switch typed := value.(type) {
case []any:
return typed, true
case []string:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, item)
}
return out, true
case []map[string]any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, item)
}
return out, true
default:
return nil, false
}
}
func hasClashMatchRule(rules []any) bool {
for _, rule := range rules {
ruleText, ok := rule.(string)
if !ok {
continue
}
parts := strings.SplitN(ruleText, ",", 2)
if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") {
return true
}
}
return false
}
func linesToClashRules(raw string) []any {
lines := strings.Split(raw, "\n")
rules := make([]any, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
rules = append(rules, line)
}
return rules
}
+5 -70
View File
@@ -49,13 +49,8 @@ type SUBController struct {
subSupportUrl string
subProfileUrl string
subAnnounce string
subEnableRouting bool
subRoutingRules string
subHideSettings bool
subIncyEnableRouting bool
subIncyRoutingRules string
subPath string
subJsonPath string
subClashPath string
@@ -97,22 +92,14 @@ type subControllerConfig struct {
updateInterval string
subJsonMux string
subJsonRules string
subJsonFinalMask string
subJsonObservatory string
subClashEnableRouting bool
subClashRules string
subTitle string
subSupportURL string
subProfileURL string
subAnnounce string
subEnableRouting bool
subRoutingRules string
subHideSettings bool
subIncyEnableRouting bool
subIncyRoutingRules string
}
type SUBControllerOption func(*subControllerConfig)
@@ -173,10 +160,6 @@ func WithSUBJsonMux(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonMux = value }
}
func WithSUBJsonRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonRules = value }
}
func WithSUBJsonFinalMask(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonFinalMask = value }
}
@@ -185,14 +168,6 @@ func WithSUBJsonObservatory(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonObservatory = value }
}
func WithSUBClashEnableRouting(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashEnableRouting = value }
}
func WithSUBClashRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashRules = value }
}
func WithSUBTitle(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subTitle = value }
}
@@ -209,26 +184,10 @@ func WithSUBAnnounce(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subAnnounce = value }
}
func WithSUBEnableRouting(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subEnableRouting = value }
}
func WithSUBRoutingRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subRoutingRules = value }
}
func WithSUBHideSettings(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subHideSettings = value }
}
func WithSUBIncyEnableRouting(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subIncyEnableRouting = value }
}
func WithSUBIncyRoutingRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subIncyRoutingRules = value }
}
func defaultSUBControllerConfig() subControllerConfig {
return subControllerConfig{
subPath: "/sub/",
@@ -248,20 +207,15 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
}
sub := NewSubService(config.remarkTemplate)
subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub)
subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonFinalMask, sub)
subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
a := &SUBController{
subTitle: config.subTitle,
subSupportUrl: config.subSupportURL,
subProfileUrl: config.subProfileURL,
subAnnounce: config.subAnnounce,
subEnableRouting: config.subEnableRouting,
subRoutingRules: config.subRoutingRules,
subHideSettings: config.subHideSettings,
subIncyEnableRouting: config.subIncyEnableRouting,
subIncyRoutingRules: config.subIncyRoutingRules,
subPath: config.subPath,
subJsonPath: config.subJsonPath,
subClashPath: config.subClashPath,
@@ -277,7 +231,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
subService: sub,
subJsonService: subJsonSvc,
subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub),
subClashService: NewSubClashService(sub),
subTemplateCache: map[string]*cachedSubTemplate{},
}
@@ -426,15 +380,7 @@ func (a *SUBController) subs(c *gin.Context) {
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
incyRules, _, err := resolveIncyRoutingSource(a.subIncyRoutingRules)
if err == nil && strings.TrimSpace(incyRules) != "" {
result.WriteString(incyRules)
result.WriteString("\n")
}
}
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings)
if a.subEncrypt {
c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String())))
@@ -749,7 +695,7 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
}
return subReq
}, subId, profileURL)
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings)
if rawDownload {
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
}
@@ -797,7 +743,7 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
}
return subReq
}, subId, profileURL)
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings)
if rawDownload {
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
} else if metadata.Title != "" {
@@ -817,8 +763,6 @@ func (a *SUBController) ApplyCommonHeaders(
profileSupportUrl string,
profileUrl string,
profileAnnounce string,
profileEnableRouting bool,
profileRoutingRules string,
profileHideSettings bool,
) {
c.Writer.Header().Set("Subscription-Userinfo", header)
@@ -838,15 +782,6 @@ func (a *SUBController) ApplyCommonHeaders(
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
}
// Advanced (Happ). Routing stays independent of the enable flag; remote
// values come only from the validated cache and never delay this response.
rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
if profileEnableRouting {
c.Writer.Header().Set("Routing-Enable", "true")
}
if (routingErr == nil || !remote) && strings.TrimSpace(rules) != "" {
c.Writer.Header().Set("Routing", rules)
}
if profileHideSettings {
c.Writer.Header().Set("Hide-Settings", "1")
}
+1 -1
View File
@@ -155,7 +155,7 @@ func TestLinkDisplayName(t *testing.T) {
func TestClashProxyFromExternalTrojanReality(t *testing.T) {
link := "trojan://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
svc := NewSubClashService(false, "", NewSubService(""))
svc := NewSubClashService(NewSubService(""))
proxy := svc.clashProxyFromExternal(link, "DE-Provider")
if proxy == nil {
t.Fatal("expected a clash proxy, got nil")
+2 -2
View File
@@ -28,7 +28,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
base := NewSubService("")
jsonService := NewSubJsonService("", "", "", base)
jsonService := NewSubJsonService("", "", base)
jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false)
if err != nil {
t.Fatalf("GetJson err = %v", err)
@@ -56,7 +56,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
t.Fatalf("standards-compliant GetJson profile count = %d, want 1", len(configs))
}
clashOut, _, err := NewSubClashService(false, "", base).GetClash("ext-only", "sub.example.com")
clashOut, _, err := NewSubClashService(base).GetClash("ext-only", "sub.example.com")
if err != nil {
t.Fatalf("GetClash err = %v", err)
}
+6 -6
View File
@@ -261,7 +261,7 @@ func TestSub_HostAllowInsecure(t *testing.T) {
t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n"))
}
clash := NewSubClashService(false, "", NewSubService(""))
clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
@@ -282,7 +282,7 @@ func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
HostHeader: "cdn.example.com", Path: "/ws-path",
})
clash := NewSubClashService(false, "", NewSubService(""))
clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
@@ -294,7 +294,7 @@ func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
t.Fatalf("clash ws-opts should carry the host record's path:\n%s", yaml)
}
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -336,7 +336,7 @@ func TestSub_HostSockoptJSON(t *testing.T) {
InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls",
SockoptParams: `{"tcpFastOpen":true}`,
})
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -354,7 +354,7 @@ func TestSub_HostMuxJSON(t *testing.T) {
InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls",
MuxParams: `{"enabled":true,"concurrency":8}`,
})
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -409,7 +409,7 @@ func TestSub_ExcludeFromSubTypes(t *testing.T) {
t.Fatalf("host not excluded from raw should appear in GetSubs")
}
clash := NewSubClashService(false, "", NewSubService(""))
clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
+3 -3
View File
@@ -51,7 +51,7 @@ func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) {
t.Fatalf("raw link must not carry a flow on ws+tls: %s", joined)
}
clash := NewSubClashService(false, "", NewSubService(""))
clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
@@ -60,7 +60,7 @@ func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) {
t.Fatalf("clash proxy must not carry a flow on ws+tls:\n%s", yaml)
}
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -76,7 +76,7 @@ func TestSub_JSONKeepsFlowOnTcpTLS(t *testing.T) {
seedFlowInbound(t, "s1", "tcpflow", 4602,
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
+1 -11
View File
@@ -34,7 +34,7 @@ type SubJsonService struct {
}
// NewSubJsonService creates a new JSON subscription service with the given configuration.
func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
func NewSubJsonService(mux string, finalMask string, subService *SubService) *SubJsonService {
var configJson map[string]any
var defaultOutbounds []json_util.RawMessage
_ = json.Unmarshal([]byte(defaultJson), &configJson)
@@ -45,16 +45,6 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
}
}
if rules != "" {
var newRules []any
routing, _ := configJson["routing"].(map[string]any)
defaultRules, _ := routing["rules"].([]any)
_ = json.Unmarshal([]byte(rules), &newRules)
defaultRules = append(newRules, defaultRules...)
routing["rules"] = defaultRules
configJson["routing"] = routing
}
return &SubJsonService{
configJson: configJson,
defaultOutbounds: defaultOutbounds,
+18 -18
View File
@@ -37,7 +37,7 @@ func outboundSettings(t *testing.T, raw []byte) map[string]any {
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
svc := NewSubJsonService("", "", finalMask, nil)
svc := NewSubJsonService("", finalMask, nil)
if hasDirectOutOutbound(svc) {
t.Fatal("direct_out outbound must never be emitted")
@@ -74,7 +74,7 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
svc := NewSubJsonService("", "", finalMask, nil)
svc := NewSubJsonService("", finalMask, nil)
stream := svc.streamData(`{
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
@@ -94,7 +94,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
}
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
svc := NewSubJsonService("", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream["finalmask"]; ok {
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
@@ -108,7 +108,7 @@ func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
// the JSON subscription must emit that form, not an array, or v2ray clients fail
// to import the config (#5401).
func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
svc := NewSubJsonService("", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "")
tls, _ := stream["tlsSettings"].(map[string]any)
@@ -121,7 +121,7 @@ func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
}
func TestSubJsonServiceTLSCipherSuitesForwarded(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
svc := NewSubJsonService("", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","cipherSuites":"TLS_AES_256_GCM_SHA384","settings":{}}}`, "")
tls, _ := stream["tlsSettings"].(map[string]any)
@@ -140,7 +140,7 @@ func TestSubJsonServiceVlessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
settings := outboundSettings(t, NewSubJsonService("", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
if _, ok := settings["vnext"]; ok {
t.Fatal("vless outbound must not use vnext")
}
@@ -153,7 +153,7 @@ func TestSubJsonServiceVlessFlowSuppressedByDisableFlow(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`, DisableFlow: true}
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
settings := outboundSettings(t, NewSubJsonService("", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
if _, ok := settings["flow"]; ok {
t.Fatalf("DisableFlow inbound must not carry a flow in the JSON outbound: %#v", settings)
}
@@ -163,7 +163,7 @@ func TestSubJsonServiceVmessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
client := model.Client{ID: "uuid-2"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client, ""))
settings := outboundSettings(t, NewSubJsonService("", "", nil).genVnext(inbound, nil, client, ""))
if _, ok := settings["vnext"]; ok {
t.Fatal("vmess outbound must not use vnext")
}
@@ -179,7 +179,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
client := model.Client{Password: "p4ss"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, trojan, nil, client, ""))
settings := outboundSettings(t, NewSubJsonService("", "", nil).genServer(&SubService{}, trojan, nil, client, ""))
server := firstServer(settings)
if server == nil {
t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
@@ -192,7 +192,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
}
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, ss, nil, client, ""))
ssSettings := outboundSettings(t, NewSubJsonService("", "", nil).genServer(&SubService{}, ss, nil, client, ""))
ssServer := firstServer(ssSettings)
if ssServer == nil {
t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
@@ -204,7 +204,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
globalMux := `{"enabled":true,"concurrency":8}`
svc := NewSubJsonService(globalMux, "", "", nil)
svc := NewSubJsonService(globalMux, "", nil)
// When xmux is present in xhttpSettings, the per-inbound xmux handles
// multiplexing and the legacy outbound.Mux must NOT be set.
@@ -251,7 +251,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
globalMux := `{"enabled":true,"concurrency":8}`
svc := NewSubJsonService(globalMux, "", "", nil)
svc := NewSubJsonService(globalMux, "", nil)
// When no xmux is present, the global subJsonMux should be used.
stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}`
@@ -305,7 +305,7 @@ func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey strin
}
func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
svc := NewSubJsonService("", "", nil)
alice := realitySpiderXFromStream(t, svc, "subAlice")
if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice {
@@ -321,13 +321,13 @@ func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
// security whose settings key is missing or null previously panicked the
// subscription request.
func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
withMask := NewSubJsonService("", `{"tcp":[{"type":"fragment"}]}`, nil)
stream := withMask.streamData("not-json", "clientKey")
if _, ok := stream["finalmask"]; !ok {
t.Fatal("finalMask must still apply when stream settings fail to parse")
}
svc := NewSubJsonService("", "", "", nil)
svc := NewSubJsonService("", "", nil)
noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey")
if v, ok := noReality["realitySettings"]; ok {
t.Fatalf("missing realitySettings must stay absent, got %v", v)
@@ -339,7 +339,7 @@ func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
}
func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
svc := NewSubJsonService("", "", nil)
stream := svc.streamData(`{
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
@@ -384,7 +384,7 @@ func TestSubJsonServiceWireguard(t *testing.T) {
AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"},
}
raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client)
raw := NewSubJsonService("", "", nil).genWireguard(inbound, client)
if raw == nil {
t.Fatal("genWireguard returned nil for a valid wireguard client")
}
@@ -428,7 +428,7 @@ func TestSubJsonServiceWireguardNoKey(t *testing.T) {
inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`}
client := model.Client{Email: "user"}
if raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client); raw != nil {
if raw := NewSubJsonService("", "", nil).genWireguard(inbound, client); raw != nil {
t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw)
}
}
+9 -29
View File
@@ -25,29 +25,9 @@ func initMutDB(t *testing.T) {
t.Cleanup(func() { _ = database.CloseDB() })
}
// --- json_service.go:40 — rules are merged into routing only when non-empty ---
func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]`
svc := NewSubJsonService("", rules, "", nil)
routing, ok := svc.configJson["routing"].(map[string]any)
if !ok {
t.Fatalf("routing missing: %#v", svc.configJson["routing"])
}
got, _ := routing["rules"].([]any)
// default.json ships exactly 1 rule; the custom rule must be prepended.
if len(got) != 2 {
t.Fatalf("rules len = %d, want 2 (custom prepended to default)", len(got))
}
first, _ := got[0].(map[string]any)
if domains, _ := first["domain"].([]any); len(domains) != 1 || domains[0] != "geosite:ads" {
t.Fatalf("custom rule must come first, got %#v", got[0])
}
}
func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
svc := NewSubJsonService("", "", nil)
routing, _ := svc.configJson["routing"].(map[string]any)
got, _ := routing["rules"].([]any)
if len(got) != 1 {
@@ -67,12 +47,12 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
wantMux bool
protocol model.Protocol
}{
{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS},
{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
{"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
{"server no mux", NewSubJsonService("", "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
{"vmess mux", NewSubJsonService(mux, "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS},
{"vless mux", NewSubJsonService(mux, "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
{"server mux", NewSubJsonService(mux, "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
{"vmess no mux", NewSubJsonService("", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
{"vless no mux", NewSubJsonService("", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
{"server no mux", NewSubJsonService("", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -103,7 +83,7 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
// finalMask is non-empty (passes the len(fm)==0 early return) but its only
// key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
// so applyGlobalFinalMask must NOT set finalmask.
svc := NewSubJsonService("", "", `{"tcp":[]}`, nil)
svc := NewSubJsonService("", `{"tcp":[]}`, nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream["finalmask"]; ok {
t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
@@ -111,7 +91,7 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
// Sanity: a finalMask that DOES merge to something still gets set, so the
// guard is the only distinguishing factor.
svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
svc2 := NewSubJsonService("", `{"tcp":[{"type":"fragment"}]}`, nil)
stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream2["finalmask"]; !ok {
t.Fatal("non-empty finalMask must be set")
-623
View File
@@ -1,623 +0,0 @@
package sub
import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
yaml "github.com/goccy/go-yaml"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
)
// Remote sources reuse the existing settings fields (one HTTPS URL = remote,
// else inline) so no second mode toggle can disagree with the field contents.
type remoteRoutingKind string
const (
remoteRoutingHapp remoteRoutingKind = "happ"
remoteRoutingClash remoteRoutingKind = "clash"
remoteRoutingCacheTTL = 10 * time.Minute
remoteRoutingRetryDelay = 30 * time.Second
remoteRoutingHTTPTimeout = 6 * time.Second
remoteRoutingHappMaxBody = 16 << 10 // 16 KiB; Happ emits the result in a response header
remoteRoutingHappMaxValue = 8 << 10 // normalized Routing header value
remoteRoutingClashMaxBody = 2 << 20 // 2 MiB
)
var errRemoteRoutingUnavailable = errors.New("remote routing source is temporarily unavailable")
type remoteRoutingKey struct {
kind remoteRoutingKind
source string
}
type remoteRoutingCacheEntry struct {
Source string `json:"source"`
Content string `json:"content"`
FetchedAt int64 `json:"fetchedAt"`
ETag string `json:"etag,omitempty"`
LastModified string `json:"lastModified,omitempty"`
Clash map[string]any `json:"-"`
}
func (e remoteRoutingCacheEntry) fetchedTime() time.Time {
return time.Unix(e.FetchedAt, 0)
}
type remoteRoutingFetch struct {
done chan struct{}
err error
}
type remoteRoutingResolver struct {
mu sync.Mutex
loadMu sync.Mutex
loaded bool
loadInFlight bool
entries map[remoteRoutingKey]remoteRoutingCacheEntry
inflight map[remoteRoutingKey]*remoteRoutingFetch
lastAttempt map[remoteRoutingKey]time.Time
client *http.Client
now func() time.Time
persist bool
}
func newRemoteRoutingResolver(client *http.Client, persist bool) *remoteRoutingResolver {
return &remoteRoutingResolver{
entries: make(map[remoteRoutingKey]remoteRoutingCacheEntry),
inflight: make(map[remoteRoutingKey]*remoteRoutingFetch),
lastAttempt: make(map[remoteRoutingKey]time.Time),
client: client,
now: time.Now,
persist: persist,
}
}
var routingSourceResolver = newRemoteRoutingResolver(newRemoteRoutingHTTPClient(), true)
// resolveRoutingSource serves a remote source from the validated cache without
// ever blocking on network; inline values pass through (bool reports remote).
func resolveRoutingSource(kind remoteRoutingKind, raw string) (string, bool, error) {
return routingSourceResolver.resolve(kind, raw)
}
func (r *remoteRoutingResolver) resolve(kind remoteRoutingKind, raw string) (string, bool, error) {
entry, remote, err := r.resolveEntry(kind, raw)
if !remote {
return raw, false, err
}
return entry.Content, true, err
}
func resolveClashRoutingSource(raw string) (string, map[string]any, bool, error) {
entry, remote, err := routingSourceResolver.resolveEntry(remoteRoutingClash, raw)
if !remote {
return raw, nil, false, err
}
return entry.Content, entry.Clash, true, err
}
func (r *remoteRoutingResolver) resolveEntry(kind remoteRoutingKind, raw string) (remoteRoutingCacheEntry, bool, error) {
source, remote, err := common.ParseRemoteRoutingURL(raw)
if err != nil {
return remoteRoutingCacheEntry{}, true, err
}
if !remote {
return remoteRoutingCacheEntry{}, false, nil
}
r.triggerPersistedLoad()
key := remoteRoutingKey{kind: kind, source: source}
now := r.now()
r.mu.Lock()
cached, hasCached := r.entries[key]
if hasCached && now.Sub(cached.fetchedTime()) < remoteRoutingCacheTTL {
r.mu.Unlock()
return cached, true, nil
}
if _, ok := r.inflight[key]; ok {
r.mu.Unlock()
if hasCached {
return cached, true, nil
}
return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable
}
if attemptedAt, attempted := r.lastAttempt[key]; attempted && now.Sub(attemptedAt) < remoteRoutingRetryDelay {
r.mu.Unlock()
if hasCached {
return cached, true, nil
}
return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable
}
fetch := &remoteRoutingFetch{done: make(chan struct{})}
r.inflight[key] = fetch
r.mu.Unlock()
common.GoRecover("remote-routing-refresh", func() { r.refresh(key, cached, hasCached, fetch) })
if hasCached {
return cached, true, nil
}
return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable
}
// RefreshRemoteRoutingSources warms and refreshes configured remote sources
// from the cron job. Concurrent resolver reads are safe; fetches coalesce.
func RefreshRemoteRoutingSources(happ, clash string) {
for kind, raw := range map[remoteRoutingKind]string{
remoteRoutingHapp: happ,
remoteRoutingClash: clash,
} {
_, remote, parseErr := common.ParseRemoteRoutingURL(raw)
if parseErr != nil {
logger.Warningf("Remote %s routing source is invalid", kind)
continue
}
if remote {
_ = routingSourceResolver.refreshSource(kind, raw)
}
}
}
func (r *remoteRoutingResolver) refreshSource(kind remoteRoutingKind, raw string) error {
source, remote, err := common.ParseRemoteRoutingURL(raw)
if err != nil || !remote {
return err
}
r.ensurePersistedLoaded()
key := remoteRoutingKey{kind: kind, source: source}
now := r.now()
r.mu.Lock()
previous, hasPrevious := r.entries[key]
if hasPrevious && now.Sub(previous.fetchedTime()) < remoteRoutingCacheTTL {
r.mu.Unlock()
return nil
}
if fetch, ok := r.inflight[key]; ok {
done := fetch.done
r.mu.Unlock()
<-done
return fetch.err
}
if attemptedAt, attempted := r.lastAttempt[key]; attempted && now.Sub(attemptedAt) < remoteRoutingRetryDelay {
r.mu.Unlock()
return errRemoteRoutingUnavailable
}
fetch := &remoteRoutingFetch{done: make(chan struct{})}
r.inflight[key] = fetch
r.mu.Unlock()
r.refresh(key, previous, hasPrevious, fetch)
return fetch.err
}
func (r *remoteRoutingResolver) refresh(key remoteRoutingKey, previous remoteRoutingCacheEntry, hasPrevious bool, fetch *remoteRoutingFetch) {
entry, err := r.fetch(key, previous, hasPrevious)
now := r.now()
r.mu.Lock()
r.lastAttempt[key] = now
if err == nil {
r.entries[key] = entry
}
fetch.err = err
delete(r.inflight, key)
close(fetch.done)
r.mu.Unlock()
if err != nil {
if hasPrevious {
logger.Warningf("Remote %s routing refresh from %s failed; keeping the last valid value", key.kind, remoteRoutingHost(key.source))
} else {
logger.Warningf("Remote %s routing refresh from %s failed; no validated value is cached", key.kind, remoteRoutingHost(key.source))
}
return
}
if r.persist {
r.persistEntry(key.kind, entry)
}
}
func (r *remoteRoutingResolver) fetch(key remoteRoutingKey, previous remoteRoutingCacheEntry, hasPrevious bool) (entry remoteRoutingCacheEntry, err error) {
// Remote bytes reach the YAML/JSON parsers below; a parser panic must
// degrade to a failed refresh (keeping last-good), not crash the panel.
defer func() {
if panicValue := recover(); panicValue != nil {
entry, err = remoteRoutingCacheEntry{}, fmt.Errorf("remote routing fetch panicked: %v", panicValue)
}
}()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, key.source, nil)
if err != nil {
return remoteRoutingCacheEntry{}, err
}
req.Header.Set("User-Agent", "3x-ui-remote-routing/1.0")
if hasPrevious {
if previous.ETag != "" {
req.Header.Set("If-None-Match", previous.ETag)
}
if previous.LastModified != "" {
req.Header.Set("If-Modified-Since", previous.LastModified)
}
}
resp, err := r.client.Do(req)
if err != nil {
return remoteRoutingCacheEntry{}, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotModified {
if !hasPrevious {
return remoteRoutingCacheEntry{}, errors.New("remote source returned 304 without a cached value")
}
previous.FetchedAt = r.now().Unix()
if etag := strings.TrimSpace(resp.Header.Get("ETag")); etag != "" {
previous.ETag = etag
}
if modified := strings.TrimSpace(resp.Header.Get("Last-Modified")); modified != "" {
previous.LastModified = modified
}
return previous, nil
}
if key.kind == remoteRoutingHapp && isRemoteHappRedirect(resp.StatusCode) {
location := strings.TrimSpace(resp.Header.Get("Location"))
content, locationErr := normalizeHappRouting([]byte(location))
if locationErr != nil {
return remoteRoutingCacheEntry{}, fmt.Errorf("invalid Happ redirect target: %w", locationErr)
}
if len(content) > remoteRoutingHappMaxValue {
return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit")
}
return remoteRoutingCacheEntry{
Source: key.source,
Content: content,
FetchedAt: r.now().Unix(),
}, nil
}
if resp.StatusCode != http.StatusOK {
return remoteRoutingCacheEntry{}, fmt.Errorf("remote source returned HTTP %d", resp.StatusCode)
}
limit := int64(remoteRoutingHappMaxBody)
if key.kind == remoteRoutingClash {
limit = remoteRoutingClashMaxBody
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return remoteRoutingCacheEntry{}, err
}
if int64(len(body)) > limit {
return remoteRoutingCacheEntry{}, errors.New("remote routing response exceeds the size limit")
}
content, clash, err := normalizeRemoteRoutingContent(key.kind, body)
if err != nil {
return remoteRoutingCacheEntry{}, err
}
if key.kind == remoteRoutingHapp && len(content) > remoteRoutingHappMaxValue {
return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit")
}
return remoteRoutingCacheEntry{
Source: key.source,
Content: content,
FetchedAt: r.now().Unix(),
ETag: strings.TrimSpace(resp.Header.Get("ETag")),
LastModified: strings.TrimSpace(resp.Header.Get("Last-Modified")),
Clash: clash,
}, nil
}
func isRemoteHappRedirect(status int) bool {
switch status {
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther,
http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
return true
default:
return false
}
}
func normalizeRemoteRoutingContent(kind remoteRoutingKind, body []byte) (string, map[string]any, error) {
switch kind {
case remoteRoutingHapp:
content, err := normalizeHappRouting(body)
return content, nil, err
case remoteRoutingClash:
return normalizeClashRouting(body)
default:
return "", nil, fmt.Errorf("unsupported remote routing kind %q", kind)
}
}
func normalizeHappRouting(body []byte) (string, error) {
text := strings.TrimSpace(string(body))
if text == "" {
return "", errors.New("empty Happ routing response")
}
if strings.HasPrefix(text, "{") {
compact, err := validateAndCompactJSONObject([]byte(text))
if err != nil {
return "", fmt.Errorf("invalid Happ routing JSON: %w", err)
}
return "happ://routing/onadd/" + base64.StdEncoding.EncodeToString(compact), nil
}
if strings.ContainsAny(text, "\r\n") {
return "", errors.New("Happ deeplink must be a single line")
}
payload := ""
for _, prefix := range []string{"happ://routing/onadd/", "happ://routing/add/"} {
if after, ok := strings.CutPrefix(text, prefix); ok {
payload = after
break
}
}
if payload == "" {
return "", errors.New("Happ response is neither routing JSON nor a routing deeplink")
}
decoded, err := decodeRoutingBase64(payload)
if err != nil {
return "", fmt.Errorf("invalid Happ routing payload: %w", err)
}
if _, err := validateAndCompactJSONObject(decoded); err != nil {
return "", fmt.Errorf("invalid Happ routing payload JSON: %w", err)
}
return text, nil
}
func validateAndCompactJSONObject(raw []byte) ([]byte, error) {
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil {
return nil, err
}
if object == nil {
return nil, errors.New("expected a JSON object")
}
return json.Marshal(object)
}
func decodeRoutingBase64(value string) ([]byte, error) {
value = strings.TrimSpace(value)
encodings := []*base64.Encoding{
base64.StdEncoding,
base64.RawStdEncoding,
base64.URLEncoding,
base64.RawURLEncoding,
}
var lastErr error
for _, encoding := range encodings {
decoded, err := encoding.DecodeString(value)
if err == nil {
return decoded, nil
}
lastErr = err
}
return nil, lastErr
}
func normalizeClashRouting(body []byte) (string, map[string]any, error) {
text := strings.TrimSpace(string(body))
if text == "" {
return "", nil, errors.New("empty Clash routing response")
}
var document map[string]any
if err := yaml.Unmarshal([]byte(text), &document); err != nil {
return "", nil, fmt.Errorf("invalid Clash routing YAML: %w", err)
}
if len(document) == 0 {
return "", nil, errors.New("Clash routing response must be a YAML map")
}
hasSupportedKey := false
for key := range document {
if remoteClashAllowedKey(key) {
hasSupportedKey = true
break
}
}
if !hasSupportedKey {
return "", nil, errors.New("Clash routing response has no supported routing keys")
}
base := map[string]any{
"proxies": []map[string]any{{"name": "validation-node", "type": "vless"}},
"proxy-groups": []map[string]any{{
"name": "PROXY", "type": "select", "proxies": []string{"validation-node", "DIRECT"},
}},
"rules": []string{"MATCH,PROXY"},
}
if err := mergeRemoteClashRules(base, document); err != nil {
return "", nil, fmt.Errorf("invalid remote Clash routing schema: %w", err)
}
return text, document, nil
}
func resolveIncyRoutingSource(raw string) (string, bool, error) {
source, remote, err := common.ParseRemoteRoutingURL(raw)
if err != nil || !remote {
return raw, remote, err
}
return "incy://autorouting/onadd/" + source, true, nil
}
func newRemoteRoutingHTTPClient() *http.Client {
transport := &http.Transport{
Proxy: nil,
DialContext: netsafe.SSRFGuardedDialContext,
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: 4 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
return &http.Client{
Timeout: remoteRoutingHTTPTimeout,
Transport: transport,
CheckRedirect: checkRemoteRoutingRedirect,
}
}
func checkRemoteRoutingRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("stopped after 5 redirects")
}
if strings.EqualFold(req.URL.Scheme, "happ") {
// routing.help-style services publish the deeplink as the final Location;
// hand the 3xx back to fetch(), which validates it without a request.
return http.ErrUseLastResponse
}
if !strings.EqualFold(req.URL.Scheme, "https") || req.URL.Hostname() == "" || req.URL.User != nil {
return errors.New("remote routing redirect must stay on an absolute HTTPS URL")
}
// The guarded dialer re-resolves, validates and connects to the same public
// address, including on every HTTPS redirect hop.
return nil
}
func remoteRoutingHost(source string) string {
u, err := url.Parse(source)
if err != nil || u.Hostname() == "" {
return "unknown host"
}
return u.Hostname()
}
func remoteRoutingSettingKey(kind remoteRoutingKind) string {
return "_subRemoteRoutingCache_" + string(kind)
}
func (r *remoteRoutingResolver) ensurePersistedLoaded() {
if !r.persist {
return
}
r.mu.Lock()
loaded := r.loaded
r.mu.Unlock()
if loaded {
return
}
r.loadMu.Lock()
defer r.loadMu.Unlock()
r.mu.Lock()
loaded = r.loaded
r.mu.Unlock()
if loaded {
return
}
db := database.GetDB()
if db == nil {
return
}
sqlDB, err := db.DB()
if err != nil || sqlDB.Ping() != nil {
return
}
r.loadPersisted()
r.mu.Lock()
r.loaded = true
r.mu.Unlock()
}
// triggerPersistedLoad keeps SQLite off the subscription request path: requests
// schedule at most one background load; the startup job loads synchronously.
func (r *remoteRoutingResolver) triggerPersistedLoad() {
if !r.persist {
return
}
r.mu.Lock()
if r.loaded || r.loadInFlight {
r.mu.Unlock()
return
}
r.loadInFlight = true
r.mu.Unlock()
common.GoRecover("remote-routing-cache-load", func() {
defer func() {
r.mu.Lock()
r.loadInFlight = false
r.mu.Unlock()
}()
r.ensurePersistedLoaded()
})
}
func (r *remoteRoutingResolver) loadPersisted() {
loaded := make(map[remoteRoutingKey]remoteRoutingCacheEntry, 2)
for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingClash} {
var setting model.Setting
err := database.GetDB().Where("key = ?", remoteRoutingSettingKey(kind)).First(&setting).Error
if err != nil {
continue
}
var entry remoteRoutingCacheEntry
if json.Unmarshal([]byte(setting.Value), &entry) != nil || entry.Source == "" || entry.Content == "" || entry.FetchedAt <= 0 {
continue
}
if _, remote, err := common.ParseRemoteRoutingURL(entry.Source); err != nil || !remote {
continue
}
normalized, clash, err := normalizeRemoteRoutingContent(kind, []byte(entry.Content))
if err != nil {
continue
}
if kind == remoteRoutingHapp && len(normalized) > remoteRoutingHappMaxValue {
continue
}
entry.Content = normalized
entry.Clash = clash
loaded[remoteRoutingKey{kind: kind, source: entry.Source}] = entry
}
r.mu.Lock()
for key, entry := range loaded {
current, exists := r.entries[key]
if !exists || entry.FetchedAt > current.FetchedAt {
r.entries[key] = entry
}
}
r.mu.Unlock()
}
func (r *remoteRoutingResolver) persistEntry(kind remoteRoutingKind, entry remoteRoutingCacheEntry) {
db := database.GetDB()
if db == nil {
return
}
encoded, err := json.Marshal(entry)
if err != nil {
return
}
key := remoteRoutingSettingKey(kind)
var setting model.Setting
err = db.Where("key = ?", key).First(&setting).Error
if database.IsNotFound(err) {
err = db.Create(&model.Setting{Key: key, Value: string(encoded)}).Error
} else if err == nil {
setting.Value = string(encoded)
err = db.Save(&setting).Error
}
if err != nil {
logger.Warningf("Could not persist the last valid %s remote routing value", kind)
}
}
-750
View File
@@ -1,750 +0,0 @@
package sub
import (
"encoding/base64"
"errors"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
yaml "github.com/goccy/go-yaml"
"github.com/mhsanaei/3x-ui/v3/internal/database"
)
func mergeRemoteClashRulesYAML(base map[string]any, raw string) error {
var remote map[string]any
if err := yaml.Unmarshal([]byte(strings.TrimSpace(raw)), &remote); err != nil {
return err
}
return mergeRemoteClashRules(base, remote)
}
type remoteRoutingRoundTripper func(*http.Request) (*http.Response, error)
func (fn remoteRoutingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func remoteRoutingTestClient(fn remoteRoutingRoundTripper) *http.Client {
return &http.Client{Transport: fn}
}
func remoteRoutingResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
}
func waitRemoteRoutingIdle(t *testing.T, resolver *remoteRoutingResolver) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
resolver.mu.Lock()
inflight := len(resolver.inflight)
resolver.mu.Unlock()
if inflight == 0 {
return
}
if time.Now().After(deadline) {
t.Fatal("remote routing refresh did not finish")
}
time.Sleep(time.Millisecond)
}
}
func waitRemoteRoutingLoadIdle(t *testing.T, resolver *remoteRoutingResolver) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
resolver.mu.Lock()
loading := resolver.loadInFlight
resolver.mu.Unlock()
if !loading {
return
}
if time.Now().After(deadline) {
t.Fatal("persisted routing cache load did not finish")
}
time.Sleep(time.Millisecond)
}
}
func primeRemoteRouting(t *testing.T, resolver *remoteRoutingResolver, kind remoteRoutingKind, source string) string {
t.Helper()
if err := resolver.refreshSource(kind, source); err != nil {
t.Fatalf("prime remote routing: %v", err)
}
value, remote, err := resolver.resolve(kind, source)
if err != nil || !remote || value == "" {
t.Fatalf("primed resolve got=%q remote=%v err=%v", value, remote, err)
}
return value
}
func TestNormalizeHappRoutingAcceptsJSONAndDeeplink(t *testing.T) {
deeplink, err := normalizeHappRouting([]byte(`{"Name":"RoscomVPN","GlobalProxy":"true"}`))
if err != nil {
t.Fatalf("normalize JSON: %v", err)
}
const prefix = "happ://routing/onadd/"
if !strings.HasPrefix(deeplink, prefix) {
t.Fatalf("deeplink = %q", deeplink)
}
decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(deeplink, prefix))
if err != nil || !strings.Contains(string(decoded), `"Name":"RoscomVPN"`) {
t.Fatalf("decoded payload = %q, err=%v", decoded, err)
}
if got, err := normalizeHappRouting([]byte(deeplink + "\n")); err != nil || got != deeplink {
t.Fatalf("ready deeplink got=%q err=%v", got, err)
}
if _, err := normalizeHappRouting([]byte("routing.help")); err == nil {
t.Fatal("invalid Happ response was accepted")
}
}
func TestRemoteRoutingResolverAcceptsHappRedirect(t *testing.T) {
deeplink, err := normalizeHappRouting([]byte(`{"Name":"redirected"}`))
if err != nil {
t.Fatalf("normalize: %v", err)
}
var requests atomic.Int32
client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
requests.Add(1)
response := remoteRoutingResponse(http.StatusFound, "")
response.Header.Set("Location", deeplink)
response.Request = req
return response, nil
})
client.CheckRedirect = checkRemoteRoutingRedirect
resolver := newRemoteRoutingResolver(client, false)
const source = "https://routing.example/"
if err := resolver.refreshSource(remoteRoutingHapp, source); err != nil {
t.Fatalf("refresh redirect: %v", err)
}
got, remote, err := resolver.resolve(remoteRoutingHapp, source)
if err != nil || !remote || got != deeplink {
t.Fatalf("redirect resolve got=%q remote=%v err=%v", got, remote, err)
}
if requests.Load() != 1 {
t.Fatalf("network requests = %d, want 1", requests.Load())
}
}
func TestRemoteRoutingResolverHandlesHappNotModified(t *testing.T) {
var requests atomic.Int32
client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
if requests.Add(1) == 1 {
response := remoteRoutingResponse(http.StatusOK, `{"Name":"etagged"}`)
response.Header.Set("ETag", `"v1"`)
return response, nil
}
if req.Header.Get("If-None-Match") != `"v1"` {
t.Errorf("If-None-Match = %q", req.Header.Get("If-None-Match"))
}
return remoteRoutingResponse(http.StatusNotModified, ""), nil
})
resolver := newRemoteRoutingResolver(client, false)
now := time.Unix(1_800_000_000, 0)
resolver.now = func() time.Time { return now }
const source = "https://example.com/default.json"
first := primeRemoteRouting(t, resolver, remoteRoutingHapp, source)
now = now.Add(remoteRoutingCacheTTL + time.Second)
second, _, err := resolver.resolve(remoteRoutingHapp, source)
if err != nil || second != first {
t.Fatalf("stale resolve got=%q err=%v", second, err)
}
waitRemoteRoutingIdle(t, resolver)
now = now.Add(time.Minute)
third, _, err := resolver.resolve(remoteRoutingHapp, source)
if err != nil || third != first {
t.Fatalf("refreshed cache got=%q err=%v", third, err)
}
if requests.Load() != 2 {
t.Fatalf("requests = %d, want 2", requests.Load())
}
}
func TestRemoteRoutingResolverDoesNotBlockAndCoalescesColdFetch(t *testing.T) {
var requests atomic.Int32
started := make(chan struct{})
release := make(chan struct{})
var startOnce sync.Once
client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
requests.Add(1)
startOnce.Do(func() { close(started) })
<-release
return remoteRoutingResponse(http.StatusOK, `{"Name":"RoscomVPN"}`), nil
})
resolver := newRemoteRoutingResolver(client, false)
const source = "https://example.com/default.json"
results := make(chan error, 8)
for range 8 {
go func() {
_, remote, err := resolver.resolve(remoteRoutingHapp, source)
if !remote {
results <- errors.New("source was not classified as remote")
return
}
results <- err
}()
}
<-started
for range 8 {
select {
case err := <-results:
if !errors.Is(err, errRemoteRoutingUnavailable) {
t.Fatalf("cold resolve err=%v", err)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("cold resolve blocked on the remote fetch")
}
}
if got := requests.Load(); got != 1 {
t.Fatalf("requests = %d, want 1", got)
}
close(release)
waitRemoteRoutingIdle(t, resolver)
if got, _, err := resolver.resolve(remoteRoutingHapp, source); err != nil || !strings.HasPrefix(got, "happ://routing/onadd/") {
t.Fatalf("cached resolve got=%q err=%v", got, err)
}
if got := requests.Load(); got != 1 {
t.Fatalf("cached request count = %d, want 1", got)
}
}
func TestRemoteRoutingResolverServesStaleAfterFailedRefresh(t *testing.T) {
var requests atomic.Int32
refreshStarted := make(chan struct{})
releaseRefresh := make(chan struct{})
var startOnce sync.Once
fail := atomic.Bool{}
client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
requests.Add(1)
if fail.Load() {
startOnce.Do(func() { close(refreshStarted) })
<-releaseRefresh
return remoteRoutingResponse(http.StatusBadGateway, "bad gateway"), nil
}
return remoteRoutingResponse(http.StatusOK, `{"Name":"last-good"}`), nil
})
resolver := newRemoteRoutingResolver(client, false)
now := time.Unix(1_800_000_000, 0)
resolver.now = func() time.Time { return now }
const source = "https://example.com/default.json"
first := primeRemoteRouting(t, resolver, remoteRoutingHapp, source)
fail.Store(true)
now = now.Add(remoteRoutingCacheTTL + time.Second)
startedAt := time.Now()
stale, remote, err := resolver.resolve(remoteRoutingHapp, source)
if err != nil || !remote || stale != first {
t.Fatalf("stale resolve got=%q remote=%v err=%v", stale, remote, err)
}
if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
t.Fatalf("stale resolve blocked for %v", elapsed)
}
select {
case <-refreshStarted:
case <-time.After(time.Second):
t.Fatal("refresh did not run")
}
close(releaseRefresh)
waitRemoteRoutingIdle(t, resolver)
if got, _, err := resolver.resolve(remoteRoutingHapp, source); err != nil || got != first {
t.Fatalf("negative-cache resolve got=%q err=%v", got, err)
}
if got := requests.Load(); got != 2 {
t.Fatalf("requests = %d, want 2", got)
}
}
func TestRemoteRoutingResolverLoadsPersistedLastGood(t *testing.T) {
initSubDB(t)
deeplink, err := normalizeHappRouting([]byte(`{"Name":"persisted"}`))
if err != nil {
t.Fatalf("normalize: %v", err)
}
const source = "https://example.com/default.json"
entry := remoteRoutingCacheEntry{
Source: source, Content: deeplink, FetchedAt: time.Now().Add(-time.Hour).Unix(), ETag: `"v1"`,
}
newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, entry)
resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
}), true)
resolver.ensurePersistedLoaded()
got, remote, err := resolver.resolve(remoteRoutingHapp, source)
if err != nil || !remote || got != deeplink {
t.Fatalf("persisted resolve got=%q remote=%v err=%v", got, remote, err)
}
waitRemoteRoutingIdle(t, resolver)
}
func TestRemoteRoutingResolverDoesNotBlockOnPersistedLoad(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
var startOnce sync.Once
resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
startOnce.Do(func() { close(started) })
<-release
return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
}), true)
resolver.loadMu.Lock()
loadLocked := true
t.Cleanup(func() {
if loadLocked {
resolver.loadMu.Unlock()
}
})
startedAt := time.Now()
_, remote, err := resolver.resolve(remoteRoutingHapp, "https://example.com/default.json")
if !remote || !errors.Is(err, errRemoteRoutingUnavailable) {
t.Fatalf("resolve remote=%v err=%v", remote, err)
}
if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
t.Fatalf("resolve blocked on persisted cache load for %v", elapsed)
}
resolver.loadMu.Unlock()
loadLocked = false
close(release)
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("background refresh did not start")
}
waitRemoteRoutingIdle(t, resolver)
waitRemoteRoutingLoadIdle(t, resolver)
}
func TestRemoteRoutingResolverRejectsOversizedPersistedHappValue(t *testing.T) {
initSubDB(t)
deeplink, err := normalizeHappRouting([]byte(`{"Name":"` + strings.Repeat("x", remoteRoutingHappMaxValue) + `"}`))
if err != nil || len(deeplink) <= remoteRoutingHappMaxValue {
t.Fatalf("oversized fixture length=%d err=%v", len(deeplink), err)
}
const source = "https://example.com/oversized.json"
newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, remoteRoutingCacheEntry{
Source: source, Content: deeplink, FetchedAt: time.Now().Unix(),
})
resolver := newRemoteRoutingResolver(nil, true)
resolver.ensurePersistedLoaded()
resolver.mu.Lock()
_, exists := resolver.entries[remoteRoutingKey{kind: remoteRoutingHapp, source: source}]
resolver.mu.Unlock()
if exists {
t.Fatal("oversized persisted Happ routing value was loaded")
}
}
func TestRemoteRoutingResolverDoesNotReplaceClashCacheWithInvalidSchema(t *testing.T) {
var requests atomic.Int32
client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
if requests.Add(1) == 1 {
return remoteRoutingResponse(http.StatusOK, "rules:\n - MATCH,PROXY\n"), nil
}
return remoteRoutingResponse(http.StatusOK, "rules: not-a-list\n"), nil
})
resolver := newRemoteRoutingResolver(client, false)
now := time.Unix(1_800_000_000, 0)
resolver.now = func() time.Time { return now }
const source = "https://example.com/routing.yaml"
first := primeRemoteRouting(t, resolver, remoteRoutingClash, source)
now = now.Add(remoteRoutingCacheTTL + time.Second)
second, _, err := resolver.resolve(remoteRoutingClash, source)
if err != nil || second != first {
t.Fatalf("invalid refresh replaced last-good: got=%q err=%v", second, err)
}
waitRemoteRoutingIdle(t, resolver)
second, _, err = resolver.resolve(remoteRoutingClash, source)
if err != nil || second != first {
t.Fatalf("invalid refresh replaced last-good after completion: got=%q err=%v", second, err)
}
if requests.Load() != 2 {
t.Fatalf("requests = %d, want 2", requests.Load())
}
}
func TestApplyCommonHeadersResolvesRemoteHappAndFailsClosed(t *testing.T) {
gin.SetMode(gin.TestMode)
oldResolver := routingSourceResolver
t.Cleanup(func() { routingSourceResolver = oldResolver })
routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(http.StatusOK, `{"Name":"RoscomVPN"}`), nil
}), false)
const source = "https://example.com/default.json"
primeRemoteRouting(t, routingSourceResolver, remoteRoutingHapp, source)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
(&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", true, source, false)
if recorder.Header().Get("Routing-Enable") != "true" || !strings.HasPrefix(recorder.Header().Get("Routing"), "happ://routing/onadd/") {
t.Fatalf("headers = %#v", recorder.Header())
}
recorder = httptest.NewRecorder()
ctx, _ = gin.CreateTestContext(recorder)
(&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, source, false)
if recorder.Header().Get("Routing-Enable") != "" || !strings.HasPrefix(recorder.Header().Get("Routing"), "happ://routing/onadd/") {
t.Fatalf("independent routing headers = %#v", recorder.Header())
}
routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(http.StatusOK, "routing.help"), nil
}), false)
recorder = httptest.NewRecorder()
ctx, _ = gin.CreateTestContext(recorder)
(&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", true, "https://example.com/bad", false)
if recorder.Header().Get("Routing-Enable") != "true" || recorder.Header().Get("Routing") != "" {
t.Fatalf("invalid remote source leaked routing headers: %#v", recorder.Header())
}
waitRemoteRoutingIdle(t, routingSourceResolver)
}
func TestResolveIncyRemoteSourceUsesAutorouting(t *testing.T) {
got, remote, err := resolveIncyRoutingSource("https://example.com/DEFAULT.JSON")
if err != nil || !remote || got != "incy://autorouting/onadd/https://example.com/DEFAULT.JSON" {
t.Fatalf("got=%q remote=%v err=%v", got, remote, err)
}
inline := "incy://routing/onadd/abc"
if got, remote, err := resolveIncyRoutingSource(inline); err != nil || remote || got != inline {
t.Fatalf("inline got=%q remote=%v err=%v", got, remote, err)
}
}
func TestMergeRemoteClashRulesPreservesGeneratedProxies(t *testing.T) {
originalProxy := map[string]any{"name": "vpn-node", "type": "vless"}
base := map[string]any{
"proxies": []map[string]any{originalProxy},
"proxy-groups": []map[string]any{{
"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
}},
"rules": []string{"MATCH,PROXY"},
}
remote := `
proxies:
- name: attacker-controlled
proxy-providers:
prov:
url: <SUBSCRIPTION PLACEHOLDER>
external-controller: 0.0.0.0:9090
allow-lan: true
mixed-port: 7890
dns:
enable: true
tun:
enable: true
proxy-groups:
- name: VPN
type: select
include-all: true
- name: PROXY
type: select
proxies: [VPN]
rule-providers:
roscom:
type: http
url: https://example.com/rules.mrs
rules:
- RULE-SET,roscom,PROXY
- MATCH,PROXY
`
if err := mergeRemoteClashRulesYAML(base, remote); err != nil {
t.Fatalf("merge: %v", err)
}
proxies, ok := base["proxies"].([]map[string]any)
if !ok || len(proxies) != 1 || proxies[0]["name"] != "vpn-node" {
t.Fatalf("generated proxies were replaced: %#v", base["proxies"])
}
if _, exists := base["proxy-providers"]; exists {
t.Fatal("remote proxy-providers were imported")
}
if _, exists := base["external-controller"]; exists {
t.Fatal("unsafe top-level key was imported")
}
for _, key := range []string{"allow-lan", "mixed-port", "dns", "tun"} {
if _, exists := base[key]; exists {
t.Fatalf("client-local key %q was imported", key)
}
}
if _, exists := base["rule-providers"]; !exists {
t.Fatal("rule-providers were not imported")
}
groups, ok := asAnySlice(base["proxy-groups"])
if !ok || len(groups) != 2 || clashProxyGroupName(groups[0]) != "VPN" || clashProxyGroupName(groups[1]) != "PROXY" {
t.Fatalf("proxy groups = %#v", base["proxy-groups"])
}
rules, ok := asAnySlice(base["rules"])
if !ok || len(rules) != 2 || rules[1] != "MATCH,PROXY" {
t.Fatalf("rules = %#v", base["rules"])
}
}
func TestMergeRemoteClashRulesKeepsBaseProxyGroupWhenRemoteOmitsIt(t *testing.T) {
base := map[string]any{
"proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
"proxy-groups": []map[string]any{{
"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
}},
"rules": []string{"MATCH,PROXY"},
}
if err := mergeRemoteClashRulesYAML(base, `proxy-groups:
- name: Extra
type: select
proxies: [PROXY]
rules:
- MATCH,PROXY
`); err != nil {
t.Fatalf("merge: %v", err)
}
groups, ok := asAnySlice(base["proxy-groups"])
if !ok || len(groups) != 2 || clashProxyGroupName(groups[0]) != "Extra" || clashProxyGroupName(groups[1]) != "PROXY" {
t.Fatalf("proxy groups = %#v", base["proxy-groups"])
}
}
func TestRemoteRoutingRejectsOversizedHappValues(t *testing.T) {
largeJSON := `{"Name":"large","Rules":"` + strings.Repeat("a", remoteRoutingHappMaxValue) + `"}`
largeDeeplink, err := normalizeHappRouting([]byte(largeJSON))
if err != nil {
t.Fatalf("prepare large deeplink: %v", err)
}
tests := []struct {
name string
response func(*http.Request) *http.Response
wantErr string
}{
{
name: "response body",
response: func(*http.Request) *http.Response {
return remoteRoutingResponse(http.StatusOK, strings.Repeat("x", remoteRoutingHappMaxBody+1))
},
wantErr: "response exceeds the size limit",
},
{
name: "normalized header",
response: func(*http.Request) *http.Response {
return remoteRoutingResponse(http.StatusOK, largeJSON)
},
wantErr: "header exceeds the size limit",
},
{
name: "redirect header",
response: func(req *http.Request) *http.Response {
response := remoteRoutingResponse(http.StatusFound, "")
response.Header.Set("Location", largeDeeplink)
response.Request = req
return response
},
wantErr: "header exceeds the size limit",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
return tt.response(req), nil
})
client.CheckRedirect = checkRemoteRoutingRedirect
resolver := newRemoteRoutingResolver(client, false)
err := resolver.refreshSource(remoteRoutingHapp, "https://example.com/rules")
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err=%v, want %q", err, tt.wantErr)
}
})
}
}
func TestRemoteRoutingRefreshTurnsPanicsIntoErrors(t *testing.T) {
client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
panic("transport exploded")
})
resolver := newRemoteRoutingResolver(client, false)
err := resolver.refreshSource(remoteRoutingHapp, "https://example.com/rules")
if err == nil || !strings.Contains(err.Error(), "panicked") {
t.Fatalf("err=%v, want the panic converted into an error", err)
}
// The inflight slot must be released so later refreshes are not wedged.
waitRemoteRoutingIdle(t, resolver)
}
func TestRemoteRoutingHTTPClientRejectsLoopback(t *testing.T) {
resolver := newRemoteRoutingResolver(newRemoteRoutingHTTPClient(), false)
startedAt := time.Now()
err := resolver.refreshSource(remoteRoutingHapp, "https://127.0.0.1:1/rules")
if err == nil {
t.Fatal("loopback remote source was accepted")
}
if elapsed := time.Since(startedAt); elapsed > 2*time.Second {
t.Fatalf("loopback rejection took %v", elapsed)
}
}
func TestRemoteRoutingPersistedLoadRetriesAfterDatabaseBecomesReady(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := database.InitDB(dbPath); err != nil {
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
deeplink, err := normalizeHappRouting([]byte(`{"Name":"persisted-after-ready"}`))
if err != nil {
t.Fatalf("normalize: %v", err)
}
const source = "https://example.com/default.json"
newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, remoteRoutingCacheEntry{
Source: source, Content: deeplink, FetchedAt: time.Now().Unix(),
})
if err := database.CloseDB(); err != nil {
t.Fatalf("close db: %v", err)
}
resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
}), true)
if _, _, err := resolver.resolve(remoteRoutingHapp, source); !errors.Is(err, errRemoteRoutingUnavailable) {
t.Fatalf("closed-db resolve err=%v", err)
}
waitRemoteRoutingIdle(t, resolver)
waitRemoteRoutingLoadIdle(t, resolver)
if err := database.InitDB(dbPath); err != nil {
t.Fatalf("reopen db: %v", err)
}
resolver.triggerPersistedLoad()
waitRemoteRoutingLoadIdle(t, resolver)
got, remote, err := resolver.resolve(remoteRoutingHapp, source)
if err != nil || !remote || got != deeplink {
t.Fatalf("reloaded resolve got=%q remote=%v err=%v", got, remote, err)
}
}
func TestRemoteClashRouteGraphValidation(t *testing.T) {
tests := []struct {
name string
remote string
wantErr string
}{
{
name: "missing group name",
remote: "proxy-groups:\n - type: select\n proxies: [vpn-node]\nrules:\n - MATCH,PROXY\n",
wantErr: "named group maps",
},
{
name: "duplicate group name",
remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - MATCH,A\n",
wantErr: "duplicated",
},
{
name: "unknown group reference",
remote: "proxy-groups:\n - {name: A, type: select, proxies: [missing]}\nrules:\n - MATCH,A\n",
wantErr: "unknown proxy or group",
},
{
name: "remote proxy provider use",
remote: "proxy-groups:\n - name: A\n type: select\n use: [manual-provider]\nrules:\n - MATCH,A\n",
wantErr: "cannot use proxy-providers",
},
{
name: "unknown rule provider",
remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - RULE-SET,missing,A\n - MATCH,A\n",
wantErr: "unknown rule-provider",
},
{
name: "unknown rule target",
remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - MATCH,missing\n",
wantErr: "unknown proxy or group",
},
{
name: "unknown provider download proxy",
remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrule-providers:\n p: {type: http, url: https://example.com/p.mrs, proxy: missing}\nrules:\n - RULE-SET,p,A\n - MATCH,A\n",
wantErr: "rule-provider \"p\" references unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
base := map[string]any{
"proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
"proxy-groups": []map[string]any{{
"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
}},
"rules": []string{"MATCH,PROXY"},
}
err := mergeRemoteClashRulesYAML(base, tt.remote)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err=%v, want %q", err, tt.wantErr)
}
})
}
}
func TestRemoteClashRouteGraphAcceptsLogicalRulesAndCachedDocument(t *testing.T) {
const remote = `
proxy-groups:
- name: Auto
type: url-test
include-all: true
- name: Video
type: select
proxies: [Auto, DIRECT]
rule-providers:
video:
type: http
url: https://example.com/video.mrs
proxy: Auto
rules:
- RULE-SET,video,Video
- AND,((NETWORK,TCP),(DST-PORT,443)),Video
- GEOIP,private,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve,src
- MATCH,Auto
`
var requests atomic.Int32
resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
requests.Add(1)
return remoteRoutingResponse(http.StatusOK, remote), nil
}), false)
const source = "https://example.com/routing.yaml"
if err := resolver.refreshSource(remoteRoutingClash, source); err != nil {
t.Fatalf("refresh: %v", err)
}
entry, remoteSource, err := resolver.resolveEntry(remoteRoutingClash, source)
if err != nil || !remoteSource || entry.Clash == nil {
t.Fatalf("entry remote=%v parsed=%v err=%v", remoteSource, entry.Clash != nil, err)
}
base := map[string]any{
"proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
"proxy-groups": []map[string]any{{
"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
}},
"rules": []string{"MATCH,PROXY"},
}
if err := mergeRemoteClashRules(base, entry.Clash); err != nil {
t.Fatalf("merge cached document: %v", err)
}
if requests.Load() != 1 {
t.Fatalf("requests=%d, want 1", requests.Load())
}
}
-42
View File
@@ -145,11 +145,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubJsonMux = ""
}
SubJsonRules, err := s.settingService.GetSubJsonRules()
if err != nil {
SubJsonRules = ""
}
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
if err != nil {
SubJsonFinalMask = ""
@@ -160,16 +155,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubJsonObservatory = ""
}
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
if err != nil {
SubClashEnableRouting = false
}
SubClashRules, err := s.settingService.GetSubClashRules()
if err != nil {
SubClashRules = ""
}
SubTitle, err := s.settingService.GetSubTitle()
if err != nil {
SubTitle = ""
@@ -190,31 +175,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubAnnounce = ""
}
SubEnableRouting, err := s.settingService.GetSubEnableRouting()
if err != nil {
return nil, err
}
SubRoutingRules, err := s.settingService.GetSubRoutingRules()
if err != nil {
SubRoutingRules = ""
}
SubHideSettings, err := s.settingService.GetSubHideSettings()
if err != nil {
SubHideSettings = false
}
SubIncyEnableRouting, err := s.settingService.GetSubIncyEnableRouting()
if err != nil {
SubIncyEnableRouting = false
}
SubIncyRoutingRules, err := s.settingService.GetSubIncyRoutingRules()
if err != nil {
SubIncyRoutingRules = ""
}
// set per-request localizer from headers/cookies
engine.Use(locale.LocalizerMiddleware())
@@ -284,20 +249,13 @@ func (s *Server) initRouter() (*gin.Engine, error) {
WithSUBRemarkTemplate(RemarkTemplate),
WithSUBUpdateInterval(SubUpdates),
WithSUBJsonMux(SubJsonMux),
WithSUBJsonRules(SubJsonRules),
WithSUBJsonFinalMask(SubJsonFinalMask),
WithSUBJsonObservatory(SubJsonObservatory),
WithSUBClashEnableRouting(SubClashEnableRouting),
WithSUBClashRules(SubClashRules),
WithSUBTitle(SubTitle),
WithSUBSupportURL(SubSupportUrl),
WithSUBProfileURL(SubProfileUrl),
WithSUBAnnounce(SubAnnounce),
WithSUBEnableRouting(SubEnableRouting),
WithSUBRoutingRules(SubRoutingRules),
WithSUBHideSettings(SubHideSettings),
WithSUBIncyEnableRouting(SubIncyEnableRouting),
WithSUBIncyRoutingRules(SubIncyRoutingRules),
)
return engine, nil
@@ -44,7 +44,7 @@ func TestSubJson_BalancerMemberTagUsesProtocol(t *testing.T) {
Remark: "proto", Strategy: "random", InboundIds: []int{vm.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
+11 -12
View File
@@ -56,8 +56,7 @@ func TestSubJson_BalancerDocument(t *testing.T) {
Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id, ws.Id}, SortOrder: 1, Enabled: true,
})
rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
js := NewSubJsonService("", rules, "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -103,8 +102,8 @@ func TestSubJson_BalancerDocument(t *testing.T) {
t.Fatalf("balancer catch-all rule missing balancerTag: %s", ruleJSON)
}
proxyRules := strings.Count(string(ruleJSON), `"balancerTag"`)
if proxyRules != 2 { // custom rule + default catch-all
t.Fatalf("balancerTag rules = %d, want 2: %s", proxyRules, ruleJSON)
if proxyRules != 1 { // default catch-all only (custom sub rules were removed)
t.Fatalf("balancerTag rules = %d, want 1: %s", proxyRules, ruleJSON)
}
observatory, _ := balancerDoc["burstObservatory"].(map[string]any)
@@ -155,7 +154,7 @@ func TestSubJson_BalancerOrderInterleavesWithInbounds(t *testing.T) {
Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -189,7 +188,7 @@ func TestSubJson_BalancerDisabledAndEmptySkipped(t *testing.T) {
Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -213,7 +212,7 @@ func TestSubJson_BalancerTagDedup(t *testing.T) {
Remark: "dedup", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -241,7 +240,7 @@ func TestSubJson_BalancerObservatoryConditional(t *testing.T) {
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
@@ -287,7 +286,7 @@ func TestSubJson_BalancerExcludesDisabledInbound(t *testing.T) {
Remark: "bal", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -322,7 +321,7 @@ func TestSubJson_BalancerSkippedWhenAllMembersDisabled(t *testing.T) {
Remark: "empty", Strategy: "random", InboundIds: []int{only.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -345,7 +344,7 @@ func TestSubJson_BalancerObservatoryConnectivityDefaultEmpty(t *testing.T) {
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -386,7 +385,7 @@ func TestSubJson_BalancerObservatoryAlwaysEmittedForProbingStrategies(t *testing
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
js.SetObservatoryConfig(`{"enabled":false}`)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
+1 -1
View File
@@ -29,7 +29,7 @@ func TestSubJson_ObservatoryConfigInvalidValuesFallBack(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
js.SetObservatoryConfig(tc.cfg)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
+5 -5
View File
@@ -68,7 +68,7 @@ func TestGetJsonToleratesHysteriaWithoutHysteriaSettings(t *testing.T) {
t.Fatalf("seed client_inbound: %v", err)
}
jsonService := NewSubJsonService("", "", "", NewSubService(""))
jsonService := NewSubJsonService("", "", NewSubService(""))
out, _, err := jsonService.GetJson(subId, "sub.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -83,7 +83,7 @@ func TestGetJsonToleratesNonStringRealityShortId(t *testing.T) {
stream := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["sni.example.com"],"shortIds":[42],"settings":{"publicKey":"pk"}}}`
seedSubInbound(t, "rlty1", "rlty", 46400, 1, stream)
jsonService := NewSubJsonService("", "", "", NewSubService(""))
jsonService := NewSubJsonService("", "", NewSubService(""))
out, _, err := jsonService.GetJson("rlty1", "sub.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -99,7 +99,7 @@ func TestGetClashEmitsPinnedCertSha256(t *testing.T) {
stream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"pin.sni","settings":{"pinnedPeerCertSha256":["` + pin + `"]}}}`
seedSubInbound(t, "pin1", "pin", 46300, 1, stream)
out, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("pin1", "sub.example.com")
out, _, err := NewSubClashService(NewSubService("")).GetClash("pin1", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
@@ -113,7 +113,7 @@ func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) {
stream := `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":"cdn.example.com"}]}`
seedSubInbound(t, "extp1", "extp", 46500, 1, stream)
jsonService := NewSubJsonService("", "", "", NewSubService(""))
jsonService := NewSubJsonService("", "", NewSubService(""))
jsonOut, _, err := jsonService.GetJson("extp1", "sub.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -122,7 +122,7 @@ func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) {
t.Fatal("GetJson returned empty for an externalProxy entry missing port")
}
clashOut, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("extp1", "sub.example.com")
clashOut, _, err := NewSubClashService(NewSubService("")).GetClash("extp1", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
+1 -1
View File
@@ -209,7 +209,7 @@ func TestGetSubsScale(t *testing.T) {
t.Fatalf("GetSubs links = %d, want 3", len(links))
}
jsonSvc := NewSubJsonService("", "", "", &SubService{})
jsonSvc := NewSubJsonService("", "", &SubService{})
start = time.Now()
for range reps {
body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com", false)
+2 -2
View File
@@ -51,7 +51,7 @@ func TestSub_HostVlessRoute_JSON(t *testing.T) {
ib := seedSubInbound(t, "s1", "vrj", 4501, 1, wsTLSStream)
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "J", Address: "j.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
js := NewSubJsonService("", "", "", NewSubService(""))
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -69,7 +69,7 @@ func TestSub_HostVlessRoute_Clash(t *testing.T) {
ib := seedSubInbound(t, "s1", "vrc", 4502, 1, wsTLSStream)
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "C", Address: "c.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
clash := NewSubClashService(false, "", NewSubService(""))
clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
-156
View File
@@ -1,156 +0,0 @@
package ldaputil
import (
"crypto/tls"
"fmt"
"slices"
"github.com/go-ldap/ldap/v3"
)
type Config struct {
Host string
Port int
UseTLS bool
InsecureSkipVerify bool
BindDN string
Password string
BaseDN string
UserFilter string
UserAttr string
FlagField string
TruthyVals []string
Invert bool
}
func tlsConfig(cfg Config) *tls.Config {
return &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}
}
// FetchVlessFlags returns map[email]enabled
func FetchVlessFlags(cfg Config) (map[string]bool, error) {
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
scheme := "ldap"
if cfg.UseTLS {
scheme = "ldaps"
}
ldapURL := fmt.Sprintf("%s://%s", scheme, addr)
var opts []ldap.DialOpt
if cfg.UseTLS {
opts = append(opts, ldap.DialWithTLSConfig(tlsConfig(cfg)))
}
conn, err := ldap.DialURL(ldapURL, opts...)
if err != nil {
return nil, err
}
defer conn.Close()
if cfg.BindDN != "" {
if err := conn.Bind(cfg.BindDN, cfg.Password); err != nil {
return nil, err
}
}
if cfg.UserFilter == "" {
cfg.UserFilter = "(objectClass=person)"
}
if cfg.UserAttr == "" {
cfg.UserAttr = "mail"
}
// if field not set we fallback to legacy vless_enabled
if cfg.FlagField == "" {
cfg.FlagField = "vless_enabled"
}
req := ldap.NewSearchRequest(
cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
cfg.UserFilter,
[]string{cfg.UserAttr, cfg.FlagField},
nil,
)
res, err := conn.Search(req)
if err != nil {
return nil, err
}
result := make(map[string]bool, len(res.Entries))
for _, e := range res.Entries {
user := e.GetAttributeValue(cfg.UserAttr)
if user == "" {
continue
}
val := e.GetAttributeValue(cfg.FlagField)
enabled := slices.Contains(cfg.TruthyVals, val)
if cfg.Invert {
enabled = !enabled
}
result[user] = enabled
}
return result, nil
}
// AuthenticateUser searches user by cfg.UserAttr and attempts to bind with provided password.
func AuthenticateUser(cfg Config, username, password string) (bool, error) {
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
scheme := "ldap"
if cfg.UseTLS {
scheme = "ldaps"
}
ldapURL := fmt.Sprintf("%s://%s", scheme, addr)
var opts []ldap.DialOpt
if cfg.UseTLS {
opts = append(opts, ldap.DialWithTLSConfig(tlsConfig(cfg)))
}
conn, err := ldap.DialURL(ldapURL, opts...)
if err != nil {
return false, err
}
defer conn.Close()
// Optional initial bind for search
if cfg.BindDN != "" {
if err := conn.Bind(cfg.BindDN, cfg.Password); err != nil {
return false, err
}
}
if cfg.UserFilter == "" {
cfg.UserFilter = "(objectClass=person)"
}
if cfg.UserAttr == "" {
cfg.UserAttr = "uid"
}
// Build filter to find specific user
filter := fmt.Sprintf("(&%s(%s=%s))", cfg.UserFilter, cfg.UserAttr, ldap.EscapeFilter(username))
req := ldap.NewSearchRequest(
cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 0, false,
filter,
[]string{"dn"},
nil,
)
res, err := conn.Search(req)
if err != nil {
return false, err
}
if len(res.Entries) == 0 {
return false, nil
}
userDN := res.Entries[0].DN
// Try to bind as the user
if err := conn.Bind(userDN, password); err != nil {
return false, nil
}
return true, nil
}
-22
View File
@@ -1,22 +0,0 @@
package ldaputil
import "testing"
func TestTLSConfig_InsecureSkipVerifyPropagates(t *testing.T) {
cases := []struct {
name string
skip bool
want bool
}{
{"default verifies", false, false},
{"skip flows through", true, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := tlsConfig(Config{InsecureSkipVerify: c.skip})
if got.InsecureSkipVerify != c.want {
t.Fatalf("InsecureSkipVerify = %v, want %v", got.InsecureSkipVerify, c.want)
}
})
}
}
-2
View File
@@ -23,8 +23,6 @@ func TestJobCadencesAreValidCronSpecs(t *testing.T) {
"cadenceNodeHeartbeat": cadenceNodeHeartbeat,
"cadenceNodeTraffic": cadenceNodeTraffic,
"cadenceOutboundSub": cadenceOutboundSub,
"cadenceRemoteRouting": cadenceRemoteRouting,
"cadenceCheckHash": cadenceCheckHash,
"cadenceCPUAlarm": cadenceCPUAlarm,
}
for name, spec := range cadences {
+384
View File
@@ -0,0 +1,384 @@
package controller
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AgentController serves the lightweight agent API.
//
// Agent endpoints (register/heartbeat/config/traffic) authenticate with the
// agent's own Bearer token, which is NOT a panel API token — they are mounted
// on a public route group and must never go through the panel auth middleware.
// The panel-authed administration endpoints (token issuance, egress
// management) live under the authenticated /panel/api group instead.
type AgentController struct {
xrayService service.XrayService
inboundService service.InboundService
}
func NewAgentController(g *gin.RouterGroup) *AgentController {
a := &AgentController{}
a.initRouter(g)
return a
}
func (a *AgentController) initRouter(g *gin.RouterGroup) {
g.POST("/register", a.register)
g.POST("/heartbeat", a.heartbeat)
g.GET("/config", a.getConfig)
g.POST("/traffic", a.reportTraffic)
}
// RegisterAdminRoutes mounts the panel-authenticated administration endpoints
// on an already-authed route group.
func (a *AgentController) RegisterAdminRoutes(g *gin.RouterGroup) {
g.POST("/tokens", a.createToken)
g.GET("/tokens", a.listTokens)
g.DELETE("/tokens/:id", a.deleteToken)
g.GET("/egress/:token", a.getEgress)
g.PUT("/egress/:token", a.setEgress)
}
func hashAgentToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
var errRequest = errors.New("bad request")
func bearerToken(c *gin.Context) string {
auth := c.GetHeader("Authorization")
if len(auth) > 7 && auth[:7] == "Bearer " {
return auth[7:]
}
return ""
}
func (a *AgentController) findAgent(token string) (*model.AgentNode, bool) {
if token == "" {
return nil, false
}
var agent model.AgentNode
if err := database.GetDB().Where("token_hash = ?", hashAgentToken(token)).First(&agent).Error; err != nil {
return nil, false
}
return &agent, true
}
func (a *AgentController) touchAgent(id uint, name string) {
updates := map[string]any{"last_seen": time.Now().UnixMilli()}
if name != "" {
updates["name"] = name
}
db := database.GetDB()
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
// If bound to a node, refresh the node's heartbeat so the legacy node
// list shows it online (the legacy probe can't reach agent-only nodes).
var nodeID *int
_ = db.Model(&model.AgentNode{}).Select("node_id").Where("id = ?", id).Scan(&nodeID).Error
if nodeID != nil {
now := time.Now().UnixMilli()
_ = db.Exec("UPDATE nodes SET last_heartbeat = ?, status = 'online' WHERE id = ?", now, *nodeID).Error
}
}
// register activates a pre-issued token. Tokens are issued bound to a node via
// POST /panel/api/agent/tokens; the agent only claims its token here.
func (a *AgentController) register(c *gin.Context) {
var req struct {
Token string `json:"token"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
jsonMsg(c, "Token required", errRequest)
return
}
agent, ok := a.findAgent(req.Token)
if !ok {
c.JSON(401, gin.H{"message": "unknown token"})
return
}
a.touchAgent(uint(agent.Id), req.Name)
logger.Infof("Agent registered: %s (token %s...)", req.Name, agent.TokenPrefix)
jsonObj(c, gin.H{"message": "registered"}, nil)
}
func (a *AgentController) heartbeat(c *gin.Context) {
agent, ok := a.findAgent(bearerToken(c))
if !ok {
c.JSON(401, gin.H{"message": "unauthorized"})
return
}
a.touchAgent(uint(agent.Id), "")
jsonObj(c, gin.H{"message": "ok"}, nil)
}
// getConfig returns the full xray config for the agent's bound node.
func (a *AgentController) getConfig(c *gin.Context) {
agent, ok := a.findAgent(bearerToken(c))
if !ok {
c.JSON(401, gin.H{"message": "unauthorized"})
return
}
if agent.NodeID == nil {
c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
return
}
cfg, err := a.xrayService.GetXrayConfigForAgent(*agent.NodeID, agent.Outbounds, agent.RoutingRules)
if err != nil {
jsonMsg(c, "Failed to build xray config", err)
return
}
raw, err := json.Marshal(cfg)
if err != nil {
jsonMsg(c, "Failed to encode xray config", err)
return
}
sum := sha256.Sum256(raw)
c.JSON(200, gin.H{
"config_hash": hex.EncodeToString(sum[:]),
"xray_config": json.RawMessage(raw),
})
}
type agentTrafficReport struct {
Inbounds []struct {
Tag string `json:"tag"`
Up int64 `json:"up"`
Down int64 `json:"down"`
} `json:"inbounds"`
Users []struct {
Email string `json:"email"`
Up int64 `json:"up"`
Down int64 `json:"down"`
} `json:"users"`
// Onlines lists client emails with at least one live connection.
Onlines []string `json:"onlines"`
}
// reportTraffic ingests per-poll traffic deltas from the agent. Inbound deltas
// land on the agent's node inbound rows; client deltas reuse the shared
// email-keyed write path (plus its lifecycle maintenance, which also drives
// config changes the agent picks up on its next poll).
func (a *AgentController) reportTraffic(c *gin.Context) {
agent, ok := a.findAgent(bearerToken(c))
if !ok {
c.JSON(401, gin.H{"message": "unauthorized"})
return
}
var req agentTrafficReport
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, "Invalid request", err)
return
}
db := database.GetDB()
if agent.NodeID != nil {
for _, ib := range req.Inbounds {
if ib.Tag == "" || ib.Up+ib.Down <= 0 {
continue
}
_ = db.Model(&model.Inbound{}).
Where("tag = ? AND node_id = ?", ib.Tag, *agent.NodeID).
Updates(map[string]any{
"up": gorm.Expr(database.ClampedAddExpr("up"), ib.Up),
"down": gorm.Expr(database.ClampedAddExpr("down"), ib.Down),
}).Error
}
}
var clientTraffics []*xray.ClientTraffic
for _, u := range req.Users {
if u.Email == "" || u.Up+u.Down <= 0 {
continue
}
clientTraffics = append(clientTraffics, &xray.ClientTraffic{
Email: u.Email,
Up: u.Up,
Down: u.Down,
})
}
if len(clientTraffics) > 0 {
if _, _, err := a.inboundService.AddTraffic(nil, clientTraffics); err != nil {
jsonMsg(c, "Failed to store traffic", err)
return
}
}
if agent.NodeID != nil {
if len(req.Onlines) > 0 {
a.inboundService.SetNodeOnlineTree(*agent.NodeID, map[string][]string{
"agent": req.Onlines,
})
} else {
a.inboundService.ClearNodeOnlineClients(*agent.NodeID)
}
}
a.touchAgent(uint(agent.Id), "")
jsonObj(c, gin.H{"message": "ok"}, nil)
}
// issueAgentToken generates a new agent token bound to nodeID and stores only
// its hash. The raw token is returned once and must be shown to the operator.
func issueAgentToken(nodeID *int, name string) (string, int, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", 0, err
}
token := hex.EncodeToString(raw)
agent := model.AgentNode{
TokenHash: hashAgentToken(token),
TokenPrefix: token[:12],
Name: name,
NodeID: nodeID,
}
if err := database.GetDB().Create(&agent).Error; err != nil {
return "", 0, err
}
return token, agent.Id, nil
}
// createToken issues a new agent token bound to a node. The raw token is
// returned once; only its hash is stored.
func (a *AgentController) createToken(c *gin.Context) {
var req struct {
NodeID *int `json:"node_id"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, "Invalid request", err)
return
}
if req.NodeID != nil {
var node model.Node
if err := database.GetDB().First(&node, *req.NodeID).Error; err != nil {
jsonMsg(c, "Node not found", errRequest)
return
}
}
token, _, err := issueAgentToken(req.NodeID, req.Name)
if err != nil {
jsonMsg(c, "Failed to generate token", err)
return
}
var agent model.AgentNode
_ = database.GetDB().Where("token_hash = ?", hashAgentToken(token)).First(&agent).Error
jsonObj(c, gin.H{
"id": agent.Id,
"token": token,
"token_prefix": agent.TokenPrefix,
"node_id": agent.NodeID,
"name": agent.Name,
}, nil)
}
func (a *AgentController) listTokens(c *gin.Context) {
var agents []model.AgentNode
_ = database.GetDB().Order("id DESC").Find(&agents).Error
out := make([]gin.H, 0, len(agents))
for _, ag := range agents {
out = append(out, gin.H{
"id": ag.Id,
"token_prefix": ag.TokenPrefix,
"name": ag.Name,
"node_id": ag.NodeID,
"last_seen": ag.LastSeen,
"has_egress": ag.Outbounds != "" || ag.RoutingRules != "",
"created_at": ag.CreatedAt,
})
}
jsonObj(c, out, nil)
}
func (a *AgentController) deleteToken(c *gin.Context) {
_ = database.GetDB().Delete(&model.AgentNode{}, "id = ?", c.Param("id")).Error
jsonObj(c, gin.H{"message": "deleted"}, nil)
}
// getEgress returns the panel-managed egress (outbounds + routing rules) for
// the agent identified by its token.
func (a *AgentController) getEgress(c *gin.Context) {
agent, ok := a.findAgent(c.Param("token"))
if !ok {
c.JSON(404, gin.H{"message": "agent not found"})
return
}
jsonObj(c, gin.H{
"id": agent.Id,
"token_prefix": agent.TokenPrefix,
"name": agent.Name,
"node_id": agent.NodeID,
"outbounds": json.RawMessage(orEmptyArray(agent.Outbounds)),
"routing_rules": json.RawMessage(orEmptyArray(agent.RoutingRules)),
}, nil)
}
// setEgress replaces the panel-managed egress for the agent. The panel is the
// only source of truth: agents never report local egress, they only apply
// what the generated config carries.
func (a *AgentController) setEgress(c *gin.Context) {
agent, ok := a.findAgent(c.Param("token"))
if !ok {
c.JSON(404, gin.H{"message": "agent not found"})
return
}
var req struct {
Outbounds json.RawMessage `json:"outbounds"`
RoutingRules json.RawMessage `json:"routing_rules"`
}
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, "Invalid request", err)
return
}
outbounds, err := canonicalJSONArray(req.Outbounds)
if err != nil {
jsonMsg(c, "outbounds must be a JSON array", err)
return
}
rules, err := canonicalJSONArray(req.RoutingRules)
if err != nil {
jsonMsg(c, "routing_rules must be a JSON array", err)
return
}
if err := database.GetDB().Model(&model.AgentNode{}).Where("id = ?", agent.Id).
Updates(map[string]any{"outbounds": outbounds, "routing_rules": rules}).Error; err != nil {
jsonMsg(c, "Failed to store egress", err)
return
}
jsonObj(c, gin.H{"message": "saved"}, nil)
}
func orEmptyArray(s string) string {
if s == "" {
return "[]"
}
return s
}
// canonicalJSONArray validates that raw is a JSON array and returns it in
// canonical form. Empty input yields an empty string (template defaults).
func canonicalJSONArray(raw json.RawMessage) (string, error) {
if len(raw) == 0 {
return "", nil
}
var arr []any
if err := json.Unmarshal(raw, &arr); err != nil {
return "", err
}
out, err := json.Marshal(arr)
if err != nil {
return "", err
}
return string(out), nil
}
+16 -12
View File
@@ -7,7 +7,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
"github.com/gin-gonic/gin"
@@ -19,12 +18,12 @@ type APIController struct {
inboundController *InboundController
serverController *ServerController
nodeController *NodeController
agentController *AgentController
hostController *HostController
settingController *SettingController
xraySettingController *XraySettingController
userService panel.UserService
apiTokenService panel.ApiTokenService
Tgbot tgbot.Tgbot
}
// NewAPIController creates a new APIController instance and initializes its routes.
@@ -81,7 +80,6 @@ var monitorScopeAllow = map[string]struct{}{
"/server/xrayMetricsHistory/:metric/:bucket": {},
"/server/xrayObservatory": {},
"/server/xrayObservatoryHistory/:tag/:bucket": {},
"/server/getXrayVersion": {},
"/server/getPanelUpdateInfo": {},
"/nodes/history/:id/:metric/:bucket": {},
}
@@ -173,15 +171,12 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
api.Use(middleware.ConfigEnvelopeMiddleware())
api.Use(middleware.CSRFMiddleware())
api.GET("/openapi.json", ServeOpenAPISpec)
// Inbounds API
inbounds := api.Group("/inbounds")
a.inboundController = NewInboundController(inbounds)
clients := api.Group("/clients")
NewClientController(clients)
NewGroupController(clients)
// Server API
server := api.Group("/server")
@@ -191,6 +186,21 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
nodes := api.Group("/nodes")
a.nodeController = NewNodeController(nodes)
// Agent API — lightweight agent mode (no web panel on node).
// The agent endpoints (register/heartbeat/config/traffic) authenticate with
// the agent's own Bearer token, which is NOT a panel API token, so they are
// mounted on a public group and must not pass through checkAPIAuth (it would
// 404 every agent call: no session, no panel token).
agentPub := g.Group("/panel/api/agent")
a.agentController = NewAgentController(agentPub)
// Agent administration — panel-authenticated (session login or panel API
// token via the group's checkAPIAuth): token issuance and per-agent egress
// management. Agent Bearer tokens cannot pass checkAPIAuth, so agents can
// never reach these.
agentAdmin := api.Group("/agent")
a.agentController.RegisterAdminRoutes(agentAdmin)
// Hosts API — per-inbound override endpoints for subscription links
hosts := api.Group("/hosts")
a.hostController = NewHostController(hosts)
@@ -205,10 +215,4 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
NewSubBalancerController(api)
// Extra routes
api.POST("/backuptotgbot", a.BackuptoTgbot)
}
// BackuptoTgbot sends a backup of the panel data to Telegram bot admins.
func (a *APIController) BackuptoTgbot(c *gin.Context) {
a.Tgbot.SendBackupToAdmins()
}
-48
View File
@@ -2,7 +2,6 @@ package controller
import (
"bytes"
"encoding/json"
htmlpkg "html"
"io/fs"
"net/http"
@@ -24,53 +23,6 @@ func SetDistFS(fsys fs.FS) {
var distPageBuildTime = time.Now()
// ServeOpenAPISpec returns the generated OpenAPI 3.0 description of the
// panel API. Postman / Insomnia / openapi-generator consume this URL
// directly; the in-panel Swagger UI page also fetches it. The spec is
// produced at frontend build time by scripts/build-openapi.mjs and
// embedded into the binary via the dist FS.
func ServeOpenAPISpec(c *gin.Context) {
body, err := fs.ReadFile(distFS, "dist/openapi.json")
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
return
}
// The embedded spec ships with `servers: [{url: "/"}]`. When the panel runs
// under a non-root web base path, Swagger UI "Try it out" and external
// generators must target that prefix, so rewrite the single server entry to
// the runtime base path before serving.
if basePath := c.GetString("base_path"); basePath != "" && basePath != "/" {
if rebuilt, err := withServerBasePath(body, basePath); err != nil {
logger.Warning("openapi.json: could not inject base path:", err)
} else {
body = rebuilt
}
}
c.Header("Cache-Control", "public, max-age=300")
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
// withServerBasePath rewrites the spec's `servers` entry so requests target the
// panel's configured web base path. Only the top-level `servers` field is
// replaced; every other field is preserved verbatim via json.RawMessage.
func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
var doc map[string]json.RawMessage
if err := json.Unmarshal(spec, &doc); err != nil {
return nil, err
}
servers, err := json.Marshal([]map[string]string{{
"url": strings.TrimSuffix(basePath, "/"),
"description": "Current panel",
}})
if err != nil {
return nil, err
}
doc["servers"] = servers
return json.Marshal(doc)
}
func normalizeWebBasePath(basePath string) string {
if basePath == "" {
return "/"
-35
View File
@@ -1,46 +1,11 @@
package controller
import (
"encoding/json"
"strings"
"testing"
)
func TestWithServerBasePath(t *testing.T) {
spec := []byte(`{"openapi":"3.0.3","info":{"title":"x"},"servers":[{"url":"/","description":"old"}],"paths":{"/p":{"get":{"summary":"s"}}}}`)
out, err := withServerBasePath(spec, "/test/")
if err != nil {
t.Fatalf("withServerBasePath: %v", err)
}
var doc map[string]any
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
servers, ok := doc["servers"].([]any)
if !ok || len(servers) != 1 {
t.Fatalf("servers = %v, want one entry", doc["servers"])
}
srv, _ := servers[0].(map[string]any)
if srv["url"] != "/test" {
t.Errorf("server url = %v, want /test (trailing slash trimmed)", srv["url"])
}
if doc["openapi"] != "3.0.3" {
t.Errorf("openapi field not preserved: %v", doc["openapi"])
}
if _, ok := doc["paths"].(map[string]any)["/p"]; !ok {
t.Errorf("paths content not preserved verbatim")
}
}
func TestWithServerBasePathInvalidJSON(t *testing.T) {
if _, err := withServerBasePath([]byte("not json"), "/test/"); err == nil {
t.Errorf("expected error on invalid spec, got nil")
}
}
func TestPWAHeadInjectionUsesRuntimeBasePath(t *testing.T) {
tests := []struct {
+45 -20
View File
@@ -13,8 +13,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/op/go-logging"
xraygeodata "github.com/xtls/xray-core/common/geodata"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/encoding/protowire"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -36,20 +35,47 @@ func newGeodataEngine(t *testing.T) *gin.Engine {
return engine
}
// The fixture databases are encoded with protowire: the wire layout
// (geodat.proto field numbers) is the contract under test, and spelling it
// out keeps the test independent of xray-core's generated types.
func geoWireBytes(num protowire.Number, payload []byte) []byte {
out := protowire.AppendTag(nil, num, protowire.BytesType)
return protowire.AppendBytes(out, payload)
}
func geoWireString(num protowire.Number, value string) []byte {
out := protowire.AppendTag(nil, num, protowire.BytesType)
return protowire.AppendString(out, value)
}
func geoWireVarint(num protowire.Number, value uint64) []byte {
out := protowire.AppendTag(nil, num, protowire.VarintType)
return protowire.AppendVarint(out, value)
}
func geoFixtureDomain(typ uint64, value string, attrs ...string) []byte {
var msg []byte
if typ != 0 {
msg = append(msg, geoWireVarint(1, typ)...)
}
msg = append(msg, geoWireString(2, value)...)
for _, attr := range attrs {
attrMsg := geoWireString(1, attr)
attrMsg = append(attrMsg, geoWireVarint(2, 1)...) // bool_value = true
msg = append(msg, geoWireBytes(3, attrMsg)...)
}
return msg
}
func writeGeositeDB(t *testing.T, dir string) {
t.Helper()
data, err := proto.Marshal(&xraygeodata.GeoSiteList{Entry: []*xraygeodata.GeoSite{
{Code: "google", Domain: []*xraygeodata.Domain{
{Type: xraygeodata.Domain_Domain, Value: "google.com"},
{Type: xraygeodata.Domain_Full, Value: "ads.google.com", Attribute: []*xraygeodata.Domain_Attribute{
{Key: "ads", TypedValue: &xraygeodata.Domain_Attribute_BoolValue{BoolValue: true}},
}},
}},
{Code: "cn", Domain: []*xraygeodata.Domain{{Type: xraygeodata.Domain_Domain, Value: "baidu.com"}}},
}})
if err != nil {
t.Fatalf("marshal geosite: %v", err)
}
google := geoWireString(1, "google")
google = append(google, geoWireBytes(2, geoFixtureDomain(2, "google.com"))...)
google = append(google, geoWireBytes(2, geoFixtureDomain(3, "ads.google.com", "ads"))...)
cn := geoWireString(1, "cn")
cn = append(cn, geoWireBytes(2, geoFixtureDomain(2, "baidu.com"))...)
data := append(geoWireBytes(1, google), geoWireBytes(1, cn)...)
if err := os.WriteFile(filepath.Join(dir, "geosite.dat"), data, 0o644); err != nil {
t.Fatalf("write geosite.dat: %v", err)
}
@@ -58,12 +84,11 @@ func writeGeositeDB(t *testing.T, dir string) {
func writeGeoipDB(t *testing.T, dir string) {
t.Helper()
prefix := netip.MustParsePrefix("10.0.0.0/8")
data, err := proto.Marshal(&xraygeodata.GeoIPList{Entry: []*xraygeodata.GeoIP{
{Code: "private", Cidr: []*xraygeodata.CIDR{{Ip: prefix.Addr().AsSlice(), Prefix: uint32(prefix.Bits())}}},
}})
if err != nil {
t.Fatalf("marshal geoip: %v", err)
}
cidr := geoWireBytes(1, prefix.Addr().AsSlice())
cidr = append(cidr, geoWireVarint(2, uint64(prefix.Bits()))...)
entry := geoWireString(1, "private")
entry = append(entry, geoWireBytes(2, cidr)...)
data := geoWireBytes(1, entry)
if err := os.WriteFile(filepath.Join(dir, "geoip.dat"), data, 0o644); err != nil {
t.Fatalf("write geoip.dat: %v", err)
}
-172
View File
@@ -1,172 +0,0 @@
package controller
import (
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/gin-gonic/gin"
)
type GroupController struct {
clientService service.ClientService
xrayService service.XrayService
}
func NewGroupController(g *gin.RouterGroup) *GroupController {
a := &GroupController{}
a.initRouter(g)
return a
}
func (a *GroupController) initRouter(g *gin.RouterGroup) {
g.GET("/groups", a.list)
g.GET("/groups/:name/emails", a.emails)
g.POST("/groups/create", a.create)
g.POST("/groups/rename", a.rename)
g.POST("/groups/delete", a.delete)
g.POST("/groups/resetTraffic", a.resetTraffic)
g.POST("/groups/bulkAdd", a.bulkAdd)
g.POST("/groups/bulkRemove", a.bulkRemove)
}
func (a *GroupController) list(c *gin.Context) {
rows, err := a.clientService.ListGroups()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, rows, nil)
}
func (a *GroupController) emails(c *gin.Context) {
name := c.Param("name")
emails, err := a.clientService.EmailsByGroup(name)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, emails, nil)
}
type groupCreateBody struct {
Name string `json:"name"`
}
func (a *GroupController) create(c *gin.Context) {
var body groupCreateBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if err := a.clientService.CreateGroup(body.Name); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"name": body.Name}, nil)
notifyClientsChanged()
}
type groupRenameBody struct {
OldName string `json:"oldName"`
NewName string `json:"newName"`
}
func (a *GroupController) rename(c *gin.Context) {
var body groupRenameBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
affected, err := a.clientService.RenameGroup(body.OldName, body.NewName)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
a.xrayService.SetToNeedRestart()
jsonObj(c, gin.H{"affected": affected}, nil)
notifyClientsChanged()
}
type groupDeleteBody struct {
Name string `json:"name"`
}
func (a *GroupController) delete(c *gin.Context) {
var body groupDeleteBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
affected, err := a.clientService.DeleteGroup(body.Name)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
a.xrayService.SetToNeedRestart()
jsonObj(c, gin.H{"affected": affected}, nil)
notifyClientsChanged()
}
type groupResetTrafficBody struct {
Name string `json:"name"`
}
func (a *GroupController) resetTraffic(c *gin.Context) {
var body groupResetTrafficBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if err := a.clientService.ResetGroupTraffic(body.Name); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"name": body.Name}, nil)
notifyClientsChanged()
}
type bulkAddToGroupRequest struct {
Emails []string `json:"emails"`
Group string `json:"group"`
}
func (a *GroupController) bulkAdd(c *gin.Context) {
var req bulkAddToGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if strings.TrimSpace(req.Group) == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("group name is required"))
return
}
affected, err := a.clientService.AddToGroup(req.Emails, req.Group)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"affected": affected}, nil)
notifyClientsChanged()
}
type bulkRemoveFromGroupRequest struct {
Emails []string `json:"emails"`
}
func (a *GroupController) bulkRemove(c *gin.Context) {
var req bulkRemoveFromGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
affected, err := a.clientService.RemoveFromGroup(req.Emails)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"affected": affected}, nil)
a.xrayService.SetToNeedRestart()
notifyClientsChanged()
}
+2 -33
View File
@@ -9,7 +9,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
"github.com/gin-gonic/gin"
@@ -19,7 +18,6 @@ import (
type LoginForm struct {
Username string `json:"username" form:"username"`
Password string `json:"password" form:"password"`
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
}
// IndexController handles the main index and login-related routes.
@@ -28,7 +26,6 @@ type IndexController struct {
settingService service.SettingService
userService panel.UserService
tgbot tgbot.Tgbot
}
// NewIndexController creates a new IndexController and initializes its routes.
@@ -38,14 +35,13 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
return a
}
// initRouter sets up the routes for index, login, logout, and two-factor authentication.
// initRouter sets up the routes for index, login, and logout.
func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index)
g.GET("/csrf-token", a.csrfToken)
g.POST("/login", middleware.CSRFMiddleware(), a.login)
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
}
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
@@ -77,22 +73,14 @@ func (a *IndexController) login(c *gin.Context) {
remoteIP := getRemoteIp(c)
safeUser := template.HTMLEscapeString(form.Username)
timeStr := time.Now().Format("2006-01-02 15:04:05")
if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok {
reason := "too many failed attempts"
logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
Username: safeUser,
IP: remoteIP,
Time: timeStr,
Status: tgbot.LoginFail,
Reason: reason,
})
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
return
}
user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
user, checkErr := a.userService.CheckUser(form.Username, form.Password)
if user == nil {
reason := loginFailureReason(checkErr)
@@ -101,25 +89,12 @@ func (a *IndexController) login(c *gin.Context) {
} else {
logger.Warningf("failed login: username=%q, IP=%q, reason=%q", safeUser, remoteIP, reason)
}
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
Username: safeUser,
IP: remoteIP,
Time: timeStr,
Status: tgbot.LoginFail,
Reason: reason,
})
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
return
}
defaultLoginLimiter.registerSuccess(remoteIP, form.Username)
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, remoteIP)
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
Username: safeUser,
IP: remoteIP,
Time: timeStr,
Status: tgbot.LoginSuccess,
})
if err := session.SetLoginUser(c, user); err != nil {
logger.Warning("Unable to save session:", err)
@@ -159,9 +134,3 @@ func (a *IndexController) csrfToken(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"success": true, "obj": token})
}
// getTwoFactorEnable retrieves the current status of two-factor authentication.
func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
status, err := a.settingService.GetTwoFactorEnable()
jsonObj(c, status, err)
}
+19 -1
View File
@@ -144,7 +144,9 @@ func (a *NodeController) add(c *gin.Context) {
if !ok {
return
}
if n.OutboundTag == "" {
isAgent := n.NodeType == "agent"
// Agent nodes have no panel to probe; they authenticate via agent token.
if n.OutboundTag == "" && !isAgent {
if err := a.ensureReachable(c, n, 0); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
@@ -164,6 +166,22 @@ func (a *NodeController) add(c *gin.Context) {
return
}
}
// For agent nodes, issue an agent token bound to the new node and
// return it so the UI can display it (shown only once).
if isAgent {
nodeID := view.Id
token, _, err := issueAgentToken(&nodeID, n.Name)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
"node": view,
"agentToken": token,
"nodeType": "agent",
}, nil)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), view, nil)
}
-34
View File
@@ -49,7 +49,6 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/xrayMetricsHistory/:metric/:bucket", a.getXrayMetricsHistoryBucket)
g.GET("/xrayObservatory", a.getXrayObservatory)
g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket)
g.GET("/getXrayVersion", a.getXrayVersion)
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getUpdateStatus", a.getUpdateStatus)
g.GET("/getConfigJson", a.getConfigJson)
@@ -67,11 +66,8 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.POST("/stopXrayService", a.stopXrayService)
g.POST("/restartXrayService", a.restartXrayService)
g.POST("/installXray/:version", a.installXray)
g.POST("/updatePanel", a.updatePanel)
g.POST("/setUpdateChannel", a.setUpdateChannel)
g.POST("/updateGeofile", a.updateGeofile)
g.POST("/updateGeofile/:fileName", a.updateGeofile)
g.POST("/logs/:count", a.getLogs)
g.POST("/xraylogs/:count", a.getXrayLogs)
g.POST("/amneziawglogs/:count", a.getAmneziaWGLogs)
@@ -182,15 +178,6 @@ func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) {
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
}
func (a *ServerController) getXrayVersion(c *gin.Context) {
versions, err := a.serverService.GetXrayVersionsCached()
if err != nil {
jsonMsg(c, I18nWeb(c, "getVersion"), err)
return
}
jsonObj(c, versions, nil)
}
// getPanelUpdateInfo retrieves the current and latest panel version.
func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
info, err := a.panelService.GetUpdateInfo()
@@ -202,13 +189,6 @@ func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
jsonObj(c, info, nil)
}
// installXray installs or updates Xray to the specified version.
func (a *ServerController) installXray(c *gin.Context) {
version := c.Param("version")
err := a.serverService.UpdateXray(version)
jsonMsg(c, I18nWeb(c, "pages.index.xraySwitchVersionPopover"), err)
}
// updatePanel starts a panel self-update. With no "dev" form value it follows
// this panel's own channel setting; an explicit "dev" (sent by the master node
// updater) overrides it for this run. The response's runId identifies this
@@ -252,20 +232,6 @@ func (a *ServerController) setUpdateChannel(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.index.updateChannelChanged"), err)
}
// updateGeofile updates the specified geo file for Xray.
func (a *ServerController) updateGeofile(c *gin.Context) {
fileName := c.Param("fileName")
if fileName != "" && !a.serverService.IsValidGeofileName(fileName) {
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"),
fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns"))
return
}
err := a.serverService.UpdateGeofile(fileName)
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), err)
}
// stopXrayService stops the Xray service.
func (a *ServerController) stopXrayService(c *gin.Context) {
err := a.serverService.StopXrayService()
+5 -113
View File
@@ -4,7 +4,6 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -12,7 +11,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/email"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
@@ -25,19 +23,15 @@ type updateUserForm struct {
OldPassword string `json:"oldPassword" form:"oldPassword"`
NewUsername string `json:"newUsername" form:"newUsername"`
NewPassword string `json:"newPassword" form:"newPassword"`
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
}
// updateSettingForm carries the persisted settings plus request-scoped fields
// that must never land in the settings table: the 2FA confirmation code and
// the explicit clear flags for redacted secrets (a blank secret alone means
// "unchanged", so clearing needs its own signal — see #5724).
// updateSettingForm carries the persisted settings plus the explicit clear
// flags for redacted secrets, which must never land in the settings table (a
// blank secret alone means "unchanged", so clearing needs its own signal —
// see #5724).
type updateSettingForm struct {
entity.AllSetting
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"`
ClearLdapPassword bool `json:"clearLdapPassword" form:"clearLdapPassword"`
ClearSmtpPassword bool `json:"clearSmtpPassword" form:"clearSmtpPassword"`
}
type validateRegexForm struct {
@@ -76,8 +70,6 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
g.POST("/apiTokens/create", a.createApiToken)
g.POST("/apiTokens/delete/:id", a.deleteApiToken)
g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
g.POST("/testSmtp", a.testSmtp)
g.POST("/testTgBot", a.testTgBot)
}
func (a *SettingController) validateRegex(c *gin.Context) {
@@ -125,34 +117,8 @@ func (a *SettingController) updateSetting(c *gin.Context) {
return
}
allSetting := &form.AllSetting
oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
oldTgEnable, _ := a.settingService.GetTgbotEnabled()
oldTgToken, _ := a.settingService.GetTgBotToken()
oldTgChatId, _ := a.settingService.GetTgBotChatId()
oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer()
if twoFactorErr == nil && oldTwoFactor {
// Rebinding the authenticator is the same class of change as turning 2FA
// off, so both need a current code. Blank still means "unchanged".
submittedToken := strings.TrimSpace(allSetting.TwoFactorToken)
storedToken, _ := a.settingService.GetTwoFactorToken()
if !allSetting.TwoFactorEnable || (submittedToken != "" && submittedToken != storedToken) {
if err := a.settingService.VerifyTwoFactorCode(form.TwoFactorCode); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
}
}
err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{
TgBotToken: form.ClearTgBotToken,
LdapPassword: form.ClearLdapPassword,
SmtpPassword: form.ClearSmtpPassword,
})
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
err = bumpErr
}
}
err := a.settingService.UpdateAllSetting(allSetting)
if err == nil && form.PanelOutbound != oldPanelOutbound {
// The egress bridge lives in the generated config; reconcile the
// running core. One SOCKS inbound plus one routing rule — both
@@ -161,16 +127,6 @@ func (a *SettingController) updateSetting(c *gin.Context) {
logger.Warning("apply panel outbound change failed:", applyErr)
}
}
// UpdateAllSetting already restored a redacted-blank token, so allSetting.TgBotToken is the effective value to compare.
if err == nil && reloadTgbotFunc != nil {
tgChanged := oldTgEnable != allSetting.TgBotEnable ||
(allSetting.TgBotEnable && (oldTgToken != allSetting.TgBotToken ||
oldTgChatId != allSetting.TgBotChatId ||
oldTgAPIServer != allSetting.TgBotAPIServer))
if tgChanged {
reloadTgbotFunc()
}
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
}
@@ -191,10 +147,6 @@ func (a *SettingController) updateUser(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), errors.New(I18nWeb(c, "pages.settings.toasts.userPassMustBeNotEmpty")))
return
}
if err := a.settingService.VerifyTwoFactorCode(form.TwoFactorCode); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), err)
return
}
err = a.userService.UpdateUser(user.Id, form.NewUsername, form.NewPassword)
if err == nil {
user.Username = form.NewUsername
@@ -287,63 +239,3 @@ func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabledExpectedScope(id, form.ExpectedScope, form.Enabled))
}
func (a *SettingController) testSmtp(c *gin.Context) {
if emailService == nil {
jsonMsg(c, I18nWeb(c, "pages.settings.smtpNotInitialized"), errors.New("email service not available"))
return
}
logger.Info("SMTP test: starting...")
result := emailService.TestConnection()
if !result.Success {
logger.Warning("SMTP test failed at", result.Stage+":", result.Message)
c.JSON(200, gin.H{
"success": false,
"stage": result.Stage,
"msg": result.Message,
})
return
}
logger.Info("SMTP test: success")
c.JSON(200, gin.H{
"success": true,
"stage": result.Stage,
"msg": result.Message,
})
}
func (a *SettingController) testTgBot(c *gin.Context) {
enabled, err := a.settingService.GetTgbotEnabled()
if err != nil || !enabled {
jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotEnabled"), errors.New("telegram bot disabled"))
return
}
// Import tgbot package would create a circular dependency, so we call
// the test through the global function registered at startup.
if testTgFunc != nil {
if err := testTgFunc(); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.tgTestFailed")+": "+err.Error(), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.tgTestSuccess"), nil)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotRunning"), errors.New("bot not started"))
}
// testTgFunc is set from web layer to test Telegram sending without circular imports.
var testTgFunc func() error
// SetTestTgFunc registers the function used to test Telegram sending.
func SetTestTgFunc(fn func() error) { testTgFunc = fn }
// reloadTgbotFunc is wired from the web layer; importing tgbot here would be a circular dependency.
var reloadTgbotFunc func()
func SetReloadTgbotFunc(fn func()) { reloadTgbotFunc = fn }
// emailService is set from web layer.
var emailService *email.EmailService
// SetEmailService registers the email service for test endpoints.
func SetEmailService(s *email.EmailService) { emailService = s }
-83
View File
@@ -1,7 +1,6 @@
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -14,7 +13,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
func TestValidateRegex(t *testing.T) {
@@ -89,84 +87,3 @@ func TestAPITokenMutationRoutesEnforceExpectedScope(t *testing.T) {
}
}
// GHSA-xqqw-jqqv-99h6: a save that keeps 2FA enabled must not be able to
// rebind the authenticator without presenting a current code.
func TestUpdateSettingRequiresCodeToReplaceTwoFactorToken(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
settingService := service.SettingService{}
if err := settingService.SetTwoFactorToken("ORIGINALSECRET234567"); err != nil {
t.Fatalf("seed token: %v", err)
}
if err := settingService.SetTwoFactorEnable(true); err != nil {
t.Fatalf("seed enable: %v", err)
}
post := func(t *testing.T, mutate func(map[string]any)) string {
t.Helper()
base, err := settingService.GetAllSetting()
if err != nil {
t.Fatalf("GetAllSetting: %v", err)
}
raw, err := json.Marshal(base)
if err != nil {
t.Fatalf("marshal: %v", err)
}
body := map[string]any{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
mutate(body)
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
gin.SetMode(gin.TestMode)
router := gin.New()
NewSettingController(router.Group("/panel/api"))
req := httptest.NewRequest(http.MethodPost, "/panel/api/setting/update", strings.NewReader(string(payload)))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
return resp.Body.String()
}
t.Run("rebind without code is rejected", func(t *testing.T) {
got := post(t, func(body map[string]any) {
body["twoFactorEnable"] = true
body["twoFactorToken"] = "ATTACKERSECRET567890"
})
if !strings.Contains(got, `"success":false`) {
t.Fatalf("rebind without a 2FA code was accepted: %s", got)
}
stored, err := settingService.GetTwoFactorToken()
if err != nil {
t.Fatalf("GetTwoFactorToken: %v", err)
}
if stored != "ORIGINALSECRET234567" {
t.Fatalf("stored 2FA secret = %q, want it unchanged", stored)
}
})
t.Run("ordinary save with redacted token still succeeds", func(t *testing.T) {
got := post(t, func(body map[string]any) {
body["twoFactorEnable"] = true
body["twoFactorToken"] = ""
})
if !strings.Contains(got, `"success":true`) {
t.Fatalf("normal settings save was rejected: %s", got)
}
stored, err := settingService.GetTwoFactorToken()
if err != nil {
t.Fatalf("GetTwoFactorToken: %v", err)
}
if stored != "ORIGINALSECRET234567" {
t.Fatalf("stored 2FA secret = %q, want it preserved", stored)
}
})
}
-25
View File
@@ -5,31 +5,6 @@ import (
"testing"
)
func TestCheckValidSmtpFrom(t *testing.T) {
base := func() *AllSetting {
return &AllSetting{WebPort: 2053, SubPort: 2096}
}
for _, v := range []string{"", "panel@example.com"} {
s := base()
s.SmtpFrom = v
if err := s.CheckValid(); err != nil {
t.Errorf("CheckValid with smtpFrom=%q: unexpected error %v", v, err)
}
}
for _, v := range []string{
"not-an-address",
"panel@example.com\r\nBcc: evil@example.com",
"a@b\nSubject: injected",
} {
s := base()
s.SmtpFrom = v
if err := s.CheckValid(); err == nil {
t.Errorf("CheckValid with smtpFrom=%q: want error, got nil", v)
}
}
}
func TestCheckValidWildcardListenPortConflict(t *testing.T) {
s := &AllSetting{WebPort: 2053, SubPort: 2053, WebListen: "0.0.0.0", SubListen: ""}
-69
View File
@@ -4,7 +4,6 @@ import (
"crypto/tls"
"math"
"net"
"net/mail"
"net/netip"
"strings"
"time"
@@ -37,36 +36,9 @@ type AllSetting struct {
SubShowIdentityOnAllLinks bool `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
Datepicker string `json:"datepicker" form:"datepicker"`
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"`
TgBotToken string `json:"tgBotToken" form:"tgBotToken"`
TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"`
TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"`
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"`
TgRunTime string `json:"tgRunTime" form:"tgRunTime"`
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"`
TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"`
TgMemory int `json:"tgMemory" form:"tgMemory" validate:"gte=0,lte=100"`
TgLang string `json:"tgLang" form:"tgLang"`
TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"`
SmtpEnable bool `json:"smtpEnable" form:"smtpEnable"`
SmtpHost string `json:"smtpHost" form:"smtpHost"`
SmtpPort int `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"`
SmtpUsername string `json:"smtpUsername" form:"smtpUsername"`
SmtpPassword string `json:"smtpPassword" form:"smtpPassword"`
SmtpFrom string `json:"smtpFrom" form:"smtpFrom"`
SmtpFromName string `json:"smtpFromName" form:"smtpFromName"`
SmtpTo string `json:"smtpTo" form:"smtpTo"`
SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"`
SmtpEnabledEvents string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"`
SmtpCpu int `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"`
SmtpMemory int `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"`
OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"`
TimeLocation string `json:"timeLocation" form:"timeLocation"`
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"`
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"`
SubEnable bool `json:"subEnable" form:"subEnable"`
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
@@ -79,10 +51,6 @@ type AllSetting struct {
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"`
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"`
SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"`
SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"`
SubListen string `json:"subListen" form:"subListen"`
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`
SubPath string `json:"subPath" form:"subPath"`
@@ -90,8 +58,6 @@ type AllSetting struct {
SubCertFile string `json:"subCertFile" form:"subCertFile"`
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"`
SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"`
ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"`
ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"`
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"`
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"`
SubURI string `json:"subURI" form:"subURI"`
@@ -100,50 +66,21 @@ type AllSetting struct {
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"`
SubClashPath string `json:"subClashPath" form:"subClashPath"`
SubClashURI string `json:"subClashURI" form:"subClashURI"`
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"`
SubClashRules string `json:"subClashRules" form:"subClashRules"`
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"`
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"`
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
LdapHost string `json:"ldapHost" form:"ldapHost"`
LdapPort int `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"`
LdapUseTLS bool `json:"ldapUseTLS" form:"ldapUseTLS"`
LdapInsecureSkipVerify bool `json:"ldapInsecureSkipVerify" form:"ldapInsecureSkipVerify"`
LdapBindDN string `json:"ldapBindDN" form:"ldapBindDN"`
LdapPassword string `json:"ldapPassword" form:"ldapPassword"`
LdapBaseDN string `json:"ldapBaseDN" form:"ldapBaseDN"`
LdapUserFilter string `json:"ldapUserFilter" form:"ldapUserFilter"`
LdapUserAttr string `json:"ldapUserAttr" form:"ldapUserAttr"`
LdapVlessField string `json:"ldapVlessField" form:"ldapVlessField"`
LdapSyncCron string `json:"ldapSyncCron" form:"ldapSyncCron"`
LdapFlagField string `json:"ldapFlagField" form:"ldapFlagField"`
LdapTruthyValues string `json:"ldapTruthyValues" form:"ldapTruthyValues"`
LdapInvertFlag bool `json:"ldapInvertFlag" form:"ldapInvertFlag"`
LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"`
LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"`
LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"`
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB" validate:"gte=0"`
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
}
type AllSettingView struct {
AllSetting
HasTgBotToken bool `json:"hasTgBotToken"`
HasTwoFactorToken bool `json:"hasTwoFactorToken"`
HasLdapPassword bool `json:"hasLdapPassword"`
HasApiToken bool `json:"hasApiToken"`
HasWarpSecret bool `json:"hasWarpSecret"`
HasNordSecret bool `json:"hasNordSecret"`
HasSmtpPassword bool `json:"hasSmtpPassword"`
}
func pathHasForbiddenChar(s string) bool {
@@ -288,12 +225,6 @@ func (s *AllSetting) CheckValid() error {
return common.NewError("time location not exist:", s.TimeLocation)
}
if s.SmtpFrom != "" {
if _, err := mail.ParseAddress(s.SmtpFrom); err != nil {
return common.NewError("SMTP from address is not valid:", s.SmtpFrom)
}
}
return nil
}
-22
View File
@@ -1,22 +0,0 @@
package job
import "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
// CheckHashStorageJob periodically cleans up expired hash entries from the Telegram bot's hash storage.
type CheckHashStorageJob struct {
tgbotService tgbot.Tgbot
}
// NewCheckHashStorageJob creates a new hash storage cleanup job instance.
func NewCheckHashStorageJob() *CheckHashStorageJob {
return new(CheckHashStorageJob)
}
// Run removes expired hash entries from the Telegram bot's hash storage.
func (j *CheckHashStorageJob) Run() {
storage := j.tgbotService.GetHashStorage()
if storage == nil {
return
}
storage.RemoveExpiredHashes()
}
@@ -1,12 +0,0 @@
package job
import "testing"
func TestCheckHashStorageJob_RunWithoutPanicWhenStorageNil(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("CheckHashStorageJob.Run panicked when storage is nil: %v", r)
}
}()
NewCheckHashStorageJob().Run()
}

Some files were not shown because too many files have changed in this diff Show More