Merge branch 'main' into feature/multi-server-support

This commit is contained in:
Sanaei 2025-09-19 13:24:09 +02:00 committed by GitHub
commit edd8b12988
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
95 changed files with 4840 additions and 2831 deletions

2
.github/FUNDING.yml vendored
View file

@ -11,4 +11,4 @@ issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username polar: # Replace with a single Polar username
buy_me_a_coffee: mhsanaei buy_me_a_coffee: mhsanaei
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] custom: https://nowpayments.io/donation/hsanaei

35
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,35 @@
{
"$schema": "vscode://schemas/launch",
"version": "0.2.0",
"configurations": [
{
"name": "Run 3x-ui (Debug)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "${workspaceFolder}",
"env": {
"XUI_DEBUG": "true"
},
"console": "integratedTerminal"
},
{
"name": "Run 3x-ui (Debug, custom env)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "${workspaceFolder}",
"env": {
// Set to true to serve assets/templates directly from disk for development
"XUI_DEBUG": "true",
// Uncomment to override DB folder location (by default uses working dir on Windows when debug)
// "XUI_DB_FOLDER": "${workspaceFolder}",
// Example: override log level (debug|info|notice|warn|error)
// "XUI_LOG_LEVEL": "debug"
},
"console": "integratedTerminal"
}
]
}

40
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,40 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "go: build",
"type": "shell",
"command": "go",
"args": ["build", "-o", "bin/3x-ui.exe", "./main.go"],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": ["$go"],
"group": { "kind": "build", "isDefault": true }
},
{
"label": "go: run",
"type": "shell",
"command": "go",
"args": ["run", "./main.go"],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"XUI_DEBUG": "true"
}
},
"problemMatcher": ["$go"]
},
{
"label": "go: test",
"type": "shell",
"command": "go",
"args": ["test", "./..."],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": ["$go"],
"group": "test"
}
]
}

View file

