Compare commits
29
Commits
v3.7.0
...
v3.7.0-agent.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17be1ad5ae | ||
|
|
a654a96c38 | ||
|
|
945b3a56a2 | ||
|
|
b8e132fc0b | ||
|
|
c67f0cb0cf | ||
|
|
8da3cde494 | ||
|
|
5ca69dd9c1 | ||
|
|
284040e994 | ||
|
|
70d24d2f15 | ||
|
|
cb8981158f | ||
|
|
c56a02c948 | ||
|
|
4149a162b8 | ||
|
|
b9e1345b23 | ||
|
|
7d42b91ed6 | ||
|
|
3cbf9164a4 | ||
|
|
e42b5d770d | ||
|
|
1498d58f14 | ||
|
|
981ba8abe1 | ||
|
|
7ece6e079b | ||
|
|
f9b0672d84 | ||
|
|
2c0d9c1209 | ||
|
|
57474e0700 | ||
|
|
bcd7b47c3d | ||
|
|
9105f7f364 | ||
|
|
6381587307 | ||
|
|
fc59cb1032 | ||
|
|
2f2efdac42 | ||
|
|
1701eb24e2 | ||
|
|
0ba1ec0596 |
+5
-3
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
## النجوم عبر الزمن
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
+5
-23
@@ -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
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
+5
-23
@@ -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>
|
||||
|
||||
## ستارهها در طول زمان
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
@@ -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
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
GPL-3.0(继承上游 3x-ui)
|
||||
|
||||
+5
-23
@@ -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>
|
||||
|
||||
## Звезды с течением времени
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
+5
-23
@@ -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
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
+6
-23
@@ -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>
|
||||
|
||||
## 随时间变化的星标数
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
@@ -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
@@ -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://` 内网地址 |
|
||||
@@ -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": [
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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 }))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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(',') : '';
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as DnsTab } from './DnsTab';
|
||||
export { default as DnsServerModal } from './DnsServerModal';
|
||||
export { default as DnsPresetsModal } from './DnsPresetsModal';
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
Executable
+83
@@ -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
|
||||
Executable
+60
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !cgo
|
||||
|
||||
package database
|
||||
|
||||
import "errors"
|
||||
|
||||
func BackupSQLite(dstPath string) error {
|
||||
return errors.New("sqlite backup requires CGO build")
|
||||
}
|
||||
@@ -58,6 +58,7 @@ func migrationModels() []any {
|
||||
&model.ClientGlobalTraffic{},
|
||||
&model.OutboundSubscription{},
|
||||
&model.SubBalancer{},
|
||||
&model.AgentNode{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 "/"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: ""}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user