Compare commits

..

No commits in common. "main" and "v3.0.2" have entirely different histories.
main ... v3.0.2

50 changed files with 378 additions and 1119 deletions

View file

@ -39,12 +39,6 @@ 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 المحسنة مع النطاقات الإيرانية المدمجة وتركيز على الأمان وحظر الإعلانات._
- [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:

View file

@ -39,12 +39,6 @@ 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._
- [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
**Si este proyecto te es útil, puedes darle una**:star2:

View file

@ -39,12 +39,6 @@ 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 با دامنه‌های ایرانی داخلی و تمرکز بر امنیت و مسدود کردن تبلیغات._
- [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: بدهید

View file

@ -39,12 +39,6 @@ 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._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
## Community Tools
Tools and integrations built by the community around 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (License: **MIT**): _Manage inbounds, clients, panel settings, and Xray configuration as code with Terraform / OpenTofu._
## Support project
**If this project is helpful to you, you may wish to give it a**:star2:

View file

@ -39,12 +39,6 @@ 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 со встроенными иранскими доменами и фокусом на безопасность и блокировку рекламы._
- [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:

View file

@ -39,12 +39,6 @@ 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 路由规则内置伊朗域名专注于安全性和广告拦截。_
- [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:

View file

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

View file

@ -51,12 +51,7 @@ export function setupAxios() {
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
// 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;
}
const basePath = window.X_UI_BASE_PATH;
if (typeof basePath === 'string' && basePath !== '' && 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 '@/composables/useTheme.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
@ -16,6 +16,4 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
});
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 '@/composables/useTheme.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
@ -16,6 +16,4 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
});
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
// stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import IndexPage from '@/pages/index/IndexPage.vue';
@ -18,6 +18,4 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
createApp(IndexPage).use(Antd).use(i18n).mount('#app');
});
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
// stored theme to <body>/<html> before Vue renders anything.
import '@/composables/useTheme.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import LoginPage from '@/pages/login/LoginPage.vue';
setupAxios();
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');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
createApp(LoginPage).use(Antd).use(i18n).mount('#app');
});
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 '@/composables/useTheme.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import NodesPage from '@/pages/nodes/NodesPage.vue';
@ -16,6 +16,4 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
createApp(NodesPage).use(Antd).use(i18n).mount('#app');
});
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
// stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import SettingsPage from '@/pages/settings/SettingsPage.vue';
@ -18,6 +18,4 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
});
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
// share links — the SPA reads those at mount.
import '@/composables/useTheme.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { i18n } from '@/i18n/index.js';
import SubPage from '@/pages/sub/SubPage.vue';
const messageContainer = document.getElementById('message');
@ -15,6 +15,4 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
createApp(SubPage).use(Antd).use(i18n).mount('#app');
});
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 '@/composables/useTheme.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import XrayPage from '@/pages/xray/XrayPage.vue';
@ -16,6 +16,4 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
createApp(XrayPage).use(Antd).use(i18n).mount('#app');
});
createApp(XrayPage).use(Antd).use(i18n).mount('#app');

View file