@ -7,11 +7,13 @@
</picture> </picture>
</p> </p>
[![](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/releases) [![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/actions) [![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg?style=for-the-badge)](#) [![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/releases/latest) [![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true&style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0.en.html) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v2.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v2)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
**3X-UI** — لوحة تحكم متقدمة مفتوحة المصدر تعتمد على الويب مصممة لإدارة خادم Xray-core. توفر واجهة سهلة الاستخدام لتكوين ومراقبة بروتوكولات VPN والوكيل المختلفة. **3X-UI** — لوحة تحكم متقدمة مفتوحة المصدر تعتمد على الويب مصممة لإدارة خادم Xray-core. توفر واجهة سهلة الاستخدام لتكوين ومراقبة بروتوكولات VPN والوكيل المختلفة.
@ -41,15 +43,13 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
**إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2: **إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2:
<p align="left"> <a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<a href="https://buymeacoffee.com/mhsanaei" target="_blank"> <img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
<img src="./media/buymeacoffe.png" alt="Image"> </a>
</a> </br>
</p> <a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC` </a>
- POL (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A`
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv`
## النجوم عبر الزمن ## النجوم عبر الزمن

View file

@ -7,11 +7,13 @@
</picture> </picture>
</p> </p>
[![](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/releases) [![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/actions) [![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg?style=for-the-badge)](#) [![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/releases/latest) [![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true&style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0.en.html) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v2.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v2)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
**3X-UI** — panel de control avanzado basado en web de código abierto diseñado para gestionar el servidor Xray-core. Ofrece una interfaz fácil de usar para configurar y monitorear varios protocolos VPN y proxy. **3X-UI** — panel de control avanzado basado en web de código abierto diseñado para gestionar el servidor Xray-core. Ofrece una interfaz fácil de usar para configurar y monitorear varios protocolos VPN y proxy.
@ -41,15 +43,14 @@ Para documentación completa, visita la [Wiki del proyecto](https://github.com/M
**Si este proyecto te es útil, puedes darle una**:star2: **Si este proyecto te es útil, puedes darle una**:star2:
<p align="left"> <a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<a href="https://buymeacoffee.com/mhsanaei" target="_blank"> <img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
<img src="./media/buymeacoffe.png" alt="Image"> </a>
</a>
</p>
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC` </br>
- POL (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A` <a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv` <img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Estrellas a lo Largo del Tiempo ## Estrellas a lo Largo del Tiempo

View file

@ -7,11 +7,13 @@
</picture> </picture>
</p> </p>
[![](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/releases) [![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/actions) [![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg?style=for-the-badge)](#) [![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/releases/latest) [![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true&style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0.en.html) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v2.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v2)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
**3X-UI** — یک پنل کنترل پیشرفته مبتنی بر وب با کد باز که برای مدیریت سرور Xray-core طراحی شده است. این پنل یک رابط کاربری آسان برای پیکربندی و نظارت بر پروتکل‌های مختلف VPN و پراکسی ارائه می‌دهد. **3X-UI** — یک پنل کنترل پیشرفته مبتنی بر وب با کد باز که برای مدیریت سرور Xray-core طراحی شده است. این پنل یک رابط کاربری آسان برای پیکربندی و نظارت بر پروتکل‌های مختلف VPN و پراکسی ارائه می‌دهد.
@ -41,15 +43,14 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
**اگر این پروژه برای شما مفید است، می‌توانید به آن یک**:star2: بدهید **اگر این پروژه برای شما مفید است، می‌توانید به آن یک**:star2: بدهید
<p align="left"> <a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<a href="https://buymeacoffee.com/mhsanaei" target="_blank"> <img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
<img src="./media/buymeacoffe.png" alt="Image"> </a>
</a>
</p>
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC` </br>
- POL (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A` <a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv` <img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## ستاره‌ها در طول زمان ## ستاره‌ها در طول زمان

View file

@ -7,11 +7,13 @@
</picture> </picture>
</p> </p>
[![](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/releases) [![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/actions) [![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg?style=for-the-badge)](#) [![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/releases/latest) [![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true&style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0.en.html) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v2.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v2)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
**3X-UI** — advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols. **3X-UI** — advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols.
@ -41,15 +43,14 @@ For full documentation, please visit the [project Wiki](https://github.com/MHSan
**If this project is helpful to you, you may wish to give it a**:star2: **If this project is helpful to you, you may wish to give it a**:star2:
<p align="left"> <a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<a href="https://buymeacoffee.com/mhsanaei" target="_blank"> <img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
<img src="./media/buymeacoffe.png" alt="Image"> </a>
</a>
</p>
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC` </br>
- POL (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A` <a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv` <img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Stargazers over Time ## Stargazers over Time

View file

@ -7,11 +7,13 @@
</picture> </picture>
</p> </p>
[![](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/releases) [![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/actions) [![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg?style=for-the-badge)](#) [![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/releases/latest) [![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true&style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0.en.html) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v2.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v2)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
**3X-UI** — продвинутая панель управления с открытым исходным кодом на основе веб-интерфейса, разработанная для управления сервером Xray-core. Предоставляет удобный интерфейс для настройки и мониторинга различных VPN и прокси-протоколов. **3X-UI** — продвинутая панель управления с открытым исходным кодом на основе веб-интерфейса, разработанная для управления сервером Xray-core. Предоставляет удобный интерфейс для настройки и мониторинга различных VPN и прокси-протоколов.
@ -41,15 +43,14 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
**Если этот проект полезен для вас, вы можете поставить ему**:star2: **Если этот проект полезен для вас, вы можете поставить ему**:star2:
<p align="left"> <a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<a href="https://buymeacoffee.com/mhsanaei" target="_blank"> <img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
<img src="./media/buymeacoffe.png" alt="Image"> </a>
</a>
</p>
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC` </br>
- POL (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A` <a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv` <img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Звезды с течением времени ## Звезды с течением времени

View file

@ -7,11 +7,13 @@
</picture> </picture>
</p> </p>
[![](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/releases) [![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/actions) [![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg?style=for-the-badge)](#) [![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg?style=for-the-badge)](https://github.com/MHSanaei/3x-ui/releases/latest) [![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true&style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0.en.html) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v2.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v2)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
**3X-UI** — 一个基于网页的高级开源控制面板,专为管理 Xray-core 服务器而设计。它提供了用户友好的界面,用于配置和监控各种 VPN 和代理协议。 **3X-UI** — 一个基于网页的高级开源控制面板,专为管理 Xray-core 服务器而设计。它提供了用户友好的界面,用于配置和监控各种 VPN 和代理协议。
@ -41,15 +43,14 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
**如果这个项目对您有帮助,您可以给它一个**:star2: **如果这个项目对您有帮助,您可以给它一个**:star2:
<p align="left"> <a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<a href="https://buymeacoffee.com/mhsanaei" target="_blank"> <img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
<img src="./media/buymeacoffe.png" alt="Image"> </a>
</a>
</p>
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC` </br>
- POL (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A` <a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv` <img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## 随时间变化的星标数 ## 随时间变化的星标数

View file

@ -1 +1 @@
2.7.0 2.8.2

View file

@ -9,10 +9,10 @@ import (
"path" "path"
"slices" "slices"
"x-ui/config" "github.com/mhsanaei/3x-ui/v2/config"
"x-ui/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"x-ui/util/crypto" "github.com/mhsanaei/3x-ui/v2/util/crypto"
"x-ui/xray" "github.com/mhsanaei/3x-ui/v2/xray"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
@ -142,6 +142,9 @@ func InitDB(dbPath string) error {
} }
isUsersEmpty, err := isTableEmpty("users") isUsersEmpty, err := isTableEmpty("users")
if err != nil {
return err
}
if err := initUser(); err != nil { if err := initUser(); err != nil {
return err return err

View file

@ -3,8 +3,8 @@ package model
import ( import (
"fmt" "fmt"
"x-ui/util/json_util" "github.com/mhsanaei/3x-ui/v2/util/json_util"
"x-ui/xray" "github.com/mhsanaei/3x-ui/v2/xray"
) )
type Protocol string type Protocol string
@ -27,16 +27,18 @@ type User struct {
} }
type Inbound struct { type Inbound struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
UserId int `json:"-"` UserId int `json:"-"`
Up int64 `json:"up" form:"up"` Up int64 `json:"up" form:"up"`
Down int64 `json:"down" form:"down"` Down int64 `json:"down" form:"down"`
Total int64 `json:"total" form:"total"` Total int64 `json:"total" form:"total"`
AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"` AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"`
Remark string `json:"remark" form:"remark"` Remark string `json:"remark" form:"remark"`
Enable bool `json:"enable" form:"enable"` Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"`
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"`
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`
// config part // config part
Listen string `json:"listen" form:"listen"` Listen string `json:"listen" form:"listen"`

5
go.mod
View file

@ -1,4 +1,4 @@
module x-ui module github.com/mhsanaei/3x-ui/v2
go 1.25.1 go 1.25.1
@ -15,11 +15,13 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.2.4
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.25.8 github.com/shirou/gopsutil/v4 v4.25.8
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.65.0 github.com/valyala/fasthttp v1.65.0
github.com/xlzd/gotp v0.1.0 github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.250911.0 github.com/xtls/xray-core v1.250911.0
go.uber.org/atomic v1.11.0 go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.42.0 golang.org/x/crypto v0.42.0
golang.org/x/sys v0.36.0
golang.org/x/text v0.29.0 golang.org/x/text v0.29.0
google.golang.org/grpc v1.75.1 google.golang.org/grpc v1.75.1
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
@ -89,7 +91,6 @@ require (
golang.org/x/mod v0.28.0 // indirect golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.44.0 // indirect golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/time v0.13.0 // indirect golang.org/x/time v0.13.0 // indirect
golang.org/x/tools v0.36.0 // indirect golang.org/x/tools v0.36.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect

2
go.sum
View file

@ -142,6 +142,8 @@ github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 h1:emzAzMZ1
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771/go.mod h1:bR6DqgcAl1zTcOX8/pE2Qkj9XO00eCNqmKb7lXP8EAg= github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771/go.mod h1:bR6DqgcAl1zTcOX8/pE2Qkj9XO00eCNqmKb7lXP8EAg=
github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

16
main.go
View file

@ -9,14 +9,14 @@ import (
"syscall" "syscall"
_ "unsafe" _ "unsafe"
"x-ui/config" "github.com/mhsanaei/3x-ui/v2/config"
"x-ui/database" "github.com/mhsanaei/3x-ui/v2/database"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/sub" "github.com/mhsanaei/3x-ui/v2/sub"
"x-ui/util/crypto" "github.com/mhsanaei/3x-ui/v2/util/crypto"
"x-ui/web" "github.com/mhsanaei/3x-ui/v2/web"
"x-ui/web/global" "github.com/mhsanaei/3x-ui/v2/web/global"
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/op/go-logging" "github.com/op/go-logging"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

BIN
media/default-yellow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -3,21 +3,42 @@ package sub
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"html/template"
"io" "io"
"io/fs"
"net" "net"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"strings"
"x-ui/config" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/util/common"
"x-ui/util/common" webpkg "github.com/mhsanaei/3x-ui/v2/web"
"x-ui/web/middleware" "github.com/mhsanaei/3x-ui/v2/web/locale"
"x-ui/web/network" "github.com/mhsanaei/3x-ui/v2/web/middleware"
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/network"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// setEmbeddedTemplates parses and sets embedded templates on the engine
func setEmbeddedTemplates(engine *gin.Engine) error {
t, err := template.New("").Funcs(engine.FuncMap).ParseFS(
webpkg.EmbeddedHTML(),
"html/common/page.html",
"html/component/aThemeSwitch.html",
"html/settings/panel/subscription/subpage.html",
)
if err != nil {
return err
}
engine.SetHTMLTemplate(t)
return nil
}
type Server struct { type Server struct {
httpServer *http.Server httpServer *http.Server
listener net.Listener listener net.Listener
@ -38,13 +59,10 @@ func NewServer() *Server {
} }
func (s *Server) initRouter() (*gin.Engine, error) { func (s *Server) initRouter() (*gin.Engine, error) {
if config.IsDebug() { // Always run in release mode for the subscription server
gin.SetMode(gin.DebugMode) gin.DefaultWriter = io.Discard
} else { gin.DefaultErrorWriter = io.Discard
gin.DefaultWriter = io.Discard gin.SetMode(gin.ReleaseMode)
gin.DefaultErrorWriter = io.Discard
gin.SetMode(gin.ReleaseMode)
}
engine := gin.Default() engine := gin.Default()
@ -67,6 +85,17 @@ func (s *Server) initRouter() (*gin.Engine, error) {
return nil, err return nil, err
} }
// Determine if JSON subscription endpoint is enabled
subJsonEnable, err := s.settingService.GetSubJsonEnable()
if err != nil {
return nil, err
}
// Set base_path based on LinksPath for template rendering
engine.Use(func(c *gin.Context) {
c.Set("base_path", LinksPath)
})
Encrypt, err := s.settingService.GetSubEncrypt() Encrypt, err := s.settingService.GetSubEncrypt()
if err != nil { if err != nil {
return nil, err return nil, err
@ -112,15 +141,87 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubTitle = "" SubTitle = ""
} }
// set per-request localizer from headers/cookies
engine.Use(locale.LocalizerMiddleware())
// register i18n function similar to web server
i18nWebFunc := func(key string, params ...string) string {
return locale.I18n(locale.Web, key, params...)
}
engine.SetFuncMap(map[string]any{"i18n": i18nWebFunc})
// Templates: prefer embedded; fallback to disk if necessary
if err := setEmbeddedTemplates(engine); err != nil {
logger.Warning("sub: failed to parse embedded templates:", err)
if files, derr := s.getHtmlFiles(); derr == nil {
engine.LoadHTMLFiles(files...)
} else {
logger.Error("sub: no templates available (embedded parse and disk load failed)", err, derr)
}
}
// Assets: use disk if present, fallback to embedded
// Serve under both root (/assets) and under the subscription path prefix (LinksPath + "assets")
// so reverse proxies with a URI prefix can load assets correctly.
// Determine LinksPath earlier to compute prefixed assets mount.
// Note: LinksPath always starts and ends with "/" (validated in settings).
var linksPathForAssets string
if LinksPath == "/" {
linksPathForAssets = "/assets"
} else {
// ensure single slash join
linksPathForAssets = strings.TrimRight(LinksPath, "/") + "/assets"
}
if _, err := os.Stat("web/assets"); err == nil {
engine.StaticFS("/assets", http.FS(os.DirFS("web/assets")))
if linksPathForAssets != "/assets" {
engine.StaticFS(linksPathForAssets, http.FS(os.DirFS("web/assets")))
}
} else {
if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil {
engine.StaticFS("/assets", http.FS(subFS))
if linksPathForAssets != "/assets" {
engine.StaticFS(linksPathForAssets, http.FS(subFS))
}
} else {
logger.Error("sub: failed to mount embedded assets:", err)
}
}
g := engine.Group("/") g := engine.Group("/")
s.sub = NewSUBController( s.sub = NewSUBController(
g, LinksPath, JsonPath, Encrypt, ShowInfo, RemarkModel, SubUpdates, g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle) SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle)
return engine, nil return engine, nil
} }
// getHtmlFiles loads templates from local folder (used in debug mode)
func (s *Server) getHtmlFiles() ([]string, error) {
dir, _ := os.Getwd()
files := []string{}
// common layout
common := filepath.Join(dir, "web", "html", "common", "page.html")
if _, err := os.Stat(common); err == nil {
files = append(files, common)
}
// components used
theme := filepath.Join(dir, "web", "html", "component", "aThemeSwitch.html")
if _, err := os.Stat(theme); err == nil {
files = append(files, theme)
}
// page itself
page := filepath.Join(dir, "web", "html", "subpage.html")
if _, err := os.Stat(page); err == nil {
files = append(files, page)
} else {
return nil, err
}
return files, nil
}
func (s *Server) Start() (err error) { func (s *Server) Start() (err error) {
// This is an anonymous function, no function name // This is an anonymous function, no function name
defer func() { defer func() {

View file

@ -2,9 +2,11 @@ package sub
import ( import (
"encoding/base64" "encoding/base64"
"net" "fmt"
"strings" "strings"
"github.com/mhsanaei/3x-ui/v2/config"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -12,6 +14,7 @@ type SUBController struct {
subTitle string subTitle string
subPath string subPath string
subJsonPath string subJsonPath string
jsonEnabled bool
subEncrypt bool subEncrypt bool
updateInterval string updateInterval string
@ -23,6 +26,7 @@ func NewSUBController(
g *gin.RouterGroup, g *gin.RouterGroup,
subPath string, subPath string,
jsonPath string, jsonPath string,
jsonEnabled bool,
encrypt bool, encrypt bool,
showInfo bool, showInfo bool,
rModel string, rModel string,
@ -38,6 +42,7 @@ func NewSUBController(
subTitle: subTitle, subTitle: subTitle,
subPath: subPath, subPath: subPath,
subJsonPath: jsonPath, subJsonPath: jsonPath,
jsonEnabled: jsonEnabled,
subEncrypt: encrypt, subEncrypt: encrypt,
updateInterval: update, updateInterval: update,
@ -50,29 +55,17 @@ func NewSUBController(
func (a *SUBController) initRouter(g *gin.RouterGroup) { func (a *SUBController) initRouter(g *gin.RouterGroup) {
gLink := g.Group(a.subPath) gLink := g.Group(a.subPath)
gJson := g.Group(a.subJsonPath)
gLink.GET(":subid", a.subs) gLink.GET(":subid", a.subs)
gJson.GET(":subid", a.subJsons) if a.jsonEnabled {
gJson := g.Group(a.subJsonPath)
gJson.GET(":subid", a.subJsons)
}
} }
func (a *SUBController) subs(c *gin.Context) { func (a *SUBController) subs(c *gin.Context) {
subId := c.Param("subid") subId := c.Param("subid")
var host string scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil { subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
host = h
}
if host == "" {
host = c.GetHeader("X-Real-IP")
}
if host == "" {
var err error
host, _, err = net.SplitHostPort(c.Request.Host)
if err != nil {
host = c.Request.Host
}
}
subs, header, err := a.subService.GetSubs(subId, host)
if err != nil || len(subs) == 0 { if err != nil || len(subs) == 0 {
c.String(400, "Error!") c.String(400, "Error!")
} else { } else {
@ -81,10 +74,42 @@ func (a *SUBController) subs(c *gin.Context) {
result += sub + "\n" result += sub + "\n"
} }
// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
accept := c.GetHeader("Accept")
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
// Build page data in service
subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId)
if !a.jsonEnabled {
subJsonURL = ""
}
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL)
c.HTML(200, "subpage.html", gin.H{
"title": "subscription.title",
"cur_ver": config.GetVersion(),
"host": page.Host,
"base_path": page.BasePath,
"sId": page.SId,
"download": page.Download,
"upload": page.Upload,
"total": page.Total,
"used": page.Used,
"remained": page.Remained,
"expire": page.Expire,
"lastOnline": page.LastOnline,
"datepicker": page.Datepicker,
"downloadByte": page.DownloadByte,
"uploadByte": page.UploadByte,
"totalByte": page.TotalByte,
"subUrl": page.SubUrl,
"subJsonUrl": page.SubJsonUrl,
"result": page.Result,
})
return
}
// Add headers // Add headers
c.Writer.Header().Set("Subscription-Userinfo", header) header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
c.Writer.Header().Set("Profile-Update-Interval", a.updateInterval) a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(a.subTitle)))
if a.subEncrypt { if a.subEncrypt {
c.String(200, base64.StdEncoding.EncodeToString([]byte(result))) c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
@ -96,41 +121,21 @@ func (a *SUBController) subs(c *gin.Context) {
func (a *SUBController) subJsons(c *gin.Context) { func (a *SUBController) subJsons(c *gin.Context) {
subId := c.Param("subid") subId := c.Param("subid")
var host string _, host, _, _ := a.subService.ResolveRequest(c)
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil {
host = h
}
if host == "" {
host = c.GetHeader("X-Real-IP")
}
if host == "" {
var err error
host, _, err = net.SplitHostPort(c.Request.Host)
if err != nil {
host = c.Request.Host
}
}
jsonSub, header, err := a.subJsonService.GetJson(subId, host) jsonSub, header, err := a.subJsonService.GetJson(subId, host)
if err != nil || len(jsonSub) == 0 { if err != nil || len(jsonSub) == 0 {
c.String(400, "Error!") c.String(400, "Error!")
} else { } else {
// Add headers // Add headers
c.Writer.Header().Set("Subscription-Userinfo", header) a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
c.Writer.Header().Set("Profile-Update-Interval", a.updateInterval)
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(a.subTitle)))
c.String(200, jsonSub) c.String(200, jsonSub)
} }
} }
func getHostFromXFH(s string) (string, error) { func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) {
if strings.Contains(s, ":") { c.Writer.Header().Set("Subscription-Userinfo", header)
realHost, _, err := net.SplitHostPort(s) c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
if err != nil { c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
return "", err
}
return realHost, nil
}
return s, nil
} }

View file

@ -6,12 +6,12 @@ import (
"fmt" "fmt"
"strings" "strings"
"x-ui/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/util/json_util" "github.com/mhsanaei/3x-ui/v2/util/json_util"
"x-ui/util/random" "github.com/mhsanaei/3x-ui/v2/util/random"
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"x-ui/xray" "github.com/mhsanaei/3x-ui/v2/xray"
) )
//go:embed default.json //go:embed default.json
@ -292,34 +292,25 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, encryption string) json_util.RawMessage { func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, encryption string) json_util.RawMessage {
outbound := Outbound{} outbound := Outbound{}
usersData := make([]UserVnext, 1)
usersData[0].ID = client.ID
usersData[0].Level = 8
if inbound.Protocol == model.VMESS {
usersData[0].Security = client.Security
}
if inbound.Protocol == model.VLESS {
usersData[0].Flow = client.Flow
usersData[0].Encryption = encryption
}
vnextData := make([]VnextSetting, 1)
vnextData[0] = VnextSetting{
Address: inbound.Listen,
Port: inbound.Port,
Users: usersData,
}
outbound.Protocol = string(inbound.Protocol) outbound.Protocol = string(inbound.Protocol)
outbound.Tag = "proxy" outbound.Tag = "proxy"
if s.mux != "" { if s.mux != "" {
outbound.Mux = json_util.RawMessage(s.mux) outbound.Mux = json_util.RawMessage(s.mux)
} }
outbound.StreamSettings = streamSettings outbound.StreamSettings = streamSettings
outbound.Settings = OutboundSettings{ // Emit flattened settings inside Settings to match new Xray format
Vnext: vnextData, settings := make(map[string]any)
settings["address"] = inbound.Listen
settings["port"] = inbound.Port
settings["id"] = client.ID
if inbound.Protocol == model.VLESS {
settings["flow"] = client.Flow
settings["encryption"] = encryption
} }
if inbound.Protocol == model.VMESS {
settings["security"] = client.Security
}
outbound.Settings = settings
result, _ := json.MarshalIndent(outbound, "", " ") result, _ := json.MarshalIndent(outbound, "", " ")
return result return result
@ -356,8 +347,8 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
outbound.Mux = json_util.RawMessage(s.mux) outbound.Mux = json_util.RawMessage(s.mux)
} }
outbound.StreamSettings = streamSettings outbound.StreamSettings = streamSettings
outbound.Settings = OutboundSettings{ outbound.Settings = map[string]any{
Servers: serverData, "servers": serverData,
} }
result, _ := json.MarshalIndent(outbound, "", " ") result, _ := json.MarshalIndent(outbound, "", " ")
@ -369,28 +360,10 @@ type Outbound struct {
Tag string `json:"tag"` Tag string `json:"tag"`
StreamSettings json_util.RawMessage `json:"streamSettings"` StreamSettings json_util.RawMessage `json:"streamSettings"`
Mux json_util.RawMessage `json:"mux,omitempty"` Mux json_util.RawMessage `json:"mux,omitempty"`
ProxySettings map[string]any `json:"proxySettings,omitempty"` Settings map[string]any `json:"settings,omitempty"`
Settings OutboundSettings `json:"settings,omitempty"`
} }
type OutboundSettings struct { // Legacy vnext-related structs removed for flattened schema
Vnext []VnextSetting `json:"vnext,omitempty"`
Servers []ServerSetting `json:"servers,omitempty"`
}
type VnextSetting struct {
Address string `json:"address"`
Port int `json:"port"`
Users []UserVnext `json:"users"`
}
type UserVnext struct {
Encryption string `json:"encryption,omitempty"`
Flow string `json:"flow,omitempty"`
ID string `json:"id"`
Security string `json:"security,omitempty"`
Level int `json:"level"`
}
type ServerSetting struct { type ServerSetting struct {
Password string `json:"password"` Password string `json:"password"`

View file

@ -3,19 +3,21 @@ package sub
import ( import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"net"
"net/url" "net/url"
"strings" "strings"
"time" "time"
"x-ui/database" "github.com/gin-gonic/gin"
"x-ui/database/model"
"x-ui/logger"
"x-ui/util/common"
"x-ui/util/random"
"x-ui/web/service"
"x-ui/xray"
"github.com/goccy/go-json" "github.com/goccy/go-json"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/mhsanaei/3x-ui/v2/util/random"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/xray"
) )
type SubService struct { type SubService struct {
@ -34,19 +36,19 @@ func NewSubService(showInfo bool, remarkModel string) *SubService {
} }
} }
func (s *SubService) GetSubs(subId string, host string) ([]string, string, error) { func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.ClientTraffic, error) {
s.address = host s.address = host
var result []string var result []string
var header string
var traffic xray.ClientTraffic var traffic xray.ClientTraffic
var lastOnline int64
var clientTraffics []xray.ClientTraffic var clientTraffics []xray.ClientTraffic
inbounds, err := s.getInboundsBySubId(subId) inbounds, err := s.getInboundsBySubId(subId)
if err != nil { if err != nil {
return nil, "", err return nil, 0, traffic, err
} }
if len(inbounds) == 0 { if len(inbounds) == 0 {
return nil, "", common.NewError("No inbounds found with ", subId) return nil, 0, traffic, common.NewError("No inbounds found with ", subId)
} }
s.datepicker, err = s.settingService.GetDatepicker() s.datepicker, err = s.settingService.GetDatepicker()
@ -73,7 +75,11 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, string, error
if client.Enable && client.SubID == subId { if client.Enable && client.SubID == subId {
link := s.getLink(inbound, client.Email) link := s.getLink(inbound, client.Email)
result = append(result, link) result = append(result, link)
clientTraffics = append(clientTraffics, s.getClientTraffics(inbound.ClientStats, client.Email)) ct := s.getClientTraffics(inbound.ClientStats, client.Email)
clientTraffics = append(clientTraffics, ct)
if ct.LastOnline > lastOnline {
lastOnline = ct.LastOnline
}
} }
} }
} }
@ -100,8 +106,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, string, error
} }
} }
} }
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) return result, lastOnline, traffic, nil
return result, header, nil
} }
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) { func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
@ -1022,3 +1027,135 @@ func searchHost(headers any) string {
return "" return ""
} }
// PageData is a view model for subpage.html
type PageData struct {
Host string
BasePath string
SId string
Download string
Upload string
Total string
Used string
Remained string
Expire int64
LastOnline int64
Datepicker string
DownloadByte int64
UploadByte int64
TotalByte int64
SubUrl string
SubJsonUrl string
Result []string
}
// ResolveRequest extracts scheme and host info from request/headers consistently.
func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
// scheme
scheme = "http"
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
scheme = "https"
}
// base host (no port)
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil && h != "" {
host = h
}
if host == "" {
host = c.GetHeader("X-Real-IP")
}
if host == "" {
var err error
host, _, err = net.SplitHostPort(c.Request.Host)
if err != nil {
host = c.Request.Host
}
}
// host:port for URLs
hostWithPort = c.GetHeader("X-Forwarded-Host")
if hostWithPort == "" {
hostWithPort = c.Request.Host
}
if hostWithPort == "" {
hostWithPort = host
}
// header display host
hostHeader = c.GetHeader("X-Forwarded-Host")
if hostHeader == "" {
hostHeader = c.GetHeader("X-Real-IP")
}
if hostHeader == "" {
hostHeader = host
}
return
}
// BuildURLs constructs absolute subscription and json URLs.
func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) {
if strings.HasSuffix(subPath, "/") {
subURL = scheme + "://" + hostWithPort + subPath + subId
} else {
subURL = scheme + "://" + hostWithPort + strings.TrimRight(subPath, "/") + "/" + subId
}
if strings.HasSuffix(subJsonPath, "/") {
subJsonURL = scheme + "://" + hostWithPort + subJsonPath + subId
} else {
subJsonURL = scheme + "://" + hostWithPort + strings.TrimRight(subJsonPath, "/") + "/" + subId
}
return
}
// BuildPageData parses header and prepares the template view model.
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string) PageData {
download := common.FormatTraffic(traffic.Down)
upload := common.FormatTraffic(traffic.Up)
total := "∞"
used := common.FormatTraffic(traffic.Up + traffic.Down)
remained := ""
if traffic.Total > 0 {
total = common.FormatTraffic(traffic.Total)
left := traffic.Total - (traffic.Up + traffic.Down)
if left < 0 {
left = 0
}
remained = common.FormatTraffic(left)
}
datepicker := s.datepicker
if datepicker == "" {
datepicker = "gregorian"
}
return PageData{
Host: hostHeader,
BasePath: "/", // kept as "/"; templates now use context base_path injected from router
SId: subId,
Download: download,
Upload: upload,
Total: total,
Used: used,
Remained: remained,
Expire: traffic.ExpiryTime / 1000,
LastOnline: lastOnline,
Datepicker: datepicker,
DownloadByte: traffic.Down,
UploadByte: traffic.Up,
TotalByte: traffic.Total,
SubUrl: subURL,
SubJsonUrl: subJsonURL,
Result: subs,
}
}
func getHostFromXFH(s string) (string, error) {
if strings.Contains(s, ":") {
realHost, _, err := net.SplitHostPort(s)
if err != nil {
return "", err
}
return realHost, nil
}
return s, nil
}

View file

@ -4,7 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
) )
func NewErrorf(format string, a ...any) error { func NewErrorf(format string, a ...any) error {

View file

@ -4,7 +4,12 @@
package sys package sys
import ( import (
"encoding/binary"
"fmt"
"sync"
"github.com/shirou/gopsutil/v4/net" "github.com/shirou/gopsutil/v4/net"
"golang.org/x/sys/unix"
) )
func GetTCPCount() (int, error) { func GetTCPCount() (int, error) {
@ -22,3 +27,69 @@ func GetUDPCount() (int, error) {
} }
return len(stats), nil return len(stats), nil
} }
// --- CPU Utilization (macOS native) ---
// sysctl kern.cp_time returns an array of 5 longs: user, nice, sys, idle, intr.
// We compute utilization deltas without cgo.
var (
cpuMu sync.Mutex
lastTotals [5]uint64
hasLastCPUT bool
)
func CPUPercentRaw() (float64, error) {
raw, err := unix.SysctlRaw("kern.cp_time")
if err != nil {
return 0, err
}
// Expect either 5*8 bytes (uint64) or 5*4 bytes (uint32)
var out [5]uint64
switch len(raw) {
case 5 * 8:
for i := 0; i < 5; i++ {
out[i] = binary.LittleEndian.Uint64(raw[i*8 : (i+1)*8])
}
case 5 * 4:
for i := 0; i < 5; i++ {
out[i] = uint64(binary.LittleEndian.Uint32(raw[i*4 : (i+1)*4]))
}
default:
return 0, fmt.Errorf("unexpected kern.cp_time size: %d", len(raw))
}
// user, nice, sys, idle, intr
user := out[0]
nice := out[1]
sysv := out[2]
idle := out[3]
intr := out[4]
cpuMu.Lock()
defer cpuMu.Unlock()
if !hasLastCPUT {
lastTotals = out
hasLastCPUT = true
return 0, nil
}
dUser := user - lastTotals[0]
dNice := nice - lastTotals[1]
dSys := sysv - lastTotals[2]
dIdle := idle - lastTotals[3]
dIntr := intr - lastTotals[4]
lastTotals = out
totald := dUser + dNice + dSys + dIdle + dIntr
if totald == 0 {
return 0, nil
}
busy := totald - dIdle
pct := float64(busy) / float64(totald) * 100.0
if pct > 100 {
pct = 100
}
return pct, nil
}

View file

@ -4,10 +4,14 @@
package sys package sys
import ( import (
"bufio"
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"os" "os"
"strconv"
"strings"
"sync"
) )
func getLinesNum(filename string) (int, error) { func getLinesNum(filename string) (int, error) {
@ -79,3 +83,99 @@ func safeGetLinesNum(path string) (int, error) {
} }
return getLinesNum(path) return getLinesNum(path)
} }
// --- CPU Utilization (Linux native) ---
var (
cpuMu sync.Mutex
lastTotal uint64
lastIdleAll uint64
hasLast bool
)
// CPUPercentRaw returns instantaneous total CPU utilization by reading /proc/stat.
// First call initializes and returns 0; subsequent calls return busy/total * 100.
func CPUPercentRaw() (float64, error) {
f, err := os.Open("/proc/stat")
if err != nil {
return 0, err
}
defer f.Close()
rd := bufio.NewReader(f)
line, err := rd.ReadString('\n')
if err != nil && err != io.EOF {
return 0, err
}
// Expect line like: cpu user nice system idle iowait irq softirq steal guest guest_nice
fields := strings.Fields(line)
if len(fields) < 5 || fields[0] != "cpu" {
return 0, fmt.Errorf("unexpected /proc/stat format")
}
var nums []uint64
for i := 1; i < len(fields); i++ {
v, err := strconv.ParseUint(fields[i], 10, 64)
if err != nil {
break
}
nums = append(nums, v)
}
if len(nums) < 4 { // need at least user,nice,system,idle
return 0, fmt.Errorf("insufficient cpu fields")
}
// Conform with standard Linux CPU accounting
var user, nice, system, idle, iowait, irq, softirq, steal uint64
user = nums[0]
if len(nums) > 1 {
nice = nums[1]
}
if len(nums) > 2 {
system = nums[2]
}
if len(nums) > 3 {
idle = nums[3]
}
if len(nums) > 4 {
iowait = nums[4]
}
if len(nums) > 5 {
irq = nums[5]
}
if len(nums) > 6 {
softirq = nums[6]
}
if len(nums) > 7 {
steal = nums[7]
}
idleAll := idle + iowait
nonIdle := user + nice + system + irq + softirq + steal
total := idleAll + nonIdle
cpuMu.Lock()
defer cpuMu.Unlock()
if !hasLast {
lastTotal = total
lastIdleAll = idleAll
hasLast = true
return 0, nil
}
totald := total - lastTotal
idled := idleAll - lastIdleAll
lastTotal = total
lastIdleAll = idleAll
if totald == 0 {
return 0, nil
}
busy := totald - idled
pct := float64(busy) / float64(totald) * 100.0
if pct > 100 {
pct = 100
}
return pct, nil
}

View file

@ -5,6 +5,9 @@ package sys
import ( import (
"errors" "errors"
"sync"
"syscall"
"unsafe"
"github.com/shirou/gopsutil/v4/net" "github.com/shirou/gopsutil/v4/net"
) )
@ -28,3 +31,81 @@ func GetTCPCount() (int, error) {
func GetUDPCount() (int, error) { func GetUDPCount() (int, error) {
return GetConnectionCount("udp") return GetConnectionCount("udp")
} }
// --- CPU Utilization (Windows native) ---
var (
modKernel32 = syscall.NewLazyDLL("kernel32.dll")
procGetSystemTimes = modKernel32.NewProc("GetSystemTimes")
cpuMu sync.Mutex
lastIdle uint64
lastKernel uint64
lastUser uint64
hasLast bool
)
type filetime struct {
LowDateTime uint32
HighDateTime uint32
}
func ftToUint64(ft filetime) uint64 {
return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
}
// CPUPercentRaw returns the instantaneous total CPU utilization percentage using
// Windows GetSystemTimes across all logical processors. The first call returns 0
// as it initializes the baseline. Subsequent calls compute deltas.
func CPUPercentRaw() (float64, error) {
var idleFT, kernelFT, userFT filetime
r1, _, e1 := procGetSystemTimes.Call(
uintptr(unsafe.Pointer(&idleFT)),
uintptr(unsafe.Pointer(&kernelFT)),
uintptr(unsafe.Pointer(&userFT)),
)
if r1 == 0 { // failure
if e1 != nil {
return 0, e1
}
return 0, syscall.GetLastError()
}
idle := ftToUint64(idleFT)
kernel := ftToUint64(kernelFT)
user := ftToUint64(userFT)
cpuMu.Lock()
defer cpuMu.Unlock()
if !hasLast {
lastIdle = idle
lastKernel = kernel
lastUser = user
hasLast = true
return 0, nil
}
idleDelta := idle - lastIdle
kernelDelta := kernel - lastKernel
userDelta := user - lastUser
// Update for next call
lastIdle = idle
lastKernel = kernel
lastUser = user
total := kernelDelta + userDelta
if total == 0 {
return 0, nil
}
// On Windows, kernel time includes idle time; busy = total - idle
busy := total - idleDelta
pct := float64(busy) / float64(total) * 100.0
// lower bound not needed; ratios of uint64 are non-negative
if pct > 100 {
pct = 100
}
return pct, nil
}

File diff suppressed because one or more lines are too long

View file

@ -10,6 +10,8 @@ class DBInbound {
this.remark = ""; this.remark = "";
this.enable = true; this.enable = true;
this.expiryTime = 0; this.expiryTime = 0;
this.trafficReset = "never";
this.lastTrafficResetTime = 0;
this.listen = ""; this.listen = "";
this.port = 0; this.port = 0;

View file

@ -219,7 +219,7 @@ class KcpStreamSettings extends CommonClass {
class WsStreamSettings extends CommonClass { class WsStreamSettings extends CommonClass {
constructor( constructor(
path = '/', path = '/',
host = '', host = '',
heartbeatPeriod = 0, heartbeatPeriod = 0,
@ -647,10 +647,6 @@ class Outbound extends CommonClass {
].includes(this.protocol); ].includes(this.protocol);
} }
hasVnext() {
return [Protocols.VMess, Protocols.VLESS].includes(this.protocol);
}
hasServers() { hasServers() {
return [Protocols.Trojan, Protocols.Shadowsocks, Protocols.Socks, Protocols.HTTP].includes(this.protocol); return [Protocols.Trojan, Protocols.Shadowsocks, Protocols.Socks, Protocols.HTTP].includes(this.protocol);
} }
@ -690,13 +686,22 @@ class Outbound extends CommonClass {
if (this.stream?.sockopt) if (this.stream?.sockopt)
stream = { sockopt: this.stream.sockopt.toJson() }; stream = { sockopt: this.stream.sockopt.toJson() };
} }
// For VMess/VLESS, emit settings as a flat object
let settingsOut = this.settings instanceof CommonClass ? this.settings.toJson() : this.settings;
// Remove undefined/null keys
if (settingsOut && typeof settingsOut === 'object') {
Object.keys(settingsOut).forEach(k => {
if (settingsOut[k] === undefined || settingsOut[k] === null) delete settingsOut[k];
});
}
return { return {
tag: this.tag == '' ? undefined : this.tag,
protocol: this.protocol, protocol: this.protocol,
settings: this.settings instanceof CommonClass ? this.settings.toJson() : this.settings, settings: settingsOut,
streamSettings: stream, // Only include tag, streamSettings, sendThrough, mux if present and not empty
sendThrough: this.sendThrough != "" ? this.sendThrough : undefined, ...(this.tag ? { tag: this.tag } : {}),
mux: this.mux?.enabled ? this.mux : undefined, ...(stream ? { streamSettings: stream } : {}),
...(this.sendThrough ? { sendThrough: this.sendThrough } : {}),
...(this.mux?.enabled ? { mux: this.mux } : {}),
}; };
} }
@ -908,7 +913,7 @@ Outbound.FreedomSettings = class extends CommonClass {
toJson() { toJson() {
return { return {
domainStrategy: ObjectUtil.isEmpty(this.domainStrategy) ? undefined : this.domainStrategy, domainStrategy: ObjectUtil.isEmpty(this.domainStrategy) ? undefined : this.domainStrategy,
redirect: ObjectUtil.isEmpty(this.redirect) ? undefined: this.redirect, redirect: ObjectUtil.isEmpty(this.redirect) ? undefined : this.redirect,
fragment: Object.keys(this.fragment).length === 0 ? undefined : this.fragment, fragment: Object.keys(this.fragment).length === 0 ? undefined : this.fragment,
noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises), noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises),
}; };
@ -1026,22 +1031,21 @@ Outbound.VmessSettings = class extends CommonClass {
} }
static fromJson(json = {}) { static fromJson(json = {}) {
if (ObjectUtil.isArrEmpty(json.vnext)) return new Outbound.VmessSettings(); if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VmessSettings();
return new Outbound.VmessSettings( return new Outbound.VmessSettings(
json.vnext[0].address, json.address,
json.vnext[0].port, json.port,
json.vnext[0].users[0].id, json.id,
json.vnext[0].users[0].security, json.security,
); );
} }
toJson() { toJson() {
return { return {
vnext: [{ address: this.address,
address: this.address, port: this.port,
port: this.port, id: this.id,
users: [{ id: this.id, security: this.security }], security: this.security,
}],
}; };
} }
}; };
@ -1056,23 +1060,23 @@ Outbound.VLESSSettings = class extends CommonClass {
} }
static fromJson(json = {}) { static fromJson(json = {}) {
if (ObjectUtil.isArrEmpty(json.vnext)) return new Outbound.VLESSSettings(); if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings();
return new Outbound.VLESSSettings( return new Outbound.VLESSSettings(
json.vnext[0].address, json.address,
json.vnext[0].port, json.port,
json.vnext[0].users[0].id, json.id,
json.vnext[0].users[0].flow, json.flow,
json.vnext[0].users[0].encryption, json.encryption
); );
} }
toJson() { toJson() {
return { return {
vnext: [{ address: this.address,
address: this.address, port: this.port,
port: this.port, id: this.id,
users: [{ id: this.id, flow: this.flow, encryption: this.encryption }], flow: this.flow,
}], encryption: this.encryption,
}; };
} }
}; };

View file

@ -26,7 +26,8 @@ class AllSetting {
this.twoFactorEnable = false; this.twoFactorEnable = false;
this.twoFactorToken = ""; this.twoFactorToken = "";
this.xrayTemplateConfig = ""; this.xrayTemplateConfig = "";
this.subEnable = false; this.subEnable = true;
this.subJsonEnable = false;
this.subTitle = ""; this.subTitle = "";
this.subListen = ""; this.subListen = "";
this.subPort = 2096; this.subPort = 2096;

View file

@ -0,0 +1,157 @@
(function () {
// Vue app for Subscription page
const el = document.getElementById('subscription-data');
if (!el) return;
const textarea = document.getElementById('subscription-links');
const rawLinks = (textarea?.value || '').split('\n').filter(Boolean);
const data = {
sId: el.getAttribute('data-sid') || '',
subUrl: el.getAttribute('data-sub-url') || '',
subJsonUrl: el.getAttribute('data-subjson-url') || '',
download: el.getAttribute('data-download') || '',
upload: el.getAttribute('data-upload') || '',
used: el.getAttribute('data-used') || '',
total: el.getAttribute('data-total') || '',
remained: el.getAttribute('data-remained') || '',
expireMs: (parseInt(el.getAttribute('data-expire') || '0', 10) || 0) * 1000,
lastOnlineMs: (parseInt(el.getAttribute('data-lastonline') || '0', 10) || 0),
downloadByte: parseInt(el.getAttribute('data-downloadbyte') || '0', 10) || 0,
uploadByte: parseInt(el.getAttribute('data-uploadbyte') || '0', 10) || 0,
totalByte: parseInt(el.getAttribute('data-totalbyte') || '0', 10) || 0,
datepicker: el.getAttribute('data-datepicker') || 'gregorian',
};
// Normalize lastOnline to milliseconds if it looks like seconds
if (data.lastOnlineMs && data.lastOnlineMs < 10_000_000_000) {
data.lastOnlineMs *= 1000;
}
function renderLink(item) {
return (
Vue.h('a-list-item', {}, [
Vue.h('a-space', { props: { size: 'small' } }, [
Vue.h('a-button', { props: { size: 'small' }, on: { click: () => copy(item) } }, [Vue.h('a-icon', { props: { type: 'copy' } })]),
Vue.h('span', { class: 'break-all' }, item)
])
])
);
}
function copy(text) {
ClipboardManager.copyText(text).then(ok => {
const messageType = ok ? 'success' : 'error';
Vue.prototype.$message[messageType](ok ? 'Copied' : 'Copy failed');
});
}
function open(url) {
window.location.href = url;
}
function drawQR(value) {
try {
new QRious({ element: document.getElementById('qrcode'), value, size: 220 });
} catch (e) {
console.warn(e);
}
}
// Try to extract a human label (email/ps) from different link types
function linkName(link, idx) {
try {
if (link.startsWith('vmess://')) {
const json = JSON.parse(atob(link.replace('vmess://', '')));
if (json.ps) return json.ps;
if (json.add && json.id) return json.add; // fallback host
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
const hashIdx = link.indexOf('#');
if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
const qIdx = link.indexOf('?');
if (qIdx !== -1) {
const qs = new URL('http://x/?' + link.substring(qIdx + 1, hashIdx !== -1 ? hashIdx : undefined)).searchParams;
if (qs.get('remark')) return qs.get('remark');
if (qs.get('email')) return qs.get('email');
}
const at = link.indexOf('@');
const protSep = link.indexOf('://');
if (at !== -1 && protSep !== -1) return link.substring(protSep + 3, at);
} else if (link.startsWith('ss://')) {
const hashIdx = link.indexOf('#');
if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
}
} catch (e) { /* ignore and fallback */ }
return 'Link ' + (idx + 1);
}
const app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
data: {
themeSwitcher,
app: data,
links: rawLinks,
lang: '',
viewportWidth: (typeof window !== 'undefined' ? window.innerWidth : 1024),
},
async mounted() {
this.lang = LanguageManager.getLanguage();
const tpl = document.getElementById('subscription-data');
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
if (sj) this.app.subJsonUrl = sj;
drawQR(this.app.subUrl);
try {
const elJson = document.getElementById('qrcode-subjson');
if (elJson && this.app.subJsonUrl) {
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
}
} catch (e) { /* ignore */ }
this._onResize = () => { this.viewportWidth = window.innerWidth; };
window.addEventListener('resize', this._onResize);
},
beforeDestroy() {
if (this._onResize) window.removeEventListener('resize', this._onResize);
},
computed: {
isMobile() {
return this.viewportWidth < 576;
},
isUnlimited() {
return !this.app.totalByte;
},
isActive() {
const now = Date.now();
const expiryOk = !this.app.expireMs || this.app.expireMs >= now;
const trafficOk = !this.app.totalByte || (this.app.uploadByte + this.app.downloadByte) <= this.app.totalByte;
return expiryOk && trafficOk;
},
shadowrocketUrl() {
const rawUrl = this.app.subUrl + '?flag=shadowrocket';
const base64Url = btoa(rawUrl);
const remark = encodeURIComponent(this.app.sId || 'Subscription');
return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
},
v2boxUrl() {
return `v2box://install-sub?url=${encodeURIComponent(this.app.subUrl)}&name=${encodeURIComponent(this.app.sId)}`;
},
streisandUrl() {
return `streisand://import/${encodeURIComponent(this.app.subUrl)}`;
},
v2raytunUrl() {
return this.app.subUrl;
},
npvtunUrl() {
return this.app.subUrl;
}
},
methods: {
renderLink,
copy,
open,
linkName,
i18nLabel(key) {
return '{{ i18n "' + key + '" }}';
},
},
});
})();

View file

@ -1,7 +1,7 @@
package controller package controller
import ( import (
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View file

@ -3,9 +3,9 @@ package controller
import ( import (
"net/http" "net/http"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/web/locale" "github.com/mhsanaei/3x-ui/v2/web/locale"
"x-ui/web/session" "github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View file

@ -5,10 +5,10 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"x-ui/database/model"
"x-ui/web/middleware" "github.com/mhsanaei/3x-ui/v2/database/model"
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"x-ui/web/session" "github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -109,8 +109,7 @@ func (a *InboundController) addInbound(c *gin.Context) {
inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port) inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
} }
needRestart := false inbound, needRestart, err := a.inboundService.AddInbound(inbound)
inbound, needRestart, err = a.inboundService.AddInbound(inbound)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
@ -127,8 +126,7 @@ func (a *InboundController) delInbound(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), err) jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), err)
return return
} }
needRestart := true needRestart, err := a.inboundService.DelInbound(id)
needRestart, err = a.inboundService.DelInbound(id)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
@ -153,8 +151,7 @@ func (a *InboundController) updateInbound(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return return
} }
needRestart := true inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
inbound, needRestart, err = a.inboundService.UpdateInbound(inbound)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
@ -196,9 +193,7 @@ func (a *InboundController) addInboundClient(c *gin.Context) {
return return
} }
needRestart := true needRestart, err := a.inboundService.AddInboundClient(data)
needRestart, err = a.inboundService.AddInboundClient(data)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
@ -217,9 +212,7 @@ func (a *InboundController) delInboundClient(c *gin.Context) {
} }
clientId := c.Param("clientId") clientId := c.Param("clientId")
needRestart := true needRestart, err := a.inboundService.DelInboundClient(id, clientId)
needRestart, err = a.inboundService.DelInboundClient(id, clientId)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
@ -240,9 +233,7 @@ func (a *InboundController) updateInboundClient(c *gin.Context) {
return return
} }
needRestart := true needRestart, err := a.inboundService.UpdateInboundClient(inbound, clientId)
needRestart, err = a.inboundService.UpdateInboundClient(inbound, clientId)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return

