mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-03-19 17:15:49 +00:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49430b3991 | ||
|
|
104526aab2 | ||
|
|
a0c07241c0 | ||
|
|
adf3242602 | ||
|
|
3f62592e4b | ||
|
|
02bff4db6c | ||
|
|
8ff4e1ff31 | ||
|
|
26c6438ec2 | ||
|
|
b3e96230c4 | ||
|
|
1016f3b4f9 | ||
|
|
020bc9d77c | ||
|
|
5620d739c6 | ||
|
|
d518979e4f | ||
|
|
83f8a03b50 | ||
|
|
b45e63a14a | ||
|
|
3007bcff97 | ||
|
|
55f1d72af5 | ||
|
|
806ecbd7c5 | ||
|
|
ae79b43cdb | ||
|
|
e64e6327ef | ||
|
|
9f024b9e6a | ||
|
|
eacfbc86b5 | ||
|
|
37c17357fc | ||
|
|
b35d339665 | ||
|
|
5e7a3db873 | ||
|
|
6ced549dea | ||
|
|
f60682a6b7 | ||
|
|
50bd7a8040 | ||
|
|
7465768ff7 | ||
|
|
5b00a52c65 | ||
|
|
151f1173a1 | ||
|
|
e262132b9d | ||
|
|
ca0a7aeb5a | ||
|
|
7447cec17e | ||
|
|
0ffd27c0aa | ||
|
|
054cb1dea0 | ||
|
|
3757ae0b11 | ||
|
|
e3883fca87 | ||
|
|
b46a0b404b | ||
|
|
0ce58a095a | ||
|
|
59ea2645db | ||
|
|
8c8d280f14 | ||
|
|
c720008187 | ||
|
|
170d24499e | ||
|
|
99c79d4056 | ||
|
|
fcdeb1fc79 | ||
|
|
0a58b5e745 | ||
|
|
db7e7dcd29 | ||
|
|
01b8a27996 | ||
|
|
3764ece26c | ||
|
|
d7efc2aef9 | ||
|
|
2eb8abf61e | ||
|
|
299572a4c2 | ||
|
|
22afa50901 | ||
|
|
bc274d1e1f | ||
|
|
dc21f41932 | ||
|
|
f137b1af76 | ||
|
|
c4871ef8fe | ||
|
|
ecfffa882a | ||
|
|
3af5026abe | ||
|
|
1de7accd7c | ||
|
|
76afff2a6f | ||
|
|
9623e87511 | ||
|
|
bc0518391e |
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -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
|
||||||
|
|||||||
85
.github/workflows/docker.yml
vendored
85
.github/workflows/docker.yml
vendored
@@ -1,4 +1,9 @@
|
|||||||
name: Release 3X-UI for Docker
|
name: Release 3X-UI for Docker
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
@@ -10,48 +15,48 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
submodules: true
|
submodules: true
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
hsanaeii/3x-ui
|
|
||||||
ghcr.io/mhsanaei/3x-ui
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=tag
|
|
||||||
type=pep440,pattern={{version}}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Docker meta
|
||||||
uses: docker/setup-qemu-action@v3
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
hsanaeii/3x-ui
|
||||||
|
ghcr.io/mhsanaei/3x-ui
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up QEMU
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
with:
|
|
||||||
install: true
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Set up Docker Buildx
|
||||||
uses: docker/login-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
install: true
|
||||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
username: ${{ github.repository_owner }}
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Login to GHCR
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
context: .
|
registry: ghcr.io
|
||||||
push: true
|
username: ${{ github.actor }}
|
||||||
platforms: linux/amd64, linux/arm64/v8, linux/arm/v7, linux/arm/v6, linux/386
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6,linux/386
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
35
.vscode/launch.json
vendored
Normal file
35
.vscode/launch.json
vendored
Normal 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
40
.vscode/tasks.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ RUN chmod +x \
|
|||||||
/usr/bin/x-ui
|
/usr/bin/x-ui
|
||||||
|
|
||||||
ENV XUI_ENABLE_FAIL2BAN="true"
|
ENV XUI_ENABLE_FAIL2BAN="true"
|
||||||
|
EXPOSE 2053
|
||||||
VOLUME [ "/etc/x-ui" ]
|
VOLUME [ "/etc/x-ui" ]
|
||||||
CMD [ "./x-ui" ]
|
CMD [ "./x-ui" ]
|
||||||
ENTRYPOINT [ "/app/DockerEntrypoint.sh" ]
|
ENTRYPOINT [ "/app/DockerEntrypoint.sh" ]
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
</picture>
|
</picture>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||||
[](#)
|
[](#)
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
[](https://pkg.go.dev/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`
|
|
||||||
|
|
||||||
## النجوم عبر الزمن
|
## النجوم عبر الزمن
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
</picture>
|
</picture>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||||
[](#)
|
[](#)
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
[](https://pkg.go.dev/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
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
</picture>
|
</picture>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||||
[](#)
|
[](#)
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
[](https://pkg.go.dev/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>
|
||||||
|
|
||||||
## ستارهها در طول زمان
|
## ستارهها در طول زمان
|
||||||
|
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -7,11 +7,13 @@
|
|||||||
</picture>
|
</picture>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||||
[](#)
|
[](#)
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
[](https://pkg.go.dev/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
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
</picture>
|
</picture>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||||
[](#)
|
[](#)
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
[](https://pkg.go.dev/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>
|
||||||
|
|
||||||
## Звезды с течением времени
|
## Звезды с течением времени
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
</picture>
|
</picture>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||||
[](#)
|
[](#)
|
||||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
[](https://pkg.go.dev/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>
|
||||||
|
|
||||||
## 随时间变化的星标数
|
## 随时间变化的星标数
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Package config provides configuration management utilities for the 3x-ui panel,
|
||||||
|
// including version information, logging levels, database paths, and environment variable handling.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,24 +18,29 @@ var version string
|
|||||||
//go:embed name
|
//go:embed name
|
||||||
var name string
|
var name string
|
||||||
|
|
||||||
|
// LogLevel represents the logging level for the application.
|
||||||
type LogLevel string
|
type LogLevel string
|
||||||
|
|
||||||
|
// Logging level constants
|
||||||
const (
|
const (
|
||||||
Debug LogLevel = "debug"
|
Debug LogLevel = "debug"
|
||||||
Info LogLevel = "info"
|
Info LogLevel = "info"
|
||||||
Notice LogLevel = "notice"
|
Notice LogLevel = "notice"
|
||||||
Warn LogLevel = "warn"
|
Warning LogLevel = "warning"
|
||||||
Error LogLevel = "error"
|
Error LogLevel = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GetVersion returns the version string of the 3x-ui application.
|
||||||
func GetVersion() string {
|
func GetVersion() string {
|
||||||
return strings.TrimSpace(version)
|
return strings.TrimSpace(version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetName returns the name of the 3x-ui application.
|
||||||
func GetName() string {
|
func GetName() string {
|
||||||
return strings.TrimSpace(name)
|
return strings.TrimSpace(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLogLevel returns the current logging level based on environment variables or defaults to Info.
|
||||||
func GetLogLevel() LogLevel {
|
func GetLogLevel() LogLevel {
|
||||||
if IsDebug() {
|
if IsDebug() {
|
||||||
return Debug
|
return Debug
|
||||||
@@ -45,10 +52,12 @@ func GetLogLevel() LogLevel {
|
|||||||
return LogLevel(logLevel)
|
return LogLevel(logLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsDebug returns true if debug mode is enabled via the XUI_DEBUG environment variable.
|
||||||
func IsDebug() bool {
|
func IsDebug() bool {
|
||||||
return os.Getenv("XUI_DEBUG") == "true"
|
return os.Getenv("XUI_DEBUG") == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetBinFolderPath returns the path to the binary folder, defaulting to "bin" if not set via XUI_BIN_FOLDER.
|
||||||
func GetBinFolderPath() string {
|
func GetBinFolderPath() string {
|
||||||
binFolderPath := os.Getenv("XUI_BIN_FOLDER")
|
binFolderPath := os.Getenv("XUI_BIN_FOLDER")
|
||||||
if binFolderPath == "" {
|
if binFolderPath == "" {
|
||||||
@@ -74,6 +83,7 @@ func getBaseDir() string {
|
|||||||
return exeDir
|
return exeDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDBFolderPath returns the path to the database folder based on environment variables or platform defaults.
|
||||||
func GetDBFolderPath() string {
|
func GetDBFolderPath() string {
|
||||||
dbFolderPath := os.Getenv("XUI_DB_FOLDER")
|
dbFolderPath := os.Getenv("XUI_DB_FOLDER")
|
||||||
if dbFolderPath != "" {
|
if dbFolderPath != "" {
|
||||||
@@ -85,10 +95,12 @@ func GetDBFolderPath() string {
|
|||||||
return "/etc/x-ui"
|
return "/etc/x-ui"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDBPath returns the full path to the database file.
|
||||||
func GetDBPath() string {
|
func GetDBPath() string {
|
||||||
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
|
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
|
||||||
func GetLogFolder() string {
|
func GetLogFolder() string {
|
||||||
logFolderPath := os.Getenv("XUI_LOG_FOLDER")
|
logFolderPath := os.Getenv("XUI_LOG_FOLDER")
|
||||||
if logFolderPath != "" {
|
if logFolderPath != "" {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2.8.0
|
2.8.4
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Package database provides database initialization, migration, and management utilities
|
||||||
|
// for the 3x-ui panel using GORM with SQLite.
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,10 +11,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"
|
||||||
@@ -45,6 +47,7 @@ func initModels() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initUser creates a default admin user if the users table is empty.
|
||||||
func initUser() error {
|
func initUser() error {
|
||||||
empty, err := isTableEmpty("users")
|
empty, err := isTableEmpty("users")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,6 +71,7 @@ func initUser() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runSeeders migrates user passwords to bcrypt and records seeder execution to prevent re-running.
|
||||||
func runSeeders(isUsersEmpty bool) error {
|
func runSeeders(isUsersEmpty bool) error {
|
||||||
empty, err := isTableEmpty("history_of_seeders")
|
empty, err := isTableEmpty("history_of_seeders")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,12 +111,14 @@ func runSeeders(isUsersEmpty bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isTableEmpty returns true if the named table contains zero rows.
|
||||||
func isTableEmpty(tableName string) (bool, error) {
|
func isTableEmpty(tableName string) (bool, error) {
|
||||||
var count int64
|
var count int64
|
||||||
err := db.Table(tableName).Count(&count).Error
|
err := db.Table(tableName).Count(&count).Error
|
||||||
return count == 0, err
|
return count == 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitDB sets up the database connection, migrates models, and runs seeders.
|
||||||
func InitDB(dbPath string) error {
|
func InitDB(dbPath string) error {
|
||||||
dir := path.Dir(dbPath)
|
dir := path.Dir(dbPath)
|
||||||
err := os.MkdirAll(dir, fs.ModePerm)
|
err := os.MkdirAll(dir, fs.ModePerm)
|
||||||
@@ -141,6 +147,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
|
||||||
@@ -148,6 +157,7 @@ func InitDB(dbPath string) error {
|
|||||||
return runSeeders(isUsersEmpty)
|
return runSeeders(isUsersEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CloseDB closes the database connection if it exists.
|
||||||
func CloseDB() error {
|
func CloseDB() error {
|
||||||
if db != nil {
|
if db != nil {
|
||||||
sqlDB, err := db.DB()
|
sqlDB, err := db.DB()
|
||||||
@@ -159,14 +169,17 @@ func CloseDB() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDB returns the global GORM database instance.
|
||||||
func GetDB() *gorm.DB {
|
func GetDB() *gorm.DB {
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsNotFound checks if the given error is a GORM record not found error.
|
||||||
func IsNotFound(err error) bool {
|
func IsNotFound(err error) bool {
|
||||||
return err == gorm.ErrRecordNotFound
|
return err == gorm.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
|
||||||
func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
||||||
signature := []byte("SQLite format 3\x00")
|
signature := []byte("SQLite format 3\x00")
|
||||||
buf := make([]byte, len(signature))
|
buf := make([]byte, len(signature))
|
||||||
@@ -177,6 +190,7 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
|||||||
return bytes.Equal(buf, signature), nil
|
return bytes.Equal(buf, signature), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
|
||||||
func Checkpoint() error {
|
func Checkpoint() error {
|
||||||
// Update WAL
|
// Update WAL
|
||||||
err := db.Exec("PRAGMA wal_checkpoint;").Error
|
err := db.Exec("PRAGMA wal_checkpoint;").Error
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
|
// Package model defines the database models and data structures used by the 3x-ui panel.
|
||||||
package model
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Protocol represents the protocol type for Xray inbounds.
|
||||||
type Protocol string
|
type Protocol string
|
||||||
|
|
||||||
|
// Protocol constants for different Xray inbound protocols
|
||||||
const (
|
const (
|
||||||
VMESS Protocol = "vmess"
|
VMESS Protocol = "vmess"
|
||||||
VLESS Protocol = "vless"
|
VLESS Protocol = "vless"
|
||||||
@@ -20,25 +23,29 @@ const (
|
|||||||
WireGuard Protocol = "wireguard"
|
WireGuard Protocol = "wireguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// User represents a user account in the 3x-ui panel.
|
||||||
type User struct {
|
type User struct {
|
||||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
||||||
type Inbound struct {
|
type Inbound struct {
|
||||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
|
||||||
UserId int `json:"-"`
|
UserId int `json:"-"` // Associated user ID
|
||||||
Up int64 `json:"up" form:"up"`
|
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
||||||
Down int64 `json:"down" form:"down"`
|
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
||||||
Total int64 `json:"total" form:"total"`
|
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
||||||
AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"`
|
AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"` // All-time traffic usage
|
||||||
Remark string `json:"remark" form:"remark"`
|
Remark string `json:"remark" form:"remark"` // Human-readable remark
|
||||||
Enable bool `json:"enable" form:"enable"`
|
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
|
||||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||||
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"` // Traffic reset schedule
|
||||||
|
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
||||||
|
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
||||||
|
|
||||||
// config part
|
// Xray configuration fields
|
||||||
Listen string `json:"listen" form:"listen"`
|
Listen string `json:"listen" form:"listen"`
|
||||||
Port int `json:"port" form:"port"`
|
Port int `json:"port" form:"port"`
|
||||||
Protocol Protocol `json:"protocol" form:"protocol"`
|
Protocol Protocol `json:"protocol" form:"protocol"`
|
||||||
@@ -48,6 +55,7 @@ type Inbound struct {
|
|||||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
||||||
type OutboundTraffics struct {
|
type OutboundTraffics struct {
|
||||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||||
@@ -56,17 +64,20 @@ type OutboundTraffics struct {
|
|||||||
Total int64 `json:"total" form:"total" gorm:"default:0"`
|
Total int64 `json:"total" form:"total" gorm:"default:0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InboundClientIps stores IP addresses associated with inbound clients for access control.
|
||||||
type InboundClientIps struct {
|
type InboundClientIps struct {
|
||||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
ClientEmail string `json:"clientEmail" form:"clientEmail" gorm:"unique"`
|
ClientEmail string `json:"clientEmail" form:"clientEmail" gorm:"unique"`
|
||||||
Ips string `json:"ips" form:"ips"`
|
Ips string `json:"ips" form:"ips"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
|
||||||
type HistoryOfSeeders struct {
|
type HistoryOfSeeders struct {
|
||||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
SeederName string `json:"seederName"`
|
SeederName string `json:"seederName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
|
||||||
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||||
listen := i.Listen
|
listen := i.Listen
|
||||||
if listen != "" {
|
if listen != "" {
|
||||||
@@ -83,33 +94,28 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setting stores key-value configuration settings for the 3x-ui panel.
|
||||||
type Setting struct {
|
type Setting struct {
|
||||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
Key string `json:"key" form:"key"`
|
Key string `json:"key" form:"key"`
|
||||||
Value string `json:"value" form:"value"`
|
Value string `json:"value" form:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"` // Unique client identifier
|
||||||
Security string `json:"security"`
|
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||||
Password string `json:"password"`
|
Password string `json:"password"` // Client password
|
||||||
Flow string `json:"flow"`
|
Flow string `json:"flow"` // Flow control (XTLS)
|
||||||
Email string `json:"email"`
|
Email string `json:"email"` // Client email identifier
|
||||||
LimitIP int `json:"limitIp"`
|
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||||
TotalGB int64 `json:"totalGB" form:"totalGB"`
|
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||||
Enable bool `json:"enable" form:"enable"`
|
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||||
TgID int64 `json:"tgId" form:"tgId"`
|
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||||
SubID string `json:"subId" form:"subId"`
|
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||||
Comment string `json:"comment" form:"comment"`
|
Comment string `json:"comment" form:"comment"` // Client comment
|
||||||
Reset int `json:"reset" form:"reset"`
|
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||||
CreatedAt int64 `json:"created_at,omitempty"`
|
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||||
UpdatedAt int64 `json:"updated_at,omitempty"`
|
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||||
}
|
|
||||||
|
|
||||||
type VLESSSettings struct {
|
|
||||||
Clients []Client `json:"clients"`
|
|
||||||
Decryption string `json:"decryption"`
|
|
||||||
Encryption string `json:"encryption"`
|
|
||||||
Fallbacks []any `json:"fallbacks"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
20
go.mod
20
go.mod
@@ -1,11 +1,11 @@
|
|||||||
module x-ui
|
module github.com/mhsanaei/3x-ui/v2
|
||||||
|
|
||||||
go 1.25.1
|
go 1.25.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-contrib/gzip v1.2.3
|
github.com/gin-contrib/gzip v1.2.3
|
||||||
github.com/gin-contrib/sessions v1.0.4
|
github.com/gin-contrib/sessions v1.0.4
|
||||||
github.com/gin-gonic/gin v1.10.1
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/goccy/go-json v0.10.5
|
github.com/goccy/go-json v0.10.5
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
@@ -16,15 +16,16 @@ require (
|
|||||||
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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/valyala/fasthttp v1.65.0
|
github.com/valyala/fasthttp v1.66.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
|
||||||
gorm.io/gorm v1.30.5
|
gorm.io/gorm v1.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -35,13 +36,14 @@ require (
|
|||||||
github.com/cloudflare/circl v1.6.1 // indirect
|
github.com/cloudflare/circl v1.6.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect
|
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect
|
||||||
github.com/ebitengine/purego v0.8.4 // indirect
|
github.com/ebitengine/purego v0.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
github.com/google/btree v1.1.3 // indirect
|
github.com/google/btree v1.1.3 // indirect
|
||||||
github.com/gorilla/context v1.1.2 // indirect
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
@@ -69,7 +71,7 @@ require (
|
|||||||
github.com/refraction-networking/utls v1.8.0 // indirect
|
github.com/refraction-networking/utls v1.8.0 // indirect
|
||||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
|
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/sagernet/sing v0.7.7 // indirect
|
github.com/sagernet/sing v0.7.10 // indirect
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
|
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
|
||||||
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 // indirect
|
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
@@ -89,14 +91,12 @@ 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.37.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
|
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
|
||||||
lukechampine.com/blake3 v1.4.1 // indirect
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
28
go.sum
28
go.sum
@@ -19,8 +19,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/dgryski/go-metro v0.0.0-20200812162917-85c65e2d0165/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
|
github.com/dgryski/go-metro v0.0.0-20200812162917-85c65e2d0165/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
|
||||||
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM=
|
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM=
|
||||||
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
|
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
|
||||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
||||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
|
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
|
||||||
@@ -31,8 +31,8 @@ github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kb
|
|||||||
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@@ -50,6 +50,8 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO
|
|||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
|
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
|
||||||
github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs=
|
github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
@@ -134,8 +136,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
|||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/sagernet/sing v0.7.7 h1:o46FzVZS+wKbBMEkMEdEHoVZxyM9jvfRpKXc7pEgS/c=
|
github.com/sagernet/sing v0.7.10 h1:2yPhZFx+EkyHPH8hXNezgyRSHyGY12CboId7CtwLROw=
|
||||||
github.com/sagernet/sing v0.7.7/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
github.com/sagernet/sing v0.7.10/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
|
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
|
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
|
||||||
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 h1:emzAzMZ1L9iaKCTxdy3Em8Wv4ChIAGnfiz18Cda70g4=
|
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 h1:emzAzMZ1L9iaKCTxdy3Em8Wv4ChIAGnfiz18Cda70g4=
|
||||||
@@ -166,8 +168,8 @@ github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e h1:5QefA066A1tF
|
|||||||
github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e/go.mod h1:5t19P9LBIrNamL6AcMQOncg/r10y3Pc01AbHeMhwlpU=
|
github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e/go.mod h1:5t19P9LBIrNamL6AcMQOncg/r10y3Pc01AbHeMhwlpU=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
|
github.com/valyala/fasthttp v1.66.0 h1:M87A0Z7EayeyNaV6pfO3tUTUiYO0dZfEJnRGXTVNuyU=
|
||||||
github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
|
github.com/valyala/fasthttp v1.66.0/go.mod h1:Y4eC+zwoocmXSVCB1JmhNbYtS7tZPRI2ztPB72EVObs=
|
||||||
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||||
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||||
@@ -224,8 +226,8 @@ golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
|||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
|
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
|
||||||
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||||
@@ -234,6 +236,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 h1:/OQuEa4YWtDt7uQWHd3q3sUMb+QOLQUg1xa8CEsRv5w=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 h1:/OQuEa4YWtDt7uQWHd3q3sUMb+QOLQUg1xa8CEsRv5w=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 h1:V1jCN2HBa8sySkR5vLcCSqJSTMv093Rw9EJefhQGP7M=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
|
||||||
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
|
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
|
||||||
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
@@ -249,8 +253,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
gorm.io/gorm v1.30.5 h1:dvEfYwxL+i+xgCNSGGBT1lDjCzfELK8fHZxL3Ee9X0s=
|
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||||
gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
|
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
|
||||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
|
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
|
||||||
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
||||||
|
|||||||
25
install.sh
25
install.sh
@@ -56,6 +56,9 @@ install_base() {
|
|||||||
opensuse-tumbleweed)
|
opensuse-tumbleweed)
|
||||||
zypper refresh && zypper -q install -y wget curl tar timezone
|
zypper refresh && zypper -q install -y wget curl tar timezone
|
||||||
;;
|
;;
|
||||||
|
alpine)
|
||||||
|
apk update && apk add wget curl tar tzdata
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
apt-get update && apt-get install -y -q wget curl tar tzdata
|
apt-get update && apt-get install -y -q wget curl tar tzdata
|
||||||
;;
|
;;
|
||||||
@@ -177,7 +180,11 @@ install_x-ui() {
|
|||||||
|
|
||||||
# Stop x-ui service and remove old resources
|
# Stop x-ui service and remove old resources
|
||||||
if [[ -e /usr/local/x-ui/ ]]; then
|
if [[ -e /usr/local/x-ui/ ]]; then
|
||||||
systemctl stop x-ui
|
if [[ $release == "alpine" ]]; then
|
||||||
|
rc-service x-ui stop
|
||||||
|
else
|
||||||
|
systemctl stop x-ui
|
||||||
|
fi
|
||||||
rm /usr/local/x-ui/ -rf
|
rm /usr/local/x-ui/ -rf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -201,10 +208,18 @@ install_x-ui() {
|
|||||||
chmod +x /usr/bin/x-ui
|
chmod +x /usr/bin/x-ui
|
||||||
config_after_install
|
config_after_install
|
||||||
|
|
||||||
cp -f x-ui.service /etc/systemd/system/
|
if [[ $release == "alpine" ]]; then
|
||||||
systemctl daemon-reload
|
wget -O /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
|
||||||
systemctl enable x-ui
|
chmod +x /etc/init.d/x-ui
|
||||||
systemctl start x-ui
|
rc-update add x-ui
|
||||||
|
rc-service x-ui start
|
||||||
|
else
|
||||||
|
cp -f x-ui.service /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable x-ui
|
||||||
|
systemctl start x-ui
|
||||||
|
fi
|
||||||
|
|
||||||
echo -e "${green}x-ui ${tag_version}${plain} installation finished, it is running now..."
|
echo -e "${green}x-ui ${tag_version}${plain} installation finished, it is running now..."
|
||||||
echo -e ""
|
echo -e ""
|
||||||
echo -e "┌───────────────────────────────────────────────────────┐
|
echo -e "┌───────────────────────────────────────────────────────┐
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Package logger provides logging functionality for the 3x-ui panel with
|
||||||
|
// buffered log storage and multiple log levels.
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,7 +11,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logger *logging.Logger
|
logger *logging.Logger
|
||||||
|
|
||||||
|
// addToBuffer appends a log entry into the in-memory ring buffer used for
|
||||||
|
// retrieving recent logs via the web UI. It keeps the buffer bounded to avoid
|
||||||
|
// uncontrolled growth.
|
||||||
logBuffer []struct {
|
logBuffer []struct {
|
||||||
time string
|
time string
|
||||||
level logging.Level
|
level logging.Level
|
||||||
@@ -21,6 +27,7 @@ func init() {
|
|||||||
InitLogger(logging.INFO)
|
InitLogger(logging.INFO)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitLogger initializes the logger with the specified logging level.
|
||||||
func InitLogger(level logging.Level) {
|
func InitLogger(level logging.Level) {
|
||||||
newLogger := logging.MustGetLogger("x-ui")
|
newLogger := logging.MustGetLogger("x-ui")
|
||||||
var err error
|
var err error
|
||||||
@@ -47,51 +54,61 @@ func InitLogger(level logging.Level) {
|
|||||||
logger = newLogger
|
logger = newLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug logs a debug message and adds it to the log buffer.
|
||||||
func Debug(args ...any) {
|
func Debug(args ...any) {
|
||||||
logger.Debug(args...)
|
logger.Debug(args...)
|
||||||
addToBuffer("DEBUG", fmt.Sprint(args...))
|
addToBuffer("DEBUG", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debugf logs a formatted debug message and adds it to the log buffer.
|
||||||
func Debugf(format string, args ...any) {
|
func Debugf(format string, args ...any) {
|
||||||
logger.Debugf(format, args...)
|
logger.Debugf(format, args...)
|
||||||
addToBuffer("DEBUG", fmt.Sprintf(format, args...))
|
addToBuffer("DEBUG", fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Info logs an info message and adds it to the log buffer.
|
||||||
func Info(args ...any) {
|
func Info(args ...any) {
|
||||||
logger.Info(args...)
|
logger.Info(args...)
|
||||||
addToBuffer("INFO", fmt.Sprint(args...))
|
addToBuffer("INFO", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Infof logs a formatted info message and adds it to the log buffer.
|
||||||
func Infof(format string, args ...any) {
|
func Infof(format string, args ...any) {
|
||||||
logger.Infof(format, args...)
|
logger.Infof(format, args...)
|
||||||
addToBuffer("INFO", fmt.Sprintf(format, args...))
|
addToBuffer("INFO", fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notice logs a notice message and adds it to the log buffer.
|
||||||
func Notice(args ...any) {
|
func Notice(args ...any) {
|
||||||
logger.Notice(args...)
|
logger.Notice(args...)
|
||||||
addToBuffer("NOTICE", fmt.Sprint(args...))
|
addToBuffer("NOTICE", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Noticef logs a formatted notice message and adds it to the log buffer.
|
||||||
func Noticef(format string, args ...any) {
|
func Noticef(format string, args ...any) {
|
||||||
logger.Noticef(format, args...)
|
logger.Noticef(format, args...)
|
||||||
addToBuffer("NOTICE", fmt.Sprintf(format, args...))
|
addToBuffer("NOTICE", fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warning logs a warning message and adds it to the log buffer.
|
||||||
func Warning(args ...any) {
|
func Warning(args ...any) {
|
||||||
logger.Warning(args...)
|
logger.Warning(args...)
|
||||||
addToBuffer("WARNING", fmt.Sprint(args...))
|
addToBuffer("WARNING", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warningf logs a formatted warning message and adds it to the log buffer.
|
||||||
func Warningf(format string, args ...any) {
|
func Warningf(format string, args ...any) {
|
||||||
logger.Warningf(format, args...)
|
logger.Warningf(format, args...)
|
||||||
addToBuffer("WARNING", fmt.Sprintf(format, args...))
|
addToBuffer("WARNING", fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error logs an error message and adds it to the log buffer.
|
||||||
func Error(args ...any) {
|
func Error(args ...any) {
|
||||||
logger.Error(args...)
|
logger.Error(args...)
|
||||||
addToBuffer("ERROR", fmt.Sprint(args...))
|
addToBuffer("ERROR", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Errorf logs a formatted error message and adds it to the log buffer.
|
||||||
func Errorf(format string, args ...any) {
|
func Errorf(format string, args ...any) {
|
||||||
logger.Errorf(format, args...)
|
logger.Errorf(format, args...)
|
||||||
addToBuffer("ERROR", fmt.Sprintf(format, args...))
|
addToBuffer("ERROR", fmt.Sprintf(format, args...))
|
||||||
@@ -115,6 +132,7 @@ func addToBuffer(level string, newLog string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLogs retrieves up to c log entries from the buffer that are at or below the specified level.
|
||||||
func GetLogs(c int, level string) []string {
|
func GetLogs(c int, level string) []string {
|
||||||
var output []string
|
var output []string
|
||||||
logLevel, _ := logging.LogLevel(level)
|
logLevel, _ := logging.LogLevel(level)
|
||||||
|
|||||||
32
main.go
32
main.go
@@ -1,3 +1,5 @@
|
|||||||
|
// Package main is the entry point for the 3x-ui web panel application.
|
||||||
|
// It initializes the database, web server, and handles command-line operations for managing the panel.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,19 +11,20 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// runWebServer initializes and starts the web server for the 3x-ui panel.
|
||||||
func runWebServer() {
|
func runWebServer() {
|
||||||
log.Printf("Starting %v %v", config.GetName(), config.GetVersion())
|
log.Printf("Starting %v %v", config.GetName(), config.GetVersion())
|
||||||
|
|
||||||
@@ -32,7 +35,7 @@ func runWebServer() {
|
|||||||
logger.InitLogger(logging.INFO)
|
logger.InitLogger(logging.INFO)
|
||||||
case config.Notice:
|
case config.Notice:
|
||||||
logger.InitLogger(logging.NOTICE)
|
logger.InitLogger(logging.NOTICE)
|
||||||
case config.Warn:
|
case config.Warning:
|
||||||
logger.InitLogger(logging.WARNING)
|
logger.InitLogger(logging.WARNING)
|
||||||
case config.Error:
|
case config.Error:
|
||||||
logger.InitLogger(logging.ERROR)
|
logger.InitLogger(logging.ERROR)
|
||||||
@@ -111,6 +114,7 @@ func runWebServer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetSetting resets all panel settings to their default values.
|
||||||
func resetSetting() {
|
func resetSetting() {
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB(config.GetDBPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -127,6 +131,7 @@ func resetSetting() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// showSetting displays the current panel settings if show is true.
|
||||||
func showSetting(show bool) {
|
func showSetting(show bool) {
|
||||||
if show {
|
if show {
|
||||||
settingService := service.SettingService{}
|
settingService := service.SettingService{}
|
||||||
@@ -176,6 +181,7 @@ func showSetting(show bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateTgbotEnableSts enables or disables the Telegram bot notifications based on the status parameter.
|
||||||
func updateTgbotEnableSts(status bool) {
|
func updateTgbotEnableSts(status bool) {
|
||||||
settingService := service.SettingService{}
|
settingService := service.SettingService{}
|
||||||
currentTgSts, err := settingService.GetTgbotEnabled()
|
currentTgSts, err := settingService.GetTgbotEnabled()
|
||||||
@@ -195,6 +201,7 @@ func updateTgbotEnableSts(status bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateTgbotSetting updates Telegram bot settings including token, chat ID, and runtime schedule.
|
||||||
func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime string) {
|
func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime string) {
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB(config.GetDBPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -232,6 +239,7 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
|
||||||
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) {
|
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) {
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB(config.GetDBPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -290,6 +298,7 @@ func updateSetting(port int, username string, password string, webBasePath strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateCert updates the SSL certificate files for the panel.
|
||||||
func updateCert(publicKey string, privateKey string) {
|
func updateCert(publicKey string, privateKey string) {
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB(config.GetDBPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -317,6 +326,7 @@ func updateCert(publicKey string, privateKey string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCertificate displays the current SSL certificate settings if getCert is true.
|
||||||
func GetCertificate(getCert bool) {
|
func GetCertificate(getCert bool) {
|
||||||
if getCert {
|
if getCert {
|
||||||
settingService := service.SettingService{}
|
settingService := service.SettingService{}
|
||||||
@@ -334,6 +344,7 @@ func GetCertificate(getCert bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetListenIP displays the current panel listen IP address if getListen is true.
|
||||||
func GetListenIP(getListen bool) {
|
func GetListenIP(getListen bool) {
|
||||||
if getListen {
|
if getListen {
|
||||||
|
|
||||||
@@ -348,6 +359,7 @@ func GetListenIP(getListen bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateDb performs database migration operations for the 3x-ui panel.
|
||||||
func migrateDb() {
|
func migrateDb() {
|
||||||
inboundService := service.InboundService{}
|
inboundService := service.InboundService{}
|
||||||
|
|
||||||
@@ -360,6 +372,8 @@ func migrateDb() {
|
|||||||
fmt.Println("Migration done!")
|
fmt.Println("Migration done!")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// main is the entry point of the 3x-ui application.
|
||||||
|
// It parses command-line arguments to run the web server, migrate database, or update settings.
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
runWebServer()
|
runWebServer()
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.1 KiB |
BIN
media/default-yellow.png
Normal file
BIN
media/default-yellow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
1
media/donation-button-black.svg
Normal file
1
media/donation-button-black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 10 KiB |
64
sub/sub.go
64
sub/sub.go
@@ -1,3 +1,5 @@
|
|||||||
|
// Package sub provides subscription server functionality for the 3x-ui panel,
|
||||||
|
// including HTTP/HTTPS servers for serving subscription links and JSON configurations.
|
||||||
package sub
|
package sub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,14 +13,15 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"x-ui/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"x-ui/util/common"
|
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||||
webpkg "x-ui/web"
|
webpkg "github.com/mhsanaei/3x-ui/v2/web"
|
||||||
"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-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -29,7 +32,7 @@ func setEmbeddedTemplates(engine *gin.Engine) error {
|
|||||||
webpkg.EmbeddedHTML(),
|
webpkg.EmbeddedHTML(),
|
||||||
"html/common/page.html",
|
"html/common/page.html",
|
||||||
"html/component/aThemeSwitch.html",
|
"html/component/aThemeSwitch.html",
|
||||||
"html/subscription.html",
|
"html/settings/panel/subscription/subpage.html",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -38,6 +41,7 @@ func setEmbeddedTemplates(engine *gin.Engine) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server represents the subscription server that serves subscription links and JSON configurations.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
@@ -49,6 +53,7 @@ type Server struct {
|
|||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new subscription server instance with a cancellable context.
|
||||||
func NewServer() *Server {
|
func NewServer() *Server {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return &Server{
|
return &Server{
|
||||||
@@ -57,6 +62,8 @@ func NewServer() *Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initRouter configures the subscription server's Gin engine, middleware,
|
||||||
|
// templates and static assets and returns the ready-to-use engine.
|
||||||
func (s *Server) initRouter() (*gin.Engine, error) {
|
func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
// Always run in release mode for the subscription server
|
// Always run in release mode for the subscription server
|
||||||
gin.DefaultWriter = io.Discard
|
gin.DefaultWriter = io.Discard
|
||||||
@@ -74,11 +81,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||||||
engine.Use(middleware.DomainValidatorMiddleware(subDomain))
|
engine.Use(middleware.DomainValidatorMiddleware(subDomain))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide base_path in context for templates
|
|
||||||
engine.Use(func(c *gin.Context) {
|
|
||||||
c.Set("base_path", "/")
|
|
||||||
})
|
|
||||||
|
|
||||||
LinksPath, err := s.settingService.GetSubPath()
|
LinksPath, err := s.settingService.GetSubPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -89,6 +91,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
|
||||||
@@ -154,11 +167,29 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Assets: use disk if present, fallback to embedded
|
// 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 {
|
if _, err := os.Stat("web/assets"); err == nil {
|
||||||
engine.StaticFS("/assets", http.FS(os.DirFS("web/assets")))
|
engine.StaticFS("/assets", http.FS(os.DirFS("web/assets")))
|
||||||
|
if linksPathForAssets != "/assets" {
|
||||||
|
engine.StaticFS(linksPathForAssets, http.FS(os.DirFS("web/assets")))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil {
|
if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil {
|
||||||
engine.StaticFS("/assets", http.FS(subFS))
|
engine.StaticFS("/assets", http.FS(subFS))
|
||||||
|
if linksPathForAssets != "/assets" {
|
||||||
|
engine.StaticFS(linksPathForAssets, http.FS(subFS))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.Error("sub: failed to mount embedded assets:", err)
|
logger.Error("sub: failed to mount embedded assets:", err)
|
||||||
}
|
}
|
||||||
@@ -167,7 +198,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||||||
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
|
||||||
@@ -188,7 +219,7 @@ func (s *Server) getHtmlFiles() ([]string, error) {
|
|||||||
files = append(files, theme)
|
files = append(files, theme)
|
||||||
}
|
}
|
||||||
// page itself
|
// page itself
|
||||||
page := filepath.Join(dir, "web", "html", "subscription.html")
|
page := filepath.Join(dir, "web", "html", "subpage.html")
|
||||||
if _, err := os.Stat(page); err == nil {
|
if _, err := os.Stat(page); err == nil {
|
||||||
files = append(files, page)
|
files = append(files, page)
|
||||||
} else {
|
} else {
|
||||||
@@ -197,6 +228,7 @@ func (s *Server) getHtmlFiles() ([]string, error) {
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start initializes and starts the subscription server with configured settings.
|
||||||
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() {
|
||||||
@@ -270,6 +302,7 @@ func (s *Server) Start() (err error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop gracefully shuts down the subscription server and closes the listener.
|
||||||
func (s *Server) Stop() error {
|
func (s *Server) Stop() error {
|
||||||
s.cancel()
|
s.cancel()
|
||||||
|
|
||||||
@@ -284,6 +317,7 @@ func (s *Server) Stop() error {
|
|||||||
return common.Combine(err1, err2)
|
return common.Combine(err1, err2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCtx returns the server's context for cancellation and deadline management.
|
||||||
func (s *Server) GetCtx() context.Context {
|
func (s *Server) GetCtx() context.Context {
|
||||||
return s.ctx
|
return s.ctx
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ package sub
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"x-ui/config"
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/config"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SUBController handles HTTP requests for subscription links and JSON configurations.
|
||||||
type SUBController struct {
|
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
|
||||||
|
|
||||||
@@ -19,10 +23,12 @@ type SUBController struct {
|
|||||||
subJsonService *SubJsonService
|
subJsonService *SubJsonService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewSUBController creates a new subscription controller with the given configuration.
|
||||||
func NewSUBController(
|
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 +44,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,
|
||||||
|
|
||||||
@@ -48,18 +55,22 @@ func NewSUBController(
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initRouter registers HTTP routes for subscription links and JSON endpoints
|
||||||
|
// on the provided router group.
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
|
||||||
func (a *SUBController) subs(c *gin.Context) {
|
func (a *SUBController) subs(c *gin.Context) {
|
||||||
subId := c.Param("subid")
|
subId := c.Param("subid")
|
||||||
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
|
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
|
||||||
subs, header, lastOnline, err := a.subService.GetSubs(subId, host)
|
subs, lastOnline, traffic, 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 {
|
||||||
@@ -73,8 +84,11 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||||||
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
|
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
|
||||||
// Build page data in service
|
// Build page data in service
|
||||||
subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId)
|
subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId)
|
||||||
page := a.subService.BuildPageData(subId, hostHeader, header, lastOnline, subs, subURL, subJsonURL)
|
if !a.jsonEnabled {
|
||||||
c.HTML(200, "subscription.html", gin.H{
|
subJsonURL = ""
|
||||||
|
}
|
||||||
|
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL)
|
||||||
|
c.HTML(200, "subpage.html", gin.H{
|
||||||
"title": "subscription.title",
|
"title": "subscription.title",
|
||||||
"cur_ver": config.GetVersion(),
|
"cur_ver": config.GetVersion(),
|
||||||
"host": page.Host,
|
"host": page.Host,
|
||||||
@@ -99,6 +113,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add headers
|
// Add headers
|
||||||
|
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
|
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
|
||||||
|
|
||||||
if a.subEncrypt {
|
if a.subEncrypt {
|
||||||
@@ -109,6 +124,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// subJsons handles HTTP requests for JSON subscription configurations.
|
||||||
func (a *SUBController) subJsons(c *gin.Context) {
|
func (a *SUBController) subJsons(c *gin.Context) {
|
||||||
subId := c.Param("subid")
|
subId := c.Param("subid")
|
||||||
_, host, _, _ := a.subService.ResolveRequest(c)
|
_, host, _, _ := a.subService.ResolveRequest(c)
|
||||||
@@ -124,6 +140,7 @@ func (a *SUBController) subJsons(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
|
||||||
func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) {
|
func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) {
|
||||||
c.Writer.Header().Set("Subscription-Userinfo", header)
|
c.Writer.Header().Set("Subscription-Userinfo", header)
|
||||||
c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
|
c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
|
||||||
|
|||||||
@@ -6,17 +6,18 @@ 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
|
||||||
var defaultJson string
|
var defaultJson string
|
||||||
|
|
||||||
|
// SubJsonService handles JSON subscription configuration generation and management.
|
||||||
type SubJsonService struct {
|
type SubJsonService struct {
|
||||||
configJson map[string]any
|
configJson map[string]any
|
||||||
defaultOutbounds []json_util.RawMessage
|
defaultOutbounds []json_util.RawMessage
|
||||||
@@ -28,6 +29,7 @@ type SubJsonService struct {
|
|||||||
SubService *SubService
|
SubService *SubService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewSubJsonService creates a new JSON subscription service with the given configuration.
|
||||||
func NewSubJsonService(fragment string, noises string, mux string, rules string, subService *SubService) *SubJsonService {
|
func NewSubJsonService(fragment string, noises string, mux string, rules string, subService *SubService) *SubJsonService {
|
||||||
var configJson map[string]any
|
var configJson map[string]any
|
||||||
var defaultOutbounds []json_util.RawMessage
|
var defaultOutbounds []json_util.RawMessage
|
||||||
@@ -67,6 +69,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetJson generates a JSON subscription configuration for the given subscription ID and host.
|
||||||
func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) {
|
func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) {
|
||||||
inbounds, err := s.SubService.getInboundsBySubId(subId)
|
inbounds, err := s.SubService.getInboundsBySubId(subId)
|
||||||
if err != nil || len(inbounds) == 0 {
|
if err != nil || len(inbounds) == 0 {
|
||||||
@@ -171,12 +174,12 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
|
|||||||
case "tls":
|
case "tls":
|
||||||
if newStream["security"] != "tls" {
|
if newStream["security"] != "tls" {
|
||||||
newStream["security"] = "tls"
|
newStream["security"] = "tls"
|
||||||
newStream["tslSettings"] = map[string]any{}
|
newStream["tlsSettings"] = map[string]any{}
|
||||||
}
|
}
|
||||||
case "none":
|
case "none":
|
||||||
if newStream["security"] != "none" {
|
if newStream["security"] != "none" {
|
||||||
newStream["security"] = "none"
|
newStream["security"] = "none"
|
||||||
delete(newStream, "tslSettings")
|
delete(newStream, "tlsSettings")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
streamSettings, _ := json.MarshalIndent(newStream, "", " ")
|
streamSettings, _ := json.MarshalIndent(newStream, "", " ")
|
||||||
@@ -185,13 +188,9 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
|
|||||||
|
|
||||||
switch inbound.Protocol {
|
switch inbound.Protocol {
|
||||||
case "vmess":
|
case "vmess":
|
||||||
newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client, ""))
|
newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client))
|
||||||
case "vless":
|
case "vless":
|
||||||
var vlessSettings model.VLESSSettings
|
newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client))
|
||||||
_ = json.Unmarshal([]byte(inbound.Settings), &vlessSettings)
|
|
||||||
|
|
||||||
newOutbounds = append(newOutbounds,
|
|
||||||
s.genVnext(inbound, streamSettings, client, vlessSettings.Encryption))
|
|
||||||
case "trojan", "shadowsocks":
|
case "trojan", "shadowsocks":
|
||||||
newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client))
|
newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client))
|
||||||
}
|
}
|
||||||
@@ -290,20 +289,13 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
|
|||||||
return rltyData
|
return rltyData
|
||||||
}
|
}
|
||||||
|
|
||||||
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) json_util.RawMessage {
|
||||||
outbound := Outbound{}
|
outbound := Outbound{}
|
||||||
usersData := make([]UserVnext, 1)
|
usersData := make([]UserVnext, 1)
|
||||||
|
|
||||||
usersData[0].ID = client.ID
|
usersData[0].ID = client.ID
|
||||||
usersData[0].Level = 8
|
usersData[0].Email = client.Email
|
||||||
if inbound.Protocol == model.VMESS {
|
usersData[0].Security = client.Security
|
||||||
usersData[0].Security = client.Security
|
|
||||||
}
|
|
||||||
if inbound.Protocol == model.VLESS {
|
|
||||||
usersData[0].Flow = client.Flow
|
|
||||||
usersData[0].Encryption = encryption
|
|
||||||
}
|
|
||||||
|
|
||||||
vnextData := make([]VnextSetting, 1)
|
vnextData := make([]VnextSetting, 1)
|
||||||
vnextData[0] = VnextSetting{
|
vnextData[0] = VnextSetting{
|
||||||
Address: inbound.Listen,
|
Address: inbound.Listen,
|
||||||
@@ -317,14 +309,42 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
|
|||||||
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{
|
||||||
Vnext: vnextData,
|
"vnext": vnextData,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
|
||||||
|
outbound := Outbound{}
|
||||||
|
outbound.Protocol = string(inbound.Protocol)
|
||||||
|
outbound.Tag = "proxy"
|
||||||
|
if s.mux != "" {
|
||||||
|
outbound.Mux = json_util.RawMessage(s.mux)
|
||||||
|
}
|
||||||
|
outbound.StreamSettings = streamSettings
|
||||||
|
settings := make(map[string]any)
|
||||||
|
settings["address"] = inbound.Listen
|
||||||
|
settings["port"] = inbound.Port
|
||||||
|
settings["id"] = client.ID
|
||||||
|
if client.Flow != "" {
|
||||||
|
settings["flow"] = client.Flow
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add encryption for VLESS outbound from inbound settings
|
||||||
|
var inboundSettings map[string]any
|
||||||
|
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||||
|
if encryption, ok := inboundSettings["encryption"].(string); ok {
|
||||||
|
settings["encryption"] = encryption
|
||||||
|
}
|
||||||
|
|
||||||
|
outbound.Settings = settings
|
||||||
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
|
func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
|
||||||
outbound := Outbound{}
|
outbound := Outbound{}
|
||||||
|
|
||||||
@@ -356,8 +376,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,13 +389,7 @@ 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 {
|
|
||||||
Vnext []VnextSetting `json:"vnext,omitempty"`
|
|
||||||
Servers []ServerSetting `json:"servers,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type VnextSetting struct {
|
type VnextSetting struct {
|
||||||
@@ -385,11 +399,9 @@ type VnextSetting struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserVnext struct {
|
type UserVnext struct {
|
||||||
Encryption string `json:"encryption,omitempty"`
|
ID string `json:"id"`
|
||||||
Flow string `json:"flow,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
ID string `json:"id"`
|
Security string `json:"security,omitempty"`
|
||||||
Security string `json:"security,omitempty"`
|
|
||||||
Level int `json:"level"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerSetting struct {
|
type ServerSetting struct {
|
||||||
|
|||||||
@@ -5,22 +5,22 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/goccy/go-json"
|
"github.com/goccy/go-json"
|
||||||
|
|
||||||
"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/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
"x-ui/xray"
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SubService provides business logic for generating subscription links and managing subscription data.
|
||||||
type SubService struct {
|
type SubService struct {
|
||||||
address string
|
address string
|
||||||
showInfo bool
|
showInfo bool
|
||||||
@@ -30,6 +30,7 @@ type SubService struct {
|
|||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewSubService creates a new subscription service with the given configuration.
|
||||||
func NewSubService(showInfo bool, remarkModel string) *SubService {
|
func NewSubService(showInfo bool, remarkModel string) *SubService {
|
||||||
return &SubService{
|
return &SubService{
|
||||||
showInfo: showInfo,
|
showInfo: showInfo,
|
||||||
@@ -37,20 +38,20 @@ func NewSubService(showInfo bool, remarkModel string) *SubService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) GetSubs(subId string, host string) ([]string, string, int64, error) {
|
// GetSubs retrieves subscription links for a given subscription ID and host.
|
||||||
|
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 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, "", 0, err
|
return nil, 0, traffic, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(inbounds) == 0 {
|
if len(inbounds) == 0 {
|
||||||
return nil, "", 0, 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()
|
||||||
@@ -108,8 +109,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, string, int64
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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, lastOnline, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
|
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
|
||||||
@@ -321,9 +321,6 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
|||||||
if inbound.Protocol != model.VLESS {
|
if inbound.Protocol != model.VLESS {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var vlessSettings model.VLESSSettings
|
|
||||||
_ = json.Unmarshal([]byte(inbound.Settings), &vlessSettings)
|
|
||||||
|
|
||||||
var stream map[string]any
|
var stream map[string]any
|
||||||
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
||||||
clients, _ := s.inboundService.GetClients(inbound)
|
clients, _ := s.inboundService.GetClients(inbound)
|
||||||
@@ -338,11 +335,15 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
|||||||
port := inbound.Port
|
port := inbound.Port
|
||||||
streamNetwork := stream["network"].(string)
|
streamNetwork := stream["network"].(string)
|
||||||
params := make(map[string]string)
|
params := make(map[string]string)
|
||||||
if vlessSettings.Encryption != "" {
|
|
||||||
params["encryption"] = vlessSettings.Encryption
|
|
||||||
}
|
|
||||||
params["type"] = streamNetwork
|
params["type"] = streamNetwork
|
||||||
|
|
||||||
|
// Add encryption parameter for VLESS from inbound settings
|
||||||
|
var settings map[string]any
|
||||||
|
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||||
|
if encryption, ok := settings["encryption"].(string); ok {
|
||||||
|
params["encryption"] = encryption
|
||||||
|
}
|
||||||
|
|
||||||
switch streamNetwork {
|
switch streamNetwork {
|
||||||
case "tcp":
|
case "tcp":
|
||||||
tcp, _ := stream["tcpSettings"].(map[string]any)
|
tcp, _ := stream["tcpSettings"].(map[string]any)
|
||||||
@@ -1010,7 +1011,8 @@ func searchHost(headers any) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageData is a view model for subscription.html
|
// PageData is a view model for subpage.html
|
||||||
|
// PageData contains data for rendering the subscription information page.
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
Host string
|
Host string
|
||||||
BasePath string
|
BasePath string
|
||||||
@@ -1032,6 +1034,7 @@ type PageData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ResolveRequest extracts scheme and host info from request/headers consistently.
|
// ResolveRequest extracts scheme and host info from request/headers consistently.
|
||||||
|
// ResolveRequest extracts scheme, host, and header information from an HTTP request.
|
||||||
func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
|
func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
|
||||||
// scheme
|
// scheme
|
||||||
scheme = "http"
|
scheme = "http"
|
||||||
@@ -1074,64 +1077,86 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildURLs constructs absolute subscription and json URLs.
|
// BuildURLs constructs absolute subscription and JSON subscription URLs for a given subscription ID.
|
||||||
|
// It prioritizes configured URIs, then individual settings, and finally falls back to request-derived components.
|
||||||
func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) {
|
func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) {
|
||||||
if strings.HasSuffix(subPath, "/") {
|
// Input validation
|
||||||
subURL = scheme + "://" + hostWithPort + subPath + subId
|
if subId == "" {
|
||||||
} else {
|
return "", ""
|
||||||
subURL = scheme + "://" + hostWithPort + strings.TrimRight(subPath, "/") + "/" + subId
|
|
||||||
}
|
}
|
||||||
if strings.HasSuffix(subJsonPath, "/") {
|
|
||||||
subJsonURL = scheme + "://" + hostWithPort + subJsonPath + subId
|
// Get configured URIs first (highest priority)
|
||||||
} else {
|
configuredSubURI, _ := s.settingService.GetSubURI()
|
||||||
subJsonURL = scheme + "://" + hostWithPort + strings.TrimRight(subJsonPath, "/") + "/" + subId
|
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
|
||||||
|
|
||||||
|
// Determine base scheme and host (cached to avoid duplicate calls)
|
||||||
|
var baseScheme, baseHostWithPort string
|
||||||
|
if configuredSubURI == "" || configuredSubJsonURI == "" {
|
||||||
|
baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
|
||||||
}
|
}
|
||||||
return
|
|
||||||
|
// Build subscription URL
|
||||||
|
subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
|
||||||
|
|
||||||
|
// Build JSON subscription URL
|
||||||
|
subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId)
|
||||||
|
|
||||||
|
return subURL, subJsonURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values
|
||||||
|
func (s *SubService) getBaseSchemeAndHost(requestScheme, requestHostWithPort string) (string, string) {
|
||||||
|
subDomain, err := s.settingService.GetSubDomain()
|
||||||
|
if err != nil || subDomain == "" {
|
||||||
|
return requestScheme, requestHostWithPort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get port and TLS settings
|
||||||
|
subPort, _ := s.settingService.GetSubPort()
|
||||||
|
subKeyFile, _ := s.settingService.GetSubKeyFile()
|
||||||
|
subCertFile, _ := s.settingService.GetSubCertFile()
|
||||||
|
|
||||||
|
// Determine scheme from TLS configuration
|
||||||
|
scheme := "http"
|
||||||
|
if subKeyFile != "" && subCertFile != "" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build host:port, always include port for clarity
|
||||||
|
hostWithPort := fmt.Sprintf("%s:%d", subDomain, subPort)
|
||||||
|
|
||||||
|
return scheme, hostWithPort
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSingleURL constructs a single URL using configured URI or base components
|
||||||
|
func (s *SubService) buildSingleURL(configuredURI, baseScheme, baseHostWithPort, basePath, subId string) string {
|
||||||
|
if configuredURI != "" {
|
||||||
|
return s.joinPathWithID(configuredURI, subId)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := fmt.Sprintf("%s://%s", baseScheme, baseHostWithPort)
|
||||||
|
return s.joinPathWithID(baseURL+basePath, subId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinPathWithID safely joins a base path with a subscription ID
|
||||||
|
func (s *SubService) joinPathWithID(basePath, subId string) string {
|
||||||
|
if strings.HasSuffix(basePath, "/") {
|
||||||
|
return basePath + subId
|
||||||
|
}
|
||||||
|
return basePath + "/" + subId
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildPageData parses header and prepares the template view model.
|
// BuildPageData parses header and prepares the template view model.
|
||||||
func (s *SubService) BuildPageData(subId, hostHeader, header string, lastOnline int64, subs []string, subURL, subJsonURL string) PageData {
|
// BuildPageData constructs page data for rendering the subscription information page.
|
||||||
// Parse header values
|
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string) PageData {
|
||||||
var uploadByte, downloadByte, totalByte, expire int64
|
download := common.FormatTraffic(traffic.Down)
|
||||||
parts := strings.Split(header, ";")
|
upload := common.FormatTraffic(traffic.Up)
|
||||||
for _, p := range parts {
|
|
||||||
kv := strings.Split(strings.TrimSpace(p), "=")
|
|
||||||
if len(kv) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.ToLower(strings.TrimSpace(kv[0]))
|
|
||||||
val := strings.TrimSpace(kv[1])
|
|
||||||
switch key {
|
|
||||||
case "upload":
|
|
||||||
if v, err := parseInt64(val); err == nil {
|
|
||||||
uploadByte = v
|
|
||||||
}
|
|
||||||
case "download":
|
|
||||||
if v, err := parseInt64(val); err == nil {
|
|
||||||
downloadByte = v
|
|
||||||
}
|
|
||||||
case "total":
|
|
||||||
if v, err := parseInt64(val); err == nil {
|
|
||||||
totalByte = v
|
|
||||||
}
|
|
||||||
case "expire":
|
|
||||||
if v, err := parseInt64(val); err == nil {
|
|
||||||
expire = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
download := common.FormatTraffic(downloadByte)
|
|
||||||
upload := common.FormatTraffic(uploadByte)
|
|
||||||
total := "∞"
|
total := "∞"
|
||||||
used := common.FormatTraffic(uploadByte + downloadByte)
|
used := common.FormatTraffic(traffic.Up + traffic.Down)
|
||||||
remained := ""
|
remained := ""
|
||||||
if totalByte > 0 {
|
if traffic.Total > 0 {
|
||||||
total = common.FormatTraffic(totalByte)
|
total = common.FormatTraffic(traffic.Total)
|
||||||
left := totalByte - (uploadByte + downloadByte)
|
left := max(traffic.Total-(traffic.Up+traffic.Down), 0)
|
||||||
if left < 0 {
|
|
||||||
left = 0
|
|
||||||
}
|
|
||||||
remained = common.FormatTraffic(left)
|
remained = common.FormatTraffic(left)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1142,19 +1167,19 @@ func (s *SubService) BuildPageData(subId, hostHeader, header string, lastOnline
|
|||||||
|
|
||||||
return PageData{
|
return PageData{
|
||||||
Host: hostHeader,
|
Host: hostHeader,
|
||||||
BasePath: "/",
|
BasePath: "/", // kept as "/"; templates now use context base_path injected from router
|
||||||
SId: subId,
|
SId: subId,
|
||||||
Download: download,
|
Download: download,
|
||||||
Upload: upload,
|
Upload: upload,
|
||||||
Total: total,
|
Total: total,
|
||||||
Used: used,
|
Used: used,
|
||||||
Remained: remained,
|
Remained: remained,
|
||||||
Expire: expire,
|
Expire: traffic.ExpiryTime / 1000,
|
||||||
LastOnline: lastOnline,
|
LastOnline: lastOnline,
|
||||||
Datepicker: datepicker,
|
Datepicker: datepicker,
|
||||||
DownloadByte: downloadByte,
|
DownloadByte: traffic.Down,
|
||||||
UploadByte: uploadByte,
|
UploadByte: traffic.Up,
|
||||||
TotalByte: totalByte,
|
TotalByte: traffic.Total,
|
||||||
SubUrl: subURL,
|
SubUrl: subURL,
|
||||||
SubJsonUrl: subJsonURL,
|
SubJsonUrl: subJsonURL,
|
||||||
Result: subs,
|
Result: subs,
|
||||||
@@ -1171,10 +1196,3 @@ func getHostFromXFH(s string) (string, error) {
|
|||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseInt64(s string) (int64, error) {
|
|
||||||
// handle potential quotes
|
|
||||||
s = strings.Trim(s, "\"'")
|
|
||||||
n, err := strconv.ParseInt(s, 10, 64)
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
|
// Package common provides common utility functions for error handling, formatting, and multi-error management.
|
||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"x-ui/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewErrorf creates a new error with formatted message.
|
||||||
func NewErrorf(format string, a ...any) error {
|
func NewErrorf(format string, a ...any) error {
|
||||||
msg := fmt.Sprintf(format, a...)
|
msg := fmt.Sprintf(format, a...)
|
||||||
return errors.New(msg)
|
return errors.New(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewError creates a new error from the given arguments.
|
||||||
func NewError(a ...any) error {
|
func NewError(a ...any) error {
|
||||||
msg := fmt.Sprintln(a...)
|
msg := fmt.Sprintln(a...)
|
||||||
return errors.New(msg)
|
return errors.New(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recover handles panic recovery and logs the panic error if a message is provided.
|
||||||
func Recover(msg string) any {
|
func Recover(msg string) any {
|
||||||
panicErr := recover()
|
panicErr := recover()
|
||||||
if panicErr != nil {
|
if panicErr != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// FormatTraffic formats traffic bytes into human-readable units (B, KB, MB, GB, TB, PB).
|
||||||
func FormatTraffic(trafficBytes int64) string {
|
func FormatTraffic(trafficBytes int64) string {
|
||||||
units := []string{"B", "KB", "MB", "GB", "TB", "PB"}
|
units := []string{"B", "KB", "MB", "GB", "TB", "PB"}
|
||||||
unitIndex := 0
|
unitIndex := 0
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// multiError represents a collection of errors.
|
||||||
type multiError []error
|
type multiError []error
|
||||||
|
|
||||||
|
// Error returns a string representation of all errors joined with " | ".
|
||||||
func (e multiError) Error() string {
|
func (e multiError) Error() string {
|
||||||
var r strings.Builder
|
var r strings.Builder
|
||||||
r.WriteString("multierr: ")
|
r.WriteString("multierr: ")
|
||||||
@@ -16,6 +18,7 @@ func (e multiError) Error() string {
|
|||||||
return r.String()
|
return r.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Combine combines multiple errors into a single error, filtering out nil errors.
|
||||||
func Combine(maybeError ...error) error {
|
func Combine(maybeError ...error) error {
|
||||||
var errs multiError
|
var errs multiError
|
||||||
for _, err := range maybeError {
|
for _, err := range maybeError {
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
|
// Package crypto provides cryptographic utilities for password hashing and verification.
|
||||||
package crypto
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// HashPasswordAsBcrypt generates a bcrypt hash of the given password.
|
||||||
func HashPasswordAsBcrypt(password string) (string, error) {
|
func HashPasswordAsBcrypt(password string) (string, error) {
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
return string(hash), err
|
return string(hash), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckPasswordHash verifies if the given password matches the bcrypt hash.
|
||||||
func CheckPasswordHash(hash, password string) bool {
|
func CheckPasswordHash(hash, password string) bool {
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
return err == nil
|
return err == nil
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
// Package json_util provides JSON utilities including a custom RawMessage type.
|
||||||
package json_util
|
package json_util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RawMessage is a custom JSON raw message type that marshals empty slices as "null".
|
||||||
type RawMessage []byte
|
type RawMessage []byte
|
||||||
|
|
||||||
// MarshalJSON: Customize json.RawMessage default behavior
|
// MarshalJSON customizes the JSON marshaling behavior for RawMessage.
|
||||||
|
// Empty RawMessage values are marshaled as "null" instead of "[]".
|
||||||
func (m RawMessage) MarshalJSON() ([]byte, error) {
|
func (m RawMessage) MarshalJSON() ([]byte, error) {
|
||||||
if len(m) == 0 {
|
if len(m) == 0 {
|
||||||
return []byte("null"), nil
|
return []byte("null"), nil
|
||||||
@@ -14,7 +17,7 @@ func (m RawMessage) MarshalJSON() ([]byte, error) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalJSON: sets *m to a copy of data.
|
// UnmarshalJSON sets *m to a copy of the JSON data.
|
||||||
func (m *RawMessage) UnmarshalJSON(data []byte) error {
|
func (m *RawMessage) UnmarshalJSON(data []byte) error {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return errors.New("json.RawMessage: UnmarshalJSON on nil pointer")
|
return errors.New("json.RawMessage: UnmarshalJSON on nil pointer")
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
// Package random provides utilities for generating random strings and numbers.
|
||||||
package random
|
package random
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math/rand"
|
"crypto/rand"
|
||||||
|
"math/big"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -13,6 +15,8 @@ var (
|
|||||||
allSeq [62]rune
|
allSeq [62]rune
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// init initializes the character sequences used for random string generation.
|
||||||
|
// It sets up arrays for numbers, lowercase letters, uppercase letters, and combinations.
|
||||||
func init() {
|
func init() {
|
||||||
for i := 0; i < 10; i++ {
|
for i := 0; i < 10; i++ {
|
||||||
numSeq[i] = rune('0' + i)
|
numSeq[i] = rune('0' + i)
|
||||||
@@ -33,14 +37,25 @@ func init() {
|
|||||||
copy(allSeq[len(numSeq)+len(lowerSeq):], upperSeq[:])
|
copy(allSeq[len(numSeq)+len(lowerSeq):], upperSeq[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seq generates a random string of length n containing alphanumeric characters (numbers, lowercase and uppercase letters).
|
||||||
func Seq(n int) string {
|
func Seq(n int) string {
|
||||||
runes := make([]rune, n)
|
runes := make([]rune, n)
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
runes[i] = allSeq[rand.Intn(len(allSeq))]
|
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(allSeq))))
|
||||||
|
if err != nil {
|
||||||
|
panic("crypto/rand failed: " + err.Error())
|
||||||
|
}
|
||||||
|
runes[i] = allSeq[idx.Int64()]
|
||||||
}
|
}
|
||||||
return string(runes)
|
return string(runes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Num generates a random integer between 0 and n-1.
|
||||||
func Num(n int) int {
|
func Num(n int) int {
|
||||||
return rand.Intn(n)
|
bn := big.NewInt(int64(n))
|
||||||
|
r, err := rand.Int(rand.Reader, bn)
|
||||||
|
if err != nil {
|
||||||
|
panic("crypto/rand failed: " + err.Error())
|
||||||
|
}
|
||||||
|
return int(r.Int64())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
// Package reflect_util provides reflection utilities for working with struct fields and values.
|
||||||
package reflect_util
|
package reflect_util
|
||||||
|
|
||||||
import "reflect"
|
import "reflect"
|
||||||
|
|
||||||
|
// GetFields returns all struct fields of the given reflect.Type.
|
||||||
func GetFields(t reflect.Type) []reflect.StructField {
|
func GetFields(t reflect.Type) []reflect.StructField {
|
||||||
num := t.NumField()
|
num := t.NumField()
|
||||||
fields := make([]reflect.StructField, 0, num)
|
fields := make([]reflect.StructField, 0, num)
|
||||||
@@ -11,6 +13,7 @@ func GetFields(t reflect.Type) []reflect.StructField {
|
|||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetFieldValues returns all field values of the given reflect.Value.
|
||||||
func GetFieldValues(v reflect.Value) []reflect.Value {
|
func GetFieldValues(v reflect.Value) []reflect.Value {
|
||||||
num := v.NumField()
|
num := v.NumField()
|
||||||
fields := make([]reflect.Value, 0, num)
|
fields := make([]reflect.Value, 0, num)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Package sys provides system utilities for monitoring network connections and CPU usage.
|
||||||
|
// Platform-specific implementations are provided for Windows, Linux, and macOS.
|
||||||
package sys
|
package sys
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -41,6 +45,8 @@ func getLinesNum(filename string) (int, error) {
|
|||||||
return sum, nil
|
return sum, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTCPCount returns the number of active TCP connections by reading
|
||||||
|
// /proc/net/tcp and /proc/net/tcp6 when available.
|
||||||
func GetTCPCount() (int, error) {
|
func GetTCPCount() (int, error) {
|
||||||
root := HostProc()
|
root := HostProc()
|
||||||
|
|
||||||
@@ -71,6 +77,8 @@ func GetUDPCount() (int, error) {
|
|||||||
return udp4 + udp6, nil
|
return udp4 + udp6, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// safeGetLinesNum returns 0 if the file does not exist, otherwise forwards
|
||||||
|
// to getLinesNum to count the number of lines.
|
||||||
func safeGetLinesNum(path string) (int, error) {
|
func safeGetLinesNum(path string) (int, error) {
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
@@ -79,3 +87,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ package sys
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/net"
|
"github.com/shirou/gopsutil/v4/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GetConnectionCount returns the number of active connections for the specified protocol ("tcp" or "udp").
|
||||||
func GetConnectionCount(proto string) (int, error) {
|
func GetConnectionCount(proto string) (int, error) {
|
||||||
if proto != "tcp" && proto != "udp" {
|
if proto != "tcp" && proto != "udp" {
|
||||||
return 0, errors.New("invalid protocol")
|
return 0, errors.New("invalid protocol")
|
||||||
@@ -21,10 +25,92 @@ func GetConnectionCount(proto string) (int, error) {
|
|||||||
return len(stats), nil
|
return len(stats), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTCPCount returns the number of active TCP connections.
|
||||||
func GetTCPCount() (int, error) {
|
func GetTCPCount() (int, error) {
|
||||||
return GetConnectionCount("tcp")
|
return GetConnectionCount("tcp")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUDPCount returns the number of active UDP connections.
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ftToUint64 converts a Windows FILETIME-like struct to a uint64 for
|
||||||
|
// arithmetic and delta calculations used by CPUPercentRaw.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
2
web/assets/css/custom.min.css
vendored
2
web/assets/css/custom.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -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;
|
||||||
|
|||||||
@@ -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,15 @@ class Outbound extends CommonClass {
|
|||||||
if (this.stream?.sockopt)
|
if (this.stream?.sockopt)
|
||||||
stream = { sockopt: this.stream.sockopt.toJson() };
|
stream = { sockopt: this.stream.sockopt.toJson() };
|
||||||
}
|
}
|
||||||
|
let settingsOut = this.settings instanceof CommonClass ? this.settings.toJson() : this.settings;
|
||||||
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 +906,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,13 +1024,16 @@ Outbound.VmessSettings = class extends CommonClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
if (ObjectUtil.isArrEmpty(json.vnext)) return new Outbound.VmessSettings();
|
if (!ObjectUtil.isArrEmpty(json.vnext)) {
|
||||||
return new Outbound.VmessSettings(
|
const v = json.vnext[0] || {};
|
||||||
json.vnext[0].address,
|
const u = ObjectUtil.isArrEmpty(v.users) ? {} : v.users[0];
|
||||||
json.vnext[0].port,
|
return new Outbound.VmessSettings(
|
||||||
json.vnext[0].users[0].id,
|
v.address,
|
||||||
json.vnext[0].users[0].security,
|
v.port,
|
||||||
);
|
u.id,
|
||||||
|
u.security,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toJson() {
|
toJson() {
|
||||||
@@ -1040,8 +1041,11 @@ Outbound.VmessSettings = class extends CommonClass {
|
|||||||
vnext: [{
|
vnext: [{
|
||||||
address: this.address,
|
address: this.address,
|
||||||
port: this.port,
|
port: this.port,
|
||||||
users: [{ id: this.id, security: this.security }],
|
users: [{
|
||||||
}],
|
id: this.id,
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class AllSetting {
|
|||||||
this.webKeyFile = "";
|
this.webKeyFile = "";
|
||||||
this.webBasePath = "/";
|
this.webBasePath = "/";
|
||||||
this.sessionMaxAge = 360;
|
this.sessionMaxAge = 360;
|
||||||
this.pageSize = 50;
|
this.pageSize = 25;
|
||||||
this.expireDiff = 0;
|
this.expireDiff = 0;
|
||||||
this.trafficDiff = 0;
|
this.trafficDiff = 0;
|
||||||
this.remarkModel = "-ieo";
|
this.remarkModel = "-ieo";
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -50,7 +50,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawQR(value) {
|
function drawQR(value) {
|
||||||
try { new QRious({ element: document.getElementById('qrcode'), value, size: 220 }); } catch (e) { console.warn(e); }
|
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
|
// Try to extract a human label (email/ps) from different link types
|
||||||
@@ -61,22 +65,18 @@
|
|||||||
if (json.ps) return json.ps;
|
if (json.ps) return json.ps;
|
||||||
if (json.add && json.id) return json.add; // fallback host
|
if (json.add && json.id) return json.add; // fallback host
|
||||||
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
|
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
|
||||||
// vless://<id>@host:port?...#name
|
|
||||||
const hashIdx = link.indexOf('#');
|
const hashIdx = link.indexOf('#');
|
||||||
if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
|
if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
|
||||||
// email sometimes in query params like sni or remark
|
|
||||||
const qIdx = link.indexOf('?');
|
const qIdx = link.indexOf('?');
|
||||||
if (qIdx !== -1) {
|
if (qIdx !== -1) {
|
||||||
const qs = new URL('http://x/?' + link.substring(qIdx + 1, hashIdx !== -1 ? hashIdx : undefined)).searchParams;
|
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('remark')) return qs.get('remark');
|
||||||
if (qs.get('email')) return qs.get('email');
|
if (qs.get('email')) return qs.get('email');
|
||||||
}
|
}
|
||||||
// else take user@host
|
|
||||||
const at = link.indexOf('@');
|
const at = link.indexOf('@');
|
||||||
const protSep = link.indexOf('://');
|
const protSep = link.indexOf('://');
|
||||||
if (at !== -1 && protSep !== -1) return link.substring(protSep + 3, at);
|
if (at !== -1 && protSep !== -1) return link.substring(protSep + 3, at);
|
||||||
} else if (link.startsWith('ss://')) {
|
} else if (link.startsWith('ss://')) {
|
||||||
// shadowsocks: label often after #
|
|
||||||
const hashIdx = link.indexOf('#');
|
const hashIdx = link.indexOf('#');
|
||||||
if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
|
if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
|
||||||
}
|
}
|
||||||
@@ -96,14 +96,16 @@
|
|||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
this.lang = LanguageManager.getLanguage();
|
this.lang = LanguageManager.getLanguage();
|
||||||
// Discover subJsonUrl if provided via template bootstrap
|
|
||||||
const tpl = document.getElementById('subscription-data');
|
const tpl = document.getElementById('subscription-data');
|
||||||
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
|
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
|
||||||
if (sj) this.app.subJsonUrl = sj;
|
if (sj) this.app.subJsonUrl = sj;
|
||||||
drawQR(this.app.subUrl);
|
drawQR(this.app.subUrl);
|
||||||
// Draw second QR if available
|
try {
|
||||||
try { new QRious({ element: document.getElementById('qrcode-subjson'), value: this.app.subJsonUrl || '', size: 220 }); } catch (e) { /* ignore */ }
|
const elJson = document.getElementById('qrcode-subjson');
|
||||||
// Track viewport width for responsive behavior
|
if (elJson && this.app.subJsonUrl) {
|
||||||
|
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
this._onResize = () => { this.viewportWidth = window.innerWidth; };
|
this._onResize = () => { this.viewportWidth = window.innerWidth; };
|
||||||
window.addEventListener('resize', this._onResize);
|
window.addEventListener('resize', this._onResize);
|
||||||
},
|
},
|
||||||
@@ -111,15 +113,48 @@
|
|||||||
if (this._onResize) window.removeEventListener('resize', this._onResize);
|
if (this._onResize) window.removeEventListener('resize', this._onResize);
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isMobile() { return this.viewportWidth < 576; },
|
isMobile() {
|
||||||
isUnlimited() { return !this.app.totalByte; },
|
return this.viewportWidth < 576;
|
||||||
|
},
|
||||||
|
isUnlimited() {
|
||||||
|
return !this.app.totalByte;
|
||||||
|
},
|
||||||
isActive() {
|
isActive() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const expiryOk = !this.app.expireMs || this.app.expireMs >= now;
|
const expiryOk = !this.app.expireMs || this.app.expireMs >= now;
|
||||||
const trafficOk = !this.app.totalByte || (this.app.uploadByte + this.app.downloadByte) <= this.app.totalByte;
|
const trafficOk = !this.app.totalByte || (this.app.uploadByte + this.app.downloadByte) <= this.app.totalByte;
|
||||||
return expiryOk && trafficOk;
|
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;
|
||||||
|
},
|
||||||
|
happUrl() {
|
||||||
|
return `happ://add/${encodeURIComponent(this.app.subUrl)}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
renderLink,
|
||||||
|
copy,
|
||||||
|
open,
|
||||||
|
linkName,
|
||||||
|
i18nLabel(key) {
|
||||||
|
return '{{ i18n "' + key + '" }}';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: { renderLink, copy, open, linkName, i18nLabel(key) { return '{{ i18n "' + key + '" }}'; } },
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"x-ui/web/service"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// APIController handles the main API routes for the 3x-ui panel, including inbounds and server management.
|
||||||
type APIController struct {
|
type APIController struct {
|
||||||
BaseController
|
BaseController
|
||||||
inboundController *InboundController
|
inboundController *InboundController
|
||||||
@@ -13,16 +17,28 @@ type APIController struct {
|
|||||||
Tgbot service.Tgbot
|
Tgbot service.Tgbot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewAPIController creates a new APIController instance and initializes its routes.
|
||||||
func NewAPIController(g *gin.RouterGroup) *APIController {
|
func NewAPIController(g *gin.RouterGroup) *APIController {
|
||||||
a := &APIController{}
|
a := &APIController{}
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkAPIAuth is a middleware that returns 404 for unauthenticated API requests
|
||||||
|
// to hide the existence of API endpoints from unauthorized users
|
||||||
|
func (a *APIController) checkAPIAuth(c *gin.Context) {
|
||||||
|
if !session.IsLogin(c) {
|
||||||
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// initRouter sets up the API routes for inbounds, server, and other endpoints.
|
||||||
func (a *APIController) initRouter(g *gin.RouterGroup) {
|
func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||||
// Main API group
|
// Main API group
|
||||||
api := g.Group("/panel/api")
|
api := g.Group("/panel/api")
|
||||||
api.Use(a.checkLogin)
|
api.Use(a.checkAPIAuth)
|
||||||
|
|
||||||
// Inbounds API
|
// Inbounds API
|
||||||
inbounds := api.Group("/inbounds")
|
inbounds := api.Group("/inbounds")
|
||||||
@@ -36,6 +52,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
|||||||
api.GET("/backuptotgbot", a.BackuptoTgbot)
|
api.GET("/backuptotgbot", a.BackuptoTgbot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BackuptoTgbot sends a backup of the panel data to Telegram bot admins.
|
||||||
func (a *APIController) BackuptoTgbot(c *gin.Context) {
|
func (a *APIController) BackuptoTgbot(c *gin.Context) {
|
||||||
a.Tgbot.SendBackupToAdmins()
|
a.Tgbot.SendBackupToAdmins()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
|
// Package controller provides HTTP request handlers and controllers for the 3x-ui web management panel.
|
||||||
|
// It handles routing, authentication, and API endpoints for managing Xray inbounds, settings, and more.
|
||||||
package controller
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BaseController provides common functionality for all controllers, including authentication checks.
|
||||||
type BaseController struct{}
|
type BaseController struct{}
|
||||||
|
|
||||||
|
// checkLogin is a middleware that verifies user authentication and handles unauthorized access.
|
||||||
func (a *BaseController) checkLogin(c *gin.Context) {
|
func (a *BaseController) checkLogin(c *gin.Context) {
|
||||||
if !session.IsLogin(c) {
|
if !session.IsLogin(c) {
|
||||||
if isAjax(c) {
|
if isAjax(c) {
|
||||||
@@ -25,6 +29,7 @@ func (a *BaseController) checkLogin(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// I18nWeb retrieves an internationalized message for the web interface based on the current locale.
|
||||||
func I18nWeb(c *gin.Context, name string, params ...string) string {
|
func I18nWeb(c *gin.Context, name string, params ...string) string {
|
||||||
anyfunc, funcExists := c.Get("I18n")
|
anyfunc, funcExists := c.Get("I18n")
|
||||||
if !funcExists {
|
if !funcExists {
|
||||||
|
|||||||
@@ -5,24 +5,27 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"x-ui/database/model"
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// InboundController handles HTTP requests related to Xray inbounds management.
|
||||||
type InboundController struct {
|
type InboundController struct {
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
xrayService service.XrayService
|
xrayService service.XrayService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewInboundController creates a new InboundController and sets up its routes.
|
||||||
func NewInboundController(g *gin.RouterGroup) *InboundController {
|
func NewInboundController(g *gin.RouterGroup) *InboundController {
|
||||||
a := &InboundController{}
|
a := &InboundController{}
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initRouter initializes the routes for inbound-related operations.
|
||||||
func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||||
|
|
||||||
g.GET("/list", a.getInbounds)
|
g.GET("/list", a.getInbounds)
|
||||||
@@ -49,6 +52,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
|
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getInbounds retrieves the list of inbounds for the logged-in user.
|
||||||
func (a *InboundController) getInbounds(c *gin.Context) {
|
func (a *InboundController) getInbounds(c *gin.Context) {
|
||||||
user := session.GetLoginUser(c)
|
user := session.GetLoginUser(c)
|
||||||
inbounds, err := a.inboundService.GetInbounds(user.Id)
|
inbounds, err := a.inboundService.GetInbounds(user.Id)
|
||||||
@@ -59,6 +63,7 @@ func (a *InboundController) getInbounds(c *gin.Context) {
|
|||||||
jsonObj(c, inbounds, nil)
|
jsonObj(c, inbounds, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getInbound retrieves a specific inbound by its ID.
|
||||||
func (a *InboundController) getInbound(c *gin.Context) {
|
func (a *InboundController) getInbound(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -73,6 +78,7 @@ func (a *InboundController) getInbound(c *gin.Context) {
|
|||||||
jsonObj(c, inbound, nil)
|
jsonObj(c, inbound, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getClientTraffics retrieves client traffic information by email.
|
||||||
func (a *InboundController) getClientTraffics(c *gin.Context) {
|
func (a *InboundController) getClientTraffics(c *gin.Context) {
|
||||||
email := c.Param("email")
|
email := c.Param("email")
|
||||||
clientTraffics, err := a.inboundService.GetClientTrafficByEmail(email)
|
clientTraffics, err := a.inboundService.GetClientTrafficByEmail(email)
|
||||||
@@ -83,6 +89,7 @@ func (a *InboundController) getClientTraffics(c *gin.Context) {
|
|||||||
jsonObj(c, clientTraffics, nil)
|
jsonObj(c, clientTraffics, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getClientTrafficsById retrieves client traffic information by inbound ID.
|
||||||
func (a *InboundController) getClientTrafficsById(c *gin.Context) {
|
func (a *InboundController) getClientTrafficsById(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
clientTraffics, err := a.inboundService.GetClientTrafficByID(id)
|
clientTraffics, err := a.inboundService.GetClientTrafficByID(id)
|
||||||
@@ -93,6 +100,7 @@ func (a *InboundController) getClientTrafficsById(c *gin.Context) {
|
|||||||
jsonObj(c, clientTraffics, nil)
|
jsonObj(c, clientTraffics, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addInbound creates a new inbound configuration.
|
||||||
func (a *InboundController) addInbound(c *gin.Context) {
|
func (a *InboundController) addInbound(c *gin.Context) {
|
||||||
inbound := &model.Inbound{}
|
inbound := &model.Inbound{}
|
||||||
err := c.ShouldBind(inbound)
|
err := c.ShouldBind(inbound)
|
||||||
@@ -108,8 +116,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
|
||||||
@@ -120,14 +127,14 @@ func (a *InboundController) addInbound(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delInbound deletes an inbound configuration by its ID.
|
||||||
func (a *InboundController) delInbound(c *gin.Context) {
|
func (a *InboundController) delInbound(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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
|
||||||
@@ -138,6 +145,7 @@ func (a *InboundController) delInbound(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateInbound updates an existing inbound configuration.
|
||||||
func (a *InboundController) updateInbound(c *gin.Context) {
|
func (a *InboundController) updateInbound(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -152,8 +160,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
|
||||||
@@ -164,6 +171,7 @@ func (a *InboundController) updateInbound(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getClientIps retrieves the IP addresses associated with a client by email.
|
||||||
func (a *InboundController) getClientIps(c *gin.Context) {
|
func (a *InboundController) getClientIps(c *gin.Context) {
|
||||||
email := c.Param("email")
|
email := c.Param("email")
|
||||||
|
|
||||||
@@ -176,6 +184,7 @@ func (a *InboundController) getClientIps(c *gin.Context) {
|
|||||||
jsonObj(c, ips, nil)
|
jsonObj(c, ips, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clearClientIps clears the IP addresses for a client by email.
|
||||||
func (a *InboundController) clearClientIps(c *gin.Context) {
|
func (a *InboundController) clearClientIps(c *gin.Context) {
|
||||||
email := c.Param("email")
|
email := c.Param("email")
|
||||||
|
|
||||||
@@ -187,6 +196,7 @@ func (a *InboundController) clearClientIps(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addInboundClient adds a new client to an existing inbound.
|
||||||
func (a *InboundController) addInboundClient(c *gin.Context) {
|
func (a *InboundController) addInboundClient(c *gin.Context) {
|
||||||
data := &model.Inbound{}
|
data := &model.Inbound{}
|
||||||
err := c.ShouldBind(data)
|
err := c.ShouldBind(data)
|
||||||
@@ -195,9 +205,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
|
||||||
@@ -208,6 +216,7 @@ func (a *InboundController) addInboundClient(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delInboundClient deletes a client from an inbound by inbound ID and client ID.
|
||||||
func (a *InboundController) delInboundClient(c *gin.Context) {
|
func (a *InboundController) delInboundClient(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -216,9 +225,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
|
||||||
@@ -229,6 +236,7 @@ func (a *InboundController) delInboundClient(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateInboundClient updates a client's configuration in an inbound.
|
||||||
func (a *InboundController) updateInboundClient(c *gin.Context) {
|
func (a *InboundController) updateInboundClient(c *gin.Context) {
|
||||||
clientId := c.Param("clientId")
|
clientId := c.Param("clientId")
|
||||||
|
|
||||||
@@ -239,9 +247,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
|
||||||
@@ -252,6 +258,7 @@ func (a *InboundController) updateInboundClient(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetClientTraffic resets the traffic counter for a specific client in an inbound.
|
||||||
func (a *InboundController) resetClientTraffic(c *gin.Context) {
|
func (a *InboundController) resetClientTraffic(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -271,6 +278,7 @@ func (a *InboundController) resetClientTraffic(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetAllTraffics resets all traffic counters across all inbounds.
|
||||||
func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
||||||
err := a.inboundService.ResetAllTraffics()
|
err := a.inboundService.ResetAllTraffics()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -282,6 +290,7 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetAllClientTraffics resets traffic counters for all clients in a specific inbound.
|
||||||
func (a *InboundController) resetAllClientTraffics(c *gin.Context) {
|
func (a *InboundController) resetAllClientTraffics(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -299,6 +308,7 @@ func (a *InboundController) resetAllClientTraffics(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// importInbound imports an inbound configuration from provided data.
|
||||||
func (a *InboundController) importInbound(c *gin.Context) {
|
func (a *InboundController) importInbound(c *gin.Context) {
|
||||||
inbound := &model.Inbound{}
|
inbound := &model.Inbound{}
|
||||||
err := json.Unmarshal([]byte(c.PostForm("data")), inbound)
|
err := json.Unmarshal([]byte(c.PostForm("data")), inbound)
|
||||||
@@ -328,6 +338,7 @@ func (a *InboundController) importInbound(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delDepletedClients deletes clients in an inbound who have exhausted their traffic limits.
|
||||||
func (a *InboundController) delDepletedClients(c *gin.Context) {
|
func (a *InboundController) delDepletedClients(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -342,15 +353,18 @@ func (a *InboundController) delDepletedClients(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.delDepletedClientsSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.delDepletedClientsSuccess"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onlines retrieves the list of currently online clients.
|
||||||
func (a *InboundController) onlines(c *gin.Context) {
|
func (a *InboundController) onlines(c *gin.Context) {
|
||||||
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
|
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lastOnline retrieves the last online timestamps for clients.
|
||||||
func (a *InboundController) lastOnline(c *gin.Context) {
|
func (a *InboundController) lastOnline(c *gin.Context) {
|
||||||
data, err := a.inboundService.GetClientsLastOnline()
|
data, err := a.inboundService.GetClientsLastOnline()
|
||||||
jsonObj(c, data, err)
|
jsonObj(c, data, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateClientTraffic updates the traffic statistics for a client by email.
|
||||||
func (a *InboundController) updateClientTraffic(c *gin.Context) {
|
func (a *InboundController) updateClientTraffic(c *gin.Context) {
|
||||||
email := c.Param("email")
|
email := c.Param("email")
|
||||||
|
|
||||||
@@ -376,6 +390,7 @@ func (a *InboundController) updateClientTraffic(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delInboundClientByEmail deletes a client from an inbound by email address.
|
||||||
func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
|
func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
|
||||||
inboundId, err := strconv.Atoi(c.Param("id"))
|
inboundId, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,20 +5,22 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LoginForm represents the login request structure.
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IndexController handles the main index and login-related routes.
|
||||||
type IndexController struct {
|
type IndexController struct {
|
||||||
BaseController
|
BaseController
|
||||||
|
|
||||||
@@ -27,19 +29,23 @@ type IndexController struct {
|
|||||||
tgbot service.Tgbot
|
tgbot service.Tgbot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewIndexController creates a new IndexController and initializes its routes.
|
||||||
func NewIndexController(g *gin.RouterGroup) *IndexController {
|
func NewIndexController(g *gin.RouterGroup) *IndexController {
|
||||||
a := &IndexController{}
|
a := &IndexController{}
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initRouter sets up the routes for index, login, logout, and two-factor authentication.
|
||||||
func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
||||||
g.GET("/", a.index)
|
g.GET("/", a.index)
|
||||||
g.POST("/login", a.login)
|
|
||||||
g.GET("/logout", a.logout)
|
g.GET("/logout", a.logout)
|
||||||
|
|
||||||
|
g.POST("/login", a.login)
|
||||||
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
|
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
|
||||||
func (a *IndexController) index(c *gin.Context) {
|
func (a *IndexController) index(c *gin.Context) {
|
||||||
if session.IsLogin(c) {
|
if session.IsLogin(c) {
|
||||||
c.Redirect(http.StatusTemporaryRedirect, "panel/")
|
c.Redirect(http.StatusTemporaryRedirect, "panel/")
|
||||||
@@ -48,6 +54,7 @@ func (a *IndexController) index(c *gin.Context) {
|
|||||||
html(c, "login.html", "pages.login.title", nil)
|
html(c, "login.html", "pages.login.title", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// login handles user authentication and session creation.
|
||||||
func (a *IndexController) login(c *gin.Context) {
|
func (a *IndexController) login(c *gin.Context) {
|
||||||
var form LoginForm
|
var form LoginForm
|
||||||
|
|
||||||
@@ -95,6 +102,7 @@ func (a *IndexController) login(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// logout handles user logout by clearing the session and redirecting to the login page.
|
||||||
func (a *IndexController) logout(c *gin.Context) {
|
func (a *IndexController) logout(c *gin.Context) {
|
||||||
user := session.GetLoginUser(c)
|
user := session.GetLoginUser(c)
|
||||||
if user != nil {
|
if user != nil {
|
||||||
@@ -107,6 +115,7 @@ func (a *IndexController) logout(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getTwoFactorEnable retrieves the current status of two-factor authentication.
|
||||||
func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
|
func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
|
||||||
status, err := a.settingService.GetTwoFactorEnable()
|
status, err := a.settingService.GetTwoFactorEnable()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -4,41 +4,43 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
|
var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
|
||||||
|
|
||||||
|
// ServerController handles server management and status-related operations.
|
||||||
type ServerController struct {
|
type ServerController struct {
|
||||||
BaseController
|
BaseController
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewServerController creates a new ServerController, initializes routes, and starts background tasks.
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initRouter sets up the routes for server status, Xray management, and utility endpoints.
|
||||||
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)
|
||||||
@@ -59,31 +61,57 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/getNewEchCert", a.getNewEchCert)
|
g.POST("/getNewEchCert", a.getNewEchCert)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshStatus updates the cached server status and collects CPU history.
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startTask initiates background tasks for continuous status monitoring.
|
||||||
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) {
|
// status returns the current server status information.
|
||||||
a.lastGetStatusTime = time.Now()
|
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) }
|
||||||
|
|
||||||
jsonObj(c, a.lastStatus, nil)
|
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getXrayVersion retrieves available Xray versions, with caching for 1 minute.
|
||||||
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,25 +123,35 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// installXray installs or updates Xray to the specified version.
|
||||||
func (a *ServerController) installXray(c *gin.Context) {
|
func (a *ServerController) installXray(c *gin.Context) {
|
||||||
version := c.Param("version")
|
version := c.Param("version")
|
||||||
err := a.serverService.UpdateXray(version)
|
err := a.serverService.UpdateXray(version)
|
||||||
jsonMsg(c, I18nWeb(c, "pages.index.xraySwitchVersionPopover"), err)
|
jsonMsg(c, I18nWeb(c, "pages.index.xraySwitchVersionPopover"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateGeofile updates the specified geo file for Xray.
|
||||||
func (a *ServerController) updateGeofile(c *gin.Context) {
|
func (a *ServerController) updateGeofile(c *gin.Context) {
|
||||||
fileName := c.Param("fileName")
|
fileName := c.Param("fileName")
|
||||||
|
|
||||||
|
// Validate the filename for security (prevent path traversal attacks)
|
||||||
|
if fileName != "" && !a.serverService.IsValidGeofileName(fileName) {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"),
|
||||||
|
fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
err := a.serverService.UpdateGeofile(fileName)
|
err := a.serverService.UpdateGeofile(fileName)
|
||||||
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), err)
|
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stopXrayService stops the Xray service.
|
||||||
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)
|
||||||
@@ -122,6 +160,7 @@ func (a *ServerController) stopXrayService(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "pages.xray.stopSuccess"), err)
|
jsonMsg(c, I18nWeb(c, "pages.xray.stopSuccess"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// restartXrayService restarts the Xray service.
|
||||||
func (a *ServerController) restartXrayService(c *gin.Context) {
|
func (a *ServerController) restartXrayService(c *gin.Context) {
|
||||||
err := a.serverService.RestartXrayService()
|
err := a.serverService.RestartXrayService()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -131,6 +170,7 @@ func (a *ServerController) restartXrayService(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "pages.xray.restartSuccess"), err)
|
jsonMsg(c, I18nWeb(c, "pages.xray.restartSuccess"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getLogs retrieves the application logs based on count, level, and syslog filters.
|
||||||
func (a *ServerController) getLogs(c *gin.Context) {
|
func (a *ServerController) getLogs(c *gin.Context) {
|
||||||
count := c.Param("count")
|
count := c.Param("count")
|
||||||
level := c.PostForm("level")
|
level := c.PostForm("level")
|
||||||
@@ -139,6 +179,7 @@ func (a *ServerController) getLogs(c *gin.Context) {
|
|||||||
jsonObj(c, logs, nil)
|
jsonObj(c, logs, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic.
|
||||||
func (a *ServerController) getXrayLogs(c *gin.Context) {
|
func (a *ServerController) getXrayLogs(c *gin.Context) {
|
||||||
count := c.Param("count")
|
count := c.Param("count")
|
||||||
filter := c.PostForm("filter")
|
filter := c.PostForm("filter")
|
||||||
@@ -183,6 +224,7 @@ func (a *ServerController) getXrayLogs(c *gin.Context) {
|
|||||||
jsonObj(c, logs, nil)
|
jsonObj(c, logs, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getConfigJson retrieves the Xray configuration as JSON.
|
||||||
func (a *ServerController) getConfigJson(c *gin.Context) {
|
func (a *ServerController) getConfigJson(c *gin.Context) {
|
||||||
configJson, err := a.serverService.GetConfigJson()
|
configJson, err := a.serverService.GetConfigJson()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -192,6 +234,7 @@ func (a *ServerController) getConfigJson(c *gin.Context) {
|
|||||||
jsonObj(c, configJson, nil)
|
jsonObj(c, configJson, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getDb downloads the database file.
|
||||||
func (a *ServerController) getDb(c *gin.Context) {
|
func (a *ServerController) getDb(c *gin.Context) {
|
||||||
db, err := a.serverService.GetDb()
|
db, err := a.serverService.GetDb()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -219,6 +262,7 @@ func isValidFilename(filename string) bool {
|
|||||||
return filenameRegex.MatchString(filename)
|
return filenameRegex.MatchString(filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// importDB imports a database file and restarts the Xray service.
|
||||||
func (a *ServerController) importDB(c *gin.Context) {
|
func (a *ServerController) importDB(c *gin.Context) {
|
||||||
// Get the file from the request body
|
// Get the file from the request body
|
||||||
file, _, err := c.Request.FormFile("db")
|
file, _, err := c.Request.FormFile("db")
|
||||||
@@ -229,9 +273,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 {
|
||||||
@@ -241,6 +283,7 @@ func (a *ServerController) importDB(c *gin.Context) {
|
|||||||
jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
|
jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getNewX25519Cert generates a new X25519 certificate.
|
||||||
func (a *ServerController) getNewX25519Cert(c *gin.Context) {
|
func (a *ServerController) getNewX25519Cert(c *gin.Context) {
|
||||||
cert, err := a.serverService.GetNewX25519Cert()
|
cert, err := a.serverService.GetNewX25519Cert()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -250,6 +293,7 @@ func (a *ServerController) getNewX25519Cert(c *gin.Context) {
|
|||||||
jsonObj(c, cert, nil)
|
jsonObj(c, cert, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getNewmldsa65 generates a new ML-DSA-65 key.
|
||||||
func (a *ServerController) getNewmldsa65(c *gin.Context) {
|
func (a *ServerController) getNewmldsa65(c *gin.Context) {
|
||||||
cert, err := a.serverService.GetNewmldsa65()
|
cert, err := a.serverService.GetNewmldsa65()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -259,6 +303,7 @@ func (a *ServerController) getNewmldsa65(c *gin.Context) {
|
|||||||
jsonObj(c, cert, nil)
|
jsonObj(c, cert, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getNewEchCert generates a new ECH certificate for the given SNI.
|
||||||
func (a *ServerController) getNewEchCert(c *gin.Context) {
|
func (a *ServerController) getNewEchCert(c *gin.Context) {
|
||||||
sni := c.PostForm("sni")
|
sni := c.PostForm("sni")
|
||||||
cert, err := a.serverService.GetNewEchCert(sni)
|
cert, err := a.serverService.GetNewEchCert(sni)
|
||||||
@@ -269,6 +314,7 @@ func (a *ServerController) getNewEchCert(c *gin.Context) {
|
|||||||
jsonObj(c, cert, nil)
|
jsonObj(c, cert, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getNewVlessEnc generates a new VLESS encryption key.
|
||||||
func (a *ServerController) getNewVlessEnc(c *gin.Context) {
|
func (a *ServerController) getNewVlessEnc(c *gin.Context) {
|
||||||
out, err := a.serverService.GetNewVlessEnc()
|
out, err := a.serverService.GetNewVlessEnc()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -278,6 +324,7 @@ func (a *ServerController) getNewVlessEnc(c *gin.Context) {
|
|||||||
jsonObj(c, out, nil)
|
jsonObj(c, out, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getNewUUID generates a new UUID.
|
||||||
func (a *ServerController) getNewUUID(c *gin.Context) {
|
func (a *ServerController) getNewUUID(c *gin.Context) {
|
||||||
uuidResp, err := a.serverService.GetNewUUID()
|
uuidResp, err := a.serverService.GetNewUUID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -288,6 +335,7 @@ func (a *ServerController) getNewUUID(c *gin.Context) {
|
|||||||
jsonObj(c, uuidResp, nil)
|
jsonObj(c, uuidResp, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getNewmlkem768 generates a new ML-KEM-768 key.
|
||||||
func (a *ServerController) getNewmlkem768(c *gin.Context) {
|
func (a *ServerController) getNewmlkem768(c *gin.Context) {
|
||||||
out, err := a.serverService.GetNewmlkem768()
|
out, err := a.serverService.GetNewmlkem768()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// updateUserForm represents the form for updating user credentials.
|
||||||
type updateUserForm struct {
|
type updateUserForm struct {
|
||||||
OldUsername string `json:"oldUsername" form:"oldUsername"`
|
OldUsername string `json:"oldUsername" form:"oldUsername"`
|
||||||
OldPassword string `json:"oldPassword" form:"oldPassword"`
|
OldPassword string `json:"oldPassword" form:"oldPassword"`
|
||||||
@@ -19,18 +20,21 @@ type updateUserForm struct {
|
|||||||
NewPassword string `json:"newPassword" form:"newPassword"`
|
NewPassword string `json:"newPassword" form:"newPassword"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SettingController handles settings and user management operations.
|
||||||
type SettingController struct {
|
type SettingController struct {
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
userService service.UserService
|
userService service.UserService
|
||||||
panelService service.PanelService
|
panelService service.PanelService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewSettingController creates a new SettingController and initializes its routes.
|
||||||
func NewSettingController(g *gin.RouterGroup) *SettingController {
|
func NewSettingController(g *gin.RouterGroup) *SettingController {
|
||||||
a := &SettingController{}
|
a := &SettingController{}
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initRouter sets up the routes for settings management.
|
||||||
func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
||||||
g = g.Group("/setting")
|
g = g.Group("/setting")
|
||||||
|
|
||||||
@@ -42,6 +46,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getAllSetting retrieves all current settings.
|
||||||
func (a *SettingController) getAllSetting(c *gin.Context) {
|
func (a *SettingController) getAllSetting(c *gin.Context) {
|
||||||
allSetting, err := a.settingService.GetAllSetting()
|
allSetting, err := a.settingService.GetAllSetting()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,6 +56,7 @@ func (a *SettingController) getAllSetting(c *gin.Context) {
|
|||||||
jsonObj(c, allSetting, nil)
|
jsonObj(c, allSetting, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getDefaultSettings retrieves the default settings based on the host.
|
||||||
func (a *SettingController) getDefaultSettings(c *gin.Context) {
|
func (a *SettingController) getDefaultSettings(c *gin.Context) {
|
||||||
result, err := a.settingService.GetDefaultSettings(c.Request.Host)
|
result, err := a.settingService.GetDefaultSettings(c.Request.Host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -60,6 +66,7 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) {
|
|||||||
jsonObj(c, result, nil)
|
jsonObj(c, result, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateSetting updates all settings with the provided data.
|
||||||
func (a *SettingController) updateSetting(c *gin.Context) {
|
func (a *SettingController) updateSetting(c *gin.Context) {
|
||||||
allSetting := &entity.AllSetting{}
|
allSetting := &entity.AllSetting{}
|
||||||
err := c.ShouldBind(allSetting)
|
err := c.ShouldBind(allSetting)
|
||||||
@@ -71,6 +78,7 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateUser updates the current user's username and password.
|
||||||
func (a *SettingController) updateUser(c *gin.Context) {
|
func (a *SettingController) updateUser(c *gin.Context) {
|
||||||
form := &updateUserForm{}
|
form := &updateUserForm{}
|
||||||
err := c.ShouldBind(form)
|
err := c.ShouldBind(form)
|
||||||
@@ -96,11 +104,13 @@ func (a *SettingController) updateUser(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUser"), err)
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUser"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// restartPanel restarts the panel service after a delay.
|
||||||
func (a *SettingController) restartPanel(c *gin.Context) {
|
func (a *SettingController) restartPanel(c *gin.Context) {
|
||||||
err := a.panelService.RestartPanel(time.Second * 3)
|
err := a.panelService.RestartPanel(time.Second * 3)
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.restartPanelSuccess"), err)
|
jsonMsg(c, I18nWeb(c, "pages.settings.restartPanelSuccess"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getDefaultXrayConfig retrieves the default Xray configuration.
|
||||||
func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
|
func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
|
||||||
defaultJsonConfig, err := a.settingService.GetDefaultXrayConfig()
|
defaultJsonConfig, err := a.settingService.GetDefaultXrayConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// getRemoteIp extracts the real IP address from the request headers or remote address.
|
||||||
func getRemoteIp(c *gin.Context) string {
|
func getRemoteIp(c *gin.Context) string {
|
||||||
value := c.GetHeader("X-Real-IP")
|
value := c.GetHeader("X-Real-IP")
|
||||||
if value != "" {
|
if value != "" {
|
||||||
@@ -27,14 +28,17 @@ func getRemoteIp(c *gin.Context) string {
|
|||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jsonMsg sends a JSON response with a message and error status.
|
||||||
func jsonMsg(c *gin.Context, msg string, err error) {
|
func jsonMsg(c *gin.Context, msg string, err error) {
|
||||||
jsonMsgObj(c, msg, nil, err)
|
jsonMsgObj(c, msg, nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jsonObj sends a JSON response with an object and error status.
|
||||||
func jsonObj(c *gin.Context, obj any, err error) {
|
func jsonObj(c *gin.Context, obj any, err error) {
|
||||||
jsonMsgObj(c, "", obj, err)
|
jsonMsgObj(c, "", obj, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jsonMsgObj sends a JSON response with a message, object, and error status.
|
||||||
func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
|
func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
|
||||||
m := entity.Msg{
|
m := entity.Msg{
|
||||||
Obj: obj,
|
Obj: obj,
|
||||||
@@ -52,6 +56,7 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
|
|||||||
c.JSON(http.StatusOK, m)
|
c.JSON(http.StatusOK, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pureJsonMsg sends a pure JSON message response with custom status code.
|
||||||
func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
|
func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
|
||||||
c.JSON(statusCode, entity.Msg{
|
c.JSON(statusCode, entity.Msg{
|
||||||
Success: success,
|
Success: success,
|
||||||
@@ -59,6 +64,7 @@ func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// html renders an HTML template with the provided data and title.
|
||||||
func html(c *gin.Context, name string, title string, data gin.H) {
|
func html(c *gin.Context, name string, title string, data gin.H) {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
data = gin.H{}
|
data = gin.H{}
|
||||||
@@ -81,6 +87,7 @@ func html(c *gin.Context, name string, title string, data gin.H) {
|
|||||||
c.HTML(http.StatusOK, name, getContext(data))
|
c.HTML(http.StatusOK, name, getContext(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getContext adds version and other context data to the provided gin.H.
|
||||||
func getContext(h gin.H) gin.H {
|
func getContext(h gin.H) gin.H {
|
||||||
a := gin.H{
|
a := gin.H{
|
||||||
"cur_ver": config.GetVersion(),
|
"cur_ver": config.GetVersion(),
|
||||||
@@ -91,6 +98,7 @@ func getContext(h gin.H) gin.H {
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isAjax checks if the request is an AJAX request.
|
||||||
func isAjax(c *gin.Context) bool {
|
func isAjax(c *gin.Context) bool {
|
||||||
return c.GetHeader("X-Requested-With") == "XMLHttpRequest"
|
return c.GetHeader("X-Requested-With") == "XMLHttpRequest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// XraySettingController handles Xray configuration and settings operations.
|
||||||
type XraySettingController struct {
|
type XraySettingController struct {
|
||||||
XraySettingService service.XraySettingService
|
XraySettingService service.XraySettingService
|
||||||
SettingService service.SettingService
|
SettingService service.SettingService
|
||||||
@@ -15,24 +16,27 @@ type XraySettingController struct {
|
|||||||
WarpService service.WarpService
|
WarpService service.WarpService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewXraySettingController creates a new XraySettingController and initializes its routes.
|
||||||
func NewXraySettingController(g *gin.RouterGroup) *XraySettingController {
|
func NewXraySettingController(g *gin.RouterGroup) *XraySettingController {
|
||||||
a := &XraySettingController{}
|
a := &XraySettingController{}
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initRouter sets up the routes for Xray settings management.
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getXraySetting retrieves the Xray configuration template and inbound tags.
|
||||||
func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
||||||
xraySetting, err := a.SettingService.GetXrayConfigTemplate()
|
xraySetting, err := a.SettingService.GetXrayConfigTemplate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,12 +52,14 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
|||||||
jsonObj(c, xrayResponse, nil)
|
jsonObj(c, xrayResponse, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateSetting updates the Xray configuration settings.
|
||||||
func (a *XraySettingController) updateSetting(c *gin.Context) {
|
func (a *XraySettingController) updateSetting(c *gin.Context) {
|
||||||
xraySetting := c.PostForm("xraySetting")
|
xraySetting := c.PostForm("xraySetting")
|
||||||
err := a.XraySettingService.SaveXraySetting(xraySetting)
|
err := a.XraySettingService.SaveXraySetting(xraySetting)
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getDefaultXrayConfig retrieves the default Xray configuration.
|
||||||
func (a *XraySettingController) getDefaultXrayConfig(c *gin.Context) {
|
func (a *XraySettingController) getDefaultXrayConfig(c *gin.Context) {
|
||||||
defaultJsonConfig, err := a.SettingService.GetDefaultXrayConfig()
|
defaultJsonConfig, err := a.SettingService.GetDefaultXrayConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,10 +69,12 @@ func (a *XraySettingController) getDefaultXrayConfig(c *gin.Context) {
|
|||||||
jsonObj(c, defaultJsonConfig, nil)
|
jsonObj(c, defaultJsonConfig, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getXrayResult retrieves the current Xray service result.
|
||||||
func (a *XraySettingController) getXrayResult(c *gin.Context) {
|
func (a *XraySettingController) getXrayResult(c *gin.Context) {
|
||||||
jsonObj(c, a.XrayService.GetXrayResult(), nil)
|
jsonObj(c, a.XrayService.GetXrayResult(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warp handles Warp-related operations based on the action parameter.
|
||||||
func (a *XraySettingController) warp(c *gin.Context) {
|
func (a *XraySettingController) warp(c *gin.Context) {
|
||||||
action := c.Param("action")
|
action := c.Param("action")
|
||||||
var resp string
|
var resp string
|
||||||
@@ -90,6 +98,7 @@ func (a *XraySettingController) warp(c *gin.Context) {
|
|||||||
jsonObj(c, resp, err)
|
jsonObj(c, resp, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getOutboundsTraffic retrieves the traffic statistics for outbounds.
|
||||||
func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
|
func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
|
||||||
outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()
|
outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -99,6 +108,7 @@ func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
|
|||||||
jsonObj(c, outboundsTraffic, nil)
|
jsonObj(c, outboundsTraffic, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetOutboundsTraffic resets the traffic statistics for the specified outbound tag.
|
||||||
func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
|
func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
|
||||||
tag := c.PostForm("tag")
|
tag := c.PostForm("tag")
|
||||||
err := a.OutboundService.ResetOutboundTraffic(tag)
|
err := a.OutboundService.ResetOutboundTraffic(tag)
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// XUIController is the main controller for the X-UI panel, managing sub-controllers.
|
||||||
type XUIController struct {
|
type XUIController struct {
|
||||||
BaseController
|
BaseController
|
||||||
|
|
||||||
inboundController *InboundController
|
|
||||||
serverController *ServerController
|
|
||||||
settingController *SettingController
|
settingController *SettingController
|
||||||
xraySettingController *XraySettingController
|
xraySettingController *XraySettingController
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewXUIController creates a new XUIController and initializes its routes.
|
||||||
func NewXUIController(g *gin.RouterGroup) *XUIController {
|
func NewXUIController(g *gin.RouterGroup) *XUIController {
|
||||||
a := &XUIController{}
|
a := &XUIController{}
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initRouter sets up the main panel routes and initializes sub-controllers.
|
||||||
func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||||
g = g.Group("/panel")
|
g = g.Group("/panel")
|
||||||
g.Use(a.checkLogin)
|
g.Use(a.checkLogin)
|
||||||
@@ -28,24 +29,26 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.GET("/settings", a.settings)
|
g.GET("/settings", a.settings)
|
||||||
g.GET("/xray", a.xraySettings)
|
g.GET("/xray", a.xraySettings)
|
||||||
|
|
||||||
a.inboundController = NewInboundController(g)
|
|
||||||
a.serverController = NewServerController(g)
|
|
||||||
a.settingController = NewSettingController(g)
|
a.settingController = NewSettingController(g)
|
||||||
a.xraySettingController = NewXraySettingController(g)
|
a.xraySettingController = NewXraySettingController(g)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// index renders the main panel index page.
|
||||||
func (a *XUIController) index(c *gin.Context) {
|
func (a *XUIController) index(c *gin.Context) {
|
||||||
html(c, "index.html", "pages.index.title", nil)
|
html(c, "index.html", "pages.index.title", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbounds renders the inbounds management page.
|
||||||
func (a *XUIController) inbounds(c *gin.Context) {
|
func (a *XUIController) inbounds(c *gin.Context) {
|
||||||
html(c, "inbounds.html", "pages.inbounds.title", nil)
|
html(c, "inbounds.html", "pages.inbounds.title", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// settings renders the settings management page.
|
||||||
func (a *XUIController) settings(c *gin.Context) {
|
func (a *XUIController) settings(c *gin.Context) {
|
||||||
html(c, "settings.html", "pages.settings.title", nil)
|
html(c, "settings.html", "pages.settings.title", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// xraySettings renders the Xray settings page.
|
||||||
func (a *XUIController) xraySettings(c *gin.Context) {
|
func (a *XUIController) xraySettings(c *gin.Context) {
|
||||||
html(c, "xray.html", "pages.xray.title", nil)
|
html(c, "xray.html", "pages.xray.title", nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package entity defines data structures and entities used by the web layer of the 3x-ui panel.
|
||||||
package entity
|
package entity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,63 +8,76 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"x-ui/util/common"
|
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Msg represents a standard API response message with success status, message text, and optional data object.
|
||||||
type Msg struct {
|
type Msg struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"` // Indicates if the operation was successful
|
||||||
Msg string `json:"msg"`
|
Msg string `json:"msg"` // Response message text
|
||||||
Obj any `json:"obj"`
|
Obj any `json:"obj"` // Optional data object
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
|
||||||
type AllSetting struct {
|
type AllSetting struct {
|
||||||
WebListen string `json:"webListen" form:"webListen"`
|
// Web server settings
|
||||||
WebDomain string `json:"webDomain" form:"webDomain"`
|
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
|
||||||
WebPort int `json:"webPort" form:"webPort"`
|
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
|
||||||
WebCertFile string `json:"webCertFile" form:"webCertFile"`
|
WebPort int `json:"webPort" form:"webPort"` // Web server port number
|
||||||
WebKeyFile string `json:"webKeyFile" form:"webKeyFile"`
|
WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server
|
||||||
WebBasePath string `json:"webBasePath" form:"webBasePath"`
|
WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server
|
||||||
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"`
|
WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs
|
||||||
PageSize int `json:"pageSize" form:"pageSize"`
|
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes
|
||||||
ExpireDiff int `json:"expireDiff" form:"expireDiff"`
|
|
||||||
TrafficDiff int `json:"trafficDiff" form:"trafficDiff"`
|
// UI settings
|
||||||
RemarkModel string `json:"remarkModel" form:"remarkModel"`
|
PageSize int `json:"pageSize" form:"pageSize"` // Number of items per page in lists
|
||||||
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"`
|
ExpireDiff int `json:"expireDiff" form:"expireDiff"` // Expiration warning threshold in days
|
||||||
TgBotToken string `json:"tgBotToken" form:"tgBotToken"`
|
TrafficDiff int `json:"trafficDiff" form:"trafficDiff"` // Traffic warning threshold percentage
|
||||||
TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"`
|
RemarkModel string `json:"remarkModel" form:"remarkModel"` // Remark model pattern for inbounds
|
||||||
TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"`
|
Datepicker string `json:"datepicker" form:"datepicker"` // Date picker format
|
||||||
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"`
|
|
||||||
TgRunTime string `json:"tgRunTime" form:"tgRunTime"`
|
// Telegram bot settings
|
||||||
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"`
|
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` // Enable Telegram bot notifications
|
||||||
TgBotLoginNotify bool `json:"tgBotLoginNotify" form:"tgBotLoginNotify"`
|
TgBotToken string `json:"tgBotToken" form:"tgBotToken"` // Telegram bot token
|
||||||
TgCpu int `json:"tgCpu" form:"tgCpu"`
|
TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"` // Proxy URL for Telegram bot
|
||||||
TgLang string `json:"tgLang" form:"tgLang"`
|
TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"` // Custom API server for Telegram bot
|
||||||
TimeLocation string `json:"timeLocation" form:"timeLocation"`
|
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"` // Telegram chat ID for notifications
|
||||||
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"`
|
TgRunTime string `json:"tgRunTime" form:"tgRunTime"` // Cron schedule for Telegram notifications
|
||||||
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"`
|
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` // Enable database backup via Telegram
|
||||||
SubEnable bool `json:"subEnable" form:"subEnable"`
|
TgBotLoginNotify bool `json:"tgBotLoginNotify" form:"tgBotLoginNotify"` // Send login notifications
|
||||||
SubTitle string `json:"subTitle" form:"subTitle"`
|
TgCpu int `json:"tgCpu" form:"tgCpu"` // CPU usage threshold for alerts
|
||||||
SubListen string `json:"subListen" form:"subListen"`
|
TgLang string `json:"tgLang" form:"tgLang"` // Telegram bot language
|
||||||
SubPort int `json:"subPort" form:"subPort"`
|
|
||||||
SubPath string `json:"subPath" form:"subPath"`
|
// Security settings
|
||||||
SubDomain string `json:"subDomain" form:"subDomain"`
|
TimeLocation string `json:"timeLocation" form:"timeLocation"` // Time zone location
|
||||||
SubCertFile string `json:"subCertFile" form:"subCertFile"`
|
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"` // Enable two-factor authentication
|
||||||
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"`
|
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"` // Two-factor authentication token
|
||||||
SubUpdates int `json:"subUpdates" form:"subUpdates"`
|
|
||||||
ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"`
|
// Subscription server settings
|
||||||
ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"`
|
SubEnable bool `json:"subEnable" form:"subEnable"` // Enable subscription server
|
||||||
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"`
|
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"` // Enable JSON subscription endpoint
|
||||||
SubShowInfo bool `json:"subShowInfo" form:"subShowInfo"`
|
SubTitle string `json:"subTitle" form:"subTitle"` // Subscription title
|
||||||
SubURI string `json:"subURI" form:"subURI"`
|
SubListen string `json:"subListen" form:"subListen"` // Subscription server listen IP
|
||||||
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"`
|
SubPort int `json:"subPort" form:"subPort"` // Subscription server port
|
||||||
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"`
|
SubPath string `json:"subPath" form:"subPath"` // Base path for subscription URLs
|
||||||
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"`
|
SubDomain string `json:"subDomain" form:"subDomain"` // Domain for subscription server validation
|
||||||
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"`
|
SubCertFile string `json:"subCertFile" form:"subCertFile"` // SSL certificate file for subscription server
|
||||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
|
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"` // SSL private key file for subscription server
|
||||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
SubUpdates int `json:"subUpdates" form:"subUpdates"` // Subscription update interval in minutes
|
||||||
Datepicker string `json:"datepicker" form:"datepicker"`
|
ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"` // Enable external traffic reporting
|
||||||
|
ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"` // URI for external traffic reporting
|
||||||
|
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` // Encrypt subscription responses
|
||||||
|
SubShowInfo bool `json:"subShowInfo" form:"subShowInfo"` // Show client information in subscriptions
|
||||||
|
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
|
||||||
|
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
|
||||||
|
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
|
||||||
|
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
|
||||||
|
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
||||||
|
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
||||||
|
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` // JSON subscription routing rules
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
|
||||||
func (s *AllSetting) CheckValid() error {
|
func (s *AllSetting) CheckValid() error {
|
||||||
if s.WebListen != "" {
|
if s.WebListen != "" {
|
||||||
ip := net.ParseIP(s.WebListen)
|
ip := net.ParseIP(s.WebListen)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package global provides global variables and interfaces for accessing web and subscription servers.
|
||||||
package global
|
package global
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,27 +13,33 @@ var (
|
|||||||
subServer SubServer
|
subServer SubServer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// WebServer interface defines methods for accessing the web server instance.
|
||||||
type WebServer interface {
|
type WebServer interface {
|
||||||
GetCron() *cron.Cron
|
GetCron() *cron.Cron // Get the cron scheduler
|
||||||
GetCtx() context.Context
|
GetCtx() context.Context // Get the server context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubServer interface defines methods for accessing the subscription server instance.
|
||||||
type SubServer interface {
|
type SubServer interface {
|
||||||
GetCtx() context.Context
|
GetCtx() context.Context // Get the server context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetWebServer sets the global web server instance.
|
||||||
func SetWebServer(s WebServer) {
|
func SetWebServer(s WebServer) {
|
||||||
webServer = s
|
webServer = s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetWebServer returns the global web server instance.
|
||||||
func GetWebServer() WebServer {
|
func GetWebServer() WebServer {
|
||||||
return webServer
|
return webServer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSubServer sets the global subscription server instance.
|
||||||
func SetSubServer(s SubServer) {
|
func SetSubServer(s SubServer) {
|
||||||
subServer = s
|
subServer = s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSubServer returns the global subscription server instance.
|
||||||
func GetSubServer() SubServer {
|
func GetSubServer() SubServer {
|
||||||
return subServer
|
return subServer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// HashEntry represents a stored hash entry with its value and timestamp.
|
||||||
type HashEntry struct {
|
type HashEntry struct {
|
||||||
Hash string
|
Hash string // MD5 hash string
|
||||||
Value string
|
Value string // Original value
|
||||||
Timestamp time.Time
|
Timestamp time.Time // Time when the hash was created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HashStorage provides thread-safe storage for hash-value pairs with expiration.
|
||||||
type HashStorage struct {
|
type HashStorage struct {
|
||||||
sync.RWMutex
|
sync.RWMutex
|
||||||
Data map[string]HashEntry
|
Data map[string]HashEntry // Map of hash to entry
|
||||||
Expiration time.Duration
|
Expiration time.Duration // Expiration duration for entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewHashStorage creates a new HashStorage instance with the specified expiration duration.
|
||||||
func NewHashStorage(expiration time.Duration) *HashStorage {
|
func NewHashStorage(expiration time.Duration) *HashStorage {
|
||||||
return &HashStorage{
|
return &HashStorage{
|
||||||
Data: make(map[string]HashEntry),
|
Data: make(map[string]HashEntry),
|
||||||
@@ -27,6 +30,7 @@ func NewHashStorage(expiration time.Duration) *HashStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveHash generates an MD5 hash for the given query string and stores it with a timestamp.
|
||||||
func (h *HashStorage) SaveHash(query string) string {
|
func (h *HashStorage) SaveHash(query string) string {
|
||||||
h.Lock()
|
h.Lock()
|
||||||
defer h.Unlock()
|
defer h.Unlock()
|
||||||
@@ -45,6 +49,7 @@ func (h *HashStorage) SaveHash(query string) string {
|
|||||||
return md5HashString
|
return md5HashString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetValue retrieves the original value for the given hash, returning true if found.
|
||||||
func (h *HashStorage) GetValue(hash string) (string, bool) {
|
func (h *HashStorage) GetValue(hash string) (string, bool) {
|
||||||
h.RLock()
|
h.RLock()
|
||||||
defer h.RUnlock()
|
defer h.RUnlock()
|
||||||
@@ -54,11 +59,13 @@ func (h *HashStorage) GetValue(hash string) (string, bool) {
|
|||||||
return entry.Value, exists
|
return entry.Value, exists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsMD5 checks if the given string is a valid 32-character MD5 hash.
|
||||||
func (h *HashStorage) IsMD5(hash string) bool {
|
func (h *HashStorage) IsMD5(hash string) bool {
|
||||||
match, _ := regexp.MatchString("^[a-f0-9]{32}$", hash)
|
match, _ := regexp.MatchString("^[a-f0-9]{32}$", hash)
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveExpiredHashes removes all hash entries that have exceeded the expiration duration.
|
||||||
func (h *HashStorage) RemoveExpiredHashes() {
|
func (h *HashStorage) RemoveExpiredHashes() {
|
||||||
h.Lock()
|
h.Lock()
|
||||||
defer h.Unlock()
|
defer h.Unlock()
|
||||||
@@ -72,6 +79,7 @@ func (h *HashStorage) RemoveExpiredHashes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset clears all stored hash entries.
|
||||||
func (h *HashStorage) Reset() {
|
func (h *HashStorage) Reset() {
|
||||||
h.Lock()
|
h.Lock()
|
||||||
defer h.Unlock()
|
defer h.Unlock()
|
||||||
|
|||||||
@@ -2,21 +2,21 @@
|
|||||||
<template slot="actions" slot-scope="text, client, index">
|
<template slot="actions" slot-scope="text, client, index">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">{{ i18n "qrCode" }}</template>
|
<template slot="title">{{ i18n "qrCode" }}</template>
|
||||||
<a-icon :style="{ fontSize: '24px' }" class="normal-icon" type="qrcode" v-if="record.hasLink()" @click="showQrcode(record.id,client);"></a-icon>
|
<a-icon :style="{ fontSize: '22px', marginInlineStart: '14px' }" class="normal-icon" type="qrcode" v-if="record.hasLink()" @click="showQrcode(record.id,client);"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">{{ i18n "pages.client.edit" }}</template>
|
<template slot="title">{{ i18n "pages.client.edit" }}</template>
|
||||||
<a-icon :style="{ fontSize: '24px' }" class="normal-icon" type="edit" @click="openEditClient(record.id,client);"></a-icon>
|
<a-icon :style="{ fontSize: '22px' }" class="normal-icon" type="edit" @click="openEditClient(record.id,client);"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">{{ i18n "info" }}</template>
|
<template slot="title">{{ i18n "info" }}</template>
|
||||||
<a-icon :style="{ fontSize: '24px' }" class="normal-icon" type="info-circle" @click="showInfo(record.id,client);"></a-icon>
|
<a-icon :style="{ fontSize: '22px' }" class="normal-icon" type="info-circle" @click="showInfo(record.id,client);"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
|
<template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
|
||||||
<a-popconfirm @confirm="resetClientTraffic(client,record.id,false)" title='{{ i18n "pages.inbounds.resetTrafficContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}' cancel-text='{{ i18n "cancel"}}'>
|
<a-popconfirm @confirm="resetClientTraffic(client,record.id,false)" title='{{ i18n "pages.inbounds.resetTrafficContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}' cancel-text='{{ i18n "cancel"}}'>
|
||||||
<a-icon slot="icon" type="question-circle-o" :style="{ color: 'var(--color-primary-100)'}"></a-icon>
|
<a-icon slot="icon" type="question-circle-o" :style="{ color: 'var(--color-primary-100)'}"></a-icon>
|
||||||
<a-icon :style="{ fontSize: '24px', cursor: 'pointer' }" class="normal-icon" type="retweet" v-if="client.email.length > 0"></a-icon>
|
<a-icon :style="{ fontSize: '22px', cursor: 'pointer' }" class="normal-icon" type="retweet" v-if="client.email.length > 0"></a-icon>
|
||||||
</a-popconfirm>
|
</a-popconfirm>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<a-popconfirm @confirm="delClient(record.id,client,false)" title='{{ i18n "pages.inbounds.deleteClientContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "delete"}}' ok-type="danger" cancel-text='{{ i18n "cancel"}}'>
|
<a-popconfirm @confirm="delClient(record.id,client,false)" title='{{ i18n "pages.inbounds.deleteClientContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "delete"}}' ok-type="danger" cancel-text='{{ i18n "cancel"}}'>
|
||||||
<a-icon slot="icon" type="question-circle-o" :style="{ color: '#e04141' }"></a-icon>
|
<a-icon slot="icon" type="question-circle-o" :style="{ color: '#e04141' }"></a-icon>
|
||||||
<a-icon :style="{ fontSize: '24px', cursor: 'pointer' }" class="delete-icon" type="delete" v-if="isRemovable(record.id)"></a-icon>
|
<a-icon :style="{ fontSize: '22px', cursor: 'pointer' }" class="delete-icon" type="delete" v-if="isRemovable(record.id)"></a-icon>
|
||||||
</a-popconfirm>
|
</a-popconfirm>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label='{{ i18n "pages.inbounds.port" }}'>
|
<a-form-item label='{{ i18n "pages.inbounds.port" }}'>
|
||||||
<a-input-number v-model.number="inbound.port" :min="1" :max="65531"></a-input-number>
|
<a-input-number v-model.number="inbound.port" :min="1" :max="65535"></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
@@ -44,6 +44,31 @@
|
|||||||
<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>
|
||||||
@@ -121,4 +146,4 @@
|
|||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
</a-collapse>
|
</a-collapse>
|
||||||
|
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
|
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
|
||||||
<a-form-item label="External Proxy">
|
<a-form-item label="External Proxy">
|
||||||
<a-switch v-model="externalProxy"></a-switch>
|
<a-switch v-model="externalProxy"></a-switch>
|
||||||
<a-button icon="plus" v-if="externalProxy" type="primary" :style="{ marginLeft: '10px' }" size="small" @click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"></a-button>
|
<a-button icon="plus" v-if="externalProxy" type="primary" :style="{ marginLeft: '10px' }" size="small"
|
||||||
|
@click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"></a-button>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-input-group :style="{ margin: '8px 0' }" compact v-for="(row, index) in inbound.stream.externalProxy">
|
<a-input-group :style="{ margin: '8px 0' }" compact v-for="(row, index) in inbound.stream.externalProxy">
|
||||||
<template>
|
<template>
|
||||||
<a-tooltip title="Force TLS">
|
<a-tooltip title="Force TLS">
|
||||||
<a-select v-model="row.forceTls" :style="{ width: '20%', margin: '0px' }" :dropdown-class-name="themeSwitcher.currentTheme">
|
<a-select v-model="row.forceTls" :style="{ width: '20%', margin: '0px' }"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
<a-select-option value="same">{{ i18n "pages.inbounds.same" }}</a-select-option>
|
<a-select-option value="same">{{ i18n "pages.inbounds.same" }}</a-select-option>
|
||||||
<a-select-option value="none">{{ i18n "none" }}</a-select-option>
|
<a-select-option value="none">{{ i18n "none" }}</a-select-option>
|
||||||
<a-select-option value="tls">TLS</a-select-option>
|
<a-select-option value="tls">TLS</a-select-option>
|
||||||
@@ -17,7 +19,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<a-input :style="{ width: '30%' }" v-model.trim="row.dest" placeholder='{{ i18n "host" }}'></a-input>
|
<a-input :style="{ width: '30%' }" v-model.trim="row.dest" placeholder='{{ i18n "host" }}'></a-input>
|
||||||
<a-tooltip title='{{ i18n "pages.inbounds.port" }}'>
|
<a-tooltip title='{{ i18n "pages.inbounds.port" }}'>
|
||||||
<a-input-number :style="{ width: '15%' }" v-model.number="row.port" min="1" max="65531"></a-input-number>
|
<a-input-number :style="{ width: '15%' }" v-model.number="row.port" min="1" max="65535"></a-input-number>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-input :style="{ width: '30%', top: '0' }" v-model.trim="row.remark" placeholder='{{ i18n "remark" }}'>
|
<a-input :style="{ width: '30%', top: '0' }" v-model.trim="row.remark" placeholder='{{ i18n "remark" }}'>
|
||||||
<template slot="addonAfter">
|
<template slot="addonAfter">
|
||||||
@@ -26,4 +28,4 @@
|
|||||||
</a-input>
|
</a-input>
|
||||||
</a-input-group>
|
</a-input-group>
|
||||||
</a-form>
|
</a-form>
|
||||||
{{end}}
|
{{end}}
|
||||||
File diff suppressed because it is too large
Load Diff
1232
web/html/index.html
1232
web/html/index.html
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
|||||||
{{ template "page/body_start" .}}
|
{{ template "page/body_start" .}}
|
||||||
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' login-app'">
|
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' login-app'">
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<a-layout-content class="under min-h-100vh">
|
<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"
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<a-row type="flex" justify="center" align="middle" class="h-100 overflow-hidden-auto">
|
<a-row type="flex" justify="center" align="middle" class="h-100 overflow-y-auto overflow-x-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" class="my-3rem">
|
||||||
<template v-if="!loadingStates.fetched">
|
<template v-if="!loadingStates.fetched">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
@@ -184,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" .}}
|
||||||
@@ -3,22 +3,29 @@
|
|||||||
:mask-closable="false" :footer="null" :class="themeSwitcher.currentTheme">
|
:mask-closable="false" :footer="null" :class="themeSwitcher.currentTheme">
|
||||||
<a-list class="ant-dns-presets-list" bordered :style="{ width: '100%' }">
|
<a-list class="ant-dns-presets-list" bordered :style="{ width: '100%' }">
|
||||||
<a-list-item v-for="dns in dnsPresetsDatabase" :style="{ padding: '12px 16px' }">
|
<a-list-item v-for="dns in dnsPresetsDatabase" :style="{ padding: '12px 16px' }">
|
||||||
<a-row justify="space-between" align="middle">
|
<div class="ant-dns-presets-line">
|
||||||
<a-col :span="12">
|
<a-space direction="horizontal" size="small" align="center">
|
||||||
<a-space direction="vertical" size="small">
|
<a-tag :color="dns.family ? 'purple' : 'green'">[[ dns.family ? '{{ i18n "pages.xray.dns.dnsPresetFamily" }}' : 'DNS' ]]</a-tag>
|
||||||
<span class="ant-dns-presets-list-name">[[ dns.name ]]</span>
|
<span class="ant-dns-presets-list-name">[[ dns.name ]]</span>
|
||||||
<a-tag :color="dns.family ? 'purple' : 'green'">[[ dns.family ? '{{ i18n "pages.xray.dns.dnsPresetFamily" }}' : 'DNS' ]]</a-tag>
|
</a-space>
|
||||||
</a-space>
|
<a-button class="ant-dns-presets-install" type="primary" @click="dnsPresetsModal.install(dns.data)">{{ i18n "install" }}</a-button>
|
||||||
</a-col>
|
</div>
|
||||||
<a-col :span="12" :style="{ textAlign: 'right' }">
|
|
||||||
<a-button type="primary" @click="dnsPresetsModal.install(dns.data)">{{ i18n "install" }}</a-button>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-list-item>
|
</a-list-item>
|
||||||
</a-list>
|
</a-list>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.ant-dns-presets-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-dns-presets-install {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.dark .ant-dns-presets-list {
|
.dark .ant-dns-presets-list {
|
||||||
border-color: var(--dark-color-stroke)
|
border-color: var(--dark-color-stroke)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,14 +102,18 @@
|
|||||||
<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 class="info-large-tag" :color="inbound.settings.encryption ? 'green' : 'red'">[[ inbound.settings.encryption ? inbound.settings.encryption : '' ]]</a-tag>
|
||||||
|
<a-tooltip title='{{ i18n "copy" }}'>
|
||||||
|
<a-button size="small" icon="snippets" @click="copy(inbound.settings.encryption)"></a-button>
|
||||||
|
</a-tooltip>
|
||||||
<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 +183,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 +311,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 +527,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 +551,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 +590,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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
<a-input v-model.trim="dnsModal.dnsServer.address"></a-input>
|
<a-input v-model.trim="dnsModal.dnsServer.address"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "pages.inbounds.port" }}'>
|
<a-form-item label='{{ i18n "pages.inbounds.port" }}'>
|
||||||
<a-input-number v-model.number="dnsModal.dnsServer.port" :min="1" :max="65531"></a-input-number>
|
<a-input-number v-model.number="dnsModal.dnsServer.port" :min="1" :max="65535"></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "pages.xray.dns.strategy" }}'>
|
<a-form-item label='{{ i18n "pages.xray.dns.strategy" }}'>
|
||||||
<a-select v-model="dnsModal.dnsServer.queryStrategy" :style="{ width: '100%' }"
|
<a-select v-model="dnsModal.dnsServer.queryStrategy" :style="{ width: '100%' }"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
<a-select-option :value="l" :label="l" v-for="l in ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6']"> [[ l ]] </a-select-option>
|
<a-select-option :value="l" :label="l" v-for="l in ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6']"> [[ l ]]
|
||||||
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
isEdit: false,
|
isEdit: false,
|
||||||
confirm: null,
|
confirm: null,
|
||||||
dnsServer: { ...defaultDnsObject },
|
dnsServer: { ...defaultDnsObject },
|
||||||
ok() {
|
ok() {
|
||||||
ObjectUtil.execute(dnsModal.confirm, { ...dnsModal.dnsServer });
|
ObjectUtil.execute(dnsModal.confirm, { ...dnsModal.dnsServer });
|
||||||
},
|
},
|
||||||
show({
|
show({
|
||||||
@@ -106,7 +107,7 @@
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.dnsServer = { ...defaultDnsObject };
|
this.dnsServer = { ...defaultDnsObject };
|
||||||
|
|
||||||
this.dnsServer.domains = [];
|
this.dnsServer.domains = [];
|
||||||
this.dnsServer.expectIPs = [];
|
this.dnsServer.expectIPs = [];
|
||||||
this.dnsServer.unexpectedIPs = [];
|
this.dnsServer.unexpectedIPs = [];
|
||||||
|
|||||||
@@ -79,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>
|
||||||
@@ -523,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" }}');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<template #title>{{ i18n "pages.settings.panelPort"}}</template>
|
<template #title>{{ i18n "pages.settings.panelPort"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.panelPortDesc"}}</template>
|
<template #description>{{ i18n "pages.settings.panelPortDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input-number :min="1" :min="65531" v-model="allSetting.webPort" :style="{ width: '100%' }"></a-input>
|
<a-input-number :min="1" :min="65535" v-model="allSetting.webPort" :style="{ width: '100%' }"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
@@ -137,7 +137,8 @@
|
|||||||
<template #title>{{ i18n "pages.settings.datepicker"}}</template>
|
<template #title>{{ i18n "pages.settings.datepicker"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.datepickerDescription"}}</template>
|
<template #description>{{ i18n "pages.settings.datepickerDescription"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select :style="{ width: '100%' }" :dropdown-class-name="themeSwitcher.currentTheme" v-model="datepicker">
|
<a-select :style="{ width: '100%' }" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
v-model="datepicker">
|
||||||
<a-select-option v-for="item in datepickerList" :value="item.value">
|
<a-select-option v-for="item in datepickerList" :value="item.value">
|
||||||
<span v-text="item.name"></span>
|
<span v-text="item.name"></span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -33,7 +40,7 @@
|
|||||||
<template #title>{{ i18n "pages.settings.subPort"}}</template>
|
<template #title>{{ i18n "pages.settings.subPort"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subPortDesc"}}</template>
|
<template #description>{{ i18n "pages.settings.subPortDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input-number v-model="allSetting.subPort" :min="1" :min="65531"
|
<a-input-number v-model="allSetting.subPort" :min="1" :min="65535"
|
||||||
:style="{ width: '100%' }"></a-input-number>
|
:style="{ width: '100%' }"></a-input-number>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
@@ -41,7 +48,10 @@
|
|||||||
<template #title>{{ i18n "pages.settings.subPath"}}</template>
|
<template #title>{{ i18n "pages.settings.subPath"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subPath"></a-input>
|
<a-input type="text" v-model="allSetting.subPath"
|
||||||
|
@input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
|
||||||
|
@blur="allSetting.subPath = (p => { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subPath)"
|
||||||
|
placeholder="/sub/"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
|
|||||||
@@ -5,7 +5,13 @@
|
|||||||
<template #title>{{ i18n "pages.settings.subPath"}}</template>
|
<template #title>{{ i18n "pages.settings.subPath"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subJsonPath"></a-input>
|
<a-input
|
||||||
|
type="text"
|
||||||
|
v-model="allSetting.subJsonPath"
|
||||||
|
@input="allSetting.subJsonPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
|
||||||
|
@blur="allSetting.subJsonPath = (p => { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subJsonPath)"
|
||||||
|
placeholder="/json/"
|
||||||
|
></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
{{ 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 + ' subscription-page'">
|
||||||
<a-layout-content class="p-2">
|
<a-layout-content class="p-2">
|
||||||
<a-row type="flex" justify="center" class="mt-2">
|
<a-row type="flex" justify="center" class="mt-2">
|
||||||
<a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12">
|
<a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12">
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<a-space direction="vertical" align="center">
|
<a-space direction="vertical" align="center">
|
||||||
<a-row type="flex" :gutter="[8,8]"
|
<a-row type="flex" :gutter="[8,8]"
|
||||||
justify="center" style="width:100%">
|
justify="center" style="width:100%">
|
||||||
<a-col :xs="24" :sm="12"
|
<a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24"
|
||||||
style="text-align:center;">
|
style="text-align:center;">
|
||||||
<tr-qr-box class="qr-box">
|
<tr-qr-box class="qr-box">
|
||||||
<a-tag color="purple"
|
<a-tag color="purple"
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
</tr-qr-bg>
|
</tr-qr-bg>
|
||||||
</tr-qr-box>
|
</tr-qr-box>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :sm="12"
|
<a-col v-if="app.subJsonUrl" :xs="24" :sm="12"
|
||||||
style="text-align:center;">
|
style="text-align:center;">
|
||||||
<tr-qr-box class="qr-box">
|
<tr-qr-box class="qr-box">
|
||||||
<a-tag color="purple"
|
<a-tag color="purple"
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
style="text-align:center;">
|
style="text-align:center;">
|
||||||
<!-- Android dropdown -->
|
<!-- Android dropdown -->
|
||||||
<a-dropdown :trigger="['click']">
|
<a-dropdown :trigger="['click']">
|
||||||
<a-button :block="isMobile"
|
<a-button icon="android" :block="isMobile"
|
||||||
:style="{ marginTop: isMobile ? '6px' : 0 }"
|
:style="{ marginTop: isMobile ? '6px' : 0 }"
|
||||||
size="large" type="primary">
|
size="large" type="primary">
|
||||||
Android <a-icon type="down" />
|
Android <a-icon type="down" />
|
||||||
@@ -218,6 +218,8 @@
|
|||||||
<a-menu-item key="android-npvtunnel"
|
<a-menu-item key="android-npvtunnel"
|
||||||
@click="copy(app.subUrl)">NPV
|
@click="copy(app.subUrl)">NPV
|
||||||
Tunnel</a-menu-item>
|
Tunnel</a-menu-item>
|
||||||
|
<a-menu-item key="android-happ"
|
||||||
|
@click="open('happ://add/' + encodeURIComponent(app.subUrl))">Happ</a-menu-item>
|
||||||
</a-menu>
|
</a-menu>
|
||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -225,7 +227,7 @@
|
|||||||
style="text-align:center;">
|
style="text-align:center;">
|
||||||
<!-- iOS dropdown -->
|
<!-- iOS dropdown -->
|
||||||
<a-dropdown :trigger="['click']">
|
<a-dropdown :trigger="['click']">
|
||||||
<a-button :block="isMobile"
|
<a-button icon="apple" :block="isMobile"
|
||||||
:style="{ marginTop: isMobile ? '6px' : 0 }"
|
:style="{ marginTop: isMobile ? '6px' : 0 }"
|
||||||
size="large" type="primary">
|
size="large" type="primary">
|
||||||
iOS <a-icon type="down" />
|
iOS <a-icon type="down" />
|
||||||
@@ -233,16 +235,19 @@
|
|||||||
<a-menu slot="overlay"
|
<a-menu slot="overlay"
|
||||||
:class="themeSwitcher.currentTheme">
|
:class="themeSwitcher.currentTheme">
|
||||||
<a-menu-item key="ios-shadowrocket"
|
<a-menu-item key="ios-shadowrocket"
|
||||||
@click="open('shadowrocket://add/subscription?url=' + encodeURIComponent(app.subUrl) + '&remark=' + encodeURIComponent(app.sId))">Shadowrocket</a-menu-item>
|
@click="open(shadowrocketUrl)">Shadowrocket</a-menu-item>
|
||||||
<a-menu-item key="ios-v2box"
|
<a-menu-item key="ios-v2box"
|
||||||
@click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item>
|
@click="open(v2boxUrl)">V2Box</a-menu-item>
|
||||||
<a-menu-item key="ios-streisand"
|
<a-menu-item key="ios-streisand"
|
||||||
@click="open('streisand://import/' + encodeURIComponent(app.subUrl))">Streisand</a-menu-item>
|
@click="open(streisandUrl)">Streisand</a-menu-item>
|
||||||
<a-menu-item key="ios-v2raytun"
|
<a-menu-item key="ios-v2raytun"
|
||||||
@click="copy(app.subUrl)">V2RayTun</a-menu-item>
|
@click="copy(v2raytunUrl)">V2RayTun</a-menu-item>
|
||||||
<a-menu-item key="ios-npvtunnel"
|
<a-menu-item key="ios-npvtunnel"
|
||||||
@click="copy(app.subUrl)">NPV
|
@click="copy(npvtunUrl)">NPV
|
||||||
Tunnel</a-menu-item>
|
Tunnel
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="ios-happ"
|
||||||
|
@click="open(happUrl)">Happ</a-menu-item>
|
||||||
</a-menu>
|
</a-menu>
|
||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -12,13 +12,14 @@
|
|||||||
<a-layout-content>
|
<a-layout-content>
|
||||||
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
|
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }" message='{{ i18n "secAlertTitle" }}'
|
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
|
||||||
color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
||||||
</a-alert>
|
</a-alert>
|
||||||
</transition>
|
</transition>
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<a-row v-if="!loadingStates.fetched">
|
<a-row v-if="!loadingStates.fetched">
|
||||||
<a-card :style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
|
<a-card
|
||||||
|
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
|
||||||
<a-spin tip='{{ i18n "loading" }}'></a-spin>
|
<a-spin tip='{{ i18n "loading" }}'></a-spin>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-row>
|
</a-row>
|
||||||
@@ -37,7 +38,8 @@
|
|||||||
<a-popover v-if="restartResult" :overlay-class-name="themeSwitcher.currentTheme">
|
<a-popover v-if="restartResult" :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
<span slot="title">{{ i18n "pages.index.xrayErrorPopoverTitle" }}</span>
|
<span slot="title">{{ i18n "pages.index.xrayErrorPopoverTitle" }}</span>
|
||||||
<template slot="content">
|
<template slot="content">
|
||||||
<span :style="{ maxWidth: '400px' }" v-for="line in restartResult.split('\n')">[[ line ]]</span>
|
<span :style="{ maxWidth: '400px' }" v-for="line in restartResult.split('\n')">[[ line
|
||||||
|
]]</span>
|
||||||
</template>
|
</template>
|
||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-popover>
|
</a-popover>
|
||||||
@@ -534,11 +536,12 @@
|
|||||||
serverObj = null;
|
serverObj = null;
|
||||||
switch (o.protocol) {
|
switch (o.protocol) {
|
||||||
case Protocols.VMess:
|
case Protocols.VMess:
|
||||||
case Protocols.VLESS:
|
|
||||||
serverObj = o.settings.vnext;
|
serverObj = o.settings.vnext;
|
||||||
break;
|
break;
|
||||||
|
case Protocols.VLESS:
|
||||||
|
return [o.settings?.address + ':' + o.settings?.port];
|
||||||
case Protocols.HTTP:
|
case Protocols.HTTP:
|
||||||
case Protocols.Mixed:
|
case Protocols.Socks:
|
||||||
case Protocols.Shadowsocks:
|
case Protocols.Shadowsocks:
|
||||||
case Protocols.Trojan:
|
case Protocols.Trojan:
|
||||||
serverObj = o.settings.servers;
|
serverObj = o.settings.servers;
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
|
||||||
type CheckClientIpJob struct {
|
type CheckClientIpJob struct {
|
||||||
lastClear int64
|
lastClear int64
|
||||||
disAllowedIps []string
|
disAllowedIps []string
|
||||||
@@ -25,6 +26,7 @@ type CheckClientIpJob struct {
|
|||||||
|
|
||||||
var job *CheckClientIpJob
|
var job *CheckClientIpJob
|
||||||
|
|
||||||
|
// NewCheckClientIpJob creates a new client IP monitoring job instance.
|
||||||
func NewCheckClientIpJob() *CheckClientIpJob {
|
func NewCheckClientIpJob() *CheckClientIpJob {
|
||||||
job = new(CheckClientIpJob)
|
job = new(CheckClientIpJob)
|
||||||
return job
|
return job
|
||||||
|
|||||||
@@ -4,21 +4,23 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CheckCpuJob monitors CPU usage and sends Telegram notifications when usage exceeds the configured threshold.
|
||||||
type CheckCpuJob struct {
|
type CheckCpuJob struct {
|
||||||
tgbotService service.Tgbot
|
tgbotService service.Tgbot
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewCheckCpuJob creates a new CPU monitoring job instance.
|
||||||
func NewCheckCpuJob() *CheckCpuJob {
|
func NewCheckCpuJob() *CheckCpuJob {
|
||||||
return new(CheckCpuJob)
|
return new(CheckCpuJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Here run is a interface method of Job interface
|
// Run checks CPU usage over the last minute and sends a Telegram alert if it exceeds the threshold.
|
||||||
func (j *CheckCpuJob) Run() {
|
func (j *CheckCpuJob) Run() {
|
||||||
threshold, _ := j.settingService.GetTgCpu()
|
threshold, _ := j.settingService.GetTgCpu()
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
package job
|
package job
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"x-ui/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CheckHashStorageJob periodically cleans up expired hash entries from the Telegram bot's hash storage.
|
||||||
type CheckHashStorageJob struct {
|
type CheckHashStorageJob struct {
|
||||||
tgbotService service.Tgbot
|
tgbotService service.Tgbot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewCheckHashStorageJob creates a new hash storage cleanup job instance.
|
||||||
func NewCheckHashStorageJob() *CheckHashStorageJob {
|
func NewCheckHashStorageJob() *CheckHashStorageJob {
|
||||||
return new(CheckHashStorageJob)
|
return new(CheckHashStorageJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Here Run is an interface method of the Job interface
|
// Run removes expired hash entries from the Telegram bot's hash storage.
|
||||||
func (j *CheckHashStorageJob) Run() {
|
func (j *CheckHashStorageJob) Run() {
|
||||||
// Remove expired hashes from storage
|
// Remove expired hashes from storage
|
||||||
j.tgbotService.GetHashStorage().RemoveExpiredHashes()
|
j.tgbotService.GetHashStorage().RemoveExpiredHashes()
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
|
// Package job provides background job implementations for the 3x-ui web panel,
|
||||||
|
// including traffic monitoring, system checks, and periodic maintenance tasks.
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CheckXrayRunningJob monitors Xray process health and restarts it if it crashes.
|
||||||
type CheckXrayRunningJob struct {
|
type CheckXrayRunningJob struct {
|
||||||
xrayService service.XrayService
|
xrayService service.XrayService
|
||||||
|
checkTime int
|
||||||
checkTime int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewCheckXrayRunningJob creates a new Xray health check job instance.
|
||||||
func NewCheckXrayRunningJob() *CheckXrayRunningJob {
|
func NewCheckXrayRunningJob() *CheckXrayRunningJob {
|
||||||
return new(CheckXrayRunningJob)
|
return new(CheckXrayRunningJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run checks if Xray has crashed and restarts it after confirming it's down for 2 consecutive checks.
|
||||||
func (j *CheckXrayRunningJob) Run() {
|
func (j *CheckXrayRunningJob) Run() {
|
||||||
if !j.xrayService.DidXrayCrash() {
|
if !j.xrayService.DidXrayCrash() {
|
||||||
j.checkTime = 0
|
j.checkTime = 0
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ClearLogsJob clears old log files to prevent disk space issues.
|
||||||
type ClearLogsJob struct{}
|
type ClearLogsJob struct{}
|
||||||
|
|
||||||
|
// NewClearLogsJob creates a new log cleanup job instance.
|
||||||
func NewClearLogsJob() *ClearLogsJob {
|
func NewClearLogsJob() *ClearLogsJob {
|
||||||
return new(ClearLogsJob)
|
return new(ClearLogsJob)
|
||||||
}
|
}
|
||||||
|
|||||||
52
web/job/periodic_traffic_reset_job.go
Normal file
52
web/job/periodic_traffic_reset_job.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Period represents the time period for traffic resets.
|
||||||
|
type Period string
|
||||||
|
|
||||||
|
// PeriodicTrafficResetJob resets traffic statistics for inbounds based on their configured reset period.
|
||||||
|
type PeriodicTrafficResetJob struct {
|
||||||
|
inboundService service.InboundService
|
||||||
|
period Period
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPeriodicTrafficResetJob creates a new periodic traffic reset job for the specified period.
|
||||||
|
func NewPeriodicTrafficResetJob(period Period) *PeriodicTrafficResetJob {
|
||||||
|
return &PeriodicTrafficResetJob{
|
||||||
|
period: period,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run resets traffic statistics for all inbounds that match the configured reset 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,29 @@
|
|||||||
package job
|
package job
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"x-ui/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LoginStatus represents the status of a login attempt.
|
||||||
type LoginStatus byte
|
type LoginStatus byte
|
||||||
|
|
||||||
const (
|
const (
|
||||||
LoginSuccess LoginStatus = 1
|
LoginSuccess LoginStatus = 1 // Successful login
|
||||||
LoginFail LoginStatus = 0
|
LoginFail LoginStatus = 0 // Failed login attempt
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StatsNotifyJob sends periodic statistics reports via Telegram bot.
|
||||||
type StatsNotifyJob struct {
|
type StatsNotifyJob struct {
|
||||||
xrayService service.XrayService
|
xrayService service.XrayService
|
||||||
tgbotService service.Tgbot
|
tgbotService service.Tgbot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewStatsNotifyJob creates a new statistics notification job instance.
|
||||||
func NewStatsNotifyJob() *StatsNotifyJob {
|
func NewStatsNotifyJob() *StatsNotifyJob {
|
||||||
return new(StatsNotifyJob)
|
return new(StatsNotifyJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Here run is a interface method of Job interface
|
// Run sends a statistics report via Telegram bot if Xray is running.
|
||||||
func (j *StatsNotifyJob) Run() {
|
func (j *StatsNotifyJob) Run() {
|
||||||
if !j.xrayService.IsXrayRunning() {
|
if !j.xrayService.IsXrayRunning() {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// XrayTrafficJob collects and processes traffic statistics from Xray, updating the database and optionally informing external APIs.
|
||||||
type XrayTrafficJob struct {
|
type XrayTrafficJob struct {
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
xrayService service.XrayService
|
xrayService service.XrayService
|
||||||
@@ -16,10 +18,12 @@ type XrayTrafficJob struct {
|
|||||||
outboundService service.OutboundService
|
outboundService service.OutboundService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewXrayTrafficJob creates a new traffic collection job instance.
|
||||||
func NewXrayTrafficJob() *XrayTrafficJob {
|
func NewXrayTrafficJob() *XrayTrafficJob {
|
||||||
return new(XrayTrafficJob)
|
return new(XrayTrafficJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run collects traffic statistics from Xray and updates the database, triggering restart if needed.
|
||||||
func (j *XrayTrafficJob) Run() {
|
func (j *XrayTrafficJob) Run() {
|
||||||
if !j.xrayService.IsXrayRunning() {
|
if !j.xrayService.IsXrayRunning() {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Package locale provides internationalization (i18n) support for the 3x-ui web panel,
|
||||||
|
// including translation loading, localization, and middleware for web and bot interfaces.
|
||||||
package locale
|
package locale
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,7 +8,7 @@ import (
|
|||||||
"os"
|
"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"
|
||||||
@@ -20,17 +22,20 @@ var (
|
|||||||
LocalizerBot *i18n.Localizer
|
LocalizerBot *i18n.Localizer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// I18nType represents the type of interface for internationalization.
|
||||||
type I18nType string
|
type I18nType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Bot I18nType = "bot"
|
Bot I18nType = "bot" // Bot interface type
|
||||||
Web I18nType = "web"
|
Web I18nType = "web" // Web interface type
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SettingService interface defines methods for accessing locale settings.
|
||||||
type SettingService interface {
|
type SettingService interface {
|
||||||
GetTgLang() (string, error)
|
GetTgLang() (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitLocalizer initializes the internationalization system with embedded translation files.
|
||||||
func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
|
func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
|
||||||
// set default bundle to english
|
// set default bundle to english
|
||||||
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
||||||
@@ -49,10 +54,11 @@ func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTemplateData(params []string, seperator ...string) map[string]any {
|
// createTemplateData creates a template data map from parameters with optional separator.
|
||||||
|
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)
|
||||||
@@ -64,6 +70,9 @@ func createTemplateData(params []string, seperator ...string) map[string]any {
|
|||||||
return templateData
|
return templateData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// I18n retrieves a localized message for the given key and type.
|
||||||
|
// It supports both bot and web contexts, with optional template parameters.
|
||||||
|
// Returns the localized message or an empty string if localization fails.
|
||||||
func I18n(i18nType I18nType, key string, params ...string) string {
|
func I18n(i18nType I18nType, key string, params ...string) string {
|
||||||
var localizer *i18n.Localizer
|
var localizer *i18n.Localizer
|
||||||
|
|
||||||
@@ -96,6 +105,7 @@ func I18n(i18nType I18nType, key string, params ...string) string {
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initTGBotLocalizer initializes the bot localizer with the configured language.
|
||||||
func initTGBotLocalizer(settingService SettingService) error {
|
func initTGBotLocalizer(settingService SettingService) error {
|
||||||
botLang, err := settingService.GetTgLang()
|
botLang, err := settingService.GetTgLang()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -106,6 +116,10 @@ func initTGBotLocalizer(settingService SettingService) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LocalizerMiddleware returns a Gin middleware that sets up localization for web requests.
|
||||||
|
// It determines the user's language from cookies or Accept-Language header,
|
||||||
|
// creates a localizer instance, and stores it in the Gin context for use in handlers.
|
||||||
|
// Also provides the I18n function in the context for template rendering.
|
||||||
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
|
// Ensure bundle is initialized so creating a Localizer won't panic
|
||||||
@@ -152,6 +166,7 @@ func loadTranslationsFromDisk(bundle *i18n.Bundle) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseTranslationFiles parses embedded translation files and adds them to the i18n bundle.
|
||||||
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 {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Package middleware provides HTTP middleware functions for the 3x-ui web panel,
|
||||||
|
// including domain validation and URL redirection utilities.
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,6 +10,10 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DomainValidatorMiddleware returns a Gin middleware that validates the request domain.
|
||||||
|
// It extracts the host from the request, strips any port number, and compares it
|
||||||
|
// against the configured domain. Requests from unauthorized domains are rejected
|
||||||
|
// with HTTP 403 Forbidden status.
|
||||||
func DomainValidatorMiddleware(domain string) gin.HandlerFunc {
|
func DomainValidatorMiddleware(domain string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
host := c.Request.Host
|
host := c.Request.Host
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RedirectMiddleware returns a Gin middleware that handles URL redirections.
|
||||||
|
// It provides backward compatibility by redirecting old '/xui' paths to new '/panel' paths,
|
||||||
|
// including API endpoints. The middleware performs permanent redirects (301) for SEO purposes.
|
||||||
func RedirectMiddleware(basePath string) gin.HandlerFunc {
|
func RedirectMiddleware(basePath string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Redirect from old '/xui' path to '/panel'
|
// Redirect from old '/xui' path to '/panel'
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Package network provides network utilities for the 3x-ui web panel,
|
||||||
|
// including automatic HTTP to HTTPS redirection functionality.
|
||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,6 +11,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AutoHttpsConn wraps a net.Conn to provide automatic HTTP to HTTPS redirection.
|
||||||
|
// It intercepts the first read to detect HTTP requests and responds with a 307 redirect
|
||||||
|
// to the HTTPS equivalent URL. Subsequent reads work normally for HTTPS connections.
|
||||||
type AutoHttpsConn struct {
|
type AutoHttpsConn struct {
|
||||||
net.Conn
|
net.Conn
|
||||||
|
|
||||||
@@ -18,6 +23,8 @@ type AutoHttpsConn struct {
|
|||||||
readRequestOnce sync.Once
|
readRequestOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewAutoHttpsConn creates a new AutoHttpsConn that wraps the given connection.
|
||||||
|
// It enables automatic redirection of HTTP requests to HTTPS.
|
||||||
func NewAutoHttpsConn(conn net.Conn) net.Conn {
|
func NewAutoHttpsConn(conn net.Conn) net.Conn {
|
||||||
return &AutoHttpsConn{
|
return &AutoHttpsConn{
|
||||||
Conn: conn,
|
Conn: conn,
|
||||||
@@ -49,6 +56,9 @@ func (c *AutoHttpsConn) readRequest() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read implements the net.Conn Read method with automatic HTTPS redirection.
|
||||||
|
// On the first read, it checks if the request is HTTP and redirects to HTTPS if so.
|
||||||
|
// Subsequent reads work normally.
|
||||||
func (c *AutoHttpsConn) Read(buf []byte) (int, error) {
|
func (c *AutoHttpsConn) Read(buf []byte) (int, error) {
|
||||||
c.readRequestOnce.Do(func() {
|
c.readRequestOnce.Do(func() {
|
||||||
c.readRequest()
|
c.readRequest()
|
||||||
|
|||||||
@@ -2,16 +2,22 @@ package network
|
|||||||
|
|
||||||
import "net"
|
import "net"
|
||||||
|
|
||||||
|
// AutoHttpsListener wraps a net.Listener to provide automatic HTTPS redirection.
|
||||||
|
// It returns AutoHttpsConn connections that handle HTTP to HTTPS redirection.
|
||||||
type AutoHttpsListener struct {
|
type AutoHttpsListener struct {
|
||||||
net.Listener
|
net.Listener
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewAutoHttpsListener creates a new AutoHttpsListener that wraps the given listener.
|
||||||
|
// It enables automatic redirection of HTTP requests to HTTPS for all accepted connections.
|
||||||
func NewAutoHttpsListener(listener net.Listener) net.Listener {
|
func NewAutoHttpsListener(listener net.Listener) net.Listener {
|
||||||
return &AutoHttpsListener{
|
return &AutoHttpsListener{
|
||||||
Listener: listener,
|
Listener: listener,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accept implements the net.Listener Accept method.
|
||||||
|
// It accepts connections and wraps them with AutoHttpsConn for HTTPS redirection.
|
||||||
func (l *AutoHttpsListener) Accept() (net.Conn, error) {
|
func (l *AutoHttpsListener) Accept() (net.Conn, error) {
|
||||||
conn, err := l.Listener.Accept()
|
conn, err := l.Listener.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Package service provides business logic services for the 3x-ui web panel,
|
||||||
|
// including inbound/outbound management, user administration, settings, and Xray integration.
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,19 +10,24 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// InboundService provides business logic for managing Xray inbound configurations.
|
||||||
|
// It handles CRUD operations for inbounds, client management, traffic monitoring,
|
||||||
|
// and integration with the Xray API for real-time updates.
|
||||||
type InboundService struct {
|
type InboundService struct {
|
||||||
xrayApi xray.XrayAPI
|
xrayApi xray.XrayAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetInbounds retrieves all inbounds for a specific user.
|
||||||
|
// Returns a slice of inbound models with their associated client statistics.
|
||||||
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
|
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var inbounds []*model.Inbound
|
var inbounds []*model.Inbound
|
||||||
@@ -28,9 +35,30 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
|
|||||||
if err != nil && err != gorm.ErrRecordNotFound {
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Enrich client stats with UUID/SubId from inbound settings
|
||||||
|
for _, inbound := range inbounds {
|
||||||
|
clients, _ := s.GetClients(inbound)
|
||||||
|
if len(clients) == 0 || len(inbound.ClientStats) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Build a map email -> client
|
||||||
|
cMap := make(map[string]model.Client, len(clients))
|
||||||
|
for _, c := range clients {
|
||||||
|
cMap[strings.ToLower(c.Email)] = c
|
||||||
|
}
|
||||||
|
for i := range inbound.ClientStats {
|
||||||
|
email := strings.ToLower(inbound.ClientStats[i].Email)
|
||||||
|
if c, ok := cMap[email]; ok {
|
||||||
|
inbound.ClientStats[i].UUID = c.ID
|
||||||
|
inbound.ClientStats[i].SubId = c.SubID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return inbounds, nil
|
return inbounds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllInbounds retrieves all inbounds from the database.
|
||||||
|
// Returns a slice of all inbound models with their associated client statistics.
|
||||||
func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
|
func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var inbounds []*model.Inbound
|
var inbounds []*model.Inbound
|
||||||
@@ -38,6 +66,34 @@ func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
|
|||||||
if err != nil && err != gorm.ErrRecordNotFound {
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Enrich client stats with UUID/SubId from inbound settings
|
||||||
|
for _, inbound := range inbounds {
|
||||||
|
clients, _ := s.GetClients(inbound)
|
||||||
|
if len(clients) == 0 || len(inbound.ClientStats) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cMap := make(map[string]model.Client, len(clients))
|
||||||
|
for _, c := range clients {
|
||||||
|
cMap[strings.ToLower(c.Email)] = c
|
||||||
|
}
|
||||||
|
for i := range inbound.ClientStats {
|
||||||
|
email := strings.ToLower(inbound.ClientStats[i].Email)
|
||||||
|
if c, ok := cMap[email]; ok {
|
||||||
|
inbound.ClientStats[i].UUID = c.ID
|
||||||
|
inbound.ClientStats[i].SubId = c.SubID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
return inbounds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +209,10 @@ func (s *InboundService) checkEmailExistForInbound(inbound *model.Inbound) (stri
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddInbound creates a new inbound configuration.
|
||||||
|
// It validates port uniqueness, client email uniqueness, and required fields,
|
||||||
|
// then saves the inbound to the database and optionally adds it to the running Xray instance.
|
||||||
|
// Returns the created inbound, whether Xray needs restart, and any error.
|
||||||
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||||
exist, err := s.checkPortExist(inbound.Listen, inbound.Port, 0)
|
exist, err := s.checkPortExist(inbound.Listen, inbound.Port, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -259,6 +319,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
|||||||
return inbound, needRestart, err
|
return inbound, needRestart, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DelInbound deletes an inbound configuration by ID.
|
||||||
|
// It removes the inbound from the database and the running Xray instance if active.
|
||||||
|
// Returns whether Xray needs restart and any error.
|
||||||
func (s *InboundService) DelInbound(id int) (bool, error) {
|
func (s *InboundService) DelInbound(id int) (bool, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
@@ -312,6 +375,9 @@ func (s *InboundService) GetInbound(id int) (*model.Inbound, error) {
|
|||||||
return inbound, nil
|
return inbound, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateInbound modifies an existing inbound configuration.
|
||||||
|
// It validates changes, updates the database, and syncs with the running Xray instance.
|
||||||
|
// Returns the updated inbound, whether Xray needs restart, and any error.
|
||||||
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||||
exist, err := s.checkPortExist(inbound.Listen, inbound.Port, inbound.Id)
|
exist, err := s.checkPortExist(inbound.Listen, inbound.Port, inbound.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -409,6 +475,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
|
||||||
@@ -698,6 +765,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
|
||||||
@@ -1260,7 +1328,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
|
||||||
@@ -1273,7 +1341,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,
|
||||||
@@ -1684,6 +1752,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})
|
||||||
@@ -1692,6 +1761,7 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1759,20 +1829,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 {
|
||||||
@@ -1804,8 +1893,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
|
||||||
}
|
}
|
||||||
@@ -1856,7 +1951,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
|
||||||
}
|
}
|
||||||
@@ -1900,22 +1996,31 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate UUID and other client data for each traffic record
|
||||||
|
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].UUID = client.ID
|
||||||
|
traffics[i].SubId = client.SubID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return traffics, nil
|
return traffics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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.UUID = client.ID
|
||||||
|
t.SubId = client.SubID
|
||||||
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1950,6 +2055,14 @@ 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].UUID = client.ID
|
||||||
|
traffics[i].SubId = client.SubID
|
||||||
|
}
|
||||||
|
}
|
||||||
return traffics, err
|
return traffics, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2045,6 +2158,9 @@ func (s *InboundService) MigrationRequirements() {
|
|||||||
defer func() {
|
defer func() {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
tx.Commit()
|
tx.Commit()
|
||||||
|
if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil {
|
||||||
|
logger.Warningf("VACUUM failed: %v", dbErr)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// OutboundService provides business logic for managing Xray outbound configurations.
|
||||||
|
// It handles outbound traffic monitoring and statistics.
|
||||||
type OutboundService struct{}
|
type OutboundService struct{}
|
||||||
|
|
||||||
func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
|
func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"x-ui/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PanelService provides business logic for panel management operations.
|
||||||
|
// It handles panel restart, updates, and system-level panel controls.
|
||||||
type PanelService struct{}
|
type PanelService struct{}
|
||||||
|
|
||||||
func (s *PanelService) RestartPanel(delay time.Duration) error {
|
func (s *PanelService) RestartPanel(delay time.Duration) error {
|
||||||
|
|||||||
@@ -13,17 +13,19 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"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"
|
||||||
@@ -34,14 +36,18 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/net"
|
"github.com/shirou/gopsutil/v4/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ProcessState represents the current state of a system process.
|
||||||
type ProcessState string
|
type ProcessState string
|
||||||
|
|
||||||
|
// Process state constants
|
||||||
const (
|
const (
|
||||||
Running ProcessState = "running"
|
Running ProcessState = "running" // Process is running normally
|
||||||
Stop ProcessState = "stop"
|
Stop ProcessState = "stop" // Process is stopped
|
||||||
Error ProcessState = "error"
|
Error ProcessState = "error" // Process is in error state
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Status represents comprehensive system and application status information.
|
||||||
|
// It includes CPU, memory, disk, network statistics, and Xray process status.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
T time.Time `json:"-"`
|
T time.Time `json:"-"`
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
@@ -88,16 +94,102 @@ type Status struct {
|
|||||||
} `json:"appStats"`
|
} `json:"appStats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Release represents information about a software release from GitHub.
|
||||||
type Release struct {
|
type Release struct {
|
||||||
TagName string `json:"tag_name"`
|
TagName string `json:"tag_name"` // The tag name of the release
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServerService provides business logic for server monitoring and management.
|
||||||
|
// It handles system status collection, IP detection, and application statistics.
|
||||||
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 +231,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 +245,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 +416,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"
|
||||||
@@ -492,14 +698,39 @@ func (s *ServerService) GetLogs(count string, level string, syslog string) []str
|
|||||||
var lines []string
|
var lines []string
|
||||||
|
|
||||||
if syslog == "true" {
|
if syslog == "true" {
|
||||||
cmdArgs := []string{"journalctl", "-u", "x-ui", "--no-pager", "-n", count, "-p", level}
|
// Check if running on Windows - journalctl is not available
|
||||||
// Run the command
|
if runtime.GOOS == "windows" {
|
||||||
cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...)
|
return []string{"Syslog is not supported on Windows. Please use application logs instead by unchecking the 'Syslog' option."}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and sanitize count parameter
|
||||||
|
countInt, err := strconv.Atoi(count)
|
||||||
|
if err != nil || countInt < 1 || countInt > 10000 {
|
||||||
|
return []string{"Invalid count parameter - must be a number between 1 and 10000"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate level parameter - only allow valid syslog levels
|
||||||
|
validLevels := map[string]bool{
|
||||||
|
"0": true, "emerg": true,
|
||||||
|
"1": true, "alert": true,
|
||||||
|
"2": true, "crit": true,
|
||||||
|
"3": true, "err": true,
|
||||||
|
"4": true, "warning": true,
|
||||||
|
"5": true, "notice": true,
|
||||||
|
"6": true, "info": true,
|
||||||
|
"7": true, "debug": true,
|
||||||
|
}
|
||||||
|
if !validLevels[level] {
|
||||||
|
return []string{"Invalid level parameter - must be a valid syslog level"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use hardcoded command with validated parameters
|
||||||
|
cmd := exec.Command("journalctl", "-u", "x-ui", "--no-pager", "-n", strconv.Itoa(countInt), "-p", level)
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
err := cmd.Run()
|
err = cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return []string{"Failed to run journalctl command!"}
|
return []string{"Failed to run journalctl command! Make sure systemd is available and x-ui service is registered."}
|
||||||
}
|
}
|
||||||
lines = strings.Split(out.String(), "\n")
|
lines = strings.Split(out.String(), "\n")
|
||||||
} else {
|
} else {
|
||||||
@@ -516,19 +747,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 +784,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -735,6 +997,35 @@ func (s *ServerService) ImportDB(file multipart.File) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsValidGeofileName validates that the filename is safe for geofile operations.
|
||||||
|
// It checks for path traversal attempts and ensures the filename contains only safe characters.
|
||||||
|
func (s *ServerService) IsValidGeofileName(filename string) bool {
|
||||||
|
if filename == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for path traversal attempts
|
||||||
|
if strings.Contains(filename, "..") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for path separators (both forward and backward slash)
|
||||||
|
if strings.ContainsAny(filename, `/\`) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for absolute path indicators
|
||||||
|
if filepath.IsAbs(filename) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional security: only allow alphanumeric, dots, underscores, and hyphens
|
||||||
|
// This is stricter than the general filename regex
|
||||||
|
validGeofilePattern := `^[a-zA-Z0-9._-]+\.dat$`
|
||||||
|
matched, _ := regexp.MatchString(validGeofilePattern, filename)
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ServerService) UpdateGeofile(fileName string) error {
|
func (s *ServerService) UpdateGeofile(fileName string) error {
|
||||||
files := []struct {
|
files := []struct {
|
||||||
URL string
|
URL string
|
||||||
@@ -748,6 +1039,25 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
|
|||||||
{"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"},
|
{"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strict allowlist check to avoid writing uncontrolled files
|
||||||
|
if fileName != "" {
|
||||||
|
// Use the centralized validation function
|
||||||
|
if !s.IsValidGeofileName(fileName) {
|
||||||
|
return common.NewErrorf("Invalid geofile name: contains unsafe path characters: %s", fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the filename matches exactly one from our allowlist
|
||||||
|
isAllowed := false
|
||||||
|
for _, file := range files {
|
||||||
|
if fileName == file.FileName {
|
||||||
|
isAllowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isAllowed {
|
||||||
|
return common.NewErrorf("Invalid geofile name: %s not in allowlist", fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
downloadFile := func(url, destPath string) error {
|
downloadFile := func(url, destPath string) error {
|
||||||
resp, err := http.Get(url)
|
resp, err := http.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -773,14 +1083,17 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
|
|||||||
|
|
||||||
if fileName == "" {
|
if fileName == "" {
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
destPath := fmt.Sprintf("%s/%s", config.GetBinFolderPath(), file.FileName)
|
// Sanitize the filename from our allowlist as an extra precaution
|
||||||
|
destPath := filepath.Join(config.GetBinFolderPath(), filepath.Base(file.FileName))
|
||||||
|
|
||||||
if err := downloadFile(file.URL, destPath); err != nil {
|
if err := downloadFile(file.URL, destPath); err != nil {
|
||||||
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", file.FileName, err))
|
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", file.FileName, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
destPath := fmt.Sprintf("%s/%s", config.GetBinFolderPath(), fileName)
|
// Use filepath.Base to ensure we only get the filename component, no path traversal
|
||||||
|
safeName := filepath.Base(fileName)
|
||||||
|
destPath := filepath.Join(config.GetBinFolderPath(), safeName)
|
||||||
|
|
||||||
var fileURL string
|
var fileURL string
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
@@ -792,10 +1105,10 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
|
|||||||
|
|
||||||
if fileURL == "" {
|
if fileURL == "" {
|
||||||
errorMessages = append(errorMessages, fmt.Sprintf("File '%s' not found in the list of Geofiles", fileName))
|
errorMessages = append(errorMessages, fmt.Sprintf("File '%s' not found in the list of Geofiles", fileName))
|
||||||
}
|
} else {
|
||||||
|
if err := downloadFile(fileURL, destPath); err != nil {
|
||||||
if err := downloadFile(fileURL, destPath); err != nil {
|
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", fileName, err))
|
||||||
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", fileName, err))
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -33,7 +33,7 @@ var defaultValueMap = map[string]string{
|
|||||||
"secret": random.Seq(32),
|
"secret": random.Seq(32),
|
||||||
"webBasePath": "/",
|
"webBasePath": "/",
|
||||||
"sessionMaxAge": "360",
|
"sessionMaxAge": "360",
|
||||||
"pageSize": "50",
|
"pageSize": "25",
|
||||||
"expireDiff": "0",
|
"expireDiff": "0",
|
||||||
"trafficDiff": "0",
|
"trafficDiff": "0",
|
||||||
"remarkModel": "-ieo",
|
"remarkModel": "-ieo",
|
||||||
@@ -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",
|
||||||
@@ -74,6 +75,8 @@ var defaultValueMap = map[string]string{
|
|||||||
"externalTrafficInformURI": "",
|
"externalTrafficInformURI": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SettingService provides business logic for application settings management.
|
||||||
|
// It handles configuration storage, retrieval, and validation for all system settings.
|
||||||
type SettingService struct{}
|
type SettingService struct{}
|
||||||
|
|
||||||
func (s *SettingService) GetDefaultJsonConfig() (any, error) {
|
func (s *SettingService) GetDefaultJsonConfig() (any, error) {
|
||||||
@@ -427,6 +430,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")
|
||||||
}
|
}
|
||||||
@@ -575,6 +582,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() },
|
||||||
@@ -593,7 +601,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()
|
||||||
@@ -619,13 +634,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,17 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"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/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"
|
||||||
@@ -44,6 +45,23 @@ var (
|
|||||||
hostname string
|
hostname string
|
||||||
hashStorage *global.HashStorage
|
hashStorage *global.HashStorage
|
||||||
|
|
||||||
|
// Performance improvements
|
||||||
|
messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing
|
||||||
|
optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts
|
||||||
|
|
||||||
|
// Simple cache for frequently accessed data
|
||||||
|
statusCache struct {
|
||||||
|
data *Status
|
||||||
|
timestamp time.Time
|
||||||
|
mutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
serverStatsCache struct {
|
||||||
|
data string
|
||||||
|
timestamp time.Time
|
||||||
|
mutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
// clients data to adding new client
|
// clients data to adding new client
|
||||||
receiver_inbound_ID int
|
receiver_inbound_ID int
|
||||||
client_Id string
|
client_Id string
|
||||||
@@ -65,14 +83,18 @@ var (
|
|||||||
|
|
||||||
var userStates = make(map[int64]string)
|
var userStates = make(map[int64]string)
|
||||||
|
|
||||||
|
// LoginStatus represents the result of a login attempt.
|
||||||
type LoginStatus byte
|
type LoginStatus byte
|
||||||
|
|
||||||
|
// Login status constants
|
||||||
const (
|
const (
|
||||||
LoginSuccess LoginStatus = 1
|
LoginSuccess LoginStatus = 1 // Login was successful
|
||||||
LoginFail LoginStatus = 0
|
LoginFail LoginStatus = 0 // Login failed
|
||||||
EmptyTelegramUserID = int64(0)
|
EmptyTelegramUserID = int64(0) // Default value for empty Telegram user ID
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Tgbot provides business logic for Telegram bot integration.
|
||||||
|
// It handles bot commands, user interactions, and status reporting via Telegram.
|
||||||
type Tgbot struct {
|
type Tgbot struct {
|
||||||
inboundService InboundService
|
inboundService InboundService
|
||||||
settingService SettingService
|
settingService SettingService
|
||||||
@@ -81,18 +103,62 @@ type Tgbot struct {
|
|||||||
lastStatus *Status
|
lastStatus *Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewTgbot creates a new Tgbot instance.
|
||||||
func (t *Tgbot) NewTgbot() *Tgbot {
|
func (t *Tgbot) NewTgbot() *Tgbot {
|
||||||
return new(Tgbot)
|
return new(Tgbot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// I18nBot retrieves a localized message for the bot interface.
|
||||||
func (t *Tgbot) I18nBot(name string, params ...string) string {
|
func (t *Tgbot) I18nBot(name string, params ...string) string {
|
||||||
return locale.I18n(locale.Bot, name, params...)
|
return locale.I18n(locale.Bot, name, params...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetHashStorage returns the hash storage instance for callback queries.
|
||||||
func (t *Tgbot) GetHashStorage() *global.HashStorage {
|
func (t *Tgbot) GetHashStorage() *global.HashStorage {
|
||||||
return hashStorage
|
return hashStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getCachedStatus returns cached server status if it's fresh enough (less than 5 seconds old)
|
||||||
|
func (t *Tgbot) getCachedStatus() (*Status, bool) {
|
||||||
|
statusCache.mutex.RLock()
|
||||||
|
defer statusCache.mutex.RUnlock()
|
||||||
|
|
||||||
|
if statusCache.data != nil && time.Since(statusCache.timestamp) < 5*time.Second {
|
||||||
|
return statusCache.data, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCachedStatus updates the status cache
|
||||||
|
func (t *Tgbot) setCachedStatus(status *Status) {
|
||||||
|
statusCache.mutex.Lock()
|
||||||
|
defer statusCache.mutex.Unlock()
|
||||||
|
|
||||||
|
statusCache.data = status
|
||||||
|
statusCache.timestamp = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCachedServerStats returns cached server stats if it's fresh enough (less than 10 seconds old)
|
||||||
|
func (t *Tgbot) getCachedServerStats() (string, bool) {
|
||||||
|
serverStatsCache.mutex.RLock()
|
||||||
|
defer serverStatsCache.mutex.RUnlock()
|
||||||
|
|
||||||
|
if serverStatsCache.data != "" && time.Since(serverStatsCache.timestamp) < 10*time.Second {
|
||||||
|
return serverStatsCache.data, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCachedServerStats updates the server stats cache
|
||||||
|
func (t *Tgbot) setCachedServerStats(stats string) {
|
||||||
|
serverStatsCache.mutex.Lock()
|
||||||
|
defer serverStatsCache.mutex.Unlock()
|
||||||
|
|
||||||
|
serverStatsCache.data = stats
|
||||||
|
serverStatsCache.timestamp = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initializes and starts the Telegram bot with the provided translation files.
|
||||||
func (t *Tgbot) Start(i18nFS embed.FS) error {
|
func (t *Tgbot) Start(i18nFS embed.FS) error {
|
||||||
// Initialize localizer
|
// Initialize localizer
|
||||||
err := locale.InitLocalizer(i18nFS, &t.settingService)
|
err := locale.InitLocalizer(i18nFS, &t.settingService)
|
||||||
@@ -103,6 +169,20 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
|
|||||||
// Initialize hash storage to store callback queries
|
// Initialize hash storage to store callback queries
|
||||||
hashStorage = global.NewHashStorage(20 * time.Minute)
|
hashStorage = global.NewHashStorage(20 * time.Minute)
|
||||||
|
|
||||||
|
// Initialize worker pool for concurrent message processing (max 10 concurrent handlers)
|
||||||
|
messageWorkerPool = make(chan struct{}, 10)
|
||||||
|
|
||||||
|
// Initialize optimized HTTP client with connection pooling
|
||||||
|
optimizedHTTPClient = &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 10,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
DisableKeepAlives: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
t.SetHostname()
|
t.SetHostname()
|
||||||
|
|
||||||
// Get Telegram bot token
|
// Get Telegram bot token
|
||||||
@@ -173,6 +253,7 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewBot creates a new Telegram bot instance with optional proxy and API server settings.
|
||||||
func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) {
|
func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) {
|
||||||
if proxyUrl == "" && apiServerUrl == "" {
|
if proxyUrl == "" && apiServerUrl == "" {
|
||||||
return telego.NewBot(token)
|
return telego.NewBot(token)
|
||||||
@@ -209,10 +290,12 @@ func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*tel
|
|||||||
return telego.NewBot(token, telego.WithAPIServer(apiServerUrl))
|
return telego.NewBot(token, telego.WithAPIServer(apiServerUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsRunning checks if the Telegram bot is currently running.
|
||||||
func (t *Tgbot) IsRunning() bool {
|
func (t *Tgbot) IsRunning() bool {
|
||||||
return isRunning
|
return isRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetHostname sets the hostname for the bot.
|
||||||
func (t *Tgbot) SetHostname() {
|
func (t *Tgbot) SetHostname() {
|
||||||
host, err := os.Hostname()
|
host, err := os.Hostname()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -223,6 +306,7 @@ func (t *Tgbot) SetHostname() {
|
|||||||
hostname = host
|
hostname = host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop stops the Telegram bot and cleans up resources.
|
||||||
func (t *Tgbot) Stop() {
|
func (t *Tgbot) Stop() {
|
||||||
if botHandler != nil {
|
if botHandler != nil {
|
||||||
botHandler.Stop()
|
botHandler.Stop()
|
||||||
@@ -232,6 +316,7 @@ func (t *Tgbot) Stop() {
|
|||||||
adminIds = nil
|
adminIds = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeQuery encodes the query string if it's longer than 64 characters.
|
||||||
func (t *Tgbot) encodeQuery(query string) string {
|
func (t *Tgbot) encodeQuery(query string) string {
|
||||||
// NOTE: we only need to hash for more than 64 chars
|
// NOTE: we only need to hash for more than 64 chars
|
||||||
if len(query) <= 64 {
|
if len(query) <= 64 {
|
||||||
@@ -241,6 +326,7 @@ func (t *Tgbot) encodeQuery(query string) string {
|
|||||||
return hashStorage.SaveHash(query)
|
return hashStorage.SaveHash(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeQuery decodes a hashed query string back to its original form.
|
||||||
func (t *Tgbot) decodeQuery(query string) (string, error) {
|
func (t *Tgbot) decodeQuery(query string) (string, error) {
|
||||||
if !hashStorage.IsMD5(query) {
|
if !hashStorage.IsMD5(query) {
|
||||||
return query, nil
|
return query, nil
|
||||||
@@ -254,9 +340,10 @@ func (t *Tgbot) decodeQuery(query string) (string, error) {
|
|||||||
return decoded, nil
|
return decoded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnReceive starts the message receiving loop for the Telegram bot.
|
||||||
func (t *Tgbot) OnReceive() {
|
func (t *Tgbot) OnReceive() {
|
||||||
params := telego.GetUpdatesParams{
|
params := telego.GetUpdatesParams{
|
||||||
Timeout: 10,
|
Timeout: 30, // Increased timeout to reduce API calls
|
||||||
}
|
}
|
||||||
|
|
||||||
updates, _ := bot.UpdatesViaLongPolling(context.Background(), ¶ms)
|
updates, _ := bot.UpdatesViaLongPolling(context.Background(), ¶ms)
|
||||||
@@ -270,14 +357,26 @@ func (t *Tgbot) OnReceive() {
|
|||||||
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
|
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
|
||||||
|
|
||||||
botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||||
delete(userStates, message.Chat.ID)
|
// Use goroutine with worker pool for concurrent command processing
|
||||||
t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID))
|
go func() {
|
||||||
|
messageWorkerPool <- struct{}{} // Acquire worker
|
||||||
|
defer func() { <-messageWorkerPool }() // Release worker
|
||||||
|
|
||||||
|
delete(userStates, message.Chat.ID)
|
||||||
|
t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID))
|
||||||
|
}()
|
||||||
return nil
|
return nil
|
||||||
}, th.AnyCommand())
|
}, th.AnyCommand())
|
||||||
|
|
||||||
botHandler.HandleCallbackQuery(func(ctx *th.Context, query telego.CallbackQuery) error {
|
botHandler.HandleCallbackQuery(func(ctx *th.Context, query telego.CallbackQuery) error {
|
||||||
delete(userStates, query.Message.GetChat().ID)
|
// Use goroutine with worker pool for concurrent callback processing
|
||||||
t.answerCallback(&query, checkAdmin(query.From.ID))
|
go func() {
|
||||||
|
messageWorkerPool <- struct{}{} // Acquire worker
|
||||||
|
defer func() { <-messageWorkerPool }() // Release worker
|
||||||
|
|
||||||
|
delete(userStates, query.Message.GetChat().ID)
|
||||||
|
t.answerCallback(&query, checkAdmin(query.From.ID))
|
||||||
|
}()
|
||||||
return nil
|
return nil
|
||||||
}, th.AnyCallbackQueryWithMessage())
|
}, th.AnyCallbackQueryWithMessage())
|
||||||
|
|
||||||
@@ -430,6 +529,7 @@ func (t *Tgbot) OnReceive() {
|
|||||||
botHandler.Start()
|
botHandler.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// answerCommand processes incoming command messages from Telegram users.
|
||||||
func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin bool) {
|
func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin bool) {
|
||||||
msg, onlyMessage := "", false
|
msg, onlyMessage := "", false
|
||||||
|
|
||||||
@@ -505,7 +605,7 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to send the message based on onlyMessage flag.
|
// sendResponse sends the response message based on the onlyMessage flag.
|
||||||
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
|
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
|
||||||
if onlyMessage {
|
if onlyMessage {
|
||||||
t.SendMsgToTgbot(chatId, msg)
|
t.SendMsgToTgbot(chatId, msg)
|
||||||
@@ -514,6 +614,7 @@ func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// randomLowerAndNum generates a random string of lowercase letters and numbers.
|
||||||
func (t *Tgbot) randomLowerAndNum(length int) string {
|
func (t *Tgbot) randomLowerAndNum(length int) string {
|
||||||
charset := "abcdefghijklmnopqrstuvwxyz0123456789"
|
charset := "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
bytes := make([]byte, length)
|
bytes := make([]byte, length)
|
||||||
@@ -524,6 +625,7 @@ func (t *Tgbot) randomLowerAndNum(length int) string {
|
|||||||
return string(bytes)
|
return string(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// randomShadowSocksPassword generates a random password for Shadowsocks.
|
||||||
func (t *Tgbot) randomShadowSocksPassword() string {
|
func (t *Tgbot) randomShadowSocksPassword() string {
|
||||||
array := make([]byte, 32)
|
array := make([]byte, 32)
|
||||||
_, err := rand.Read(array)
|
_, err := rand.Read(array)
|
||||||
@@ -533,6 +635,7 @@ func (t *Tgbot) randomShadowSocksPassword() string {
|
|||||||
return base64.StdEncoding.EncodeToString(array)
|
return base64.StdEncoding.EncodeToString(array)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// answerCallback processes callback queries from inline keyboards.
|
||||||
func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool) {
|
func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool) {
|
||||||
chatId := callbackQuery.Message.GetChat().ID
|
chatId := callbackQuery.Message.GetChat().ID
|
||||||
|
|
||||||
@@ -548,6 +651,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)
|
||||||
@@ -805,7 +959,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 {
|
||||||
@@ -909,7 +1063,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)
|
||||||
@@ -1327,6 +1481,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1509,23 +1684,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
|||||||
)
|
)
|
||||||
prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+client_Comment)
|
prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+client_Comment)
|
||||||
t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
|
t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
|
||||||
default:
|
|
||||||
// dynamic callbacks
|
|
||||||
if strings.HasPrefix(callbackQuery.Data, "client_sub_links ") {
|
|
||||||
email := strings.TrimPrefix(callbackQuery.Data, "client_sub_links ")
|
|
||||||
t.sendClientSubLinks(chatId, email)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(callbackQuery.Data, "client_individual_links ") {
|
|
||||||
email := strings.TrimPrefix(callbackQuery.Data, "client_individual_links ")
|
|
||||||
t.sendClientIndividualLinks(chatId, email)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(callbackQuery.Data, "client_qr_links ") {
|
|
||||||
email := strings.TrimPrefix(callbackQuery.Data, "client_qr_links ")
|
|
||||||
t.sendClientQRLinks(chatId, email)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "add_client_ch_default_traffic":
|
case "add_client_ch_default_traffic":
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
inlineKeyboard := tu.InlineKeyboard(
|
||||||
tu.InlineKeyboardRow(
|
tu.InlineKeyboardRow(
|
||||||
@@ -1741,9 +1899,26 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildInboundClientDataMessage builds a message with client data for the given inbound and protocol.
|
||||||
func (t *Tgbot) BuildInboundClientDataMessage(inbound_remark string, protocol model.Protocol) (string, error) {
|
func (t *Tgbot) BuildInboundClientDataMessage(inbound_remark string, protocol model.Protocol) (string, error) {
|
||||||
var message string
|
var message string
|
||||||
|
|
||||||
@@ -1793,6 +1968,7 @@ func (t *Tgbot) BuildInboundClientDataMessage(inbound_remark string, protocol mo
|
|||||||
return message, nil
|
return message, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildJSONForProtocol builds a JSON string for the given protocol with client data.
|
||||||
func (t *Tgbot) BuildJSONForProtocol(protocol model.Protocol) (string, error) {
|
func (t *Tgbot) BuildJSONForProtocol(protocol model.Protocol) (string, error) {
|
||||||
var jsonString string
|
var jsonString string
|
||||||
|
|
||||||
@@ -1871,6 +2047,7 @@ func (t *Tgbot) BuildJSONForProtocol(protocol model.Protocol) (string, error) {
|
|||||||
return jsonString, nil
|
return jsonString, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubmitAddClient submits the client addition request to the inbound service.
|
||||||
func (t *Tgbot) SubmitAddClient() (bool, error) {
|
func (t *Tgbot) SubmitAddClient() (bool, error) {
|
||||||
|
|
||||||
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
|
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||||
@@ -1893,6 +2070,7 @@ func (t *Tgbot) SubmitAddClient() (bool, error) {
|
|||||||
return t.inboundService.AddInboundClient(newInbound)
|
return t.inboundService.AddInboundClient(newInbound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkAdmin checks if the given Telegram ID is an admin.
|
||||||
func checkAdmin(tgId int64) bool {
|
func checkAdmin(tgId int64) bool {
|
||||||
for _, adminId := range adminIds {
|
for _, adminId := range adminIds {
|
||||||
if adminId == tgId {
|
if adminId == tgId {
|
||||||
@@ -1902,6 +2080,7 @@ func checkAdmin(tgId int64) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendAnswer sends a response message with an inline keyboard to the specified chat.
|
||||||
func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
|
func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
|
||||||
numericKeyboard := tu.InlineKeyboard(
|
numericKeyboard := tu.InlineKeyboard(
|
||||||
tu.InlineKeyboardRow(
|
tu.InlineKeyboardRow(
|
||||||
@@ -1927,6 +2106,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(
|
||||||
@@ -1952,6 +2136,7 @@ func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
|
|||||||
t.SendMsgToTgbot(chatId, msg, ReplyMarkup)
|
t.SendMsgToTgbot(chatId, msg, ReplyMarkup)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendMsgToTgbot sends a message to the Telegram bot with optional reply markup.
|
||||||
func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) {
|
func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) {
|
||||||
if !isRunning {
|
if !isRunning {
|
||||||
return
|
return
|
||||||
@@ -1998,7 +2183,10 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("Error sending telegram message :", err)
|
logger.Warning("Error sending telegram message :", err)
|
||||||
}
|
}
|
||||||
time.Sleep(500 * time.Millisecond)
|
// Reduced delay to improve performance (only needed for rate limiting)
|
||||||
|
if n < len(allMessages)-1 { // Only delay between messages, not after the last one
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2016,6 +2204,7 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
|
|||||||
subPort, _ := t.settingService.GetSubPort()
|
subPort, _ := t.settingService.GetSubPort()
|
||||||
subPath, _ := t.settingService.GetSubPath()
|
subPath, _ := t.settingService.GetSubPath()
|
||||||
subJsonPath, _ := t.settingService.GetSubJsonPath()
|
subJsonPath, _ := t.settingService.GetSubJsonPath()
|
||||||
|
subJsonEnable, _ := t.settingService.GetSubJsonEnable()
|
||||||
subKeyFile, _ := t.settingService.GetSubKeyFile()
|
subKeyFile, _ := t.settingService.GetSubKeyFile()
|
||||||
subCertFile, _ := t.settingService.GetSubCertFile()
|
subCertFile, _ := t.settingService.GetSubCertFile()
|
||||||
|
|
||||||
@@ -2060,20 +2249,29 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
|
|||||||
|
|
||||||
subURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
|
subURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
|
||||||
subJsonURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
|
subJsonURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
|
||||||
|
if !subJsonEnable {
|
||||||
|
subJsonURL = ""
|
||||||
|
}
|
||||||
return subURL, subJsonURL, nil
|
return subURL, subJsonURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendClientSubLinks sends the subscription links for the client to the chat.
|
||||||
func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
|
func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
|
||||||
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := "Subscription URL:\r\n<code>" + subURL + "</code>\r\n\r\n" +
|
msg := "Subscription URL:\r\n<code>" + subURL + "</code>"
|
||||||
"JSON URL:\r\n<code>" + subJsonURL + "</code>"
|
if subJsonURL != "" {
|
||||||
|
msg += "\r\n\r\nJSON URL:\r\n<code>" + subJsonURL + "</code>"
|
||||||
|
}
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
inlineKeyboard := tu.InlineKeyboard(
|
||||||
tu.InlineKeyboardRow(
|
tu.InlineKeyboardRow(
|
||||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links " + email)),
|
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)
|
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
||||||
@@ -2097,12 +2295,12 @@ func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
|
|||||||
// Force plain text to avoid HTML page; controller respects Accept header
|
// Force plain text to avoid HTML page; controller respects Accept header
|
||||||
req.Header.Set("Accept", "text/plain, */*;q=0.1")
|
req.Header.Set("Accept", "text/plain, */*;q=0.1")
|
||||||
|
|
||||||
// Use default client with reasonable timeout via context
|
// Use optimized client with connection pooling
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
req = req.WithContext(ctx)
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := optimizedHTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||||
return
|
return
|
||||||
@@ -2191,15 +2389,17 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
|||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send JSON URL QR (filename: subjson.png)
|
// Send JSON URL QR (filename: subjson.png) when available
|
||||||
if png, err := createQR(subJsonURL, 320); err == nil {
|
if subJsonURL != "" {
|
||||||
document := tu.Document(
|
if png, err := createQR(subJsonURL, 320); err == nil {
|
||||||
tu.ID(chatId),
|
document := tu.Document(
|
||||||
tu.FileFromBytes(png, "subjson.png"),
|
tu.ID(chatId),
|
||||||
)
|
tu.FileFromBytes(png, "subjson.png"),
|
||||||
_, _ = bot.SendDocument(context.Background(), document)
|
)
|
||||||
} else {
|
_, _ = bot.SendDocument(context.Background(), document)
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
} else {
|
||||||
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also generate a few individual links' QRs (first up to 5)
|
// Also generate a few individual links' QRs (first up to 5)
|
||||||
@@ -2210,7 +2410,7 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
req = req.WithContext(ctx)
|
req = req.WithContext(ctx)
|
||||||
if resp, err := http.DefaultClient.Do(req); err == nil {
|
if resp, err := optimizedHTTPClient.Do(req); err == nil {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
encoded, _ := t.settingService.GetSubEncrypt()
|
encoded, _ := t.settingService.GetSubEncrypt()
|
||||||
@@ -2243,7 +2443,10 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
|||||||
tu.FileFromBytes(png, filename),
|
tu.FileFromBytes(png, filename),
|
||||||
)
|
)
|
||||||
_, _ = bot.SendDocument(context.Background(), document)
|
_, _ = bot.SendDocument(context.Background(), document)
|
||||||
time.Sleep(200 * time.Millisecond)
|
// Reduced delay for better performance
|
||||||
|
if i < max-1 { // Only delay between documents, not after the last one
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2251,6 +2454,7 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendMsgToTgbotAdmins sends a message to all admin Telegram chats.
|
||||||
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 {
|
||||||
@@ -2263,6 +2467,7 @@ func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMark
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendReport sends a periodic report to admin chats.
|
||||||
func (t *Tgbot) SendReport() {
|
func (t *Tgbot) SendReport() {
|
||||||
runTime, err := t.settingService.GetTgbotRuntime()
|
runTime, err := t.settingService.GetTgbotRuntime()
|
||||||
if err == nil && len(runTime) > 0 {
|
if err == nil && len(runTime) > 0 {
|
||||||
@@ -2284,6 +2489,7 @@ func (t *Tgbot) SendReport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendBackupToAdmins sends a database backup to admin chats.
|
||||||
func (t *Tgbot) SendBackupToAdmins() {
|
func (t *Tgbot) SendBackupToAdmins() {
|
||||||
if !t.IsRunning() {
|
if !t.IsRunning() {
|
||||||
return
|
return
|
||||||
@@ -2293,6 +2499,7 @@ func (t *Tgbot) SendBackupToAdmins() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendExhaustedToAdmins sends notifications about exhausted clients to admins.
|
||||||
func (t *Tgbot) sendExhaustedToAdmins() {
|
func (t *Tgbot) sendExhaustedToAdmins() {
|
||||||
if !t.IsRunning() {
|
if !t.IsRunning() {
|
||||||
return
|
return
|
||||||
@@ -2302,6 +2509,7 @@ func (t *Tgbot) sendExhaustedToAdmins() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getServerUsage retrieves and formats server usage information.
|
||||||
func (t *Tgbot) getServerUsage(chatId int64, messageID ...int) string {
|
func (t *Tgbot) getServerUsage(chatId int64, messageID ...int) string {
|
||||||
info := t.prepareServerUsageInfo()
|
info := t.prepareServerUsageInfo()
|
||||||
|
|
||||||
@@ -2323,11 +2531,22 @@ func (t *Tgbot) sendServerUsage() string {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prepareServerUsageInfo prepares the server usage information string.
|
||||||
func (t *Tgbot) prepareServerUsageInfo() string {
|
func (t *Tgbot) prepareServerUsageInfo() string {
|
||||||
|
// Check if we have cached data first
|
||||||
|
if cachedStats, found := t.getCachedServerStats(); found {
|
||||||
|
return cachedStats
|
||||||
|
}
|
||||||
|
|
||||||
info, ipv4, ipv6 := "", "", ""
|
info, ipv4, ipv6 := "", "", ""
|
||||||
|
|
||||||
// get latest status of server
|
// get latest status of server with caching
|
||||||
t.lastStatus = t.serverService.GetStatus(t.lastStatus)
|
if cachedStatus, found := t.getCachedStatus(); found {
|
||||||
|
t.lastStatus = cachedStatus
|
||||||
|
} else {
|
||||||
|
t.lastStatus = t.serverService.GetStatus(t.lastStatus)
|
||||||
|
t.setCachedStatus(t.lastStatus)
|
||||||
|
}
|
||||||
onlines := p.GetOnlineClients()
|
onlines := p.GetOnlineClients()
|
||||||
|
|
||||||
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||||
@@ -2369,9 +2588,14 @@ func (t *Tgbot) prepareServerUsageInfo() string {
|
|||||||
info += t.I18nBot("tgbot.messages.udpCount", "Count=="+strconv.Itoa(t.lastStatus.UdpCount))
|
info += t.I18nBot("tgbot.messages.udpCount", "Count=="+strconv.Itoa(t.lastStatus.UdpCount))
|
||||||
info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent+t.lastStatus.NetTraffic.Recv)), "Upload=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent)), "Download=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Recv)))
|
info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent+t.lastStatus.NetTraffic.Recv)), "Upload=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent)), "Download=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Recv)))
|
||||||
info += t.I18nBot("tgbot.messages.xrayStatus", "State=="+fmt.Sprint(t.lastStatus.Xray.State))
|
info += t.I18nBot("tgbot.messages.xrayStatus", "State=="+fmt.Sprint(t.lastStatus.Xray.State))
|
||||||
|
|
||||||
|
// Cache the complete server stats
|
||||||
|
t.setCachedServerStats(info)
|
||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserLoginNotify sends a notification about user login attempts to admins.
|
||||||
func (t *Tgbot) UserLoginNotify(username string, password string, ip string, time string, status LoginStatus) {
|
func (t *Tgbot) UserLoginNotify(username string, password string, ip string, time string, status LoginStatus) {
|
||||||
if !t.IsRunning() {
|
if !t.IsRunning() {
|
||||||
return
|
return
|
||||||
@@ -2403,6 +2627,7 @@ func (t *Tgbot) UserLoginNotify(username string, password string, ip string, tim
|
|||||||
t.SendMsgToTgbotAdmins(msg)
|
t.SendMsgToTgbotAdmins(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getInboundUsages retrieves and formats inbound usage information.
|
||||||
func (t *Tgbot) getInboundUsages() string {
|
func (t *Tgbot) getInboundUsages() string {
|
||||||
info := ""
|
info := ""
|
||||||
// get traffic
|
// get traffic
|
||||||
@@ -2428,6 +2653,8 @@ func (t *Tgbot) getInboundUsages() string {
|
|||||||
}
|
}
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getInbounds creates an inline keyboard with all inbounds.
|
||||||
func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) {
|
func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) {
|
||||||
inbounds, err := t.inboundService.GetAllInbounds()
|
inbounds, err := t.inboundService.GetAllInbounds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2459,6 +2686,74 @@ func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) {
|
|||||||
return keyboard, nil
|
return keyboard, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getInboundsFor builds an inline keyboard of inbounds for a custom next action.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// getInboundsAddClient creates an inline keyboard for adding clients to inbounds.
|
||||||
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 {
|
||||||
@@ -2501,6 +2796,7 @@ func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
|
|||||||
return keyboard, nil
|
return keyboard, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getInboundClients creates an inline keyboard with clients of a specific inbound.
|
||||||
func (t *Tgbot) getInboundClients(id int) (*telego.InlineKeyboardMarkup, error) {
|
func (t *Tgbot) getInboundClients(id int) (*telego.InlineKeyboardMarkup, error) {
|
||||||
inbound, err := t.inboundService.GetInbound(id)
|
inbound, err := t.inboundService.GetInbound(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2535,6 +2831,7 @@ func (t *Tgbot) getInboundClients(id int) (*telego.InlineKeyboardMarkup, error)
|
|||||||
return keyboard, nil
|
return keyboard, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clientInfoMsg formats client information message based on traffic and flags.
|
||||||
func (t *Tgbot) clientInfoMsg(
|
func (t *Tgbot) clientInfoMsg(
|
||||||
traffic *xray.ClientTraffic,
|
traffic *xray.ClientTraffic,
|
||||||
printEnabled bool,
|
printEnabled bool,
|
||||||
@@ -2641,6 +2938,7 @@ func (t *Tgbot) clientInfoMsg(
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getClientUsage retrieves and sends client usage information to the chat.
|
||||||
func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) {
|
func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) {
|
||||||
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
|
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2683,6 +2981,7 @@ func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) {
|
|||||||
t.SendAnswer(chatId, output, false)
|
t.SendAnswer(chatId, output, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// searchClientIps searches and sends client IP addresses for the given email.
|
||||||
func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
|
func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
|
||||||
ips, err := t.inboundService.GetInboundClientIps(email)
|
ips, err := t.inboundService.GetInboundClientIps(email)
|
||||||
if err != nil || len(ips) == 0 {
|
if err != nil || len(ips) == 0 {
|
||||||
@@ -2710,6 +3009,7 @@ func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clientTelegramUserInfo retrieves and sends Telegram user info for the client.
|
||||||
func (t *Tgbot) clientTelegramUserInfo(chatId int64, email string, messageID ...int) {
|
func (t *Tgbot) clientTelegramUserInfo(chatId int64, email string, messageID ...int) {
|
||||||
traffic, client, err := t.inboundService.GetClientByEmail(email)
|
traffic, client, err := t.inboundService.GetClientByEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2762,6 +3062,7 @@ func (t *Tgbot) clientTelegramUserInfo(chatId int64, email string, messageID ...
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// searchClient searches for a client by email and sends the information.
|
||||||
func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
|
func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
|
||||||
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
|
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2807,6 +3108,7 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addClient handles the process of adding a new client to an inbound.
|
||||||
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
||||||
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
|
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2903,6 +3205,7 @@ func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// searchInbound searches for inbounds by remark and sends the results.
|
||||||
func (t *Tgbot) searchInbound(chatId int64, remark string) {
|
func (t *Tgbot) searchInbound(chatId int64, remark string) {
|
||||||
inbounds, err := t.inboundService.SearchInbounds(remark)
|
inbounds, err := t.inboundService.SearchInbounds(remark)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2940,6 +3243,7 @@ func (t *Tgbot) searchInbound(chatId int64, remark string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getExhausted retrieves and sends information about exhausted clients.
|
||||||
func (t *Tgbot) getExhausted(chatId int64) {
|
func (t *Tgbot) getExhausted(chatId int64) {
|
||||||
trDiff := int64(0)
|
trDiff := int64(0)
|
||||||
exDiff := int64(0)
|
exDiff := int64(0)
|
||||||
@@ -3036,6 +3340,7 @@ func (t *Tgbot) getExhausted(chatId int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notifyExhausted sends notifications for exhausted clients.
|
||||||
func (t *Tgbot) notifyExhausted() {
|
func (t *Tgbot) notifyExhausted() {
|
||||||
trDiff := int64(0)
|
trDiff := int64(0)
|
||||||
exDiff := int64(0)
|
exDiff := int64(0)
|
||||||
@@ -3107,6 +3412,7 @@ func (t *Tgbot) notifyExhausted() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// int64Contains checks if an int64 slice contains a specific item.
|
||||||
func int64Contains(slice []int64, item int64) bool {
|
func int64Contains(slice []int64, item int64) bool {
|
||||||
for _, s := range slice {
|
for _, s := range slice {
|
||||||
if s == item {
|
if s == item {
|
||||||
@@ -3116,6 +3422,7 @@ func int64Contains(slice []int64, item int64) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onlineClients retrieves and sends information about online clients.
|
||||||
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
|
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
|
||||||
if !p.IsRunning() {
|
if !p.IsRunning() {
|
||||||
return
|
return
|
||||||
@@ -3150,6 +3457,7 @@ func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendBackup sends a backup of the database and configuration files.
|
||||||
func (t *Tgbot) sendBackup(chatId int64) {
|
func (t *Tgbot) sendBackup(chatId int64) {
|
||||||
output := t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
output := t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||||
t.SendMsgToTgbot(chatId, output)
|
t.SendMsgToTgbot(chatId, output)
|
||||||
@@ -3189,6 +3497,7 @@ func (t *Tgbot) sendBackup(chatId int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendBanLogs sends the ban logs to the specified chat.
|
||||||
func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
|
func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
|
||||||
if dt {
|
if dt {
|
||||||
output := t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
|
output := t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||||
@@ -3238,6 +3547,7 @@ func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendCallbackAnswerTgBot answers a callback query with a message.
|
||||||
func (t *Tgbot) sendCallbackAnswerTgBot(id string, message string) {
|
func (t *Tgbot) sendCallbackAnswerTgBot(id string, message string) {
|
||||||
params := telego.AnswerCallbackQueryParams{
|
params := telego.AnswerCallbackQueryParams{
|
||||||
CallbackQueryID: id,
|
CallbackQueryID: id,
|
||||||
@@ -3248,6 +3558,7 @@ func (t *Tgbot) sendCallbackAnswerTgBot(id string, message string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// editMessageCallbackTgBot edits the reply markup of a message.
|
||||||
func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyboard *telego.InlineKeyboardMarkup) {
|
func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyboard *telego.InlineKeyboardMarkup) {
|
||||||
params := telego.EditMessageReplyMarkupParams{
|
params := telego.EditMessageReplyMarkupParams{
|
||||||
ChatID: tu.ID(chatId),
|
ChatID: tu.ID(chatId),
|
||||||
@@ -3259,6 +3570,7 @@ func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyb
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// editMessageTgBot edits the text and reply markup of a message.
|
||||||
func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlineKeyboard ...*telego.InlineKeyboardMarkup) {
|
func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlineKeyboard ...*telego.InlineKeyboardMarkup) {
|
||||||
params := telego.EditMessageTextParams{
|
params := telego.EditMessageTextParams{
|
||||||
ChatID: tu.ID(chatId),
|
ChatID: tu.ID(chatId),
|
||||||
@@ -3274,6 +3586,7 @@ func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendMsgToTgbotDeleteAfter sends a message and deletes it after a specified delay.
|
||||||
func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSeconds int, replyMarkup ...telego.ReplyMarkup) {
|
func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSeconds int, replyMarkup ...telego.ReplyMarkup) {
|
||||||
// Determine if replyMarkup was passed; otherwise, set it to nil
|
// Determine if replyMarkup was passed; otherwise, set it to nil
|
||||||
var replyMarkupParam telego.ReplyMarkup
|
var replyMarkupParam telego.ReplyMarkup
|
||||||
@@ -3300,6 +3613,7 @@ func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSecon
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteMessageTgBot deletes a message from the chat.
|
||||||
func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
|
func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
|
||||||
params := telego.DeleteMessageParams{
|
params := telego.DeleteMessageParams{
|
||||||
ChatID: tu.ID(chatId),
|
ChatID: tu.ID(chatId),
|
||||||
@@ -3312,6 +3626,7 @@ func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSingleWord checks if the text contains only a single word.
|
||||||
func (t *Tgbot) isSingleWord(text string) bool {
|
func (t *Tgbot) isSingleWord(text string) bool {
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
re := regexp.MustCompile(`\s+`)
|
re := regexp.MustCompile(`\s+`)
|
||||||
|
|||||||
@@ -3,19 +3,23 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// UserService provides business logic for user management and authentication.
|
||||||
|
// It handles user creation, login, password management, and 2FA operations.
|
||||||
type UserService struct {
|
type UserService struct {
|
||||||
settingService SettingService
|
settingService SettingService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetFirstUser retrieves the first user from the database.
|
||||||
|
// This is typically used for initial setup or when there's only one admin user.
|
||||||
func (s *UserService) GetFirstUser() (*model.User, error) {
|
func (s *UserService) GetFirstUser() (*model.User, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// WarpService provides business logic for Cloudflare WARP integration.
|
||||||
|
// It manages WARP configuration and connectivity settings.
|
||||||
type WarpService struct {
|
type WarpService struct {
|
||||||
SettingService
|
SettingService
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -20,16 +20,20 @@ var (
|
|||||||
result string
|
result string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// XrayService provides business logic for Xray process management.
|
||||||
|
// It handles starting, stopping, restarting Xray, and managing its configuration.
|
||||||
type XrayService struct {
|
type XrayService struct {
|
||||||
inboundService InboundService
|
inboundService InboundService
|
||||||
settingService SettingService
|
settingService SettingService
|
||||||
xrayAPI xray.XrayAPI
|
xrayAPI xray.XrayAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsXrayRunning checks if the Xray process is currently running.
|
||||||
func (s *XrayService) IsXrayRunning() bool {
|
func (s *XrayService) IsXrayRunning() bool {
|
||||||
return p != nil && p.IsRunning()
|
return p != nil && p.IsRunning()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetXrayErr returns the error from the Xray process, if any.
|
||||||
func (s *XrayService) GetXrayErr() error {
|
func (s *XrayService) GetXrayErr() error {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -46,6 +50,7 @@ func (s *XrayService) GetXrayErr() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetXrayResult returns the result string from the Xray process.
|
||||||
func (s *XrayService) GetXrayResult() string {
|
func (s *XrayService) GetXrayResult() string {
|
||||||
if result != "" {
|
if result != "" {
|
||||||
return result
|
return result
|
||||||
@@ -68,6 +73,7 @@ func (s *XrayService) GetXrayResult() string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetXrayVersion returns the version of the running Xray process.
|
||||||
func (s *XrayService) GetXrayVersion() string {
|
func (s *XrayService) GetXrayVersion() string {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
@@ -75,10 +81,13 @@ func (s *XrayService) GetXrayVersion() string {
|
|||||||
return p.GetVersion()
|
return p.GetVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveIndex removes an element at the specified index from a slice.
|
||||||
|
// Returns a new slice with the element removed.
|
||||||
func RemoveIndex(s []any, index int) []any {
|
func RemoveIndex(s []any, index int) []any {
|
||||||
return append(s[:index], s[index+1:]...)
|
return append(s[:index], s[index+1:]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetXrayConfig retrieves and builds the Xray configuration from settings and inbounds.
|
||||||
func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||||
templateConfig, err := s.settingService.GetXrayConfigTemplate()
|
templateConfig, err := s.settingService.GetXrayConfigTemplate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -182,6 +191,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
|||||||
return xrayConfig, nil
|
return xrayConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetXrayTraffic fetches the current traffic statistics from the running Xray process.
|
||||||
func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
|
func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
|
||||||
if !s.IsXrayRunning() {
|
if !s.IsXrayRunning() {
|
||||||
err := errors.New("xray is not running")
|
err := errors.New("xray is not running")
|
||||||
@@ -200,6 +210,7 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
|
|||||||
return traffic, clientTraffic, nil
|
return traffic, clientTraffic, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RestartXray restarts the Xray process, optionally forcing a restart even if config unchanged.
|
||||||
func (s *XrayService) RestartXray(isForce bool) error {
|
func (s *XrayService) RestartXray(isForce bool) error {
|
||||||
lock.Lock()
|
lock.Lock()
|
||||||
defer lock.Unlock()
|
defer lock.Unlock()
|
||||||
@@ -229,6 +240,7 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StopXray stops the running Xray process.
|
||||||
func (s *XrayService) StopXray() error {
|
func (s *XrayService) StopXray() error {
|
||||||
lock.Lock()
|
lock.Lock()
|
||||||
defer lock.Unlock()
|
defer lock.Unlock()
|
||||||
@@ -240,15 +252,17 @@ func (s *XrayService) StopXray() error {
|
|||||||
return errors.New("xray is not running")
|
return errors.New("xray is not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetToNeedRestart marks that Xray needs to be restarted.
|
||||||
func (s *XrayService) SetToNeedRestart() {
|
func (s *XrayService) SetToNeedRestart() {
|
||||||
isNeedXrayRestart.Store(true)
|
isNeedXrayRestart.Store(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsNeedRestartAndSetFalse checks if restart is needed and resets the flag to false.
|
||||||
func (s *XrayService) IsNeedRestartAndSetFalse() bool {
|
func (s *XrayService) IsNeedRestartAndSetFalse() bool {
|
||||||
return isNeedXrayRestart.CompareAndSwap(true, false)
|
return isNeedXrayRestart.CompareAndSwap(true, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if Xray is not running and wasn't stopped manually, i.e. crashed
|
// DidXrayCrash checks if Xray crashed by verifying it's not running and wasn't manually stopped.
|
||||||
func (s *XrayService) DidXrayCrash() bool {
|
func (s *XrayService) DidXrayCrash() bool {
|
||||||
return !s.IsXrayRunning() && !isManuallyStopped.Load()
|
return !s.IsXrayRunning() && !isManuallyStopped.Load()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// XraySettingService provides business logic for Xray configuration management.
|
||||||
|
// It handles validation and storage of Xray template configurations.
|
||||||
type XraySettingService struct {
|
type XraySettingService struct {
|
||||||
SettingService
|
SettingService
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
// Package session provides session management utilities for the 3x-ui web panel.
|
||||||
|
// It handles user authentication state, login sessions, and session storage using Gin sessions.
|
||||||
package session
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"net/http"
|
"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"
|
||||||
@@ -19,6 +21,8 @@ func init() {
|
|||||||
gob.Register(model.User{})
|
gob.Register(model.User{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLoginUser stores the authenticated user in the session.
|
||||||
|
// The user object is serialized and stored for subsequent requests.
|
||||||
func SetLoginUser(c *gin.Context, user *model.User) {
|
func SetLoginUser(c *gin.Context, user *model.User) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return
|
return
|
||||||
@@ -27,6 +31,8 @@ func SetLoginUser(c *gin.Context, user *model.User) {
|
|||||||
s.Set(loginUserKey, *user)
|
s.Set(loginUserKey, *user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMaxAge configures the session cookie maximum age in seconds.
|
||||||
|
// This controls how long the session remains valid before requiring re-authentication.
|
||||||
func SetMaxAge(c *gin.Context, maxAge int) {
|
func SetMaxAge(c *gin.Context, maxAge int) {
|
||||||
s := sessions.Default(c)
|
s := sessions.Default(c)
|
||||||
s.Options(sessions.Options{
|
s.Options(sessions.Options{
|
||||||
@@ -37,6 +43,8 @@ func SetMaxAge(c *gin.Context, maxAge int) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLoginUser retrieves the authenticated user from the session.
|
||||||
|
// Returns nil if no user is logged in or if the session data is invalid.
|
||||||
func GetLoginUser(c *gin.Context) *model.User {
|
func GetLoginUser(c *gin.Context) *model.User {
|
||||||
s := sessions.Default(c)
|
s := sessions.Default(c)
|
||||||
obj := s.Get(loginUserKey)
|
obj := s.Get(loginUserKey)
|
||||||
@@ -52,10 +60,14 @@ func GetLoginUser(c *gin.Context) *model.User {
|
|||||||
return &user
|
return &user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsLogin checks if a user is currently authenticated in the session.
|
||||||
|
// Returns true if a valid user session exists, false otherwise.
|
||||||
func IsLogin(c *gin.Context) bool {
|
func IsLogin(c *gin.Context) bool {
|
||||||
return GetLoginUser(c) != nil
|
return GetLoginUser(c) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClearSession removes all session data and invalidates the session.
|
||||||
|
// This effectively logs out the user and clears any stored session information.
|
||||||
func ClearSession(c *gin.Context) {
|
func ClearSession(c *gin.Context) {
|
||||||
s := sessions.Default(c)
|
s := sessions.Default(c)
|
||||||
s.Clear()
|
s.Clear()
|
||||||
|
|||||||
@@ -244,6 +244,9 @@
|
|||||||
"exportInbound" = "تصدير الإدخال"
|
"exportInbound" = "تصدير الإدخال"
|
||||||
"import" = "استيراد"
|
"import" = "استيراد"
|
||||||
"importInbound" = "استيراد إدخال"
|
"importInbound" = "استيراد إدخال"
|
||||||
|
"periodicTrafficResetTitle" = "إعادة تعيين حركة المرور"
|
||||||
|
"periodicTrafficResetDesc" = "إعادة تعيين عداد حركة المرور تلقائيًا في فترات محددة"
|
||||||
|
"lastReset" = "آخر إعادة تعيين"
|
||||||
|
|
||||||
[pages.client]
|
[pages.client]
|
||||||
"add" = "أضف عميل"
|
"add" = "أضف عميل"
|
||||||
@@ -263,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" = "تم التحديث بنجاح"
|
||||||
@@ -362,6 +371,7 @@
|
|||||||
"subSettings" = "الاشتراك"
|
"subSettings" = "الاشتراك"
|
||||||
"subEnable" = "تفعيل خدمة الاشتراك"
|
"subEnable" = "تفعيل خدمة الاشتراك"
|
||||||
"subEnableDesc" = "يفعل خدمة الاشتراك."
|
"subEnableDesc" = "يفعل خدمة الاشتراك."
|
||||||
|
"subJsonEnable" = "تمكين/تعطيل نقطة نهاية اشتراك JSON بشكل مستقل."
|
||||||
"subTitle" = "عنوان الاشتراك"
|
"subTitle" = "عنوان الاشتراك"
|
||||||
"subTitleDesc" = "العنوان اللي هيظهر في عميل VPN"
|
"subTitleDesc" = "العنوان اللي هيظهر في عميل VPN"
|
||||||
"subListen" = "IP الاستماع"
|
"subListen" = "IP الاستماع"
|
||||||
|
|||||||
@@ -244,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"
|
||||||
@@ -263,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."
|
||||||
@@ -360,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"
|
||||||
|
|||||||
@@ -244,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"
|
||||||
@@ -263,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"
|
||||||
@@ -362,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"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user