Merge branch 'main' into fix/node-tag-unique-scope

This commit is contained in:
Sanaei 2026-05-15 11:46:57 +02:00 committed by GitHub
commit 3463aeef62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 953 additions and 253 deletions

View file

@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (الترخيص: **GPL-3.0**): _قواعد توجيه v2ray/xray و v2ray/xray-clients المحسنة مع النطاقات الإيرانية المدمجة وتركيز على الأمان وحظر الإعلانات._ - [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (الترخيص: **GPL-3.0**): _قواعد توجيه v2ray/xray و v2ray/xray-clients المحسنة مع النطاقات الإيرانية المدمجة وتركيز على الأمان وحظر الإعلانات._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (الترخيص: **GPL-3.0**): _يحتوي هذا المستودع على قواعد توجيه V2Ray محدثة تلقائيًا بناءً على بيانات النطاقات والعناوين المحظورة في روسيا._ - [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (الترخيص: **GPL-3.0**): _يحتوي هذا المستودع على قواعد توجيه V2Ray محدثة تلقائيًا بناءً على بيانات النطاقات والعناوين المحظورة في روسيا._
## أدوات المجتمع
أدوات وتكاملات بناها المجتمع حول 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (الترخيص: **MIT**): _إدارة الاتصالات الواردة والعملاء وإعدادات اللوحة وتكوين Xray كرمز باستخدام Terraform / OpenTofu._
## دعم المشروع ## دعم المشروع
**إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2: **إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2:

View file

@ -39,6 +39,12 @@ Para documentación completa, visita la [Wiki del proyecto](https://github.com/M
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Licencia: **GPL-3.0**): _Reglas de enrutamiento mejoradas para v2ray/xray y v2ray/xray-clients con dominios iraníes incorporados y un enfoque en seguridad y bloqueo de anuncios._ - [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Licencia: **GPL-3.0**): _Reglas de enrutamiento mejoradas para v2ray/xray y v2ray/xray-clients con dominios iraníes incorporados y un enfoque en seguridad y bloqueo de anuncios._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Licencia: **GPL-3.0**): _Este repositorio contiene reglas de enrutamiento V2Ray actualizadas automáticamente basadas en datos de dominios y direcciones bloqueadas en Rusia._ - [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Licencia: **GPL-3.0**): _Este repositorio contiene reglas de enrutamiento V2Ray actualizadas automáticamente basadas en datos de dominios y direcciones bloqueadas en Rusia._
## Herramientas de la Comunidad
Herramientas e integraciones construidas por la comunidad alrededor de 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Licencia: **MIT**): _Gestiona inbounds, clientes, configuración del panel y configuración de Xray como código con Terraform / OpenTofu._
## Apoyar el Proyecto ## Apoyar el Proyecto
**Si este proyecto te es útil, puedes darle una**:star2: **Si este proyecto te es útil, puedes darle una**:star2:

View file

