Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 950a647bcc | |||
| c8ad42631c | |||
| 4f597a08c4 | |||
| d56505004e | |||
| f0e459e51e | |||
| 327228d8f3 | |||
| d2dc589f14 | |||
| 87f446fe22 | |||
| 49ef1449f1 | |||
| b9612f1326 | |||
| 7bc31dd194 | |||
| 8fa248c621 | |||
| 01d2ec5061 | |||
| 56ec359041 | |||
| b2e2120eb3 | |||
| cb17eb8c06 | |||
| 49bec1db0f | |||
| 5b6e05a0fc | |||
| bcb982aeba | |||
| ccd0853b6c | |||
| 3657ed55dc | |||
| 47d9b49666 | |||
| 5b9ed34009 | |||
| 588ea86298 | |||
| 7f8c79675f | |||
| 80173b1b1d | |||
| 6ae1b38607 | |||
| 803e010921 | |||
| b6641439d4 | |||
| d29a17d333 | |||
| 39b716409a | |||
| 13c04bb982 | |||
| 28330e60d8 | |||
| 72121784fe | |||
| 16edb037e7 | |||
| 2b7c1eeb6a | |||
| 6b2243a40f | |||
| f9aa363a63 |
@@ -7,29 +7,143 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
<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>
|
||||
<a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
|
||||
</p>
|
||||
|
||||
**3X-UI** — لوحة تحكم متقدمة مفتوحة المصدر تعتمد على الويب مصممة لإدارة خادم Xray-core. توفر واجهة سهلة الاستخدام لتكوين ومراقبة بروتوكولات VPN والوكيل المختلفة.
|
||||
**3X-UI** هي لوحة تحكم ويب متقدمة ومفتوحة المصدر لإدارة خوادم [Xray-core](https://github.com/XTLS/Xray-core). توفّر واجهة نظيفة ومتعددة اللغات لنشر وتكوين ومراقبة مجموعة واسعة من بروتوكولات الوكيل وVPN — من خادم VPS واحد إلى عمليات النشر متعددة العقد.
|
||||
|
||||
تم بناء 3X-UI كنسخة محسّنة (fork) من مشروع X-UI الأصلي، وتضيف دعمًا أوسع للبروتوكولات، واستقرارًا محسّنًا، ومحاسبة للترافيك لكل عميل، والعديد من ميزات تحسين تجربة الاستخدام.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> هذا المشروع مخصص للاستخدام الشخصي والاتصال فقط، يرجى عدم استخدامه لأغراض غير قانونية، يرجى عدم استخدامه في بيئة الإنتاج.
|
||||
> هذا المشروع مخصص للاستخدام الشخصي فقط. يرجى عدم استخدامه لأغراض غير قانونية أو في بيئة إنتاجية.
|
||||
|
||||
كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
|
||||
## الميزات
|
||||
|
||||
- **اتصالات واردة متعددة البروتوكولات** — VLESS، VMess، Trojan، Shadowsocks، WireGuard، Hysteria2، HTTP، SOCKS (Mixed)، Dokodemo-door / Tunnel و TUN.
|
||||
- **وسائل نقل وأمان حديثة** — TCP (Raw)، mKCP، WebSocket، gRPC، HTTPUpgrade و XHTTP، مؤمَّنة بـ TLS و XTLS و REALITY.
|
||||
- **Fallback** — تقديم عدة بروتوكولات على منفذ واحد (مثل VLESS و Trojan على المنفذ 443) باستخدام ميزة fallback في Xray.
|
||||
- **إدارة لكل عميل** — حصص الترافيك، تواريخ انتهاء الصلاحية، حدود IP، حالة الاتصال المباشرة، وروابط مشاركة وأكواد QR واشتراكات بنقرة واحدة.
|
||||
- **إحصائيات الترافيك** — لكل اتصال وارد، ولكل عميل، ولكل اتصال صادر، مع عناصر تحكم لإعادة التعيين.
|
||||
- **دعم العقد المتعددة** — إدارة وتوسيع عبر عدة خوادم من لوحة واحدة.
|
||||
- **الاتصالات الصادرة والتوجيه** — WARP، NordVPN، قواعد توجيه مخصصة، موازنات تحميل، وتسلسل الوكلاء الصادرة.
|
||||
- **خادم اشتراك مدمج** بصيغ إخراج متعددة.
|
||||
- **روبوت تيليجرام** للمراقبة والإدارة عن بُعد.
|
||||
- **واجهة RESTful API** مع توثيق Swagger داخل اللوحة.
|
||||
- **تخزين مرن** — SQLite (افتراضي) أو PostgreSQL.
|
||||
- **13 لغة لواجهة المستخدم** مع سمات داكنة وفاتحة.
|
||||
- **تكامل مع Fail2ban** لفرض حدود IP لكل عميل.
|
||||
|
||||
## لقطات الشاشة
|
||||
|
||||
<details>
|
||||
<summary>انقر للتوسيع</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>
|
||||
|
||||
## البدء السريع
|
||||
|
||||
```
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
أثناء التثبيت، يتم إنشاء اسم مستخدم وكلمة مرور ومسار وصول عشوائية. بعد التثبيت، شغّل `x-ui` لفتح قائمة الإدارة، حيث يمكنك بدء/إيقاف الخدمة، وعرض أو إعادة تعيين بيانات تسجيل الدخول، وإدارة شهادات SSL، والمزيد.
|
||||
|
||||
للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
## المنصات المدعومة
|
||||
|
||||
**أنظمة التشغيل:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap)، Alpine و Windows.
|
||||
|
||||
**المعماريات:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
|
||||
|
||||
## خيارات قاعدة البيانات
|
||||
|
||||
يدعم 3X-UI خلفيتين (backends) يتم اختيارهما أثناء التثبيت:
|
||||
|
||||
- **SQLite** (افتراضي) — ملف واحد في `/etc/x-ui/x-ui.db`. بدون إعداد، مثالي لعمليات النشر الصغيرة والمتوسطة.
|
||||
- **PostgreSQL** — موصى به لأعداد العملاء الكبيرة أو الإعدادات متعددة العقد. يمكن للمثبِّت تثبيت PostgreSQL محليًا لك، أو قبول DSN لخادم موجود.
|
||||
|
||||
في وقت التشغيل، يتم اختيار الخلفية عبر متغيرات البيئة (يكتبها المثبِّت لك في `/etc/default/x-ui`):
|
||||
|
||||
```
|
||||
XUI_DB_TYPE=postgres
|
||||
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
||||
```
|
||||
|
||||
### ترحيل تثبيت SQLite موجود إلى PostgreSQL
|
||||
|
||||
```bash
|
||||
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
|
||||
# ثم عيّن XUI_DB_TYPE و XUI_DB_DSN في /etc/default/x-ui وأعد التشغيل:
|
||||
systemctl restart x-ui
|
||||
```
|
||||
|
||||
يبقى ملف SQLite الأصلي دون تغيير؛ احذفه يدويًا بعد التحقق من الخلفية الجديدة.
|
||||
|
||||
### Docker
|
||||
|
||||
يستمر الأمر الافتراضي `docker compose up -d` في استخدام SQLite. للتشغيل مع خدمة PostgreSQL المرفقة، أزِل التعليق عن سطري متغيرات البيئة `XUI_DB_*` في `docker-compose.yml` وشغّل باستخدام البروفايل:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## متغيرات البيئة
|
||||
|
||||
| المتغير | الوصف | الافتراضي |
|
||||
| --- | --- | --- |
|
||||
| `XUI_DB_TYPE` | خلفية قاعدة البيانات: `sqlite` أو `postgres` | `sqlite` |
|
||||
| `XUI_DB_DSN` | سلسلة اتصال PostgreSQL (عندما `XUI_DB_TYPE=postgres`) | — |
|
||||
| `XUI_DB_FOLDER` | مجلد ملف قاعدة بيانات SQLite | `/etc/x-ui` |
|
||||
| `XUI_DB_MAX_OPEN_CONNS` | الحد الأقصى للاتصالات المفتوحة (تجمّع PostgreSQL) | — |
|
||||
| `XUI_DB_MAX_IDLE_CONNS` | الحد الأقصى للاتصالات الخاملة (تجمّع PostgreSQL) | — |
|
||||
| `XUI_ENABLE_FAIL2BAN` | تفعيل فرض حدود IP المعتمد على Fail2ban | `true` |
|
||||
| `XUI_LOG_LEVEL` | مستوى السجل (`debug`، `info`، `warning`، `error`) | `info` |
|
||||
| `XUI_DEBUG` | تفعيل وضع التصحيح | `false` |
|
||||
|
||||
## اللغات المدعومة
|
||||
|
||||
تتوفر واجهة اللوحة بـ 13 لغة:
|
||||
|
||||
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
||||
|
||||
## المساهمة
|
||||
|
||||
المساهمات مرحب بها. يرجى قراءة [دليل المساهمة](/CONTRIBUTING.md) قبل فتح مشكلة (issue) أو طلب سحب (pull request).
|
||||
|
||||
## شكر خاص إلى
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
@@ -7,28 +7,142 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
<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>
|
||||
<a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
|
||||
</p>
|
||||
|
||||
**3X-UI** — panel de control avanzado basado en web de código abierto diseñado para gestionar el servidor Xray-core. Ofrece una interfaz fácil de usar para configurar y monitorear varios protocolos VPN y proxy.
|
||||
**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.
|
||||
|
||||
Construido como un fork mejorado del proyecto X-UI original, 3X-UI añade un soporte de protocolos más amplio, mayor estabilidad, contabilidad de tráfico por cliente y muchas funciones que mejoran la experiencia de uso.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Este proyecto es solo para uso personal y comunicación, por favor no lo use para fines ilegales, por favor no lo use en un entorno de producción.
|
||||
> Este proyecto está destinado únicamente al uso personal. Por favor, no lo uses para fines ilegales ni en un entorno de producción.
|
||||
|
||||
Como una versión mejorada del proyecto X-UI original, 3X-UI proporciona mayor estabilidad, soporte más amplio de protocolos y características adicionales.
|
||||
## Características
|
||||
|
||||
- **Entradas multiprotocolo** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel y TUN.
|
||||
- **Transportes y seguridad modernos** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade y XHTTP, protegidos con TLS, XTLS y REALITY.
|
||||
- **Fallbacks** — sirve varios protocolos en un solo puerto (p. ej. VLESS y Trojan en el 443) usando la función de fallback de Xray.
|
||||
- **Gestión por cliente** — cuotas de tráfico, fechas de caducidad, límites de IP, estado en línea en tiempo real y enlaces de compartición, códigos QR y suscripciones con un solo clic.
|
||||
- **Estadísticas de tráfico** — por entrada, por cliente y por salida, con controles de reinicio.
|
||||
- **Soporte multinodo** — gestiona y escala a través de varios servidores desde un único panel.
|
||||
- **Salida y enrutamiento** — WARP, NordVPN, reglas de enrutamiento personalizadas, balanceadores de carga y encadenamiento de proxy de salida.
|
||||
- **Servidor de suscripción integrado** con múltiples formatos de salida.
|
||||
- **Bot de Telegram** para monitorización y gestión remotas.
|
||||
- **API RESTful** con documentación Swagger dentro del panel.
|
||||
- **Almacenamiento flexible** — SQLite (predeterminado) o PostgreSQL.
|
||||
- **13 idiomas de interfaz** con temas oscuro y claro.
|
||||
- **Integración con Fail2ban** para aplicar límites de IP por cliente.
|
||||
|
||||
## Capturas de pantalla
|
||||
|
||||
<details>
|
||||
<summary>Haz clic para expandir</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>
|
||||
|
||||
## Inicio Rápido
|
||||
|
||||
```
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
Para documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
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).
|
||||
|
||||
## Plataformas Compatibles
|
||||
|
||||
**Sistemas operativos:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine y Windows.
|
||||
|
||||
**Arquitecturas:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
|
||||
|
||||
## Opciones de Base de Datos
|
||||
|
||||
3X-UI admite dos backends, que se eligen durante la instalación:
|
||||
|
||||
- **SQLite** (predeterminado) — un único archivo en `/etc/x-ui/x-ui.db`. Sin configuración, ideal para despliegues pequeños y medianos.
|
||||
- **PostgreSQL** — recomendado para un gran número de clientes o configuraciones multinodo. El instalador puede instalar PostgreSQL localmente por ti, o aceptar un DSN a un servidor existente.
|
||||
|
||||
En tiempo de ejecución, el backend se selecciona mediante variables de entorno (el instalador las escribe por ti en `/etc/default/x-ui`):
|
||||
|
||||
```
|
||||
XUI_DB_TYPE=postgres
|
||||
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
||||
```
|
||||
|
||||
### Migrar una instalación de SQLite existente a PostgreSQL
|
||||
|
||||
```bash
|
||||
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
|
||||
# luego define XUI_DB_TYPE y XUI_DB_DSN en /etc/default/x-ui y reinicia:
|
||||
systemctl restart x-ui
|
||||
```
|
||||
|
||||
El archivo SQLite de origen permanece intacto; elimínalo manualmente una vez que hayas verificado el nuevo backend.
|
||||
|
||||
### Docker
|
||||
|
||||
El comando predeterminado `docker compose up -d` sigue usando SQLite. Para ejecutarlo con el servicio PostgreSQL incluido, descomenta las dos líneas de variables de entorno `XUI_DB_*` en `docker-compose.yml` e inícialo con el perfil:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## Variables de Entorno
|
||||
|
||||
| Variable | Descripción | Predeterminado |
|
||||
| --- | --- | --- |
|
||||
| `XUI_DB_TYPE` | Backend de base de datos: `sqlite` o `postgres` | `sqlite` |
|
||||
| `XUI_DB_DSN` | Cadena de conexión de PostgreSQL (cuando `XUI_DB_TYPE=postgres`) | — |
|
||||
| `XUI_DB_FOLDER` | Directorio del archivo de base de datos SQLite | `/etc/x-ui` |
|
||||
| `XUI_DB_MAX_OPEN_CONNS` | Máximo de conexiones abiertas (pool de PostgreSQL) | — |
|
||||
| `XUI_DB_MAX_IDLE_CONNS` | Máximo de conexiones inactivas (pool de PostgreSQL) | — |
|
||||
| `XUI_ENABLE_FAIL2BAN` | Habilitar la aplicación de límites de IP basada en Fail2ban | `true` |
|
||||
| `XUI_LOG_LEVEL` | Nivel de registro (`debug`, `info`, `warning`, `error`) | `info` |
|
||||
| `XUI_DEBUG` | Habilitar el modo de depuración | `false` |
|
||||
|
||||
## Idiomas Compatibles
|
||||
|
||||
La interfaz del panel está disponible en 13 idiomas:
|
||||
|
||||
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
||||
|
||||
## Contribuir
|
||||
|
||||
Las contribuciones son bienvenidas. Por favor, lee la [Guía de contribución](/CONTRIBUTING.md) antes de abrir una incidencia (issue) o una solicitud de incorporación (pull request).
|
||||
|
||||
## Un Agradecimiento Especial a
|
||||
|
||||
|
||||
@@ -7,29 +7,143 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
<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>
|
||||
<a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
|
||||
</p>
|
||||
|
||||
**3X-UI** — یک پنل کنترل پیشرفته مبتنی بر وب با کد باز که برای مدیریت سرور Xray-core طراحی شده است. این پنل یک رابط کاربری آسان برای پیکربندی و نظارت بر پروتکلهای مختلف VPN و پراکسی ارائه میدهد.
|
||||
**3X-UI** یک پنل کنترل وب پیشرفته و متنباز برای مدیریت سرورهای [Xray-core](https://github.com/XTLS/Xray-core) است. این پنل یک رابط کاربری تمیز و چندزبانه برای استقرار، پیکربندی و نظارت بر طیف گستردهای از پروتکلهای پراکسی و VPN ارائه میدهد — از یک VPS تکی تا استقرارهای چندنودی.
|
||||
|
||||
3X-UI که بهعنوان یک فورک بهبودیافته از پروژهی اصلی X-UI ساخته شده است، پشتیبانی گستردهتر از پروتکلها، پایداری بهتر، حسابداری ترافیک بهازای هر کلاینت و بسیاری از ویژگیهای رفاهی را اضافه میکند.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> این پروژه فقط برای استفاده شخصی و ارتباطات است، لطفاً از آن برای اهداف غیرقانونی استفاده نکنید، لطفاً از آن در محیط تولید استفاده نکنید.
|
||||
> این پروژه فقط برای استفادهی شخصی در نظر گرفته شده است. لطفاً از آن برای اهداف غیرقانونی یا در محیط تولید (production) استفاده نکنید.
|
||||
|
||||
به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گستردهتر از پروتکلها و ویژگیهای اضافی را ارائه میدهد.
|
||||
## ویژگیها
|
||||
|
||||
- **اینباندهای چندپروتکلی** — VLESS، VMess، Trojan، Shadowsocks، WireGuard، Hysteria2، HTTP، SOCKS (Mixed)، Dokodemo-door / Tunnel و TUN.
|
||||
- **ترنسپورتها و امنیت مدرن** — TCP (Raw)، mKCP، WebSocket، gRPC، HTTPUpgrade و XHTTP، ایمنشده با TLS، XTLS و REALITY.
|
||||
- **فالبک (Fallback)** — ارائهی چند پروتکل روی یک پورت واحد (مثلاً VLESS و Trojan روی پورت 443) با استفاده از قابلیت fallback در Xray.
|
||||
- **مدیریت بهازای هر کلاینت** — سهمیهی ترافیک، تاریخ انقضا، محدودیت IP، وضعیت آنلاینِ زنده و لینکهای اشتراکگذاری، کدهای QR و سابسکریپشنها با یک کلیک.
|
||||
- **آمار ترافیک** — بهازای هر اینباند، هر کلاینت و هر اوتباند، همراه با کنترل بازنشانی (reset).
|
||||
- **پشتیبانی از چند نود** — مدیریت و مقیاسدهی روی چندین سرور از یک پنل واحد.
|
||||
- **اوتباند و مسیریابی** — WARP، NordVPN، قوانین مسیریابی سفارشی، متعادلکنندههای بار (load balancer) و زنجیرهکردن پراکسی اوتباند.
|
||||
- **سرور سابسکریپشن داخلی** با چندین فرمت خروجی.
|
||||
- **ربات تلگرام** برای نظارت و مدیریت از راه دور.
|
||||
- **RESTful API** همراه با مستندات Swagger درونپنل.
|
||||
- **ذخیرهسازی منعطف** — SQLite (پیشفرض) یا PostgreSQL.
|
||||
- **۱۳ زبان رابط کاربری** با تمهای تیره و روشن.
|
||||
- **یکپارچگی با Fail2ban** برای اعمال محدودیت IP بهازای هر کلاینت.
|
||||
|
||||
## اسکرینشاتها
|
||||
|
||||
<details>
|
||||
<summary>برای باز شدن کلیک کنید</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>
|
||||
|
||||
## شروع سریع
|
||||
|
||||
```
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
در حین نصب، یک نام کاربری، رمز عبور و مسیر دسترسی تصادفی تولید میشود. پس از نصب، دستور `x-ui` را اجرا کنید تا منوی مدیریت باز شود؛ در آنجا میتوانید سرویس را شروع/متوقف کنید، اطلاعات ورود خود را ببینید یا بازنشانی کنید، گواهیهای SSL را مدیریت کنید و کارهای دیگری انجام دهید.
|
||||
|
||||
برای مستندات کامل، لطفاً به [ویکی پروژه](https://github.com/MHSanaei/3x-ui/wiki) مراجعه کنید.
|
||||
|
||||
## پلتفرمهای پشتیبانیشده
|
||||
|
||||
**سیستمعاملها:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap)، Alpine و Windows.
|
||||
|
||||
**معماریها:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
|
||||
|
||||
## گزینههای پایگاهداده
|
||||
|
||||
3X-UI از دو بکاند پشتیبانی میکند که در حین نصب انتخاب میشوند:
|
||||
|
||||
- **SQLite** (پیشفرض) — یک فایل واحد در مسیر `/etc/x-ui/x-ui.db`. بدون نیاز به تنظیمات، ایدهآل برای استقرارهای کوچک و متوسط.
|
||||
- **PostgreSQL** — برای تعداد کلاینت بالا یا راهاندازیهای چندنودی توصیه میشود. نصبکننده میتواند PostgreSQL را بهصورت محلی برایتان نصب کند، یا یک DSN به یک سرور موجود را بپذیرد.
|
||||
|
||||
در زمان اجرا، بکاند از طریق متغیرهای محیطی انتخاب میشود (نصبکننده این موارد را برای شما در `/etc/default/x-ui` مینویسد):
|
||||
|
||||
```
|
||||
XUI_DB_TYPE=postgres
|
||||
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
||||
```
|
||||
|
||||
### انتقال یک نصب موجود SQLite به PostgreSQL
|
||||
|
||||
```bash
|
||||
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
|
||||
# سپس XUI_DB_TYPE و XUI_DB_DSN را در /etc/default/x-ui تنظیم کرده و ریاستارت کنید:
|
||||
systemctl restart x-ui
|
||||
```
|
||||
|
||||
فایل اصلی SQLite دستنخورده باقی میماند؛ پس از اطمینان از صحت بکاند جدید، آن را بهصورت دستی حذف کنید.
|
||||
|
||||
### Docker
|
||||
|
||||
دستور پیشفرض `docker compose up -d` همچنان از SQLite استفاده میکند. برای اجرا با سرویس PostgreSQL همراه، دو خط متغیر محیطی `XUI_DB_*` را در `docker-compose.yml` از حالت کامنت خارج کنید و با پروفایل زیر اجرا کنید:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## متغیرهای محیطی
|
||||
|
||||
| متغیر | توضیحات | پیشفرض |
|
||||
| --- | --- | --- |
|
||||
| `XUI_DB_TYPE` | بکاند پایگاهداده: `sqlite` یا `postgres` | `sqlite` |
|
||||
| `XUI_DB_DSN` | رشتهی اتصال PostgreSQL (وقتی `XUI_DB_TYPE=postgres`) | — |
|
||||
| `XUI_DB_FOLDER` | پوشهی فایل پایگاهدادهی SQLite | `/etc/x-ui` |
|
||||
| `XUI_DB_MAX_OPEN_CONNS` | حداکثر اتصالات باز (استخر PostgreSQL) | — |
|
||||
| `XUI_DB_MAX_IDLE_CONNS` | حداکثر اتصالات بیکار (استخر PostgreSQL) | — |
|
||||
| `XUI_ENABLE_FAIL2BAN` | فعالسازی اعمال محدودیت IP مبتنی بر Fail2ban | `true` |
|
||||
| `XUI_LOG_LEVEL` | سطح گزارشگیری (`debug`، `info`، `warning`، `error`) | `info` |
|
||||
| `XUI_DEBUG` | فعالسازی حالت دیباگ | `false` |
|
||||
|
||||
## زبانهای پشتیبانیشده
|
||||
|
||||
رابط کاربری پنل به ۱۳ زبان در دسترس است:
|
||||
|
||||
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
||||
|
||||
## مشارکت
|
||||
|
||||
از مشارکتها استقبال میشود. لطفاً پیش از باز کردن issue یا pull request، [راهنمای مشارکت](/CONTRIBUTING.md) را مطالعه کنید.
|
||||
|
||||
## تشکر ویژه از
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
@@ -7,20 +7,65 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
<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>
|
||||
<a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
|
||||
</p>
|
||||
|
||||
**3X-UI** — advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols.
|
||||
**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.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project is only for personal usage, please do not use it for illegal purposes, and please do not use it in a production environment.
|
||||
> This project is intended for personal use only. Please do not use it for illegal purposes or in a production environment.
|
||||
|
||||
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
|
||||
## 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.
|
||||
- **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.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<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
|
||||
|
||||
@@ -28,16 +73,24 @@ As an enhanced fork of the original X-UI project, 3X-UI provides improved stabil
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
## 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/medium deployments.
|
||||
- **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 env vars (the installer writes these to `/etc/default/x-ui` for you):
|
||||
At runtime the backend is selected via environment variables (the installer writes these to `/etc/default/x-ui` for you):
|
||||
|
||||
```
|
||||
XUI_DB_TYPE=postgres
|
||||
@@ -62,6 +115,35 @@ The default `docker compose up -d` keeps using SQLite. To run with the bundled P
|
||||
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:
|
||||
|
||||
```bash
|
||||
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
||||
```
|
||||
|
||||
## 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_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` |
|
||||
|
||||
## Supported Languages
|
||||
|
||||
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/)
|
||||
|
||||
@@ -7,29 +7,143 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
<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>
|
||||
<a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
|
||||
</p>
|
||||
|
||||
**3X-UI** — продвинутая панель управления с открытым исходным кодом на основе веб-интерфейса, разработанная для управления сервером Xray-core. Предоставляет удобный интерфейс для настройки и мониторинга различных VPN и прокси-протоколов.
|
||||
**3X-UI** — продвинутая веб-панель управления с открытым исходным кодом для управления серверами [Xray-core](https://github.com/XTLS/Xray-core). Она предоставляет аккуратный многоязычный интерфейс для развёртывания, настройки и мониторинга широкого спектра протоколов прокси и VPN — от одного VPS до развёртываний с несколькими узлами.
|
||||
|
||||
Созданный как улучшенный форк оригинального проекта X-UI, 3X-UI добавляет более широкую поддержку протоколов, повышенную стабильность, учёт трафика по каждому клиенту и множество функций для удобства использования.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Этот проект предназначен только для личного использования, пожалуйста, не используйте его в незаконных целях и в производственной среде.
|
||||
> Этот проект предназначен только для личного использования. Пожалуйста, не используйте его в незаконных целях или в производственной среде.
|
||||
|
||||
Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
|
||||
## Возможности
|
||||
|
||||
- **Многопротокольные входящие подключения** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel и TUN.
|
||||
- **Современные транспорты и безопасность** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade и XHTTP, защищённые с помощью TLS, XTLS и REALITY.
|
||||
- **Fallback** — обслуживание нескольких протоколов на одном порту (например, VLESS и Trojan на 443) с помощью функции fallback в Xray.
|
||||
- **Управление по каждому клиенту** — квоты трафика, даты истечения, лимиты IP, статус «онлайн» в реальном времени, а также ссылки для общего доступа, QR-коды и подписки в один клик.
|
||||
- **Статистика трафика** — по каждому входящему, по каждому клиенту и по каждому исходящему, с возможностью сброса.
|
||||
- **Поддержка нескольких узлов** — управление и масштабирование на несколько серверов из одной панели.
|
||||
- **Исходящие подключения и маршрутизация** — WARP, NordVPN, пользовательские правила маршрутизации, балансировщики нагрузки и цепочки исходящих прокси.
|
||||
- **Встроенный сервер подписок** с несколькими форматами вывода.
|
||||
- **Telegram-бот** для удалённого мониторинга и управления.
|
||||
- **RESTful API** с документацией Swagger внутри панели.
|
||||
- **Гибкое хранилище** — SQLite (по умолчанию) или PostgreSQL.
|
||||
- **13 языков интерфейса** с тёмной и светлой темами.
|
||||
- **Интеграция с Fail2ban** для применения лимитов IP по каждому клиенту.
|
||||
|
||||
## Скриншоты
|
||||
|
||||
<details>
|
||||
<summary>Нажмите, чтобы развернуть</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>
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
Во время установки генерируются случайные имя пользователя, пароль и путь доступа. После установки выполните `x-ui`, чтобы открыть меню управления, где можно запускать/останавливать сервис, просматривать или сбрасывать учётные данные для входа, управлять SSL-сертификатами и многое другое.
|
||||
|
||||
Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
## Поддерживаемые платформы
|
||||
|
||||
**Операционные системы:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine и Windows.
|
||||
|
||||
**Архитектуры:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
|
||||
|
||||
## Варианты базы данных
|
||||
|
||||
3X-UI поддерживает два бэкенда, выбираемых при установке:
|
||||
|
||||
- **SQLite** (по умолчанию) — единый файл по пути `/etc/x-ui/x-ui.db`. Без настройки, идеально для небольших и средних развёртываний.
|
||||
- **PostgreSQL** — рекомендуется при большом числе клиентов или конфигурациях с несколькими узлами. Установщик может установить PostgreSQL локально за вас или принять DSN к существующему серверу.
|
||||
|
||||
Во время выполнения бэкенд выбирается через переменные окружения (установщик записывает их за вас в `/etc/default/x-ui`):
|
||||
|
||||
```
|
||||
XUI_DB_TYPE=postgres
|
||||
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
||||
```
|
||||
|
||||
### Перенос существующей установки SQLite в PostgreSQL
|
||||
|
||||
```bash
|
||||
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
|
||||
# затем задайте XUI_DB_TYPE и XUI_DB_DSN в /etc/default/x-ui и перезапустите:
|
||||
systemctl restart x-ui
|
||||
```
|
||||
|
||||
Исходный файл SQLite остаётся нетронутым; удалите его вручную после проверки нового бэкенда.
|
||||
|
||||
### Docker
|
||||
|
||||
Команда по умолчанию `docker compose up -d` продолжает использовать SQLite. Чтобы запустить со встроенным сервисом PostgreSQL, раскомментируйте две строки переменных окружения `XUI_DB_*` в `docker-compose.yml` и запустите с профилем:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
| --- | --- | --- |
|
||||
| `XUI_DB_TYPE` | Бэкенд базы данных: `sqlite` или `postgres` | `sqlite` |
|
||||
| `XUI_DB_DSN` | Строка подключения PostgreSQL (когда `XUI_DB_TYPE=postgres`) | — |
|
||||
| `XUI_DB_FOLDER` | Каталог для файла базы данных SQLite | `/etc/x-ui` |
|
||||
| `XUI_DB_MAX_OPEN_CONNS` | Максимум открытых соединений (пул PostgreSQL) | — |
|
||||
| `XUI_DB_MAX_IDLE_CONNS` | Максимум простаивающих соединений (пул PostgreSQL) | — |
|
||||
| `XUI_ENABLE_FAIL2BAN` | Включить применение лимитов IP на основе Fail2ban | `true` |
|
||||
| `XUI_LOG_LEVEL` | Уровень логирования (`debug`, `info`, `warning`, `error`) | `info` |
|
||||
| `XUI_DEBUG` | Включить режим отладки | `false` |
|
||||
|
||||
## Поддерживаемые языки
|
||||
|
||||
Интерфейс панели доступен на 13 языках:
|
||||
|
||||
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
||||
|
||||
## Участие в разработке
|
||||
|
||||
Вклад приветствуется. Пожалуйста, прочитайте [руководство по участию](/CONTRIBUTING.md), прежде чем открывать issue или pull request.
|
||||
|
||||
## Особая благодарность
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
@@ -7,29 +7,143 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
<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>
|
||||
<a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
|
||||
</p>
|
||||
|
||||
**3X-UI** — 一个基于网页的高级开源控制面板,专为管理 Xray-core 服务器而设计。它提供了用户友好的界面,用于配置和监控各种 VPN 和代理协议。
|
||||
**3X-UI** 是一个先进的开源 Web 控制面板,用于管理 [Xray-core](https://github.com/XTLS/Xray-core) 服务器。它提供简洁、多语言的界面,用于部署、配置和监控各种代理与 VPN 协议——从单台 VPS 到多节点部署。
|
||||
|
||||
3X-UI 作为原始 X-UI 项目的增强分支(fork),增加了更广泛的协议支持、更好的稳定性、按客户端的流量统计以及许多提升使用体验的功能。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 本项目仅用于个人使用和通信,请勿将其用于非法目的,请勿在生产环境中使用。
|
||||
> 本项目仅供个人使用。请勿将其用于非法目的,也请勿在生产环境中使用。
|
||||
|
||||
作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
|
||||
## 功能特性
|
||||
|
||||
- **多协议入站** — VLESS、VMess、Trojan、Shadowsocks、WireGuard、Hysteria2、HTTP、SOCKS (Mixed)、Dokodemo-door / Tunnel 和 TUN。
|
||||
- **现代传输与安全** — TCP (Raw)、mKCP、WebSocket、gRPC、HTTPUpgrade 和 XHTTP,并通过 TLS、XTLS 和 REALITY 加密。
|
||||
- **回落 (Fallback)** — 通过 Xray 的 fallback 功能在单个端口上提供多种协议(例如在 443 端口上同时使用 VLESS 和 Trojan)。
|
||||
- **按客户端管理** — 流量配额、到期日期、IP 限制、实时在线状态,以及一键分享链接、二维码和订阅。
|
||||
- **流量统计** — 按入站、按客户端、按出站统计,并支持重置控制。
|
||||
- **多节点支持** — 从单一面板管理并扩展到多台服务器。
|
||||
- **出站与路由** — WARP、NordVPN、自定义路由规则、负载均衡器和出站代理链。
|
||||
- **内置订阅服务器**,支持多种输出格式。
|
||||
- **Telegram 机器人**,用于远程监控和管理。
|
||||
- **RESTful API**,带有面板内置的 Swagger 文档。
|
||||
- **灵活的存储** — SQLite(默认)或 PostgreSQL。
|
||||
- **13 种界面语言**,支持深色和浅色主题。
|
||||
- **Fail2ban 集成**,用于强制执行按客户端的 IP 限制。
|
||||
|
||||
## 截图
|
||||
|
||||
<details>
|
||||
<summary>点击展开</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>
|
||||
|
||||
## 快速开始
|
||||
|
||||
```
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
安装过程中会生成随机的用户名、密码和访问路径。安装完成后,运行 `x-ui` 打开管理菜单,您可以在其中启动/停止服务、查看或重置登录凭据、管理 SSL 证书等。
|
||||
|
||||
完整文档请参阅 [项目Wiki](https://github.com/MHSanaei/3x-ui/wiki)。
|
||||
|
||||
## 支持的平台
|
||||
|
||||
**操作系统:** Ubuntu、Debian、Armbian、Fedora、CentOS、RHEL、AlmaLinux、Rocky Linux、Oracle Linux、Amazon Linux、Virtuozzo、Arch、Manjaro、Parch、openSUSE (Tumbleweed / Leap)、Alpine 和 Windows。
|
||||
|
||||
**架构:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`。
|
||||
|
||||
## 数据库选项
|
||||
|
||||
3X-UI 支持两种后端,可在安装时选择:
|
||||
|
||||
- **SQLite**(默认)— 位于 `/etc/x-ui/x-ui.db` 的单个文件。无需配置,适合中小型部署。
|
||||
- **PostgreSQL** — 推荐用于大量客户端或多节点设置。安装程序可以为您在本地安装 PostgreSQL,或接受指向现有服务器的 DSN。
|
||||
|
||||
运行时通过环境变量选择后端(安装程序会为您写入 `/etc/default/x-ui`):
|
||||
|
||||
```
|
||||
XUI_DB_TYPE=postgres
|
||||
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
||||
```
|
||||
|
||||
### 将现有的 SQLite 安装迁移到 PostgreSQL
|
||||
|
||||
```bash
|
||||
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
|
||||
# 然后在 /etc/default/x-ui 中设置 XUI_DB_TYPE 和 XUI_DB_DSN 并重启:
|
||||
systemctl restart x-ui
|
||||
```
|
||||
|
||||
源 SQLite 文件保持不变;在确认新后端正常工作后,请手动删除它。
|
||||
|
||||
### Docker
|
||||
|
||||
默认的 `docker compose up -d` 仍使用 SQLite。若要使用捆绑的 PostgreSQL 服务运行,请取消注释 `docker-compose.yml` 中的两行 `XUI_DB_*` 环境变量,并使用该 profile 启动:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `XUI_DB_TYPE` | 数据库后端:`sqlite` 或 `postgres` | `sqlite` |
|
||||
| `XUI_DB_DSN` | PostgreSQL 连接字符串(当 `XUI_DB_TYPE=postgres` 时) | — |
|
||||
| `XUI_DB_FOLDER` | SQLite 数据库文件所在目录 | `/etc/x-ui` |
|
||||
| `XUI_DB_MAX_OPEN_CONNS` | 最大打开连接数(PostgreSQL 连接池) | — |
|
||||
| `XUI_DB_MAX_IDLE_CONNS` | 最大空闲连接数(PostgreSQL 连接池) | — |
|
||||
| `XUI_ENABLE_FAIL2BAN` | 启用基于 Fail2ban 的 IP 限制 | `true` |
|
||||
| `XUI_LOG_LEVEL` | 日志级别(`debug`、`info`、`warning`、`error`) | `info` |
|
||||
| `XUI_DEBUG` | 启用调试模式 | `false` |
|
||||
|
||||
## 支持的语言
|
||||
|
||||
面板界面提供 13 种语言:
|
||||
|
||||
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献。在提交 issue 或 pull request 之前,请阅读[贡献指南](/CONTRIBUTING.md)。
|
||||
|
||||
## 特别感谢
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.2.5
|
||||
3.2.6
|
||||
@@ -72,6 +72,7 @@ func initModels() error {
|
||||
&model.ClientInbound{},
|
||||
&model.ClientGroup{},
|
||||
&model.InboundFallback{},
|
||||
&model.NodeClientTraffic{},
|
||||
}
|
||||
for _, mdl := range models {
|
||||
if err := db.AutoMigrate(mdl); err != nil {
|
||||
@@ -203,6 +204,9 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
if crypto.IsHashed(user.Password) {
|
||||
continue
|
||||
}
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
||||
|
||||
@@ -133,7 +133,7 @@ func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing
|
||||
}
|
||||
|
||||
subIdPattern := regexp.MustCompile(`^[0-9a-z]{16}$`)
|
||||
for i := 0; i < 2; i++ {
|
||||
for i := range 2 {
|
||||
obj := clients[i].(map[string]any)
|
||||
sub, _ := obj["subId"].(string)
|
||||
if !subIdPattern.MatchString(sub) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
@@ -36,6 +37,7 @@ func migrationModels() []any {
|
||||
&model.ClientRecord{},
|
||||
&model.ClientInbound{},
|
||||
&model.InboundFallback{},
|
||||
&model.NodeClientTraffic{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,25 +104,42 @@ func MigrateData(srcPath, dstDSN string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyTable streams every row of `mdl` from src to dst in batches.
|
||||
func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
|
||||
const batchSize = 500
|
||||
|
||||
sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
|
||||
batchPtr := reflect.New(sliceType)
|
||||
batchPtr.Elem().Set(reflect.MakeSlice(sliceType, 0, 0))
|
||||
|
||||
// Resolve primary-key columns so paging is deterministic across successive
|
||||
// LIMIT/OFFSET reads. The model set is trusted (not user input).
|
||||
stmt := &gorm.Statement{DB: src}
|
||||
if err := stmt.Parse(mdl); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
order := strings.Join(stmt.Schema.PrimaryFieldDBNames, ", ")
|
||||
|
||||
total := 0
|
||||
err := src.Model(mdl).FindInBatches(batchPtr.Interface(), 500, func(tx *gorm.DB, _ int) error {
|
||||
batch := batchPtr.Elem()
|
||||
if batch.Len() == 0 {
|
||||
return nil
|
||||
for offset := 0; ; offset += batchSize {
|
||||
batchPtr := reflect.New(sliceType)
|
||||
q := src.Model(mdl).Limit(batchSize).Offset(offset)
|
||||
if order != "" {
|
||||
q = q.Order(order)
|
||||
}
|
||||
if err := q.Find(batchPtr.Interface()).Error; err != nil {
|
||||
return total, err
|
||||
}
|
||||
n := batchPtr.Elem().Len()
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
if err := dst.CreateInBatches(batchPtr.Interface(), 200).Error; err != nil {
|
||||
return err
|
||||
return total, err
|
||||
}
|
||||
total += batch.Len()
|
||||
return nil
|
||||
}).Error
|
||||
return total, err
|
||||
total += n
|
||||
if n < batchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// resetPostgresSequences advances each migrated table's id sequence past MAX(id),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestMigrateData_CompositeKeyTableLargerThanBatch(t *testing.T) {
|
||||
dsn := os.Getenv("XUI_TEST_PG_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set XUI_TEST_PG_DSN to a reachable Postgres to run this test")
|
||||
}
|
||||
|
||||
// Seed a SQLite source with the full schema and >500 client_inbounds rows.
|
||||
srcPath := t.TempDir() + "/x-ui.db"
|
||||
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
for _, m := range migrationModels() {
|
||||
if err := src.AutoMigrate(m); err != nil {
|
||||
t.Fatalf("automigrate %T: %v", m, err)
|
||||
}
|
||||
}
|
||||
const n = 600 // > batchSize (500) so the between-batches path is exercised
|
||||
links := make([]model.ClientInbound, 0, n)
|
||||
for i := 1; i <= n; i++ {
|
||||
links = append(links, model.ClientInbound{ClientId: i, InboundId: 1})
|
||||
}
|
||||
if err := src.CreateInBatches(links, 200).Error; err != nil {
|
||||
t.Fatalf("seed client_inbounds: %v", err)
|
||||
}
|
||||
if sqlDB, err := src.DB(); err == nil {
|
||||
sqlDB.Close() // flush before MigrateData reopens the file
|
||||
}
|
||||
|
||||
// Make the test re-runnable: drop any tables from a previous run.
|
||||
dst, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres: %v", err)
|
||||
}
|
||||
if err := dst.Migrator().DropTable(migrationModels()...); err != nil {
|
||||
t.Fatalf("drop tables: %v", err)
|
||||
}
|
||||
|
||||
if err := MigrateData(srcPath, dsn); err != nil {
|
||||
t.Fatalf("MigrateData: %v", err) // fails here before the fix
|
||||
}
|
||||
|
||||
var got int64
|
||||
if err := dst.Model(&model.ClientInbound{}).Count(&got).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if got != n {
|
||||
t.Fatalf("client_inbounds rows = %d, want %d", got, n)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ type Inbound struct {
|
||||
|
||||
// Xray configuration fields
|
||||
Listen string `json:"listen" form:"listen"`
|
||||
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"`
|
||||
Port int `json:"port" form:"port" validate:"gte=0,lte=65535"`
|
||||
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun"`
|
||||
Settings string `json:"settings" form:"settings"`
|
||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||
@@ -307,6 +307,7 @@ func StripVlessInboundEncryption(settings string) (string, bool) {
|
||||
// inbound with "users must have empty method" when a client carries
|
||||
// one — strip stale entries left over from a switch off a legacy
|
||||
// cipher.
|
||||
//
|
||||
// Returns the rewritten settings string and true when anything changed.
|
||||
func HealShadowsocksClientMethods(settings string) (string, bool) {
|
||||
if settings == "" {
|
||||
@@ -379,6 +380,8 @@ type Node struct {
|
||||
ApiToken string `json:"apiToken" form:"apiToken" validate:"required"`
|
||||
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
||||
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
||||
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
|
||||
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
|
||||
|
||||
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
||||
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
type NodeClientTraffic struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
NodeId int `json:"nodeId" gorm:"uniqueIndex:idx_node_email,priority:1;not null"`
|
||||
Email string `json:"email" gorm:"uniqueIndex:idx_node_email,priority:2;not null"`
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
}
|
||||
@@ -5,6 +5,13 @@ services:
|
||||
dockerfile: ./Dockerfile
|
||||
container_name: 3xui_app
|
||||
# hostname: yourhostname <- optional
|
||||
# The bundled Fail2ban (XUI_ENABLE_FAIL2BAN below) enforces the IP limit
|
||||
# with iptables, which needs NET_ADMIN. Without these caps a ban is logged
|
||||
# and shown in fail2ban status but never actually applied. NET_RAW covers
|
||||
# ip6tables. If you disable Fail2ban, you can drop cap_add.
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
volumes:
|
||||
- $PWD/db/:/etc/x-ui/
|
||||
- $PWD/cert/:/root/cert/
|
||||
|
||||
@@ -4203,6 +4203,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/nodes/certFingerprint": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Nodes"
|
||||
],
|
||||
"summary": "Connect to the node over HTTPS without verifying its certificate and return the leaf certificate's SHA-256 (base64). Used by the Add/Edit Node dialog to fetch and pin a self-signed certificate. Uses the same body as /test.",
|
||||
"operationId": "post_panel_api_nodes_certFingerprint",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"example": {
|
||||
"scheme": "https",
|
||||
"address": "node1.example.com",
|
||||
"port": 2053,
|
||||
"basePath": "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": "k3b1...base64-sha256...="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/nodes/probe/{id}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
@@ -70,5 +70,7 @@ export function useNodeMutations() {
|
||||
const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
|
||||
return parseMsg(raw, ProbeResultSchema, 'nodes/test');
|
||||
},
|
||||
fetchFingerprint: (payload: Partial<NodeRecord>): Promise<Msg<string>> =>
|
||||
HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -333,10 +333,12 @@ export interface Node {
|
||||
name: string;
|
||||
onlineCount: number;
|
||||
panelVersion: string;
|
||||
pinnedCertSha256: string;
|
||||
port: number;
|
||||
remark: string;
|
||||
scheme: string;
|
||||
status: string;
|
||||
tlsVerifyMode: string;
|
||||
updatedAt: number;
|
||||
uptimeSecs: number;
|
||||
xrayVersion: string;
|
||||
|
||||
@@ -291,7 +291,7 @@ export const InboundSchema = z.object({
|
||||
lastTrafficResetTime: z.number().int(),
|
||||
listen: z.string(),
|
||||
nodeId: z.number().int().nullable().optional(),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
|
||||
remark: z.string(),
|
||||
settings: z.unknown(),
|
||||
@@ -350,10 +350,12 @@ export const NodeSchema = z.object({
|
||||
name: z.string(),
|
||||
onlineCount: z.number().int(),
|
||||
panelVersion: z.string(),
|
||||
pinnedCertSha256: z.string(),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
remark: z.string(),
|
||||
scheme: z.enum(['http', 'https']),
|
||||
status: z.string(),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
|
||||
updatedAt: z.number().int(),
|
||||
uptimeSecs: z.number().int(),
|
||||
xrayVersion: z.string(),
|
||||
|
||||
@@ -197,13 +197,21 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
}, [queryClient]);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () =>
|
||||
HttpUtil.post('/panel/xray/update', {
|
||||
xraySetting: xraySettingRef.current,
|
||||
outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL,
|
||||
}),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||
mutationFn: async () => {
|
||||
const sentXraySetting = xraySettingRef.current;
|
||||
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
|
||||
const msg = await HttpUtil.post('/panel/xray/update', {
|
||||
xraySetting: sentXraySetting,
|
||||
outboundTestUrl: sentTestUrl,
|
||||
});
|
||||
return { msg, sentXraySetting, sentTestUrl };
|
||||
},
|
||||
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
|
||||
if (!msg?.success) return;
|
||||
oldXraySettingRef.current = sentXraySetting;
|
||||
oldOutboundTestUrlRef.current = sentTestUrl;
|
||||
setSaveDisabled(true);
|
||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -610,6 +610,9 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
|
||||
if (tls.alpn.length > 0) params.set('alpn', tls.alpn.join(','));
|
||||
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
|
||||
if (tls.serverName.length > 0) params.set('sni', tls.serverName);
|
||||
if (tls.settings.pinnedPeerCertSha256.length > 0) {
|
||||
params.set('pinSHA256', tls.settings.pinnedPeerCertSha256.join(','));
|
||||
}
|
||||
|
||||
const udpMasks = stream.finalmask?.udp;
|
||||
if (Array.isArray(udpMasks)) {
|
||||
@@ -703,15 +706,22 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
||||
|
||||
export type { WireguardInboundPeer };
|
||||
|
||||
function isUnixSocketListen(listen: string): boolean {
|
||||
return listen.startsWith('/') || listen.startsWith('@');
|
||||
}
|
||||
|
||||
// Orchestrators.
|
||||
// resolveAddr picks the host that goes into share/sub links. Order:
|
||||
// 1. hostOverride (caller supplies node address for node-managed inbounds)
|
||||
// 2. inbound's bind listen (when explicit, not 0.0.0.0)
|
||||
// 2. inbound's bind listen (when it's an explicit reachable address —
|
||||
// not 0.0.0.0 and not a unix domain socket path)
|
||||
// 3. fallbackHostname (caller-supplied — typically window.location.hostname
|
||||
// in the browser; tests pass a fixed value)
|
||||
export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
|
||||
if (hostOverride.length > 0) return hostOverride;
|
||||
if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0') return inbound.listen;
|
||||
if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0' && !isUnixSocketListen(inbound.listen)) {
|
||||
return inbound.listen;
|
||||
}
|
||||
return fallbackHostname;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
|
||||
port,
|
||||
id,
|
||||
flow,
|
||||
encryption: (encryption === 'none' ? 'none' : 'none') as 'none',
|
||||
encryption: encryption || 'none',
|
||||
reverseTag,
|
||||
reverseSniffing,
|
||||
testpre: asNumber(raw.testpre, 0),
|
||||
|
||||
@@ -210,6 +210,7 @@ function applySecurityParams(stream: Raw, params: URLSearchParams): void {
|
||||
reality.publicKey = params.get('pbk') ?? '';
|
||||
reality.shortId = params.get('sid') ?? '';
|
||||
reality.spiderX = params.get('spx') ?? '';
|
||||
reality.mldsa65Verify = params.get('pqv') ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +404,7 @@ export function parseHysteria2Link(link: string): Raw | null {
|
||||
const address = url.hostname;
|
||||
const port = Number(url.port) || 443;
|
||||
const params = url.searchParams;
|
||||
const alpn = params.get('alpn');
|
||||
const stream: Raw = {
|
||||
network: 'hysteria',
|
||||
security: 'tls',
|
||||
@@ -411,13 +413,14 @@ export function parseHysteria2Link(link: string): Raw | null {
|
||||
},
|
||||
tlsSettings: {
|
||||
serverName: params.get('sni') ?? '',
|
||||
alpn: ['h3'],
|
||||
fingerprint: '',
|
||||
echConfigList: '',
|
||||
alpn: alpn ? alpn.split(',') : ['h3'],
|
||||
fingerprint: params.get('fp') ?? '',
|
||||
echConfigList: params.get('ech') ?? '',
|
||||
verifyPeerCertByName: '',
|
||||
pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
|
||||
},
|
||||
};
|
||||
applyFinalMaskParam(stream, params);
|
||||
return {
|
||||
protocol: 'hysteria',
|
||||
tag: decodeRemark(url),
|
||||
|
||||
@@ -769,6 +769,13 @@ export const sections: readonly Section[] = [
|
||||
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 12.5,\n "memPct": 45.2,\n "uptimeSecs": 86400,\n "error": ""\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/certFingerprint',
|
||||
summary: "Connect to the node over HTTPS without verifying its certificate and return the leaf certificate's SHA-256 (base64). Used by the Add/Edit Node dialog to fetch and pin a self-signed certificate. Uses the same body as /test.",
|
||||
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/"\n}',
|
||||
response: '{\n "success": true,\n "obj": "k3b1...base64-sha256...="\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/probe/:id',
|
||||
|
||||
@@ -390,6 +390,8 @@ export default function ClientFormModal({
|
||||
cancelText={t('cancel')}
|
||||
okButtonProps={{ loading: submitting }}
|
||||
width={720}
|
||||
style={{ top: 20 }}
|
||||
styles={{ body: { maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' } }}
|
||||
onOk={onSubmit}
|
||||
onCancel={close}
|
||||
>
|
||||
|
||||
@@ -44,12 +44,13 @@ export default function FallbacksCard({
|
||||
value={record.childId}
|
||||
options={fallbackChildOptions}
|
||||
placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
|
||||
allowClear
|
||||
showSearch={{
|
||||
filterOption: (input, option) =>
|
||||
((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateFallback(record.rowKey, { childId: v })}
|
||||
onChange={(v) => updateFallback(record.rowKey, { childId: v ?? null })}
|
||||
/>
|
||||
<Button
|
||||
disabled={idx === 0}
|
||||
|
||||
@@ -161,6 +161,8 @@ export default function InboundFormModal({
|
||||
const streamEnabled = canEnableStream({ protocol });
|
||||
|
||||
const wPort = Form.useWatch('port', form);
|
||||
const wListen = (Form.useWatch('listen', form) ?? '') as string;
|
||||
const isUdsListen = wListen.startsWith('/');
|
||||
const wNodeId = Form.useWatch('nodeId', form) ?? null;
|
||||
const wTag = Form.useWatch('tag', form) ?? '';
|
||||
const wSsNetwork = Form.useWatch(['settings', 'network'], form);
|
||||
@@ -479,7 +481,11 @@ export default function InboundFormModal({
|
||||
<Select disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="listen" label={t('pages.inbounds.address')}>
|
||||
<Form.Item
|
||||
name="listen"
|
||||
label={t('pages.inbounds.address')}
|
||||
extra={t('pages.inbounds.form.listenHelp')}
|
||||
>
|
||||
<Input placeholder={t('pages.inbounds.monitorDesc')} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -488,7 +494,7 @@ export default function InboundFormModal({
|
||||
label={t('pages.inbounds.port')}
|
||||
rules={[antdRule(InboundFormBaseSchema.shape.port, t)]}
|
||||
>
|
||||
<InputNumber min={1} max={65535} />
|
||||
<InputNumber min={isUdsListen ? 0 : 1} max={65535} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
|
||||
@@ -56,13 +56,6 @@ export default function RawForm() {
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
{/* Per Xray docs (transports/raw.html#httpheaderobject), the
|
||||
`request` object is honored only by outbound proxies; the
|
||||
inbound listener reads `response`. Showing Host / Path /
|
||||
Method / Version / request-headers on the inbound side was
|
||||
a regression from this modal's earlier iteration — those
|
||||
inputs wrote to the wire but xray-core ignored them. The
|
||||
inbound modal now only exposes the response side. */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function SockoptForm({
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'interfaceName']}
|
||||
name={['streamSettings', 'sockopt', 'interface']}
|
||||
label={t('pages.inbounds.info.interfaceName')}
|
||||
>
|
||||
<Input />
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
}[])
|
||||
.map((r) => ({
|
||||
rowKey: `fb-${++fallbackKeyRef.current}`,
|
||||
childId: r.childId,
|
||||
childId: r.childId && r.childId > 0 ? r.childId : null,
|
||||
name: r.name || '',
|
||||
alpn: r.alpn || '',
|
||||
path: r.path || '',
|
||||
@@ -52,7 +52,7 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
const saveFallbacks = async (masterId: number) => {
|
||||
if (!masterId) return true;
|
||||
const payload = {
|
||||
fallbacks: fallbacks.filter((c) => c.childId).map((c, i) => ({
|
||||
fallbacks: fallbacks.filter((c) => c.childId || (c.dest ?? '').trim()).map((c, i) => ({
|
||||
childId: c.childId,
|
||||
name: c.name,
|
||||
alpn: c.alpn,
|
||||
|
||||
@@ -26,6 +26,7 @@ interface NodeFormModalProps {
|
||||
mode: Mode;
|
||||
node: NodeRecord | null;
|
||||
testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
|
||||
fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
|
||||
save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
@@ -42,6 +43,8 @@ function defaultValues(): NodeFormValues {
|
||||
apiToken: '',
|
||||
enable: true,
|
||||
allowPrivateAddress: false,
|
||||
tlsVerifyMode: 'verify',
|
||||
pinnedCertSha256: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +53,7 @@ export default function NodeFormModal({
|
||||
mode,
|
||||
node,
|
||||
testConnection,
|
||||
fetchFingerprint,
|
||||
save,
|
||||
onOpenChange,
|
||||
}: NodeFormModalProps) {
|
||||
@@ -59,7 +63,9 @@ export default function NodeFormModal({
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [fetchingPin, setFetchingPin] = useState(false);
|
||||
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
|
||||
const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -94,6 +100,8 @@ export default function NodeFormModal({
|
||||
apiToken: values.apiToken.trim(),
|
||||
enable: values.enable,
|
||||
allowPrivateAddress: values.allowPrivateAddress,
|
||||
tlsVerifyMode: values.tlsVerifyMode,
|
||||
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +126,27 @@ export default function NodeFormModal({
|
||||
}
|
||||
}
|
||||
|
||||
async function onFetchPin() {
|
||||
try {
|
||||
await form.validateFields(['address', 'port']);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setFetchingPin(true);
|
||||
try {
|
||||
const payload = buildPayload(form.getFieldsValue(true));
|
||||
const msg = await fetchFingerprint(payload);
|
||||
if (msg?.success && msg.obj) {
|
||||
form.setFieldValue('pinnedCertSha256', msg.obj);
|
||||
messageApi.success(t('pages.nodes.pinFetched'));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed'));
|
||||
}
|
||||
} finally {
|
||||
setFetchingPin(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFinish(values: NodeFormValues) {
|
||||
const result = NodeFormSchema.safeParse(values);
|
||||
if (!result.success) {
|
||||
@@ -233,6 +262,44 @@ export default function NodeFormModal({
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('pages.nodes.tlsVerifyMode')}
|
||||
name="tlsVerifyMode"
|
||||
extra={t('pages.nodes.tlsVerifyModeHint')}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
|
||||
{ value: 'pin', label: t('pages.nodes.tlsPin') },
|
||||
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{tlsVerifyMode === 'skip' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.tlsSkipWarning')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tlsVerifyMode === 'pin' && (
|
||||
<Form.Item
|
||||
label={t('pages.nodes.pinnedCert')}
|
||||
name="pinnedCertSha256"
|
||||
extra={t('pages.nodes.pinnedCertHint')}
|
||||
>
|
||||
<Input.Search
|
||||
placeholder={t('pages.nodes.pinnedCertPlaceholder')}
|
||||
enterButton={t('pages.nodes.fetchPin')}
|
||||
loading={fetchingPin}
|
||||
onSearch={onFetchPin}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={t('pages.nodes.apiToken')}
|
||||
name="apiToken"
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function NodesPage() {
|
||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||
|
||||
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
|
||||
const { create, update, remove, setEnable, testConnection, probe, updatePanels } = useNodeMutations();
|
||||
const { create, update, remove, setEnable, testConnection, fetchFingerprint, probe, updatePanels } = useNodeMutations();
|
||||
|
||||
const { data: latestVersion = '' } = useQuery({
|
||||
queryKey: ['server', 'panelUpdateInfo'],
|
||||
@@ -231,6 +231,7 @@ export default function NodesPage() {
|
||||
mode={formMode}
|
||||
node={formNode}
|
||||
testConnection={testConnection}
|
||||
fetchFingerprint={fetchFingerprint}
|
||||
save={onSave}
|
||||
onOpenChange={setFormOpen}
|
||||
/>
|
||||
|
||||
@@ -205,7 +205,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
|
||||
<InputNumber value={allSetting.trafficDiff} min={0} style={{ width: '100%' }}
|
||||
<InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
|
||||
</SettingListItem>
|
||||
</>
|
||||
|
||||
@@ -168,7 +168,7 @@ export default function BasicsTab({
|
||||
['statsInboundUplink', t('pages.xray.statsInboundUplink')],
|
||||
['statsInboundDownlink', t('pages.xray.statsInboundDownlink')],
|
||||
['statsOutboundUplink', t('pages.xray.statsOutboundUplink')],
|
||||
['statsOutboundDownlink', 'Outbound downlink stats'],
|
||||
['statsOutboundDownlink', t('pages.xray.statsOutboundDownlink')],
|
||||
].map(([field, label]) => (
|
||||
<SettingListItem
|
||||
key={field}
|
||||
|
||||
@@ -575,7 +575,9 @@ export default function OutboundFormModal({
|
||||
|
||||
{security === 'reality' && realityAllowed && <RealityForm />}
|
||||
|
||||
{((streamAllowed && network) || !streamAllowed) && <SockoptForm form={form} />}
|
||||
{((streamAllowed && network) || !streamAllowed) && (
|
||||
<SockoptForm form={form} outboundTags={existingTags} />
|
||||
)}
|
||||
|
||||
<FinalMaskForm
|
||||
name={['streamSettings', 'finalmask']}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function TlsForm() {
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('none')}
|
||||
options={UTLS_OPTIONS}
|
||||
options={[{ value: '', label: t('none') }, ...UTLS_OPTIONS]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
|
||||
@@ -47,6 +47,15 @@ export default function RawForm({ form }: { form: FormInstance<OutboundFormValue
|
||||
</Form.Item>
|
||||
{type === 'http' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.requestVersion')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'request', 'version',
|
||||
]}
|
||||
>
|
||||
<Input placeholder="1.1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.requestMethod')}
|
||||
name={[
|
||||
@@ -57,13 +66,19 @@ export default function RawForm({ form }: { form: FormInstance<OutboundFormValue
|
||||
<Input placeholder="GET" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.requestVersion')}
|
||||
label={t('pages.inbounds.form.requestPath')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'request', 'version',
|
||||
'request', 'path',
|
||||
]}
|
||||
getValueProps={(v) => ({ value: Array.isArray(v) ? v.join(',') : v })}
|
||||
getValueFromEvent={(e) => {
|
||||
const raw = (e?.target?.value ?? '') as string;
|
||||
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return parts.length > 0 ? parts : ['/'];
|
||||
}}
|
||||
>
|
||||
<Input placeholder="1.1" />
|
||||
<Input placeholder="/" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.requestHeaders')}
|
||||
|
||||
@@ -7,7 +7,13 @@ import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
|
||||
|
||||
import { ADDRESS_PORT_STRATEGY_OPTIONS } from '../outbound-form-constants';
|
||||
|
||||
export default function SockoptForm({ form }: { form: FormInstance<OutboundFormValues> }) {
|
||||
export default function SockoptForm({
|
||||
form,
|
||||
outboundTags = [],
|
||||
}: {
|
||||
form: FormInstance<OutboundFormValues>;
|
||||
outboundTags?: string[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
@@ -16,6 +22,14 @@ export default function SockoptForm({ form }: { form: FormInstance<OutboundFormV
|
||||
'streamSettings',
|
||||
'sockopt',
|
||||
]);
|
||||
const dialerProxy = (form.getFieldValue([
|
||||
'streamSettings',
|
||||
'sockopt',
|
||||
'dialerProxy',
|
||||
]) ?? '') as string;
|
||||
const dialerProxyOptions = Array.from(
|
||||
new Set([...outboundTags, dialerProxy].filter(Boolean)),
|
||||
).map((tg) => ({ value: tg, label: tg }));
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.outboundForm.sockopts')}>
|
||||
@@ -34,8 +48,14 @@ export default function SockoptForm({ form }: { form: FormInstance<OutboundFormV
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.dialerProxy')}
|
||||
name={['streamSettings', 'sockopt', 'dialerProxy']}
|
||||
tooltip={t('pages.xray.outboundForm.dialerProxyHint')}
|
||||
>
|
||||
<Input />
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t('pages.xray.outboundForm.dialerProxyPlaceholder')}
|
||||
options={dialerProxyOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.wireguard.domainStrategy')}
|
||||
@@ -89,7 +109,7 @@ export default function SockoptForm({ form }: { form: FormInstance<OutboundFormV
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.interface')}
|
||||
name={['streamSettings', 'sockopt', 'interfaceName']}
|
||||
name={['streamSettings', 'sockopt', 'interface']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
@@ -38,6 +38,7 @@ interface WarpConfig {
|
||||
model?: string;
|
||||
enabled?: boolean;
|
||||
config?: {
|
||||
client_id?: string;
|
||||
interface?: { addresses?: { v4?: string; v6?: string } };
|
||||
peers?: { public_key?: string; endpoint?: { host?: string } }[];
|
||||
};
|
||||
@@ -99,7 +100,7 @@ export default function WarpModal({
|
||||
mtu: 1420,
|
||||
secretKey: data?.private_key,
|
||||
address: addressesFor(cfg.interface?.addresses || {}),
|
||||
reserved: reservedFor(data?.client_id),
|
||||
reserved: reservedFor(cfg.client_id ?? data?.client_id),
|
||||
domainStrategy: 'ForceIP',
|
||||
peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
|
||||
noKernelTun: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PortSchema, SniffingSchema } from '@/schemas/primitives';
|
||||
import { InboundPortSchema, SniffingSchema } from '@/schemas/primitives';
|
||||
import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
|
||||
import { SecuritySettingsSchema } from '@/schemas/protocols/security';
|
||||
import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
|
||||
@@ -32,7 +32,7 @@ export const InboundCoreSchema = z.object({
|
||||
enable: z.boolean().default(true),
|
||||
expiryTime: z.number().int().default(0),
|
||||
listen: z.string().default(''),
|
||||
port: PortSchema,
|
||||
port: InboundPortSchema,
|
||||
tag: z.string().default(''),
|
||||
sniffing: SniffingSchema.default({
|
||||
enabled: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PortSchema, SniffingSchema } from '@/schemas/primitives';
|
||||
import { InboundPortSchema, SniffingSchema } from '@/schemas/primitives';
|
||||
import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
|
||||
import { SecuritySettingsSchema } from '@/schemas/protocols/security';
|
||||
import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
|
||||
@@ -44,7 +44,7 @@ export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
|
||||
export const InboundFormBaseSchema = z.object({
|
||||
remark: z.string().default(''),
|
||||
enable: z.boolean().default(true),
|
||||
port: PortSchema,
|
||||
port: InboundPortSchema,
|
||||
listen: z.string().default(''),
|
||||
tag: z.string().default(''),
|
||||
expiryTime: z.number().int().default(0),
|
||||
|
||||
@@ -24,6 +24,8 @@ export const NodeRecordSchema = z.object({
|
||||
lastHeartbeat: z.number().optional(),
|
||||
lastError: z.string().optional(),
|
||||
allowPrivateAddress: z.boolean().optional(),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
|
||||
pinnedCertSha256: z.string().optional(),
|
||||
}).loose();
|
||||
|
||||
export const NodeListSchema = z.array(NodeRecordSchema);
|
||||
@@ -46,6 +48,8 @@ export const NodeFormSchema = z.object({
|
||||
apiToken: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
||||
enable: z.boolean(),
|
||||
allowPrivateAddress: z.boolean(),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
|
||||
pinnedCertSha256: z.string(),
|
||||
});
|
||||
|
||||
export type NodeRecord = z.infer<typeof NodeRecordSchema>;
|
||||
|
||||
@@ -2,3 +2,6 @@ import { z } from 'zod';
|
||||
|
||||
export const PortSchema = z.number().int().min(1).max(65535);
|
||||
export type Port = z.infer<typeof PortSchema>;
|
||||
|
||||
export const InboundPortSchema = z.number().int().min(0).max(65535);
|
||||
export type InboundPort = z.infer<typeof InboundPortSchema>;
|
||||
|
||||
@@ -65,7 +65,7 @@ export const SockoptStreamSettingsSchema = z.object({
|
||||
tcpcongestion: TcpCongestionSchema.default('bbr'),
|
||||
V6Only: z.boolean().default(false),
|
||||
tcpWindowClamp: z.number().int().min(0).default(600),
|
||||
interfaceName: z.string().default(''),
|
||||
interface: z.string().default(''),
|
||||
trustedXForwardedFor: z.array(z.string()).default([]),
|
||||
addressPortStrategy: AddressPortStrategySchema.default('none'),
|
||||
happyEyeballs: HappyEyeballsSchema.optional(),
|
||||
|
||||
@@ -16,7 +16,7 @@ export const AllSettingSchema = z.object({
|
||||
panelProxy: z.string().optional(),
|
||||
pageSize: z.number().int().min(1).max(1000).optional(),
|
||||
expireDiff: nonNegativeInt.optional(),
|
||||
trafficDiff: nonNegativeInt.optional(),
|
||||
trafficDiff: nonNegativeInt.max(100).optional(),
|
||||
remarkModel: z.string().optional(),
|
||||
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
|
||||
tgBotEnable: z.boolean().optional(),
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
.nodes-page .ant-card.ant-card-hoverable:hover,
|
||||
.groups-page .ant-card.ant-card-hoverable:hover,
|
||||
.api-docs-page .ant-card.ant-card-hoverable:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ exports[`SockoptStreamSettingsSchema fixtures > parses defaults byte-stably 1`]
|
||||
"customSockopt": [],
|
||||
"dialerProxy": "",
|
||||
"domainStrategy": "AsIs",
|
||||
"interfaceName": "",
|
||||
"interface": "",
|
||||
"mark": 0,
|
||||
"penetrate": false,
|
||||
"tcpFastOpen": false,
|
||||
@@ -32,7 +32,7 @@ exports[`SockoptStreamSettingsSchema fixtures > parses full byte-stably 1`] = `
|
||||
"customSockopt": [],
|
||||
"dialerProxy": "out-proxy-tag",
|
||||
"domainStrategy": "UseIP",
|
||||
"interfaceName": "eth0",
|
||||
"interface": "eth0",
|
||||
"mark": 100,
|
||||
"penetrate": false,
|
||||
"tcpFastOpen": true,
|
||||
@@ -59,7 +59,7 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tcp-tuning byte-stably 1`
|
||||
"customSockopt": [],
|
||||
"dialerProxy": "",
|
||||
"domainStrategy": "AsIs",
|
||||
"interfaceName": "",
|
||||
"interface": "",
|
||||
"mark": 0,
|
||||
"penetrate": false,
|
||||
"tcpFastOpen": true,
|
||||
@@ -83,7 +83,7 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tproxy byte-stably 1`] =
|
||||
"customSockopt": [],
|
||||
"dialerProxy": "",
|
||||
"domainStrategy": "ForceIPv4",
|
||||
"interfaceName": "",
|
||||
"interface": "",
|
||||
"mark": 255,
|
||||
"penetrate": true,
|
||||
"tcpFastOpen": false,
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
"tcpcongestion": "cubic",
|
||||
"V6Only": false,
|
||||
"tcpWindowClamp": 600,
|
||||
"interfaceName": "eth0",
|
||||
"interface": "eth0",
|
||||
"trustedXForwardedFor": ["10.0.0.0/8", "192.168.0.0/16"]
|
||||
}
|
||||
|
||||
@@ -196,6 +196,19 @@ describe('resolveAddr precedence', () => {
|
||||
)).toBe('fallback.test');
|
||||
});
|
||||
|
||||
it('skips a unix socket path listen and falls through to fallbackHostname', () => {
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: '/run/xray/in.sock' } as never,
|
||||
'',
|
||||
'fallback.test',
|
||||
)).toBe('fallback.test');
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: '@xray-abstract' } as never,
|
||||
'',
|
||||
'fallback.test',
|
||||
)).toBe('fallback.test');
|
||||
});
|
||||
|
||||
it('falls through to fallbackHostname when listen is empty', () => {
|
||||
expect(resolveAddr(
|
||||
baseInbound as never,
|
||||
|
||||
@@ -74,6 +74,25 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('vless preserves a non-none encryption value (post-quantum)', () => {
|
||||
const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
|
||||
const wire = {
|
||||
protocol: 'vless',
|
||||
settings: {
|
||||
address: 'srv',
|
||||
port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
flow: '',
|
||||
encryption: enc,
|
||||
},
|
||||
};
|
||||
const form = rawOutboundToFormValues(wire);
|
||||
if (form.protocol === 'vless') {
|
||||
expect(form.settings.encryption).toBe(enc);
|
||||
}
|
||||
expect((formValuesToWirePayload(form).settings as Record<string, unknown>).encryption).toBe(enc);
|
||||
});
|
||||
|
||||
it('vless emits reverse + sniffing when reverseTag is set', () => {
|
||||
const wire = {
|
||||
protocol: 'vless',
|
||||
|
||||
@@ -173,6 +173,23 @@ describe('parseVlessLink', () => {
|
||||
expect(reality.shortId).toBe('abcd');
|
||||
expect(reality.serverName).toBe('cloudflare.com');
|
||||
});
|
||||
|
||||
it('parses encryption + pqv (post-quantum) into settings and mldsa65Verify', () => {
|
||||
const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
|
||||
const pqv = 'GIsemxbGPjDRH1ONfmoGlVkJ4etNuLmYDvzpjmFFreDLd8WjoJxJ4Fmt_NQJaC6';
|
||||
const link
|
||||
= 'vless://9406c224-8ac6-4675-ae0b-f93785959418@localhost:1121'
|
||||
+ `?encryption=${enc}&pqv=${pqv}`
|
||||
+ '&security=reality&sid=29cf418813d5bac7&sni=aws.amazon.com'
|
||||
+ '&pbk=aQaGBOT2hMfXWebYtjADoOVUrP8qZRdwXVap7nrId0I&fp=chrome&spx=%2FOUTjB7xHRiP4zBP&type=tcp'
|
||||
+ '#giqssbgmo9';
|
||||
const out = parseVlessLink(link);
|
||||
const settings = out?.settings as { encryption: string };
|
||||
expect(settings.encryption).toBe(enc);
|
||||
const reality = (out?.streamSettings as Record<string, unknown>).realitySettings as Record<string, unknown>;
|
||||
expect(reality.mldsa65Verify).toBe(pqv);
|
||||
expect(reality.publicKey).toBe('aQaGBOT2hMfXWebYtjADoOVUrP8qZRdwXVap7nrId0I');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTrojanLink', () => {
|
||||
@@ -250,6 +267,32 @@ describe('parseHysteria2Link', () => {
|
||||
const out = parseHysteria2Link('hy2://auth@srv:443?sni=example.com');
|
||||
expect(out?.protocol).toBe('hysteria');
|
||||
});
|
||||
|
||||
it('parses alpn, fingerprint and the salamander UDP mask (fm) — #4760', () => {
|
||||
const link = 'hysteria2://78e7795a209c4c099f896a816fc8448f@news.domain.org:8443?'
|
||||
+ 'alpn=h2%2Chttp%2F1.1&'
|
||||
+ 'fm=%7B%22udp%22%3A%5B%7B%22settings%22%3A%7B%22password%22%3A%22ftwfgb9655hh2mgo%22%7D%2C%22type%22%3A%22salamander%22%7D%5D%7D&'
|
||||
+ 'fp=chrome&obfs=salamander&obfs-password=655hh2mgo&security=tls&sni=news.domain.org'
|
||||
+ '#hy2-ej596ty350qs';
|
||||
const out = parseHysteria2Link(link);
|
||||
expect(out).not.toBeNull();
|
||||
const stream = out!.streamSettings as Record<string, unknown>;
|
||||
const tls = stream.tlsSettings as Record<string, unknown>;
|
||||
expect(tls.alpn).toEqual(['h2', 'http/1.1']);
|
||||
expect(tls.fingerprint).toBe('chrome');
|
||||
expect(tls.serverName).toBe('news.domain.org');
|
||||
const finalmask = stream.finalmask as Record<string, unknown>;
|
||||
expect(finalmask).toBeDefined();
|
||||
const udp = finalmask.udp as Array<Record<string, unknown>>;
|
||||
expect(udp[0].type).toBe('salamander');
|
||||
expect((udp[0].settings as Record<string, unknown>).password).toBe('ftwfgb9655hh2mgo');
|
||||
});
|
||||
|
||||
it('defaults alpn to h3 when the link omits it', () => {
|
||||
const out = parseHysteria2Link('hysteria2://auth@srv:443?sni=example.com');
|
||||
const tls = (out!.streamSettings as Record<string, unknown>).tlsSettings as Record<string, unknown>;
|
||||
expect(tls.alpn).toEqual(['h3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 117 KiB |
@@ -60,6 +60,8 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
ensureUniqueProxyNames(proxies)
|
||||
|
||||
emails := make([]string, 0, len(seenEmails))
|
||||
for e := range seenEmails {
|
||||
emails = append(emails, e)
|
||||
@@ -93,6 +95,38 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
return string(finalYAML), header, nil
|
||||
}
|
||||
|
||||
// ensureUniqueProxyNames keeps every proxy "name" non-empty and unique:
|
||||
// mihomo rejects the whole config on a duplicate name (the empty string
|
||||
// genRemark returns for a remark-less inbound counts), vanishing the Clash
|
||||
// profile on refresh. See issue #4641.
|
||||
func ensureUniqueProxyNames(proxies []map[string]any) {
|
||||
seen := make(map[string]struct{}, len(proxies))
|
||||
for i, proxy := range proxies {
|
||||
base, _ := proxy["name"].(string)
|
||||
if base == "" {
|
||||
base = fallbackProxyName(proxy, i)
|
||||
}
|
||||
name := base
|
||||
for n := 2; ; n++ {
|
||||
if _, dup := seen[name]; !dup {
|
||||
break
|
||||
}
|
||||
name = fmt.Sprintf("%s-%d", base, n)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
proxy["name"] = name
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackProxyName(proxy map[string]any, idx int) string {
|
||||
typ, _ := proxy["type"].(string)
|
||||
server, _ := proxy["server"].(string)
|
||||
if typ != "" && server != "" {
|
||||
return fmt.Sprintf("%s-%s-%v", typ, server, proxy["port"])
|
||||
}
|
||||
return fmt.Sprintf("proxy-%d", idx+1)
|
||||
}
|
||||
|
||||
func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any {
|
||||
stream := s.streamData(inbound.StreamSettings)
|
||||
// For node-managed inbounds the Clash proxy "server" must be the
|
||||
|
||||
@@ -5,6 +5,40 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureUniqueProxyNames(t *testing.T) {
|
||||
proxies := []map[string]any{
|
||||
{"name": "", "type": "vless", "server": "a.com", "port": 443},
|
||||
{"name": "", "type": "vmess", "server": "b.com", "port": 8443},
|
||||
{"name": "node"},
|
||||
{"name": "node"},
|
||||
{"name": ""},
|
||||
}
|
||||
|
||||
ensureUniqueProxyNames(proxies)
|
||||
|
||||
seen := map[string]bool{}
|
||||
for i, p := range proxies {
|
||||
name, _ := p["name"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("proxy %d still has an empty name (mihomo would reject the config, #4641)", i)
|
||||
}
|
||||
if seen[name] {
|
||||
t.Fatalf("proxy %d has duplicate name %q (mihomo rejects the whole config, #4641)", i, name)
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
|
||||
if got := proxies[0]["name"]; got != "vless-a.com-443" {
|
||||
t.Errorf("empty name fallback = %q, want vless-a.com-443", got)
|
||||
}
|
||||
if proxies[2]["name"] == proxies[3]["name"] {
|
||||
t.Errorf("duplicate %q was not disambiguated", proxies[2]["name"])
|
||||
}
|
||||
if got := proxies[4]["name"]; got != "proxy-5" {
|
||||
t.Errorf("typeless empty name fallback = %q, want proxy-5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTransport_XHTTP(t *testing.T) {
|
||||
svc := &SubClashService{}
|
||||
proxy := map[string]any{}
|
||||
|
||||
@@ -277,6 +277,10 @@ func (a *SUBController) subClashs(c *gin.Context) {
|
||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
}
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
|
||||
if a.subTitle != "" {
|
||||
// Clash clients commonly use Content-Disposition to choose the imported profile name.
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, a.subTitle))
|
||||
}
|
||||
c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,34 +158,61 @@ func (s *SubService) AggregateTrafficByEmails(emails []string) (xray.ClientTraff
|
||||
if len(emails) == 0 {
|
||||
return agg, 0
|
||||
}
|
||||
db := database.GetDB()
|
||||
var rows []xray.ClientTraffic
|
||||
if err := database.GetDB().
|
||||
if err := db.
|
||||
Model(&xray.ClientTraffic{}).
|
||||
Where("email IN ?", emails).
|
||||
Find(&rows).Error; err != nil {
|
||||
logger.Warning("SubService - AggregateTrafficByEmails: load by email:", err)
|
||||
return agg, 0
|
||||
}
|
||||
|
||||
// total/expiry are configured limits owned by the clients table, not the
|
||||
// runtime traffic rows. In a multi-node setup the node snapshot can reset
|
||||
// client_traffics.total/expiry_time to 0, so fall back to the clients
|
||||
// table to keep the Subscription-Userinfo header in sync with the UI (#4645).
|
||||
limits := make(map[string][2]int64, len(emails))
|
||||
var records []model.ClientRecord
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Find(&records).Error; err != nil {
|
||||
logger.Warning("SubService - AggregateTrafficByEmails: load client limits:", err)
|
||||
} else {
|
||||
for _, r := range records {
|
||||
limits[r.Email] = [2]int64{r.TotalGB, r.ExpiryTime}
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for i, ct := range rows {
|
||||
first := true
|
||||
for _, ct := range rows {
|
||||
if ct.LastOnline > lastOnline {
|
||||
lastOnline = ct.LastOnline
|
||||
}
|
||||
if i == 0 {
|
||||
total, expiry := ct.Total, ct.ExpiryTime
|
||||
if lim, ok := limits[ct.Email]; ok {
|
||||
if total == 0 {
|
||||
total = lim[0]
|
||||
}
|
||||
if expiry == 0 {
|
||||
expiry = lim[1]
|
||||
}
|
||||
}
|
||||
if first {
|
||||
agg.Up = ct.Up
|
||||
agg.Down = ct.Down
|
||||
agg.Total = ct.Total
|
||||
agg.ExpiryTime = subscriptionExpiryFromClient(now, ct.ExpiryTime)
|
||||
agg.Total = total
|
||||
agg.ExpiryTime = subscriptionExpiryFromClient(now, expiry)
|
||||
first = false
|
||||
continue
|
||||
}
|
||||
agg.Up += ct.Up
|
||||
agg.Down += ct.Down
|
||||
if agg.Total == 0 || ct.Total == 0 {
|
||||
if agg.Total == 0 || total == 0 {
|
||||
agg.Total = 0
|
||||
} else {
|
||||
agg.Total += ct.Total
|
||||
agg.Total += total
|
||||
}
|
||||
normalized := subscriptionExpiryFromClient(now, ct.ExpiryTime)
|
||||
normalized := subscriptionExpiryFromClient(now, expiry)
|
||||
if normalized != agg.ExpiryTime {
|
||||
agg.ExpiryTime = 0
|
||||
}
|
||||
@@ -576,11 +603,14 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
||||
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
|
||||
params["fp"], _ = fpValue.(string)
|
||||
}
|
||||
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
|
||||
if insecure.(bool) {
|
||||
params["insecure"] = "1"
|
||||
if echValue, ok := searchKey(tlsSettings, "echConfigList"); ok {
|
||||
if ech, _ := echValue.(string); ech != "" {
|
||||
params["ech"] = ech
|
||||
}
|
||||
}
|
||||
if pins, ok := pinnedSha256List(tlsSettings); ok {
|
||||
params["pinSHA256"] = strings.Join(pins, ",")
|
||||
}
|
||||
}
|
||||
|
||||
// salamander obfs (Hysteria2). The panel-side link generator already
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
)
|
||||
|
||||
func TestAggregateTrafficByEmails_FallsBackToClientLimits(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
const email = "node-client@example.com"
|
||||
const totalBytes = int64(300) * 1024 * 1024 * 1024
|
||||
const expiry = int64(1893456000000)
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Create(&model.ClientRecord{
|
||||
Email: email,
|
||||
TotalGB: totalBytes,
|
||||
ExpiryTime: expiry,
|
||||
Enable: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed client record: %v", err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{
|
||||
Email: email,
|
||||
Up: 111,
|
||||
Down: 222,
|
||||
Total: 0,
|
||||
ExpiryTime: 0,
|
||||
Enable: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed client traffic: %v", err)
|
||||
}
|
||||
|
||||
var s SubService
|
||||
agg, _ := s.AggregateTrafficByEmails([]string{email})
|
||||
|
||||
if agg.Up != 111 || agg.Down != 222 {
|
||||
t.Errorf("usage = up %d/down %d, want 111/222", agg.Up, agg.Down)
|
||||
}
|
||||
if agg.Total != totalBytes {
|
||||
t.Errorf("total = %d, want %d (fallback to clients table)", agg.Total, totalBytes)
|
||||
}
|
||||
if agg.ExpiryTime != expiry {
|
||||
t.Errorf("expiry = %d, want %d (fallback to clients table)", agg.ExpiryTime, expiry)
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,8 @@ func HashPasswordAsBcrypt(password string) (string, error) {
|
||||
func CheckPasswordHash(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func IsHashed(s string) bool {
|
||||
_, err := bcrypt.Cost([]byte(s))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/setEnable/:id", a.setEnable)
|
||||
|
||||
g.POST("/test", a.test)
|
||||
g.POST("/certFingerprint", a.certFingerprint)
|
||||
g.POST("/probe/:id", a.probe)
|
||||
g.POST("/updatePanel", a.updatePanel)
|
||||
g.GET("/history/:id/:metric/:bucket", a.history)
|
||||
@@ -143,6 +144,29 @@ func (a *NodeController) test(c *gin.Context) {
|
||||
jsonObj(c, patch.ToUI(err == nil), nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) certFingerprint(c *gin.Context) {
|
||||
n := &model.Node{}
|
||||
if err := c.ShouldBind(n); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
}
|
||||
if n.Scheme == "" {
|
||||
n.Scheme = "https"
|
||||
}
|
||||
if n.BasePath == "" {
|
||||
n.BasePath = "/"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
fp, err := a.nodeService.FetchCertFingerprint(ctx, n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, fp, nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) probe(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -41,8 +41,8 @@ const defaultXrayAPIPort = 62789
|
||||
//
|
||||
// Without this eviction, an IP that connected once and then went away
|
||||
// keeps sitting in the table with its old timestamp. Because the
|
||||
// excess-IP selector sorts ascending ("oldest wins, newest loses") to
|
||||
// protect the original/current connections, that stale entry keeps
|
||||
// excess-IP selector sorts ascending ("newest wins, oldest loses") to
|
||||
// protect the most recent connections, that stale entry keeps
|
||||
// occupying a slot and the IP that is *actually* currently using the
|
||||
// config gets classified as "new excess" and banned by fail2ban on
|
||||
// every single run — producing the continuous ban loop from #4077.
|
||||
@@ -66,8 +66,12 @@ func (j *CheckClientIpJob) Run() {
|
||||
}
|
||||
|
||||
shouldClearAccessLog := false
|
||||
iplimitActive := j.hasLimitIp()
|
||||
f2bInstalled := j.checkFail2BanInstalled()
|
||||
fail2BanEnabled := isFail2BanEnabled()
|
||||
iplimitActive := fail2BanEnabled && j.hasLimitIp()
|
||||
f2bInstalled := false
|
||||
if iplimitActive {
|
||||
f2bInstalled = j.checkFail2BanInstalled()
|
||||
}
|
||||
isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive)
|
||||
|
||||
if isAccessLogAvailable {
|
||||
@@ -80,9 +84,7 @@ func (j *CheckClientIpJob) Run() {
|
||||
if f2bInstalled {
|
||||
shouldClearAccessLog = j.processLogFile()
|
||||
} else {
|
||||
if !f2bInstalled {
|
||||
logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
|
||||
}
|
||||
logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,15 +257,13 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
|
||||
//
|
||||
// only live ips count toward the per-client limit. historical ones stay
|
||||
// in the db so the panel keeps showing them, but they must not take a
|
||||
// protected slot. the 30min cutoff alone isn't tight enough: an ip that
|
||||
// stopped connecting a few minutes ago still looks fresh to
|
||||
// mergeClientIps, and since the over-limit picker sorts ascending and
|
||||
// keeps the oldest, those idle entries used to win the slot while the
|
||||
// ip actually connecting got classified as excess and sent to fail2ban
|
||||
// every tick. see #4077 / #4091.
|
||||
// live slot or get re-banned. the 30min cutoff alone isn't tight enough:
|
||||
// an ip that stopped connecting a few minutes ago still looks fresh to
|
||||
// mergeClientIps, and without this split it would keep triggering
|
||||
// fail2ban even though it isn't currently connected. see #4077 / #4091.
|
||||
//
|
||||
// live is sorted ascending so the "protect original, ban newcomer"
|
||||
// rule still holds when several ips are really connecting at once.
|
||||
// live is sorted ascending by timestamp (oldest → newest), so we keep
|
||||
// the most recent entries at the end of the slice (last IP wins).
|
||||
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
|
||||
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
||||
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
||||
@@ -281,12 +281,21 @@ func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool)
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
|
||||
if !isFail2BanEnabled() {
|
||||
return false
|
||||
}
|
||||
|
||||
cmd := "fail2ban-client"
|
||||
args := []string{"-h"}
|
||||
err := exec.Command(cmd, args...).Run()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func isFail2BanEnabled() bool {
|
||||
value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
|
||||
return !ok || value == "true"
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) checkAccessLogAvailable(iplimitActive bool) bool {
|
||||
accessLogPath, err := xray.GetAccessLogPath()
|
||||
if err != nil {
|
||||
@@ -408,9 +417,10 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||
if len(liveIps) > limitIp {
|
||||
shouldCleanLog = true
|
||||
|
||||
// protect the oldest live ip, ban newcomers.
|
||||
keptLive = liveIps[:limitIp]
|
||||
bannedLive := liveIps[limitIp:]
|
||||
// keep the newest live ips, ban older ones.
|
||||
cutoff := len(liveIps) - limitIp
|
||||
keptLive = liveIps[cutoff:]
|
||||
bannedLive := liveIps[:cutoff]
|
||||
|
||||
// Open log file only when a ban entry needs to be written.
|
||||
// Use a local logger to avoid mutating the global log.* state,
|
||||
@@ -456,7 +466,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||
}
|
||||
|
||||
if len(j.disAllowedIps) > 0 {
|
||||
logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d new IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
|
||||
logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d old IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
|
||||
}
|
||||
|
||||
return shouldCleanLog
|
||||
|
||||
@@ -128,6 +128,49 @@ func ipSet(entries []IPWithTimestamp) map[string]int64 {
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRun_DisabledFail2BanSkipsProbeAndBanLog(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
|
||||
marker := fakeFail2BanClient(t)
|
||||
|
||||
const email = "disabled-fail2ban"
|
||||
seedInboundWithClient(t, "inbound-disabled-fail2ban", email, 1)
|
||||
|
||||
binDir := t.TempDir()
|
||||
accessLog := filepath.Join(t.TempDir(), "access.log")
|
||||
t.Setenv("XUI_BIN_FOLDER", binDir)
|
||||
configData, err := json.Marshal(map[string]any{
|
||||
"log": map[string]any{"access": accessLog},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal xray config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
|
||||
t.Fatalf("write xray config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(accessLog, []byte("2026/05/26 12:00:00 from tcp:203.0.113.10:443 accepted tcp:example.com:443 email: disabled-fail2ban\n"), 0644); err != nil {
|
||||
t.Fatalf("write access log: %v", err)
|
||||
}
|
||||
|
||||
j := NewCheckClientIpJob()
|
||||
j.Run()
|
||||
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
|
||||
}
|
||||
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
||||
body, _ := os.ReadFile(readIpLimitLogPath())
|
||||
t.Fatalf("3xipl.log should be empty when fail2ban is disabled, got:\n%s", body)
|
||||
}
|
||||
var count int64
|
||||
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", email).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count InboundClientIps: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("disabled fail2ban should not persist IP-limit rows, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// #4091 repro: client has limit=3, db still holds 3 idle ips from a
|
||||
// few minutes ago, only one live ip is actually connecting. pre-fix:
|
||||
// live ip got banned every tick and never appeared in the panel.
|
||||
@@ -179,7 +222,8 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
|
||||
}
|
||||
|
||||
// opposite invariant: when several ips are actually live and exceed
|
||||
// the limit, the newcomer still gets banned.
|
||||
// the limit, the oldest connection is dropped and the most recent one
|
||||
// keeps the slot (last-IP-wins policy from #3735, restored in #4699).
|
||||
func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
|
||||
@@ -193,8 +237,8 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
||||
|
||||
j := NewCheckClientIpJob()
|
||||
// both live, limit=1. use distinct timestamps so sort-by-timestamp
|
||||
// is deterministic: 10.1.0.1 is the original (older), 192.0.2.9
|
||||
// joined later and must get banned.
|
||||
// is deterministic: 10.1.0.1 is the original (older) and must get
|
||||
// banned; 192.0.2.9 joined later and keeps the slot (last IP wins).
|
||||
live := []IPWithTimestamp{
|
||||
{IP: "10.1.0.1", Timestamp: now - 5},
|
||||
{IP: "192.0.2.9", Timestamp: now},
|
||||
@@ -205,16 +249,16 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
||||
if !shouldCleanLog {
|
||||
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
|
||||
}
|
||||
if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "192.0.2.9" {
|
||||
t.Fatalf("expected 192.0.2.9 to be banned; disAllowedIps = %v", j.disAllowedIps)
|
||||
if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "10.1.0.1" {
|
||||
t.Fatalf("expected 10.1.0.1 to be banned; disAllowedIps = %v", j.disAllowedIps)
|
||||
}
|
||||
|
||||
persisted := ipSet(readClientIps(t, email))
|
||||
if _, ok := persisted["10.1.0.1"]; !ok {
|
||||
t.Errorf("original IP 10.1.0.1 must still be persisted; got %v", persisted)
|
||||
if _, ok := persisted["192.0.2.9"]; !ok {
|
||||
t.Errorf("newest IP 192.0.2.9 must still be persisted; got %v", persisted)
|
||||
}
|
||||
if _, ok := persisted["192.0.2.9"]; ok {
|
||||
t.Errorf("banned IP 192.0.2.9 must NOT be persisted; got %v", persisted)
|
||||
if _, ok := persisted["10.1.0.1"]; ok {
|
||||
t.Errorf("banned IP 10.1.0.1 must NOT be persisted; got %v", persisted)
|
||||
}
|
||||
|
||||
// 3xipl.log must contain the ban line in the exact fail2ban format.
|
||||
@@ -222,7 +266,7 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read 3xipl.log: %v", err)
|
||||
}
|
||||
wantSubstr := "[LIMIT_IP] Email = pr4091-abuse || Disconnecting OLD IP = 192.0.2.9"
|
||||
wantSubstr := "[LIMIT_IP] Email = pr4091-abuse || Disconnecting OLD IP = 10.1.0.1"
|
||||
if !contains(string(body), wantSubstr) {
|
||||
t.Fatalf("3xipl.log missing expected ban line %q\nfull log:\n%s", wantSubstr, body)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -107,9 +110,10 @@ func TestPartitionLiveIps_SingleLiveNotStarvedByStillFreshHistoricals(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionLiveIps_ConcurrentLiveIpsStillBanNewcomers(t *testing.T) {
|
||||
// keep the "protect original, ban newcomer" policy when several ips
|
||||
// are really live. with limit=1, A must stay and B must be banned.
|
||||
func TestPartitionLiveIps_ConcurrentLiveIpsSortedAscending(t *testing.T) {
|
||||
// when several ips are really live, partition returns them all in the
|
||||
// live set sorted ascending by timestamp. updateInboundClientIps then
|
||||
// keeps the newest and bans the oldest (last-IP-wins, #4699).
|
||||
ipMap := map[string]int64{
|
||||
"A": 5000,
|
||||
"B": 5500,
|
||||
@@ -144,3 +148,75 @@ func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
|
||||
t.Fatalf("all merged entries should flow to historical\ngot: %v\nwant: [A B]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckFail2BanInstalled_DisabledEnvSkipsClientProbe(t *testing.T) {
|
||||
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
|
||||
marker := fakeFail2BanClient(t)
|
||||
|
||||
if (&CheckClientIpJob{}).checkFail2BanInstalled() {
|
||||
t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN=false")
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckFail2BanInstalled_EmptyEnvSkipsClientProbe(t *testing.T) {
|
||||
t.Setenv("XUI_ENABLE_FAIL2BAN", "")
|
||||
marker := fakeFail2BanClient(t)
|
||||
|
||||
if (&CheckClientIpJob{}).checkFail2BanInstalled() {
|
||||
t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN is empty")
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsFail2BanEnabled_DefaultsToEnabledWhenUnset(t *testing.T) {
|
||||
value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
|
||||
os.Unsetenv("XUI_ENABLE_FAIL2BAN")
|
||||
t.Cleanup(func() {
|
||||
if ok {
|
||||
os.Setenv("XUI_ENABLE_FAIL2BAN", value)
|
||||
} else {
|
||||
os.Unsetenv("XUI_ENABLE_FAIL2BAN")
|
||||
}
|
||||
})
|
||||
|
||||
if !isFail2BanEnabled() {
|
||||
t.Fatal("fail2ban should default to enabled when XUI_ENABLE_FAIL2BAN is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckFail2BanInstalled_EnabledEnvProbesClient(t *testing.T) {
|
||||
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
|
||||
marker := fakeFail2BanClient(t)
|
||||
|
||||
if !(&CheckClientIpJob{}).checkFail2BanInstalled() {
|
||||
t.Fatal("fail2ban should be available when the client probe succeeds")
|
||||
}
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("fail2ban-client should have been executed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fakeFail2BanClient(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
marker := filepath.Join(dir, "probe-called")
|
||||
fakeClient := filepath.Join(dir, "fail2ban-client")
|
||||
script := "#!/bin/sh\n: > \"$FAIL2BAN_PROBE_MARKER\"\nexit 0\n"
|
||||
if runtime.GOOS == "windows" {
|
||||
fakeClient += ".bat"
|
||||
script = "@echo off\ntype nul > \"%FAIL2BAN_PROBE_MARKER%\"\nexit /b 0\n"
|
||||
}
|
||||
if err := os.WriteFile(fakeClient, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake fail2ban-client: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("FAIL2BAN_PROBE_MARKER", marker)
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return marker
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
)
|
||||
|
||||
func setupBulkDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
}
|
||||
|
||||
func clientsSettings(t *testing.T, clients []model.Client) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(map[string][]model.Client{"clients": clients})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func emailsOf(clients []model.Client) []string {
|
||||
out := make([]string, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
out = append(out, c.Email)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedEmails(list []model.Client) []string {
|
||||
out := emailsOf(list)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func mkInbound(t *testing.T, port int, proto model.Protocol, settings string) *model.Inbound {
|
||||
t.Helper()
|
||||
ib := &model.Inbound{
|
||||
Tag: string(proto) + "-" + filepath.Base(t.TempDir()),
|
||||
Enable: true,
|
||||
Port: port,
|
||||
Protocol: proto,
|
||||
Settings: settings,
|
||||
}
|
||||
if err := database.GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound %d: %v", port, err)
|
||||
}
|
||||
return ib
|
||||
}
|
||||
|
||||
// TestBulkAttachDetach_VLESS exercises the batched attach/detach round-trip on
|
||||
// VLESS inbounds: linkage, settings JSON, idempotency, skip, and record survival.
|
||||
func TestBulkAttachDetach_VLESS(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{
|
||||
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
|
||||
{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
|
||||
{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
|
||||
}
|
||||
|
||||
ib1 := mkInbound(t, 20001, model.VLESS, clientsSettings(t, source))
|
||||
ib2 := mkInbound(t, 20002, model.VLESS, `{"clients":[]}`)
|
||||
ib3 := mkInbound(t, 20003, model.VLESS, `{"clients":[]}`)
|
||||
|
||||
if err := svc.SyncInbound(nil, ib1.Id, source); err != nil {
|
||||
t.Fatalf("seed source linkage: %v", err)
|
||||
}
|
||||
|
||||
emails := emailsOf(source)
|
||||
|
||||
res, _, err := svc.BulkAttach(inboundSvc, emails, []int{ib2.Id, ib3.Id})
|
||||
if err != nil {
|
||||
t.Fatalf("BulkAttach: %v", err)
|
||||
}
|
||||
if len(res.Errors) != 0 {
|
||||
t.Fatalf("BulkAttach errors: %v", res.Errors)
|
||||
}
|
||||
if len(res.Skipped) != 0 {
|
||||
t.Fatalf("BulkAttach skipped unexpectedly: %v", res.Skipped)
|
||||
}
|
||||
if len(res.Attached) != 6 {
|
||||
t.Fatalf("expected 6 attach entries (3 clients x 2 inbounds), got %d: %v", len(res.Attached), res.Attached)
|
||||
}
|
||||
|
||||
for _, ib := range []*model.Inbound{ib2, ib3} {
|
||||
list, err := svc.ListForInbound(nil, ib.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForInbound(%d): %v", ib.Id, err)
|
||||
}
|
||||
if got := sortedEmails(list); len(got) != 3 {
|
||||
t.Fatalf("inbound %d: expected 3 linked clients, got %v", ib.Id, got)
|
||||
}
|
||||
reloaded, err := inboundSvc.GetInbound(ib.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInbound(%d): %v", ib.Id, err)
|
||||
}
|
||||
jsonClients, err := inboundSvc.GetClients(reloaded)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClients(%d): %v", ib.Id, err)
|
||||
}
|
||||
if len(jsonClients) != 3 {
|
||||
t.Fatalf("inbound %d settings JSON: expected 3 clients, got %d", ib.Id, len(jsonClients))
|
||||
}
|
||||
}
|
||||
|
||||
res2, _, err := svc.BulkAttach(inboundSvc, emails, []int{ib2.Id, ib3.Id})
|
||||
if err != nil {
|
||||
t.Fatalf("BulkAttach (idempotent): %v", err)
|
||||
}
|
||||
if len(res2.Attached) != 0 {
|
||||
t.Fatalf("re-attach should add nothing, got Attached=%v", res2.Attached)
|
||||
}
|
||||
if len(res2.Skipped) != 6 {
|
||||
t.Fatalf("re-attach should skip all 6, got Skipped=%v", res2.Skipped)
|
||||
}
|
||||
|
||||
dres, _, err := svc.BulkDetach(inboundSvc, emails, []int{ib2.Id, ib3.Id})
|
||||
if err != nil {
|
||||
t.Fatalf("BulkDetach: %v", err)
|
||||
}
|
||||
if len(dres.Errors) != 0 {
|
||||
t.Fatalf("BulkDetach errors: %v", dres.Errors)
|
||||
}
|
||||
if len(dres.Detached) != 3 {
|
||||
t.Fatalf("expected 3 detached emails, got %v", dres.Detached)
|
||||
}
|
||||
|
||||
for _, ib := range []*model.Inbound{ib2, ib3} {
|
||||
list, err := svc.ListForInbound(nil, ib.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForInbound after detach(%d): %v", ib.Id, err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("inbound %d should have no clients after detach, got %v", ib.Id, sortedEmails(list))
|
||||
}
|
||||
reloaded, _ := inboundSvc.GetInbound(ib.Id)
|
||||
jsonClients, _ := inboundSvc.GetClients(reloaded)
|
||||
if len(jsonClients) != 0 {
|
||||
t.Fatalf("inbound %d settings JSON should be empty after detach, got %d", ib.Id, len(jsonClients))
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range emails {
|
||||
rec, err := svc.GetRecordByEmail(nil, e)
|
||||
if err != nil {
|
||||
t.Fatalf("record %q should survive detach: %v", e, err)
|
||||
}
|
||||
ids, err := svc.GetInboundIdsForRecord(rec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInboundIdsForRecord(%q): %v", e, err)
|
||||
}
|
||||
if len(ids) != 1 || ids[0] != ib1.Id {
|
||||
t.Fatalf("record %q should remain attached only to source inbound %d, got %v", e, ib1.Id, ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkDetach_SkipsUnattached verifies emails not on any requested inbound
|
||||
// land in Skipped, not Detached, and produce no error.
|
||||
func TestBulkDetach_SkipsUnattached(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{
|
||||
{Email: "only-on-1@x", ID: "44444444-4444-4444-4444-444444444444", SubID: "s1", Enable: true},
|
||||
}
|
||||
ib1 := mkInbound(t, 21001, model.VLESS, clientsSettings(t, source))
|
||||
ib2 := mkInbound(t, 21002, model.VLESS, `{"clients":[]}`)
|
||||
if err := svc.SyncInbound(nil, ib1.Id, source); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
dres, restart, err := svc.BulkDetach(inboundSvc, []string{"only-on-1@x"}, []int{ib2.Id})
|
||||
if err != nil {
|
||||
t.Fatalf("BulkDetach: %v", err)
|
||||
}
|
||||
if restart {
|
||||
t.Fatalf("no-op detach should not require restart")
|
||||
}
|
||||
if len(dres.Detached) != 0 {
|
||||
t.Fatalf("nothing should be detached, got %v", dres.Detached)
|
||||
}
|
||||
if len(dres.Skipped) != 1 || dres.Skipped[0] != "only-on-1@x" {
|
||||
t.Fatalf("expected the email in Skipped, got %v", dres.Skipped)
|
||||
}
|
||||
if len(dres.Errors) != 0 {
|
||||
t.Fatalf("unexpected errors: %v", dres.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkAttachDetach_Trojan checks the protocol-specific key matching in the
|
||||
// batched detach path (Trojan keys on password, not id).
|
||||
func TestBulkAttachDetach_Trojan(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{
|
||||
{Email: "t1@x", Password: "pw-t1", SubID: "t1", Enable: true},
|
||||
{Email: "t2@x", Password: "pw-t2", SubID: "t2", Enable: true},
|
||||
}
|
||||
ib1 := mkInbound(t, 22001, model.Trojan, clientsSettings(t, source))
|
||||
ib2 := mkInbound(t, 22002, model.Trojan, `{"clients":[]}`)
|
||||
if err := svc.SyncInbound(nil, ib1.Id, source); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
emails := emailsOf(source)
|
||||
if res, _, err := svc.BulkAttach(inboundSvc, emails, []int{ib2.Id}); err != nil {
|
||||
t.Fatalf("BulkAttach: %v", err)
|
||||
} else if len(res.Errors) != 0 || len(res.Attached) != 2 {
|
||||
t.Fatalf("attach result unexpected: attached=%v errors=%v", res.Attached, res.Errors)
|
||||
}
|
||||
|
||||
list, _ := svc.ListForInbound(nil, ib2.Id)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected 2 trojan clients on ib2, got %v", sortedEmails(list))
|
||||
}
|
||||
|
||||
dres, _, err := svc.BulkDetach(inboundSvc, emails, []int{ib2.Id})
|
||||
if err != nil {
|
||||
t.Fatalf("BulkDetach: %v", err)
|
||||
}
|
||||
if len(dres.Detached) != 2 || len(dres.Errors) != 0 {
|
||||
t.Fatalf("detach result unexpected: detached=%v errors=%v", dres.Detached, dres.Errors)
|
||||
}
|
||||
if list, _ := svc.ListForInbound(nil, ib2.Id); len(list) != 0 {
|
||||
t.Fatalf("trojan clients should be gone from ib2, got %v", sortedEmails(list))
|
||||
}
|
||||
}
|
||||
@@ -245,10 +245,7 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
|
||||
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
|
||||
row.CreatedAt = incoming.CreatedAt
|
||||
}
|
||||
preservedUpdatedAt := row.UpdatedAt
|
||||
if incoming.UpdatedAt > preservedUpdatedAt {
|
||||
preservedUpdatedAt = incoming.UpdatedAt
|
||||
}
|
||||
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
|
||||
row.UpdatedAt = preservedUpdatedAt
|
||||
if err := tx.Save(row).Error; err != nil {
|
||||
return err
|
||||
@@ -886,6 +883,12 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string,
|
||||
records = append(records, rec)
|
||||
}
|
||||
|
||||
emailSubIDs, sidErr := inboundSvc.getAllEmailSubIDs()
|
||||
if sidErr != nil {
|
||||
emailSubIDs = nil
|
||||
logger.Warningf("[BulkAttach] getAllEmailSubIDs: %v", sidErr)
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range inboundIds {
|
||||
inbound, err := inboundSvc.GetInbound(ibId)
|
||||
@@ -927,7 +930,7 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string,
|
||||
recordErr("inbound %d: %v", ibId, err)
|
||||
continue
|
||||
}
|
||||
nr, err := s.AddInboundClient(inboundSvc, &model.Inbound{Id: ibId, Settings: string(payload)})
|
||||
nr, err := s.addInboundClient(inboundSvc, &model.Inbound{Id: ibId, Settings: string(payload)}, emailSubIDs)
|
||||
if err != nil {
|
||||
recordErr("inbound %d: %v", ibId, err)
|
||||
continue
|
||||
@@ -972,7 +975,10 @@ func (s *ClientService) BulkDetach(inboundSvc *InboundService, emails []string,
|
||||
requested[id] = struct{}{}
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
recsByInbound := make(map[int][]*model.ClientRecord)
|
||||
emailOrder := make([]string, 0, len(emails))
|
||||
emailRepr := make(map[string]string, len(emails))
|
||||
emailFailed := make(map[string]bool, len(emails))
|
||||
seenEmail := make(map[string]struct{}, len(emails))
|
||||
for _, email := range emails {
|
||||
if email == "" {
|
||||
@@ -994,30 +1000,194 @@ func (s *ClientService) BulkDetach(inboundSvc *InboundService, emails []string,
|
||||
recordErr("%s: %v", email, err)
|
||||
continue
|
||||
}
|
||||
intersection := make([]int, 0, len(currentIds))
|
||||
matched := false
|
||||
for _, id := range currentIds {
|
||||
if _, ok := requested[id]; ok {
|
||||
intersection = append(intersection, id)
|
||||
recsByInbound[id] = append(recsByInbound[id], rec)
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if len(intersection) == 0 {
|
||||
if !matched {
|
||||
result.Skipped = append(result.Skipped, rec.Email)
|
||||
continue
|
||||
}
|
||||
nr, err := s.Detach(inboundSvc, rec.Id, intersection)
|
||||
emailOrder = append(emailOrder, key)
|
||||
emailRepr[key] = rec.Email
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range inboundIds {
|
||||
recs, ok := recsByInbound[ibId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(recsByInbound, ibId)
|
||||
nr, err := s.delInboundClients(inboundSvc, ibId, recs, true)
|
||||
if err != nil {
|
||||
recordErr("%s: %v", rec.Email, err)
|
||||
recordErr("inbound %d: %v", ibId, err)
|
||||
for _, rec := range recs {
|
||||
emailFailed[strings.ToLower(rec.Email)] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
result.Detached = append(result.Detached, rec.Email)
|
||||
}
|
||||
|
||||
for _, key := range emailOrder {
|
||||
if emailFailed[key] {
|
||||
continue
|
||||
}
|
||||
result.Detached = append(result.Detached, emailRepr[key])
|
||||
}
|
||||
|
||||
return result, needRestart, nil
|
||||
}
|
||||
|
||||
// delInboundClients removes several clients from a single inbound in one pass:
|
||||
// one settings rewrite, one runtime sweep, one Save and one SyncInbound for the
|
||||
// whole batch, instead of repeating the full per-client cycle. It mirrors the
|
||||
// semantics of DelInboundClient for each removed client. needRestart is the OR
|
||||
// across all removals.
|
||||
func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId int, recs []*model.ClientRecord, keepTraffic bool) (bool, error) {
|
||||
if len(recs) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
defer lockInbound(inboundId).Unlock()
|
||||
|
||||
oldInbound, err := inboundSvc.GetInbound(inboundId)
|
||||
if err != nil {
|
||||
logger.Error("Load Old Data Error")
|
||||
return false, err
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
clientKey := "id"
|
||||
switch oldInbound.Protocol {
|
||||
case "trojan":
|
||||
clientKey = "password"
|
||||
case "shadowsocks":
|
||||
clientKey = "email"
|
||||
case "hysteria":
|
||||
clientKey = "auth"
|
||||
}
|
||||
|
||||
wanted := make(map[string]struct{}, len(recs))
|
||||
for _, rec := range recs {
|
||||
if k := clientKeyForProtocol(oldInbound.Protocol, rec); k != "" {
|
||||
wanted[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
interfaceClients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
return false, common.NewError("invalid clients format in inbound settings")
|
||||
}
|
||||
|
||||
type removedClient struct {
|
||||
email string
|
||||
needApiDel bool
|
||||
}
|
||||
removed := make([]removedClient, 0, len(wanted))
|
||||
newClients := make([]any, 0, len(interfaceClients))
|
||||
for _, client := range interfaceClients {
|
||||
c, ok := client.(map[string]any)
|
||||
if !ok {
|
||||
newClients = append(newClients, client)
|
||||
continue
|
||||
}
|
||||
cid, _ := c[clientKey].(string)
|
||||
if _, hit := wanted[cid]; hit && cid != "" {
|
||||
email, _ := c["email"].(string)
|
||||
enable, _ := c["enable"].(bool)
|
||||
removed = append(removed, removedClient{email: email, needApiDel: enable})
|
||||
continue
|
||||
}
|
||||
newClients = append(newClients, client)
|
||||
}
|
||||
|
||||
if len(removed) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
newClients = compactOrphans(db, newClients)
|
||||
if newClients == nil {
|
||||
newClients = []any{}
|
||||
}
|
||||
settings["clients"] = newClients
|
||||
newSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
needRestart := false
|
||||
for _, r := range removed {
|
||||
email := r.email
|
||||
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
|
||||
if err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
if !emailShared && !keepTraffic {
|
||||
if err := inboundSvc.DelClientIPs(db, email); err != nil {
|
||||
logger.Error("Error in delete client IPs")
|
||||
return needRestart, err
|
||||
}
|
||||
}
|
||||
if len(email) > 0 {
|
||||
var enables []bool
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Limit(1).Pluck("enable", &enables).Error; err != nil {
|
||||
logger.Error("Get stats error")
|
||||
return needRestart, err
|
||||
}
|
||||
notDepleted := len(enables) > 0 && enables[0]
|
||||
if !emailShared && !keepTraffic {
|
||||
if err := inboundSvc.DelClientStat(db, email); err != nil {
|
||||
logger.Error("Delete stats Data Error")
|
||||
return needRestart, err
|
||||
}
|
||||
}
|
||||
if r.needApiDel && notDepleted && oldInbound.NodeID == nil {
|
||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
||||
if rterr != nil {
|
||||
needRestart = true
|
||||
} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 != nil {
|
||||
if !strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if oldInbound.NodeID != nil && len(email) > 0 {
|
||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
||||
if rterr != nil {
|
||||
return needRestart, rterr
|
||||
}
|
||||
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
||||
return needRestart, err1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Save(oldInbound).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
|
||||
if gcErr != nil {
|
||||
return needRestart, gcErr
|
||||
}
|
||||
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
|
||||
if email == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
@@ -2884,10 +3054,13 @@ func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, clients []model.Client) (string, error) {
|
||||
emailSubIDs, err := inboundSvc.getAllEmailSubIDs()
|
||||
if err != nil {
|
||||
return "", err
|
||||
func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, clients []model.Client, emailSubIDs map[string]string) (string, error) {
|
||||
if emailSubIDs == nil {
|
||||
var err error
|
||||
emailSubIDs, err = inboundSvc.getAllEmailSubIDs()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
seen := make(map[string]string, len(clients))
|
||||
for _, client := range clients {
|
||||
@@ -2912,6 +3085,14 @@ func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, c
|
||||
}
|
||||
|
||||
func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model.Inbound) (bool, error) {
|
||||
return s.addInboundClient(inboundSvc, data, nil)
|
||||
}
|
||||
|
||||
// addInboundClient is AddInboundClient with an optional precomputed email→subId
|
||||
// map. Bulk callers pass a single snapshot so the global getAllEmailSubIDs scan
|
||||
// runs once for the whole batch instead of once per target inbound; a nil map
|
||||
// makes it compute its own (the single-add path).
|
||||
func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model.Inbound, emailSubIDs map[string]string) (bool, error) {
|
||||
defer lockInbound(data.Id).Unlock()
|
||||
|
||||
clients, err := inboundSvc.GetClients(data)
|
||||
@@ -2940,7 +3121,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
||||
interfaceClients[i] = cm
|
||||
}
|
||||
}
|
||||
existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients)
|
||||
existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients, emailSubIDs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -3159,7 +3340,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
}
|
||||
|
||||
if clients[0].Email != oldEmail {
|
||||
existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients)
|
||||
existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -63,12 +63,16 @@ func (s *FallbackService) SetByMaster(masterId int, items []FallbackInput) error
|
||||
return err
|
||||
}
|
||||
for i, c := range items {
|
||||
if c.ChildId <= 0 || c.ChildId == masterId {
|
||||
childId := c.ChildId
|
||||
if childId == masterId {
|
||||
childId = 0
|
||||
}
|
||||
if childId <= 0 && strings.TrimSpace(c.Dest) == "" {
|
||||
continue
|
||||
}
|
||||
row := model.InboundFallback{
|
||||
MasterId: masterId,
|
||||
ChildId: c.ChildId,
|
||||
ChildId: childId,
|
||||
Name: c.Name,
|
||||
Alpn: c.Alpn,
|
||||
Path: c.Path,
|
||||
@@ -117,12 +121,12 @@ func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[s
|
||||
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
child, ok := byId[r.ChildId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dest := r.Dest
|
||||
dest := strings.TrimSpace(r.Dest)
|
||||
if dest == "" {
|
||||
child, ok := byId[r.ChildId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
listen := strings.TrimSpace(child.Listen)
|
||||
if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
|
||||
listen = "127.0.0.1"
|
||||
|
||||
@@ -479,7 +479,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
if err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
existEmail, err := s.clientService.checkEmailsExistForClients(s, clients)
|
||||
existEmail, err := s.clientService.checkEmailsExistForClients(s, clients, nil)
|
||||
if err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
@@ -1251,6 +1251,18 @@ const resetGracePeriodMs int64 = 30000
|
||||
// long after a real disconnect.
|
||||
const onlineGracePeriodMs int64 = 20000
|
||||
|
||||
type nodeTrafficCounter struct {
|
||||
Up int64
|
||||
Down int64
|
||||
}
|
||||
|
||||
func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email string, up, down int64) error {
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "node_id"}, {Name: "email"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"up", "down"}),
|
||||
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
|
||||
}
|
||||
|
||||
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
|
||||
var structuralChange bool
|
||||
err := submitTrafficWrite(func() error {
|
||||
@@ -1313,6 +1325,26 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
centralCSByEmail[centralClientStats[i].Email] = ¢ralClientStats[i]
|
||||
}
|
||||
|
||||
nodeBaselines := make(map[string]nodeTrafficCounter)
|
||||
var baselineRows []model.NodeClientTraffic
|
||||
if err := db.Model(&model.NodeClientTraffic{}).
|
||||
Where("node_id = ?", nodeID).
|
||||
Find(&baselineRows).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
for i := range baselineRows {
|
||||
nodeBaselines[baselineRows[i].Email] = nodeTrafficCounter{Up: baselineRows[i].Up, Down: baselineRows[i].Down}
|
||||
}
|
||||
|
||||
var existingEmailsList []string
|
||||
if err := db.Model(xray.ClientTraffic{}).Pluck("email", &existingEmailsList).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
existingEmails := make(map[string]struct{}, len(existingEmailsList))
|
||||
for _, e := range existingEmailsList {
|
||||
existingEmails[e] = struct{}{}
|
||||
}
|
||||
|
||||
var defaultUserId int
|
||||
if len(central) > 0 {
|
||||
defaultUserId = central[0].UserId
|
||||
@@ -1458,6 +1490,18 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if _, kept := snapTags[c.Tag]; kept {
|
||||
continue
|
||||
}
|
||||
var goneEmails []string
|
||||
if err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("inbound_id = ?", c.Id).
|
||||
Pluck("email", &goneEmails).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(goneEmails) > 0 {
|
||||
if err := tx.Where("node_id = ? AND email IN ?", nodeID, goneEmails).
|
||||
Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if err := tx.Where("inbound_id = ?", c.Id).
|
||||
Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return false, err
|
||||
@@ -1481,17 +1525,22 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
|
||||
|
||||
snapEmails := make(map[string]struct{}, len(snapIb.ClientStats))
|
||||
for _, cs := range snapIb.ClientStats {
|
||||
snapEmails[cs.Email] = struct{}{}
|
||||
|
||||
existing := centralCS[csKey{c.Id, cs.Email}]
|
||||
if existing == nil {
|
||||
existing = centralCSByEmail[cs.Email]
|
||||
base, seen := nodeBaselines[cs.Email]
|
||||
var deltaUp, deltaDown int64
|
||||
if seen {
|
||||
if deltaUp = cs.Up - base.Up; deltaUp < 0 {
|
||||
deltaUp = cs.Up
|
||||
}
|
||||
if deltaDown = cs.Down - base.Down; deltaDown < 0 {
|
||||
deltaDown = cs.Down
|
||||
}
|
||||
}
|
||||
if existing == nil {
|
||||
|
||||
if _, rowExists := existingEmails[cs.Email]; !rowExists {
|
||||
row := &xray.ClientTraffic{
|
||||
InboundId: c.Id,
|
||||
Email: cs.Email,
|
||||
@@ -1509,42 +1558,40 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
}
|
||||
centralCS[csKey{c.Id, cs.Email}] = row
|
||||
centralCSByEmail[cs.Email] = row
|
||||
existingEmails[cs.Email] = struct{}{}
|
||||
structuralChange = true
|
||||
continue
|
||||
}
|
||||
|
||||
if existing.Enable != cs.Enable ||
|
||||
existing.Total != cs.Total ||
|
||||
existing.ExpiryTime != cs.ExpiryTime ||
|
||||
existing.Reset != cs.Reset {
|
||||
structuralChange = true
|
||||
}
|
||||
|
||||
if inGrace && cs.Up+cs.Down > 0 {
|
||||
if err := tx.Exec(
|
||||
`UPDATE client_traffics
|
||||
SET enable = ?, total = ?, expiry_time = ?, reset = ?
|
||||
WHERE email = ?`,
|
||||
cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset, cs.Email,
|
||||
).Error; err != nil {
|
||||
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, cs.Up, cs.Down); err != nil {
|
||||
return false, err
|
||||
}
|
||||
nodeBaselines[cs.Email] = nodeTrafficCounter{Up: cs.Up, Down: cs.Down}
|
||||
continue
|
||||
}
|
||||
|
||||
if existing := centralCSByEmail[cs.Email]; existing != nil &&
|
||||
(existing.Enable != cs.Enable ||
|
||||
existing.Total != cs.Total ||
|
||||
existing.ExpiryTime != cs.ExpiryTime ||
|
||||
existing.Reset != cs.Reset) {
|
||||
structuralChange = true
|
||||
}
|
||||
|
||||
if err := tx.Exec(
|
||||
fmt.Sprintf(
|
||||
`UPDATE client_traffics
|
||||
SET up = ?, down = ?, enable = ?, total = ?, expiry_time = ?, reset = ?,
|
||||
SET up = up + ?, down = down + ?, enable = ?, total = ?, expiry_time = ?, reset = ?,
|
||||
last_online = %s
|
||||
WHERE email = ?`,
|
||||
database.GreatestExpr("last_online", "?"),
|
||||
),
|
||||
cs.Up, cs.Down, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
|
||||
deltaUp, deltaDown, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
|
||||
cs.LastOnline, cs.Email,
|
||||
).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, cs.Up, cs.Down); err != nil {
|
||||
return false, err
|
||||
}
|
||||
nodeBaselines[cs.Email] = nodeTrafficCounter{Up: cs.Up, Down: cs.Down}
|
||||
}
|
||||
|
||||
for k, existing := range centralCS {
|
||||
@@ -1554,6 +1601,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if _, kept := snapEmails[k.email]; kept {
|
||||
continue
|
||||
}
|
||||
if err := tx.Where("node_id = ? AND email = ?", nodeID, existing.Email).
|
||||
Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email).
|
||||
Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return false, err
|
||||
@@ -1671,6 +1722,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
logger.Warningf("setRemoteTraffic: delete ClientTraffic %q failed: %v", email, err)
|
||||
}
|
||||
if err := tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
logger.Warningf("setRemoteTraffic: delete NodeClientTraffic %q failed: %v", email, err)
|
||||
}
|
||||
structuralChange = true
|
||||
}
|
||||
}
|
||||
@@ -2329,7 +2383,10 @@ func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail
|
||||
}
|
||||
|
||||
func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
|
||||
return tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error
|
||||
if err := tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error
|
||||
}
|
||||
|
||||
func (s *InboundService) DelClientIPs(tx *gorm.DB, email string) error {
|
||||
|
||||
@@ -2,6 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -42,6 +47,113 @@ var nodeHTTPClient = &http.Client{
|
||||
},
|
||||
}
|
||||
|
||||
// nodeHTTPClientFor returns the HTTP client used to reach a node, honoring its
|
||||
// per-node TLS verification mode. "verify" (or any http node) uses the shared
|
||||
// client with default certificate validation. "skip" disables validation.
|
||||
// "pin" disables the default chain check but verifies the leaf certificate's
|
||||
// SHA-256 against the stored pin, keeping MITM protection for self-signed certs.
|
||||
func nodeHTTPClientFor(n *model.Node) (*http.Client, error) {
|
||||
mode := n.TlsVerifyMode
|
||||
if mode == "" {
|
||||
mode = "verify"
|
||||
}
|
||||
if mode == "verify" || n.Scheme == "http" {
|
||||
return nodeHTTPClient, nil
|
||||
}
|
||||
tlsCfg := &tls.Config{InsecureSkipVerify: true}
|
||||
if mode == "pin" {
|
||||
want, err := decodeCertPin(n.PinnedCertSha256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsCfg.VerifyConnection = func(cs tls.ConnectionState) error {
|
||||
if len(cs.PeerCertificates) == 0 {
|
||||
return common.NewError("node presented no certificate")
|
||||
}
|
||||
sum := sha256.Sum256(cs.PeerCertificates[0].Raw)
|
||||
if subtle.ConstantTimeCompare(sum[:], want) != 1 {
|
||||
return common.NewError("node certificate does not match pinned SHA-256")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
TLSClientConfig: tlsCfg,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decodeCertPin accepts a SHA-256 certificate hash as base64 (the format used
|
||||
// by Xray's pinnedPeerCertSha256) or hex with optional colons (the openssl
|
||||
// -fingerprint style) and returns the 32 raw bytes.
|
||||
func decodeCertPin(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, common.NewError("certificate pin is empty")
|
||||
}
|
||||
if b, err := hex.DecodeString(strings.ReplaceAll(s, ":", "")); err == nil && len(b) == sha256.Size {
|
||||
return b, nil
|
||||
}
|
||||
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
|
||||
if b, err := enc.DecodeString(s); err == nil && len(b) == sha256.Size {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
return nil, common.NewError("certificate pin must be a SHA-256 hash (base64 or hex)")
|
||||
}
|
||||
|
||||
// FetchCertFingerprint connects to the node over HTTPS without verifying the
|
||||
// certificate and returns the leaf certificate's SHA-256 as base64, so the UI
|
||||
// can offer a "fetch and pin current certificate" action.
|
||||
func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) (string, error) {
|
||||
addr, err := netsafe.NormalizeHost(n.Address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scheme := n.Scheme
|
||||
if scheme != "http" && scheme != "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
if scheme != "https" {
|
||||
return "", common.NewError("certificate pinning is only available for https nodes")
|
||||
}
|
||||
if n.Port <= 0 || n.Port > 65535 {
|
||||
return "", common.NewError("node port must be 1-65535")
|
||||
}
|
||||
probeURL := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)),
|
||||
Path: normalizeBasePath(n.BasePath) + "panel/api/server/status",
|
||||
}
|
||||
req, err := http.NewRequestWithContext(
|
||||
netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
|
||||
http.MethodGet, probeURL.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // lgtm[go/disabled-certificate-check]
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
|
||||
return "", common.NewError("node did not present a TLS certificate")
|
||||
}
|
||||
sum := sha256.Sum256(resp.TLS.PeerCertificates[0].Raw)
|
||||
return base64.StdEncoding.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func (s *NodeService) GetAll() ([]*model.Node, error) {
|
||||
db := database.GetDB()
|
||||
var nodes []*model.Node
|
||||
@@ -187,6 +299,15 @@ func (s *NodeService) normalize(n *model.Node) error {
|
||||
if n.Scheme != "http" && n.Scheme != "https" {
|
||||
n.Scheme = "https"
|
||||
}
|
||||
if n.TlsVerifyMode != "skip" && n.TlsVerifyMode != "pin" {
|
||||
n.TlsVerifyMode = "verify"
|
||||
}
|
||||
n.PinnedCertSha256 = strings.TrimSpace(n.PinnedCertSha256)
|
||||
if n.TlsVerifyMode == "pin" {
|
||||
if _, err := decodeCertPin(n.PinnedCertSha256); err != nil {
|
||||
return common.NewError(err.Error())
|
||||
}
|
||||
}
|
||||
n.BasePath = normalizeBasePath(n.BasePath)
|
||||
return nil
|
||||
}
|
||||
@@ -218,6 +339,8 @@ func (s *NodeService) Update(id int, in *model.Node) error {
|
||||
"api_token": in.ApiToken,
|
||||
"enable": in.Enable,
|
||||
"allow_private_address": in.AllowPrivateAddress,
|
||||
"tls_verify_mode": in.TlsVerifyMode,
|
||||
"pinned_cert_sha256": in.PinnedCertSha256,
|
||||
}
|
||||
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
@@ -233,6 +356,9 @@ func (s *NodeService) Delete(id int) error {
|
||||
if err := db.Where("id = ?", id).Delete(model.Node{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if mgr := runtime.GetManager(); mgr != nil {
|
||||
mgr.InvalidateNode(id)
|
||||
}
|
||||
@@ -362,8 +488,14 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client, err := nodeHTTPClientFor(n)
|
||||
if err != nil {
|
||||
patch.LastError = err.Error()
|
||||
return patch, err
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := nodeHTTPClient.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
patch.LastError = err.Error()
|
||||
return patch, err
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func initTrafficTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
return database.GetDB()
|
||||
}
|
||||
|
||||
func createNodeInbound(t *testing.T, db *gorm.DB, nodeID int, tag string, port int) {
|
||||
t.Helper()
|
||||
nid := nodeID
|
||||
ib := &model.Inbound{UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, NodeID: &nid}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create node inbound %q: %v", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats ...xray.ClientTraffic) {
|
||||
t.Helper()
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}},
|
||||
}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func readTraffic(t *testing.T, db *gorm.DB, email string) xray.ClientTraffic {
|
||||
t.Helper()
|
||||
var ct xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&ct).Error; err != nil {
|
||||
t.Fatalf("read client_traffics %q: %v", email, err)
|
||||
}
|
||||
return ct
|
||||
}
|
||||
|
||||
func assertUpDown(t *testing.T, ct xray.ClientTraffic, wantUp, wantDown int64, when string) {
|
||||
t.Helper()
|
||||
if ct.Up != wantUp || ct.Down != wantDown {
|
||||
t.Errorf("%s: up=%d down=%d, want %d/%d", when, ct.Up, ct.Down, wantUp, wantDown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoNodesShareEmail_SumsCorrectly(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
createNodeInbound(t, db, 2, "n2-in", 41002)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "shared"
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
|
||||
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
|
||||
|
||||
assertUpDown(t, readTraffic(t, db, email), 100, 100, "after baselines")
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 150, Down: 150, Enable: true})
|
||||
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 260, Down: 260, Enable: true})
|
||||
|
||||
assertUpDown(t, readTraffic(t, db, email), 210, 210, "after both nodes grow")
|
||||
}
|
||||
|
||||
func TestSingleNode_MirrorsCorrectly(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "solo"
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 500, Down: 600, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 500, 600, "first sync")
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 700, Down: 800, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 700, 800, "second sync mirrors cumulative")
|
||||
}
|
||||
|
||||
func TestUpgrade_PreExistingRow_NoDoubleCount(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "legacy"
|
||||
var ib model.Inbound
|
||||
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
|
||||
t.Fatalf("load inbound: %v", err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: email, Up: 1000, Down: 2000, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("seed pre-existing row: %v", err)
|
||||
}
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1000, Down: 2000, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 1000, 2000, "first snapshot must not double-count")
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1100, Down: 2100, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 1100, 2100, "growth after upgrade accrues")
|
||||
}
|
||||
|
||||
func TestNodeCounterReset_Clamped(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "restart"
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 900, Enable: true})
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 950, Enable: true})
|
||||
assertUpDown(t, readTraffic(t, db, email), 950, 950, "before node reset")
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 50, Enable: true})
|
||||
ct := readTraffic(t, db, email)
|
||||
if ct.Up < 0 || ct.Down < 0 {
|
||||
t.Fatalf("row went negative after node reset: up=%d down=%d", ct.Up, ct.Down)
|
||||
}
|
||||
assertUpDown(t, ct, 1000, 1000, "after node counter reset (clamped)")
|
||||
}
|
||||
|
||||
func TestCentralReset_NoReAdd(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
createNodeInbound(t, db, 2, "n2-in", 41002)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "reset"
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
|
||||
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
|
||||
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
|
||||
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
|
||||
Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
|
||||
t.Fatalf("simulate central reset: %v", err)
|
||||
}
|
||||
|
||||
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 210, Down: 210, Enable: true})
|
||||
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 205, Down: 205, Enable: true})
|
||||
|
||||
assertUpDown(t, readTraffic(t, db, email), 15, 15, "after central reset only increments accrue")
|
||||
}
|
||||
|
||||
func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "gone"
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: email, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("seed client_traffics: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: email, Up: 10, Down: 10}).Error; err != nil {
|
||||
t.Fatalf("seed node baseline 1: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.NodeClientTraffic{NodeId: 2, Email: email, Up: 20, Down: 20}).Error; err != nil {
|
||||
t.Fatalf("seed node baseline 2: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.DelClientStat(db, email); err != nil {
|
||||
t.Fatalf("DelClientStat: %v", err)
|
||||
}
|
||||
|
||||
var cnt int64
|
||||
if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil {
|
||||
t.Fatalf("count baselines: %v", err)
|
||||
}
|
||||
if cnt != 0 {
|
||||
t.Errorf("expected node baselines cleaned, found %d", cnt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeDelete_CleansNodeBaselines(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
nodeSvc := NodeService{}
|
||||
|
||||
if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "a", Up: 1, Down: 1}).Error; err != nil {
|
||||
t.Fatalf("seed node 7 a: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "b", Up: 2, Down: 2}).Error; err != nil {
|
||||
t.Fatalf("seed node 7 b: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.NodeClientTraffic{NodeId: 8, Email: "c", Up: 3, Down: 3}).Error; err != nil {
|
||||
t.Fatalf("seed node 8 c: %v", err)
|
||||
}
|
||||
|
||||
if err := nodeSvc.Delete(7); err != nil {
|
||||
t.Fatalf("NodeService.Delete(7): %v", err)
|
||||
}
|
||||
|
||||
var sevenCnt, eightCnt int64
|
||||
db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 7).Count(&sevenCnt)
|
||||
db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 8).Count(&eightCnt)
|
||||
if sevenCnt != 0 {
|
||||
t.Errorf("node 7 baselines not cleaned: %d remain", sevenCnt)
|
||||
}
|
||||
if eightCnt != 1 {
|
||||
t.Errorf("node 8 baseline should survive, found %d", eightCnt)
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,11 @@ func TestBuildSubURIBase(t *testing.T) {
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
subDomain, port string
|
||||
cert, key string
|
||||
host string
|
||||
want string
|
||||
name string
|
||||
subDomain, port string
|
||||
cert, key string
|
||||
host string
|
||||
want string
|
||||
}{
|
||||
{"no domain, plain, non-standard port", "", "2096", "", "", "panel.example.com", "http://panel.example.com:2096"},
|
||||
{"host carries a port — stripped, sub port applied", "", "2096", "", "", "panel.example.com:9999", "http://panel.example.com:2096"},
|
||||
|
||||
@@ -106,6 +106,11 @@ func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error
|
||||
"license_key": license,
|
||||
"private_key": secretKey,
|
||||
}
|
||||
if config, ok := rsp["config"].(map[string]any); ok {
|
||||
if clientID, ok := config["client_id"].(string); ok {
|
||||
warpData["client_id"] = clientID
|
||||
}
|
||||
}
|
||||
warpJSON, err := json.MarshalIndent(warpData, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
"localPanel": "بانل محلي",
|
||||
"fallbacks": {
|
||||
"title": "Fallbacks",
|
||||
"help": "عند وصول اتصال إلى هذا الـ inbound لا يطابق أي عميل، يتم توجيهه إلى inbound آخر. اختر فرعًا أدناه وسيتم ملء حقول التوجيه (SNI / ALPN / Path / xver) تلقائيًا من نقل الفرع — في الغالب لا تحتاج إلى أي تعديل إضافي. يجب أن يستمع كل فرع على 127.0.0.1 مع security=none.",
|
||||
"help": "عند وصول اتصال إلى هذا الـ inbound لا يطابق أي عميل، يتم توجيهه إلى مكان آخر. اختر inbound فرعيًا أدناه لملء حقول التوجيه (SNI / ALPN / Path / xver) تلقائيًا من نقله، أو اترك القائمة فارغة واضبط Dest مباشرةً (مثل 8080 أو 127.0.0.1:8080) للتوجيه إلى خادم خارجي مثل Nginx. يجب أن يستمع كل inbound فرعي على 127.0.0.1 مع security=none.",
|
||||
"empty": "لا توجد fallbacks بعد",
|
||||
"add": "إضافة fallback",
|
||||
"pickInbound": "اختر inbound",
|
||||
@@ -570,7 +570,8 @@
|
||||
"getNewCert": "احصل على شهادة جديدة",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
"mldsa65Verify": "mldsa65 Verify",
|
||||
"getNewSeed": "احصل على Seed جديد"
|
||||
"getNewSeed": "احصل على Seed جديد",
|
||||
"listenHelp": "يمكنك أيضًا إدخال مسار Unix socket (مثل /run/xray/in.sock) للاستماع على socket بدلاً من منفذ TCP — في هذه الحالة اضبط المنفذ على 0."
|
||||
},
|
||||
"info": {
|
||||
"mode": "الوضع",
|
||||
@@ -868,7 +869,19 @@
|
||||
"updateStarted": "بدأ تحديث اللوحة",
|
||||
"updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}",
|
||||
"updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة"
|
||||
}
|
||||
},
|
||||
"tlsVerifyMode": "التحقق من TLS",
|
||||
"tlsVerifyModeHint": "كيف يتحقق اللوحة من شهادة HTTPS الخاصة بالعقدة. التثبيت أو التخطّي مخصّصان للشهادات الموقّعة ذاتيًا (عُقد https فقط).",
|
||||
"tlsVerify": "تحقّق (CA الافتراضية)",
|
||||
"tlsPin": "تثبيت الشهادة (SHA-256)",
|
||||
"tlsSkip": "تخطّي التحقق",
|
||||
"tlsSkipWarning": "تخطّي التحقق يزيل الحماية من هجمات الوسيط — قد يُعترض رمز الـ API. يُفضَّل تثبيت الشهادة بدلاً من ذلك.",
|
||||
"pinnedCert": "SHA-256 للشهادة المثبّتة",
|
||||
"pinnedCertHint": "SHA-256 لشهادة العقدة بصيغة base64 أو hex. استخدم \"جلب\" لقراءتها من العقدة الآن.",
|
||||
"pinnedCertPlaceholder": "SHA-256 بصيغة base64 أو hex",
|
||||
"fetchPin": "جلب",
|
||||
"pinFetched": "تم جلب شهادة العقدة الحالية",
|
||||
"pinFetchFailed": "تعذّر جلب الشهادة"
|
||||
},
|
||||
"settings": {
|
||||
"title": "إعدادات البانل",
|
||||
@@ -1193,6 +1206,8 @@
|
||||
"tagRequired": "الوسم مطلوب",
|
||||
"tagPlaceholder": "وسم-فريد",
|
||||
"localIpPlaceholder": "IP محلي",
|
||||
"dialerProxyPlaceholder": "اختر مخرجًا لتمرير الاتصال عبره",
|
||||
"dialerProxyHint": "وجّه هذا المخرج عبر مخرج آخر (حسب الوسم) لبناء سلسلة بروكسي. اتركه فارغًا للاتصال المباشر.",
|
||||
"addressRequired": "العنوان مطلوب",
|
||||
"portRequired": "المنفذ مطلوب",
|
||||
"optional": "اختياري",
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
"localPanel": "Local panel",
|
||||
"fallbacks": {
|
||||
"title": "Fallbacks",
|
||||
"help": "When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.",
|
||||
"help": "When a connection on this inbound does not match any client, route it elsewhere. Pick a child inbound below to auto-fill the routing fields (SNI / ALPN / path / xver) from its transport, or leave the picker empty and set Dest directly (e.g. 8080 or 127.0.0.1:8080) to route to an external server such as Nginx. Each child inbound should listen on 127.0.0.1 with security=none.",
|
||||
"empty": "No fallbacks yet",
|
||||
"add": "Add fallback",
|
||||
"pickInbound": "Pick an inbound",
|
||||
@@ -570,7 +570,8 @@
|
||||
"getNewCert": "Get New Cert",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
"mldsa65Verify": "mldsa65 Verify",
|
||||
"getNewSeed": "Get New Seed"
|
||||
"getNewSeed": "Get New Seed",
|
||||
"listenHelp": "You can also enter a Unix socket path (e.g. /run/xray/in.sock) to listen on a socket instead of a TCP port — set Port to 0 in that case."
|
||||
},
|
||||
"info": {
|
||||
"mode": "Mode",
|
||||
@@ -868,7 +869,19 @@
|
||||
"updateStarted": "Panel update started",
|
||||
"updateResult": "Update triggered on {ok} node(s), {failed} failed",
|
||||
"updateNoneEligible": "Select at least one online, enabled node"
|
||||
}
|
||||
},
|
||||
"tlsVerifyMode": "TLS verification",
|
||||
"tlsVerifyModeHint": "How the panel validates the node's HTTPS certificate. Pin or Skip are for self-signed certs (https nodes only).",
|
||||
"tlsVerify": "Verify (default CA)",
|
||||
"tlsPin": "Pin certificate (SHA-256)",
|
||||
"tlsSkip": "Skip verification",
|
||||
"tlsSkipWarning": "Skipping verification removes protection against man-in-the-middle attacks — the API token could be intercepted. Prefer pinning the certificate.",
|
||||
"pinnedCert": "Pinned certificate SHA-256",
|
||||
"pinnedCertHint": "Base64 or hex SHA-256 of the node's certificate. Use Fetch to read it from the node now.",
|
||||
"pinnedCertPlaceholder": "base64 or hex SHA-256",
|
||||
"fetchPin": "Fetch",
|
||||
"pinFetched": "Fetched the node's current certificate",
|
||||
"pinFetchFailed": "Could not fetch the certificate"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Panel Settings",
|
||||
@@ -1193,6 +1206,8 @@
|
||||
"tagRequired": "Tag is required",
|
||||
"tagPlaceholder": "unique-tag",
|
||||
"localIpPlaceholder": "local IP",
|
||||
"dialerProxyPlaceholder": "Select an outbound to chain through",
|
||||
"dialerProxyHint": "Dial this outbound through another outbound (by tag) to build a proxy chain. Leave empty to connect directly.",
|
||||
"addressRequired": "Address is required",
|
||||
"portRequired": "Port is required",
|
||||
"optional": "optional",
|
||||
|
||||