@ -1,54 +1,93 @@
// 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 { 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 lazyModules = import.meta.glob([
'../../../web/translation/*.json',
'!../../../web/translation/en-US.json',
]);
const lazyModules = import.meta.glob('../../../web/translation/*.json');
const eagerModules = import.meta.glob('../../../web/translation/*.json', { eager: true });
function moduleKeyFor(code) {
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();
if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
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({
legacy: false,
// `composition` mode (legacy: false) so `useI18n()` works in
// <script setup> blocks.
globalInjection: true,
locale: active,
fallbackLocale: FALLBACK,
messages: { [FALLBACK]: enUS },
// Locale JSON is nested by namespace ({pages: {inbounds: {email: ...}}})
// 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,
missingWarn: 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) {
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) {
if (code === FALLBACK) {
i18n.global.locale.value = FALLBACK;
return true;
}
const loader = lazyModules[moduleKeyFor(code)];
const key = moduleKeyFor(code);
const loader = lazyModules[key];
if (!loader) return false;
const mod = await loader();
i18n.global.setLocaleMessage(code, mod.default || mod);
i18n.global.locale.value = code;
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 {
return new TlsStreamSettings.Cert(
false, '', '',
Array.isArray(json.certificate) ? json.certificate.join('\n') : (json.certificate ?? ''),
Array.isArray(json.key) ? json.key.join('\n') : (json.key ?? ''),
json.certificate.join('\n'),
json.key.join('\n'),
json.oneTimeLoading,
json.usage,
json.buildChain,
@ -2412,25 +2412,20 @@ export class Inbound extends XrayCommonClass {
}
toJson() {
// Only these protocols use streamSettings
const streamProtocols = [Protocols.VLESS, Protocols.VMESS, Protocols.TROJAN, Protocols.SHADOWSOCKS, Protocols.HYSTERIA];
const result = {
let streamSettings;
if (this.canEnableStream() || this.stream?.sockopt) {
streamSettings = this.stream.toJson();
}
return {
port: this.port,
listen: this.listen,
protocol: this.protocol,
settings: this.settings instanceof XrayCommonClass ? this.settings.toJson() : this.settings,
streamSettings: streamSettings,
tag: this.tag,
sniffing: this.sniffing.toJson(),
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,13 +1397,6 @@ export class Outbound extends CommonClass {
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);
}
@ -1503,14 +1496,6 @@ export class Outbound extends CommonClass {
default:
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);
// Remove '#' from url.hash
remark = remark.length > 0 ? remark.substring(1) : 'out-' + protocol + '-' + port;
@ -1531,17 +1516,7 @@ export class Outbound extends CommonClass {
let urlParams = new URLSearchParams(params);
// Create stream settings with hysteria network
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);
}
let stream = new StreamSettings('hysteria', 'none');
// Set hysteria stream settings
stream.hysteria.auth = password;
@ -1559,7 +1534,7 @@ export class Outbound extends CommonClass {
stream.hysteria.udphopIntervalMax = parseInt(urlParams.get('udphopIntervalMax') ?? '30');
}
// Optional QUIC parameters for FinalMask support and hysteria2 share links
// Optional QUIC parameters
if (urlParams.has('initStreamReceiveWindow')) {
stream.hysteria.initStreamReceiveWindow = parseInt(urlParams.get('initStreamReceiveWindow'));
}
@ -1582,38 +1557,6 @@ export class Outbound extends CommonClass {
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
let settings = new Outbound.HysteriaSettings(address, port, 2);

View file

@ -70,11 +70,8 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const inbound = ref(null);
const dbForm = ref(null);
const saving = ref(false);
const advancedStreamText = ref('');
const advancedSniffingText = ref('');
const advancedSettingsText = ref('');
const advancedJson = ref({ stream: '', sniffing: '', settings: '' });
const activeTabKey = ref('basic');
const advancedSectionKey = ref('all');
// Cached default cert/key paths from /panel/setting/defaultSettings
// powers the "Set default cert" button on the TLS form.
const defaultCert = ref('');
@ -226,7 +223,15 @@ function freshDbForm() {
function primeAdvancedJson() {
if (!inbound.value) return;
['stream', 'sniffing', 'settings'].forEach(stampAdvancedTextFor);
try {
advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
} catch (_e) { /* keep prior text */ }
try {
advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.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 */ }
}
watch(() => props.open, (next) => {
@ -239,28 +244,39 @@ watch(() => props.open, (next) => {
primeAdvancedJson();
}
activeTabKey.value = 'basic';
advancedSectionKey.value = 'all';
fetchDefaultCertSettings();
});
function applyAdvancedJsonToBasic() {
if (!inbound.value) return true;
let settings; let streamSettings; let sniffing;
let parsedSettings;
let parsedStream;
let parsedSniffing;
try {
settings = parseAdvancedSliceWithLabel(advancedSettingsText.value, settingsFallback(), 'Settings');
streamSettings = parseAdvancedSliceWithLabel(advancedStreamText.value, streamFallback(), 'Stream');
sniffing = parseAdvancedSliceWithLabel(advancedSniffingText.value, sniffingFallback(), 'Sniffing');
} catch (_e) { return false; }
parsedSettings = advancedJson.value.settings.trim()
? JSON.parse(advancedJson.value.settings)
: inbound.value.settings?.toJson?.();
} catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return false; }
try {
parsedStream = advancedJson.value.stream.trim()
? JSON.parse(advancedJson.value.stream)
: inbound.value.stream?.toJson?.();
} catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return false; }
try {
parsedSniffing = advancedJson.value.sniffing.trim()
? JSON.parse(advancedJson.value.sniffing)
: inbound.value.sniffing?.toJson?.();
} catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return false; }
try {
inbound.value = Inbound.fromJson({
port: inbound.value.port,
listen: inbound.value.listen,
protocol: inbound.value.protocol,
settings,
streamSettings,
settings: parsedSettings,
streamSettings: parsedStream,
tag: inbound.value.tag,
sniffing,
sniffing: parsedSniffing,
clientStats: inbound.value.clientStats,
});
} catch (e) {
@ -308,181 +324,6 @@ 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 settingsFallback = () => inbound.value?.settings?.toJson?.() || {};
const sniffingFallback = () => inbound.value?.sniffing?.toJson?.() || {};
const streamFallback = () => inbound.value?.stream?.toJson?.() || {};
const advancedTextRefs = {
stream: advancedStreamText,
sniffing: advancedSniffingText,
settings: advancedSettingsText,
};
function stampAdvancedTextFor(slice) {
const textRef = advancedTextRefs[slice];
if (!textRef) return;
if (slice === 'stream' && !canEnableStream.value) {
textRef.value = '{}';
return;
}
const obj = inbound.value?.[slice];
if (!obj) return;
try {
textRef.value = JSON.stringify(JSON.parse(obj.toString()), null, 2);
} catch (_e) { /* keep prior text */ }
}
function parseAdvancedSliceWithLabel(rawText, fallback, label) {
try {
return parseAdvancedSliceOrFallback(rawText, fallback);
} catch (e) {
message.error(`${label} JSON invalid: ${e.message}`);
throw e;
}
}
function compactAdvancedJson(raw, fallback, label) {
try {
return JSON.stringify(JSON.parse(raw || fallback));
} catch (e) {
message.error(`${label} JSON invalid: ${e.message}`);
throw e;
}
}
async function withSaving(fn) {
saving.value = true;
try { return await fn(); } finally { saving.value = false; }
}
function makeWrappedAdvancedConfig({ key, textRef, getFallback, label }) {
const invalid = `${label} JSON invalid`;
return computed({
get: () => {
if (!inbound.value) return '';
try {
const value = parseAdvancedSliceOrFallback(textRef.value, getFallback());
return JSON.stringify({ [key]: value }, null, 2);
} catch (_e) {
return '';
}
},
set: (next) => {
let parsed;
try {
parsed = JSON.parse(next);
} catch (e) {
message.error(`${invalid}: ${e.message}`);
return;
}
const unwrapped = unwrapWrappedObject(parsed, key);
if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
message.error(`${label} JSON must be an object or { ${key}: { ... } }.`);
return;
}
try {
textRef.value = JSON.stringify(unwrapped, null, 2);
} catch (e) {
message.error(`${invalid}: ${e.message}`);
}
},
});
}
const advancedAllConfig = computed({
get: () => {
if (!inbound.value) return '';
try {
const result = {
listen: inbound.value.listen,
port: inbound.value.port,
protocol: inbound.value.protocol,
settings: parseAdvancedSliceOrFallback(advancedSettingsText.value, settingsFallback()),
sniffing: parseAdvancedSliceOrFallback(advancedSniffingText.value, sniffingFallback()),
tag: inbound.value.tag,
};
if (canEnableStream.value) {
result.streamSettings = parseAdvancedSliceOrFallback(advancedStreamText.value, streamFallback());
}
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 port = Number(parsed.port);
if (Number.isFinite(port)) inbound.value.port = port;
}
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, settingsFallback());
advancedSettingsText.value = JSON.stringify(parsed.settings ?? existingSettings, null, 2);
advancedSniffingText.value = JSON.stringify(parsed.sniffing ?? sniffingFallback(), null, 2);
advancedStreamText.value = canEnableStream.value
? JSON.stringify(parsed.streamSettings ?? streamFallback(), null, 2)
: '{}';
} catch (e) {
message.error(`All JSON invalid: ${e.message}`);
}
},
});
const advancedSettingsConfig = makeWrappedAdvancedConfig({
key: 'settings',
textRef: advancedSettingsText,
getFallback: settingsFallback,
label: 'Settings',
});
const advancedSniffingConfig = makeWrappedAdvancedConfig({
key: 'sniffing',
textRef: advancedSniffingText,
getFallback: sniffingFallback,
label: 'Sniffing',
});
const advancedStreamConfig = makeWrappedAdvancedConfig({
key: 'streamSettings',
textRef: advancedStreamText,
getFallback: streamFallback,
label: 'Stream',
});
// === Random helpers wired to the form's sync icons ==================
function randomEmail(target) {
if (target) target.email = RandomUtil.randomLowerAndNum(9);
@ -515,13 +356,16 @@ function regenInboundWg() {
// === Reality keygen via existing API =================================
async function genRealityKeypair() {
await withSaving(async () => {
saving.value = true;
try {
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
if (msg?.success) {
inbound.value.stream.reality.privateKey = msg.obj.privateKey;
inbound.value.stream.reality.settings.publicKey = msg.obj.publicKey;
}
});
} finally {
saving.value = false;
}
}
function clearRealityKeypair() {
@ -531,13 +375,16 @@ function clearRealityKeypair() {
}
async function genMldsa65() {
await withSaving(async () => {
saving.value = true;
try {
const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
if (msg?.success) {
inbound.value.stream.reality.mldsa65Seed = msg.obj.seed;
inbound.value.stream.reality.settings.mldsa65Verify = msg.obj.verify;
}
});
} finally {
saving.value = false;
}
}
function clearMldsa65() {
@ -561,7 +408,8 @@ function randomizeShortIds() {
// === ECH cert helpers ================================================
async function getNewEchCert() {
if (!inbound.value?.stream?.tls) return;
await withSaving(async () => {
saving.value = true;
try {
const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', {
sni: inbound.value.stream.tls.sni,
});
@ -569,7 +417,9 @@ async function getNewEchCert() {
inbound.value.stream.tls.echServerKeys = msg.obj.echServerKeys;
inbound.value.stream.tls.settings.echConfigList = msg.obj.echConfigList;
}
});
} finally {
saving.value = false;
}
}
function clearEchCert() {
@ -613,14 +463,17 @@ function matchesVlessAuth(block, authId) {
async function getNewVlessEnc(authId) {
if (!authId || !inbound.value?.settings) return;
await withSaving(async () => {
saving.value = true;
try {
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
if (!msg?.success) return;
const block = (msg.obj?.auths || []).find((a) => matchesVlessAuth(a, authId));
if (!block) return;
inbound.value.settings.decryption = block.decryption;
inbound.value.settings.encryption = block.encryption;
});
} finally {
saving.value = false;
}
}
function clearVlessEnc() {
@ -665,16 +518,24 @@ async function submit() {
if (!inbound.value || !dbForm.value) return;
saving.value = true;
try {
let streamSettings; let sniffing; let settings;
// Sniffing tab is structured; stream stays JSON for unsupported
// transports both go to wire as serialized JSON.
let streamSettings;
let sniffing;
let settings;
try {
streamSettings = canEnableStream.value
? compactAdvancedJson(advancedStreamText.value, '', 'Stream')
? JSON.stringify(JSON.parse(advancedJson.value.stream))
: (inbound.value.stream?.sockopt
? JSON.stringify({ sockopt: inbound.value.stream.sockopt.toJson() })
: '');
sniffing = compactAdvancedJson(advancedSniffingText.value, inbound.value.sniffing.toString(), 'Sniffing');
settings = compactAdvancedJson(advancedSettingsText.value, inbound.value.settings.toString(), 'Settings');
} catch (_e) { return; }
} catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return; }
try {
sniffing = JSON.stringify(JSON.parse(advancedJson.value.sniffing || inbound.value.sniffing.toString()));
} catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return; }
try {
settings = JSON.stringify(JSON.parse(advancedJson.value.settings || inbound.value.settings.toString()));
} catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return; }
// The structured form mutates `inbound.stream` directly when the
// user edits TCP/WS/gRPC/HTTPUpgrade fields, but if they touched
@ -730,15 +591,35 @@ const okText = computed(() =>
// Whenever the structured form mutates stream / sniffing / settings,
// refresh the matching slice of the Advanced JSON tab so the user
// always sees the live state.
['stream', 'sniffing', 'settings'].forEach((slice) => {
watch(
() => inbound.value && JSON.stringify(inbound.value[slice]?.toJson?.() || {}),
() => stampAdvancedTextFor(slice),
);
});
watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
// always sees the live state flipping a switch in Sniffing or
// editing encryption in Protocol now reflects in Advanced.
watch(
() => inbound.value && JSON.stringify(inbound.value.stream?.toJson?.() || {}),
() => {
if (!inbound.value?.stream) return;
try {
advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
} catch (_e) { /* leave as is */ }
},
);
watch(
() => inbound.value && JSON.stringify(inbound.value.sniffing?.toJson?.() || {}),
() => {
if (!inbound.value?.sniffing) return;
try {
advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
} catch (_e) { /* leave as is */ }
},
);
watch(
() => inbound.value && JSON.stringify(inbound.value.settings?.toJson?.() || {}),
() => {
if (!inbound.value?.settings) return;
try {
advancedJson.value.settings = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
} catch (_e) { /* leave as is */ }
},
);
</script>
<template>
@ -1124,7 +1005,7 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
<a-form-item>
<template #label>
<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
</a-tooltip>
</template>
@ -1953,6 +1834,14 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
</template>
<a-input-number v-model:value="inbound.stream.hysteria.version" :min="2" :max="2" />
</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>
<template #label>
<a-tooltip title="Idle timeout (seconds) for a single QUIC native UDP connection.">
@ -2063,48 +1952,20 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
<!-- ============================== ADVANCED ============================== -->
<a-tab-pane key="advanced" :tab="t('pages.xray.advancedTemplate')">
<div class="advanced-shell">
<div class="advanced-panel">
<div class="advanced-panel__header">
<div>
<div class="advanced-panel__title">Inbound JSON sections</div>
<div class="advanced-panel__subtitle">
Full inbound JSON and focused editors for settings, sniffing, and streamSettings.
</div>
</div>
</div>
<a-tabs v-model:active-key="advancedSectionKey" class="advanced-inner-tabs">
<a-tab-pane key="all" tab="All">
<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-alert type="info" show-icon
message="Edit raw stream JSON to access advanced fields we don't yet expose through the form."
class="mb-12" />
<a-form layout="vertical">
<a-form-item label="settings (clients, encryption, fallbacks, …)">
<JsonEditor v-model:value="advancedJson.settings" min-height="280px" max-height="520px" />
</a-form-item>
<a-form-item label="streamSettings">
<JsonEditor v-model:value="advancedJson.stream" min-height="280px" max-height="520px" />
</a-form-item>
<a-form-item label="sniffing (overrides the Sniffing tab when set)">
<JsonEditor v-model:value="advancedJson.sniffing" min-height="180px" max-height="360px" />
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-modal>
@ -2180,66 +2041,6 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
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;
opacity: 0.7;
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;
opacity: 0.75;
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(body.dark) .advanced-panel,
:global(html[data-theme='ultra-dark']) .advanced-panel {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.03);
}
.section-heading {
font-weight: 500;
margin: 12px 0 6px;

View file

@ -80,7 +80,7 @@ const savedFilterState = (() => {
const enableFilter = ref(!!savedFilterState.enableFilter);
const searchKey = ref(savedFilterState.searchKey || '');
const filterBy = ref(savedFilterState.filterBy || '');
const protocolFilter = ref(savedFilterState.protocolFilter || undefined);
const protocolFilter = ref(savedFilterState.protocolFilter || '');
const nodeFilter = ref(savedFilterState.nodeFilter || '');
watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {

View file

@ -111,8 +111,7 @@ function close() {
<template v-if="dbInbound">
<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">
<QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''"
:show-qr="!item.value.includes('mldsa65') && !item.value.includes('ML-KEM-768')" />
<QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''" />
</a-collapse-panel>
</a-collapse>
</template>

View file

@ -1,7 +1,6 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { CopyOutlined, DownloadOutlined, PictureOutlined } from '@ant-design/icons-vue';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { ClipboardManager, FileManager } from '@/utils';
@ -16,8 +15,6 @@ const props = defineProps({
showQr: { type: Boolean, default: true },
});
const qrRef = ref(null);
async function copy() {
const ok = await ClipboardManager.copyText(props.value);
if (ok) message.success(t('copied'));
@ -27,55 +24,6 @@ function download() {
if (!props.downloadName) return;
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>
<template>
@ -89,13 +37,6 @@ async function downloadImage() {
</template>
</a-button>
</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-button size="small" @click="download">
<template #icon>
@ -104,11 +45,9 @@ async function downloadImage() {
</a-button>
</a-tooltip>
</div>
<div v-if="showQr" ref="qrRef" class="qr-panel-canvas">
<a-tooltip :title="t('copy')">
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false" color="#000000"
bg-color="#ffffff" @click="copyImage" />
</a-tooltip>
<div v-if="showQr" class="qr-panel-canvas">
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy" />
</div>
</div>
</template>

View file

@ -1,7 +1,6 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import {
BarsOutlined,
ControlOutlined,
@ -19,18 +18,17 @@ import {
DesktopOutlined,
DatabaseOutlined,
ForkOutlined,
CopyOutlined,
} from '@ant-design/icons-vue';
const { t } = useI18n();
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { HttpUtil, SizeFormatter, TimeFormatter } from '@/utils';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import { useStatus } from '@/composables/useStatus.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import CustomStatistic from '@/components/CustomStatistic.vue';
import JsonEditor from '@/components/JsonEditor.vue';
import TextModal from '@/components/TextModal.vue';
import StatusCard from './StatusCard.vue';
import XrayStatusCard from './XrayStatusCard.vue';
import PanelUpdateModal from './PanelUpdateModal.vue';
@ -119,7 +117,7 @@ function openTelegram() {
}
// Legacy "Config" action fetch the rendered xray config and show
// it as JSON in the config modal with syntax highlighting.
// it as JSON in the shared TextModal (same UX as main).
async function openConfig() {
loading.value = true;
try {
@ -131,17 +129,6 @@ async function openConfig() {
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>
<template>
@ -373,27 +360,8 @@ function downloadConfig() {
<XrayMetricsModal v-model:open="xrayMetricsOpen" />
<XrayLogModal v-model:open="xrayLogsOpen" />
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
<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>
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
file-name="config.json" />
</a-layout>
</a-config-provider>
</template>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -77,6 +77,10 @@ func (j *XrayTrafficJob) Run() {
// a missing/null onlineClients field as "no update", so without this the
// "everyone went offline" transition was silently dropped — stale online
// 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()
if err != nil {
logger.Warning("get clients last online failed:", err)
@ -84,17 +88,6 @@ func (j *XrayTrafficJob) Run() {
if lastOnlineMap == nil {
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{
"traffics": traffics,
"clientTraffics": clientTraffics,

View file

@ -344,15 +344,10 @@ func wireInbound(ib *model.Inbound) url.Values {
}
// sanitizeStreamSettingsForRemote strips file-based TLS certificate paths
// from the StreamSettings before sending to a remote node, but ONLY when
// inline certificate content (certificate / key) is also present in the same
// entry. In that case the file paths are redundant and stripping them avoids
// 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.
// from the StreamSettings before sending to a remote node. File paths
// (certificateFile / keyFile) are local to the main panel's filesystem
// and will cause Xray on the remote node to crash if they don't exist there.
// Inline certificate content (certificate / key) is kept intact.
func sanitizeStreamSettingsForRemote(streamSettings string) string {
if streamSettings == "" {
return streamSettings
@ -373,40 +368,18 @@ func sanitizeStreamSettingsForRemote(streamSettings string) string {
return streamSettings
}
changed := false
for _, cert := range certificates {
c, ok := cert.(map[string]any)
if !ok {
continue
}
// Only strip file paths when inline content is present so that the
// 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
}
delete(c, "certificateFile")
delete(c, "keyFile")
}
if !changed {
return streamSettings
}
out, err := json.Marshal(stream)
if err != nil {
return streamSettings
}
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

@ -1,96 +0,0 @@
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

@ -278,31 +278,11 @@ func (s *InboundService) checkEmailsExistForClients(clients []model.Client) (str
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.
// 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.
// Returns the created inbound, whether Xray needs restart, and any 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)
if err != nil {
return inbound, false, err
@ -521,19 +501,6 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
return true, nil
}
// Remote nodes interpret DelInbound as a real row delete (it hits
// panel/api/inbounds/del/:id on the remote), so toggling the enable
// switch on a remote inbound used to wipe the row entirely (#4402).
// PATCH the remote row via UpdateInbound instead — preserves the
// settings/client history and just flips the enable flag.
if inbound.NodeID != nil {
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
logger.Debug("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
return false, err
}
return false, nil
}
if err := rt.DelInbound(context.Background(), inbound); err != nil &&
!strings.Contains(err.Error(), "not found") {
logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
@ -543,22 +510,26 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
return needRestart, nil
}
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
if err != nil {
logger.Debug("SetInboundEnable: build runtime config failed:", err)
return true, nil
addTarget := inbound
if inbound.NodeID == nil {
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
if err != nil {
logger.Debug("SetInboundEnable: build runtime config failed:", err)
return true, nil
}
addTarget = runtimeInbound
}
if err := rt.AddInbound(context.Background(), runtimeInbound); err != nil {
if err := rt.AddInbound(context.Background(), addTarget); err != nil {
logger.Debug("SetInboundEnable: AddInbound on", rt.Name(), "failed:", err)
if inbound.NodeID != nil {
return false, err
}
needRestart = true
}
return needRestart, nil
}
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)
if err != nil {
return inbound, false, err
@ -1545,13 +1516,6 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
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) {
var structuralChange bool
err := submitTrafficWrite(func() error {
@ -1893,9 +1857,15 @@ func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic
func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) (err error) {
if len(traffics) == 0 {
// Empty onlineUsers
if p != nil {
p.SetOnlineClients(make([]string, 0))
}
return nil
}
onlineClients := make([]string, 0)
emails := make([]string, 0, len(traffics))
for _, traffic := range traffics {
emails = append(emails, traffic.Email)
@ -1938,10 +1908,14 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
dbClientTraffics[dbTraffic_index].Down += t.Down
dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down
if t.Up+t.Down > 0 {
onlineClients = append(onlineClients, t.Email)
dbClientTraffics[dbTraffic_index].LastOnline = now
}
}
// Set onlineUsers
p.SetOnlineClients(onlineClients)
err = tx.Save(dbClientTraffics).Error
if err != nil {
logger.Warning("AddClientTraffic update data ", err)
@ -3767,19 +3741,6 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
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) {
db := database.GetDB()

View file

@ -190,7 +190,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
wg.Add(1)
go func(i int) {
defer wg.Done()
results[i] = probeEndpoint(endpoints[i], 5*time.Second)
results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
}(i)
}
wg.Wait()
@ -207,11 +207,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
}
}
mode := "tcp"
if endpoints[0].Network == "udp" {
mode = "udp"
}
out := &TestOutboundResult{Mode: mode, Endpoints: results}
out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
if bestDelay >= 0 {
out.Success = true
out.Delay = bestDelay
@ -224,22 +220,6 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
return out, nil
}
// outboundEndpoint is a host:port plus the transport its proxy actually
// listens on. WireGuard (and WARP, which is WireGuard) is UDP-only, so a
// TCP dial to its peer endpoint always times out — the probe must match
// the transport of the outbound being tested.
type outboundEndpoint struct {
Address string
Network string
}
func probeEndpoint(ep outboundEndpoint, timeout time.Duration) TestEndpointResult {
if ep.Network == "udp" {
return probeUDPEndpoint(ep.Address, timeout)
}
return probeTCPEndpoint(ep.Address, timeout)
}
func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
r := TestEndpointResult{Address: endpoint}
start := time.Now()
@ -254,69 +234,18 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
return r
}
// probeUDPEndpoint sends a single byte and waits briefly for a reply or
// an ICMP-driven error. WireGuard won't answer an unauthenticated byte,
// so a read timeout is the normal "endpoint reachable" outcome; a
// concrete error (e.g. ECONNREFUSED, "host unreachable") fails the probe.
func probeUDPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
r := TestEndpointResult{Address: endpoint}
start := time.Now()
conn, err := net.DialTimeout("udp", endpoint, timeout)
if err != nil {
r.Delay = time.Since(start).Milliseconds()
r.Error = err.Error()
return r
}
defer conn.Close()
if _, werr := conn.Write([]byte{0}); werr != nil {
r.Delay = time.Since(start).Milliseconds()
r.Error = werr.Error()
return r
}
_ = conn.SetReadDeadline(time.Now().Add(timeout))
buf := make([]byte, 64)
_, rerr := conn.Read(buf)
r.Delay = time.Since(start).Milliseconds()
if rerr != nil {
if nerr, ok := rerr.(net.Error); ok && nerr.Timeout() {
r.Success = true
return r
}
r.Error = rerr.Error()
return r
}
r.Success = true
return r
}
func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
func extractOutboundEndpoints(ob map[string]any) []string {
protocol, _ := ob["protocol"].(string)
settings, _ := ob["settings"].(map[string]any)
if settings == nil {
return nil
}
// Hysteria (and hysteria2 over trojan) is QUIC/UDP. Detect it via the
// outer protocol or via streamSettings.network so trojan-with-hysteria2
// transport gets probed over UDP too. kcp and quic are also UDP-based.
network := "tcp"
if protocol == "hysteria" || protocol == "wireguard" {
network = "udp"
}
if stream, ok := ob["streamSettings"].(map[string]any); ok {
if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
network = "udp"
}
}
var out []outboundEndpoint
var out []string
addServer := func(addr any, port any) {
host, _ := addr.(string)
p := numAsInt(port)
if host != "" && p > 0 {
out = append(out, outboundEndpoint{Address: fmt.Sprintf("%s:%d", host, p), Network: network})
out = append(out, fmt.Sprintf("%s:%d", host, p))
}
}
switch protocol {
@ -330,8 +259,6 @@ func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
}
case "vless":
addServer(settings["address"], settings["port"])
case "hysteria":
addServer(settings["address"], settings["port"])
case "trojan", "shadowsocks", "http", "socks":
if servers, ok := settings["servers"].([]any); ok {
for _, sv := range servers {
@ -345,7 +272,7 @@ func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
for _, p := range peers {
if pm, ok := p.(map[string]any); ok {
if ep, _ := pm["endpoint"].(string); ep != "" {
out = append(out, outboundEndpoint{Address: ep, Network: network})
out = append(out, ep)
}
}
}

View file

@ -1398,25 +1398,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
return
}
t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
case "add_client_set_flow":
if dataArray[1] == "none" {
client_Flow = ""
} else {
client_Flow = dataArray[1]
}
messageId := callbackQuery.Message.GetMessageID()
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
case "add_client_ip_limit_in":
@ -1884,22 +1865,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
),
)
t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
case "add_client_ch_default_flow":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("None").WithCallbackData(t.encodeQuery("add_client_set_flow none")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("xtls-rprx-vision").WithCallbackData(t.encodeQuery("add_client_set_flow xtls-rprx-vision")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("xtls-rprx-vision-udp443").WithCallbackData(t.encodeQuery("add_client_set_flow xtls-rprx-vision-udp443")),
),
)
t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
case "add_client_ch_default_ip_limit":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
@ -3380,25 +3345,6 @@ func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
}
}
// inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend
// model: xtls-rprx-vision is only valid on VLESS-over-TCP with TLS or Reality.
func inboundCanEnableTlsFlow(ib *model.Inbound) bool {
if ib == nil || ib.Protocol != model.VLESS {
return false
}
var stream struct {
Network string `json:"network"`
Security string `json:"security"`
}
if err := json.Unmarshal([]byte(ib.StreamSettings), &stream); err != nil {
return false
}
if stream.Network != "tcp" {
return false
}
return stream.Security == "tls" || stream.Security == "reality"
}
// addClient handles the process of adding a new client to an inbound.
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
@ -3411,31 +3357,13 @@ func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
var protocolRows [][]telego.InlineKeyboardButton
switch protocol {
case model.VMESS:
case model.VMESS, model.VLESS:
protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
),
}
case model.VLESS:
protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
),
}
if inboundCanEnableTlsFlow(inbound) {
flowLabel := t.I18nBot("tgbot.buttons.change_flow")
if client_Flow != "" {
flowLabel = flowLabel + ": " + client_Flow
}
protocolRows = append(protocolRows, tu.InlineKeyboardRow(
tu.InlineKeyboardButton(flowLabel).WithCallbackData("add_client_ch_default_flow"),
))
} else if client_Flow != "" {
client_Flow = ""
}
case model.Trojan:
protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(

View file

@ -418,8 +418,6 @@
"apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في الإعدادات → توكن API.",
"regenerate": "تجديد التوكن",
"regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
"allowPrivateAddress": "السماح بالعنوان الخاص",
"allowPrivateAddressHint": "التفعيل فقط للعقد على شبكة خاصة أو VPN.",
"enable": "مفعل",
"status": "الحالة",
"cpu": "المعالج",
@ -952,7 +950,6 @@
"change_password": "⚙️🔑 كلمة السر",
"change_email": "⚙️📧 البريد الإلكتروني",
"change_comment": "⚙️💬 تعليق",
"change_flow": "⚙️🚦 التدفق",
"ResetAllTraffics": "إعادة ضبط جميع الترافيك",
"SortedTrafficUsageReport": "تقرير استخدام الترافيك المرتب"
},

View file

@ -32,7 +32,7 @@
"delete": "Delete",
"reset": "Reset",
"noData": "No data.",
"copySuccess": "Copied successfully",
"copySuccess": "Copied Successful",
"sure": "Sure",
"encryption": "Encryption",
"useIPv4ForHost": "Use IPv4 for host",
@ -52,14 +52,14 @@
"certificate": "Digital Certificate",
"fail": "Failed",
"comment": "Comment",
"success": "Success",
"success": "Successfully",
"lastOnline": "Last Online",
"getVersion": "Get Version",
"install": "Install",
"clients": "Clients",
"usage": "Usage",
"twoFactorCode": "Code",
"remained": "Remaining",
"remained": "Remained",
"security": "Security",
"secAlertTitle": "Security Alert",
"secAlertSsl": "This connection is not secure. Please avoid entering sensitive information until TLS is activated for data protection.",
@ -107,7 +107,7 @@
"title": "Welcome",
"loginAgain": "Your session has expired, please log in again",
"toasts": {
"invalidFormData": "The input data format is invalid.",
"invalidFormData": "The Input data format is invalid.",
"emptyUsername": "Username is required",
"emptyPassword": "Password is required",
"wrongUsernameOrPassword": "Invalid username or password or two-factor code.",
@ -138,7 +138,7 @@
"upToDate": "Up to date",
"xrayStatusUnknown": "Unknown",
"xrayStatusRunning": "Running",
"xrayStatusStop": "Stopped",
"xrayStatusStop": "Stop",
"xrayStatusError": "Error",
"xrayErrorPopoverTitle": "An error occurred while running Xray",
"operationHours": "Uptime",
@ -238,7 +238,7 @@
},
"inbounds": {
"allTimeTraffic": "All-time Traffic",
"allTimeTrafficUsage": "All-Time Total Usage",
"allTimeTrafficUsage": "All Time Total Usage",
"title": "Inbounds",
"totalDownUp": "Total Sent/Received",
"totalUsage": "Total Usage",
@ -263,9 +263,9 @@
"generalActions": "General Actions",
"modifyInbound": "Modify Inbound",
"deleteInbound": "Delete Inbound",
"deleteInboundContent": "Are you sure you want to delete this inbound?",
"deleteInboundContent": "Are you sure you want to delete inbound?",
"deleteClient": "Delete Client",
"deleteClientContent": "Are you sure you want to delete this client?",
"deleteClientContent": "Are you sure you want to delete client?",
"resetTrafficContent": "Are you sure you want to reset traffic?",
"copyLink": "Copy URL",
"address": "Address",
@ -288,14 +288,14 @@
"cloneInbound": "Clone",
"cloneInboundContent": "All settings of this inbound, except Port, Listening IP, and Clients, will be applied to the clone.",
"cloneInboundOk": "Clone",
"resetAllTraffic": "Reset Traffic for All Inbounds",
"resetAllTrafficTitle": "Reset Traffic for All Inbounds",
"resetAllTraffic": "Reset All Inbounds Traffic",
"resetAllTrafficTitle": "Reset All Inbounds Traffic",
"resetAllTrafficContent": "Are you sure you want to reset the traffic of all inbounds?",
"resetInboundClientTraffics": "Reset Clients' Traffic",
"resetInboundClientTrafficTitle": "Reset Clients' Traffic",
"resetInboundClientTraffics": "Reset Clients Traffic",
"resetInboundClientTrafficTitle": "Reset Clients Traffic",
"resetInboundClientTrafficContent": "Are you sure you want to reset the traffic of this inbound's clients?",
"resetAllClientTraffics": "Reset All Clients' Traffic",
"resetAllClientTrafficTitle": "Reset All Clients' Traffic",
"resetAllClientTraffics": "Reset All Clients Traffic",
"resetAllClientTrafficTitle": "Reset All Clients Traffic",
"resetAllClientTrafficContent": "Are you sure you want to reset the traffic of all clients?",
"delDepletedClients": "Delete Depleted Clients",
"delDepletedClientsTitle": "Delete Depleted Clients",
@ -305,8 +305,8 @@
"IPLimit": "IP Limit",
"IPLimitDesc": "Disables inbound if the count exceeds the set value. (0 = disable)",
"IPLimitlog": "IP Log",
"IPLimitlogDesc": "The IP history log. (to re-enable the inbound after disabling, clear the log)",
"IPLimitlogclear": "Clear the Log",
"IPLimitlogDesc": "The IPs history log. (to enable inbound after disabling, clear the log)",
"IPLimitlogclear": "Clear The Log",
"setDefaultCert": "Set Cert from Panel",
"telegramDesc": "Please provide Telegram Chat ID. (use '/id' command in the bot) or ({'@'}userinfobot)",
"subscriptionDesc": "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients.",
@ -337,12 +337,12 @@
"inboundClientAddSuccess": "Inbound client(s) have been added.",
"inboundClientDeleteSuccess": "Inbound client has been deleted.",
"inboundClientUpdateSuccess": "Inbound client has been updated.",
"delDepletedClientsSuccess": "All depleted clients have been deleted.",
"resetAllClientTrafficSuccess": "Traffic for all clients has been reset.",
"delDepletedClientsSuccess": "All depleted clients are deleted.",
"resetAllClientTrafficSuccess": "All traffic from the client has been reset.",
"resetAllTrafficSuccess": "All traffic has been reset.",
"resetInboundClientTrafficSuccess": "Traffic has been reset.",
"resetInboundTrafficSuccess": "Inbound traffic has been reset.",
"trafficGetError": "Error getting traffic.",
"trafficGetError": "Error getting traffics.",
"getNewX25519CertError": "Error while obtaining the X25519 certificate.",
"getNewmldsa65Error": "Error while obtaining mldsa65.",
"getNewVlessEncError": "Error while obtaining VlessEnc."
@ -418,8 +418,6 @@
"apiTokenHint": "The remote panel exposes its API token under Settings → API Token.",
"regenerate": "Regenerate Token",
"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",
"status": "Status",
"cpu": "CPU",
@ -526,7 +524,7 @@
"subProfileUrl": "Profile URL",
"subProfileUrlDesc": "A link to your website displayed in the VPN client",
"subAnnounce": "Announce",
"subAnnounceDesc": "The announcement text displayed in the VPN client",
"subAnnounceDesc": "The text of the announce displayed in the VPN client",
"subEnableRouting": "Enable routing",
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
"subRoutingRules": "Routing rules",
@ -570,7 +568,7 @@
"direct": "Direct Connection",
"directDesc": "Directly establishes connections with domains or IP ranges of a specific country.",
"notifications": "Notifications",
"certs": "Certificates",
"certs": "Certificaties",
"externalTraffic": "External Traffic",
"dateAndTime": "Date and Time",
"proxyAndServer": "Proxy and Server",
@ -608,10 +606,10 @@
"getSettings": "An error occurred while retrieving parameters.",
"modifyUserError": "An error occurred while changing administrator credentials.",
"modifyUser": "You have successfully changed the credentials of the administrator.",
"originalUserPassIncorrect": "The current username or password is invalid",
"userPassMustBeNotEmpty": "The new username and password are empty",
"getOutboundTrafficError": "Error getting traffic",
"resetOutboundTrafficError": "Error resetting outbound traffic"
"originalUserPassIncorrect": "The сurrent username or password is invalid",
"userPassMustBeNotEmpty": "The new username and password is empty",
"getOutboundTrafficError": "Error getting traffics",
"resetOutboundTrafficError": "Error in reset outbound traffics"
}
},
"xray": {
@ -662,9 +660,9 @@
"logLevel": "Log Level",
"logLevelDesc": "The log level for error logs, indicating the information that needs to be recorded.",
"accessLog": "Access Log",
"accessLogDesc": "The file path for the access log. The special value 'none' disables access logs",
"accessLogDesc": "The file path for the access log. The special value 'none' disabled access logs",
"errorLog": "Error Log",
"errorLogDesc": "The file path for the error log. The special value 'none' disables error logs",
"errorLogDesc": "The file path for the error log. The special value 'none' disabled error logs",
"dnsLog": "DNS Log",
"dnsLogDesc": "Whether to enable DNS query logs",
"maskAddress": "Mask Address",
@ -774,7 +772,7 @@
"edit": "Edit Server",
"domains": "Domains",
"expectIPs": "Expect IPs",
"unexpectIPs": "Unexpected IPs",
"unexpectIPs": "Unexpect IPs",
"useSystemHosts": "Use System Hosts",
"useSystemHostsDesc": "Use the hosts file from an installed system",
"serveStale": "Serve Stale",
@ -896,10 +894,10 @@
"received_password": "🔑📥 Password updated.",
"received_email": "📧📥 Email updated.",
"received_comment": "💬📥 Comment updated.",
"id_prompt": "🔑 Default ID: {{ .ClientId }}\n\nEnter your ID.",
"id_prompt": "🔑 Default ID: {{ .ClientId }}\n\nEnter your id.",
"pass_prompt": "🔑 Default Password: {{ .ClientPassword }}\n\nEnter your password.",
"email_prompt": "📧 Default Email: {{ .ClientEmail }}\n\nEnter your email.",
"comment_prompt": "💬 Default Comment: {{ .ClientComment }}\n\nEnter your comment.",
"comment_prompt": "💬 Default Comment: {{ .ClientComment }}\n\nEnter your Comment.",
"inbound_client_data_id": "🔄 Inbound: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Traffic: {{ .ClientTraffic }}\n📅 Expire Date: {{ .ClientExp }}\n🌐 IP Limit: {{ .IpLimit }}\n💬 Comment: {{ .ClientComment }}\n\nYou can add the client to inbound now!",
"inbound_client_data_pass": "🔄 Inbound: {{ .InboundRemark }}\n\n🔑 Password: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Traffic: {{ .ClientTraffic }}\n📅 Expire Date: {{ .ClientExp }}\n🌐 IP Limit: {{ .IpLimit }}\n💬 Comment: {{ .ClientComment }}\n\nYou can add the client to inbound now!",
"cancel": "❌ Process Canceled! \n\nYou can /start again anytime. 🔄",
@ -952,8 +950,7 @@
"change_password": "⚙️🔑 Password",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Comment",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Reset All Traffic",
"ResetAllTraffics": "Reset All Traffics",
"SortedTrafficUsageReport": "Sorted Traffic Usage Report"
},
"answers": {

View file

@ -418,8 +418,6 @@
"apiTokenHint": "El panel remoto expone su token de API en Configuración → Token de API.",
"regenerate": "Regenerar token",
"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",
"status": "Estado",
"cpu": "CPU",
@ -952,7 +950,6 @@
"change_password": "⚙️🔑 Contraseña",
"change_email": "⚙️📧 Correo electrónico",
"change_comment": "⚙️💬 Comentario",
"change_flow": "⚙️🚦 Flujo",
"ResetAllTraffics": "Reiniciar todo el tráfico",
"SortedTrafficUsageReport": "Informe de uso de tráfico ordenado"
},

View file

@ -418,8 +418,6 @@
"apiTokenHint": "پنل ریموت توکن API خودش را در بخش تنظیمات → توکن API نمایش می‌دهد.",
"regenerate": "تولید مجدد توکن",
"regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
"allowPrivateAddress": "اجازه آدرس خصوصی",
"allowPrivateAddressHint": "فقط برای نودهای روی شبکه خصوصی یا VPN فعال شود.",
"enable": "فعال",
"status": "وضعیت",
"cpu": "پردازنده",
@ -952,7 +950,6 @@
"change_password": "⚙️🔑 گذرواژه",
"change_email": "⚙️📧 ایمیل",
"change_comment": "⚙️💬 نظر",
"change_flow": "⚙️🚦 جریان",
"ResetAllTraffics": "بازنشانی همه ترافیک‌ها",
"SortedTrafficUsageReport": "گزارش استفاده از ترافیک مرتب‌شده"
},

View file

@ -418,8 +418,6 @@
"apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Pengaturan → Token API.",
"regenerate": "Buat Ulang Token",
"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",
"status": "Status",
"cpu": "CPU",
@ -952,7 +950,6 @@
"change_password": "⚙️🔑 Kata Sandi",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Komentar",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Reset Semua Lalu Lintas",
"SortedTrafficUsageReport": "Laporan Penggunaan Lalu Lintas yang Terurut"
},

View file

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

View file

@ -418,8 +418,6 @@
"apiTokenHint": "O painel remoto exibe o token da API em Configurações → Token da API.",
"regenerate": "Regenerar token",
"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",
"status": "Status",
"cpu": "CPU",
@ -952,7 +950,6 @@
"change_password": "⚙️🔑 Senha",
"change_email": "⚙️📧 E-mail",
"change_comment": "⚙️💬 Comentário",
"change_flow": "⚙️🚦 Fluxo",
"ResetAllTraffics": "Redefinir Todo o Tráfego",
"SortedTrafficUsageReport": "Relatório de Uso de Tráfego Ordenado"
},

View file

@ -418,8 +418,6 @@
"apiTokenHint": "Удалённая панель показывает свой токен API в разделе Настройки → Токен API.",
"regenerate": "Сгенерировать токен заново",
"regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
"allowPrivateAddress": "Разрешить частный адрес",
"allowPrivateAddressHint": "Включить только для узлов в частной сети или VPN.",
"enable": "Включён",
"status": "Статус",
"cpu": "CPU",
@ -952,7 +950,6 @@
"change_password": "⚙️🔑 Пароль",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Комментарий",
"change_flow": "⚙️🚦 Поток",
"ResetAllTraffics": "Сбросить весь трафик",
"SortedTrafficUsageReport": "Отсортированный отчет об использовании трафика"
},

View file

@ -418,8 +418,6 @@
"apiTokenHint": "Uzak panel API token'ını Ayarlar → API Token altında gösterir.",
"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?",
"allowPrivateAddress": "Özel adrese izin ver",
"allowPrivateAddressHint": "Yalnızca özel ağ veya VPN üzerindeki düğümler için etkinleştir.",
"enable": "Etkin",
"status": "Durum",
"cpu": "CPU",
@ -952,7 +950,6 @@
"change_password": "⚙️🔑 Şifre",
"change_email": "⚙️📧 E-posta",
"change_comment": "⚙️💬 Yorum",
"change_flow": "⚙️🚦 Akış",
"ResetAllTraffics": "Tüm Trafikleri Sıfırla",
"SortedTrafficUsageReport": "Sıralı Trafik Kullanım Raporu"
},

View file

@ -418,8 +418,6 @@
"apiTokenHint": "Віддалена панель показує свій токен API в Налаштуваннях → Токен API.",
"regenerate": "Перегенерувати токен",
"regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
"allowPrivateAddress": "Дозволити приватну адресу",
"allowPrivateAddressHint": "Увімкнути лише для вузлів у приватній мережі або VPN.",
"enable": "Увімкнено",
"status": "Статус",
"cpu": "CPU",
@ -952,7 +950,6 @@
"change_password": "⚙️🔑 Пароль",
"change_email": "⚙️📧 Електронна пошта",
"change_comment": "⚙️💬 Коментар",
"change_flow": "⚙️🚦 Потік",
"ResetAllTraffics": "Скинути весь трафік",
"SortedTrafficUsageReport": "Відсортований звіт про використання трафіку"
},

View file

@ -418,8 +418,6 @@
"apiTokenHint": "Panel từ xa hiển thị token API tại Cài đặt → Token API.",
"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?",
"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",
"status": "Trạng thái",
"cpu": "CPU",
@ -952,7 +950,6 @@
"change_password": "⚙️🔑 Mật Khẩu",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Bình Luận",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Đặt lại tất cả lưu lượng",
"SortedTrafficUsageReport": "Báo cáo sử dụng lưu lượng đã sắp xếp"
},

View file

@ -418,8 +418,6 @@
"apiTokenHint": "远程面板在 设置 → API 令牌 中显示其 API 令牌。",
"regenerate": "重新生成令牌",
"regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
"allowPrivateAddress": "允许私有地址",
"allowPrivateAddressHint": "仅对私有网络或VPN上的节点启用。",
"enable": "已启用",
"status": "状态",
"cpu": "CPU",
@ -952,7 +950,6 @@
"change_password": "⚙️🔑 密码",
"change_email": "⚙️📧 邮箱",
"change_comment": "⚙️💬 评论",
"change_flow": "⚙️🚦 流控",
"ResetAllTraffics": "重置所有流量",
"SortedTrafficUsageReport": "排序的流量使用报告"
},

View file

@ -418,8 +418,6 @@
"apiTokenHint": "遠端面板在 設定 → API 權杖 中顯示其 API 權杖。",
"regenerate": "重新產生權杖",
"regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
"allowPrivateAddress": "允許私有地址",
"allowPrivateAddressHint": "僅對私有網路或VPN上的節點啟用。",
"enable": "已啟用",
"status": "狀態",
"cpu": "CPU",
@ -952,7 +950,6 @@
"change_password": "⚙️🔑 密碼",
"change_email": "⚙️📧 電子郵件",
"change_comment": "⚙️💬 評論",
"change_flow": "⚙️🚦 流控",
"ResetAllTraffics": "重設所有流量",
"SortedTrafficUsageReport": "排序過的流量使用報告"
},

View file

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

View file

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