@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (مجوز: **GPL-3.0**): _قوانین مسیریابی بهبود یافته v2ray/xray و v2ray/xray-clients با دامنه‌های ایرانی داخلی و تمرکز بر امنیت و مسدود کردن تبلیغات._ - [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (مجوز: **GPL-3.0**): _قوانین مسیریابی بهبود یافته v2ray/xray و v2ray/xray-clients با دامنه‌های ایرانی داخلی و تمرکز بر امنیت و مسدود کردن تبلیغات._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (مجوز: **GPL-3.0**): _این مخزن شامل قوانین مسیریابی V2Ray به‌روزرسانی شده خودکار بر اساس داده‌های دامنه‌ها و آدرس‌های مسدود شده در روسیه است._ - [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (مجوز: **GPL-3.0**): _این مخزن شامل قوانین مسیریابی V2Ray به‌روزرسانی شده خودکار بر اساس داده‌های دامنه‌ها و آدرس‌های مسدود شده در روسیه است._
## ابزارهای جامعه
ابزارها و یکپارچه‌سازی‌هایی که توسط جامعه پیرامون 3x-ui ساخته شده‌اند.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (مجوز: **MIT**): _مدیریت اینباندها، کلاینت‌ها، تنظیمات پنل و پیکربندی Xray به‌صورت کد با Terraform / OpenTofu._
## پشتیبانی از پروژه ## پشتیبانی از پروژه
**اگر این پروژه برای شما مفید است، می‌توانید به آن یک**:star2: بدهید **اگر این پروژه برای شما مفید است، می‌توانید به آن یک**:star2: بدهید

View file

@ -39,6 +39,12 @@ For full documentation, please visit the [project Wiki](https://github.com/MHSan
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._ - [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._ - [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
## Community Tools
Tools and integrations built by the community around 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (License: **MIT**): _Manage inbounds, clients, panel settings, and Xray configuration as code with Terraform / OpenTofu._
## Support project ## Support project
**If this project is helpful to you, you may wish to give it a**:star2: **If this project is helpful to you, you may wish to give it a**:star2:

View file

@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Лицензия: **GPL-3.0**): _Улучшенные правила маршрутизации для v2ray/xray и v2ray/xray-clients со встроенными иранскими доменами и фокусом на безопасность и блокировку рекламы._ - [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Лицензия: **GPL-3.0**): _Улучшенные правила маршрутизации для v2ray/xray и v2ray/xray-clients со встроенными иранскими доменами и фокусом на безопасность и блокировку рекламы._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Лицензия: **GPL-3.0**): _Этот репозиторий содержит автоматически обновляемые правила маршрутизации V2Ray на основе данных о заблокированных доменах и адресах в России._ - [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Лицензия: **GPL-3.0**): _Этот репозиторий содержит автоматически обновляемые правила маршрутизации V2Ray на основе данных о заблокированных доменах и адресах в России._
## Инструменты сообщества
Инструменты и интеграции, созданные сообществом вокруг 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Лицензия: **MIT**): _Управление входящими, клиентами, настройками панели и конфигурацией Xray через код с помощью Terraform / OpenTofu._
## Поддержка проекта ## Поддержка проекта
**Если этот проект полезен для вас, вы можете поставить ему**:star2: **Если этот проект полезен для вас, вы можете поставить ему**:star2:

View file

@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (许可证: **GPL-3.0**): _增强的 v2ray/xray 和 v2ray/xray-clients 路由规则内置伊朗域名专注于安全性和广告拦截。_ - [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (许可证: **GPL-3.0**): _增强的 v2ray/xray 和 v2ray/xray-clients 路由规则内置伊朗域名专注于安全性和广告拦截。_
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (许可证: **GPL-3.0**): _此仓库包含基于俄罗斯被阻止域名和地址数据自动更新的 V2Ray 路由规则。_ - [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (许可证: **GPL-3.0**): _此仓库包含基于俄罗斯被阻止域名和地址数据自动更新的 V2Ray 路由规则。_
## 社区工具
社区围绕 3x-ui 构建的工具和集成。
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (许可证: **MIT**): _使用 Terraform / OpenTofu 通过代码管理入站、客户端、面板设置和 Xray 配置。_
## 支持项目 ## 支持项目
**如果这个项目对您有帮助,您可以给它一个**:star2: **如果这个项目对您有帮助,您可以给它一个**:star2:

View file

@ -210,9 +210,9 @@
} }
}, },
"node_modules/@codemirror/view": { "node_modules/@codemirror/view": {
"version": "6.42.1", "version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.42.1.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg==", "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
@ -610,9 +610,9 @@
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.129.0", "version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@ -620,9 +620,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
"integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -637,9 +637,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -654,9 +654,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
"integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -671,9 +671,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
"integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -688,9 +688,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
"integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -705,9 +705,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
"integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -725,9 +725,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
"integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -745,9 +745,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
"integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -765,9 +765,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
"integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -785,9 +785,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
"integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -805,9 +805,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
"integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -825,9 +825,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
"integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -842,9 +842,9 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
"integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
@ -861,9 +861,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
"integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -878,9 +878,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
"integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2715,14 +2715,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.129.0", "@oxc-project/types": "=0.130.0",
"@rolldown/pluginutils": "1.0.0" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
"rolldown": "bin/cli.mjs" "rolldown": "bin/cli.mjs"
@ -2731,27 +2731,27 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0", "@rolldown/binding-android-arm64": "1.0.1",
"@rolldown/binding-darwin-arm64": "1.0.0", "@rolldown/binding-darwin-arm64": "1.0.1",
"@rolldown/binding-darwin-x64": "1.0.0", "@rolldown/binding-darwin-x64": "1.0.1",
"@rolldown/binding-freebsd-x64": "1.0.0", "@rolldown/binding-freebsd-x64": "1.0.1",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
"@rolldown/binding-linux-arm64-gnu": "1.0.0", "@rolldown/binding-linux-arm64-gnu": "1.0.1",
"@rolldown/binding-linux-arm64-musl": "1.0.0", "@rolldown/binding-linux-arm64-musl": "1.0.1",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0", "@rolldown/binding-linux-ppc64-gnu": "1.0.1",
"@rolldown/binding-linux-s390x-gnu": "1.0.0", "@rolldown/binding-linux-s390x-gnu": "1.0.1",
"@rolldown/binding-linux-x64-gnu": "1.0.0", "@rolldown/binding-linux-x64-gnu": "1.0.1",
"@rolldown/binding-linux-x64-musl": "1.0.0", "@rolldown/binding-linux-x64-musl": "1.0.1",
"@rolldown/binding-openharmony-arm64": "1.0.0", "@rolldown/binding-openharmony-arm64": "1.0.1",
"@rolldown/binding-wasm32-wasi": "1.0.0", "@rolldown/binding-wasm32-wasi": "1.0.1",
"@rolldown/binding-win32-arm64-msvc": "1.0.0", "@rolldown/binding-win32-arm64-msvc": "1.0.1",
"@rolldown/binding-win32-x64-msvc": "1.0.0" "@rolldown/binding-win32-x64-msvc": "1.0.1"
} }
}, },
"node_modules/rolldown/node_modules/@rolldown/pluginutils": { "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -2964,16 +2964,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.12", "version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
"postcss": "^8.5.14", "postcss": "^8.5.14",
"rolldown": "1.0.0", "rolldown": "1.0.1",
"tinyglobby": "^0.2.16" "tinyglobby": "^0.2.16"
}, },
"bin": { "bin": {

View file

@ -51,7 +51,12 @@ export function setupAxios() {
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'; axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
const basePath = window.X_UI_BASE_PATH; // Read base path from window object or fallback to meta tag (for Cloudflare Rocket Loader compatibility)
let basePath = window.X_UI_BASE_PATH;
if (!basePath) {
const metaTag = document.querySelector('meta[name="base-path"]');
basePath = metaTag ? metaTag.getAttribute('content') : null;
}
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') { if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
axios.defaults.baseURL = basePath; axios.defaults.baseURL = basePath;
} }

View file

@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js'; import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue'; import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js'; import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import InboundsPage from '@/pages/inbounds/InboundsPage.vue'; import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(InboundsPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -6,7 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the // Importing useTheme triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue mounts. // stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import IndexPage from '@/pages/index/IndexPage.vue'; import IndexPage from '@/pages/index/IndexPage.vue';
@ -18,4 +18,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(IndexPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(IndexPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -6,18 +6,18 @@ import { setupAxios } from '@/api/axios-init.js';
// Importing this module triggers the boot side-effect that applies the // Importing this module triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue renders anything. // stored theme to <body>/<html> before Vue renders anything.
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import LoginPage from '@/pages/login/LoginPage.vue'; import LoginPage from '@/pages/login/LoginPage.vue';
setupAxios(); setupAxios();
applyDocumentTitle(); applyDocumentTitle();
// Toasts attach to a #message div the page provides — keeps theme
// styling in sync with the rest of the panel.
const messageContainer = document.getElementById('message'); const messageContainer = document.getElementById('message');
if (messageContainer) { if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(LoginPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(LoginPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js'; import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import NodesPage from '@/pages/nodes/NodesPage.vue'; import NodesPage from '@/pages/nodes/NodesPage.vue';
@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(NodesPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(NodesPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -6,7 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the // Importing useTheme triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue mounts. // stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import SettingsPage from '@/pages/settings/SettingsPage.vue'; import SettingsPage from '@/pages/settings/SettingsPage.vue';
@ -18,4 +18,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(SettingsPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -7,7 +7,7 @@ import 'ant-design-vue/dist/reset.css';
// with the parsed traffic/quota/expiry view-model and the rendered // with the parsed traffic/quota/expiry view-model and the rendered
// share links — the SPA reads those at mount. // share links — the SPA reads those at mount.
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import SubPage from '@/pages/sub/SubPage.vue'; import SubPage from '@/pages/sub/SubPage.vue';
const messageContainer = document.getElementById('message'); const messageContainer = document.getElementById('message');
@ -15,4 +15,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(SubPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(SubPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js'; import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import XrayPage from '@/pages/xray/XrayPage.vue'; import XrayPage from '@/pages/xray/XrayPage.vue';
@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(XrayPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(XrayPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -1,93 +1,54 @@
// vue-i18n setup. Locale files live in web/translation/*.json — the same
// directory the Go binary embeds, so SPA + Telegram bot + subscription
// page all read from a single source.
//
// Usage in a component:
// import { useI18n } from 'vue-i18n';
// const { t } = useI18n();
// ...
// <span>{{ t('pages.inbounds.email') }}</span>
//
// Or via the global helper exposed on the app:
// <span>{{ $t('pages.inbounds.email') }}</span>
//
// The locale follows the `lang` cookie that LanguageManager already
// reads/writes — switching language anywhere in the app continues to
// trigger a full page reload (matches legacy ergonomics), so we don't
// need a runtime locale switcher here.
import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n';
import { LanguageManager } from '@/utils'; import { LanguageManager } from '@/utils';
import enUS from '../../../web/translation/en-US.json';
// Lazy-loaded locales — Vite splits each one into its own chunk. We
// eager-load only the active language plus the en-US fallback so the
// initial page payload stays small (the inbounds bundle was sitting
// at ~700kB gzipped with all 13 locales eager; now ~480kB).
//
// LanguageManager.setLanguage() does a full reload on change, so
// "lazy" here effectively means "load only what this page needs for
// its lifetime."
const FALLBACK = 'en-US'; const FALLBACK = 'en-US';
const lazyModules = import.meta.glob('../../../web/translation/*.json'); const lazyModules = import.meta.glob([
const eagerModules = import.meta.glob('../../../web/translation/*.json', { eager: true }); '../../../web/translation/*.json',
'!../../../web/translation/en-US.json',
]);
function moduleKeyFor(code) { function moduleKeyFor(code) {
return `../../../web/translation/${code}.json`; return `../../../web/translation/${code}.json`;
} }
// Resolve the active locale via LanguageManager so the cookie set on
// the legacy panel keeps working after a user upgrades. Falls back
// to en-US when the cookie names a language we don't have.
let active = LanguageManager.getLanguage(); let active = LanguageManager.getLanguage();
if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) { if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
active = FALLBACK; active = FALLBACK;
} }
const messages = {};
// Eagerly include the active locale + the fallback (when distinct)
// so the very first render has strings ready. Vite still emits these
// as their own chunks so the user pays for at most two locales.
for (const code of new Set([active, FALLBACK])) {
const mod = eagerModules[moduleKeyFor(code)];
if (mod) messages[code] = mod.default || mod;
}
export const i18n = createI18n({ export const i18n = createI18n({
legacy: false, legacy: false,
// `composition` mode (legacy: false) so `useI18n()` works in
// <script setup> blocks.
globalInjection: true, globalInjection: true,
locale: active, locale: active,
fallbackLocale: FALLBACK, fallbackLocale: FALLBACK,
// Locale JSON is nested by namespace ({pages: {inbounds: {email: ...}}}) messages: { [FALLBACK]: enUS },
// so vue-i18n's default `.`-delimited lookups walk straight into it.
messages,
// The Go side sometimes interpolates `#variable#` into translated
// strings (e.g. xraySwitchVersionDialogDesc). vue-i18n's default
// expects `{var}` — disable warnings about strings that look like
// they don't use the new syntax.
warnHtmlMessage: false, warnHtmlMessage: false,
missingWarn: false, missingWarn: false,
fallbackWarn: false, fallbackWarn: false,
}); });
// Convenience export for non-component contexts (HTTP error toasts,
// stores, etc.) that need to look up a translation outside a setup
// scope.
export function t(key, params) { export function t(key, params) {
return i18n.global.t(key, params || {}); return i18n.global.t(key, params || {});
} }
// loadLocale fetches a locale module on demand and registers it with
// vue-i18n. Pages that switch language at runtime (rather than via
// LanguageManager's reload) can call this to swap strings live.
export async function loadLocale(code) { export async function loadLocale(code) {
const key = moduleKeyFor(code); if (code === FALLBACK) {
const loader = lazyModules[key]; i18n.global.locale.value = FALLBACK;
return true;
}
const loader = lazyModules[moduleKeyFor(code)];
if (!loader) return false; if (!loader) return false;
const mod = await loader(); const mod = await loader();
i18n.global.setLocaleMessage(code, mod.default || mod); i18n.global.setLocaleMessage(code, mod.default || mod);
i18n.global.locale.value = code; i18n.global.locale.value = code;
return true; return true;
} }
export async function readyI18n() {
if (active !== FALLBACK) {
await loadLocale(active);
}
return i18n;
}

View file

@ -827,8 +827,8 @@ TlsStreamSettings.Cert = class extends XrayCommonClass {
} else { } else {
return new TlsStreamSettings.Cert( return new TlsStreamSettings.Cert(
false, '', '', false, '', '',
json.certificate.join('\n'), Array.isArray(json.certificate) ? json.certificate.join('\n') : (json.certificate ?? ''),
json.key.join('\n'), Array.isArray(json.key) ? json.key.join('\n') : (json.key ?? ''),
json.oneTimeLoading, json.oneTimeLoading,
json.usage, json.usage,
json.buildChain, json.buildChain,
@ -2412,20 +2412,25 @@ export class Inbound extends XrayCommonClass {
} }
toJson() { toJson() {
let streamSettings; // Only these protocols use streamSettings
if (this.canEnableStream() || this.stream?.sockopt) { const streamProtocols = [Protocols.VLESS, Protocols.VMESS, Protocols.TROJAN, Protocols.SHADOWSOCKS, Protocols.HYSTERIA];
streamSettings = this.stream.toJson();
} const result = {
return {
port: this.port, port: this.port,
listen: this.listen, listen: this.listen,
protocol: this.protocol, protocol: this.protocol,
settings: this.settings instanceof XrayCommonClass ? this.settings.toJson() : this.settings, settings: this.settings instanceof XrayCommonClass ? this.settings.toJson() : this.settings,
streamSettings: streamSettings,
tag: this.tag, tag: this.tag,
sniffing: this.sniffing.toJson(), sniffing: this.sniffing.toJson(),
clientStats: this.clientStats clientStats: this.clientStats
}; };
// Only add streamSettings if protocol supports it
if (streamProtocols.includes(this.protocol)) {
result.streamSettings = this.stream.toJson();
}
return result;
} }
} }

View file

@ -1397,6 +1397,13 @@ export class Outbound extends CommonClass {
const port = json.port * 1; const port = json.port * 1;
// Parse fm (finalmask) JSON string — TCP/UDP masks + QUIC params from 3x-ui share links
if (json.fm) {
try {
stream.finalmask = FinalMaskStreamSettings.fromJson(JSON.parse(json.fm));
} catch (_) { /* ignore malformed fm */ }
}
return new Outbound(json.ps, Protocols.VMess, new Outbound.VmessSettings(json.add, port, json.id, json.scy), stream); return new Outbound(json.ps, Protocols.VMess, new Outbound.VmessSettings(json.add, port, json.id, json.scy), stream);
} }
@ -1496,6 +1503,14 @@ export class Outbound extends CommonClass {
default: default:
return null; return null;
} }
// Parse fm (finalmask) JSON param — TCP/UDP masks + QUIC params from 3x-ui share links
const fmRaw = url.searchParams.get('fm');
if (fmRaw) {
try {
stream.finalmask = FinalMaskStreamSettings.fromJson(JSON.parse(fmRaw));
} catch (_) { /* ignore malformed fm */ }
}
let remark = decodeURIComponent(url.hash); let remark = decodeURIComponent(url.hash);
// Remove '#' from url.hash // Remove '#' from url.hash
remark = remark.length > 0 ? remark.substring(1) : 'out-' + protocol + '-' + port; remark = remark.length > 0 ? remark.substring(1) : 'out-' + protocol + '-' + port;
@ -1516,7 +1531,17 @@ export class Outbound extends CommonClass {
let urlParams = new URLSearchParams(params); let urlParams = new URLSearchParams(params);
// Create stream settings with hysteria network // Create stream settings with hysteria network
let stream = new StreamSettings('hysteria', 'none'); let security = urlParams.get('security') ?? 'none';
let stream = new StreamSettings('hysteria', security);
// Parse TLS settings when security=tls
if (security === 'tls') {
let fp = urlParams.get('fp') ?? 'none';
let alpn = urlParams.get('alpn');
let sni = urlParams.get('sni') ?? '';
let ech = urlParams.get('ech') ?? '';
stream.tls = new TlsStreamSettings(sni, alpn ? alpn.split(',') : [], fp, ech);
}
// Set hysteria stream settings // Set hysteria stream settings
stream.hysteria.auth = password; stream.hysteria.auth = password;
@ -1534,7 +1559,7 @@ export class Outbound extends CommonClass {
stream.hysteria.udphopIntervalMax = parseInt(urlParams.get('udphopIntervalMax') ?? '30'); stream.hysteria.udphopIntervalMax = parseInt(urlParams.get('udphopIntervalMax') ?? '30');
} }
// Optional QUIC parameters // Optional QUIC parameters for FinalMask support and hysteria2 share links
if (urlParams.has('initStreamReceiveWindow')) { if (urlParams.has('initStreamReceiveWindow')) {
stream.hysteria.initStreamReceiveWindow = parseInt(urlParams.get('initStreamReceiveWindow')); stream.hysteria.initStreamReceiveWindow = parseInt(urlParams.get('initStreamReceiveWindow'));
} }
@ -1557,6 +1582,38 @@ export class Outbound extends CommonClass {
stream.hysteria.disablePathMTUDiscovery = urlParams.get('disablePathMTUDiscovery') === 'true'; stream.hysteria.disablePathMTUDiscovery = urlParams.get('disablePathMTUDiscovery') === 'true';
} }
// Parse fm (finalmask) JSON param — TCP/UDP masks + QUIC params from 3x-ui share links, with special handling to mirror QUIC params into both stream.finalmask and stream.hysteria
const fmRaw = urlParams.get('fm');
if (fmRaw) {
try {
const fm = JSON.parse(fmRaw);
const qp = fm.quicParams;
if (qp && typeof qp === 'object') {
// Populate stream.finalmask.quicParams — this enables the "QUIC Params"
// toggle in FinalMaskForm and carries all QUIC tuning settings.
stream.finalmask.quicParams = QuicParams.fromJson(qp);
// Also mirror the overlapping fields into stream.hysteria so the
// Hysteria transport section of the form shows consistent values.
if (qp.congestion) stream.hysteria.congestion = qp.congestion;
if (Number.isInteger(qp.initStreamReceiveWindow)) stream.hysteria.initStreamReceiveWindow = qp.initStreamReceiveWindow;
if (Number.isInteger(qp.maxStreamReceiveWindow)) stream.hysteria.maxStreamReceiveWindow = qp.maxStreamReceiveWindow;
if (Number.isInteger(qp.initConnectionReceiveWindow)) stream.hysteria.initConnectionReceiveWindow = qp.initConnectionReceiveWindow;
if (Number.isInteger(qp.maxConnectionReceiveWindow)) stream.hysteria.maxConnectionReceiveWindow = qp.maxConnectionReceiveWindow;
if (Number.isInteger(qp.maxIdleTimeout)) stream.hysteria.maxIdleTimeout = qp.maxIdleTimeout;
if (Number.isInteger(qp.keepAlivePeriod)) stream.hysteria.keepAlivePeriod = qp.keepAlivePeriod;
if (qp.disablePathMTUDiscovery === true) stream.hysteria.disablePathMTUDiscovery = true;
if (qp.udpHop) {
stream.hysteria.udphopPort = qp.udpHop.ports ?? stream.hysteria.udphopPort;
if (qp.udpHop.interval !== undefined) {
stream.hysteria.udphopIntervalMin = qp.udpHop.interval;
stream.hysteria.udphopIntervalMax = qp.udpHop.interval;
}
}
}
} catch (_) { /* ignore malformed fm */ }
}
// Create settings // Create settings
let settings = new Outbound.HysteriaSettings(address, port, 2); let settings = new Outbound.HysteriaSettings(address, port, 2);

View file

@ -70,8 +70,11 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const inbound = ref(null); const inbound = ref(null);
const dbForm = ref(null); const dbForm = ref(null);
const saving = ref(false); const saving = ref(false);
const advancedJson = ref({ stream: '', sniffing: '', settings: '' }); const advancedStreamText = ref('');
const advancedSniffingText = ref('');
const advancedSettingsText = ref('');
const activeTabKey = ref('basic'); const activeTabKey = ref('basic');
const advancedSectionKey = ref('all');
// Cached default cert/key paths from /panel/setting/defaultSettings // Cached default cert/key paths from /panel/setting/defaultSettings
// powers the "Set default cert" button on the TLS form. // powers the "Set default cert" button on the TLS form.
const defaultCert = ref(''); const defaultCert = ref('');
@ -223,14 +226,19 @@ function freshDbForm() {
function primeAdvancedJson() { function primeAdvancedJson() {
if (!inbound.value) return; if (!inbound.value) return;
// Only set stream text for protocols that support it
if (canEnableStream.value) {
try {
advancedStreamText.value = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
} catch (_e) { /* keep prior text */ }
} else {
advancedStreamText.value = '{}';
}
try { try {
advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2); advancedSniffingText.value = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
} catch (_e) { /* keep prior text */ } } catch (_e) { /* keep prior text */ }
try { try {
advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2); advancedSettingsText.value = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
} catch (_e) { /* keep prior text */ }
try {
advancedJson.value.settings = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
} catch (_e) { /* keep prior text */ } } catch (_e) { /* keep prior text */ }
} }
@ -244,6 +252,7 @@ watch(() => props.open, (next) => {
primeAdvancedJson(); primeAdvancedJson();
} }
activeTabKey.value = 'basic'; activeTabKey.value = 'basic';
advancedSectionKey.value = 'all';
fetchDefaultCertSettings(); fetchDefaultCertSettings();
}); });
@ -253,18 +262,18 @@ function applyAdvancedJsonToBasic() {
let parsedStream; let parsedStream;
let parsedSniffing; let parsedSniffing;
try { try {
parsedSettings = advancedJson.value.settings.trim() parsedSettings = advancedSettingsText.value.trim()
? JSON.parse(advancedJson.value.settings) ? JSON.parse(advancedSettingsText.value)
: inbound.value.settings?.toJson?.(); : inbound.value.settings?.toJson?.();
} catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return false; } } catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return false; }
try { try {
parsedStream = advancedJson.value.stream.trim() parsedStream = advancedStreamText.value.trim()
? JSON.parse(advancedJson.value.stream) ? JSON.parse(advancedStreamText.value)
: inbound.value.stream?.toJson?.(); : inbound.value.stream?.toJson?.();
} catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return false; } } catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return false; }
try { try {
parsedSniffing = advancedJson.value.sniffing.trim() parsedSniffing = advancedSniffingText.value.trim()
? JSON.parse(advancedJson.value.sniffing) ? JSON.parse(advancedSniffingText.value)
: inbound.value.sniffing?.toJson?.(); : inbound.value.sniffing?.toJson?.();
} catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return false; } } catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return false; }
@ -324,6 +333,216 @@ function onNetworkChange(next) {
} }
} }
function parseAdvancedSliceOrFallback(rawText, fallbackValue) {
if (!rawText?.trim()) return fallbackValue;
return JSON.parse(rawText);
}
function unwrapWrappedObject(parsed, key) {
if (
parsed
&& typeof parsed === 'object'
&& !Array.isArray(parsed)
&& parsed[key] !== undefined
) {
return parsed[key];
}
return parsed;
}
const advancedAllConfig = computed({
get: () => {
if (!inbound.value) return '';
try {
const settings = parseAdvancedSliceOrFallback(
advancedSettingsText.value,
inbound.value.settings?.toJson?.() || {},
);
const streamSettings = parseAdvancedSliceOrFallback(
advancedStreamText.value,
inbound.value.stream?.toJson?.() || {},
);
const sniffing = parseAdvancedSliceOrFallback(
advancedSniffingText.value,
inbound.value.sniffing?.toJson?.() || {},
);
const result = {
listen: inbound.value.listen,
port: inbound.value.port,
protocol: inbound.value.protocol,
settings,
sniffing,
tag: inbound.value.tag,
};
// Only include streamSettings for protocols that support it
if (canEnableStream.value) {
result.streamSettings = streamSettings;
}
return JSON.stringify(result, null, 2);
} catch (_e) {
return '';
}
},
set: (next) => {
let parsed;
try {
parsed = JSON.parse(next);
} catch (e) {
message.error(`All JSON invalid: ${e.message}`);
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
message.error('All JSON must be an inbound object.');
return;
}
try {
if (typeof parsed.listen === 'string') {
inbound.value.listen = parsed.listen;
}
if (parsed.port !== undefined) {
const parsedPort = Number(parsed.port);
if (!Number.isNaN(parsedPort) && Number.isFinite(parsedPort)) {
inbound.value.port = parsedPort;
}
}
if (typeof parsed.protocol === 'string' && PROTOCOLS.includes(parsed.protocol)) {
inbound.value.protocol = parsed.protocol;
}
if (typeof parsed.tag === 'string') {
inbound.value.tag = parsed.tag;
}
const existingSettings = parseAdvancedSliceOrFallback(
advancedSettingsText.value,
inbound.value?.settings?.toJson?.() || {},
);
const settings = parsed.settings ?? existingSettings;
const sniffing = parsed.sniffing ?? (inbound.value?.sniffing?.toJson?.() || {});
advancedSettingsText.value = JSON.stringify(settings, null, 2);
advancedSniffingText.value = JSON.stringify(sniffing, null, 2);
// Only update stream settings if protocol supports it
if (canEnableStream.value) {
const streamSettings = parsed.streamSettings ?? (inbound.value?.stream?.toJson?.() || {});
advancedStreamText.value = JSON.stringify(streamSettings, null, 2);
} else {
advancedStreamText.value = '{}';
}
} catch (e) {
message.error(`All JSON invalid: ${e.message}`);
}
},
});
const advancedSettingsConfig = computed({
get: () => {
if (!inbound.value) return '';
try {
const settings = parseAdvancedSliceOrFallback(
advancedSettingsText.value,
inbound.value.settings?.toJson?.() || {},
);
return JSON.stringify({
settings,
}, null, 2);
} catch (_e) {
return '';
}
},
set: (next) => {
let parsed;
try {
parsed = JSON.parse(next);
} catch (e) {
message.error(`Settings JSON invalid: ${e.message}`);
return;
}
const unwrapped = unwrapWrappedObject(parsed, 'settings');
if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
message.error('Settings JSON must be an object or { settings: { ... } }.');
return;
}
try {
advancedSettingsText.value = JSON.stringify(unwrapped, null, 2);
} catch (e) {
message.error(`Settings JSON invalid: ${e.message}`);
}
},
});
const advancedSniffingConfig = computed({
get: () => {
if (!inbound.value) return '';
try {
const sniffing = parseAdvancedSliceOrFallback(
advancedSniffingText.value,
inbound.value.sniffing?.toJson?.() || {},
);
return JSON.stringify({ sniffing }, null, 2);
} catch (_e) {
return '';
}
},
set: (next) => {
let parsed;
try {
parsed = JSON.parse(next);
} catch (e) {
message.error(`Sniffing JSON invalid: ${e.message}`);
return;
}
const unwrapped = unwrapWrappedObject(parsed, 'sniffing');
if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
message.error('Sniffing JSON must be an object or { sniffing: { ... } }.');
return;
}
try {
advancedSniffingText.value = JSON.stringify(unwrapped, null, 2);
} catch (e) {
message.error(`Sniffing JSON invalid: ${e.message}`);
}
},
});
const advancedStreamConfig = computed({
get: () => {
if (!inbound.value) return '';
try {
const streamSettings = parseAdvancedSliceOrFallback(
advancedStreamText.value,
inbound.value.stream?.toJson?.() || {},
);
return JSON.stringify({ streamSettings }, null, 2);
} catch (_e) {
return '';
}
},
set: (next) => {
let parsed;
try {
parsed = JSON.parse(next);
} catch (e) {
message.error(`Stream JSON invalid: ${e.message}`);
return;
}
const unwrapped = unwrapWrappedObject(parsed, 'streamSettings');
if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
message.error('Stream JSON must be an object or { streamSettings: { ... } }.');
return;
}
try {
advancedStreamText.value = JSON.stringify(unwrapped, null, 2);
} catch (e) {
message.error(`Stream JSON invalid: ${e.message}`);
}
},
});
// === Random helpers wired to the form's sync icons ================== // === Random helpers wired to the form's sync icons ==================
function randomEmail(target) { function randomEmail(target) {
if (target) target.email = RandomUtil.randomLowerAndNum(9); if (target) target.email = RandomUtil.randomLowerAndNum(9);
@ -525,16 +744,16 @@ async function submit() {
let settings; let settings;
try { try {
streamSettings = canEnableStream.value streamSettings = canEnableStream.value
? JSON.stringify(JSON.parse(advancedJson.value.stream)) ? JSON.stringify(JSON.parse(advancedStreamText.value))
: (inbound.value.stream?.sockopt : (inbound.value.stream?.sockopt
? JSON.stringify({ sockopt: inbound.value.stream.sockopt.toJson() }) ? JSON.stringify({ sockopt: inbound.value.stream.sockopt.toJson() })
: ''); : '');
} catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return; } } catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return; }
try { try {
sniffing = JSON.stringify(JSON.parse(advancedJson.value.sniffing || inbound.value.sniffing.toString())); sniffing = JSON.stringify(JSON.parse(advancedSniffingText.value || inbound.value.sniffing.toString()));
} catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return; } } catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return; }
try { try {
settings = JSON.stringify(JSON.parse(advancedJson.value.settings || inbound.value.settings.toString())); settings = JSON.stringify(JSON.parse(advancedSettingsText.value || inbound.value.settings.toString()));
} catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return; } } catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return; }
// The structured form mutates `inbound.stream` directly when the // The structured form mutates `inbound.stream` directly when the
@ -597,8 +816,13 @@ watch(
() => inbound.value && JSON.stringify(inbound.value.stream?.toJson?.() || {}), () => inbound.value && JSON.stringify(inbound.value.stream?.toJson?.() || {}),
() => { () => {
if (!inbound.value?.stream) return; if (!inbound.value?.stream) return;
// Only update stream text for protocols that support it
if (!canEnableStream.value) {
advancedStreamText.value = '{}';
return;
}
try { try {
advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2); advancedStreamText.value = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
} catch (_e) { /* leave as is */ } } catch (_e) { /* leave as is */ }
}, },
); );
@ -607,7 +831,7 @@ watch(
() => { () => {
if (!inbound.value?.sniffing) return; if (!inbound.value?.sniffing) return;
try { try {
advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2); advancedSniffingText.value = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
} catch (_e) { /* leave as is */ } } catch (_e) { /* leave as is */ }
}, },
); );
@ -616,10 +840,21 @@ watch(
() => { () => {
if (!inbound.value?.settings) return; if (!inbound.value?.settings) return;
try { try {
advancedJson.value.settings = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2); advancedSettingsText.value = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
} catch (_e) { /* leave as is */ } } catch (_e) { /* leave as is */ }
}, },
); );
// Watch protocol changes to clear stream settings for protocols that don't support it
watch(
() => inbound.value?.protocol,
() => {
if (!inbound.value) return;
if (!canEnableStream.value) {
advancedStreamText.value = '{}';
}
},
);
</script> </script>
<template> <template>
@ -1005,7 +1240,7 @@ watch(
<a-form-item> <a-form-item>
<template #label> <template #label>
<a-tooltip <a-tooltip
title='Physical interface for outbound traffic. Use "auto" to detect; auto-enabled when Auto system routes is set.'> title="Physical interface for outbound traffic. Use 'auto' to detect; auto-enabled when Auto system routes is set.">
Auto outbounds interface Auto outbounds interface
</a-tooltip> </a-tooltip>
</template> </template>
@ -1834,14 +2069,6 @@ watch(
</template> </template>
<a-input-number v-model:value="inbound.stream.hysteria.version" :min="2" :max="2" /> <a-input-number v-model:value="inbound.stream.hysteria.version" :min="2" :max="2" />
</a-form-item> </a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Obfuscation password. Must match between server and client.">
Obfs password
</a-tooltip>
</template>
<a-input v-model:value="inbound.stream.hysteria.auth" />
</a-form-item>
<a-form-item> <a-form-item>
<template #label> <template #label>
<a-tooltip title="Idle timeout (seconds) for a single QUIC native UDP connection."> <a-tooltip title="Idle timeout (seconds) for a single QUIC native UDP connection.">
@ -1952,20 +2179,48 @@ watch(
<!-- ============================== ADVANCED ============================== --> <!-- ============================== ADVANCED ============================== -->
<a-tab-pane key="advanced" :tab="t('pages.xray.advancedTemplate')"> <a-tab-pane key="advanced" :tab="t('pages.xray.advancedTemplate')">
<a-alert type="info" show-icon <div class="advanced-shell">
message="Edit raw stream JSON to access advanced fields we don't yet expose through the form." <div class="advanced-panel">
class="mb-12" /> <div class="advanced-panel__header">
<a-form layout="vertical"> <div>
<a-form-item label="settings (clients, encryption, fallbacks, …)"> <div class="advanced-panel__title">Inbound JSON sections</div>
<JsonEditor v-model:value="advancedJson.settings" min-height="280px" max-height="520px" /> <div class="advanced-panel__subtitle">
</a-form-item> Full inbound JSON and focused editors for settings, sniffing, and streamSettings.
<a-form-item label="streamSettings"> </div>
<JsonEditor v-model:value="advancedJson.stream" min-height="280px" max-height="520px" /> </div>
</a-form-item> </div>
<a-form-item label="sniffing (overrides the Sniffing tab when set)">
<JsonEditor v-model:value="advancedJson.sniffing" min-height="180px" max-height="360px" /> <a-tabs v-model:active-key="advancedSectionKey" class="advanced-inner-tabs">
</a-form-item> <a-tab-pane key="all" tab="All">
</a-form> <div class="advanced-editor-meta">
Full inbound object with all fields in one editor.
</div>
<JsonEditor v-model:value="advancedAllConfig" min-height="340px" max-height="560px" />
</a-tab-pane>
<a-tab-pane key="settings" tab="Settings">
<div class="advanced-editor-meta">
Xray settings block wrapper:
<code>{ settings: { ... } }</code>.
</div>
<JsonEditor v-model:value="advancedSettingsConfig" min-height="320px" max-height="540px" />
</a-tab-pane>
<a-tab-pane key="sniffingSection" tab="Sniffing">
<div class="advanced-editor-meta">
Xray sniffing block wrapper:
<code>{ sniffing: { ... } }</code>.
</div>
<JsonEditor v-model:value="advancedSniffingConfig" min-height="240px" max-height="420px" />
</a-tab-pane>
<a-tab-pane v-if="canEnableStream" key="streamSection" tab="Stream">
<div class="advanced-editor-meta">
Xray stream block wrapper:
<code>{ streamSettings: { ... } }</code>.
</div>
<JsonEditor v-model:value="advancedStreamConfig" min-height="320px" max-height="540px" />
</a-tab-pane>
</a-tabs>
</div>
</div>
</a-tab-pane> </a-tab-pane>
</a-tabs> </a-tabs>
</a-modal> </a-modal>
@ -2041,6 +2296,73 @@ watch(
margin-top: 4px; margin-top: 4px;
} }
.advanced-shell {
display: flex;
flex-direction: column;
gap: 12px;
}
.advanced-panel {
padding: 14px;
border: 1px solid rgba(128, 128, 128, 0.18);
border-radius: 12px;
background: rgba(128, 128, 128, 0.04);
}
.advanced-panel__header {
margin-bottom: 12px;
}
.advanced-panel__title {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
}
.advanced-panel__subtitle {
margin-top: 4px;
color: rgba(0, 0, 0, 0.6);
line-height: 1.5;
}
.advanced-inner-tabs :deep(.ant-tabs-nav) {
margin-bottom: 12px;
}
.advanced-inner-tabs :deep(.ant-tabs-tab) {
padding-inline: 14px;
}
.advanced-editor-meta {
margin-bottom: 10px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.5;
}
@media (max-width: 768px) {
.advanced-panel {
padding: 12px;
border-radius: 10px;
}
.advanced-inner-tabs :deep(.ant-tabs-tab) {
padding-inline: 10px;
}
}
:global(.dark) .advanced-panel__subtitle,
:global(.dark) .advanced-editor-meta,
:global(.ultra) .advanced-panel__subtitle,
:global(.ultra) .advanced-editor-meta {
color: rgba(255, 255, 255, 0.65);
}
:global(.dark) .advanced-panel,
:global(.ultra) .advanced-panel {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.03);
}
.section-heading { .section-heading {
font-weight: 500; font-weight: 500;
margin: 12px 0 6px; margin: 12px 0 6px;

View file

@ -111,7 +111,8 @@ function close() {
<template v-if="dbInbound"> <template v-if="dbInbound">
<a-collapse v-model:active-key="activeKeys" ghost class="qr-collapse"> <a-collapse v-model:active-key="activeKeys" ghost class="qr-collapse">
<a-collapse-panel v-for="item in qrItems" :key="item.key" :header="item.header"> <a-collapse-panel v-for="item in qrItems" :key="item.key" :header="item.header">
<QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''" /> <QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''"
:show-qr="!item.value.includes('mldsa65') && !item.value.includes('ML-KEM-768')" />
</a-collapse-panel> </a-collapse-panel>
</a-collapse> </a-collapse>
</template> </template>

View file

@ -1,6 +1,7 @@
<script setup> <script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue'; import { CopyOutlined, DownloadOutlined, PictureOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import { ClipboardManager, FileManager } from '@/utils'; import { ClipboardManager, FileManager } from '@/utils';
@ -15,6 +16,8 @@ const props = defineProps({
showQr: { type: Boolean, default: true }, showQr: { type: Boolean, default: true },
}); });
const qrRef = ref(null);
async function copy() { async function copy() {
const ok = await ClipboardManager.copyText(props.value); const ok = await ClipboardManager.copyText(props.value);
if (ok) message.success(t('copied')); if (ok) message.success(t('copied'));
@ -24,6 +27,55 @@ function download() {
if (!props.downloadName) return; if (!props.downloadName) return;
FileManager.downloadTextFile(props.value, props.downloadName); FileManager.downloadTextFile(props.value, props.downloadName);
} }
function svgToPngBlob(size = 360) {
const svgEl = qrRef.value?.querySelector('svg');
if (!svgEl) return Promise.resolve(null);
const svgData = new XMLSerializer().serializeToString(svgEl);
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, size, size);
ctx.drawImage(img, 0, 0, size, size);
URL.revokeObjectURL(url);
canvas.toBlob(resolve, 'image/png');
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
img.src = url;
});
}
async function copyImage() {
const blob = await svgToPngBlob(props.size);
if (!blob) return;
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
message.success(t('copied'));
} catch {
downloadImageBlob(blob);
}
}
function downloadImageBlob(blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${props.remark || 'qrcode'}.png`;
link.click();
URL.revokeObjectURL(url);
}
async function downloadImage() {
const blob = await svgToPngBlob(props.size);
if (blob) downloadImageBlob(blob);
}
</script> </script>
<template> <template>
@ -37,6 +89,13 @@ function download() {
</template> </template>
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<a-tooltip v-if="showQr" :title="t('downloadImage', 'Download Image')">
<a-button size="small" @click="downloadImage">
<template #icon>
<PictureOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="downloadName" :title="t('download')"> <a-tooltip v-if="downloadName" :title="t('download')">
<a-button size="small" @click="download"> <a-button size="small" @click="download">
<template #icon> <template #icon>
@ -45,9 +104,11 @@ function download() {
</a-button> </a-button>
</a-tooltip> </a-tooltip>
</div> </div>
<div v-if="showQr" class="qr-panel-canvas"> <div v-if="showQr" ref="qrRef" class="qr-panel-canvas">
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false" <a-tooltip :title="t('copy')">
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy" /> <a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false" color="#000000"
bg-color="#ffffff" @click="copyImage" />
</a-tooltip>
</div> </div>
</div> </div>
</template> </template>

View file

@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import { import {
BarsOutlined, BarsOutlined,
ControlOutlined, ControlOutlined,
@ -18,17 +19,18 @@ import {
DesktopOutlined, DesktopOutlined,
DatabaseOutlined, DatabaseOutlined,
ForkOutlined, ForkOutlined,
CopyOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
const { t } = useI18n(); const { t } = useI18n();
import { HttpUtil, SizeFormatter, TimeFormatter } from '@/utils'; import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js'; import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import { useStatus } from '@/composables/useStatus.js'; import { useStatus } from '@/composables/useStatus.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js'; import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue'; import AppSidebar from '@/components/AppSidebar.vue';
import CustomStatistic from '@/components/CustomStatistic.vue'; import CustomStatistic from '@/components/CustomStatistic.vue';
import TextModal from '@/components/TextModal.vue'; import JsonEditor from '@/components/JsonEditor.vue';
import StatusCard from './StatusCard.vue'; import StatusCard from './StatusCard.vue';
import XrayStatusCard from './XrayStatusCard.vue'; import XrayStatusCard from './XrayStatusCard.vue';
import PanelUpdateModal from './PanelUpdateModal.vue'; import PanelUpdateModal from './PanelUpdateModal.vue';
@ -117,7 +119,7 @@ function openTelegram() {
} }
// Legacy "Config" action fetch the rendered xray config and show // Legacy "Config" action fetch the rendered xray config and show
// it as JSON in the shared TextModal (same UX as main). // it as JSON in the config modal with syntax highlighting.
async function openConfig() { async function openConfig() {
loading.value = true; loading.value = true;
try { try {
@ -129,6 +131,17 @@ async function openConfig() {
loading.value = false; loading.value = false;
} }
} }
async function copyConfig() {
const ok = await ClipboardManager.copyText(configText.value || '');
if (ok) {
message.success('Copied');
}
}
function downloadConfig() {
FileManager.downloadTextFile(configText.value, 'config.json');
}
</script> </script>
<template> <template>
@ -360,8 +373,27 @@ async function openConfig() {
<XrayMetricsModal v-model:open="xrayMetricsOpen" /> <XrayMetricsModal v-model:open="xrayMetricsOpen" />
<XrayLogModal v-model:open="xrayLogsOpen" /> <XrayLogModal v-model:open="xrayLogsOpen" />
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" /> <VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
file-name="config.json" /> <a-modal v-model:open="configTextOpen" :title="t('pages.index.config')" :width="isMobile ? '100%' : '900px'"
:style="isMobile ? { top: '20px', maxWidth: 'calc(100vw - 16px)' } : {}" :closable="true">
<JsonEditor v-model:value="configText" :min-height="isMobile ? '300px' : '420px'"
:max-height="isMobile ? '500px' : '720px'" :readonly="true" />
<template #footer>
<a-button @click="downloadConfig" :size="isMobile ? 'small' : 'middle'">
<template #icon>
<CloudDownloadOutlined />
</template>
<span v-if="!isMobile">config.json</span>
<span v-else>Download</span>
</a-button>
<a-button type="primary" @click="copyConfig" :size="isMobile ? 'small' : 'middle'">
<template #icon>
<CopyOutlined />
</template>
Copy
</a-button>
</template>
</a-modal>
</a-layout> </a-layout>
</a-config-provider> </a-config-provider>
</template> </template>

View file

@ -163,9 +163,9 @@ async function onSave() {
</a-col> </a-col>
</a-row> </a-row>
<a-form-item label="Allow private address"> <a-form-item :label="t('pages.nodes.allowPrivateAddress')">
<a-switch v-model:checked="form.allowPrivateAddress" /> <a-switch v-model:checked="form.allowPrivateAddress" />
<div class="hint">Enable only for nodes on a private network or VPN.</div> <div class="hint">{{ t('pages.nodes.allowPrivateAddressHint') }}</div>
</a-form-item> </a-form-item>
<a-form-item :label="t('pages.nodes.apiToken')" required> <a-form-item :label="t('pages.nodes.apiToken')" required>

View file

@ -61,6 +61,16 @@ const isValid = computed(
() => !tagEmpty.value && !duplicateTag.value && !emptySelector.value, () => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
); );
const fallbackSupported = computed(
() => form.strategy === 'leastPing' || form.strategy === 'leastLoad',
);
watch(() => form.strategy, (next) => {
if (next !== 'leastPing' && next !== 'leastLoad') {
form.fallbackTag = '';
}
});
const tagValidateStatus = computed(() => { const tagValidateStatus = computed(() => {
if (tagEmpty.value) return 'error'; if (tagEmpty.value) return 'error';
if (duplicateTag.value) return 'warning'; if (duplicateTag.value) return 'warning';
@ -111,8 +121,9 @@ const okText = computed(() =>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="Fallback"> <a-form-item label="Fallback"
<a-select v-model:value="form.fallbackTag" allow-clear> :help="fallbackSupported ? '' : 'Available only with Least ping / Least load'">
<a-select v-model:value="form.fallbackTag" allow-clear :disabled="!fallbackSupported">
<a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag"> <a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
{{ tag || `(${t('none')})` }} {{ tag || `(${t('none')})` }}
</a-select-option> </a-select-option>

View file

@ -145,10 +145,11 @@ function syncObservatories() {
} }
function buildWireBalancer(form) { function buildWireBalancer(form) {
const supportsFallback = form.strategy === 'leastPing' || form.strategy === 'leastLoad';
const out = { const out = {
tag: form.tag, tag: form.tag,
selector: [...form.selector], selector: [...form.selector],
fallbackTag: form.fallbackTag, fallbackTag: supportsFallback ? form.fallbackTag : '',
}; };
if (form.strategy && form.strategy !== 'random') { if (form.strategy && form.strategy !== 'random') {
out.strategy = { type: form.strategy }; out.strategy = { type: form.strategy };

View file

@ -56,6 +56,7 @@ func serveDistPage(c *gin.Context, name string) {
csrfToken = "" csrfToken = ""
} }
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`) csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
basePathMeta := []byte(`<meta name="base-path" content="` + htmlpkg.EscapeString(basePath) + `">`)
nonceAttr := "" nonceAttr := ""
if nonce := c.GetString("csp_nonce"); nonce != "" { if nonce := c.GetString("csp_nonce"); nonce != "" {
@ -69,6 +70,7 @@ func serveDistPage(c *gin.Context, name string) {
script += `;</script>` script += `;</script>`
inject := []byte(script) inject := []byte(script)
inject = append(inject, csrfMeta...) inject = append(inject, csrfMeta...)
inject = append(inject, basePathMeta...)
inject = append(inject, []byte(`</head>`)...) inject = append(inject, []byte(`</head>`)...)
out := bytes.Replace(body, []byte("</head>"), inject, 1) out := bytes.Replace(body, []byte("</head>"), inject, 1)

View file

@ -87,10 +87,6 @@ func (j *NodeTrafficSyncJob) Run() {
return return
} }
online := j.inboundService.GetOnlineClients()
if online == nil {
online = []string{}
}
lastOnline, err := j.inboundService.GetClientsLastOnline() lastOnline, err := j.inboundService.GetClientsLastOnline()
if err != nil { if err != nil {
logger.Warning("node traffic sync: get last-online failed:", err) logger.Warning("node traffic sync: get last-online failed:", err)
@ -98,6 +94,13 @@ func (j *NodeTrafficSyncJob) Run() {
if lastOnline == nil { if lastOnline == nil {
lastOnline = map[string]int64{} lastOnline = map[string]int64{}
} }
j.inboundService.RefreshOnlineClientsFromMap(lastOnline)
online := j.inboundService.GetOnlineClients()
if online == nil {
online = []string{}
}
websocket.BroadcastTraffic(map[string]any{ websocket.BroadcastTraffic(map[string]any{
"onlineClients": online, "onlineClients": online,
"lastOnlineMap": lastOnline, "lastOnlineMap": lastOnline,

View file

@ -77,10 +77,6 @@ func (j *XrayTrafficJob) Run() {
// a missing/null onlineClients field as "no update", so without this the // a missing/null onlineClients field as "no update", so without this the
// "everyone went offline" transition was silently dropped — stale online // "everyone went offline" transition was silently dropped — stale online
// users lingered in the list and the online filter kept showing them. // users lingered in the list and the online filter kept showing them.
onlineClients := j.inboundService.GetOnlineClients()
if onlineClients == nil {
onlineClients = []string{}
}
lastOnlineMap, err := j.inboundService.GetClientsLastOnline() lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
if err != nil { if err != nil {
logger.Warning("get clients last online failed:", err) logger.Warning("get clients last online failed:", err)
@ -88,6 +84,17 @@ func (j *XrayTrafficJob) Run() {
if lastOnlineMap == nil { if lastOnlineMap == nil {
lastOnlineMap = make(map[string]int64) lastOnlineMap = make(map[string]int64)
} }
// Determine online clients from lastOnline timestamps with a 5-second
// grace period instead of just the current 5-second traffic poll. This
// prevents idle-but-connected clients from randomly disappearing from
// the UI between polling windows.
j.inboundService.RefreshOnlineClientsFromMap(lastOnlineMap)
onlineClients := j.inboundService.GetOnlineClients()
if onlineClients == nil {
onlineClients = []string{}
}
websocket.BroadcastTraffic(map[string]any{ websocket.BroadcastTraffic(map[string]any{
"traffics": traffics, "traffics": traffics,
"clientTraffics": clientTraffics, "clientTraffics": clientTraffics,

View file

@ -344,10 +344,15 @@ func wireInbound(ib *model.Inbound) url.Values {
} }
// sanitizeStreamSettingsForRemote strips file-based TLS certificate paths // sanitizeStreamSettingsForRemote strips file-based TLS certificate paths
// from the StreamSettings before sending to a remote node. File paths // from the StreamSettings before sending to a remote node, but ONLY when
// (certificateFile / keyFile) are local to the main panel's filesystem // inline certificate content (certificate / key) is also present in the same
// and will cause Xray on the remote node to crash if they don't exist there. // entry. In that case the file paths are redundant and stripping them avoids
// Inline certificate content (certificate / key) is kept intact. // confusion when the central panel's local paths don't exist on the remote.
//
// When a certificate entry contains ONLY file paths (no inline content) the
// paths are left untouched: the user explicitly entered paths that exist on
// the remote node's filesystem, and removing them would leave Xray with TLS
// configured but no certificate, causing Xray to crash on the remote node.
func sanitizeStreamSettingsForRemote(streamSettings string) string { func sanitizeStreamSettingsForRemote(streamSettings string) string {
if streamSettings == "" { if streamSettings == "" {
return streamSettings return streamSettings
@ -368,18 +373,40 @@ func sanitizeStreamSettingsForRemote(streamSettings string) string {
return streamSettings return streamSettings
} }
changed := false
for _, cert := range certificates { for _, cert := range certificates {
c, ok := cert.(map[string]any) c, ok := cert.(map[string]any)
if !ok { if !ok {
continue continue
} }
delete(c, "certificateFile") // Only strip file paths when inline content is present so that the
delete(c, "keyFile") // remote Xray still has a valid certificate to use.
hasCertFile := c["certificateFile"] != nil && c["certificateFile"] != ""
hasKeyFile := c["keyFile"] != nil && c["keyFile"] != ""
hasCertInline := isNonEmptySlice(c["certificate"])
hasKeyInline := isNonEmptySlice(c["key"])
if hasCertFile && hasCertInline {
delete(c, "certificateFile")
changed = true
}
if hasKeyFile && hasKeyInline {
delete(c, "keyFile")
changed = true
}
} }
if !changed {
return streamSettings
}
out, err := json.Marshal(stream) out, err := json.Marshal(stream)
if err != nil { if err != nil {
return streamSettings return streamSettings
} }
return string(out) return string(out)
} }
// isNonEmptySlice reports whether v is a non-nil, non-empty JSON array value.
func isNonEmptySlice(v any) bool {
s, ok := v.([]any)
return ok && len(s) > 0
}

View file

@ -0,0 +1,96 @@
package runtime
import (
"encoding/json"
"testing"
)
func TestSanitizeStreamSettingsForRemote(t *testing.T) {
tests := []struct {
name string
input string
// wantCertFile / wantKeyFile: expected presence after sanitize
wantCertFile bool
wantKeyFile bool
}{
{
name: "file paths only — kept intact (remote node paths)",
input: `{
"tlsSettings": {
"certificates": [{
"certificateFile": "/etc/ssl/cert.crt",
"keyFile": "/etc/ssl/key.key"
}]
}
}`,
wantCertFile: true,
wantKeyFile: true,
},
{
name: "inline content only — unchanged",
input: `{
"tlsSettings": {
"certificates": [{
"certificate": ["-----BEGIN CERTIFICATE-----"],
"key": ["-----BEGIN PRIVATE KEY-----"]
}]
}
}`,
wantCertFile: false,
wantKeyFile: false,
},
{
name: "both file paths and inline content — file paths stripped (redundant)",
input: `{
"tlsSettings": {
"certificates": [{
"certificateFile": "/etc/ssl/cert.crt",
"keyFile": "/etc/ssl/key.key",
"certificate": ["-----BEGIN CERTIFICATE-----"],
"key": ["-----BEGIN PRIVATE KEY-----"]
}]
}
}`,
wantCertFile: false,
wantKeyFile: false,
},
{
name: "empty stream settings",
input: "",
// empty input returns empty, nothing to check
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.input == "" {
if got := sanitizeStreamSettingsForRemote(tc.input); got != "" {
t.Errorf("expected empty string, got %q", got)
}
return
}
got := sanitizeStreamSettingsForRemote(tc.input)
var out map[string]any
if err := json.Unmarshal([]byte(got), &out); err != nil {
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, got)
}
tls, _ := out["tlsSettings"].(map[string]any)
certs, _ := tls["certificates"].([]any)
if len(certs) == 0 {
t.Fatal("certificates array missing in output")
}
cert, _ := certs[0].(map[string]any)
_, hasCertFile := cert["certificateFile"]
_, hasKeyFile := cert["keyFile"]
if hasCertFile != tc.wantCertFile {
t.Errorf("certificateFile present=%v, want %v", hasCertFile, tc.wantCertFile)
}
if hasKeyFile != tc.wantKeyFile {
t.Errorf("keyFile present=%v, want %v", hasKeyFile, tc.wantKeyFile)
}
})
}
}

View file

@ -279,11 +279,31 @@ func (s *InboundService) checkEmailsExistForClients(clients []model.Client) (str
return "", nil return "", nil
} }
// normalizeStreamSettings clears StreamSettings for protocols that don't use it.
// Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings.
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
protocolsWithStream := map[model.Protocol]bool{
model.VMESS: true,
model.VLESS: true,
model.Trojan: true,
model.Shadowsocks: true,
model.Hysteria: true,
model.Hysteria2: true,
}
if !protocolsWithStream[inbound.Protocol] {
inbound.StreamSettings = ""
}
}
// AddInbound creates a new inbound configuration. // AddInbound creates a new inbound configuration.
// It validates port uniqueness, client email uniqueness, and required fields, // It validates port uniqueness, client email uniqueness, and required fields,
// then saves the inbound to the database and optionally adds it to the running Xray instance. // then saves the inbound to the database and optionally adds it to the running Xray instance.
// Returns the created inbound, whether Xray needs restart, and any error. // Returns the created inbound, whether Xray needs restart, and any error.
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
exist, err := s.checkPortConflict(inbound, 0) exist, err := s.checkPortConflict(inbound, 0)
if err != nil { if err != nil {
return inbound, false, err return inbound, false, err
@ -550,6 +570,9 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
} }
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
exist, err := s.checkPortConflict(inbound, inbound.Id) exist, err := s.checkPortConflict(inbound, inbound.Id)
if err != nil { if err != nil {
return inbound, false, err return inbound, false, err
@ -1543,6 +1566,13 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
const resetGracePeriodMs int64 = 30000 const resetGracePeriodMs int64 = 30000
// onlineGracePeriodMs must comfortably exceed the 5s traffic-poll interval —
// Xray's stats counters often report a zero delta for an active session across
// a single poll, so a 5s grace would still drop the client on the next tick.
// ~4 polls of slack keeps idle-but-connected clients visible without lingering
// long after a real disconnect.
const onlineGracePeriodMs int64 = 20000
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) { func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
var structuralChange bool var structuralChange bool
err := submitTrafficWrite(func() error { err := submitTrafficWrite(func() error {
@ -1884,15 +1914,9 @@ func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic
func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) (err error) { func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) (err error) {
if len(traffics) == 0 { if len(traffics) == 0 {
// Empty onlineUsers
if p != nil {
p.SetOnlineClients(make([]string, 0))
}
return nil return nil
} }
onlineClients := make([]string, 0)
emails := make([]string, 0, len(traffics)) emails := make([]string, 0, len(traffics))
for _, traffic := range traffics { for _, traffic := range traffics {
emails = append(emails, traffic.Email) emails = append(emails, traffic.Email)
@ -1935,14 +1959,10 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
dbClientTraffics[dbTraffic_index].Down += t.Down dbClientTraffics[dbTraffic_index].Down += t.Down
dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down
if t.Up+t.Down > 0 { if t.Up+t.Down > 0 {
onlineClients = append(onlineClients, t.Email)
dbClientTraffics[dbTraffic_index].LastOnline = now dbClientTraffics[dbTraffic_index].LastOnline = now
} }
} }
// Set onlineUsers
p.SetOnlineClients(onlineClients)
err = tx.Save(dbClientTraffics).Error err = tx.Save(dbClientTraffics).Error
if err != nil { if err != nil {
logger.Warning("AddClientTraffic update data ", err) logger.Warning("AddClientTraffic update data ", err)
@ -3768,6 +3788,19 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
return result, nil return result, nil
} }
func (s *InboundService) RefreshOnlineClientsFromMap(lastOnlineMap map[string]int64) {
now := time.Now().UnixMilli()
newOnlineClients := make([]string, 0, len(lastOnlineMap))
for email, lastOnline := range lastOnlineMap {
if now-lastOnline < onlineGracePeriodMs {
newOnlineClients = append(newOnlineClients, email)
}
}
if p != nil {
p.SetOnlineClients(newOnlineClients)
}
}
func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) { func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
db := database.GetDB() db := database.GetDB()

View file

@ -418,6 +418,8 @@
"apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في الإعدادات → توكن API.", "apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في الإعدادات → توكن API.",
"regenerate": "تجديد التوكن", "regenerate": "تجديد التوكن",
"regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟", "regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
"allowPrivateAddress": "السماح بالعنوان الخاص",
"allowPrivateAddressHint": "التفعيل فقط للعقد على شبكة خاصة أو VPN.",
"enable": "مفعل", "enable": "مفعل",
"status": "الحالة", "status": "الحالة",
"cpu": "المعالج", "cpu": "المعالج",

View file

@ -418,6 +418,8 @@
"apiTokenHint": "The remote panel exposes its API token under Settings → API Token.", "apiTokenHint": "The remote panel exposes its API token under Settings → API Token.",
"regenerate": "Regenerate Token", "regenerate": "Regenerate Token",
"regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?", "regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
"allowPrivateAddress": "Allow private address",
"allowPrivateAddressHint": "Enable only for nodes on a private network or VPN.",
"enable": "Enabled", "enable": "Enabled",
"status": "Status", "status": "Status",
"cpu": "CPU", "cpu": "CPU",

View file

@ -418,6 +418,8 @@
"apiTokenHint": "El panel remoto expone su token de API en Configuración → Token de API.", "apiTokenHint": "El panel remoto expone su token de API en Configuración → Token de API.",
"regenerate": "Regenerar token", "regenerate": "Regenerar token",
"regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?", "regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
"allowPrivateAddress": "Permitir dirección privada",
"allowPrivateAddressHint": "Habilitar solo para nodos en una red privada o VPN.",
"enable": "Habilitado", "enable": "Habilitado",
"status": "Estado", "status": "Estado",
"cpu": "CPU", "cpu": "CPU",

View file

@ -418,6 +418,8 @@
"apiTokenHint": "پنل ریموت توکن API خودش را در بخش تنظیمات → توکن API نمایش می‌دهد.", "apiTokenHint": "پنل ریموت توکن API خودش را در بخش تنظیمات → توکن API نمایش می‌دهد.",
"regenerate": "تولید مجدد توکن", "regenerate": "تولید مجدد توکن",
"regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟", "regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
"allowPrivateAddress": "اجازه آدرس خصوصی",
"allowPrivateAddressHint": "فقط برای نودهای روی شبکه خصوصی یا VPN فعال شود.",
"enable": "فعال", "enable": "فعال",
"status": "وضعیت", "status": "وضعیت",
"cpu": "پردازنده", "cpu": "پردازنده",

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Pengaturan → Token API.", "apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Pengaturan → Token API.",
"regenerate": "Buat Ulang Token", "regenerate": "Buat Ulang Token",
"regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?", "regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
"allowPrivateAddress": "Izinkan alamat pribadi",
"allowPrivateAddressHint": "Aktifkan hanya untuk node di jaringan pribadi atau VPN.",
"enable": "Aktif", "enable": "Aktif",
"status": "Status", "status": "Status",
"cpu": "CPU", "cpu": "CPU",

View file

@ -418,6 +418,8 @@
"apiTokenHint": "リモートパネルでは、設定 → APIトークン でAPIトークンを確認できます。", "apiTokenHint": "リモートパネルでは、設定 → APIトークン でAPIトークンを確認できます。",
"regenerate": "トークンを再生成", "regenerate": "トークンを再生成",
"regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?", "regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
"allowPrivateAddress": "プライベートアドレスを許可",
"allowPrivateAddressHint": "プライベートネットワークまたはVPN上のードにのみ有効にします。",
"enable": "有効", "enable": "有効",
"status": "ステータス", "status": "ステータス",
"cpu": "CPU", "cpu": "CPU",

View file

@ -418,6 +418,8 @@
"apiTokenHint": "O painel remoto exibe o token da API em Configurações → Token da API.", "apiTokenHint": "O painel remoto exibe o token da API em Configurações → Token da API.",
"regenerate": "Regenerar token", "regenerate": "Regenerar token",
"regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?", "regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
"allowPrivateAddress": "Permitir endereço privado",
"allowPrivateAddressHint": "Ativar apenas para nós em uma rede privada ou VPN.",
"enable": "Ativado", "enable": "Ativado",
"status": "Status", "status": "Status",
"cpu": "CPU", "cpu": "CPU",

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Удалённая панель показывает свой токен API в разделе Настройки → Токен API.", "apiTokenHint": "Удалённая панель показывает свой токен API в разделе Настройки → Токен API.",
"regenerate": "Сгенерировать токен заново", "regenerate": "Сгенерировать токен заново",
"regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?", "regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
"allowPrivateAddress": "Разрешить частный адрес",
"allowPrivateAddressHint": "Включить только для узлов в частной сети или VPN.",
"enable": "Включён", "enable": "Включён",
"status": "Статус", "status": "Статус",
"cpu": "CPU", "cpu": "CPU",

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Uzak panel API token'ını Ayarlar → API Token altında gösterir.", "apiTokenHint": "Uzak panel API token'ını Ayarlar → API Token altında gösterir.",
"regenerate": "Token'ı Yeniden Oluştur", "regenerate": "Token'ı Yeniden Oluştur",
"regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?", "regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
"allowPrivateAddress": "Özel adrese izin ver",
"allowPrivateAddressHint": "Yalnızca özel ağ veya VPN üzerindeki düğümler için etkinleştir.",
"enable": "Etkin", "enable": "Etkin",
"status": "Durum", "status": "Durum",
"cpu": "CPU", "cpu": "CPU",

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Віддалена панель показує свій токен API в Налаштуваннях → Токен API.", "apiTokenHint": "Віддалена панель показує свій токен API в Налаштуваннях → Токен API.",
"regenerate": "Перегенерувати токен", "regenerate": "Перегенерувати токен",
"regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?", "regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
"allowPrivateAddress": "Дозволити приватну адресу",
"allowPrivateAddressHint": "Увімкнути лише для вузлів у приватній мережі або VPN.",
"enable": "Увімкнено", "enable": "Увімкнено",
"status": "Статус", "status": "Статус",
"cpu": "CPU", "cpu": "CPU",

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Panel từ xa hiển thị token API tại Cài đặt → Token API.", "apiTokenHint": "Panel từ xa hiển thị token API tại Cài đặt → Token API.",
"regenerate": "Tạo lại token", "regenerate": "Tạo lại token",
"regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?", "regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
"allowPrivateAddress": "Cho phép địa chỉ riêng",
"allowPrivateAddressHint": "Chỉ bật cho các nút trên mạng riêng hoặc VPN.",
"enable": "Kích hoạt", "enable": "Kích hoạt",
"status": "Trạng thái", "status": "Trạng thái",
"cpu": "CPU", "cpu": "CPU",

View file

@ -418,6 +418,8 @@
"apiTokenHint": "远程面板在 设置 → API 令牌 中显示其 API 令牌。", "apiTokenHint": "远程面板在 设置 → API 令牌 中显示其 API 令牌。",
"regenerate": "重新生成令牌", "regenerate": "重新生成令牌",
"regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?", "regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
"allowPrivateAddress": "允许私有地址",
"allowPrivateAddressHint": "仅对私有网络或VPN上的节点启用。",
"enable": "已启用", "enable": "已启用",
"status": "状态", "status": "状态",
"cpu": "CPU", "cpu": "CPU",

View file

@ -418,6 +418,8 @@
"apiTokenHint": "遠端面板在 設定 → API 權杖 中顯示其 API 權杖。", "apiTokenHint": "遠端面板在 設定 → API 權杖 中顯示其 API 權杖。",
"regenerate": "重新產生權杖", "regenerate": "重新產生權杖",
"regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?", "regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
"allowPrivateAddress": "允許私有地址",
"allowPrivateAddressHint": "僅對私有網路或VPN上的節點啟用。",
"enable": "已啟用", "enable": "已啟用",
"status": "狀態", "status": "狀態",
"cpu": "CPU", "cpu": "CPU",

View file

@ -11,17 +11,17 @@ import (
type Config struct { type Config struct {
LogConfig json_util.RawMessage `json:"log"` LogConfig json_util.RawMessage `json:"log"`
RouterConfig json_util.RawMessage `json:"routing"` RouterConfig json_util.RawMessage `json:"routing"`
DNSConfig json_util.RawMessage `json:"dns"` DNSConfig json_util.RawMessage `json:"dns,omitempty"`
InboundConfigs []InboundConfig `json:"inbounds"` InboundConfigs []InboundConfig `json:"inbounds"`
OutboundConfigs json_util.RawMessage `json:"outbounds"` OutboundConfigs json_util.RawMessage `json:"outbounds"`
Transport json_util.RawMessage `json:"transport"` Transport json_util.RawMessage `json:"transport,omitempty"`
Policy json_util.RawMessage `json:"policy"` Policy json_util.RawMessage `json:"policy"`
API json_util.RawMessage `json:"api"` API json_util.RawMessage `json:"api"`
Stats json_util.RawMessage `json:"stats"` Stats json_util.RawMessage `json:"stats"`
Reverse json_util.RawMessage `json:"reverse"` Reverse json_util.RawMessage `json:"reverse,omitempty"`
FakeDNS json_util.RawMessage `json:"fakedns"` FakeDNS json_util.RawMessage `json:"fakedns,omitempty"`
Observatory json_util.RawMessage `json:"observatory"` Observatory json_util.RawMessage `json:"observatory,omitempty"`
BurstObservatory json_util.RawMessage `json:"burstObservatory"` BurstObservatory json_util.RawMessage `json:"burstObservatory,omitempty"`
Metrics json_util.RawMessage `json:"metrics"` Metrics json_util.RawMessage `json:"metrics"`
} }

View file

@ -13,9 +13,9 @@ type InboundConfig struct {
Port int `json:"port"` Port int `json:"port"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Settings json_util.RawMessage `json:"settings"` Settings json_util.RawMessage `json:"settings"`
StreamSettings json_util.RawMessage `json:"streamSettings"` StreamSettings json_util.RawMessage `json:"streamSettings,omitempty"`
Tag string `json:"tag"` Tag string `json:"tag"`
Sniffing json_util.RawMessage `json:"sniffing"` Sniffing json_util.RawMessage `json:"sniffing,omitempty"`
} }
// Equals compares two InboundConfig instances for deep equality. // Equals compares two InboundConfig instances for deep equality.