View file

@ -5,18 +5,18 @@ import (
"text/template" "text/template"
"time" "time"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"x-ui/web/session" "github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type LoginForm struct { type LoginForm struct {
Username string `json:"username" form:"username"` Username string `json:"username" form:"username"`
Password string `json:"password" form:"password"` Password string `json:"password" form:"password"`
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"` TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
} }
type IndexController struct { type IndexController struct {

View file

@ -4,10 +4,11 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"time" "time"
"x-ui/web/global" "github.com/mhsanaei/3x-ui/v2/web/global"
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -20,17 +21,14 @@ type ServerController struct {
serverService service.ServerService serverService service.ServerService
settingService service.SettingService settingService service.SettingService
lastStatus *service.Status lastStatus *service.Status
lastGetStatusTime time.Time
lastVersions []string lastVersions []string
lastGetVersionsTime time.Time lastGetVersionsTime int64 // unix seconds
} }
func NewServerController(g *gin.RouterGroup) *ServerController { func NewServerController(g *gin.RouterGroup) *ServerController {
a := &ServerController{ a := &ServerController{}
lastGetStatusTime: time.Now(),
}
a.initRouter(g) a.initRouter(g)
a.startTask() a.startTask()
return a return a
@ -39,6 +37,7 @@ func NewServerController(g *gin.RouterGroup) *ServerController {
func (a *ServerController) initRouter(g *gin.RouterGroup) { func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/status", a.status) g.GET("/status", a.status)
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
g.GET("/getXrayVersion", a.getXrayVersion) g.GET("/getXrayVersion", a.getXrayVersion)
g.GET("/getConfigJson", a.getConfigJson) g.GET("/getConfigJson", a.getConfigJson)
g.GET("/getDb", a.getDb) g.GET("/getDb", a.getDb)
@ -61,29 +60,50 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
func (a *ServerController) refreshStatus() { func (a *ServerController) refreshStatus() {
a.lastStatus = a.serverService.GetStatus(a.lastStatus) a.lastStatus = a.serverService.GetStatus(a.lastStatus)
// collect cpu history when status is fresh
if a.lastStatus != nil {
a.serverService.AppendCpuSample(time.Now(), a.lastStatus.Cpu)
}
} }
func (a *ServerController) startTask() { func (a *ServerController) startTask() {
webServer := global.GetWebServer() webServer := global.GetWebServer()
c := webServer.GetCron() c := webServer.GetCron()
c.AddFunc("@every 2s", func() { c.AddFunc("@every 2s", func() {
now := time.Now() // Always refresh to keep CPU history collected continuously.
if now.Sub(a.lastGetStatusTime) > time.Minute*3 { // Sampling is lightweight and capped to ~6 hours in memory.
return
}
a.refreshStatus() a.refreshStatus()
}) })
} }
func (a *ServerController) status(c *gin.Context) { func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) }
a.lastGetStatusTime = time.Now()
jsonObj(c, a.lastStatus, nil) func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
bucketStr := c.Param("bucket")
bucket, err := strconv.Atoi(bucketStr)
if err != nil || bucket <= 0 {
jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket"))
return
}
allowed := map[int]bool{
2: true, // Real-time view
30: true, // 30s intervals
60: true, // 1m intervals
120: true, // 2m intervals
180: true, // 3m intervals
300: true, // 5m intervals
}
if !allowed[bucket] {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return
}
points := a.serverService.AggregateCpuHistory(bucket, 60)
jsonObj(c, points, nil)
} }
func (a *ServerController) getXrayVersion(c *gin.Context) { func (a *ServerController) getXrayVersion(c *gin.Context) {
now := time.Now() now := time.Now().Unix()
if now.Sub(a.lastGetVersionsTime) <= time.Minute { if now-a.lastGetVersionsTime <= 60 { // 1 minute cache
jsonObj(c, a.lastVersions, nil) jsonObj(c, a.lastVersions, nil)
return return
} }
@ -95,7 +115,7 @@ func (a *ServerController) getXrayVersion(c *gin.Context) {
} }
a.lastVersions = versions a.lastVersions = versions
a.lastGetVersionsTime = time.Now() a.lastGetVersionsTime = now
jsonObj(c, versions, nil) jsonObj(c, versions, nil)
} }
@ -113,7 +133,6 @@ func (a *ServerController) updateGeofile(c *gin.Context) {
} }
func (a *ServerController) stopXrayService(c *gin.Context) { func (a *ServerController) stopXrayService(c *gin.Context) {
a.lastGetStatusTime = time.Now()
err := a.serverService.StopXrayService() err := a.serverService.StopXrayService()
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err) jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err)
@ -229,9 +248,7 @@ func (a *ServerController) importDB(c *gin.Context) {
defer file.Close() defer file.Close()
// Always restart Xray before return // Always restart Xray before return
defer a.serverService.RestartXrayService() defer a.serverService.RestartXrayService()
defer func() { // lastGetStatusTime removed; no longer needed
a.lastGetStatusTime = time.Now()
}()
// Import it // Import it
err = a.serverService.ImportDB(file) err = a.serverService.ImportDB(file)
if err != nil { if err != nil {

View file

@ -4,10 +4,10 @@ import (
"errors" "errors"
"time" "time"
"x-ui/util/crypto" "github.com/mhsanaei/3x-ui/v2/util/crypto"
"x-ui/web/entity" "github.com/mhsanaei/3x-ui/v2/web/entity"
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"x-ui/web/session" "github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View file

@ -5,9 +5,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"x-ui/config" "github.com/mhsanaei/3x-ui/v2/config"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/web/entity" "github.com/mhsanaei/3x-ui/v2/web/entity"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View file

@ -1,7 +1,7 @@
package controller package controller
import ( import (
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -23,13 +23,13 @@ func NewXraySettingController(g *gin.RouterGroup) *XraySettingController {
func (a *XraySettingController) initRouter(g *gin.RouterGroup) { func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
g = g.Group("/xray") g = g.Group("/xray")
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
g.GET("/getXrayResult", a.getXrayResult)
g.POST("/", a.getXraySetting) g.POST("/", a.getXraySetting)
g.POST("/update", a.updateSetting)
g.GET("/getXrayResult", a.getXrayResult)
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.POST("/warp/:action", a.warp) g.POST("/warp/:action", a.warp)
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic) g.POST("/update", a.updateSetting)
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic) g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
} }

View file

@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"x-ui/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
) )
type Msg struct { type Msg struct {
@ -42,6 +42,7 @@ type AllSetting struct {
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"` TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"`
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"` TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"`
SubEnable bool `json:"subEnable" form:"subEnable"` SubEnable bool `json:"subEnable" form:"subEnable"`
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
SubTitle string `json:"subTitle" form:"subTitle"` SubTitle string `json:"subTitle" form:"subTitle"`
SubListen string `json:"subListen" form:"subListen"` SubListen string `json:"subListen" form:"subListen"`
SubPort int `json:"subPort" form:"subPort"` SubPort int `json:"subPort" form:"subPort"`

View file

@ -49,7 +49,7 @@
<a-space direction="horizontal" :size="2"> <a-space direction="horizontal" :size="2">
<a-tooltip> <a-tooltip>
<template slot="title"> <template slot="title">
<template v-if="!isClientEnabled(record, client.email)">{{ i18n "depleted" }}</template> <template v-if="isClientDepleted(record, client.email)">{{ i18n "depleted" }}</template>
<template v-else-if="!client.enable">{{ i18n "disabled" }}</template> <template v-else-if="!client.enable">{{ i18n "disabled" }}</template>
<template v-else-if="client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template> <template v-else-if="client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template>
</template> </template>
@ -90,7 +90,7 @@
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" /> <a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
</td> </td>
<td class="tr-table-bar" v-else-if="client.totalGB > 0"> <td class="tr-table-bar" v-else-if="client.totalGB > 0">
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientEnabled(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" /> <a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
</td> </td>
<td v-else class="infinite-bar tr-table-bar"> <td v-else class="infinite-bar tr-table-bar">
<a-progress :show-info="false" :percent="100"></a-progress> <a-progress :show-info="false" :percent="100"></a-progress>
@ -126,7 +126,7 @@
<tr class="tr-table-box"> <tr class="tr-table-box">
<td class="tr-table-rt"> [[ remainedDays(client.expiryTime) ]] </td> <td class="tr-table-rt"> [[ remainedDays(client.expiryTime) ]] </td>
<td class="infinite-bar tr-table-bar"> <td class="infinite-bar tr-table-bar">
<a-progress :show-info="false" :status="isClientEnabled(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" /> <a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
</td> </td>
<td class="tr-table-lt">[[ client.reset + "d" ]]</td> <td class="tr-table-lt">[[ client.reset + "d" ]]</td>
</tr> </tr>
@ -213,7 +213,7 @@
</tr> </tr>
</table> </table>
</template> </template>
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientEnabled(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" /> <a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
</a-popover> </a-popover>
</td> </td>
<td width="120px" v-else class="infinite-bar"> <td width="120px" v-else class="infinite-bar">
@ -247,7 +247,7 @@
</template> </template>
</span> </span>
</template> </template>
<a-progress :show-info="false" :status="isClientEnabled(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" /> <a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
</a-popover> </a-popover>
</td> </td>
<td width="60px">[[ client.reset + "d" ]]</td> <td width="60px">[[ client.reset + "d" ]]</td>

View file

@ -44,6 +44,30 @@
<a-input-number v-model.number="dbInbound.totalGB" :min="0"></a-input-number> <a-input-number v-model.number="dbInbound.totalGB" :min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.inbounds.periodicTrafficResetDesc" }}</span>
<br v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
<span v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
<strong>{{ i18n "pages.inbounds.lastReset" }}:</strong>
<span v-if="datepicker == 'gregorian'">[[ moment(dbInbound.lastTrafficResetTime).format('YYYY-MM-DD HH:mm:ss') ]]</span>
<span v-else>[[ DateUtil.convertToJalalian(moment(dbInbound.lastTrafficResetTime)) ]]</span>
</span>
</template>
{{ i18n "pages.inbounds.periodicTrafficResetTitle" }}
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-select v-model="dbInbound.trafficReset" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="never">{{ i18n "pages.inbounds.periodicTrafficReset.never" }}</a-select-option>
<a-select-option value="daily">{{ i18n "pages.inbounds.periodicTrafficReset.daily" }}</a-select-option>
<a-select-option value="weekly">{{ i18n "pages.inbounds.periodicTrafficReset.weekly" }}</a-select-option>
<a-select-option value="monthly">{{ i18n "pages.inbounds.periodicTrafficReset.monthly" }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item> <a-form-item>
<template slot="label"> <template slot="label">
<a-tooltip> <a-tooltip>

View file

@ -210,7 +210,7 @@
</a-form-item> </a-form-item>
</template> </template>
<!-- Vnext (vless/vmess) settings --> <!-- VLESS/VMess user settings -->
<template v-if="[Protocols.VMess, Protocols.VLESS].includes(outbound.protocol)"> <template v-if="[Protocols.VMess, Protocols.VLESS].includes(outbound.protocol)">
<a-form-item label='ID'> <a-form-item label='ID'>
<a-input v-model.trim="outbound.settings.id"></a-input> <a-input v-model.trim="outbound.settings.id"></a-input>

View file

@ -22,10 +22,10 @@
<a-input-number v-model.number="inbound.stream.reality.maxTimediff" :min="0"></a-input-number> <a-input-number v-model.number="inbound.stream.reality.maxTimediff" :min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='Min Client Ver'> <a-form-item label='Min Client Ver'>
<a-input v-model.trim="inbound.stream.reality.minClientVer"></a-input> <a-input v-model.trim="inbound.stream.reality.minClientVer" placeholder='25.9.11'></a-input>
</a-form-item> </a-form-item>
<a-form-item label='Max Client Ver'> <a-form-item label='Max Client Ver'>
<a-input v-model.trim="inbound.stream.reality.maxClientVer"></a-input> <a-input v-model.trim="inbound.stream.reality.maxClientVer" placeholder='25.9.11'></a-input>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<template slot="label"> <template slot="label">

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,456 +1,10 @@
{{ template "page/head_start" .}} {{ template "page/head_start" .}}
<style>
html * {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
text-align: center;
/*margin: 20px 0 50px 0;*/
height: 110px;
}
.ant-form-item-children .ant-btn,
.ant-input {
height: 50px;
border-radius: 30px;
}
.ant-input-group-addon {
border-radius: 0 30px 30px 0;
width: 50px;
font-size: 18px;
}
.ant-input-affix-wrapper .ant-input-prefix {
left: 23px;
}
.ant-input-affix-wrapper .ant-input:not(:first-child) {
padding-left: 50px;
}
.centered {
display: flex;
text-align: center;
align-items: center;
justify-content: center;
width: 100%;
}
.title {
font-size: 2rem;
margin-block-end: 2rem;
}
.title b {
font-weight: bold !important;
}
#app {
overflow: hidden;
}
#login {
animation: charge 0.5s both;
background-color: #fff;
border-radius: 2rem;
padding: 4rem 3rem;
transition: all 0.3s;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
}
#login:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
}
@keyframes charge {
from {
transform: translateY(5rem);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.under {
background-color: #c7ebe2;
z-index: 0;
}
.dark .under {
background-color: var(--dark-color-login-wave);
}
.dark #login {
background-color: var(--dark-color-surface-100);
}
.dark h1 {
color: rgba(255, 255, 255);
}
.ant-btn-primary-login {
width: 100%;
}
.ant-btn-primary-login:focus,
.ant-btn-primary-login:hover {
color: #fff;
background-color: #006655;
border-color: #006655;
background-image: linear-gradient(270deg,
rgba(123, 199, 77, 0) 30%,
#009980,
rgba(123, 199, 77, 0) 100%);
background-repeat: no-repeat;
animation: ma-bg-move ease-in-out 5s infinite;
background-position-x: -500px;
width: 95%;
animation-delay: -0.5s;
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.045);
}
.ant-btn-primary-login.active,
.ant-btn-primary-login:active {
color: #fff;
background-color: #006655;
border-color: #006655;
}
@keyframes ma-bg-move {
0% {
background-position: -500px 0;
}
50% {
background-position: 1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.wave-btn-bg {
position: relative;
border-radius: 25px;
width: 100%;
transition: all 0.3s cubic-bezier(.645, .045, .355, 1);
}
.dark .wave-btn-bg {
color: #fff;
position: relative;
background-color: #0a7557;
border: 2px double transparent;
background-origin: border-box;
background-clip: padding-box, border-box;
background-size: 300%;
width: 100%;
z-index: 1;
}
.dark .wave-btn-bg:hover {
animation: wave-btn-tara 4s ease infinite;
}
.dark .wave-btn-bg-cl {
background-image: linear-gradient(rgba(13, 14, 33, 0), rgba(13, 14, 33, 0)),
radial-gradient(circle at left top, #006655, #009980, #006655) !important;
border-radius: 3em;
}
.dark .wave-btn-bg-cl:hover {
width: 95%;
}
.dark .wave-btn-bg-cl:before {
position: absolute;
content: "";
top: -5px;
left: -5px;
bottom: -5px;
right: -5px;
z-index: -1;
background: inherit;
background-size: inherit;
border-radius: 4em;
opacity: 0;
transition: 0.5s;
}
.dark .wave-btn-bg-cl:hover::before {
opacity: 1;
filter: blur(20px);
animation: wave-btn-tara 8s linear infinite;
}
@keyframes wave-btn-tara {
to {
background-position: 300%;
}
}
.dark .ant-btn-primary-login {
font-size: 14px;
color: #fff;
text-align: center;
background-image: linear-gradient(rgba(13, 14, 33, 0.45),
rgba(13, 14, 33, 0.35));
border-radius: 2rem;
border: none;
outline: none;
background-color: transparent;
height: 46px;
position: relative;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
padding: 0 15px;
width: 100%;
animation: none;
background-position-x: 0;
box-shadow: none;
}
.waves-header {
position: fixed;
width: 100%;
text-align: center;
background-color: #dbf5ed;
color: white;
z-index: -1;
}
.dark .waves-header {
background-color: var(--dark-color-login-background);
}
.waves-inner-header {
height: 50vh;
width: 100%;
margin: 0;
padding: 0;
}
.waves {
position: relative;
width: 100%;
height: 15vh;
margin-bottom: -8px;
/*Fix for safari gap*/
min-height: 100px;
max-height: 150px;
}
.parallax>use {
animation: move-forever 25s cubic-bezier(0.55, 0.5, 0.45, 0.5) infinite;
}
.dark .parallax>use {
fill: var(--dark-color-login-wave);
}
.parallax>use:nth-child(1) {
animation-delay: -2s;
animation-duration: 4s;
opacity: 0.2;
}
.parallax>use:nth-child(2) {
animation-delay: -3s;
animation-duration: 7s;
opacity: 0.4;
}
.parallax>use:nth-child(3) {
animation-delay: -4s;
animation-duration: 10s;
opacity: 0.6;
}
.parallax>use:nth-child(4) {
animation-delay: -5s;
animation-duration: 13s;
}
@keyframes move-forever {
0% {
transform: translate3d(-90px, 0, 0);
}
100% {
transform: translate3d(85px, 0, 0);
}
}
@media (max-width: 768px) {
.waves {
height: 40px;
min-height: 40px;
}
}
.words-wrapper {
width: 100%;
display: inline-block;
position: relative;
text-align: center;
}
.words-wrapper b {
width: 100%;
display: inline-block;
position: absolute;
left: 0;
top: 0;
}
.words-wrapper b.is-visible {
position: relative;
}
.headline.zoom .words-wrapper {
-webkit-perspective: 300px;
-moz-perspective: 300px;
perspective: 300px;
}
.headline {
display: flex;
justify-content: center;
align-items: center;
}
.headline.zoom b {
opacity: 0;
}
.headline.zoom b.is-visible {
opacity: 1;
-webkit-animation: zoom-in 0.8s;
-moz-animation: zoom-in 0.8s;
animation: cubic-bezier(0.215, 0.610, 0.355, 1.000) zoom-in 0.8s;
}
.headline.zoom b.is-hidden {
-webkit-animation: zoom-out 0.8s;
-moz-animation: zoom-out 0.8s;
animation: cubic-bezier(0.215, 0.610, 0.355, 1.000) zoom-out 0.4s;
}
@-webkit-keyframes zoom-in {
0% {
opacity: 0;
-webkit-transform: translateZ(100px);
}
100% {
opacity: 1;
-webkit-transform: translateZ(0);
}
}
@-moz-keyframes zoom-in {
0% {
opacity: 0;
-moz-transform: translateZ(100px);
}
100% {
opacity: 1;
-moz-transform: translateZ(0);
}
}
@keyframes zoom-in {
0% {
opacity: 0;
-webkit-transform: translateZ(100px);
-moz-transform: translateZ(100px);
-ms-transform: translateZ(100px);
-o-transform: translateZ(100px);
transform: translateZ(100px);
}
100% {
opacity: 1;
-webkit-transform: translateZ(0);
-moz-transform: translateZ(0);
-ms-transform: translateZ(0);
-o-transform: translateZ(0);
transform: translateZ(0);
}
}
@-webkit-keyframes zoom-out {
0% {
opacity: 1;
-webkit-transform: translateZ(0);
}
100% {
opacity: 0;
-webkit-transform: translateZ(-100px);
}
}
@-moz-keyframes zoom-out {
0% {
opacity: 1;
-moz-transform: translateZ(0);
}
100% {
opacity: 0;
-moz-transform: translateZ(-100px);
}
}
@keyframes zoom-out {
0% {
opacity: 1;
-webkit-transform: translateZ(0);
-moz-transform: translateZ(0);
-ms-transform: translateZ(0);
-o-transform: translateZ(0);
transform: translateZ(0);
}
100% {
opacity: 0;
-webkit-transform: translateZ(-100px);
-moz-transform: translateZ(-100px);
-ms-transform: translateZ(-100px);
-o-transform: translateZ(-100px);
transform: translateZ(-100px);
}
}
.setting-section {
position: absolute;
top: 0;
right: 0;
padding: 22px;
}
.ant-space-item .ant-switch {
margin: 2px 0 4px;
}
</style>
{{ template "page/head_end" .}} {{ template "page/head_end" .}}
{{ template "page/body_start" .}} {{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme"> <a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' login-app'">
<transition name="list" appear> <transition name="list" appear>
<a-layout-content class="under" :style="{ minHeight: '0' }"> <a-layout-content class="under min-h-0">
<div class="waves-header"> <div class="waves-header">
<div class="waves-inner-header"></div> <div class="waves-inner-header"></div>
<svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" <svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
@ -466,11 +20,10 @@
</g> </g>
</svg> </svg>
</div> </div>
<a-row type="flex" justify="center" align="middle" <a-row type="flex" justify="center" align="middle" class="h-100 overflow-y-auto overflow-x-hidden">
:style="{ height: '100%', overflow: 'auto', overflowX: 'hidden' }"> <a-col :xs="22" :sm="12" :md="10" :lg="8" :xl="6" :xxl="5" id="login" class="my-3rem">
<a-col :xs="22" :sm="12" :md="10" :lg="8" :xl="6" :xxl="5" id="login" :style="{ margin: '3rem 0' }">
<template v-if="!loadingStates.fetched"> <template v-if="!loadingStates.fetched">
<div :style="{ textAlign: 'center' }"> <div class="text-center">
<a-spin size="large" /> <a-spin size="large" />
</div> </div>
</template> </template>
@ -482,7 +35,7 @@
<a-space direction="vertical" :size="10"> <a-space direction="vertical" :size="10">
<a-theme-switch-login></a-theme-switch-login> <a-theme-switch-login></a-theme-switch-login>
<span>{{ i18n "pages.settings.language" }}</span> <span>{{ i18n "pages.settings.language" }}</span>
<a-select ref="selectLang" :style="{ width: '100%' }" v-model="lang" <a-select ref="selectLang" class="w-100" v-model="lang"
@change="LanguageManager.setLanguage(lang)" :dropdown-class-name="themeSwitcher.currentTheme"> @change="LanguageManager.setLanguage(lang)" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="l.value" label="English" v-for="l in LanguageManager.supportedLanguages"> <a-select-option :value="l.value" label="English" v-for="l in LanguageManager.supportedLanguages">
<span role="img" aria-label="l.name" v-text="l.icon"></span> <span role="img" aria-label="l.name" v-text="l.icon"></span>
@ -511,26 +64,24 @@
<a-form-item> <a-form-item>
<a-input autocomplete="username" name="username" v-model.trim="user.username" <a-input autocomplete="username" name="username" v-model.trim="user.username"
placeholder='{{ i18n "username" }}' autofocus required> placeholder='{{ i18n "username" }}' autofocus required>
<a-icon slot="prefix" type="user" :style="{ fontSize: '1rem' }"></a-icon> <a-icon slot="prefix" type="user" class="fs-1rem"></a-icon>
</a-input> </a-input>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<a-input-password autocomplete="password" name="password" v-model.trim="user.password" <a-input-password autocomplete="password" name="password" v-model.trim="user.password"
placeholder='{{ i18n "password" }}' required> placeholder='{{ i18n "password" }}' required>
<a-icon slot="prefix" type="lock" :style="{ fontSize: '1rem' }"></a-icon> <a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon>
</a-input-password> </a-input-password>
</a-form-item> </a-form-item>
<a-form-item v-if="twoFactorEnable"> <a-form-item v-if="twoFactorEnable">
<a-input autocomplete="one-time-code" name="twoFactorCode" v-model.trim="user.twoFactorCode" <a-input autocomplete="one-time-code" name="twoFactorCode" v-model.trim="user.twoFactorCode"
placeholder='{{ i18n "twoFactorCode" }}' required> placeholder='{{ i18n "twoFactorCode" }}' required>
<a-icon slot="prefix" type="key" :style="{ fontSize: '1rem' }"></a-icon> <a-icon slot="prefix" type="key" class="fs-1rem"></a-icon>
</a-input> </a-input>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<a-row justify="center" class="centered"> <a-row justify="center" class="centered">
<div <div class="wave-btn-bg wave-btn-bg-cl h-50px mt-1rem" :style="loadingStates.spinning ? 'width: 52px' : 'display: inline-block'">
:style="{ height: '50px', marginTop: '1rem', ...loadingStates.spinning ? { width: '52px' } : { display: 'inline-block' } }"
class="wave-btn-bg wave-btn-bg-cl">
<a-button class="ant-btn-primary-login" type="primary" :loading="loadingStates.spinning" <a-button class="ant-btn-primary-login" type="primary" :loading="loadingStates.spinning"
:icon="loadingStates.spinning ? 'poweroff' : undefined" html-type="submit"> :icon="loadingStates.spinning ? 'poweroff' : undefined" html-type="submit">
[[ loadingStates.spinning ? '' : '{{ i18n "login" }}' ]] [[ loadingStates.spinning ? '' : '{{ i18n "login" }}' ]]
@ -633,5 +184,80 @@
newWord.classList.add('is-visible'); newWord.classList.add('is-visible');
} }
}); });
const pm_input_selector = 'input.ant-input, textarea.ant-input';
const pm_strip_props = [
'background',
'background-color',
'background-image',
'color'
];
const pm_observed_forms = new WeakSet();
function pm_strip_inline(el) {
if (!el || el.nodeType !== 1 || !el.matches?.(pm_input_selector)) return;
let did_change = false;
for (const prop of pm_strip_props) {
if (el.style.getPropertyValue(prop)) {
el.style.removeProperty(prop);
did_change = true;
}
}
if (did_change && el.style.length === 0) {
el.removeAttribute('style');
}
}
function pm_attach_observer(form) {
if (pm_observed_forms.has(form)) return;
pm_observed_forms.add(form);
form.querySelectorAll(pm_input_selector).forEach(pm_strip_inline);
const pm_mo = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.type === 'attributes') {
pm_strip_inline(m.target);
} else if (m.type === 'childList') {
for (const n of m.addedNodes) {
if (n.nodeType !== 1) continue;
if (n.matches?.(pm_input_selector)) pm_strip_inline(n);
n.querySelectorAll?.(pm_input_selector).forEach(pm_strip_inline);
}
}
}
});
pm_mo.observe(form, {
attributes: true,
attributeFilter: ['style'],
childList: true,
subtree: true
});
}
function pm_init() {
document.querySelectorAll('form.ant-form').forEach(pm_attach_observer);
const pm_host = document.getElementById('login') || document.body;
const pm_wait_for_forms = new MutationObserver(mutations => {
for (const m of mutations) {
for (const n of m.addedNodes) {
if (n.nodeType !== 1) continue;
if (n.matches?.('form.ant-form')) pm_attach_observer(n);
n.querySelectorAll?.('form.ant-form').forEach(pm_attach_observer);
}
}
});
pm_wait_for_forms.observe(pm_host, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', pm_init, { once: true });
} else {
pm_init();
}
</script> </script>
{{ template "page/body_end" .}} {{ template "page/body_end" .}}

View file

@ -102,14 +102,15 @@
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[ inbound.stream.security ]]</a-tag> <a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[ inbound.stream.security ]]</a-tag>
<br /> <br />
<td>Authentication</td> <td>Authentication</td>
<a-tag :color="inbound.settings.selectedAuth ? 'green' : 'red'">[[ inbound.settings.selectedAuth ? inbound.settings.selectedAuth : '' ]]</a-tag> <a-tag v-if="inbound.settings.selectedAuth" color="green">[[ inbound.settings.selectedAuth ? inbound.settings.selectedAuth : '' ]]</a-tag>
<a-tag v-else color="red">{{ i18n "none" }}</a-tag>
<br /> <br />
{{ i18n "encryption" }} {{ i18n "encryption" }}
<a-tag :color="inbound.settings.encryption ? 'green' : 'red'">[[ inbound.settings.encryption ? inbound.settings.encryption : '' ]]</a-tag> <a-tag :color="inbound.settings.encryption ? 'green' : 'red'">[[ inbound.settings.encryption ? inbound.settings.encryption : '' ]]</a-tag>
<br /> <br />
<template v-if="inbound.stream.security != 'none'"> <template v-if="inbound.stream.security != 'none'">
{{ i18n "domainName" }} {{ i18n "domainName" }}
<a-tag v-if="inbound.serverName" :color="inbound.serverName ? 'green' : 'orange'">[[ inbound.serverName ? inbound.serverName : '' ]]</a-tag> <a-tag v-if="inbound.serverName" color="green">[[ inbound.serverName ? inbound.serverName : '' ]]</a-tag>
<a-tag v-else color="orange">{{ i18n "none" }}</a-tag> <a-tag v-else color="orange">{{ i18n "none" }}</a-tag>
</template> </template>
</template> </template>
@ -179,9 +180,9 @@
<tr> <tr>
<td>{{ i18n "status" }}</td> <td>{{ i18n "status" }}</td>
<td> <td>
<a-tag v-if="isEnable" color="green">{{ i18n "enabled" }}</a-tag> <a-tag v-if="isDepleted" color="red">{{ i18n "depleted" }}</a-tag>
<a-tag v-else-if="isEnable" color="green">{{ i18n "enabled" }}</a-tag>
<a-tag v-else>{{ i18n "disabled" }}</a-tag> <a-tag v-else>{{ i18n "disabled" }}</a-tag>
<a-tag v-if="!isActive" color="red">{{ i18n "depleted" }}</a-tag>
</td> </td>
</tr> </tr>
<tr v-if="infoModal.clientStats"> <tr v-if="infoModal.clientStats">
@ -307,7 +308,7 @@
</tr-info-title> </tr-info-title>
<a :href="[[ infoModal.subLink ]]" target="_blank">[[ infoModal.subLink ]]</a> <a :href="[[ infoModal.subLink ]]" target="_blank">[[ infoModal.subLink ]]</a>
</tr-info-row> </tr-info-row>
<tr-info-row class="tr-info-row"> <tr-info-row class="tr-info-row" v-if="app.subSettings.subJsonEnable">
<tr-info-title class="tr-info-title"> <tr-info-title class="tr-info-title">
<a-tag color="purple">Json Link</a-tag> <a-tag color="purple">Json Link</a-tag>
<a-tooltip title='{{ i18n "copy" }}'> <a-tooltip title='{{ i18n "copy" }}'>
@ -523,7 +524,7 @@
this.dbInbound = new DBInbound(dbInbound); this.dbInbound = new DBInbound(dbInbound);
this.clientSettings = this.inbound.clients ? this.inbound.clients[index] : null; this.clientSettings = this.inbound.clients ? this.inbound.clients[index] : null;
this.isExpired = this.inbound.clients ? this.inbound.isExpiry(index) : this.dbInbound.isExpiry; this.isExpired = this.inbound.clients ? this.inbound.isExpiry(index) : this.dbInbound.isExpiry;
this.clientStats = this.inbound.clients ? this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) : []; this.clientStats = this.inbound.clients ? (this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) || null) : null;
if ( if (
[ [
@ -547,7 +548,7 @@
if (this.clientSettings) { if (this.clientSettings) {
if (this.clientSettings.subId) { if (this.clientSettings.subId) {
this.subLink = this.genSubLink(this.clientSettings.subId); this.subLink = this.genSubLink(this.clientSettings.subId);
this.subJsonLink = this.genSubJsonLink(this.clientSettings.subId); this.subJsonLink = app.subSettings.subJsonEnable ? this.genSubJsonLink(this.clientSettings.subId) : '';
} }
} }
this.visible = true; this.visible = true;
@ -586,6 +587,24 @@
} }
return infoModal.dbInbound.isEnable; return infoModal.dbInbound.isEnable;
}, },
get isDepleted() {
const stats = infoModal.clientStats;
const settings = infoModal.clientSettings;
if (!stats || !settings) {
return false;
}
const total = stats.total ?? 0;
const used = (stats.up ?? 0) + (stats.down ?? 0);
const hasTotal = total > 0;
const exhausted = hasTotal && used >= total;
const expiryTime = settings.expiryTime ?? 0;
const hasExpiry = expiryTime > 0;
const now = Date.now();
const expired = hasExpiry && now >= expiryTime;
return expired || exhausted;
},
}, },
methods: { methods: {
copy(content) { copy(content) {

View file

@ -30,7 +30,7 @@
</tr-qr-bg-inner> </tr-qr-bg-inner>
</tr-qr-bg> </tr-qr-bg>
</tr-qr-box> </tr-qr-box>
<tr-qr-box class="qr-box"> <tr-qr-box class="qr-box" v-if="app.subSettings.subJsonEnable">
<a-tag color="purple" class="qr-tag"><span>{{ i18n "pages.settings.subSettings"}} Json</span></a-tag> <a-tag color="purple" class="qr-tag"><span>{{ i18n "pages.settings.subSettings"}} Json</span></a-tag>
<tr-qr-bg class="qr-bg-sub"> <tr-qr-bg class="qr-bg-sub">
<tr-qr-bg-inner class="qr-bg-sub-inner"> <tr-qr-bg-inner class="qr-bg-sub-inner">
@ -262,7 +262,9 @@
if (qrModal.client && qrModal.client.subId) { if (qrModal.client && qrModal.client.subId) {
qrModal.subId = qrModal.client.subId; qrModal.subId = qrModal.client.subId;
this.setQrCode("qrCode-sub", this.genSubLink(qrModal.subId)); this.setQrCode("qrCode-sub", this.genSubLink(qrModal.subId));
this.setQrCode("qrCode-subJson", this.genSubJsonLink(qrModal.subId)); if (app.subSettings.subJsonEnable) {
this.setQrCode("qrCode-subJson", this.genSubJsonLink(qrModal.subId));
}
} }
qrModal.qrcodes.forEach((element, index) => { qrModal.qrcodes.forEach((element, index) => {
this.setQrCode("qrCode-" + index, element.link); this.setQrCode("qrCode-" + index, element.link);

View file

@ -9,7 +9,7 @@
</template> Source IPs <a-icon type="question-circle"></a-icon> </template> Source IPs <a-icon type="question-circle"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-input v-model.trim="ruleModal.rule.sourceIP"></a-input> <a-input v-model.trim="ruleModal.rule.sourceIP" placeholder="e.g. 0.0.0.0/8, fc00::/7, geoip:ir"></a-input>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<template slot="label"> <template slot="label">
@ -19,7 +19,17 @@
</template> Source Port <a-icon type="question-circle"></a-icon> </template> Source Port <a-icon type="question-circle"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-input v-model.trim="ruleModal.rule.sourcePort"></a-input> <a-input v-model.trim="ruleModal.rule.sourcePort" placeholder="e.g. 53,443,1000-2000"></a-input>
</a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.xray.rules.useComma" }}</span>
</template> VLESS Route <a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="ruleModal.rule.vlessRoute" placeholder="e.g. 53,443,1000-2000"></a-input>
</a-form-item> </a-form-item>
<a-form-item label='Network'> <a-form-item label='Network'>
<a-select v-model="ruleModal.rule.network" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="ruleModal.rule.network" :dropdown-class-name="themeSwitcher.currentTheme">
@ -52,7 +62,7 @@
</template> IP <a-icon type="question-circle"></a-icon> </template> IP <a-icon type="question-circle"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-input v-model.trim="ruleModal.rule.ip"></a-input> <a-input v-model.trim="ruleModal.rule.ip" placeholder="e.g. 0.0.0.0/8, fc00::/7, geoip:ir"></a-input>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<template slot="label"> <template slot="label">
@ -62,7 +72,7 @@
</template> Domain <a-icon type="question-circle"></a-icon> </template> Domain <a-icon type="question-circle"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-input v-model.trim="ruleModal.rule.domain"></a-input> <a-input v-model.trim="ruleModal.rule.domain" placeholder="e.g. google.com, geosite:cn"></a-input>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<template slot="label"> <template slot="label">
@ -72,7 +82,7 @@
</template> User <a-icon type="question-circle"></a-icon> </template> User <a-icon type="question-circle"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-input v-model.trim="ruleModal.rule.user"></a-input> <a-input v-model.trim="ruleModal.rule.user" placeholder="e.g. email address"></a-input>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<template slot="label"> <template slot="label">
@ -82,7 +92,7 @@
</template> Port <a-icon type="question-circle"></a-icon> </template> Port <a-icon type="question-circle"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-input v-model.trim="ruleModal.rule.port"></a-input> <a-input v-model.trim="ruleModal.rule.port" placeholder="e.g. 53,443,1000-2000"></a-input>
</a-form-item> </a-form-item>
<a-form-item label='Inbound Tags'> <a-form-item label='Inbound Tags'>
<a-select v-model="ruleModal.rule.inboundTag" mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="ruleModal.rule.inboundTag" mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme">
@ -122,6 +132,7 @@
ip: "", ip: "",
port: "", port: "",
sourcePort: "", sourcePort: "",
vlessRoute: "",
network: "", network: "",
sourceIP: "", sourceIP: "",
user: "", user: "",
@ -155,6 +166,7 @@
this.rule.ip = rule.ip ? rule.ip.join(',') : []; this.rule.ip = rule.ip ? rule.ip.join(',') : [];
this.rule.port = rule.port; this.rule.port = rule.port;
this.rule.sourcePort = rule.sourcePort; this.rule.sourcePort = rule.sourcePort;
this.rule.vlessRoute = rule.vlessRoute;
this.rule.network = rule.network; this.rule.network = rule.network;
this.rule.sourceIP = rule.sourceIP ? rule.sourceIP.join(',') : []; this.rule.sourceIP = rule.sourceIP ? rule.sourceIP.join(',') : [];
this.rule.user = rule.user ? rule.user.join(',') : []; this.rule.user = rule.user ? rule.user.join(',') : [];
@ -169,6 +181,7 @@
ip: "", ip: "",
port: "", port: "",
sourcePort: "", sourcePort: "",
vlessRoute: "",
network: "", network: "",
sourceIP: "", sourceIP: "",
user: "", user: "",
@ -210,6 +223,7 @@
rule.ip = value.ip.length > 0 ? value.ip.split(',') : []; rule.ip = value.ip.length > 0 ? value.ip.split(',') : [];
rule.port = value.port; rule.port = value.port;
rule.sourcePort = value.sourcePort; rule.sourcePort = value.sourcePort;
rule.vlessRoute = value.vlessRoute;
rule.network = value.network; rule.network = value.network;
rule.sourceIP = value.sourceIP.length > 0 ? value.sourceIP.split(',') : []; rule.sourceIP = value.sourceIP.length > 0 ? value.sourceIP.split(',') : [];
rule.user = value.user.length > 0 ? value.user.split(',') : []; rule.user = value.user.length > 0 ? value.user.split(',') : [];

View file

@ -1,67 +1,8 @@
{{ template "page/head_start" .}} {{ template "page/head_start" .}}
<style>
@media (min-width: 769px) {
.ant-layout-content {
margin: 24px 16px;
}
}
@media (max-width: 768px) {
.ant-tabs-nav .ant-tabs-tab {
margin: 0;
padding: 12px .5rem;
}
}
.ant-tabs-bar {
margin: 0;
}
.ant-list-item {
display: block;
}
.alert-msg {
color: rgb(194, 117, 18);
font-weight: normal;
font-size: 16px;
padding: .5rem 1rem;
text-align: center;
background: rgb(255 145 0 / 15%);
margin: 1.5rem 2.5rem 0rem;
border-radius: .5rem;
transition: all 0.5s;
animation: signal 3s cubic-bezier(0.18, 0.89, 0.32, 1.28) infinite;
}
.alert-msg:hover {
cursor: default;
transition-duration: .3s;
animation: signal 0.9s ease infinite;
}
@keyframes signal {
0% {
box-shadow: 0 0 0 0 rgba(194, 118, 18, 0.5);
}
50% {
box-shadow: 0 0 0 6px rgba(0, 0, 0, 0);
}
100% {
box-shadow: 0 0 0 6px rgba(0, 0, 0, 0);
}
}
.alert-msg>i {
color: inherit;
font-size: 24px;
}
.dark .ant-input-password-icon {
color: var(--dark-color-text-primary);
}
.ant-collapse-content-box .ant-alert {
margin-block-end: 12px;
}
</style>
{{ template "page/head_end" .}} {{ template "page/head_end" .}}
{{ template "page/body_start" .}} {{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme"> <a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' settings-page'">
<a-sidebar></a-sidebar> <a-sidebar></a-sidebar>
<a-layout id="content-layout"> <a-layout id="content-layout">
<a-layout-content> <a-layout-content>
@ -138,7 +79,7 @@
</template> </template>
{{ template "settings/panel/subscription/general" . }} {{ template "settings/panel/subscription/general" . }}
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="5" v-if="allSetting.subEnable" :style="{ paddingTop: '20px' }"> <a-tab-pane key="5" v-if="allSetting.subJsonEnable" :style="{ paddingTop: '20px' }">
<template #tab> <template #tab>
<a-icon type="code"></a-icon> <a-icon type="code"></a-icon>
<span>{{ i18n "pages.settings.subSettings" }} (JSON)</span> <span>{{ i18n "pages.settings.subSettings" }} (JSON)</span>
@ -582,6 +523,8 @@
if (this.allSetting.subEnable) { if (this.allSetting.subEnable) {
subPath = this.allSetting.subURI.length > 0 ? new URL(this.allSetting.subURI).pathname : this.allSetting.subPath; subPath = this.allSetting.subURI.length > 0 ? new URL(this.allSetting.subURI).pathname : this.allSetting.subPath;
if (subPath == '/sub/') alerts.push('{{ i18n "secAlertSubURI" }}'); if (subPath == '/sub/') alerts.push('{{ i18n "secAlertSubURI" }}');
}
if (this.allSetting.subJsonEnable) {
subJsonPath = this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname : this.allSetting.subJsonPath; subJsonPath = this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname : this.allSetting.subJsonPath;
if (subJsonPath == '/json/') alerts.push('{{ i18n "secAlertSubJsonURI" }}'); if (subJsonPath == '/json/') alerts.push('{{ i18n "secAlertSubJsonURI" }}');
} }

View file

@ -8,6 +8,13 @@
<a-switch v-model="allSetting.subEnable"></a-switch> <a-switch v-model="allSetting.subEnable"></a-switch>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>JSON Subscription</template>
<template #description>{{ i18n "pages.settings.subJsonEnable"}}</template>
<template #control>
<a-switch v-model="allSetting.subJsonEnable"></a-switch>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subTitle"}}</template> <template #title>{{ i18n "pages.settings.subTitle"}}</template>
<template #description>{{ i18n "pages.settings.subTitleDesc"}}</template> <template #description>{{ i18n "pages.settings.subTitleDesc"}}</template>

View file

@ -0,0 +1,276 @@
{{ template "page/head_start" .}}
<script src="{{ .base_path }}assets/moment/moment.min.js"></script>
<script src="{{ .base_path }}assets/moment/moment-jalali.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script>
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/util/date-util.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' subscription-page'">
<a-layout-content class="p-2">
<a-row type="flex" justify="center" class="mt-2">
<a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12">
<a-card hoverable class="subscription-card">
<template #title>
<a-space>
<span>{{ i18n "subscription.title" }}</span>
<a-tag>{{ .sId }}</a-tag>
</a-space>
</template>
<template #extra>
<a-popover
:overlay-class-name="themeSwitcher.currentTheme"
title='{{ i18n "menu.settings" }}'
placement="bottomRight" trigger="click">
<template #content>
<a-space direction="vertical" :size="10">
<a-theme-switch-login></a-theme-switch-login>
<span>{{ i18n "pages.settings.language"
}}</span>
<a-select ref="selectLang" class="w-100"
v-model="lang"
@change="LanguageManager.setLanguage(lang)"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="l.value"
label="English"
v-for="l in LanguageManager.supportedLanguages"
:key="l.value">
<span role="img"
:aria-label="l.name"
v-text="l.icon"></span>
&nbsp;&nbsp;<span
v-text="l.name"></span>
</a-select-option>
</a-select>
</a-space>
</template>
<a-button shape="circle" icon="setting"></a-button>
</a-popover>
</template>
<a-form layout="vertical">
<a-form-item>
<a-space direction="vertical" align="center">
<a-row type="flex" :gutter="[8,8]"
justify="center" style="width:100%">
<a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24"
style="text-align:center;">
<tr-qr-box class="qr-box">
<a-tag color="purple"
class="qr-tag">
<span>{{ i18n
"pages.settings.subSettings"}}</span>
</a-tag>
<tr-qr-bg class="qr-bg-sub">
<tr-qr-bg-inner
class="qr-bg-sub-inner">
<canvas id="qrcode"
class="qr-cv"
title='{{ i18n "copy" }}'
@click="copy(app.subUrl)"></canvas>
</tr-qr-bg-inner>
</tr-qr-bg>
</tr-qr-box>
</a-col>
<a-col v-if="app.subJsonUrl" :xs="24" :sm="12"
style="text-align:center;">
<tr-qr-box class="qr-box">
<a-tag color="purple"
class="qr-tag">
<span>{{ i18n
"pages.settings.subSettings"}}
Json</span>
</a-tag>
<tr-qr-bg class="qr-bg-sub">
<tr-qr-bg-inner
class="qr-bg-sub-inner">
<canvas id="qrcode-subjson"
class="qr-cv"
title='{{ i18n "copy" }}'
@click="copy(app.subJsonUrl)"></canvas>
</tr-qr-bg-inner>
</tr-qr-bg>
</tr-qr-box>
</a-col>
</a-row>
</a-space>
</a-form-item>
<a-form-item>
<a-descriptions bordered :column="1" size="small">
<a-descriptions-item
label='{{ i18n "subscription.subId" }}'>[[
app.sId
]]</a-descriptions-item>
<a-descriptions-item
label='{{ i18n "subscription.status" }}'>
<template v-if="isUnlimited">
<a-tag color="purple">{{ i18n
"subscription.unlimited" }}</a-tag>
</template>
<template v-else>
<a-tag
:color="isActive ? 'green' : 'red'">[[
isActive ? '{{ i18n
"subscription.active" }}' : '{{ i18n
"subscription.inactive" }}'
]]</a-tag>
</template>
</a-descriptions-item>
<a-descriptions-item
label='{{ i18n "subscription.downloaded" }}'>[[
app.download
]]</a-descriptions-item>
<a-descriptions-item
label='{{ i18n "subscription.uploaded" }}'>[[
app.upload
]]</a-descriptions-item>
<a-descriptions-item
label='{{ i18n "usage" }}'>[[ app.used
]]</a-descriptions-item>
<a-descriptions-item
label='{{ i18n "subscription.totalQuota" }}'>[[
app.total
]]</a-descriptions-item>
<a-descriptions-item v-if="app.totalByte > 0"
label='{{ i18n "remained" }}'>[[
app.remained ]]</a-descriptions-item>
<a-descriptions-item
label='{{ i18n "lastOnline" }}'>
<template v-if="app.lastOnlineMs > 0">
<template
v-if="app.datepicker === 'gregorian'">
[[
DateUtil.formatMillis(app.lastOnlineMs)
]]
</template>
<template v-else>
[[
DateUtil.convertToJalalian(moment(app.lastOnlineMs))
]]
</template>
</template>
<template v-else>
<span>-</span>
</template>
</a-descriptions-item>
<a-descriptions-item
label='{{ i18n "subscription.expiry" }}'>
<template v-if="app.expireMs === 0">
{{ i18n "subscription.noExpiry" }}
</template>
<template v-else>
<template
v-if="app.datepicker === 'gregorian'">
[[
DateUtil.formatMillis(app.expireMs)
]]
</template>
<template v-else>
[[
DateUtil.convertToJalalian(moment(app.expireMs))
]]
</template>
</template>
</a-descriptions-item>
</a-descriptions>
</a-form-item>
</a-form>
<br />
<a-list bordered>
<a-list-item v-for="(link, idx) in links" :key="link">
<div style="width:100%; text-align:center;">
<a-button type="primary" :block="isMobile"
@click="copy(link)">[[ linkName(link, idx)
]]</a-button>
</div>
</a-list-item>
</a-list>
<br />
<a-form layout="vertical">
<a-form-item>
<a-row type="flex" justify="center" :gutter="[8,8]"
style="width:100%">
<a-col :xs="24" :sm="12"
style="text-align:center;">
<!-- Android dropdown -->
<a-dropdown :trigger="['click']">
<a-button icon="android" :block="isMobile"
:style="{ marginTop: isMobile ? '6px' : 0 }"
size="large" type="primary">
Android <a-icon type="down" />
</a-button>
<a-menu slot="overlay"
:class="themeSwitcher.currentTheme">
<a-menu-item key="android-v2box"
@click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item>
<a-menu-item key="android-v2rayng"
@click="open('v2rayng://install-config?url=' + encodeURIComponent(app.subUrl))">V2RayNG</a-menu-item>
<a-menu-item key="android-singbox"
@click="copy(app.subUrl)">Sing-box</a-menu-item>
<a-menu-item key="android-v2raytun"
@click="copy(app.subUrl)">V2RayTun</a-menu-item>
<a-menu-item key="android-npvtunnel"
@click="copy(app.subUrl)">NPV
Tunnel</a-menu-item>
</a-menu>
</a-dropdown>
</a-col>
<a-col :xs="24" :sm="12"
style="text-align:center;">
<!-- iOS dropdown -->
<a-dropdown :trigger="['click']">
<a-button icon="apple" :block="isMobile"
:style="{ marginTop: isMobile ? '6px' : 0 }"
size="large" type="primary">
iOS <a-icon type="down" />
</a-button>
<a-menu slot="overlay"
:class="themeSwitcher.currentTheme">
<a-menu-item key="ios-shadowrocket"
@click="open(shadowrocketUrl)">Shadowrocket</a-menu-item>
<a-menu-item key="ios-v2box"
@click="open(v2boxUrl)">V2Box</a-menu-item>
<a-menu-item key="ios-streisand"
@click="open(streisandUrl)">Streisand</a-menu-item>
<a-menu-item key="ios-v2raytun"
@click="copy(v2raytunUrl)">V2RayTun</a-menu-item>
<a-menu-item key="ios-npvtunnel"
@click="copy(npvtunUrl)">NPV
Tunnel
</a-menu-item>
</a-menu>
</a-dropdown>
</a-col>
</a-row>
</a-form-item>
</a-form>
</a-card>
</a-col>
</a-row>
</a-layout-content>
</a-layout>
<!-- Bootstrap data for external JS -->
<template id="subscription-data" data-sid="{{ .sId }}"
data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
data-download="{{ .download }}"
data-upload="{{ .upload }}" data-used="{{ .used }}"
data-total="{{ .total }}" data-remained="{{ .remained }}"
data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
data-downloadbyte="{{ .downloadByte }}"
data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
data-datepicker="{{ .datepicker }}"></template>
<textarea id="subscription-links"
style="display:none">{{ range .result }}{{ . }}
{{ end }}</textarea>
{{template "component/aThemeSwitch" .}}
<script src="{{ .base_path }}assets/js/subscription.js?{{ .cur_ver }}"></script>
{{ template "page/body_end" .}}

View file

@ -67,18 +67,22 @@
</template> </template>
<template slot="info" slot-scope="text, rule, index"> <template slot="info" slot-scope="text, rule, index">
<a-popover placement="bottomRight" <a-popover placement="bottomRight"
v-if="(rule.source+rule.sourcePort+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0" v-if="(rule.sourceIP+rule.sourcePort+rule.vlessRoute+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0"
:overlay-class-name="themeSwitcher.currentTheme" trigger="click"> :overlay-class-name="themeSwitcher.currentTheme" trigger="click">
<template slot="content"> <template slot="content">
<table cellpadding="2" :style="{ maxWidth: '300px' }"> <table cellpadding="2" :style="{ maxWidth: '300px' }">
<tr v-if="rule.source"> <tr v-if="rule.sourceIP">
<td>Source</td> <td>Source IP</td>
<td><a-tag color="blue" v-for="r in rule.source.split(',')">[[ r ]]</a-tag></td> <td><a-tag color="blue" v-for="r in rule.sourceIP.split(',')">[[ r ]]</a-tag></td>
</tr> </tr>
<tr v-if="rule.sourcePort"> <tr v-if="rule.sourcePort">
<td>Source Port</td> <td>Source Port</td>
<td><a-tag color="green" v-for="r in rule.sourcePort.split(',')">[[ r ]]</a-tag></td> <td><a-tag color="green" v-for="r in rule.sourcePort.split(',')">[[ r ]]</a-tag></td>
</tr> </tr>
<tr v-if="rule.vlessRoute">
<td>VLESS Route</td>
<td><a-tag color="geekblue" v-for="r in rule.vlessRoute.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.network"> <tr v-if="rule.network">
<td>Network</td> <td>Network</td>
<td><a-tag color="blue" v-for="r in rule.network.split(',')">[[ r ]]</a-tag></td> <td><a-tag color="blue" v-for="r in rule.network.split(',')">[[ r ]]</a-tag></td>

View file

@ -3,45 +3,10 @@
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/fold/foldgutter.css"> <link rel="stylesheet" href="{{ .base_path }}assets/codemirror/fold/foldgutter.css">
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/xq.min.css?{{ .cur_ver }}"> <link rel="stylesheet" href="{{ .base_path }}assets/codemirror/xq.min.css?{{ .cur_ver }}">
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/lint/lint.css"> <link rel="stylesheet" href="{{ .base_path }}assets/codemirror/lint/lint.css">
<style>
@media (min-width: 769px) {
.ant-layout-content {
margin: 24px 16px;
}
}
@media (max-width: 768px) {
.ant-tabs-nav .ant-tabs-tab {
margin: 0;
padding: 12px .5rem;
}
.ant-table-thead>tr>th,
.ant-table-tbody>tr>td {
padding: 10px 0px;
}
}
.ant-tabs-bar {
margin: 0;
}
.ant-list-item {
display: block;
}
.ant-list-item>li {
padding: 10px 20px !important;
}
.ant-collapse-content-box .ant-alert {
margin-block-end: 12px;
}
</style>
{{ template "page/head_end" .}} {{ template "page/head_end" .}}
{{ template "page/body_start" .}} {{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme"> <a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' xray-page'">
<a-sidebar></a-sidebar> <a-sidebar></a-sidebar>
<a-layout id="content-layout"> <a-layout id="content-layout">
<a-layout-content> <a-layout-content>
@ -181,8 +146,9 @@
{ title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } }, { title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
{ {
title: '{{ i18n "pages.xray.rules.source"}}', children: [ title: '{{ i18n "pages.xray.rules.source"}}', children: [
{ title: 'IP', dataIndex: "source", align: 'center', width: 20, ellipsis: true }, { title: 'IP', dataIndex: "sourceIP", align: 'center', width: 20, ellipsis: true },
{ title: '{{ i18n "pages.inbounds.port" }}', dataIndex: 'sourcePort', align: 'center', width: 10, ellipsis: true }] { title: '{{ i18n "pages.inbounds.port" }}', dataIndex: 'sourcePort', align: 'center', width: 10, ellipsis: true },
{ title: 'VLESS Route', dataIndex: 'vlessRoute', align: 'center', width: 15, ellipsis: true }]
}, },
{ {
title: '{{ i18n "pages.inbounds.network"}}', children: [ title: '{{ i18n "pages.inbounds.network"}}', children: [
@ -569,7 +535,9 @@
switch (o.protocol) { switch (o.protocol) {
case Protocols.VMess: case Protocols.VMess:
case Protocols.VLESS: case Protocols.VLESS:
serverObj = o.settings.vnext; if (o.settings && o.settings.address && o.settings.port) {
return [o.settings.address + ':' + o.settings.port];
}
break; break;
case Protocols.HTTP: case Protocols.HTTP:
case Protocols.Mixed: case Protocols.Mixed:

View file

@ -12,10 +12,10 @@ import (
"sort" "sort"
"time" "time"
"x-ui/database" "github.com/mhsanaei/3x-ui/v2/database"
"x-ui/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/xray" "github.com/mhsanaei/3x-ui/v2/xray"
) )
type CheckClientIpJob struct { type CheckClientIpJob struct {

View file

@ -4,7 +4,7 @@ import (
"strconv" "strconv"
"time" "time"
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/cpu"
) )

View file

@ -1,7 +1,7 @@
package job package job
import ( import (
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
) )
type CheckHashStorageJob struct { type CheckHashStorageJob struct {

View file

@ -1,8 +1,8 @@
package job package job
import ( import (
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
) )
type CheckXrayRunningJob struct { type CheckXrayRunningJob struct {

View file

@ -5,8 +5,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/xray" "github.com/mhsanaei/3x-ui/v2/xray"
) )
type ClearLogsJob struct{} type ClearLogsJob struct{}

View file

@ -0,0 +1,48 @@
package job
import (
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/service"
)
type Period string
type PeriodicTrafficResetJob struct {
inboundService service.InboundService
period Period
}
func NewPeriodicTrafficResetJob(period Period) *PeriodicTrafficResetJob {
return &PeriodicTrafficResetJob{
period: period,
}
}
func (j *PeriodicTrafficResetJob) Run() {
inbounds, err := j.inboundService.GetInboundsByTrafficReset(string(j.period))
if err != nil {
logger.Warning("Failed to get inbounds for traffic reset:", err)
return
}
if len(inbounds) == 0 {
return
}
logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds))
resetCount := 0
for _, inbound := range inbounds {
if err := j.inboundService.ResetAllClientTraffics(inbound.Id); err != nil {
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", err)
continue
}
resetCount++
logger.Infof("Reset traffic for inbound %d (%s)", inbound.Id, inbound.Remark)
}
if resetCount > 0 {
logger.Infof("Periodic traffic reset completed: %d inbounds reset", resetCount)
}
}

View file

@ -1,7 +1,7 @@
package job package job
import ( import (
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
) )
type LoginStatus byte type LoginStatus byte

View file

@ -2,9 +2,10 @@ package job
import ( import (
"encoding/json" "encoding/json"
"x-ui/logger"
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/xray" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/xray"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
) )

View file

@ -3,9 +3,10 @@ package locale
import ( import (
"embed" "embed"
"io/fs" "io/fs"
"os"
"strings" "strings"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/nicksnyder/go-i18n/v2/i18n" "github.com/nicksnyder/go-i18n/v2/i18n"
@ -48,10 +49,10 @@ func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
return nil return nil
} }
func createTemplateData(params []string, seperator ...string) map[string]any { func createTemplateData(params []string, separator ...string) map[string]any {
var sep string = "==" var sep string = "=="
if len(seperator) > 0 { if len(separator) > 0 {
sep = seperator[0] sep = separator[0]
} }
templateData := make(map[string]any) templateData := make(map[string]any)
@ -78,6 +79,11 @@ func I18n(i18nType I18nType, key string, params ...string) string {
templateData := createTemplateData(params) templateData := createTemplateData(params)
if localizer == nil {
// Fallback to key if localizer not ready; prevents nil panic on pages like sub
return key
}
msg, err := localizer.Localize(&i18n.LocalizeConfig{ msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: key, MessageID: key,
TemplateData: templateData, TemplateData: templateData,
@ -102,6 +108,15 @@ func initTGBotLocalizer(settingService SettingService) error {
func LocalizerMiddleware() gin.HandlerFunc { func LocalizerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// Ensure bundle is initialized so creating a Localizer won't panic
if i18nBundle == nil {
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
// Try lazy-load from disk when running sub server without InitLocalizer
if err := loadTranslationsFromDisk(i18nBundle); err != nil {
logger.Warning("i18n lazy load failed:", err)
}
}
var lang string var lang string
if cookie, err := c.Request.Cookie("lang"); err == nil { if cookie, err := c.Request.Cookie("lang"); err == nil {
@ -118,6 +133,25 @@ func LocalizerMiddleware() gin.HandlerFunc {
} }
} }
// loadTranslationsFromDisk attempts to load translation files from "web/translation" using the local filesystem.
func loadTranslationsFromDisk(bundle *i18n.Bundle) error {
root := os.DirFS("web")
return fs.WalkDir(root, "translation", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := fs.ReadFile(root, path)
if err != nil {
return err
}
_, err = bundle.ParseMessageFileBytes(data, path)
return err
})
}
func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error { func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
err := fs.WalkDir(i18nFS, "translation", err := fs.WalkDir(i18nFS, "translation",
func(path string, d fs.DirEntry, err error) error { func(path string, d fs.DirEntry, err error) error {

View file

@ -11,11 +11,11 @@ import (
"strings" "strings"
"time" "time"
"x-ui/database" "github.com/mhsanaei/3x-ui/v2/database"
"x-ui/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
"x-ui/xray" "github.com/mhsanaei/3x-ui/v2/xray"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -44,6 +44,16 @@ func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
return inbounds, nil return inbounds, nil
} }
func (s *InboundService) GetInboundsByTrafficReset(period string) ([]*model.Inbound, error) {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).Where("traffic_reset = ?", period).Find(&inbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
return inbounds, nil
}
func (s *InboundService) checkPortExist(listen string, port int, ignoreId int) (bool, error) { func (s *InboundService) checkPortExist(listen string, port int, ignoreId int) (bool, error) {
db := database.GetDB() db := database.GetDB()
if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" { if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
@ -412,6 +422,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
oldInbound.Remark = inbound.Remark oldInbound.Remark = inbound.Remark
oldInbound.Enable = inbound.Enable oldInbound.Enable = inbound.Enable
oldInbound.ExpiryTime = inbound.ExpiryTime oldInbound.ExpiryTime = inbound.ExpiryTime
oldInbound.TrafficReset = inbound.TrafficReset
oldInbound.Listen = inbound.Listen oldInbound.Listen = inbound.Listen
oldInbound.Port = inbound.Port oldInbound.Port = inbound.Port
oldInbound.Protocol = inbound.Protocol oldInbound.Protocol = inbound.Protocol
@ -711,6 +722,7 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
} }
func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId string) (bool, error) { func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId string) (bool, error) {
// TODO: check if TrafficReset field is updating
clients, err := s.GetClients(data) clients, err := s.GetClients(data)
if err != nil { if err != nil {
return false, err return false, err
@ -1279,7 +1291,7 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model
clientTraffic.Email = client.Email clientTraffic.Email = client.Email
clientTraffic.Total = client.TotalGB clientTraffic.Total = client.TotalGB
clientTraffic.ExpiryTime = client.ExpiryTime clientTraffic.ExpiryTime = client.ExpiryTime
clientTraffic.Enable = true clientTraffic.Enable = client.Enable
clientTraffic.Up = 0 clientTraffic.Up = 0
clientTraffic.Down = 0 clientTraffic.Down = 0
clientTraffic.Reset = client.Reset clientTraffic.Reset = client.Reset
@ -1292,7 +1304,7 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
result := tx.Model(xray.ClientTraffic{}). result := tx.Model(xray.ClientTraffic{}).
Where("email = ?", email). Where("email = ?", email).
Updates(map[string]any{ Updates(map[string]any{
"enable": true, "enable": client.Enable,
"email": client.Email, "email": client.Email,
"total": client.TotalGB, "total": client.TotalGB,
"expiry_time": client.ExpiryTime, "expiry_time": client.ExpiryTime,
@ -1703,6 +1715,7 @@ func (s *InboundService) ResetClientTrafficLimitByEmail(clientEmail string, tota
func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error { func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
db := database.GetDB() db := database.GetDB()
// Reset traffic stats in ClientTraffic table
result := db.Model(xray.ClientTraffic{}). result := db.Model(xray.ClientTraffic{}).
Where("email = ?", clientEmail). Where("email = ?", clientEmail).
Updates(map[string]any{"enable": true, "up": 0, "down": 0}) Updates(map[string]any{"enable": true, "up": 0, "down": 0})
@ -1711,6 +1724,7 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
if err != nil { if err != nil {
return err return err
} }
return nil return nil
} }
@ -1778,20 +1792,39 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (bool, e
func (s *InboundService) ResetAllClientTraffics(id int) error { func (s *InboundService) ResetAllClientTraffics(id int) error {
db := database.GetDB() db := database.GetDB()
now := time.Now().Unix() * 1000
whereText := "inbound_id " return db.Transaction(func(tx *gorm.DB) error {
if id == -1 { whereText := "inbound_id "
whereText += " > ?" if id == -1 {
} else { whereText += " > ?"
whereText += " = ?" } else {
} whereText += " = ?"
}
result := db.Model(xray.ClientTraffic{}). // Reset client traffics
Where(whereText, id). result := tx.Model(xray.ClientTraffic{}).
Updates(map[string]any{"enable": true, "up": 0, "down": 0}) Where(whereText, id).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
err := result.Error if result.Error != nil {
return err return result.Error
}
// Update lastTrafficResetTime for the inbound(s)
inboundWhereText := "id "
if id == -1 {
inboundWhereText += " > ?"
} else {
inboundWhereText += " = ?"
}
result = tx.Model(model.Inbound{}).
Where(inboundWhereText, id).
Update("last_traffic_reset_time", now)
return result.Error
})
} }
func (s *InboundService) ResetAllTraffics() error { func (s *InboundService) ResetAllTraffics() error {
@ -1823,8 +1856,14 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
whereText += "= ?" whereText += "= ?"
} }
// Only consider truly depleted clients: expired OR traffic exhausted
now := time.Now().Unix() * 1000
depletedClients := []xray.ClientTraffic{} depletedClients := []xray.ClientTraffic{}
err = db.Model(xray.ClientTraffic{}).Where(whereText+" and enable = ?", id, false).Select("inbound_id, GROUP_CONCAT(email) as email").Group("inbound_id").Find(&depletedClients).Error err = db.Model(xray.ClientTraffic{}).
Where(whereText+" and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))", id, now).
Select("inbound_id, GROUP_CONCAT(email) as email").
Group("inbound_id").
Find(&depletedClients).Error
if err != nil { if err != nil {
return err return err
} }
@ -1875,7 +1914,8 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
} }
} }
err = tx.Where(whereText+" and enable = ?", id, false).Delete(xray.ClientTraffic{}).Error // Delete stats only for truly depleted clients
err = tx.Where(whereText+" and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))", id, now).Delete(xray.ClientTraffic{}).Error
if err != nil { if err != nil {
return err return err
} }
@ -1923,18 +1963,17 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
} }
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) { func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
db := database.GetDB() // Prefer retrieving along with client to reflect actual enabled state from inbound settings
var traffics []*xray.ClientTraffic t, client, err := s.GetClientByEmail(email)
err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error
if err != nil { if err != nil {
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err) logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
return nil, err return nil, err
} }
if len(traffics) > 0 { if t != nil && client != nil {
return traffics[0], nil t.Enable = client.Enable
t.SubId = client.SubID
return t, nil
} }
return nil, nil return nil, nil
} }
@ -1969,6 +2008,13 @@ func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic,
logger.Debug(err) logger.Debug(err)
return nil, err return nil, err
} }
// Reconcile enable flag with client settings per email to avoid stale DB value
for i := range traffics {
if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil {
traffics[i].Enable = client.Enable
traffics[i].SubId = client.SubID
}
}
return traffics, err return traffics, err
} }

View file

@ -1,10 +1,10 @@
package service package service
import ( import (
"x-ui/database" "github.com/mhsanaei/3x-ui/v2/database"
"x-ui/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/xray" "github.com/mhsanaei/3x-ui/v2/xray"
"gorm.io/gorm" "gorm.io/gorm"
) )

View file

@ -5,7 +5,7 @@ import (
"syscall" "syscall"
"time" "time"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
) )
type PanelService struct{} type PanelService struct{}

View file

@ -16,14 +16,15 @@ import (
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"x-ui/config" "github.com/mhsanaei/3x-ui/v2/config"
"x-ui/database" "github.com/mhsanaei/3x-ui/v2/database"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
"x-ui/util/sys" "github.com/mhsanaei/3x-ui/v2/util/sys"
"x-ui/xray" "github.com/mhsanaei/3x-ui/v2/xray"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/cpu"
@ -93,11 +94,94 @@ type Release struct {
} }
type ServerService struct { type ServerService struct {
xrayService XrayService xrayService XrayService
inboundService InboundService inboundService InboundService
cachedIPv4 string cachedIPv4 string
cachedIPv6 string cachedIPv6 string
noIPv6 bool noIPv6 bool
mu sync.Mutex
lastCPUTimes cpu.TimesStat
hasLastCPUSample bool
emaCPU float64
cpuHistory []CPUSample
cachedCpuSpeedMhz float64
lastCpuInfoAttempt time.Time
}
// AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds over recent data.
func (s *ServerService) AggregateCpuHistory(bucketSeconds int, maxPoints int) []map[string]any {
if bucketSeconds <= 0 || maxPoints <= 0 {
return nil
}
cutoff := time.Now().Add(-time.Duration(bucketSeconds*maxPoints) * time.Second).Unix()
s.mu.Lock()
// find start index (history sorted ascending)
hist := s.cpuHistory
// binary-ish scan (simple linear from end since size capped ~10800 is fine)
startIdx := 0
for i := len(hist) - 1; i >= 0; i-- {
if hist[i].T < cutoff {
startIdx = i + 1
break
}
}
if startIdx >= len(hist) {
s.mu.Unlock()
return []map[string]any{}
}
slice := hist[startIdx:]
// copy for unlock
tmp := make([]CPUSample, len(slice))
copy(tmp, slice)
s.mu.Unlock()
if len(tmp) == 0 {
return []map[string]any{}
}
var out []map[string]any
var acc []float64
bSize := int64(bucketSeconds)
curBucket := (tmp[0].T / bSize) * bSize
flush := func(ts int64) {
if len(acc) == 0 {
return
}
sum := 0.0
for _, v := range acc {
sum += v
}
avg := sum / float64(len(acc))
out = append(out, map[string]any{"t": ts, "cpu": avg})
acc = acc[:0]
}
for _, p := range tmp {
b := (p.T / bSize) * bSize
if b != curBucket {
flush(curBucket)
curBucket = b
}
acc = append(acc, p.Cpu)
}
flush(curBucket)
if len(out) > maxPoints {
out = out[len(out)-maxPoints:]
}
return out
}
// CPUSample single CPU utilization sample
type CPUSample struct {
T int64 `json:"t"` // unix seconds
Cpu float64 `json:"cpu"` // percent 0..100
}
type LogEntry struct {
DateTime time.Time
FromAddress string
ToAddress string
Inbound string
Outbound string
Email string
Event int
} }
func getPublicIP(url string) string { func getPublicIP(url string) string {
@ -139,11 +223,11 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
} }
// CPU stats // CPU stats
percents, err := cpu.Percent(0, false) util, err := s.sampleCPUUtilization()
if err != nil { if err != nil {
logger.Warning("get cpu percent failed:", err) logger.Warning("get cpu percent failed:", err)
} else { } else {
status.Cpu = percents[0] status.Cpu = util
} }
status.CpuCores, err = cpu.Counts(false) status.CpuCores, err = cpu.Counts(false)
@ -153,13 +237,30 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.LogicalPro = runtime.NumCPU() status.LogicalPro = runtime.NumCPU()
cpuInfos, err := cpu.Info() if status.CpuSpeedMhz = s.cachedCpuSpeedMhz; s.cachedCpuSpeedMhz == 0 && time.Since(s.lastCpuInfoAttempt) > 5*time.Minute {
if err != nil { s.lastCpuInfoAttempt = time.Now()
logger.Warning("get cpu info failed:", err) done := make(chan struct{})
} else if len(cpuInfos) > 0 { go func() {
status.CpuSpeedMhz = cpuInfos[0].Mhz defer close(done)
} else { cpuInfos, err := cpu.Info()
logger.Warning("could not find cpu info") if err != nil {
logger.Warning("get cpu info failed:", err)
return
}
if len(cpuInfos) > 0 {
s.cachedCpuSpeedMhz = cpuInfos[0].Mhz
status.CpuSpeedMhz = s.cachedCpuSpeedMhz
} else {
logger.Warning("could not find cpu info")
}
}()
select {
case <-done:
case <-time.After(1500 * time.Millisecond):
logger.Warning("cpu info query timed out; will retry later")
}
} else if s.cachedCpuSpeedMhz != 0 {
status.CpuSpeedMhz = s.cachedCpuSpeedMhz
} }
// Uptime // Uptime
@ -307,6 +408,103 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
return status return status
} }
func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
const capacity = 9000 // ~5 hours @ 2s interval
s.mu.Lock()
defer s.mu.Unlock()
p := CPUSample{T: t.Unix(), Cpu: v}
if n := len(s.cpuHistory); n > 0 && s.cpuHistory[n-1].T == p.T {
s.cpuHistory[n-1] = p
} else {
s.cpuHistory = append(s.cpuHistory, p)
}
if len(s.cpuHistory) > capacity {
s.cpuHistory = s.cpuHistory[len(s.cpuHistory)-capacity:]
}
}
func (s *ServerService) sampleCPUUtilization() (float64, error) {
// Prefer native Windows API to avoid external deps for CPU percent
if runtime.GOOS == "windows" {
if pct, err := sys.CPUPercentRaw(); err == nil {
s.mu.Lock()
// Smooth with EMA
const alpha = 0.3
if s.emaCPU == 0 {
s.emaCPU = pct
} else {
s.emaCPU = alpha*pct + (1-alpha)*s.emaCPU
}
val := s.emaCPU
s.mu.Unlock()
return val, nil
}
// If native call fails, fall back to gopsutil times
}
// Read aggregate CPU times (all CPUs combined)
times, err := cpu.Times(false)
if err != nil {
return 0, err
}
if len(times) == 0 {
return 0, fmt.Errorf("no cpu times available")
}
cur := times[0]
s.mu.Lock()
defer s.mu.Unlock()
// If this is the first sample, initialize and return current EMA (0 by default)
if !s.hasLastCPUSample {
s.lastCPUTimes = cur
s.hasLastCPUSample = true
return s.emaCPU, nil
}
// Compute busy and total deltas
idleDelta := cur.Idle - s.lastCPUTimes.Idle
// Sum of busy deltas (exclude Idle)
busyDelta := (cur.User - s.lastCPUTimes.User) +
(cur.System - s.lastCPUTimes.System) +
(cur.Nice - s.lastCPUTimes.Nice) +
(cur.Iowait - s.lastCPUTimes.Iowait) +
(cur.Irq - s.lastCPUTimes.Irq) +
(cur.Softirq - s.lastCPUTimes.Softirq) +
(cur.Steal - s.lastCPUTimes.Steal) +
(cur.Guest - s.lastCPUTimes.Guest) +
(cur.GuestNice - s.lastCPUTimes.GuestNice)
totalDelta := busyDelta + idleDelta
// Update last sample for next time
s.lastCPUTimes = cur
// Guard against division by zero or negative deltas (e.g., counter resets)
if totalDelta <= 0 {
return s.emaCPU, nil
}
raw := 100.0 * (busyDelta / totalDelta)
if raw < 0 {
raw = 0
}
if raw > 100 {
raw = 100
}
// Exponential moving average to smooth spikes
const alpha = 0.3 // smoothing factor (0<alpha<=1). Higher = more responsive, lower = smoother
if s.emaCPU == 0 {
// Initialize EMA with the first real reading to avoid long warm-up from zero
s.emaCPU = raw
} else {
s.emaCPU = alpha*raw + (1-alpha)*s.emaCPU
}
return s.emaCPU, nil
}
func (s *ServerService) GetXrayVersions() ([]string, error) { func (s *ServerService) GetXrayVersions() ([]string, error) {
const ( const (
XrayURL = "https://api.github.com/repos/XTLS/Xray-core/releases" XrayURL = "https://api.github.com/repos/XTLS/Xray-core/releases"
@ -516,19 +714,25 @@ func (s *ServerService) GetXrayLogs(
showBlocked string, showBlocked string,
showProxy string, showProxy string,
freedoms []string, freedoms []string,
blackholes []string) []string { blackholes []string) []LogEntry {
const (
Direct = iota
Blocked
Proxied
)
countInt, _ := strconv.Atoi(count) countInt, _ := strconv.Atoi(count)
var lines []string var entries []LogEntry
pathToAccessLog, err := xray.GetAccessLogPath() pathToAccessLog, err := xray.GetAccessLogPath()
if err != nil { if err != nil {
return lines return nil
} }
file, err := os.Open(pathToAccessLog) file, err := os.Open(pathToAccessLog)
if err != nil { if err != nil {
return lines return nil
} }
defer file.Close() defer file.Close()
@ -547,37 +751,62 @@ func (s *ServerService) GetXrayLogs(
continue continue
} }
//adding suffixes to further distinguish entries by outbound var entry LogEntry
if hasSuffix(line, freedoms) { parts := strings.Fields(line)
for i, part := range parts {
if i == 0 {
dateTime, err := time.Parse("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1])
if err != nil {
continue
}
entry.DateTime = dateTime
}
if part == "from" {
entry.FromAddress = parts[i+1]
} else if part == "accepted" {
entry.ToAddress = parts[i+1]
} else if strings.HasPrefix(part, "[") {
entry.Inbound = part[1:]
} else if strings.HasSuffix(part, "]") {
entry.Outbound = part[:len(part)-1]
} else if part == "email:" {
entry.Email = parts[i+1]
}
}
if logEntryContains(line, freedoms) {
if showDirect == "false" { if showDirect == "false" {
continue continue
} }
line = line + " f" entry.Event = Direct
} else if hasSuffix(line, blackholes) { } else if logEntryContains(line, blackholes) {
if showBlocked == "false" { if showBlocked == "false" {
continue continue
} }
line = line + " b" entry.Event = Blocked
} else { } else {
if showProxy == "false" { if showProxy == "false" {
continue continue
} }
line = line + " p" entry.Event = Proxied
} }
lines = append(lines, line) entries = append(entries, entry)
} }
if len(lines) > countInt { if len(entries) > countInt {
lines = lines[len(lines)-countInt:] entries = entries[len(entries)-countInt:]
} }
return lines return entries
} }
func hasSuffix(line string, suffixes []string) bool { func logEntryContains(line string, suffixes []string) bool {
for _, sfx := range suffixes { for _, sfx := range suffixes {
if strings.HasSuffix(line, sfx+"]") { if strings.Contains(line, sfx+"]") {
return true return true
} }
} }

View file

@ -10,14 +10,14 @@ import (
"strings" "strings"
"time" "time"
"x-ui/database" "github.com/mhsanaei/3x-ui/v2/database"
"x-ui/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
"x-ui/util/random" "github.com/mhsanaei/3x-ui/v2/util/random"
"x-ui/util/reflect_util" "github.com/mhsanaei/3x-ui/v2/util/reflect_util"
"x-ui/web/entity" "github.com/mhsanaei/3x-ui/v2/web/entity"
"x-ui/xray" "github.com/mhsanaei/3x-ui/v2/xray"
) )
//go:embed config.json //go:embed config.json
@ -50,7 +50,8 @@ var defaultValueMap = map[string]string{
"tgLang": "en-US", "tgLang": "en-US",
"twoFactorEnable": "false", "twoFactorEnable": "false",
"twoFactorToken": "", "twoFactorToken": "",
"subEnable": "false", "subEnable": "true",
"subJsonEnable": "false",
"subTitle": "", "subTitle": "",
"subListen": "", "subListen": "",
"subPort": "2096", "subPort": "2096",
@ -442,6 +443,10 @@ func (s *SettingService) GetSubEnable() (bool, error) {
return s.getBool("subEnable") return s.getBool("subEnable")
} }
func (s *SettingService) GetSubJsonEnable() (bool, error) {
return s.getBool("subJsonEnable")
}
func (s *SettingService) GetSubTitle() (string, error) { func (s *SettingService) GetSubTitle() (string, error) {
return s.getString("subTitle") return s.getString("subTitle")
} }
@ -590,6 +595,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
"defaultKey": func() (any, error) { return s.GetKeyFile() }, "defaultKey": func() (any, error) { return s.GetKeyFile() },
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() }, "tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
"subEnable": func() (any, error) { return s.GetSubEnable() }, "subEnable": func() (any, error) { return s.GetSubEnable() },
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
"subTitle": func() (any, error) { return s.GetSubTitle() }, "subTitle": func() (any, error) { return s.GetSubTitle() },
"subURI": func() (any, error) { return s.GetSubURI() }, "subURI": func() (any, error) { return s.GetSubURI() },
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() }, "subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
@ -608,7 +614,14 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
result[key] = value result[key] = value
} }
if result["subEnable"].(bool) && (result["subURI"].(string) == "" || result["subJsonURI"].(string) == "") { subEnable := result["subEnable"].(bool)
subJsonEnable := false
if v, ok := result["subJsonEnable"]; ok {
if b, ok2 := v.(bool); ok2 {
subJsonEnable = b
}
}
if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") {
subURI := "" subURI := ""
subTitle, _ := s.GetSubTitle() subTitle, _ := s.GetSubTitle()
subPort, _ := s.GetSubPort() subPort, _ := s.GetSubPort()
@ -634,13 +647,13 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
} else { } else {
subURI += fmt.Sprintf("%s:%d", subDomain, subPort) subURI += fmt.Sprintf("%s:%d", subDomain, subPort)
} }
if result["subURI"].(string) == "" { if subEnable && result["subURI"].(string) == "" {
result["subURI"] = subURI + subPath result["subURI"] = subURI + subPath
} }
if result["subTitle"].(string) == "" { if result["subTitle"].(string) == "" {
result["subTitle"] = subTitle result["subTitle"] = subTitle
} }
if result["subJsonURI"].(string) == "" { if subJsonEnable && result["subJsonURI"].(string) == "" {
result["subJsonURI"] = subURI + subJsonPath result["subJsonURI"] = subURI + subJsonPath
} }
} }

View file

@ -7,8 +7,10 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io"
"math/big" "math/big"
"net" "net"
"net/http"
"net/url" "net/url"
"os" "os"
"regexp" "regexp"
@ -16,19 +18,20 @@ import (
"strings" "strings"
"time" "time"
"x-ui/config" "github.com/mhsanaei/3x-ui/v2/config"
"x-ui/database" "github.com/mhsanaei/3x-ui/v2/database"
"x-ui/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
"x-ui/web/global" "github.com/mhsanaei/3x-ui/v2/web/global"
"x-ui/web/locale" "github.com/mhsanaei/3x-ui/v2/web/locale"
"x-ui/xray" "github.com/mhsanaei/3x-ui/v2/xray"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/mymmrac/telego" "github.com/mymmrac/telego"
th "github.com/mymmrac/telego/telegohandler" th "github.com/mymmrac/telego/telegohandler"
tu "github.com/mymmrac/telego/telegoutil" tu "github.com/mymmrac/telego/telegoutil"
"github.com/skip2/go-qrcode"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpproxy" "github.com/valyala/fasthttp/fasthttpproxy"
) )
@ -545,6 +548,57 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
if len(dataArray) >= 2 && len(dataArray[1]) > 0 { if len(dataArray) >= 2 && len(dataArray[1]) > 0 {
email := dataArray[1] email := dataArray[1]
switch dataArray[0] { switch dataArray[0] {
case "get_clients_for_sub":
inboundId := dataArray[1]
inboundIdInt, err := strconv.Atoi(inboundId)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_sub_links")
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
inbound, _ := t.inboundService.GetInbound(inboundIdInt)
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB)
case "get_clients_for_individual":
inboundId := dataArray[1]
inboundIdInt, err := strconv.Atoi(inboundId)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_individual_links")
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
inbound, _ := t.inboundService.GetInbound(inboundIdInt)
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB)
case "get_clients_for_qr":
inboundId := dataArray[1]
inboundIdInt, err := strconv.Atoi(inboundId)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_qr_links")
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
inbound, _ := t.inboundService.GetInbound(inboundIdInt)
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB)
case "client_sub_links":
t.sendClientSubLinks(chatId, email)
return
case "client_individual_links":
t.sendClientIndividualLinks(chatId, email)
return
case "client_qr_links":
t.sendClientQRLinks(chatId, email)
return
case "client_get_usage": case "client_get_usage":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.messages.email", "Email=="+email)) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.messages.email", "Email=="+email))
t.searchClient(chatId, email) t.searchClient(chatId, email)
@ -802,7 +856,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
if len(dataArray) == 3 { if len(dataArray) == 3 {
days, err := strconv.Atoi(dataArray[2]) days, err := strconv.Atoi(dataArray[2])
if err == nil { if err == nil {
var date int64 = 0 var date int64
if days > 0 { if days > 0 {
traffic, err := t.inboundService.GetClientTrafficByEmail(email) traffic, err := t.inboundService.GetClientTrafficByEmail(email)
if err != nil { if err != nil {
@ -906,7 +960,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
case "add_client_reset_exp_c": case "add_client_reset_exp_c":
client_ExpiryTime = 0 client_ExpiryTime = 0
days, _ := strconv.Atoi(dataArray[1]) days, _ := strconv.Atoi(dataArray[1])
var date int64 = 0 var date int64
if client_ExpiryTime > 0 { if client_ExpiryTime > 0 {
if client_ExpiryTime-time.Now().Unix()*1000 < 0 { if client_ExpiryTime-time.Now().Unix()*1000 < 0 {
date = -int64(days * 24 * 60 * 60000) date = -int64(days * 24 * 60 * 60000)
@ -1324,6 +1378,27 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
} }
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.allClients")) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.allClients"))
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds)
case "admin_client_sub_links":
inbounds, err := t.getInboundsFor("get_clients_for_sub")
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds)
case "admin_client_individual_links":
inbounds, err := t.getInboundsFor("get_clients_for_individual")
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds)
case "admin_client_qr_links":
inbounds, err := t.getInboundsFor("get_clients_for_qr")
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds)
} }
} }
@ -1355,6 +1430,73 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
case "client_commands": case "client_commands":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands")) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands"))
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.helpClientCommands")) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.helpClientCommands"))
case "client_sub_links":
// show user's own clients to choose one for sub links
tgUserID := callbackQuery.From.ID
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
if err != nil {
// fallback to message
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
}
if len(traffics) == 0 {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
return
}
var buttons []telego.InlineKeyboardButton
for _, tr := range traffics {
buttons = append(buttons, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_sub_links "+tr.Email)))
}
cols := 1
if len(buttons) >= 6 {
cols = 2
}
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard)
case "client_individual_links":
// show user's clients to choose for individual links
tgUserID := callbackQuery.From.ID
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
}
if len(traffics) == 0 {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
return
}
var buttons2 []telego.InlineKeyboardButton
for _, tr := range traffics {
buttons2 = append(buttons2, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_individual_links "+tr.Email)))
}
cols2 := 1
if len(buttons2) >= 6 {
cols2 = 2
}
keyboard2 := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols2, buttons2...))
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard2)
case "client_qr_links":
// show user's clients to choose for QR codes
tgUserID := callbackQuery.From.ID
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOccurred")+"\r\n"+err.Error())
return
}
if len(traffics) == 0 {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
return
}
var buttons3 []telego.InlineKeyboardButton
for _, tr := range traffics {
buttons3 = append(buttons3, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_qr_links "+tr.Email)))
}
cols3 := 1
if len(buttons3) >= 6 {
cols3 = 2
}
keyboard3 := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols3, buttons3...))
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard3)
case "onlines": case "onlines":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.onlines")) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.onlines"))
t.onlineClients(chatId) t.onlineClients(chatId)
@ -1654,6 +1796,22 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
t.SendMsgToTgbot(chatId, msg, tu.ReplyKeyboardRemove()) t.SendMsgToTgbot(chatId, msg, tu.ReplyKeyboardRemove())
} }
default:
if after, ok := strings.CutPrefix(callbackQuery.Data, "client_sub_links "); ok {
email := after
t.sendClientSubLinks(chatId, email)
return
}
if after, ok := strings.CutPrefix(callbackQuery.Data, "client_individual_links "); ok {
email := after
t.sendClientIndividualLinks(chatId, email)
return
}
if after, ok := strings.CutPrefix(callbackQuery.Data, "client_qr_links "); ok {
email := after
t.sendClientQRLinks(chatId, email)
return
}
} }
} }
@ -1840,6 +1998,11 @@ func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")),
), ),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("admin_client_sub_links")),
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("admin_client_individual_links")),
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("admin_client_qr_links")),
),
// TODOOOOOOOOOOOOOO: Add restart button here. // TODOOOOOOOOOOOOOO: Add restart button here.
) )
numericKeyboardClient := tu.InlineKeyboard( numericKeyboardClient := tu.InlineKeyboard(
@ -1847,6 +2010,13 @@ func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")),
), ),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("client_sub_links")),
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links")),
),
) )
var ReplyMarkup telego.ReplyMarkup var ReplyMarkup telego.ReplyMarkup
@ -1908,6 +2078,266 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
} }
} }
// buildSubscriptionURLs builds the HTML sub page URL and JSON subscription URL for a client email
func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
// Resolve subId from client email
traffic, client, err := t.inboundService.GetClientByEmail(email)
_ = traffic
if err != nil || client == nil {
return "", "", errors.New("client not found")
}
// Gather settings to construct absolute URLs
subDomain, _ := t.settingService.GetSubDomain()
subPort, _ := t.settingService.GetSubPort()
subPath, _ := t.settingService.GetSubPath()
subJsonPath, _ := t.settingService.GetSubJsonPath()
subJsonEnable, _ := t.settingService.GetSubJsonEnable()
subKeyFile, _ := t.settingService.GetSubKeyFile()
subCertFile, _ := t.settingService.GetSubCertFile()
tls := (subKeyFile != "" && subCertFile != "")
scheme := "http"
if tls {
scheme = "https"
}
// Fallbacks
if subDomain == "" {
// try panel domain, otherwise OS hostname
if d, err := t.settingService.GetWebDomain(); err == nil && d != "" {
subDomain = d
} else if hostname != "" {
subDomain = hostname
} else {
subDomain = "localhost"
}
}
host := subDomain
if (subPort == 443 && tls) || (subPort == 80 && !tls) {
// standard ports: no port in host
} else {
host = fmt.Sprintf("%s:%d", subDomain, subPort)
}
// Ensure paths
if !strings.HasPrefix(subPath, "/") {
subPath = "/" + subPath
}
if !strings.HasSuffix(subPath, "/") {
subPath = subPath + "/"
}
if !strings.HasPrefix(subJsonPath, "/") {
subJsonPath = "/" + subJsonPath
}
if !strings.HasSuffix(subJsonPath, "/") {
subJsonPath = subJsonPath + "/"
}
subURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
subJsonURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
if !subJsonEnable {
subJsonURL = ""
}
return subURL, subJsonURL, nil
}
func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
}
msg := "Subscription URL:\r\n<code>" + subURL + "</code>"
if subJsonURL != "" {
msg += "\r\n\r\nJSON URL:\r\n<code>" + subJsonURL + "</code>"
}
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links "+email)),
),
)
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
}
// sendClientIndividualLinks fetches the subscription content (individual links) and sends it to the user
func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
// Build the HTML sub page URL; we'll call it with header Accept to get raw content
subURL, _, err := t.buildSubscriptionURLs(email)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
}
// Try to fetch raw subscription links. Prefer plain text response.
req, err := http.NewRequest("GET", subURL, nil)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
}
// Force plain text to avoid HTML page; controller respects Accept header
req.Header.Set("Accept", "text/plain, */*;q=0.1")
// Use default client with reasonable timeout via context
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req = req.WithContext(ctx)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
}
// If service is configured to encode (Base64), decode it
encoded, _ := t.settingService.GetSubEncrypt()
var content string
if encoded {
decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
if err != nil {
// fallback to raw text
content = string(bodyBytes)
} else {
content = string(decoded)
}
} else {
content = string(bodyBytes)
}
// Normalize line endings and trim
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
var cleaned []string
for _, l := range lines {
l = strings.TrimSpace(l)
if l != "" {
cleaned = append(cleaned, l)
}
}
if len(cleaned) == 0 {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult"))
return
}
// Send in chunks to respect message length; use monospace formatting
const maxPerMessage = 50
for i := 0; i < len(cleaned); i += maxPerMessage {
j := i + maxPerMessage
if j > len(cleaned) {
j = len(cleaned)
}
chunk := cleaned[i:j]
msg := t.I18nBot("subscription.individualLinks") + ":\r\n"
for _, link := range chunk {
// wrap each link in <code>
msg += "<code>" + link + "</code>\r\n"
}
t.SendMsgToTgbot(chatId, msg)
}
}
// sendClientQRLinks generates QR images for subscription URL, JSON URL, and a few individual links, then sends them
func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
}
// Helper to create QR PNG bytes from content
createQR := func(content string, size int) ([]byte, error) {
if size <= 0 {
size = 256
}
return qrcode.Encode(content, qrcode.Medium, size)
}
// Inform user
t.SendMsgToTgbot(chatId, "QRCode"+":")
// Send sub URL QR (filename: sub.png)
if png, err := createQR(subURL, 320); err == nil {
document := tu.Document(
tu.ID(chatId),
tu.FileFromBytes(png, "sub.png"),
)
_, _ = bot.SendDocument(context.Background(), document)
} else {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
}
// Send JSON URL QR (filename: subjson.png) when available
if subJsonURL != "" {
if png, err := createQR(subJsonURL, 320); err == nil {
document := tu.Document(
tu.ID(chatId),
tu.FileFromBytes(png, "subjson.png"),
)
_, _ = bot.SendDocument(context.Background(), document)
} else {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
}
}
// Also generate a few individual links' QRs (first up to 5)
subPageURL := subURL
req, err := http.NewRequest("GET", subPageURL, nil)
if err == nil {
req.Header.Set("Accept", "text/plain, */*;q=0.1")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req = req.WithContext(ctx)
if resp, err := http.DefaultClient.Do(req); err == nil {
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
encoded, _ := t.settingService.GetSubEncrypt()
var content string
if encoded {
if dec, err := base64.StdEncoding.DecodeString(string(body)); err == nil {
content = string(dec)
} else {
content = string(body)
}
} else {
content = string(body)
}
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
var cleaned []string
for _, l := range lines {
l = strings.TrimSpace(l)
if l != "" {
cleaned = append(cleaned, l)
}
}
if len(cleaned) > 0 {
max := min(len(cleaned), 5)
for i := range max {
if png, err := createQR(cleaned[i], 320); err == nil {
// Use the email as filename for individual link QR
filename := email + ".png"
document := tu.Document(
tu.ID(chatId),
tu.FileFromBytes(png, filename),
)
_, _ = bot.SendDocument(context.Background(), document)
time.Sleep(200 * time.Millisecond)
}
}
}
}
}
}
func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) { func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
if len(replyMarkup) > 0 { if len(replyMarkup) > 0 {
for _, adminId := range adminIds { for _, adminId := range adminIds {
@ -2116,6 +2546,74 @@ func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) {
return keyboard, nil return keyboard, nil
} }
// getInboundsFor builds an inline keyboard of inbounds where each button leads to a custom next action
// nextAction should be one of: get_clients_for_sub|get_clients_for_individual|get_clients_for_qr
func (t *Tgbot) getInboundsFor(nextAction string) (*telego.InlineKeyboardMarkup, error) {
inbounds, err := t.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("GetAllInbounds run failed:", err)
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
if len(inbounds) == 0 {
logger.Warning("No inbounds found")
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
var buttons []telego.InlineKeyboardButton
for _, inbound := range inbounds {
status := "❌"
if inbound.Enable {
status = "✅"
}
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", nextAction, inbound.Id))
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
}
cols := 1
if len(buttons) >= 6 {
cols = 2
}
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
return keyboard, nil
}
// getInboundClientsFor lists clients of an inbound with a specific action prefix to be appended with email
func (t *Tgbot) getInboundClientsFor(inboundID int, action string) (*telego.InlineKeyboardMarkup, error) {
inbound, err := t.inboundService.GetInbound(inboundID)
if err != nil {
logger.Warning("getInboundClientsFor run failed:", err)
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
}
clients, err := t.inboundService.GetClients(inbound)
var buttons []telego.InlineKeyboardButton
if err != nil {
logger.Warning("GetInboundClients run failed:", err)
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
} else {
if len(clients) > 0 {
for _, client := range clients {
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email)))
}
} else {
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
}
}
cols := 0
if len(buttons) < 6 {
cols = 3
} else {
cols = 2
}
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
return keyboard, nil
}
func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) { func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
inbounds, err := t.inboundService.GetAllInbounds() inbounds, err := t.inboundService.GetAllInbounds()
if err != nil { if err != nil {

View file

@ -3,10 +3,10 @@ package service
import ( import (
"errors" "errors"
"x-ui/database" "github.com/mhsanaei/3x-ui/v2/database"
"x-ui/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/util/crypto" "github.com/mhsanaei/3x-ui/v2/util/crypto"
"github.com/xlzd/gotp" "github.com/xlzd/gotp"
"gorm.io/gorm" "gorm.io/gorm"

View file

@ -7,8 +7,9 @@ import (
"net/http" "net/http"
"os" "os"
"time" "time"
"x-ui/logger"
"x-ui/util/common" "github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/common"
) )
type WarpService struct { type WarpService struct {

View file

@ -6,8 +6,8 @@ import (
"runtime" "runtime"
"sync" "sync"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/xray" "github.com/mhsanaei/3x-ui/v2/xray"
"go.uber.org/atomic" "go.uber.org/atomic"
) )

View file

@ -4,8 +4,8 @@ import (
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"x-ui/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
"x-ui/xray" "github.com/mhsanaei/3x-ui/v2/xray"
) )
type XraySettingService struct { type XraySettingService struct {

View file

@ -2,8 +2,9 @@ package session
import ( import (
"encoding/gob" "encoding/gob"
"net/http"
"x-ui/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -32,6 +33,7 @@ func SetMaxAge(c *gin.Context, maxAge int) {
Path: defaultPath, Path: defaultPath,
MaxAge: maxAge, MaxAge: maxAge,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}) })
} }
@ -61,5 +63,6 @@ func ClearSession(c *gin.Context) {
Path: defaultPath, Path: defaultPath,
MaxAge: -1, MaxAge: -1,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}) })
} }

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "مفيش بروكسي عكسي مضاف." "emptyReverseDesc" = "مفيش بروكسي عكسي مضاف."
"somethingWentWrong" = "حدث خطأ ما" "somethingWentWrong" = "حدث خطأ ما"
[subscription]
"title" = "معلومات الاشتراك"
"subId" = "معرّف الاشتراك"
"status" = "الحالة"
"downloaded" = "التنزيل"
"uploaded" = "الرفع"
"expiry" = "تاريخ الانتهاء"
"totalQuota" = "الحصة الإجمالية"
"individualLinks" = "روابط فردية"
"active" = "نشط"
"inactive" = "غير نشط"
"unlimited" = "غير محدود"
"noExpiry" = "بدون انتهاء"
[menu] [menu]
"theme" = "الثيم" "theme" = "الثيم"
"dark" = "داكن" "dark" = "داكن"
@ -230,6 +244,9 @@
"exportInbound" = "تصدير الإدخال" "exportInbound" = "تصدير الإدخال"
"import" = "استيراد" "import" = "استيراد"
"importInbound" = "استيراد إدخال" "importInbound" = "استيراد إدخال"
"periodicTrafficResetTitle" = "إعادة تعيين حركة المرور"
"periodicTrafficResetDesc" = "إعادة تعيين عداد حركة المرور تلقائيًا في فترات محددة"
"lastReset" = "آخر إعادة تعيين"
[pages.client] [pages.client]
"add" = "أضف عميل" "add" = "أضف عميل"
@ -249,6 +266,12 @@
"renew" = "تجديد تلقائي" "renew" = "تجديد تلقائي"
"renewDesc" = "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل)(الوحدة: يوم)" "renewDesc" = "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل)(الوحدة: يوم)"
[pages.inbounds.periodicTrafficReset]
"never" = "أبداً"
"daily" = "يومياً"
"weekly" = "أسبوعياً"
"monthly" = "شهرياً"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "تم الحصول عليه" "obtain" = "تم الحصول عليه"
"updateSuccess" = "تم التحديث بنجاح" "updateSuccess" = "تم التحديث بنجاح"
@ -348,6 +371,7 @@
"subSettings" = "الاشتراك" "subSettings" = "الاشتراك"
"subEnable" = "تفعيل خدمة الاشتراك" "subEnable" = "تفعيل خدمة الاشتراك"
"subEnableDesc" = "يفعل خدمة الاشتراك." "subEnableDesc" = "يفعل خدمة الاشتراك."
"subJsonEnable" = "تمكين/تعطيل نقطة نهاية اشتراك JSON بشكل مستقل."
"subTitle" = "عنوان الاشتراك" "subTitle" = "عنوان الاشتراك"
"subTitleDesc" = "العنوان اللي هيظهر في عميل VPN" "subTitleDesc" = "العنوان اللي هيظهر في عميل VPN"
"subListen" = "IP الاستماع" "subListen" = "IP الاستماع"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "No added reverse proxies." "emptyReverseDesc" = "No added reverse proxies."
"somethingWentWrong" = "Something went wrong" "somethingWentWrong" = "Something went wrong"
[subscription]
"title" = "Subscription info"
"subId" = "Subscription ID"
"status" = "Status"
"downloaded" = "Downloaded"
"uploaded" = "Uploaded"
"expiry" = "Expiry"
"totalQuota" = "Total quota"
"individualLinks" = "Individual links"
"active" = "Active"
"inactive" = "Inactive"
"unlimited" = "Unlimited"
"noExpiry" = "No expiry"
[menu] [menu]
"theme" = "Theme" "theme" = "Theme"
"dark" = "Dark" "dark" = "Dark"
@ -230,6 +244,9 @@
"exportInbound" = "Export Inbound" "exportInbound" = "Export Inbound"
"import" = "Import" "import" = "Import"
"importInbound" = "Import an Inbound" "importInbound" = "Import an Inbound"
"periodicTrafficResetTitle" = "Traffic Reset"
"periodicTrafficResetDesc" = "Automatically reset traffic counter at specified intervals"
"lastReset" = "Last Reset"
[pages.client] [pages.client]
"add" = "Add Client" "add" = "Add Client"
@ -249,6 +266,12 @@
"renew" = "Auto Renew" "renew" = "Auto Renew"
"renewDesc" = "Auto-renewal after expiration. (0 = disable)(unit: day)" "renewDesc" = "Auto-renewal after expiration. (0 = disable)(unit: day)"
[pages.inbounds.periodicTrafficReset]
"never" = "Never"
"daily" = "Daily"
"weekly" = "Weekly"
"monthly" = "Monthly"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Obtain" "obtain" = "Obtain"
"updateSuccess" = "The update was successful." "updateSuccess" = "The update was successful."
@ -346,8 +369,9 @@
"timeZone" = "Time Zone" "timeZone" = "Time Zone"
"timeZoneDesc" = "Scheduled tasks will run based on this time zone." "timeZoneDesc" = "Scheduled tasks will run based on this time zone."
"subSettings" = "Subscription" "subSettings" = "Subscription"
"subEnable" = "Enable Subscription Service" "subEnable" = "Subscription Service"
"subEnableDesc" = "Enables the subscription service." "subEnableDesc" = "Enable/Disable the subscription service."
"subJsonEnable" = "Enable/Disable the JSON subscription endpoint independently."
"subTitle" = "Subscription Title" "subTitle" = "Subscription Title"
"subTitleDesc" = "Title shown in VPN client" "subTitleDesc" = "Title shown in VPN client"
"subListen" = "Listen IP" "subListen" = "Listen IP"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "No hay proxies inversos añadidos." "emptyReverseDesc" = "No hay proxies inversos añadidos."
"somethingWentWrong" = "Algo salió mal" "somethingWentWrong" = "Algo salió mal"
[subscription]
"title" = "Información de suscripción"
"subId" = "ID de suscripción"
"status" = "Estado"
"downloaded" = "Descargado"
"uploaded" = "Subido"
"expiry" = "Caducidad"
"totalQuota" = "Cuota total"
"individualLinks" = "Enlaces individuales"
"active" = "Activo"
"inactive" = "Inactivo"
"unlimited" = "Ilimitado"
"noExpiry" = "Sin caducidad"
[menu] [menu]
"theme" = "Tema" "theme" = "Tema"
"dark" = "Oscuro" "dark" = "Oscuro"
@ -230,6 +244,9 @@
"exportInbound" = "Exportación entrante" "exportInbound" = "Exportación entrante"
"import" = "Importar" "import" = "Importar"
"importInbound" = "Importar un entrante" "importInbound" = "Importar un entrante"
"periodicTrafficResetTitle" = "Reset de Tráfico"
"periodicTrafficResetDesc" = "Reiniciar automáticamente el contador de tráfico en intervalos especificados"
"lastReset" = "Último reinicio"
[pages.client] [pages.client]
"add" = "Agregar Cliente" "add" = "Agregar Cliente"
@ -249,6 +266,12 @@
"renew" = "Renovación automática" "renew" = "Renovación automática"
"renewDesc" = "Renovación automática después de la expiración. (0 = desactivar) (unidad: día)" "renewDesc" = "Renovación automática después de la expiración. (0 = desactivar) (unidad: día)"
[pages.inbounds.periodicTrafficReset]
"never" = "Nunca"
"daily" = "Diariamente"
"weekly" = "Semanalmente"
"monthly" = "Mensualmente"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Recibir" "obtain" = "Recibir"
"updateSuccess" = "La actualización fue exitosa" "updateSuccess" = "La actualización fue exitosa"
@ -348,6 +371,7 @@
"subSettings" = "Suscripción" "subSettings" = "Suscripción"
"subEnable" = "Habilitar Servicio" "subEnable" = "Habilitar Servicio"
"subEnableDesc" = "Función de suscripción con configuración separada." "subEnableDesc" = "Función de suscripción con configuración separada."
"subJsonEnable" = "Habilitar/Deshabilitar el endpoint de suscripción JSON de forma independiente."
"subTitle" = "Título de la Suscripción" "subTitle" = "Título de la Suscripción"
"subTitleDesc" = "Título mostrado en el cliente de VPN" "subTitleDesc" = "Título mostrado en el cliente de VPN"
"subListen" = "Listening IP" "subListen" = "Listening IP"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "هیچ پروکسی معکوس اضافه نشده است." "emptyReverseDesc" = "هیچ پروکسی معکوس اضافه نشده است."
"somethingWentWrong" = "مشکلی پیش آمد" "somethingWentWrong" = "مشکلی پیش آمد"
[subscription]
"title" = "اطلاعات سابسکریپشن"
"subId" = "شناسه اشتراک"
"status" = "وضعیت"
"downloaded" = "دانلود"
"uploaded" = "آپلود"
"expiry" = "تاریخ پایان"
"totalQuota" = "حجم کلی"
"individualLinks" = "لینک‌های تکی"
"active" = "فعال"
"inactive" = "غیرفعال"
"unlimited" = "نامحدود"
"noExpiry" = "بدون انقضا"
[menu] [menu]
"theme" = "تم" "theme" = "تم"
"dark" = "تیره" "dark" = "تیره"
@ -230,6 +244,9 @@
"exportInbound" = "استخراج ورودی" "exportInbound" = "استخراج ورودی"
"import" = "افزودن" "import" = "افزودن"
"importInbound" = "افزودن یک ورودی" "importInbound" = "افزودن یک ورودی"
"periodicTrafficResetTitle" = "بازنشانی ترافیک"
"periodicTrafficResetDesc" = "بازنشانی خودکار شمارنده ترافیک در فواصل زمانی مشخص"
"lastReset" = "آخرین بازنشانی"
[pages.client] [pages.client]
"add" = "کاربر جدید" "add" = "کاربر جدید"
@ -247,7 +264,13 @@
"expireDays" = "مدت زمان" "expireDays" = "مدت زمان"
"days" = "(روز)" "days" = "(روز)"
"renew" = "تمدید خودکار" "renew" = "تمدید خودکار"
"renewDesc" = "(تمدید خودکار پس‌از ‌انقضا. (0 = غیرفعال)(واحد: روز" "renewDesc" = "تمدید خودکار پس‌از ‌انقضا. (0 = غیرفعال)(واحد: روز)"
[pages.inbounds.periodicTrafficReset]
"never" = "هرگز"
"daily" = "روزانه"
"weekly" = "هفتگی"
"monthly" = "ماهانه"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "فراهم‌سازی" "obtain" = "فراهم‌سازی"
@ -348,6 +371,7 @@
"subSettings" = "سابسکریپشن" "subSettings" = "سابسکریپشن"
"subEnable" = "فعال‌سازی سرویس سابسکریپشن" "subEnable" = "فعال‌سازی سرویس سابسکریپشن"
"subEnableDesc" = "سرویس سابسکریپشن‌ را فعال‌می‌کند" "subEnableDesc" = "سرویس سابسکریپشن‌ را فعال‌می‌کند"
"subJsonEnable" = "فعال/غیرفعال‌سازی مستقل نقطه دسترسی سابسکریپشن JSON."
"subTitle" = "عنوان اشتراک" "subTitle" = "عنوان اشتراک"
"subTitleDesc" = "عنوان نمایش داده شده در کلاینت VPN" "subTitleDesc" = "عنوان نمایش داده شده در کلاینت VPN"
"subListen" = "آدرس آی‌پی" "subListen" = "آدرس آی‌پی"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "Tidak ada proxy terbalik yang ditambahkan." "emptyReverseDesc" = "Tidak ada proxy terbalik yang ditambahkan."
"somethingWentWrong" = "Terjadi kesalahan" "somethingWentWrong" = "Terjadi kesalahan"
[subscription]
"title" = "Info langganan"
"subId" = "ID langganan"
"status" = "Status"
"downloaded" = "Diunduh"
"uploaded" = "Diunggah"
"expiry" = "Kedaluwarsa"
"totalQuota" = "Kuota total"
"individualLinks" = "Tautan individual"
"active" = "Aktif"
"inactive" = "Nonaktif"
"unlimited" = "Tanpa batas"
"noExpiry" = "Tanpa kedaluwarsa"
[menu] [menu]
"theme" = "Tema" "theme" = "Tema"
"dark" = "Gelap" "dark" = "Gelap"
@ -230,6 +244,9 @@
"exportInbound" = "Ekspor Masuk" "exportInbound" = "Ekspor Masuk"
"import" = "Impor" "import" = "Impor"
"importInbound" = "Impor Masuk" "importInbound" = "Impor Masuk"
"periodicTrafficResetTitle" = "Reset Trafik Berkala"
"periodicTrafficResetDesc" = "Reset otomatis penghitung trafik pada interval tertentu"
"lastReset" = "Reset Terakhir"
[pages.client] [pages.client]
"add" = "Tambah Klien" "add" = "Tambah Klien"
@ -249,6 +266,12 @@
"renew" = "Perpanjang Otomatis" "renew" = "Perpanjang Otomatis"
"renewDesc" = "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif)(unit: hari)" "renewDesc" = "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif)(unit: hari)"
[pages.inbounds.periodicTrafficReset]
"never" = "Tidak Pernah"
"daily" = "Harian"
"weekly" = "Mingguan"
"monthly" = "Bulanan"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Dapatkan" "obtain" = "Dapatkan"
"updateSuccess" = "Pembaruan berhasil" "updateSuccess" = "Pembaruan berhasil"
@ -348,6 +371,7 @@
"subSettings" = "Langganan" "subSettings" = "Langganan"
"subEnable" = "Aktifkan Layanan Langganan" "subEnable" = "Aktifkan Layanan Langganan"
"subEnableDesc" = "Mengaktifkan layanan langganan." "subEnableDesc" = "Mengaktifkan layanan langganan."
"subJsonEnable" = "Aktifkan/Nonaktifkan endpoint langganan JSON secara mandiri."
"subTitle" = "Judul Langganan" "subTitle" = "Judul Langganan"
"subTitleDesc" = "Judul yang ditampilkan di klien VPN" "subTitleDesc" = "Judul yang ditampilkan di klien VPN"
"subListen" = "IP Pendengar" "subListen" = "IP Pendengar"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "追加されたリバースプロキシはありません。" "emptyReverseDesc" = "追加されたリバースプロキシはありません。"
"somethingWentWrong" = "エラーが発生しました" "somethingWentWrong" = "エラーが発生しました"
[subscription]
"title" = "サブスクリプション情報"
"subId" = "サブスクリプションID"
"status" = "ステータス"
"downloaded" = "ダウンロード"
"uploaded" = "アップロード"
"expiry" = "有効期限"
"totalQuota" = "合計クォータ"
"individualLinks" = "個別リンク"
"active" = "有効"
"inactive" = "無効"
"unlimited" = "無制限"
"noExpiry" = "期限なし"
[menu] [menu]
"theme" = "テーマ" "theme" = "テーマ"
"dark" = "ダーク" "dark" = "ダーク"
@ -230,6 +244,9 @@
"exportInbound" = "インバウンドルールをエクスポート" "exportInbound" = "インバウンドルールをエクスポート"
"import" = "インポート" "import" = "インポート"
"importInbound" = "インバウンドルールをインポート" "importInbound" = "インバウンドルールをインポート"
"periodicTrafficResetTitle" = "トラフィックリセット"
"periodicTrafficResetDesc" = "指定された間隔でトラフィックカウンタを自動的にリセット"
"lastReset" = "最後のリセット"
[pages.client] [pages.client]
"add" = "クライアント追加" "add" = "クライアント追加"
@ -249,6 +266,12 @@
"renew" = "自動更新" "renew" = "自動更新"
"renewDesc" = "期限が切れた後に自動更新。0 = 無効)(単位:日)" "renewDesc" = "期限が切れた後に自動更新。0 = 無効)(単位:日)"
[pages.inbounds.periodicTrafficReset]
"never" = "なし"
"daily" = "毎日"
"weekly" = "毎週"
"monthly" = "毎月"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "取得" "obtain" = "取得"
"updateSuccess" = "更新が成功しました" "updateSuccess" = "更新が成功しました"
@ -348,6 +371,7 @@
"subSettings" = "サブスクリプション設定" "subSettings" = "サブスクリプション設定"
"subEnable" = "サブスクリプションサービスを有効にする" "subEnable" = "サブスクリプションサービスを有効にする"
"subEnableDesc" = "サブスクリプションサービス機能を有効にする" "subEnableDesc" = "サブスクリプションサービス機能を有効にする"
"subJsonEnable" = "JSON サブスクリプションのエンドポイントを個別に有効/無効にする。"
"subTitle" = "サブスクリプションタイトル" "subTitle" = "サブスクリプションタイトル"
"subTitleDesc" = "VPNクライアントに表示されるタイトル" "subTitleDesc" = "VPNクライアントに表示されるタイトル"
"subListen" = "監視IP" "subListen" = "監視IP"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "Nenhum proxy reverso adicionado." "emptyReverseDesc" = "Nenhum proxy reverso adicionado."
"somethingWentWrong" = "Algo deu errado" "somethingWentWrong" = "Algo deu errado"
[subscription]
"title" = "Informações da assinatura"
"subId" = "ID da assinatura"
"status" = "Status"
"downloaded" = "Baixado"
"uploaded" = "Enviado"
"expiry" = "Validade"
"totalQuota" = "Cota total"
"individualLinks" = "Links individuais"
"active" = "Ativo"
"inactive" = "Inativo"
"unlimited" = "Ilimitado"
"noExpiry" = "Sem validade"
[menu] [menu]
"theme" = "Tema" "theme" = "Tema"
"dark" = "Escuro" "dark" = "Escuro"
@ -230,6 +244,9 @@
"exportInbound" = "Exportar Inbound" "exportInbound" = "Exportar Inbound"
"import" = "Importar" "import" = "Importar"
"importInbound" = "Importar um Inbound" "importInbound" = "Importar um Inbound"
"periodicTrafficResetTitle" = "Reset de Tráfego"
"periodicTrafficResetDesc" = "Reinicia automaticamente o contador de tráfego em intervalos especificados"
"lastReset" = "Último Reset"
[pages.client] [pages.client]
"add" = "Adicionar Cliente" "add" = "Adicionar Cliente"
@ -249,6 +266,12 @@
"renew" = "Renovação Automática" "renew" = "Renovação Automática"
"renewDesc" = "Renovação automática após expiração. (0 = desativado)(unidade: dia)" "renewDesc" = "Renovação automática após expiração. (0 = desativado)(unidade: dia)"
[pages.inbounds.periodicTrafficReset]
"never" = "Nunca"
"daily" = "Diariamente"
"weekly" = "Semanalmente"
"monthly" = "Mensalmente"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Obter" "obtain" = "Obter"
"updateSuccess" = "A atualização foi bem-sucedida" "updateSuccess" = "A atualização foi bem-sucedida"
@ -348,6 +371,7 @@
"subSettings" = "Assinatura" "subSettings" = "Assinatura"
"subEnable" = "Ativar Serviço de Assinatura" "subEnable" = "Ativar Serviço de Assinatura"
"subEnableDesc" = "Ativa o serviço de assinatura." "subEnableDesc" = "Ativa o serviço de assinatura."
"subJsonEnable" = "Ativar/Desativar o endpoint de assinatura JSON de forma independente."
"subTitle" = "Título da Assinatura" "subTitle" = "Título da Assinatura"
"subTitleDesc" = "Título exibido no cliente VPN" "subTitleDesc" = "Título exibido no cliente VPN"
"subListen" = "IP de Escuta" "subListen" = "IP de Escuta"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "Нет добавленных реверс-прокси." "emptyReverseDesc" = "Нет добавленных реверс-прокси."
"somethingWentWrong" = "Что-то пошло не так" "somethingWentWrong" = "Что-то пошло не так"
[subscription]
"title" = "Информация о подписке"
"subId" = "ID подписки"
"status" = "Статус"
"downloaded" = "Загружено"
"uploaded" = "Отправлено"
"expiry" = "Срок действия"
"totalQuota" = "Общий лимит"
"individualLinks" = "Индивидуальные ссылки"
"active" = "Активна"
"inactive" = "Неактивна"
"unlimited" = "Безлимит"
"noExpiry" = "Без срока"
[menu] [menu]
"theme" = "Тема" "theme" = "Тема"
"dark" = "Темная" "dark" = "Темная"
@ -230,6 +244,9 @@
"exportInbound" = "Экспорт инбаундов" "exportInbound" = "Экспорт инбаундов"
"import" = "Импортировать" "import" = "Импортировать"
"importInbound" = "Импорт инбаундов" "importInbound" = "Импорт инбаундов"
"periodicTrafficResetTitle" = "Сброс трафика"
"periodicTrafficResetDesc" = "Автоматический сброс счетчика трафика через указанные интервалы"
"lastReset" = "Последний сброс"
[pages.client] [pages.client]
"add" = "Создать клиента" "add" = "Создать клиента"
@ -249,6 +266,12 @@
"renew" = "Автопродление" "renew" = "Автопродление"
"renewDesc" = "Автопродление после истечения срока действия. (0 = отключить)(единица: день)" "renewDesc" = "Автопродление после истечения срока действия. (0 = отключить)(единица: день)"
[pages.inbounds.periodicTrafficReset]
"never" = "Никогда"
"daily" = "Ежедневно"
"weekly" = "Еженедельно"
"monthly" = "Ежемесячно"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Получить" "obtain" = "Получить"
"updateSuccess" = "Обновление прошло успешно" "updateSuccess" = "Обновление прошло успешно"
@ -348,6 +371,7 @@
"subSettings" = "Подписка" "subSettings" = "Подписка"
"subEnable" = "Включить подписку" "subEnable" = "Включить подписку"
"subEnableDesc" = "Функция подписки с отдельной конфигурацией" "subEnableDesc" = "Функция подписки с отдельной конфигурацией"
"subJsonEnable" = "Включить/отключить JSON-эндпоинт подписки независимо."
"subTitle" = "Заголовок подписки" "subTitle" = "Заголовок подписки"
"subTitleDesc" = "Название подписки, которое видит клиент в VPN клиенте" "subTitleDesc" = "Название подписки, которое видит клиент в VPN клиенте"
"subListen" = "Прослушивание IP" "subListen" = "Прослушивание IP"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "Eklenmiş ters proxy yok." "emptyReverseDesc" = "Eklenmiş ters proxy yok."
"somethingWentWrong" = "Bir şeyler yanlış gitti" "somethingWentWrong" = "Bir şeyler yanlış gitti"
[subscription]
"title" = "Abonelik Bilgisi"
"subId" = "Abonelik Kimliği"
"status" = "Durum"
"downloaded" = "İndirilen"
"uploaded" = "Yüklenen"
"expiry" = "Son Kullanma"
"totalQuota" = "Toplam Kota"
"individualLinks" = "Bireysel Bağlantılar"
"active" = "Aktif"
"inactive" = "Pasif"
"unlimited" = "Sınırsız"
"noExpiry" = "Süresiz"
[menu] [menu]
"theme" = "Tema" "theme" = "Tema"
"dark" = "Koyu" "dark" = "Koyu"
@ -230,6 +244,9 @@
"exportInbound" = "Geleni Dışa Aktar" "exportInbound" = "Geleni Dışa Aktar"
"import" = "İçe Aktar" "import" = "İçe Aktar"
"importInbound" = "Bir Gelen İçe Aktar" "importInbound" = "Bir Gelen İçe Aktar"
"periodicTrafficResetTitle" = "Trafik Sıfırlama"
"periodicTrafficResetDesc" = "Belirtilen aralıklarla trafik sayacını otomatik olarak sıfırla"
"lastReset" = "Son Sıfırlama"
[pages.client] [pages.client]
"add" = "Müşteri Ekle" "add" = "Müşteri Ekle"
@ -249,6 +266,12 @@
"renew" = "Otomatik Yenile" "renew" = "Otomatik Yenile"
"renewDesc" = "Süresi dolduktan sonra otomatik yenileme. (0 = devre dışı)(birim: gün)" "renewDesc" = "Süresi dolduktan sonra otomatik yenileme. (0 = devre dışı)(birim: gün)"
[pages.inbounds.periodicTrafficReset]
"never" = "Asla"
"daily" = "Günlük"
"weekly" = "Haftalık"
"monthly" = "Aylık"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Elde Et" "obtain" = "Elde Et"
"updateSuccess" = "Güncelleme başarılı oldu" "updateSuccess" = "Güncelleme başarılı oldu"
@ -348,6 +371,7 @@
"subSettings" = "Abonelik" "subSettings" = "Abonelik"
"subEnable" = "Abonelik Hizmetini Etkinleştir" "subEnable" = "Abonelik Hizmetini Etkinleştir"
"subEnableDesc" = "Abonelik hizmetini etkinleştirir." "subEnableDesc" = "Abonelik hizmetini etkinleştirir."
"subJsonEnable" = "JSON abonelik uç noktasını bağımsız olarak Etkinleştir/Devre Dışı bırak."
"subTitle" = "Abonelik Başlığı" "subTitle" = "Abonelik Başlığı"
"subTitleDesc" = "VPN istemcisinde gösterilen başlık" "subTitleDesc" = "VPN istemcisinde gösterilen başlık"
"subListen" = "Dinleme IP" "subListen" = "Dinleme IP"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "Немає доданих зворотних проксі." "emptyReverseDesc" = "Немає доданих зворотних проксі."
"somethingWentWrong" = "Щось пішло не так" "somethingWentWrong" = "Щось пішло не так"
[subscription]
"title" = "Інформація про підписку"
"subId" = "ID підписки"
"status" = "Статус"
"downloaded" = "Завантажено"
"uploaded" = "Відвантажено"
"expiry" = "Термін дії"
"totalQuota" = "Загальна квота"
"individualLinks" = "Окремі посилання"
"active" = "Активна"
"inactive" = "Неактивна"
"unlimited" = "Безліміт"
"noExpiry" = "Без строку"
[menu] [menu]
"theme" = "Тема" "theme" = "Тема"
"dark" = "Темна" "dark" = "Темна"
@ -230,6 +244,9 @@
"exportInbound" = "Експортувати вхідні" "exportInbound" = "Експортувати вхідні"
"import" = "Імпорт" "import" = "Імпорт"
"importInbound" = "Імпортувати вхідний" "importInbound" = "Імпортувати вхідний"
"periodicTrafficResetTitle" = "Скидання трафіку"
"periodicTrafficResetDesc" = "Автоматично скидати лічильник трафіку через певні проміжки часу"
"lastReset" = "Останнє скидання"
[pages.client] [pages.client]
"add" = "Додати клієнта" "add" = "Додати клієнта"
@ -249,6 +266,12 @@
"renew" = "Автоматичне оновлення" "renew" = "Автоматичне оновлення"
"renewDesc" = "Автоматичне поновлення після закінчення терміну дії. (0 = вимкнено)(одиниця: день)" "renewDesc" = "Автоматичне поновлення після закінчення терміну дії. (0 = вимкнено)(одиниця: день)"
[pages.inbounds.periodicTrafficReset]
"never" = "Ніколи"
"daily" = "Щодня"
"weekly" = "Щотижня"
"monthly" = "Щомісяця"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Отримати" "obtain" = "Отримати"
"updateSuccess" = "Оновлення пройшло успішно" "updateSuccess" = "Оновлення пройшло успішно"
@ -348,6 +371,7 @@
"subSettings" = "Підписка" "subSettings" = "Підписка"
"subEnable" = "Увімкнути службу підписки" "subEnable" = "Увімкнути службу підписки"
"subEnableDesc" = "Вмикає службу підписки." "subEnableDesc" = "Вмикає службу підписки."
"subJsonEnable" = "Увімкнути/вимкнути JSON-кінець підписки незалежно."
"subTitle" = "Назва Підписки" "subTitle" = "Назва Підписки"
"subTitleDesc" = "Назва, яка відображається у VPN-клієнті" "subTitleDesc" = "Назва, яка відображається у VPN-клієнті"
"subListen" = "Слухати IP" "subListen" = "Слухати IP"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "Không có proxy ngược nào được thêm." "emptyReverseDesc" = "Không có proxy ngược nào được thêm."
"somethingWentWrong" = "Đã xảy ra lỗi" "somethingWentWrong" = "Đã xảy ra lỗi"
[subscription]
"title" = "Thông tin đăng ký"
"subId" = "ID đăng ký"
"status" = "Trạng thái"
"downloaded" = "Đã tải xuống"
"uploaded" = "Đã tải lên"
"expiry" = "Hết hạn"
"totalQuota" = "Tổng hạn mức"
"individualLinks" = "Liên kết riêng lẻ"
"active" = "Hoạt động"
"inactive" = "Không hoạt động"
"unlimited" = "Không giới hạn"
"noExpiry" = "Không hết hạn"
[menu] [menu]
"theme" = "Chủ đề" "theme" = "Chủ đề"
"dark" = "Tối" "dark" = "Tối"
@ -230,6 +244,9 @@
"exportInbound" = "Xuất nhập khẩu" "exportInbound" = "Xuất nhập khẩu"
"import" = "Nhập" "import" = "Nhập"
"importInbound" = "Nhập inbound" "importInbound" = "Nhập inbound"
"periodicTrafficResetTitle" = "Đặt lại lưu lượng"
"periodicTrafficResetDesc" = "Tự động đặt lại bộ đếm lưu lượng theo khoảng thời gian xác định"
"lastReset" = "Đặt lại lần cuối"
[pages.client] [pages.client]
"add" = "Thêm người dùng" "add" = "Thêm người dùng"
@ -249,6 +266,12 @@
"renew" = "Tự động gia hạn" "renew" = "Tự động gia hạn"
"renewDesc" = "Tự động gia hạn sau khi hết hạn. (0 = tắt)(đơn vị: ngày)" "renewDesc" = "Tự động gia hạn sau khi hết hạn. (0 = tắt)(đơn vị: ngày)"
[pages.inbounds.periodicTrafficReset]
"never" = "Không bao giờ"
"daily" = "Hàng ngày"
"weekly" = "Hàng tuần"
"monthly" = "Hàng tháng"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Nhận" "obtain" = "Nhận"
"updateSuccess" = "Cập nhật thành công" "updateSuccess" = "Cập nhật thành công"
@ -348,6 +371,7 @@
"subSettings" = "Gói đăng ký" "subSettings" = "Gói đăng ký"
"subEnable" = "Bật dịch vụ" "subEnable" = "Bật dịch vụ"
"subEnableDesc" = "Tính năng gói đăng ký với cấu hình riêng" "subEnableDesc" = "Tính năng gói đăng ký với cấu hình riêng"
"subJsonEnable" = "Bật/Tắt điểm cuối đăng ký JSON độc lập."
"subTitle" = "Tiêu đề Đăng ký" "subTitle" = "Tiêu đề Đăng ký"
"subTitleDesc" = "Tiêu đề hiển thị trong ứng dụng VPN" "subTitleDesc" = "Tiêu đề hiển thị trong ứng dụng VPN"
"subListen" = "Listening IP" "subListen" = "Listening IP"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "未添加反向代理。" "emptyReverseDesc" = "未添加反向代理。"
"somethingWentWrong" = "出了点问题" "somethingWentWrong" = "出了点问题"
[subscription]
"title" = "订阅信息"
"subId" = "订阅 ID"
"status" = "状态"
"downloaded" = "已下载"
"uploaded" = "已上传"
"expiry" = "到期"
"totalQuota" = "总配额"
"individualLinks" = "单独链接"
"active" = "启用"
"inactive" = "停用"
"unlimited" = "无限制"
"noExpiry" = "无到期"
[menu] [menu]
"theme" = "主题" "theme" = "主题"
"dark" = "暗色" "dark" = "暗色"
@ -230,6 +244,9 @@
"exportInbound" = "导出入站规则" "exportInbound" = "导出入站规则"
"import"="导入" "import"="导入"
"importInbound" = "导入入站规则" "importInbound" = "导入入站规则"
"periodicTrafficResetTitle" = "流量重置"
"periodicTrafficResetDesc" = "按指定间隔自动重置流量计数器"
"lastReset" = "上次重置"
[pages.client] [pages.client]
"add" = "添加客户端" "add" = "添加客户端"
@ -249,6 +266,12 @@
"renew" = "自动续订" "renew" = "自动续订"
"renewDesc" = "到期后自动续订。(0 = 禁用)(单位: 天)" "renewDesc" = "到期后自动续订。(0 = 禁用)(单位: 天)"
[pages.inbounds.periodicTrafficReset]
"never" = "从不"
"daily" = "每日"
"weekly" = "每周"
"monthly" = "每月"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "获取" "obtain" = "获取"
"updateSuccess" = "更新成功" "updateSuccess" = "更新成功"
@ -348,6 +371,7 @@
"subSettings" = "订阅设置" "subSettings" = "订阅设置"
"subEnable" = "启用订阅服务" "subEnable" = "启用订阅服务"
"subEnableDesc" = "启用订阅服务功能" "subEnableDesc" = "启用订阅服务功能"
"subJsonEnable" = "单独启用/禁用 JSON 订阅端点。"
"subTitle" = "订阅标题" "subTitle" = "订阅标题"
"subTitleDesc" = "在VPN客户端中显示的标题" "subTitleDesc" = "在VPN客户端中显示的标题"
"subListen" = "监听 IP" "subListen" = "监听 IP"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "未添加反向代理。" "emptyReverseDesc" = "未添加反向代理。"
"somethingWentWrong" = "發生錯誤" "somethingWentWrong" = "發生錯誤"
[subscription]
"title" = "訂閱資訊"
"subId" = "訂閱 ID"
"status" = "狀態"
"downloaded" = "已下載"
"uploaded" = "已上傳"
"expiry" = "到期"
"totalQuota" = "總配額"
"individualLinks" = "個別連結"
"active" = "啟用"
"inactive" = "停用"
"unlimited" = "無限制"
"noExpiry" = "無到期"
[menu] [menu]
"theme" = "主題" "theme" = "主題"
"dark" = "深色" "dark" = "深色"
@ -230,6 +244,9 @@
"exportInbound" = "匯出入站規則" "exportInbound" = "匯出入站規則"
"import"="匯入" "import"="匯入"
"importInbound" = "匯入入站規則" "importInbound" = "匯入入站規則"
"periodicTrafficResetTitle" = "流量重置"
"periodicTrafficResetDesc" = "按指定間隔自動重置流量計數器"
"lastReset" = "上次重置"
[pages.client] [pages.client]
"add" = "新增客戶端" "add" = "新增客戶端"
@ -249,6 +266,12 @@
"renew" = "自動續訂" "renew" = "自動續訂"
"renewDesc" = "到期後自動續訂。(0 = 禁用)(單位: 天)" "renewDesc" = "到期後自動續訂。(0 = 禁用)(單位: 天)"
[pages.inbounds.periodicTrafficReset]
"never" = "從不"
"daily" = "每日"
"weekly" = "每週"
"monthly" = "每月"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "獲取" "obtain" = "獲取"
"updateSuccess" = "更新成功" "updateSuccess" = "更新成功"
@ -348,6 +371,7 @@
"subSettings" = "訂閱設定" "subSettings" = "訂閱設定"
"subEnable" = "啟用訂閱服務" "subEnable" = "啟用訂閱服務"
"subEnableDesc" = "啟用訂閱服務功能" "subEnableDesc" = "啟用訂閱服務功能"
"subJsonEnable" = "獨立啟用/停用 JSON 訂閱端點。"
"subTitle" = "訂閱標題" "subTitle" = "訂閱標題"
"subTitleDesc" = "在VPN客戶端中顯示的標題" "subTitleDesc" = "在VPN客戶端中顯示的標題"
"subListen" = "監聽 IP" "subListen" = "監聽 IP"

View file

@ -14,15 +14,15 @@ import (
"strings" "strings"
"time" "time"
"x-ui/config" "github.com/mhsanaei/3x-ui/v2/config"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
"x-ui/web/controller" "github.com/mhsanaei/3x-ui/v2/web/controller"
"x-ui/web/job" "github.com/mhsanaei/3x-ui/v2/web/job"
"x-ui/web/locale" "github.com/mhsanaei/3x-ui/v2/web/locale"
"x-ui/web/middleware" "github.com/mhsanaei/3x-ui/v2/web/middleware"
"x-ui/web/network" "github.com/mhsanaei/3x-ui/v2/web/network"
"x-ui/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/gin-contrib/gzip" "github.com/gin-contrib/gzip"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
@ -31,7 +31,7 @@ import (
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
) )
//go:embed assets/* //go:embed assets
var assetsFS embed.FS var assetsFS embed.FS
//go:embed html/* //go:embed html/*
@ -78,6 +78,15 @@ func (f *wrapAssetsFileInfo) ModTime() time.Time {
return startTime return startTime
} }
// Expose embedded resources for reuse by other servers (e.g., sub server)
func EmbeddedHTML() embed.FS {
return htmlFS
}
func EmbeddedAssets() embed.FS {
return assetsFS
}
type Server struct { type Server struct {
httpServer *http.Server httpServer *http.Server
listener net.Listener listener net.Listener
@ -180,6 +189,15 @@ func (s *Server) initRouter() (*gin.Engine, error) {
assetsBasePath := basePath + "assets/" assetsBasePath := basePath + "assets/"
store := cookie.NewStore(secret) store := cookie.NewStore(secret)
// Configure default session cookie options, including expiration (MaxAge)
if sessionMaxAge, err := s.settingService.GetSessionMaxAge(); err == nil {
store.Options(sessions.Options{
Path: "/",
MaxAge: sessionMaxAge * 60, // minutes -> seconds
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
engine.Use(sessions.Sessions("3x-ui", store)) engine.Use(sessions.Sessions("3x-ui", store))
engine.Use(func(c *gin.Context) { engine.Use(func(c *gin.Context) {
c.Set("base_path", basePath) c.Set("base_path", basePath)
@ -201,7 +219,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
i18nWebFunc := func(key string, params ...string) string { i18nWebFunc := func(key string, params ...string) string {
return locale.I18n(locale.Web, key, params...) return locale.I18n(locale.Web, key, params...)
} }
engine.FuncMap["i18n"] = i18nWebFunc // Register template functions before loading templates
funcMap := template.FuncMap{
"i18n": i18nWebFunc,
}
engine.SetFuncMap(funcMap)
engine.Use(locale.LocalizerMiddleware()) engine.Use(locale.LocalizerMiddleware())
// set static files and template // set static files and template
@ -211,11 +233,12 @@ func (s *Server) initRouter() (*gin.Engine, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Use the registered func map with the loaded templates
engine.LoadHTMLFiles(files...) engine.LoadHTMLFiles(files...)
engine.StaticFS(basePath+"assets", http.FS(os.DirFS("web/assets"))) engine.StaticFS(basePath+"assets", http.FS(os.DirFS("web/assets")))
} else { } else {
// for production // for production
template, err := s.getHtmlTemplate(engine.FuncMap) template, err := s.getHtmlTemplate(funcMap)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -266,6 +289,14 @@ func (s *Server) startTask() {
// check client ips from log file every day // check client ips from log file every day
s.cron.AddJob("@daily", job.NewClearLogsJob()) s.cron.AddJob("@daily", job.NewClearLogsJob())
// Inbound traffic reset jobs
// Run once a day, midnight
s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
// Run once a week, midnight between Sat/Sun
s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly"))
// Run once a month, midnight, first of month
s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
// Make a traffic condition every day, 8:30 // Make a traffic condition every day, 8:30
var entry cron.EntryID var entry cron.EntryID
isTgbotenabled, err := s.settingService.GetTgbotEnabled() isTgbotenabled, err := s.settingService.GetTgbotEnabled()

View file

@ -4,12 +4,12 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math"
"regexp" "regexp"
"time" "time"
"math"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/xtls/xray-core/app/proxyman/command" "github.com/xtls/xray-core/app/proxyman/command"
statsService "github.com/xtls/xray-core/app/stats/command" statsService "github.com/xtls/xray-core/app/stats/command"

View file

@ -5,6 +5,7 @@ type ClientTraffic struct {
InboundId int `json:"inboundId" form:"inboundId"` InboundId int `json:"inboundId" form:"inboundId"`
Enable bool `json:"enable" form:"enable"` Enable bool `json:"enable" form:"enable"`
Email string `json:"email" form:"email" gorm:"unique"` Email string `json:"email" form:"email" gorm:"unique"`
SubId string `json:"subId" form:"subId" gorm:"-"`
Up int64 `json:"up" form:"up"` Up int64 `json:"up" form:"up"`
Down int64 `json:"down" form:"down"` Down int64 `json:"down" form:"down"`
AllTime int64 `json:"allTime" form:"allTime"` AllTime int64 `json:"allTime" form:"allTime"`

View file

@ -3,7 +3,7 @@ package xray
import ( import (
"bytes" "bytes"
"x-ui/util/json_util" "github.com/mhsanaei/3x-ui/v2/util/json_util"
) )
type Config struct { type Config struct {

View file

@ -3,7 +3,7 @@ package xray
import ( import (
"bytes" "bytes"
"x-ui/util/json_util" "github.com/mhsanaei/3x-ui/v2/util/json_util"
) )
type InboundConfig struct { type InboundConfig struct {

View file

@ -2,9 +2,10 @@ package xray
import ( import (
"regexp" "regexp"
"runtime"
"strings" "strings"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
) )
func NewLogWriter() *LogWriter { func NewLogWriter() *LogWriter {
@ -20,6 +21,12 @@ func (lw *LogWriter) Write(m []byte) (n int, err error) {
// Convert the data to a string // Convert the data to a string
message := strings.TrimSpace(string(m)) message := strings.TrimSpace(string(m))
msgLowerAll := strings.ToLower(message)
// Suppress noisy Windows process-kill signal that surfaces as exit status 1
if runtime.GOOS == "windows" && strings.Contains(msgLowerAll, "exit status 1") {
return len(m), nil
}
// Check if the message contains a crash // Check if the message contains a crash
if crashRegex.MatchString(message) { if crashRegex.MatchString(message) {

View file

@ -9,12 +9,13 @@ import (
"os" "os"
"os/exec" "os/exec"
"runtime" "runtime"
"strings"
"syscall" "syscall"
"time" "time"
"x-ui/config" "github.com/mhsanaei/3x-ui/v2/config"
"x-ui/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"x-ui/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
) )
func GetBinaryName() string { func GetBinaryName() string {
@ -224,6 +225,15 @@ func (p *process) Start() (err error) {
go func() { go func() {
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
// On Windows, killing the process results in "exit status 1" which isn't an error for us
if runtime.GOOS == "windows" {
errStr := strings.ToLower(err.Error())
if strings.Contains(errStr, "exit status 1") {
// Suppress noisy log on graceful stop
p.exitErr = err
return
}
}
logger.Error("Failure in running xray-core:", err) logger.Error("Failure in running xray-core:", err)
p.exitErr = err p.exitErr = err
} }
@ -239,7 +249,7 @@ func (p *process) Stop() error {
if !p.IsRunning() { if !p.IsRunning() {
return errors.New("xray is not running") return errors.New("xray is not running")
} }
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
return p.cmd.Process.Kill() return p.cmd.Process.Kill()
} else { } else {