mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-07 13:44:24 +00:00
Merge branch 'main' into feature/nord-vpn
This commit is contained in:
commit
8f74ccdda6
82 changed files with 3589 additions and 621 deletions
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions" # See documentation for possible values
|
||||||
|
directory: "/" # Location of package manifests
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
14
.github/workflows/docker.yml
vendored
14
.github/workflows/docker.yml
vendored
|
|
@ -15,13 +15,13 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
submodules: true
|
submodules: true
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
hsanaeii/3x-ui
|
hsanaeii/3x-ui
|
||||||
|
|
@ -32,28 +32,28 @@ jobs:
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
with:
|
with:
|
||||||
install: true
|
install: true
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|
|
||||||
64
.github/workflows/release.yml
vendored
64
.github/workflows/release.yml
vendored
|
|
@ -2,11 +2,9 @@ name: Release 3X-UI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- '**'
|
||||||
tags:
|
tags:
|
||||||
- "v*.*.*"
|
- "v*.*.*"
|
||||||
paths:
|
paths:
|
||||||
|
|
@ -20,9 +18,48 @@ on:
|
||||||
- 'x-ui.service.debian'
|
- 'x-ui.service.debian'
|
||||||
- 'x-ui.service.arch'
|
- 'x-ui.service.arch'
|
||||||
- 'x-ui.service.rhel'
|
- 'x-ui.service.rhel'
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze Go code
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
unformatted=$(gofmt -l .)
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "These files are not gofmt-formatted:"
|
||||||
|
echo "$unformatted"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Run staticcheck
|
||||||
|
uses: dominikh/staticcheck-action@v1
|
||||||
|
with:
|
||||||
|
version: "latest"
|
||||||
|
install-go: false
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: go test -race -shuffle=on ./...
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
needs: analyze
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
strategy:
|
strategy:
|
||||||
|
|
@ -38,7 +75,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|
@ -133,19 +170,17 @@ jobs:
|
||||||
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
||||||
|
|
||||||
- name: Upload files to Artifacts
|
- name: Upload files to Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: x-ui-linux-${{ matrix.platform }}
|
name: x-ui-linux-${{ matrix.platform }}
|
||||||
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
|
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||||
|
|
||||||
- name: Upload files to GH release
|
- name: Upload files to GH release
|
||||||
uses: svenstaro/upload-release-action@v2
|
uses: svenstaro/upload-release-action@v2
|
||||||
if: |
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
(github.event_name == 'release' && github.event.action == 'published') ||
|
|
||||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
tag: ${{ github.ref }}
|
tag: ${{ github.ref_name }}
|
||||||
file: x-ui-linux-${{ matrix.platform }}.tar.gz
|
file: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||||
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
|
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||||
overwrite: true
|
overwrite: true
|
||||||
|
|
@ -156,6 +191,7 @@ jobs:
|
||||||
# =================================
|
# =================================
|
||||||
build-windows:
|
build-windows:
|
||||||
name: Build for Windows
|
name: Build for Windows
|
||||||
|
needs: analyze
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
strategy:
|
strategy:
|
||||||
|
|
@ -165,7 +201,7 @@ jobs:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|
@ -230,19 +266,17 @@ jobs:
|
||||||
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
|
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
|
||||||
|
|
||||||
- name: Upload files to Artifacts
|
- name: Upload files to Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: x-ui-windows-amd64
|
name: x-ui-windows-amd64
|
||||||
path: ./x-ui-windows-amd64.zip
|
path: ./x-ui-windows-amd64.zip
|
||||||
|
|
||||||
- name: Upload files to GH release
|
- name: Upload files to GH release
|
||||||
uses: svenstaro/upload-release-action@v2
|
uses: svenstaro/upload-release-action@v2
|
||||||
if: |
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
(github.event_name == 'release' && github.event.action == 'published') ||
|
|
||||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
tag: ${{ github.ref }}
|
tag: ${{ github.ref_name }}
|
||||||
file: x-ui-windows-amd64.zip
|
file: x-ui-windows-amd64.zip
|
||||||
asset_name: x-ui-windows-amd64.zip
|
asset_name: x-ui-windows-amd64.zip
|
||||||
overwrite: true
|
overwrite: true
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,14 @@
|
||||||
|
|
||||||
كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
|
كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
|
||||||
|
|
||||||
|
## مصادر DAT مخصصة GeoSite / GeoIP
|
||||||
|
|
||||||
|
يمكن للمسؤولين إضافة ملفات `.dat` لـ GeoSite وGeoIP من عناوين URL في اللوحة (نفس أسلوب تحديث ملفات الجيو المدمجة). تُحفظ الملفات بجانب ثنائي Xray (`XUI_BIN_FOLDER`، الافتراضي `bin/`) بأسماء ثابتة: `geosite_<alias>.dat` و`geoip_<alias>.dat`.
|
||||||
|
|
||||||
|
**التوجيه:** استخدم الصيغة `ext:`، مثل `ext:geosite_myalias.dat:tag` أو `ext:geoip_myalias.dat:tag`، حيث `tag` اسم قائمة داخل ملف DAT (كما في `ext:geoip_IR.dat:ir`).
|
||||||
|
|
||||||
|
**الأسماء المحجوزة:** يُقارَن شكل مُطبَّع فقط لمعرفة التحفظ (`strings.ToLower`، `-` → `_`). لا تُعاد كتابة الأسماء التي يدخلها المستخدم أو سجلات قاعدة البيانات؛ يجب أن تطابق `^[a-z0-9_-]+$`. مثلاً `geoip-ir` و`geoip_ir` يصطدمان بنفس الحجز.
|
||||||
|
|
||||||
## البدء السريع
|
## البدء السريع
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,14 @@
|
||||||
|
|
||||||
Como una versión mejorada del proyecto X-UI original, 3X-UI proporciona mayor estabilidad, soporte más amplio de protocolos y características adicionales.
|
Como una versión mejorada del proyecto X-UI original, 3X-UI proporciona mayor estabilidad, soporte más amplio de protocolos y características adicionales.
|
||||||
|
|
||||||
|
## Fuentes DAT personalizadas GeoSite / GeoIP
|
||||||
|
|
||||||
|
Los administradores pueden añadir archivos `.dat` de GeoSite y GeoIP desde URLs en el panel (mismo flujo que los geoficheros integrados). Los archivos se guardan junto al binario de Xray (`XUI_BIN_FOLDER`, por defecto `bin/`) con nombres fijos: `geosite_<alias>.dat` y `geoip_<alias>.dat`.
|
||||||
|
|
||||||
|
**Enrutamiento:** use la forma `ext:`, por ejemplo `ext:geosite_myalias.dat:tag` o `ext:geoip_myalias.dat:tag`, donde `tag` es un nombre de lista dentro del DAT (igual que en archivos regionales como `ext:geoip_IR.dat:ir`).
|
||||||
|
|
||||||
|
**Alias reservados:** solo para comprobar si un nombre está reservado se compara una forma normalizada (`strings.ToLower`, `-` → `_`). Los alias introducidos y los nombres en la base de datos no se reescriben; deben cumplir `^[a-z0-9_-]+$`. Por ejemplo, `geoip-ir` y `geoip_ir` chocan con la misma entrada reservada.
|
||||||
|
|
||||||
## Inicio Rápido
|
## Inicio Rápido
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,14 @@
|
||||||
|
|
||||||
به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گستردهتر از پروتکلها و ویژگیهای اضافی را ارائه میدهد.
|
به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گستردهتر از پروتکلها و ویژگیهای اضافی را ارائه میدهد.
|
||||||
|
|
||||||
|
## منابع DAT سفارشی GeoSite / GeoIP
|
||||||
|
|
||||||
|
سرپرستان میتوانند از طریق پنل فایلهای `.dat` GeoSite و GeoIP را از URL اضافه کنند (همان الگوی بهروزرسانی ژئوفایلهای داخلی). فایلها در کنار باینری Xray (`XUI_BIN_FOLDER`، پیشفرض `bin/`) با نامهای ثابت `geosite_<alias>.dat` و `geoip_<alias>.dat` ذخیره میشوند.
|
||||||
|
|
||||||
|
**مسیریابی:** از شکل `ext:` استفاده کنید، مثلاً `ext:geosite_myalias.dat:tag` یا `ext:geoip_myalias.dat:tag`؛ `tag` نام لیست داخل همان DAT است (مانند `ext:geoip_IR.dat:ir`).
|
||||||
|
|
||||||
|
**نامهای رزرو:** فقط برای تشخیص رزرو بودن، نسخه نرمالشده (`strings.ToLower`، `-` → `_`) مقایسه میشود. نامهای واردشده و رکورد پایگاه داده بازنویسی نمیشوند و باید با `^[a-z0-9_-]+$` سازگار باشند؛ مثلاً `geoip-ir` و `geoip_ir` به یک رزرو یکسان میخورند.
|
||||||
|
|
||||||
## شروع سریع
|
## شروع سریع
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,14 @@
|
||||||
|
|
||||||
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
|
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
|
||||||
|
|
||||||
|
## Custom GeoSite / GeoIP DAT sources
|
||||||
|
|
||||||
|
Administrators can add custom GeoSite and GeoIP `.dat` files from URLs in the panel (same workflow as updating built-in geofiles). Files are stored under the same directory as the Xray binary (`XUI_BIN_FOLDER`, default `bin/`) with deterministic names: `geosite_<alias>.dat` and `geoip_<alias>.dat`.
|
||||||
|
|
||||||
|
**Routing:** Xray resolves extra lists using the `ext:` form, for example `ext:geosite_myalias.dat:tag` or `ext:geoip_myalias.dat:tag`, where `tag` is a list name inside that DAT file (same pattern as built-in regional files such as `ext:geoip_IR.dat:ir`).
|
||||||
|
|
||||||
|
**Reserved aliases:** Only for deciding whether a name is reserved, the panel compares a normalized form of the alias (`strings.ToLower`, `-` → `_`). User-entered aliases and generated file names are not rewritten in the database; they must still match `^[a-z0-9_-]+$`. For example, `geoip-ir` and `geoip_ir` collide with the same reserved entry.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,14 @@
|
||||||
|
|
||||||
Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
|
Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
|
||||||
|
|
||||||
|
## Пользовательские GeoSite / GeoIP (DAT)
|
||||||
|
|
||||||
|
В панели можно задать свои источники `.dat` по URL (тот же сценарий, что и для встроенных геофайлов). Файлы сохраняются в каталоге с бинарником Xray (`XUI_BIN_FOLDER`, по умолчанию `bin/`) как `geosite_<alias>.dat` и `geoip_<alias>.dat`.
|
||||||
|
|
||||||
|
**Маршрутизация:** в правилах используйте форму `ext:имя_файла.dat:тег`, например `ext:geosite_myalias.dat:tag` (как у региональных списков `ext:geoip_IR.dat:ir`).
|
||||||
|
|
||||||
|
**Зарезервированные псевдонимы:** только для проверки на резерв используется нормализованная форма (`strings.ToLower`, `-` → `_`). Введённые пользователем псевдонимы и имена файлов в БД не переписываются и должны соответствовать `^[a-z0-9_-]+$`. Например, `geoip-ir` и `geoip_ir` попадают под одну и ту же зарезервированную запись.
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,14 @@
|
||||||
|
|
||||||
作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
|
作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
|
||||||
|
|
||||||
|
## 自定义 GeoSite / GeoIP(DAT)
|
||||||
|
|
||||||
|
管理员可在面板中从 URL 添加自定义 GeoSite 与 GeoIP `.dat` 文件(与内置地理文件相同的管理流程)。文件保存在 Xray 可执行文件所在目录(`XUI_BIN_FOLDER`,默认 `bin/`),文件名为 `geosite_<alias>.dat` 和 `geoip_<alias>.dat`。
|
||||||
|
|
||||||
|
**路由:** 在规则中使用 `ext:` 形式,例如 `ext:geosite_myalias.dat:tag` 或 `ext:geoip_myalias.dat:tag`,其中 `tag` 为该 DAT 文件内的列表名(与内置区域文件如 `ext:geoip_IR.dat:ir` 相同)。
|
||||||
|
|
||||||
|
**保留别名:** 仅在为判断是否命中保留名时,会对别名做规范化比较(`strings.ToLower`,`-` → `_`)。用户输入的别名与数据库中的名称不会被改写,且须符合 `^[a-z0-9_-]+$`。例如 `geoip-ir` 与 `geoip_ir` 视为同一保留项。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
2.8.10
|
2.8.11
|
||||||
|
|
@ -38,6 +38,7 @@ func initModels() error {
|
||||||
&model.InboundClientIps{},
|
&model.InboundClientIps{},
|
||||||
&xray.ClientTraffic{},
|
&xray.ClientTraffic{},
|
||||||
&model.HistoryOfSeeders{},
|
&model.HistoryOfSeeders{},
|
||||||
|
&model.CustomGeoResource{},
|
||||||
}
|
}
|
||||||
for _, model := range models {
|
for _, model := range models {
|
||||||
if err := db.AutoMigrate(model); err != nil {
|
if err := db.AutoMigrate(model); err != nil {
|
||||||
|
|
@ -175,9 +176,8 @@ 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 errors.Is(err, gorm.ErrRecordNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
|
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,18 @@ type Setting struct {
|
||||||
Value string `json:"value" form:"value"`
|
Value string `json:"value" form:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CustomGeoResource struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
|
||||||
|
Alias string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
|
||||||
|
Url string `json:"url" gorm:"not null"`
|
||||||
|
LocalPath string `json:"localPath" gorm:"column:local_path"`
|
||||||
|
LastUpdatedAt int64 `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
|
||||||
|
LastModified string `json:"lastModified" gorm:"column:last_modified"`
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime;column:created_at"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ID string `json:"id"` // Unique client identifier
|
ID string `json:"id"` // Unique client identifier
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,4 @@ services:
|
||||||
XUI_ENABLE_FAIL2BAN: "true"
|
XUI_ENABLE_FAIL2BAN: "true"
|
||||||
tty: true
|
tty: true
|
||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
77
go.mod
77
go.mod
|
|
@ -1,53 +1,54 @@
|
||||||
module github.com/mhsanaei/3x-ui/v2
|
module github.com/mhsanaei/3x-ui/v2
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-contrib/gzip v1.2.5
|
github.com/gin-contrib/gzip v1.2.6
|
||||||
github.com/gin-contrib/sessions v1.0.4
|
github.com/gin-contrib/sessions v1.1.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/go-ldap/ldap/v3 v3.4.12
|
github.com/go-ldap/ldap/v3 v3.4.13
|
||||||
github.com/goccy/go-json v0.10.5
|
github.com/goccy/go-json v0.10.6
|
||||||
|
github.com/goccy/go-yaml v1.19.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mymmrac/telego v1.6.0
|
github.com/mymmrac/telego v1.7.0
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.3.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/shirou/gopsutil/v4 v4.26.1
|
github.com/shirou/gopsutil/v4 v4.26.3
|
||||||
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.69.0
|
github.com/valyala/fasthttp v1.69.0
|
||||||
github.com/xlzd/gotp v0.1.0
|
github.com/xlzd/gotp v0.1.0
|
||||||
github.com/xtls/xray-core v1.260206.0
|
github.com/xtls/xray-core v1.260327.0
|
||||||
go.uber.org/atomic v1.11.0
|
go.uber.org/atomic v1.11.0
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/sys v0.41.0
|
golang.org/x/sys v0.42.0
|
||||||
golang.org/x/text v0.34.0
|
golang.org/x/text v0.35.0
|
||||||
google.golang.org/grpc v1.79.1
|
google.golang.org/grpc v1.80.0
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||||
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178 // indirect
|
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/ebitengine/purego v0.9.1 // indirect
|
github.com/ebitengine/purego v0.10.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // 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.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // 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
|
||||||
|
|
@ -57,12 +58,12 @@ require (
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/juju/ratelimit v1.0.2 // indirect
|
github.com/juju/ratelimit v1.0.2 // indirect
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
github.com/mattn/go-sqlite3 v1.14.38 // indirect
|
||||||
github.com/miekg/dns v1.1.72 // indirect
|
github.com/miekg/dns v1.1.72 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
|
@ -70,32 +71,32 @@ require (
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
github.com/refraction-networking/utls v1.8.2 // indirect
|
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // 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.18 // indirect
|
github.com/sagernet/sing v0.8.4 // indirect
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
|
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fastjson v1.6.7 // indirect
|
github.com/valyala/fastjson v1.6.10 // indirect
|
||||||
github.com/vishvananda/netlink v1.3.1 // indirect
|
github.com/vishvananda/netlink v1.3.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.5 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 // indirect
|
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.25.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
golang.org/x/tools v0.43.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-20260209200024-4cfbd4190f57 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
|
||||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||||
lukechampine.com/blake3 v1.4.1 // indirect
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
146
go.sum
146
go.sum
|
|
@ -4,16 +4,16 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178 h1:bSq8n+gX4oO/qnM3MKf4kroW75n+phO9Qp6nigJKZ1E=
|
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 h1:00ziBGnLWQEcR9LThDwvxOznJJquJ9bYUdmBFnawLMU=
|
||||||
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178/go.mod h1:N1WIjPphkqs4efXWuyDNQ6OjjIK04vM3h+bEgwV+eVU=
|
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
|
|
@ -23,24 +23,24 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.13/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=
|
||||||
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
|
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
|
||||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
|
||||||
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
|
||||||
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
|
||||||
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||||
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=
|
||||||
|
|
@ -54,10 +54,10 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/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=
|
||||||
|
|
@ -107,8 +107,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
|
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
|
||||||
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
|
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
|
||||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
|
@ -117,12 +117,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
|
||||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
|
@ -130,16 +130,16 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
|
||||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
|
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
|
||||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
|
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
|
||||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
|
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
|
||||||
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|
@ -150,18 +150,18 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
|
||||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
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.18 h1:iZHkaru1/MoHugx3G+9S3WG4owMewKO/KvieE2Pzk4E=
|
github.com/sagernet/sing v0.8.4 h1:Fj+jlY3F8vhcRfz/G/P3Dwcs5wqnmyNPT7u1RVVmjFI=
|
||||||
github.com/sagernet/sing v0.7.18/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
github.com/sagernet/sing v0.8.4/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/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
|
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
|
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
|
@ -187,22 +187,24 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
|
||||||
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.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
|
github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
|
||||||
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
|
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
|
||||||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 h1:UXjrmniKlY+ZbIqpN91lejB3pszQQQRVu1vqH/p/aGM=
|
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
|
||||||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ=
|
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
|
||||||
github.com/xtls/xray-core v1.260206.0 h1:gY8IV6u76CW93txL9QmacgZ0Udxr2Q3e9qUxXAhdHqI=
|
github.com/xtls/xray-core v1.260327.0 h1:g4TzxMwyPrxslZh6uD+FiG3lXKTrnNO+b4ky2OhogHE=
|
||||||
github.com/xtls/xray-core v1.260206.0/go.mod h1:GyFIgVGRJkt3eyV/NMcdxOKXcJPqGGpyupHzy16uJhU=
|
github.com/xtls/xray-core v1.260327.0/go.mod h1:OXMlhBloFry8mw0KwWLWLd3RQyXJzEYsCGlgsX36h60=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
|
|
@ -223,42 +225,42 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
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=
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|
|
||||||
104
install.sh
104
install.sh
|
|
@ -76,37 +76,38 @@ is_port_in_use() {
|
||||||
install_base() {
|
install_base() {
|
||||||
case "${release}" in
|
case "${release}" in
|
||||||
ubuntu | debian | armbian)
|
ubuntu | debian | armbian)
|
||||||
apt-get update && apt-get install -y -q curl tar tzdata socat ca-certificates
|
apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
|
||||||
;;
|
;;
|
||||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||||||
dnf -y update && dnf install -y -q curl tar tzdata socat ca-certificates
|
dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
|
||||||
;;
|
;;
|
||||||
centos)
|
centos)
|
||||||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||||||
yum -y update && yum install -y curl tar tzdata socat ca-certificates
|
yum -y update && yum install -y cronie curl tar tzdata socat ca-certificates openssl
|
||||||
else
|
else
|
||||||
dnf -y update && dnf install -y -q curl tar tzdata socat ca-certificates
|
dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
arch | manjaro | parch)
|
arch | manjaro | parch)
|
||||||
pacman -Syu && pacman -Syu --noconfirm curl tar tzdata socat ca-certificates
|
pacman -Syu && pacman -Syu --noconfirm cronie curl tar tzdata socat ca-certificates openssl
|
||||||
;;
|
;;
|
||||||
opensuse-tumbleweed | opensuse-leap)
|
opensuse-tumbleweed | opensuse-leap)
|
||||||
zypper refresh && zypper -q install -y curl tar timezone socat ca-certificates
|
zypper refresh && zypper -q install -y cron curl tar timezone socat ca-certificates openssl
|
||||||
;;
|
;;
|
||||||
alpine)
|
alpine)
|
||||||
apk update && apk add curl tar tzdata socat ca-certificates
|
apk update && apk add dcron curl tar tzdata socat ca-certificates openssl
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
apt-get update && apt-get install -y -q curl tar tzdata socat ca-certificates
|
apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
gen_random_string() {
|
gen_random_string() {
|
||||||
local length="$1"
|
local length="$1"
|
||||||
local random_string=$(LC_ALL=C tr -dc 'a-zA-Z0-9' </dev/urandom | fold -w "$length" | head -n 1)
|
openssl rand -base64 $(( length * 2 )) \
|
||||||
echo "$random_string"
|
| tr -dc 'a-zA-Z0-9' \
|
||||||
|
| head -c "$length"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_acme() {
|
install_acme() {
|
||||||
|
|
@ -378,15 +379,15 @@ ssl_cert_issue() {
|
||||||
break
|
break
|
||||||
done
|
done
|
||||||
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
||||||
|
SSL_ISSUED_DOMAIN="${domain}"
|
||||||
|
|
||||||
# check if there already exists a certificate
|
# detect existing certificate and reuse it if present
|
||||||
local currentCert=$(~/.acme.sh/acme.sh --list | tail -1 | awk '{print $1}')
|
local cert_exists=0
|
||||||
if [ "${currentCert}" == "${domain}" ]; then
|
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||||
local certInfo=$(~/.acme.sh/acme.sh --list)
|
cert_exists=1
|
||||||
echo -e "${red}System already has certificates for this domain. Cannot issue again.${plain}"
|
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
|
||||||
echo -e "${yellow}Current certificate details:${plain}"
|
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
|
||||||
echo "$certInfo"
|
[[ -n "${certInfo}" ]] && echo "$certInfo"
|
||||||
return 1
|
|
||||||
else
|
else
|
||||||
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
||||||
fi
|
fi
|
||||||
|
|
@ -413,16 +414,20 @@ ssl_cert_issue() {
|
||||||
echo -e "${yellow}Stopping panel temporarily...${plain}"
|
echo -e "${yellow}Stopping panel temporarily...${plain}"
|
||||||
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
|
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
|
||||||
|
|
||||||
# issue the certificate
|
if [[ ${cert_exists} -eq 0 ]]; then
|
||||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
# issue the certificate
|
||||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
||||||
if [ $? -ne 0 ]; then
|
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||||
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
if [ $? -ne 0 ]; then
|
||||||
rm -rf ~/.acme.sh/${domain}
|
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
||||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
rm -rf ~/.acme.sh/${domain}
|
||||||
return 1
|
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
|
echo -e "${green}Using existing certificate, installing certificates...${plain}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Setup reload command
|
# Setup reload command
|
||||||
|
|
@ -452,17 +457,27 @@ ssl_cert_issue() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# install the certificate
|
# install the certificate
|
||||||
~/.acme.sh/acme.sh --installcert -d ${domain} \
|
local installOutput=""
|
||||||
|
installOutput=$(~/.acme.sh/acme.sh --installcert -d ${domain} \
|
||||||
--key-file /root/cert/${domain}/privkey.pem \
|
--key-file /root/cert/${domain}/privkey.pem \
|
||||||
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}"
|
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
|
||||||
|
local installRc=$?
|
||||||
|
echo "${installOutput}"
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
local installWroteFiles=0
|
||||||
|
if echo "${installOutput}" | grep -q "Installing key to:" && echo "${installOutput}" | grep -q "Installing full chain to:"; then
|
||||||
|
installWroteFiles=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && ( ${installRc} -eq 0 || ${installWroteFiles} -eq 1 ) ]]; then
|
||||||
|
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
|
||||||
|
else
|
||||||
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
||||||
rm -rf ~/.acme.sh/${domain}
|
if [[ ${cert_exists} -eq 0 ]]; then
|
||||||
|
rm -rf ~/.acme.sh/${domain}
|
||||||
|
fi
|
||||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||||
return 1
|
return 1
|
||||||
else
|
|
||||||
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# enable auto-renew
|
# enable auto-renew
|
||||||
|
|
@ -535,14 +550,21 @@ prompt_and_setup_ssl() {
|
||||||
1)
|
1)
|
||||||
# User chose Let's Encrypt domain option
|
# User chose Let's Encrypt domain option
|
||||||
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
||||||
ssl_cert_issue
|
if ssl_cert_issue; then
|
||||||
# Extract the domain that was used from the certificate
|
local cert_domain="${SSL_ISSUED_DOMAIN}"
|
||||||
local cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
if [[ -z "${cert_domain}" ]]; then
|
||||||
if [[ -n "${cert_domain}" ]]; then
|
cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
||||||
SSL_HOST="${cert_domain}"
|
fi
|
||||||
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
|
||||||
|
if [[ -n "${cert_domain}" ]]; then
|
||||||
|
SSL_HOST="${cert_domain}"
|
||||||
|
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
||||||
|
else
|
||||||
|
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
|
||||||
|
SSL_HOST="${server_ip}"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
|
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
|
||||||
SSL_HOST="${server_ip}"
|
SSL_HOST="${server_ip}"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
@ -580,7 +602,7 @@ prompt_and_setup_ssl() {
|
||||||
|
|
||||||
# 3.1 Request Domain to compose Panel URL later
|
# 3.1 Request Domain to compose Panel URL later
|
||||||
read -rp "Please enter domain name certificate issued for: " custom_domain
|
read -rp "Please enter domain name certificate issued for: " custom_domain
|
||||||
custom_domain="${custom_domain// /}" # Убираем пробелы
|
custom_domain="${custom_domain// /}" # Remove spaces
|
||||||
|
|
||||||
# 3.2 Loop for Certificate Path
|
# 3.2 Loop for Certificate Path
|
||||||
while true; do
|
while true; do
|
||||||
|
|
|
||||||
9
main.go
9
main.go
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/sub"
|
"github.com/mhsanaei/3x-ui/v2/sub"
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/util/sys"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web"
|
"github.com/mhsanaei/3x-ui/v2/web"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/global"
|
"github.com/mhsanaei/3x-ui/v2/web/global"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
|
@ -70,7 +71,7 @@ func runWebServer() {
|
||||||
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
// Trap shutdown signals
|
// Trap shutdown signals
|
||||||
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1)
|
||||||
for {
|
for {
|
||||||
sig := <-sigCh
|
sig := <-sigCh
|
||||||
|
|
||||||
|
|
@ -108,6 +109,12 @@ func runWebServer() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Println("Sub server restarted successfully.")
|
log.Println("Sub server restarted successfully.")
|
||||||
|
case sys.SIGUSR1:
|
||||||
|
logger.Info("Received USR1 signal, restarting xray-core...")
|
||||||
|
err := server.RestartXray()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to restart xray-core:", err)
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown ---
|
// --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown ---
|
||||||
|
|
|
||||||
13
sub/sub.go
13
sub/sub.go
|
|
@ -91,12 +91,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if JSON subscription endpoint is enabled
|
ClashPath, err := s.settingService.GetSubClashPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
subJsonEnable, err := s.settingService.GetSubJsonEnable()
|
subJsonEnable, err := s.settingService.GetSubJsonEnable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subClashEnable, err := s.settingService.GetSubClashEnable()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Set base_path based on LinksPath for template rendering
|
// Set base_path based on LinksPath for template rendering
|
||||||
// Ensure LinksPath ends with "/" for proper asset URL generation
|
// Ensure LinksPath ends with "/" for proper asset URL generation
|
||||||
basePath := LinksPath
|
basePath := LinksPath
|
||||||
|
|
@ -255,7 +264,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
g := engine.Group("/")
|
g := engine.Group("/")
|
||||||
|
|
||||||
s.sub = NewSUBController(
|
s.sub = NewSUBController(
|
||||||
g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
||||||
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
|
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
|
||||||
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
||||||
|
|
||||||
|
|
|
||||||
385
sub/subClashService.go
Normal file
385
sub/subClashService.go
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
yaml "github.com/goccy/go-yaml"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SubClashService struct {
|
||||||
|
inboundService service.InboundService
|
||||||
|
SubService *SubService
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClashConfig struct {
|
||||||
|
Proxies []map[string]any `yaml:"proxies"`
|
||||||
|
ProxyGroups []map[string]any `yaml:"proxy-groups"`
|
||||||
|
Rules []string `yaml:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSubClashService(subService *SubService) *SubClashService {
|
||||||
|
return &SubClashService{SubService: subService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
|
||||||
|
inbounds, err := s.SubService.getInboundsBySubId(subId)
|
||||||
|
if err != nil || len(inbounds) == 0 {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var traffic xray.ClientTraffic
|
||||||
|
var clientTraffics []xray.ClientTraffic
|
||||||
|
var proxies []map[string]any
|
||||||
|
|
||||||
|
for _, inbound := range inbounds {
|
||||||
|
clients, err := s.inboundService.GetClients(inbound)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("SubClashService - GetClients: Unable to get clients from inbound")
|
||||||
|
}
|
||||||
|
if clients == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
|
||||||
|
listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
|
||||||
|
if err == nil {
|
||||||
|
inbound.Listen = listen
|
||||||
|
inbound.Port = port
|
||||||
|
inbound.StreamSettings = streamSettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, client := range clients {
|
||||||
|
if client.Enable && client.SubID == subId {
|
||||||
|
clientTraffics = append(clientTraffics, s.SubService.getClientTraffics(inbound.ClientStats, client.Email))
|
||||||
|
proxies = append(proxies, s.getProxies(inbound, client, host)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(proxies) == 0 {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, clientTraffic := range clientTraffics {
|
||||||
|
if index == 0 {
|
||||||
|
traffic.Up = clientTraffic.Up
|
||||||
|
traffic.Down = clientTraffic.Down
|
||||||
|
traffic.Total = clientTraffic.Total
|
||||||
|
if clientTraffic.ExpiryTime > 0 {
|
||||||
|
traffic.ExpiryTime = clientTraffic.ExpiryTime
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
traffic.Up += clientTraffic.Up
|
||||||
|
traffic.Down += clientTraffic.Down
|
||||||
|
if traffic.Total == 0 || clientTraffic.Total == 0 {
|
||||||
|
traffic.Total = 0
|
||||||
|
} else {
|
||||||
|
traffic.Total += clientTraffic.Total
|
||||||
|
}
|
||||||
|
if clientTraffic.ExpiryTime != traffic.ExpiryTime {
|
||||||
|
traffic.ExpiryTime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyNames := make([]string, 0, len(proxies)+1)
|
||||||
|
for _, proxy := range proxies {
|
||||||
|
if name, ok := proxy["name"].(string); ok && name != "" {
|
||||||
|
proxyNames = append(proxyNames, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proxyNames = append(proxyNames, "DIRECT")
|
||||||
|
|
||||||
|
config := ClashConfig{
|
||||||
|
Proxies: proxies,
|
||||||
|
ProxyGroups: []map[string]any{{
|
||||||
|
"name": "PROXY",
|
||||||
|
"type": "select",
|
||||||
|
"proxies": proxyNames,
|
||||||
|
}},
|
||||||
|
Rules: []string{"MATCH,PROXY"},
|
||||||
|
}
|
||||||
|
|
||||||
|
finalYAML, err := yaml.Marshal(config)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||||
|
return string(finalYAML), header, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any {
|
||||||
|
stream := s.streamData(inbound.StreamSettings)
|
||||||
|
externalProxies, ok := stream["externalProxy"].([]any)
|
||||||
|
if !ok || len(externalProxies) == 0 {
|
||||||
|
externalProxies = []any{map[string]any{
|
||||||
|
"forceTls": "same",
|
||||||
|
"dest": host,
|
||||||
|
"port": float64(inbound.Port),
|
||||||
|
"remark": "",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
delete(stream, "externalProxy")
|
||||||
|
|
||||||
|
proxies := make([]map[string]any, 0, len(externalProxies))
|
||||||
|
for _, ep := range externalProxies {
|
||||||
|
extPrxy := ep.(map[string]any)
|
||||||
|
workingInbound := *inbound
|
||||||
|
workingInbound.Listen = extPrxy["dest"].(string)
|
||||||
|
workingInbound.Port = int(extPrxy["port"].(float64))
|
||||||
|
workingStream := cloneMap(stream)
|
||||||
|
|
||||||
|
switch extPrxy["forceTls"].(string) {
|
||||||
|
case "tls":
|
||||||
|
if workingStream["security"] != "tls" {
|
||||||
|
workingStream["security"] = "tls"
|
||||||
|
workingStream["tlsSettings"] = map[string]any{}
|
||||||
|
}
|
||||||
|
case "none":
|
||||||
|
if workingStream["security"] != "none" {
|
||||||
|
workingStream["security"] = "none"
|
||||||
|
delete(workingStream, "tlsSettings")
|
||||||
|
delete(workingStream, "realitySettings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string))
|
||||||
|
if len(proxy) > 0 {
|
||||||
|
proxies = append(proxies, proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return proxies
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
|
||||||
|
proxy := map[string]any{
|
||||||
|
"name": s.SubService.genRemark(inbound, client.Email, extraRemark),
|
||||||
|
"server": inbound.Listen,
|
||||||
|
"port": inbound.Port,
|
||||||
|
"udp": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
network, _ := stream["network"].(string)
|
||||||
|
if !s.applyTransport(proxy, network, stream) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch inbound.Protocol {
|
||||||
|
case model.VMESS:
|
||||||
|
proxy["type"] = "vmess"
|
||||||
|
proxy["uuid"] = client.ID
|
||||||
|
proxy["alterId"] = 0
|
||||||
|
cipher := client.Security
|
||||||
|
if cipher == "" {
|
||||||
|
cipher = "auto"
|
||||||
|
}
|
||||||
|
proxy["cipher"] = cipher
|
||||||
|
case model.VLESS:
|
||||||
|
proxy["type"] = "vless"
|
||||||
|
proxy["uuid"] = client.ID
|
||||||
|
if client.Flow != "" && network == "tcp" {
|
||||||
|
proxy["flow"] = client.Flow
|
||||||
|
}
|
||||||
|
var inboundSettings map[string]any
|
||||||
|
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||||
|
if encryption, ok := inboundSettings["encryption"].(string); ok && encryption != "" {
|
||||||
|
proxy["packet-encoding"] = encryption
|
||||||
|
}
|
||||||
|
case model.Trojan:
|
||||||
|
proxy["type"] = "trojan"
|
||||||
|
proxy["password"] = client.Password
|
||||||
|
case model.Shadowsocks:
|
||||||
|
proxy["type"] = "ss"
|
||||||
|
proxy["password"] = client.Password
|
||||||
|
var inboundSettings map[string]any
|
||||||
|
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||||
|
method, _ := inboundSettings["method"].(string)
|
||||||
|
if method == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
proxy["cipher"] = method
|
||||||
|
if strings.HasPrefix(method, "2022") {
|
||||||
|
if serverPassword, ok := inboundSettings["password"].(string); ok && serverPassword != "" {
|
||||||
|
proxy["password"] = fmt.Sprintf("%s:%s", serverPassword, client.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
security, _ := stream["security"].(string)
|
||||||
|
if !s.applySecurity(proxy, security, stream) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubClashService) applyTransport(proxy map[string]any, network string, stream map[string]any) bool {
|
||||||
|
switch network {
|
||||||
|
case "", "tcp":
|
||||||
|
proxy["network"] = "tcp"
|
||||||
|
tcp, _ := stream["tcpSettings"].(map[string]any)
|
||||||
|
if tcp != nil {
|
||||||
|
header, _ := tcp["header"].(map[string]any)
|
||||||
|
if header != nil {
|
||||||
|
typeStr, _ := header["type"].(string)
|
||||||
|
if typeStr != "" && typeStr != "none" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case "ws":
|
||||||
|
proxy["network"] = "ws"
|
||||||
|
ws, _ := stream["wsSettings"].(map[string]any)
|
||||||
|
wsOpts := map[string]any{}
|
||||||
|
if ws != nil {
|
||||||
|
if path, ok := ws["path"].(string); ok && path != "" {
|
||||||
|
wsOpts["path"] = path
|
||||||
|
}
|
||||||
|
host := ""
|
||||||
|
if v, ok := ws["host"].(string); ok && v != "" {
|
||||||
|
host = v
|
||||||
|
} else if headers, ok := ws["headers"].(map[string]any); ok {
|
||||||
|
host = searchHost(headers)
|
||||||
|
}
|
||||||
|
if host != "" {
|
||||||
|
wsOpts["headers"] = map[string]any{"Host": host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(wsOpts) > 0 {
|
||||||
|
proxy["ws-opts"] = wsOpts
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case "grpc":
|
||||||
|
proxy["network"] = "grpc"
|
||||||
|
grpc, _ := stream["grpcSettings"].(map[string]any)
|
||||||
|
grpcOpts := map[string]any{}
|
||||||
|
if grpc != nil {
|
||||||
|
if serviceName, ok := grpc["serviceName"].(string); ok && serviceName != "" {
|
||||||
|
grpcOpts["grpc-service-name"] = serviceName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(grpcOpts) > 0 {
|
||||||
|
proxy["grpc-opts"] = grpcOpts
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubClashService) applySecurity(proxy map[string]any, security string, stream map[string]any) bool {
|
||||||
|
switch security {
|
||||||
|
case "", "none":
|
||||||
|
proxy["tls"] = false
|
||||||
|
return true
|
||||||
|
case "tls":
|
||||||
|
proxy["tls"] = true
|
||||||
|
tlsSettings, _ := stream["tlsSettings"].(map[string]any)
|
||||||
|
if tlsSettings != nil {
|
||||||
|
if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" {
|
||||||
|
proxy["servername"] = serverName
|
||||||
|
switch proxy["type"] {
|
||||||
|
case "trojan":
|
||||||
|
proxy["sni"] = serverName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fingerprint, ok := tlsSettings["fingerprint"].(string); ok && fingerprint != "" {
|
||||||
|
proxy["client-fingerprint"] = fingerprint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case "reality":
|
||||||
|
proxy["tls"] = true
|
||||||
|
realitySettings, _ := stream["realitySettings"].(map[string]any)
|
||||||
|
if realitySettings == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if serverName, ok := realitySettings["serverName"].(string); ok && serverName != "" {
|
||||||
|
proxy["servername"] = serverName
|
||||||
|
}
|
||||||
|
realityOpts := map[string]any{}
|
||||||
|
if publicKey, ok := realitySettings["publicKey"].(string); ok && publicKey != "" {
|
||||||
|
realityOpts["public-key"] = publicKey
|
||||||
|
}
|
||||||
|
if shortID, ok := realitySettings["shortId"].(string); ok && shortID != "" {
|
||||||
|
realityOpts["short-id"] = shortID
|
||||||
|
}
|
||||||
|
if len(realityOpts) > 0 {
|
||||||
|
proxy["reality-opts"] = realityOpts
|
||||||
|
}
|
||||||
|
if fingerprint, ok := realitySettings["fingerprint"].(string); ok && fingerprint != "" {
|
||||||
|
proxy["client-fingerprint"] = fingerprint
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubClashService) streamData(stream string) map[string]any {
|
||||||
|
var streamSettings map[string]any
|
||||||
|
json.Unmarshal([]byte(stream), &streamSettings)
|
||||||
|
security, _ := streamSettings["security"].(string)
|
||||||
|
switch security {
|
||||||
|
case "tls":
|
||||||
|
if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok {
|
||||||
|
streamSettings["tlsSettings"] = s.tlsData(tlsSettings)
|
||||||
|
}
|
||||||
|
case "reality":
|
||||||
|
if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok {
|
||||||
|
streamSettings["realitySettings"] = s.realityData(realitySettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(streamSettings, "sockopt")
|
||||||
|
return streamSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubClashService) tlsData(tData map[string]any) map[string]any {
|
||||||
|
tlsData := make(map[string]any, 1)
|
||||||
|
tlsClientSettings, _ := tData["settings"].(map[string]any)
|
||||||
|
tlsData["serverName"] = tData["serverName"]
|
||||||
|
tlsData["alpn"] = tData["alpn"]
|
||||||
|
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
|
||||||
|
tlsData["fingerprint"] = fingerprint
|
||||||
|
}
|
||||||
|
return tlsData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubClashService) realityData(rData map[string]any) map[string]any {
|
||||||
|
rDataOut := make(map[string]any, 1)
|
||||||
|
realityClientSettings, _ := rData["settings"].(map[string]any)
|
||||||
|
if publicKey, ok := realityClientSettings["publicKey"].(string); ok {
|
||||||
|
rDataOut["publicKey"] = publicKey
|
||||||
|
}
|
||||||
|
if fingerprint, ok := realityClientSettings["fingerprint"].(string); ok {
|
||||||
|
rDataOut["fingerprint"] = fingerprint
|
||||||
|
}
|
||||||
|
if serverNames, ok := rData["serverNames"].([]any); ok && len(serverNames) > 0 {
|
||||||
|
rDataOut["serverName"] = fmt.Sprint(serverNames[0])
|
||||||
|
}
|
||||||
|
if shortIDs, ok := rData["shortIds"].([]any); ok && len(shortIDs) > 0 {
|
||||||
|
rDataOut["shortId"] = fmt.Sprint(shortIDs[0])
|
||||||
|
}
|
||||||
|
return rDataOut
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMap(src map[string]any) map[string]any {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dst := make(map[string]any, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
dst[k] = v
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
@ -21,12 +21,15 @@ type SUBController struct {
|
||||||
subRoutingRules string
|
subRoutingRules string
|
||||||
subPath string
|
subPath string
|
||||||
subJsonPath string
|
subJsonPath string
|
||||||
|
subClashPath string
|
||||||
jsonEnabled bool
|
jsonEnabled bool
|
||||||
|
clashEnabled bool
|
||||||
subEncrypt bool
|
subEncrypt bool
|
||||||
updateInterval string
|
updateInterval string
|
||||||
|
|
||||||
subService *SubService
|
subService *SubService
|
||||||
subJsonService *SubJsonService
|
subJsonService *SubJsonService
|
||||||
|
subClashService *SubClashService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSUBController creates a new subscription controller with the given configuration.
|
// NewSUBController creates a new subscription controller with the given configuration.
|
||||||
|
|
@ -34,7 +37,9 @@ func NewSUBController(
|
||||||
g *gin.RouterGroup,
|
g *gin.RouterGroup,
|
||||||
subPath string,
|
subPath string,
|
||||||
jsonPath string,
|
jsonPath string,
|
||||||
|
clashPath string,
|
||||||
jsonEnabled bool,
|
jsonEnabled bool,
|
||||||
|
clashEnabled bool,
|
||||||
encrypt bool,
|
encrypt bool,
|
||||||
showInfo bool,
|
showInfo bool,
|
||||||
rModel string,
|
rModel string,
|
||||||
|
|
@ -60,12 +65,15 @@ func NewSUBController(
|
||||||
subRoutingRules: subRoutingRules,
|
subRoutingRules: subRoutingRules,
|
||||||
subPath: subPath,
|
subPath: subPath,
|
||||||
subJsonPath: jsonPath,
|
subJsonPath: jsonPath,
|
||||||
|
subClashPath: clashPath,
|
||||||
jsonEnabled: jsonEnabled,
|
jsonEnabled: jsonEnabled,
|
||||||
|
clashEnabled: clashEnabled,
|
||||||
subEncrypt: encrypt,
|
subEncrypt: encrypt,
|
||||||
updateInterval: update,
|
updateInterval: update,
|
||||||
|
|
||||||
subService: sub,
|
subService: sub,
|
||||||
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
||||||
|
subClashService: NewSubClashService(sub),
|
||||||
}
|
}
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
return a
|
return a
|
||||||
|
|
@ -80,6 +88,10 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
|
||||||
gJson := g.Group(a.subJsonPath)
|
gJson := g.Group(a.subJsonPath)
|
||||||
gJson.GET(":subid", a.subJsons)
|
gJson.GET(":subid", a.subJsons)
|
||||||
}
|
}
|
||||||
|
if a.clashEnabled {
|
||||||
|
gClash := g.Group(a.subClashPath)
|
||||||
|
gClash.GET(":subid", a.subClashs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
|
// subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
|
||||||
|
|
@ -99,10 +111,13 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||||
accept := c.GetHeader("Accept")
|
accept := c.GetHeader("Accept")
|
||||||
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, subClashURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, a.subClashPath, subId)
|
||||||
if !a.jsonEnabled {
|
if !a.jsonEnabled {
|
||||||
subJsonURL = ""
|
subJsonURL = ""
|
||||||
}
|
}
|
||||||
|
if !a.clashEnabled {
|
||||||
|
subClashURL = ""
|
||||||
|
}
|
||||||
// Get base_path from context (set by middleware)
|
// Get base_path from context (set by middleware)
|
||||||
basePath, exists := c.Get("base_path")
|
basePath, exists := c.Get("base_path")
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|
@ -116,7 +131,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||||
// Remove trailing slash if exists, add subId, then add trailing slash
|
// Remove trailing slash if exists, add subId, then add trailing slash
|
||||||
basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
|
basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
|
||||||
}
|
}
|
||||||
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, basePathStr)
|
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr)
|
||||||
c.HTML(200, "subpage.html", gin.H{
|
c.HTML(200, "subpage.html", gin.H{
|
||||||
"title": "subscription.title",
|
"title": "subscription.title",
|
||||||
"cur_ver": config.GetVersion(),
|
"cur_ver": config.GetVersion(),
|
||||||
|
|
@ -136,6 +151,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||||
"totalByte": page.TotalByte,
|
"totalByte": page.TotalByte,
|
||||||
"subUrl": page.SubUrl,
|
"subUrl": page.SubUrl,
|
||||||
"subJsonUrl": page.SubJsonUrl,
|
"subJsonUrl": page.SubJsonUrl,
|
||||||
|
"subClashUrl": page.SubClashUrl,
|
||||||
"result": page.Result,
|
"result": page.Result,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -165,7 +181,6 @@ func (a *SUBController) subJsons(c *gin.Context) {
|
||||||
if err != nil || len(jsonSub) == 0 {
|
if err != nil || len(jsonSub) == 0 {
|
||||||
c.String(400, "Error!")
|
c.String(400, "Error!")
|
||||||
} else {
|
} else {
|
||||||
// Add headers
|
|
||||||
profileUrl := a.subProfileUrl
|
profileUrl := a.subProfileUrl
|
||||||
if profileUrl == "" {
|
if profileUrl == "" {
|
||||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||||
|
|
@ -176,6 +191,22 @@ func (a *SUBController) subJsons(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *SUBController) subClashs(c *gin.Context) {
|
||||||
|
subId := c.Param("subid")
|
||||||
|
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||||
|
clashSub, header, err := a.subClashService.GetClash(subId, host)
|
||||||
|
if err != nil || len(clashSub) == 0 {
|
||||||
|
c.String(400, "Error!")
|
||||||
|
} else {
|
||||||
|
profileUrl := a.subProfileUrl
|
||||||
|
if profileUrl == "" {
|
||||||
|
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||||
|
}
|
||||||
|
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
|
||||||
|
c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
|
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
|
||||||
func (a *SUBController) ApplyCommonHeaders(
|
func (a *SUBController) ApplyCommonHeaders(
|
||||||
c *gin.Context,
|
c *gin.Context,
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||||
headers, _ := xhttp["headers"].(map[string]any)
|
headers, _ := xhttp["headers"].(map[string]any)
|
||||||
obj["host"] = searchHost(headers)
|
obj["host"] = searchHost(headers)
|
||||||
}
|
}
|
||||||
obj["mode"] = xhttp["mode"].(string)
|
obj["mode"], _ = xhttp["mode"].(string)
|
||||||
}
|
}
|
||||||
security, _ := stream["security"].(string)
|
security, _ := stream["security"].(string)
|
||||||
obj["tls"] = security
|
obj["tls"] = security
|
||||||
|
|
@ -405,7 +405,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||||
headers, _ := xhttp["headers"].(map[string]any)
|
headers, _ := xhttp["headers"].(map[string]any)
|
||||||
params["host"] = searchHost(headers)
|
params["host"] = searchHost(headers)
|
||||||
}
|
}
|
||||||
params["mode"] = xhttp["mode"].(string)
|
params["mode"], _ = xhttp["mode"].(string)
|
||||||
}
|
}
|
||||||
security, _ := stream["security"].(string)
|
security, _ := stream["security"].(string)
|
||||||
if security == "tls" {
|
if security == "tls" {
|
||||||
|
|
@ -601,7 +601,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||||
headers, _ := xhttp["headers"].(map[string]any)
|
headers, _ := xhttp["headers"].(map[string]any)
|
||||||
params["host"] = searchHost(headers)
|
params["host"] = searchHost(headers)
|
||||||
}
|
}
|
||||||
params["mode"] = xhttp["mode"].(string)
|
params["mode"], _ = xhttp["mode"].(string)
|
||||||
}
|
}
|
||||||
security, _ := stream["security"].(string)
|
security, _ := stream["security"].(string)
|
||||||
if security == "tls" {
|
if security == "tls" {
|
||||||
|
|
@ -800,7 +800,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||||
headers, _ := xhttp["headers"].(map[string]any)
|
headers, _ := xhttp["headers"].(map[string]any)
|
||||||
params["host"] = searchHost(headers)
|
params["host"] = searchHost(headers)
|
||||||
}
|
}
|
||||||
params["mode"] = xhttp["mode"].(string)
|
params["mode"], _ = xhttp["mode"].(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
security, _ := stream["security"].(string)
|
security, _ := stream["security"].(string)
|
||||||
|
|
@ -1031,6 +1031,7 @@ type PageData struct {
|
||||||
TotalByte int64
|
TotalByte int64
|
||||||
SubUrl string
|
SubUrl string
|
||||||
SubJsonUrl string
|
SubJsonUrl string
|
||||||
|
SubClashUrl string
|
||||||
Result []string
|
Result []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1080,29 +1081,25 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
|
||||||
|
|
||||||
// BuildURLs constructs absolute subscription and JSON subscription URLs for a given subscription ID.
|
// 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.
|
// 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, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) {
|
||||||
// Input validation
|
|
||||||
if subId == "" {
|
if subId == "" {
|
||||||
return "", ""
|
return "", "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get configured URIs first (highest priority)
|
|
||||||
configuredSubURI, _ := s.settingService.GetSubURI()
|
configuredSubURI, _ := s.settingService.GetSubURI()
|
||||||
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
|
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
|
||||||
|
configuredSubClashURI, _ := s.settingService.GetSubClashURI()
|
||||||
|
|
||||||
// Determine base scheme and host (cached to avoid duplicate calls)
|
|
||||||
var baseScheme, baseHostWithPort string
|
var baseScheme, baseHostWithPort string
|
||||||
if configuredSubURI == "" || configuredSubJsonURI == "" {
|
if configuredSubURI == "" || configuredSubJsonURI == "" || configuredSubClashURI == "" {
|
||||||
baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
|
baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build subscription URL
|
|
||||||
subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
|
subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
|
||||||
|
|
||||||
// Build JSON subscription URL
|
|
||||||
subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId)
|
subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId)
|
||||||
|
subClashURL = s.buildSingleURL(configuredSubClashURI, baseScheme, baseHostWithPort, subClashPath, subId)
|
||||||
|
|
||||||
return subURL, subJsonURL
|
return subURL, subJsonURL, subClashURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values
|
// getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values
|
||||||
|
|
@ -1149,7 +1146,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
|
||||||
|
|
||||||
// BuildPageData parses header and prepares the template view model.
|
// BuildPageData parses header and prepares the template view model.
|
||||||
// BuildPageData constructs page data for rendering the subscription information page.
|
// BuildPageData constructs page data for rendering the subscription information page.
|
||||||
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string, basePath string) PageData {
|
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string) PageData {
|
||||||
download := common.FormatTraffic(traffic.Down)
|
download := common.FormatTraffic(traffic.Down)
|
||||||
upload := common.FormatTraffic(traffic.Up)
|
upload := common.FormatTraffic(traffic.Up)
|
||||||
total := "∞"
|
total := "∞"
|
||||||
|
|
@ -1183,6 +1180,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
|
||||||
TotalByte: traffic.Total,
|
TotalByte: traffic.Total,
|
||||||
SubUrl: subURL,
|
SubUrl: subURL,
|
||||||
SubJsonUrl: subJsonURL,
|
SubJsonUrl: subJsonURL,
|
||||||
|
SubClashUrl: subClashURL,
|
||||||
Result: subs,
|
Result: subs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
104
update.sh
104
update.sh
|
|
@ -100,37 +100,38 @@ is_port_in_use() {
|
||||||
|
|
||||||
gen_random_string() {
|
gen_random_string() {
|
||||||
local length="$1"
|
local length="$1"
|
||||||
local random_string=$(LC_ALL=C tr -dc 'a-zA-Z0-9' </dev/urandom | fold -w "$length" | head -n 1)
|
openssl rand -base64 $(( length * 2 )) \
|
||||||
echo "$random_string"
|
| tr -dc 'a-zA-Z0-9' \
|
||||||
|
| head -c "$length"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_base() {
|
install_base() {
|
||||||
echo -e "${green}Updating and install dependency packages...${plain}"
|
echo -e "${green}Updating and install dependency packages...${plain}"
|
||||||
case "${release}" in
|
case "${release}" in
|
||||||
ubuntu | debian | armbian)
|
ubuntu | debian | armbian)
|
||||||
apt-get update >/dev/null 2>&1 && apt-get install -y -q curl tar tzdata socat >/dev/null 2>&1
|
apt-get update >/dev/null 2>&1 && apt-get install -y -q cron curl tar tzdata socat openssl >/dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||||||
dnf -y update >/dev/null 2>&1 && dnf install -y -q curl tar tzdata socat >/dev/null 2>&1
|
dnf -y update >/dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
centos)
|
centos)
|
||||||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||||||
yum -y update >/dev/null 2>&1 && yum install -y -q curl tar tzdata socat >/dev/null 2>&1
|
yum -y update >/dev/null 2>&1 && yum install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
|
||||||
else
|
else
|
||||||
dnf -y update >/dev/null 2>&1 && dnf install -y -q curl tar tzdata socat >/dev/null 2>&1
|
dnf -y update >/dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
arch | manjaro | parch)
|
arch | manjaro | parch)
|
||||||
pacman -Syu >/dev/null 2>&1 && pacman -Syu --noconfirm curl tar tzdata socat >/dev/null 2>&1
|
pacman -Syu >/dev/null 2>&1 && pacman -Syu --noconfirm cronie curl tar tzdata socat openssl >/dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
opensuse-tumbleweed | opensuse-leap)
|
opensuse-tumbleweed | opensuse-leap)
|
||||||
zypper refresh >/dev/null 2>&1 && zypper -q install -y curl tar timezone socat >/dev/null 2>&1
|
zypper refresh >/dev/null 2>&1 && zypper -q install -y cron curl tar timezone socat openssl >/dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
alpine)
|
alpine)
|
||||||
apk update >/dev/null 2>&1 && apk add curl tar tzdata socat >/dev/null 2>&1
|
apk update >/dev/null 2>&1 && apk add dcron curl tar tzdata socat openssl>/dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
apt-get update >/dev/null 2>&1 && apt install -y -q curl tar tzdata socat >/dev/null 2>&1
|
apt-get update >/dev/null 2>&1 && apt install -y -q cron curl tar tzdata socat openssl >/dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
@ -401,15 +402,15 @@ ssl_cert_issue() {
|
||||||
break
|
break
|
||||||
done
|
done
|
||||||
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
||||||
|
SSL_ISSUED_DOMAIN="${domain}"
|
||||||
|
|
||||||
# check if there already exists a certificate
|
# detect existing certificate and reuse it if present
|
||||||
local currentCert=$(~/.acme.sh/acme.sh --list | tail -1 | awk '{print $1}')
|
local cert_exists=0
|
||||||
if [ "${currentCert}" == "${domain}" ]; then
|
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||||
local certInfo=$(~/.acme.sh/acme.sh --list)
|
cert_exists=1
|
||||||
echo -e "${red}System already has certificates for this domain. Cannot issue again.${plain}"
|
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
|
||||||
echo -e "${yellow}Current certificate details:${plain}"
|
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
|
||||||
echo "$certInfo"
|
[[ -n "${certInfo}" ]] && echo "$certInfo"
|
||||||
return 1
|
|
||||||
else
|
else
|
||||||
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
||||||
fi
|
fi
|
||||||
|
|
@ -436,16 +437,20 @@ ssl_cert_issue() {
|
||||||
echo -e "${yellow}Stopping panel temporarily...${plain}"
|
echo -e "${yellow}Stopping panel temporarily...${plain}"
|
||||||
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
|
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
|
||||||
|
|
||||||
# issue the certificate
|
if [[ ${cert_exists} -eq 0 ]]; then
|
||||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
# issue the certificate
|
||||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
||||||
if [ $? -ne 0 ]; then
|
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||||
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
if [ $? -ne 0 ]; then
|
||||||
rm -rf ~/.acme.sh/${domain}
|
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
||||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
rm -rf ~/.acme.sh/${domain}
|
||||||
return 1
|
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
|
echo -e "${green}Using existing certificate, installing certificates...${plain}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Setup reload command
|
# Setup reload command
|
||||||
|
|
@ -475,17 +480,27 @@ ssl_cert_issue() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# install the certificate
|
# install the certificate
|
||||||
~/.acme.sh/acme.sh --installcert -d ${domain} \
|
local installOutput=""
|
||||||
|
installOutput=$(~/.acme.sh/acme.sh --installcert -d ${domain} \
|
||||||
--key-file /root/cert/${domain}/privkey.pem \
|
--key-file /root/cert/${domain}/privkey.pem \
|
||||||
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}"
|
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
|
||||||
|
local installRc=$?
|
||||||
|
echo "${installOutput}"
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
local installWroteFiles=0
|
||||||
|
if echo "${installOutput}" | grep -q "Installing key to:" && echo "${installOutput}" | grep -q "Installing full chain to:"; then
|
||||||
|
installWroteFiles=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && ( ${installRc} -eq 0 || ${installWroteFiles} -eq 1 ) ]]; then
|
||||||
|
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
|
||||||
|
else
|
||||||
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
||||||
rm -rf ~/.acme.sh/${domain}
|
if [[ ${cert_exists} -eq 0 ]]; then
|
||||||
|
rm -rf ~/.acme.sh/${domain}
|
||||||
|
fi
|
||||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||||
return 1
|
return 1
|
||||||
else
|
|
||||||
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# enable auto-renew
|
# enable auto-renew
|
||||||
|
|
@ -555,14 +570,21 @@ prompt_and_setup_ssl() {
|
||||||
1)
|
1)
|
||||||
# User chose Let's Encrypt domain option
|
# User chose Let's Encrypt domain option
|
||||||
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
||||||
ssl_cert_issue
|
if ssl_cert_issue; then
|
||||||
# Extract the domain that was used from the certificate
|
local cert_domain="${SSL_ISSUED_DOMAIN}"
|
||||||
local cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
if [[ -z "${cert_domain}" ]]; then
|
||||||
if [[ -n "${cert_domain}" ]]; then
|
cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
||||||
SSL_HOST="${cert_domain}"
|
fi
|
||||||
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
|
||||||
|
if [[ -n "${cert_domain}" ]]; then
|
||||||
|
SSL_HOST="${cert_domain}"
|
||||||
|
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
||||||
|
else
|
||||||
|
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
|
||||||
|
SSL_HOST="${server_ip}"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
|
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
|
||||||
SSL_HOST="${server_ip}"
|
SSL_HOST="${server_ip}"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
@ -608,7 +630,7 @@ prompt_and_setup_ssl() {
|
||||||
|
|
||||||
# 3.1 Request Domain to compose Panel URL later
|
# 3.1 Request Domain to compose Panel URL later
|
||||||
read -rp "Please enter domain name certificate issued for: " custom_domain
|
read -rp "Please enter domain name certificate issued for: " custom_domain
|
||||||
custom_domain="${custom_domain// /}" # Убираем пробелы
|
custom_domain="${custom_domain// /}" # Remove spaces
|
||||||
|
|
||||||
# 3.2 Loop for Certificate Path
|
# 3.2 Loop for Certificate Path
|
||||||
while true; do
|
while true; do
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,14 @@ import (
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/net"
|
"github.com/shirou/gopsutil/v4/net"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var SIGUSR1 = syscall.SIGUSR1
|
||||||
|
|
||||||
func GetTCPCount() (int, error) {
|
func GetTCPCount() (int, error) {
|
||||||
stats, err := net.Connections("tcp")
|
stats, err := net.Connections("tcp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,11 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var SIGUSR1 = syscall.SIGUSR1
|
||||||
|
|
||||||
func getLinesNum(filename string) (int, error) {
|
func getLinesNum(filename string) (int, error) {
|
||||||
file, err := os.Open(filename)
|
file, err := os.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import (
|
||||||
"github.com/shirou/gopsutil/v4/net"
|
"github.com/shirou/gopsutil/v4/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var SIGUSR1 = syscall.Signal(0)
|
||||||
|
|
||||||
// GetConnectionCount returns the number of active connections for the specified protocol ("tcp" or "udp").
|
// 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" {
|
||||||
|
|
|
||||||
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
|
|
@ -90,7 +90,16 @@ class DBInbound {
|
||||||
return this.expiryTime < new Date().getTime();
|
return this.expiryTime < new Date().getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateCache() {
|
||||||
|
this._cachedInbound = null;
|
||||||
|
this._clientStatsMap = null;
|
||||||
|
}
|
||||||
|
|
||||||
toInbound() {
|
toInbound() {
|
||||||
|
if (this._cachedInbound) {
|
||||||
|
return this._cachedInbound;
|
||||||
|
}
|
||||||
|
|
||||||
let settings = {};
|
let settings = {};
|
||||||
if (!ObjectUtil.isEmpty(this.settings)) {
|
if (!ObjectUtil.isEmpty(this.settings)) {
|
||||||
settings = JSON.parse(this.settings);
|
settings = JSON.parse(this.settings);
|
||||||
|
|
@ -116,7 +125,21 @@ class DBInbound {
|
||||||
sniffing: sniffing,
|
sniffing: sniffing,
|
||||||
clientStats: this.clientStats,
|
clientStats: this.clientStats,
|
||||||
};
|
};
|
||||||
return Inbound.fromJson(config);
|
|
||||||
|
this._cachedInbound = Inbound.fromJson(config);
|
||||||
|
return this._cachedInbound;
|
||||||
|
}
|
||||||
|
|
||||||
|
getClientStats(email) {
|
||||||
|
if (!this._clientStatsMap) {
|
||||||
|
this._clientStatsMap = new Map();
|
||||||
|
if (this.clientStats && Array.isArray(this.clientStats)) {
|
||||||
|
for (const stats of this.clientStats) {
|
||||||
|
this._clientStatsMap.set(stats.email, stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this._clientStatsMap.get(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
isMultiUser() {
|
isMultiUser() {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ class AllSetting {
|
||||||
this.subPort = 2096;
|
this.subPort = 2096;
|
||||||
this.subPath = "/sub/";
|
this.subPath = "/sub/";
|
||||||
this.subJsonPath = "/json/";
|
this.subJsonPath = "/json/";
|
||||||
|
this.subClashEnable = true;
|
||||||
|
this.subClashPath = "/clash/";
|
||||||
this.subDomain = "";
|
this.subDomain = "";
|
||||||
this.externalTrafficInformEnable = false;
|
this.externalTrafficInformEnable = false;
|
||||||
this.externalTrafficInformURI = "";
|
this.externalTrafficInformURI = "";
|
||||||
|
|
@ -48,6 +50,7 @@ class AllSetting {
|
||||||
this.subShowInfo = true;
|
this.subShowInfo = true;
|
||||||
this.subURI = "";
|
this.subURI = "";
|
||||||
this.subJsonURI = "";
|
this.subJsonURI = "";
|
||||||
|
this.subClashURI = "";
|
||||||
this.subJsonFragment = "";
|
this.subJsonFragment = "";
|
||||||
this.subJsonNoises = "";
|
this.subJsonNoises = "";
|
||||||
this.subJsonMux = "";
|
this.subJsonMux = "";
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
sId: el.getAttribute('data-sid') || '',
|
sId: el.getAttribute('data-sid') || '',
|
||||||
subUrl: el.getAttribute('data-sub-url') || '',
|
subUrl: el.getAttribute('data-sub-url') || '',
|
||||||
subJsonUrl: el.getAttribute('data-subjson-url') || '',
|
subJsonUrl: el.getAttribute('data-subjson-url') || '',
|
||||||
|
subClashUrl: el.getAttribute('data-subclash-url') || '',
|
||||||
download: el.getAttribute('data-download') || '',
|
download: el.getAttribute('data-download') || '',
|
||||||
upload: el.getAttribute('data-upload') || '',
|
upload: el.getAttribute('data-upload') || '',
|
||||||
used: el.getAttribute('data-used') || '',
|
used: el.getAttribute('data-used') || '',
|
||||||
|
|
@ -98,13 +99,19 @@
|
||||||
this.lang = LanguageManager.getLanguage();
|
this.lang = LanguageManager.getLanguage();
|
||||||
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') : '';
|
||||||
|
const sc = tpl ? tpl.getAttribute('data-subclash-url') : '';
|
||||||
if (sj) this.app.subJsonUrl = sj;
|
if (sj) this.app.subJsonUrl = sj;
|
||||||
|
if (sc) this.app.subClashUrl = sc;
|
||||||
drawQR(this.app.subUrl);
|
drawQR(this.app.subUrl);
|
||||||
try {
|
try {
|
||||||
const elJson = document.getElementById('qrcode-subjson');
|
const elJson = document.getElementById('qrcode-subjson');
|
||||||
if (elJson && this.app.subJsonUrl) {
|
if (elJson && this.app.subJsonUrl) {
|
||||||
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
|
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
|
||||||
}
|
}
|
||||||
|
const elClash = document.getElementById('qrcode-subclash');
|
||||||
|
if (elClash && this.app.subClashUrl) {
|
||||||
|
new QRious({ element: elClash, value: this.app.subClashUrl, size: 220 });
|
||||||
|
}
|
||||||
} catch (e) { /* ignore */ }
|
} 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);
|
||||||
|
|
@ -144,7 +151,7 @@
|
||||||
return this.app.subUrl;
|
return this.app.subUrl;
|
||||||
},
|
},
|
||||||
happUrl() {
|
happUrl() {
|
||||||
return `happ://add/${encodeURIComponent(this.app.subUrl)}`;
|
return `happ://add/${this.app.subUrl}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@ type APIController struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPIController creates a new APIController instance and initializes its routes.
|
// NewAPIController creates a new APIController instance and initializes its routes.
|
||||||
func NewAPIController(g *gin.RouterGroup) *APIController {
|
func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *APIController {
|
||||||
a := &APIController{}
|
a := &APIController{}
|
||||||
a.initRouter(g)
|
a.initRouter(g, customGeo)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,7 +35,7 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// initRouter sets up the API routes for inbounds, server, and other endpoints.
|
// 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, customGeo *service.CustomGeoService) {
|
||||||
// Main API group
|
// Main API group
|
||||||
api := g.Group("/panel/api")
|
api := g.Group("/panel/api")
|
||||||
api.Use(a.checkAPIAuth)
|
api.Use(a.checkAPIAuth)
|
||||||
|
|
@ -48,6 +48,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||||
server := api.Group("/server")
|
server := api.Group("/server")
|
||||||
a.serverController = NewServerController(server)
|
a.serverController = NewServerController(server)
|
||||||
|
|
||||||
|
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
|
||||||
|
|
||||||
// Extra routes
|
// Extra routes
|
||||||
api.GET("/backuptotgbot", a.BackuptoTgbot)
|
api.GET("/backuptotgbot", a.BackuptoTgbot)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
174
web/controller/custom_geo.go
Normal file
174
web/controller/custom_geo.go
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/entity"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CustomGeoController struct {
|
||||||
|
BaseController
|
||||||
|
customGeoService *service.CustomGeoService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCustomGeoController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *CustomGeoController {
|
||||||
|
a := &CustomGeoController{customGeoService: customGeo}
|
||||||
|
a.initRouter(g)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CustomGeoController) initRouter(g *gin.RouterGroup) {
|
||||||
|
g.GET("/list", a.list)
|
||||||
|
g.GET("/aliases", a.aliases)
|
||||||
|
g.POST("/add", a.add)
|
||||||
|
g.POST("/update/:id", a.update)
|
||||||
|
g.POST("/delete/:id", a.delete)
|
||||||
|
g.POST("/download/:id", a.download)
|
||||||
|
g.POST("/update-all", a.updateAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapCustomGeoErr(c *gin.Context, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrCustomGeoInvalidType):
|
||||||
|
return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidType"))
|
||||||
|
case errors.Is(err, service.ErrCustomGeoAliasRequired):
|
||||||
|
return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasRequired"))
|
||||||
|
case errors.Is(err, service.ErrCustomGeoAliasPattern):
|
||||||
|
return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasPattern"))
|
||||||
|
case errors.Is(err, service.ErrCustomGeoAliasReserved):
|
||||||
|
return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasReserved"))
|
||||||
|
case errors.Is(err, service.ErrCustomGeoURLRequired):
|
||||||
|
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlRequired"))
|
||||||
|
case errors.Is(err, service.ErrCustomGeoInvalidURL):
|
||||||
|
return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidUrl"))
|
||||||
|
case errors.Is(err, service.ErrCustomGeoURLScheme):
|
||||||
|
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlScheme"))
|
||||||
|
case errors.Is(err, service.ErrCustomGeoURLHost):
|
||||||
|
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
|
||||||
|
case errors.Is(err, service.ErrCustomGeoDuplicateAlias):
|
||||||
|
return errors.New(I18nWeb(c, "pages.index.customGeoErrDuplicateAlias"))
|
||||||
|
case errors.Is(err, service.ErrCustomGeoNotFound):
|
||||||
|
return errors.New(I18nWeb(c, "pages.index.customGeoErrNotFound"))
|
||||||
|
case errors.Is(err, service.ErrCustomGeoDownload):
|
||||||
|
logger.Warning("custom geo download:", err)
|
||||||
|
return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CustomGeoController) list(c *gin.Context) {
|
||||||
|
list, err := a.customGeoService.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastList"), mapCustomGeoErr(c, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, list, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CustomGeoController) aliases(c *gin.Context) {
|
||||||
|
out, err := a.customGeoService.GetAliasesForUI()
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.index.customGeoAliasesError"), mapCustomGeoErr(c, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, out, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type customGeoForm struct {
|
||||||
|
Type string `json:"type" form:"type"`
|
||||||
|
Alias string `json:"alias" form:"alias"`
|
||||||
|
Url string `json:"url" form:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CustomGeoController) add(c *gin.Context) {
|
||||||
|
var form customGeoForm
|
||||||
|
if err := c.ShouldBind(&form); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := &model.CustomGeoResource{
|
||||||
|
Type: form.Type,
|
||||||
|
Alias: form.Alias,
|
||||||
|
Url: form.Url,
|
||||||
|
}
|
||||||
|
err := a.customGeoService.Create(r)
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), mapCustomGeoErr(c, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCustomGeoID(c *gin.Context, idStr string) (int, bool) {
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), err)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if id <= 0 {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), errors.New(""))
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CustomGeoController) update(c *gin.Context) {
|
||||||
|
id, ok := parseCustomGeoID(c, c.Param("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var form customGeoForm
|
||||||
|
if bindErr := c.ShouldBind(&form); bindErr != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), bindErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := &model.CustomGeoResource{
|
||||||
|
Type: form.Type,
|
||||||
|
Alias: form.Alias,
|
||||||
|
Url: form.Url,
|
||||||
|
}
|
||||||
|
err := a.customGeoService.Update(id, r)
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), mapCustomGeoErr(c, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CustomGeoController) delete(c *gin.Context) {
|
||||||
|
id, ok := parseCustomGeoID(c, c.Param("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, err := a.customGeoService.Delete(id)
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDelete", "fileName=="+name), mapCustomGeoErr(c, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CustomGeoController) download(c *gin.Context) {
|
||||||
|
id, ok := parseCustomGeoID(c, c.Param("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, err := a.customGeoService.TriggerUpdate(id)
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDownload", "fileName=="+name), mapCustomGeoErr(c, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CustomGeoController) updateAll(c *gin.Context) {
|
||||||
|
res, err := a.customGeoService.TriggerUpdateAll()
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), mapCustomGeoErr(c, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(res.Failed) > 0 {
|
||||||
|
c.JSON(http.StatusOK, entity.Msg{
|
||||||
|
Success: false,
|
||||||
|
Msg: I18nWeb(c, "pages.index.customGeoErrUpdateAllIncomplete"),
|
||||||
|
Obj: res,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsgObj(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), res, nil)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -71,14 +72,22 @@ func (a *IndexController) login(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
|
user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
|
||||||
timeStr := time.Now().Format("2006-01-02 15:04:05")
|
timeStr := time.Now().Format("2006-01-02 15:04:05")
|
||||||
safeUser := template.HTMLEscapeString(form.Username)
|
safeUser := template.HTMLEscapeString(form.Username)
|
||||||
safePass := template.HTMLEscapeString(form.Password)
|
safePass := template.HTMLEscapeString(form.Password)
|
||||||
|
|
||||||
if user == nil {
|
if user == nil {
|
||||||
logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c))
|
logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c))
|
||||||
a.tgbot.UserLoginNotify(safeUser, safePass, getRemoteIp(c), timeStr, 0)
|
|
||||||
|
notifyPass := safePass
|
||||||
|
|
||||||
|
if checkErr != nil && checkErr.Error() == "invalid 2fa code" {
|
||||||
|
translatedError := a.tgbot.I18nBot("tgbot.messages.2faFailed")
|
||||||
|
notifyPass = fmt.Sprintf("*** (%s)", translatedError)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.tgbot.UserLoginNotify(safeUser, notifyPass, getRemoteIp(c), timeStr, 0)
|
||||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,17 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m.Success = false
|
m.Success = false
|
||||||
m.Msg = msg + " (" + err.Error() + ")"
|
errStr := err.Error()
|
||||||
logger.Warning(msg+" "+I18nWeb(c, "fail")+": ", err)
|
if errStr != "" {
|
||||||
|
m.Msg = msg + " (" + errStr + ")"
|
||||||
|
logger.Warning(msg+" "+I18nWeb(c, "fail")+": ", err)
|
||||||
|
} else if msg != "" {
|
||||||
|
m.Msg = msg
|
||||||
|
logger.Warning(msg + " " + I18nWeb(c, "fail"))
|
||||||
|
} else {
|
||||||
|
m.Msg = I18nWeb(c, "somethingWentWrong")
|
||||||
|
logger.Warning(I18nWeb(c, "somethingWentWrong") + " " + I18nWeb(c, "fail"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, m)
|
c.JSON(http.StatusOK, m)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,10 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = ws.Upgrader{
|
var upgrader = ws.Upgrader{
|
||||||
ReadBufferSize: 4096, // Increased from 1024 for better performance
|
ReadBufferSize: 32768,
|
||||||
WriteBufferSize: 4096, // Increased from 1024 for better performance
|
WriteBufferSize: 32768,
|
||||||
|
EnableCompression: true, // Negotiate permessage-deflate compression if the client supports it
|
||||||
|
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
// Check origin for security
|
// Check origin for security
|
||||||
origin := r.Header.Get("Origin")
|
origin := r.Header.Get("Origin")
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,9 @@ type AllSetting struct {
|
||||||
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
|
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
|
||||||
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
|
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
|
||||||
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
|
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
|
||||||
|
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
|
||||||
|
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
|
||||||
|
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
|
||||||
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
|
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
|
||||||
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
||||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
||||||
|
|
@ -168,6 +171,13 @@ func (s *AllSetting) CheckValid() error {
|
||||||
s.SubJsonPath += "/"
|
s.SubJsonPath += "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(s.SubClashPath, "/") {
|
||||||
|
s.SubClashPath = "/" + s.SubClashPath
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(s.SubClashPath, "/") {
|
||||||
|
s.SubClashPath += "/"
|
||||||
|
}
|
||||||
|
|
||||||
_, err := time.LoadLocation(s.TimeLocation)
|
_, err := time.LoadLocation(s.TimeLocation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return common.NewError("time location not exist:", s.TimeLocation)
|
return common.NewError("time location not exist:", s.TimeLocation)
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
<a-select-option value="never">{{ i18n
|
<a-select-option value="never">{{ i18n
|
||||||
"pages.inbounds.periodicTrafficReset.never" }}</a-select-option>
|
"pages.inbounds.periodicTrafficReset.never" }}</a-select-option>
|
||||||
|
<a-select-option value="hourly">{{ i18n
|
||||||
|
"pages.inbounds.periodicTrafficReset.hourly" }}</a-select-option>
|
||||||
<a-select-option value="daily">{{ i18n
|
<a-select-option value="daily">{{ i18n
|
||||||
"pages.inbounds.periodicTrafficReset.daily" }}</a-select-option>
|
"pages.inbounds.periodicTrafficReset.daily" }}</a-select-option>
|
||||||
<a-select-option value="weekly">{{ i18n
|
<a-select-option value="weekly">{{ i18n
|
||||||
|
|
|
||||||
|
|
@ -612,7 +612,7 @@
|
||||||
</a-divider>
|
</a-divider>
|
||||||
<a-form-item label='Type'>
|
<a-form-item label='Type'>
|
||||||
<a-select v-model="mask.type"
|
<a-select v-model="mask.type"
|
||||||
@change="(type) => mask.settings = mask._getDefaultSettings(type, {})"
|
@change="(type) => { mask.settings = mask._getDefaultSettings(type, {}); if(outbound.stream.network === 'kcp') { outbound.stream.kcp.mtu = type === 'xdns' ? 900 : 1350; } }"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
<!-- Salamander for Hysteria2 only -->
|
<!-- Salamander for Hysteria2 only -->
|
||||||
<a-select-option v-if="outbound.protocol === Protocols.Hysteria"
|
<a-select-option v-if="outbound.protocol === Protocols.Hysteria"
|
||||||
|
|
@ -643,9 +643,9 @@
|
||||||
<a-select-option v-if="outbound.stream.network === 'kcp'"
|
<a-select-option v-if="outbound.stream.network === 'kcp'"
|
||||||
value="mkcp-original">
|
value="mkcp-original">
|
||||||
mKCP Original</a-select-option>
|
mKCP Original</a-select-option>
|
||||||
<!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP -->
|
<!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP/KCP -->
|
||||||
<a-select-option
|
<a-select-option
|
||||||
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp'].includes(outbound.stream.network)"
|
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp', 'kcp'].includes(outbound.stream.network)"
|
||||||
value="xdns">
|
value="xdns">
|
||||||
xDNS (Experimental)</a-select-option>
|
xDNS (Experimental)</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
</a-divider>
|
</a-divider>
|
||||||
<a-form-item label='Type'>
|
<a-form-item label='Type'>
|
||||||
<a-select v-model="mask.type"
|
<a-select v-model="mask.type"
|
||||||
@change="(type) => mask.settings = mask._getDefaultSettings(type, {})"
|
@change="(type) => { mask.settings = mask._getDefaultSettings(type, {}); if(inbound.stream.network === 'kcp') { inbound.stream.kcp.mtu = type === 'xdns' ? 900 : 1350; } }"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
<!-- mKCP-specific masks -->
|
<!-- mKCP-specific masks -->
|
||||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
||||||
|
|
@ -48,9 +48,9 @@
|
||||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
||||||
value="xicmp">
|
value="xicmp">
|
||||||
xICMP (Experimental)</a-select-option>
|
xICMP (Experimental)</a-select-option>
|
||||||
<!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP -->
|
<!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP/KCP -->
|
||||||
<a-select-option
|
<a-select-option
|
||||||
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp'].includes(inbound.stream.network)"
|
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp', 'kcp'].includes(inbound.stream.network)"
|
||||||
value="xdns">
|
value="xdns">
|
||||||
xDNS (Experimental)</a-select-option>
|
xDNS (Experimental)</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@
|
||||||
<a-select-option
|
<a-select-option
|
||||||
value="queryInHeader">queryInHeader</a-select-option>
|
value="queryInHeader">queryInHeader</a-select-option>
|
||||||
<a-select-option value="header">header</a-select-option>
|
<a-select-option value="header">header</a-select-option>
|
||||||
|
<a-select-option value="cookie">cookie</a-select-option>
|
||||||
|
<a-select-option value="query">query</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Padding Method">
|
<a-form-item label="Padding Method">
|
||||||
|
|
@ -127,6 +129,7 @@
|
||||||
<a-select-option value>Default (body)</a-select-option>
|
<a-select-option value>Default (body)</a-select-option>
|
||||||
<a-select-option value="body">body</a-select-option>
|
<a-select-option value="body">body</a-select-option>
|
||||||
<a-select-option value="header">header</a-select-option>
|
<a-select-option value="header">header</a-select-option>
|
||||||
|
<a-select-option value="cookie">cookie</a-select-option>
|
||||||
<a-select-option value="query">query</a-select-option>
|
<a-select-option value="query">query</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<a-sidebar></a-sidebar>
|
<a-sidebar></a-sidebar>
|
||||||
<a-layout id="content-layout">
|
<a-layout id="content-layout">
|
||||||
<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"}}' size="large">
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
|
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
|
||||||
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
||||||
|
|
@ -14,10 +14,7 @@
|
||||||
</transition>
|
</transition>
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<a-row v-if="!loadingStates.fetched">
|
<a-row v-if="!loadingStates.fetched">
|
||||||
<a-card
|
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
|
||||||
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
|
|
||||||
<a-spin tip='{{ i18n "loading" }}'></a-spin>
|
|
||||||
</a-card>
|
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
||||||
<a-col>
|
<a-col>
|
||||||
|
|
@ -1101,7 +1098,10 @@
|
||||||
}
|
}
|
||||||
data.sniffing = inbound.sniffing.toString();
|
data.sniffing = inbound.sniffing.toString();
|
||||||
|
|
||||||
await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, data, inModal);
|
const formData = new FormData();
|
||||||
|
Object.keys(data).forEach(key => formData.append(key, data[key]));
|
||||||
|
|
||||||
|
await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, formData, inModal);
|
||||||
},
|
},
|
||||||
openAddClient(dbInboundId) {
|
openAddClient(dbInboundId) {
|
||||||
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
|
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
|
||||||
|
|
@ -1291,9 +1291,36 @@
|
||||||
infoModal.show(newDbInbound, index);
|
infoModal.show(newDbInbound, index);
|
||||||
},
|
},
|
||||||
switchEnable(dbInboundId, state) {
|
switchEnable(dbInboundId, state) {
|
||||||
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
|
let dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
|
||||||
|
if (!dbInbound) return;
|
||||||
dbInbound.enable = state;
|
dbInbound.enable = state;
|
||||||
this.submit(`/panel/api/inbounds/update/${dbInboundId}`, dbInbound);
|
let inbound = dbInbound.toInbound();
|
||||||
|
const data = {
|
||||||
|
up: dbInbound.up,
|
||||||
|
down: dbInbound.down,
|
||||||
|
total: dbInbound.total,
|
||||||
|
remark: dbInbound.remark,
|
||||||
|
enable: dbInbound.enable,
|
||||||
|
expiryTime: dbInbound.expiryTime,
|
||||||
|
trafficReset: dbInbound.trafficReset,
|
||||||
|
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
|
||||||
|
|
||||||
|
listen: inbound.listen,
|
||||||
|
port: inbound.port,
|
||||||
|
protocol: inbound.protocol,
|
||||||
|
settings: inbound.settings.toString(),
|
||||||
|
};
|
||||||
|
if (inbound.canEnableStream()) {
|
||||||
|
data.streamSettings = inbound.stream.toString();
|
||||||
|
} else if (inbound.stream?.sockopt) {
|
||||||
|
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
|
||||||
|
}
|
||||||
|
data.sniffing = inbound.sniffing.toString();
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
Object.keys(data).forEach(key => formData.append(key, data[key]));
|
||||||
|
|
||||||
|
this.submit(`/panel/api/inbounds/update/${dbInboundId}`, formData);
|
||||||
},
|
},
|
||||||
async switchEnableClient(dbInboundId, client) {
|
async switchEnableClient(dbInboundId, client) {
|
||||||
this.loading()
|
this.loading()
|
||||||
|
|
@ -1367,42 +1394,54 @@
|
||||||
isExpiry(dbInbound, index) {
|
isExpiry(dbInbound, index) {
|
||||||
return dbInbound.toInbound().isExpiry(index);
|
return dbInbound.toInbound().isExpiry(index);
|
||||||
},
|
},
|
||||||
|
getClientStats(dbInbound, email) {
|
||||||
|
if (!dbInbound) return null;
|
||||||
|
if (!dbInbound._clientStatsMap) {
|
||||||
|
dbInbound._clientStatsMap = new Map();
|
||||||
|
if (dbInbound.clientStats && Array.isArray(dbInbound.clientStats)) {
|
||||||
|
for (const stats of dbInbound.clientStats) {
|
||||||
|
dbInbound._clientStatsMap.set(stats.email, stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dbInbound._clientStatsMap.get(email);
|
||||||
|
},
|
||||||
getUpStats(dbInbound, email) {
|
getUpStats(dbInbound, email) {
|
||||||
if (email.length == 0) return 0;
|
if (!email || email.length == 0) return 0;
|
||||||
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
|
let clientStats = this.getClientStats(dbInbound, email);
|
||||||
return clientStats ? clientStats.up : 0;
|
return clientStats ? clientStats.up : 0;
|
||||||
},
|
},
|
||||||
getDownStats(dbInbound, email) {
|
getDownStats(dbInbound, email) {
|
||||||
if (email.length == 0) return 0;
|
if (!email || email.length == 0) return 0;
|
||||||
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
|
let clientStats = this.getClientStats(dbInbound, email);
|
||||||
return clientStats ? clientStats.down : 0;
|
return clientStats ? clientStats.down : 0;
|
||||||
},
|
},
|
||||||
getSumStats(dbInbound, email) {
|
getSumStats(dbInbound, email) {
|
||||||
if (email.length == 0) return 0;
|
if (!email || email.length == 0) return 0;
|
||||||
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
|
let clientStats = this.getClientStats(dbInbound, email);
|
||||||
return clientStats ? clientStats.up + clientStats.down : 0;
|
return clientStats ? clientStats.up + clientStats.down : 0;
|
||||||
},
|
},
|
||||||
getAllTimeClient(dbInbound, email) {
|
getAllTimeClient(dbInbound, email) {
|
||||||
if (email.length == 0) return 0;
|
if (!email || email.length == 0) return 0;
|
||||||
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
|
let clientStats = this.getClientStats(dbInbound, email);
|
||||||
if (!clientStats) return 0;
|
if (!clientStats) return 0;
|
||||||
return clientStats.allTime || (clientStats.up + clientStats.down);
|
return clientStats.allTime || (clientStats.up + clientStats.down);
|
||||||
},
|
},
|
||||||
getRemStats(dbInbound, email) {
|
getRemStats(dbInbound, email) {
|
||||||
if (email.length == 0) return 0;
|
if (!email || email.length == 0) return 0;
|
||||||
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
|
let clientStats = this.getClientStats(dbInbound, email);
|
||||||
if (!clientStats) return 0;
|
if (!clientStats) return 0;
|
||||||
remained = clientStats.total - (clientStats.up + clientStats.down);
|
let remained = clientStats.total - (clientStats.up + clientStats.down);
|
||||||
return remained > 0 ? remained : 0;
|
return remained > 0 ? remained : 0;
|
||||||
},
|
},
|
||||||
clientStatsColor(dbInbound, email) {
|
clientStatsColor(dbInbound, email) {
|
||||||
if (email.length == 0) return ColorUtils.clientUsageColor();
|
if (!email || email.length == 0) return ColorUtils.clientUsageColor();
|
||||||
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
|
let clientStats = this.getClientStats(dbInbound, email);
|
||||||
return ColorUtils.clientUsageColor(clientStats, app.trafficDiff)
|
return ColorUtils.clientUsageColor(clientStats, app.trafficDiff)
|
||||||
},
|
},
|
||||||
statsProgress(dbInbound, email) {
|
statsProgress(dbInbound, email) {
|
||||||
if (email.length == 0) return 100;
|
if (!email || email.length == 0) return 100;
|
||||||
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
|
let clientStats = this.getClientStats(dbInbound, email);
|
||||||
if (!clientStats) return 0;
|
if (!clientStats) return 0;
|
||||||
if (clientStats.total == 0) return 100;
|
if (clientStats.total == 0) return 100;
|
||||||
return 100 * (clientStats.down + clientStats.up) / clientStats.total;
|
return 100 * (clientStats.down + clientStats.up) / clientStats.total;
|
||||||
|
|
@ -1415,11 +1454,11 @@
|
||||||
return 100 * (1 - (remainedSeconds / resetSeconds));
|
return 100 * (1 - (remainedSeconds / resetSeconds));
|
||||||
},
|
},
|
||||||
statsExpColor(dbInbound, email) {
|
statsExpColor(dbInbound, email) {
|
||||||
if (email.length == 0) return '#7a316f';
|
if (!email || email.length == 0) return '#7a316f';
|
||||||
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
|
let clientStats = this.getClientStats(dbInbound, email);
|
||||||
if (!clientStats) return '#7a316f';
|
if (!clientStats) return '#7a316f';
|
||||||
statsColor = ColorUtils.usageColor(clientStats.down + clientStats.up, this.trafficDiff, clientStats.total);
|
let statsColor = ColorUtils.usageColor(clientStats.down + clientStats.up, this.trafficDiff, clientStats.total);
|
||||||
expColor = ColorUtils.usageColor(new Date().getTime(), this.expireDiff, clientStats.expiryTime);
|
let expColor = ColorUtils.usageColor(new Date().getTime(), this.expireDiff, clientStats.expiryTime);
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case statsColor == "red" || expColor == "red":
|
case statsColor == "red" || expColor == "red":
|
||||||
return "#cf3c3c"; // Red
|
return "#cf3c3c"; // Red
|
||||||
|
|
@ -1432,12 +1471,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isClientEnabled(dbInbound, email) {
|
isClientEnabled(dbInbound, email) {
|
||||||
clientStats = dbInbound.clientStats ? dbInbound.clientStats.find(stats => stats.email === email) : null;
|
let clientStats = dbInbound ? this.getClientStats(dbInbound, email) : null;
|
||||||
return clientStats ? clientStats['enable'] : true;
|
return clientStats ? clientStats['enable'] : true;
|
||||||
},
|
},
|
||||||
isClientDepleted(dbInbound, email) {
|
isClientDepleted(dbInbound, email) {
|
||||||
if (!email || !dbInbound || !dbInbound.clientStats) return false;
|
if (!email || !dbInbound) return false;
|
||||||
const stats = dbInbound.clientStats.find(s => s.email === email);
|
const stats = this.getClientStats(dbInbound, email);
|
||||||
if (!stats) return false;
|
if (!stats) return false;
|
||||||
const total = stats.total ?? 0;
|
const total = stats.total ?? 0;
|
||||||
const used = (stats.up ?? 0) + (stats.down ?? 0);
|
const used = (stats.up ?? 0) + (stats.down ?? 0);
|
||||||
|
|
@ -1557,12 +1596,18 @@
|
||||||
pagination(obj) {
|
pagination(obj) {
|
||||||
if (this.pageSize > 0 && obj.length > this.pageSize) {
|
if (this.pageSize > 0 && obj.length > this.pageSize) {
|
||||||
// Set page options based on object size
|
// Set page options based on object size
|
||||||
sizeOptions = [];
|
let sizeOptions = [this.pageSize.toString()];
|
||||||
for (i = this.pageSize; i <= obj.length; i = i + this.pageSize) {
|
const increments = [2, 5, 10, 20];
|
||||||
sizeOptions.push(i.toString());
|
for (const m of increments) {
|
||||||
|
const val = this.pageSize * m;
|
||||||
|
if (val < obj.length && val <= 1000) {
|
||||||
|
sizeOptions.push(val.toString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Add option to see all in one page
|
// Add option to see all in one page
|
||||||
sizeOptions.push(i.toString());
|
if (!sizeOptions.includes(obj.length.toString())) {
|
||||||
|
sizeOptions.push(obj.length.toString());
|
||||||
|
}
|
||||||
|
|
||||||
p = {
|
p = {
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
|
@ -1605,11 +1650,25 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for invalidate signals (sent when payload is too large for WebSocket)
|
||||||
|
// The server sends a lightweight notification and we re-fetch via REST API
|
||||||
|
let invalidateTimer = null;
|
||||||
|
window.wsClient.on('invalidate', (payload) => {
|
||||||
|
if (payload && (payload.type === 'inbounds' || payload.type === 'traffic')) {
|
||||||
|
// Debounce to avoid flooding the REST API with multiple invalidate signals
|
||||||
|
if (invalidateTimer) clearTimeout(invalidateTimer);
|
||||||
|
invalidateTimer = setTimeout(() => {
|
||||||
|
invalidateTimer = null;
|
||||||
|
this.getDBInbounds();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Listen for traffic updates
|
// Listen for traffic updates
|
||||||
window.wsClient.on('traffic', (payload) => {
|
window.wsClient.on('traffic', (payload) => {
|
||||||
// Note: Do NOT update total consumed traffic (stats.up, stats.down) from this event
|
// Note: Do NOT update total consumed traffic (stats.up, stats.down) from this event
|
||||||
// because clientTraffics contains delta/incremental values, not total accumulated values.
|
// because clientTraffics contains delta/incremental values, not total accumulated values.
|
||||||
// Total traffic is updated via the 'inbounds' event which contains accumulated values from database.
|
// Total traffic is updated via the 'inbounds' WebSocket event (or 'invalidate' fallback for large panels).
|
||||||
|
|
||||||
// Update online clients list in real-time
|
// Update online clients list in real-time
|
||||||
if (payload && Array.isArray(payload.onlineClients)) {
|
if (payload && Array.isArray(payload.onlineClients)) {
|
||||||
|
|
@ -1627,22 +1686,27 @@
|
||||||
this.onlineClients = nextOnlineClients;
|
this.onlineClients = nextOnlineClients;
|
||||||
if (onlineChanged) {
|
if (onlineChanged) {
|
||||||
// Recalculate client counts to update online status
|
// Recalculate client counts to update online status
|
||||||
|
// Use $set for Vue 2 reactivity — direct array index assignment is not reactive
|
||||||
this.dbInbounds.forEach(dbInbound => {
|
this.dbInbounds.forEach(dbInbound => {
|
||||||
const inbound = this.inbounds.find(ib => ib.id === dbInbound.id);
|
const inbound = this.inbounds.find(ib => ib.id === dbInbound.id);
|
||||||
if (inbound && this.clientCount[dbInbound.id]) {
|
if (inbound && this.clientCount[dbInbound.id]) {
|
||||||
this.clientCount[dbInbound.id] = this.getClientCounts(dbInbound, inbound);
|
this.$set(this.clientCount, dbInbound.id, this.getClientCounts(dbInbound, inbound));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Always trigger UI refresh — not just when filter is enabled
|
||||||
if (this.enableFilter) {
|
if (this.enableFilter) {
|
||||||
this.filterInbounds();
|
this.filterInbounds();
|
||||||
|
} else {
|
||||||
|
this.searchInbounds(this.searchKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last online map in real-time
|
// Update last online map in real-time
|
||||||
|
// Replace entirely (server sends the full map) to avoid unbounded growth from deleted clients
|
||||||
if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
|
if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
|
||||||
this.lastOnlineMap = { ...this.lastOnlineMap, ...payload.lastOnlineMap };
|
this.lastOnlineMap = payload.lastOnlineMap;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1697,4 +1761,18 @@
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
#content-layout > .ant-layout-content > .ant-spin-nested-loading > div > .ant-spin {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 50vh !important;
|
||||||
|
left: calc(50vw + 100px) !important;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 99999 !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#content-layout > .ant-layout-content > .ant-spin-nested-loading > div > .ant-spin {
|
||||||
|
left: 50vw !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{{ template "page/body_end" .}}
|
{{ template "page/body_end" .}}
|
||||||
|
|
@ -2,11 +2,25 @@
|
||||||
{{ template "page/head_end" .}}
|
{{ template "page/head_end" .}}
|
||||||
|
|
||||||
{{ template "page/body_start" .}}
|
{{ template "page/body_start" .}}
|
||||||
|
<style>
|
||||||
|
body.dark .custom-geo-section code.custom-geo-ext-code {
|
||||||
|
color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.85));
|
||||||
|
background: var(--dark-color-surface-200, #222d42);
|
||||||
|
border: 1px solid var(--dark-color-stroke, #2c3950);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
html[data-theme="ultra-dark"] body.dark .custom-geo-section code.custom-geo-ext-code {
|
||||||
|
color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.88));
|
||||||
|
background: var(--dark-color-surface-700, #111929);
|
||||||
|
border-color: var(--dark-color-stroke, #2c3950);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' index-page'">
|
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' index-page'">
|
||||||
<a-sidebar></a-sidebar>
|
<a-sidebar></a-sidebar>
|
||||||
<a-layout id="content-layout">
|
<a-layout id="content-layout">
|
||||||
<a-layout-content>
|
<a-layout-content>
|
||||||
<a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip">
|
<a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip" size="large">
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched" class="mb-10"
|
<a-alert type="error" v-if="showAlert && loadingStates.fetched" class="mb-10"
|
||||||
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
||||||
|
|
@ -15,9 +29,7 @@
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<template>
|
<template>
|
||||||
<a-row v-if="!loadingStates.fetched">
|
<a-row v-if="!loadingStates.fetched">
|
||||||
<a-card class="card-placeholder text-center">
|
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
|
||||||
<a-spin tip='{{ i18n "loading" }}'></a-spin>
|
|
||||||
</a-card>
|
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
||||||
<a-col>
|
<a-col>
|
||||||
|
|
@ -107,7 +119,7 @@
|
||||||
</a-row>
|
</a-row>
|
||||||
</span>
|
</span>
|
||||||
<template slot="content">
|
<template slot="content">
|
||||||
<span class="max-w-400" v-for="line in status.xray.errorMsg.split('\n')">[[ line ]]</span>
|
<span class="max-w-400" v-for="line in (status.xray.errorMsg || '').split('\n')">[[ line ]]</span>
|
||||||
</template>
|
</template>
|
||||||
<a-badge :text="status.xray.stateMsg" :color="status.xray.color"
|
<a-badge :text="status.xray.stateMsg" :color="status.xray.color"
|
||||||
:class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
|
:class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
|
||||||
|
|
@ -115,7 +127,7 @@
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<a-space v-if="app.ipLimitEnable" direction="horizontal" @click="openXrayLogs()" class="jc-center">
|
<a-space v-if="ipLimitEnable" direction="horizontal" @click="openXrayLogs()" class="jc-center">
|
||||||
<a-icon type="bars"></a-icon>
|
<a-icon type="bars"></a-icon>
|
||||||
<span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span>
|
<span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span>
|
||||||
</a-space>
|
</a-space>
|
||||||
|
|
@ -330,8 +342,65 @@
|
||||||
<div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
|
<div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
|
||||||
"pages.index.geofilesUpdateAll" }}</a-button></div>
|
"pages.index.geofilesUpdateAll" }}</a-button></div>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
|
<a-collapse-panel key="3" header='{{ i18n "pages.index.customGeoTitle" }}'>
|
||||||
|
<div class="custom-geo-section">
|
||||||
|
<a-alert type="info" show-icon class="mb-10"
|
||||||
|
message='{{ i18n "pages.index.customGeoRoutingHint" }}'></a-alert>
|
||||||
|
<div class="mb-10">
|
||||||
|
<a-button type="primary" icon="plus" @click="openCustomGeoModal(null)" :loading="customGeoLoading">
|
||||||
|
{{ i18n "pages.index.customGeoAdd" }}
|
||||||
|
</a-button>
|
||||||
|
<a-button class="ml-8" icon="reload" @click="updateAllCustomGeo" :loading="customGeoUpdatingAll">{{ i18n
|
||||||
|
"pages.index.geofilesUpdateAll" }}</a-button>
|
||||||
|
</div>
|
||||||
|
<a-table :columns="customGeoColumns" :data-source="customGeoList" :pagination="false" :row-key="r => r.id"
|
||||||
|
:loading="customGeoLoading" size="small" :scroll="{ x: 520 }">
|
||||||
|
<template slot="extDat" slot-scope="text, record">
|
||||||
|
<code class="custom-geo-ext-code">[[ customGeoExtDisplay(record) ]]</code>
|
||||||
|
</template>
|
||||||
|
<template slot="lastUpdatedAt" slot-scope="text, record">
|
||||||
|
<span v-if="record.lastUpdatedAt">[[ customGeoFormatTime(record.lastUpdatedAt) ]]</span>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
<template slot="action" slot-scope="text, record">
|
||||||
|
<a-space size="small">
|
||||||
|
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<template slot="title">{{ i18n "pages.index.customGeoEdit" }}</template>
|
||||||
|
<a-button type="link" size="small" icon="edit" @click="openCustomGeoModal(record)"></a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<template slot="title">{{ i18n "pages.index.customGeoDownload" }}</template>
|
||||||
|
<a-button type="link" size="small" icon="reload" @click="downloadCustomGeo(record.id)" :loading="customGeoActionId === record.id"></a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<template slot="title">{{ i18n "pages.index.customGeoDelete" }}</template>
|
||||||
|
<a-button type="link" size="small" icon="delete" @click="confirmDeleteCustomGeo(record)"></a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
</a-collapse-panel>
|
||||||
</a-collapse>
|
</a-collapse>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
<a-modal v-model="customGeoModal.visible" :title="customGeoModal.editId ? '{{ i18n "pages.index.customGeoModalEdit" }}' : '{{ i18n "pages.index.customGeoModalAdd" }}'"
|
||||||
|
:confirm-loading="customGeoModal.saving" @ok="submitCustomGeo" :ok-text="'{{ i18n "pages.index.customGeoModalSave" }}'" :cancel-text="'{{ i18n "close" }}'"
|
||||||
|
:class="themeSwitcher.currentTheme">
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label='{{ i18n "pages.index.customGeoType" }}'>
|
||||||
|
<a-select v-model="customGeoModal.form.type" :disabled="!!customGeoModal.editId" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
|
<a-select-option value="geosite">geosite</a-select-option>
|
||||||
|
<a-select-option value="geoip">geoip</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label='{{ i18n "pages.index.customGeoAlias" }}'>
|
||||||
|
<a-input v-model.trim="customGeoModal.form.alias" :disabled="!!customGeoModal.editId" placeholder='{{ i18n "pages.index.customGeoAliasPlaceholder" }}'></a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label='{{ i18n "pages.index.customGeoUrl" }}'>
|
||||||
|
<a-input v-model.trim="customGeoModal.form.url" placeholder="https://"></a-input>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
<a-modal id="log-modal" v-model="logModal.visible" :closable="true" @cancel="() => logModal.visible = false"
|
<a-modal id="log-modal" v-model="logModal.visible" :closable="true" @cancel="() => logModal.visible = false"
|
||||||
:class="themeSwitcher.currentTheme" width="800px" footer="">
|
:class="themeSwitcher.currentTheme" width="800px" footer="">
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
|
|
@ -872,6 +941,12 @@
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const customGeoColumns = [
|
||||||
|
{ title: '{{ i18n "pages.index.customGeoExtColumn" }}', key: 'extDat', scopedSlots: { customRender: 'extDat' }, ellipsis: true },
|
||||||
|
{ title: '{{ i18n "pages.index.customGeoLastUpdated" }}', key: 'lastUpdatedAt', scopedSlots: { customRender: 'lastUpdatedAt' }, width: 160 },
|
||||||
|
{ title: '{{ i18n "pages.index.customGeoActions" }}', key: 'action', scopedSlots: { customRender: 'action' }, width: 120, fixed: 'right' },
|
||||||
|
];
|
||||||
|
|
||||||
const app = new Vue({
|
const app = new Vue({
|
||||||
delimiters: ['[[', ']]'],
|
delimiters: ['[[', ']]'],
|
||||||
el: '#app',
|
el: '#app',
|
||||||
|
|
@ -895,6 +970,25 @@
|
||||||
showAlert: false,
|
showAlert: false,
|
||||||
showIp: false,
|
showIp: false,
|
||||||
ipLimitEnable: false,
|
ipLimitEnable: false,
|
||||||
|
customGeoColumns,
|
||||||
|
customGeoList: [],
|
||||||
|
customGeoLoading: false,
|
||||||
|
customGeoUpdatingAll: false,
|
||||||
|
customGeoActionId: null,
|
||||||
|
customGeoModal: {
|
||||||
|
visible: false,
|
||||||
|
editId: null,
|
||||||
|
saving: false,
|
||||||
|
form: {
|
||||||
|
type: 'geosite',
|
||||||
|
alias: '',
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customGeoValidation: {
|
||||||
|
alias: '{{ i18n "pages.index.customGeoValidationAlias" }}',
|
||||||
|
url: '{{ i18n "pages.index.customGeoValidationUrl" }}',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
loading(spinning, tip = '{{ i18n "loading"}}') {
|
loading(spinning, tip = '{{ i18n "loading"}}') {
|
||||||
|
|
@ -963,6 +1057,128 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
versionModal.show(msg.obj);
|
versionModal.show(msg.obj);
|
||||||
|
this.loadCustomGeo();
|
||||||
|
},
|
||||||
|
customGeoFormatTime(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
return typeof moment !== 'undefined' ? moment(ts * 1000).format('YYYY-MM-DD HH:mm') : String(ts);
|
||||||
|
},
|
||||||
|
customGeoExtDisplay(record) {
|
||||||
|
const fn = record.type === 'geoip'
|
||||||
|
? `geoip_${record.alias}.dat`
|
||||||
|
: `geosite_${record.alias}.dat`;
|
||||||
|
return `ext:${fn}:tag`;
|
||||||
|
},
|
||||||
|
async loadCustomGeo() {
|
||||||
|
this.customGeoLoading = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
|
||||||
|
if (msg.success && Array.isArray(msg.obj)) {
|
||||||
|
this.customGeoList = msg.obj;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.customGeoLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openCustomGeoModal(record) {
|
||||||
|
if (record) {
|
||||||
|
this.customGeoModal.editId = record.id;
|
||||||
|
this.customGeoModal.form = {
|
||||||
|
type: record.type,
|
||||||
|
alias: record.alias,
|
||||||
|
url: record.url,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.customGeoModal.editId = null;
|
||||||
|
this.customGeoModal.form = {
|
||||||
|
type: 'geosite',
|
||||||
|
alias: '',
|
||||||
|
url: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.customGeoModal.visible = true;
|
||||||
|
},
|
||||||
|
validateCustomGeoForm() {
|
||||||
|
const f = this.customGeoModal.form;
|
||||||
|
const re = /^[a-z0-9_-]+$/;
|
||||||
|
if (!re.test(f.alias || '')) {
|
||||||
|
this.$message.error(this.customGeoValidation.alias);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const u = (f.url || '').trim();
|
||||||
|
if (!/^https?:\/\//i.test(u)) {
|
||||||
|
this.$message.error(this.customGeoValidation.url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(u);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
this.$message.error(this.customGeoValidation.url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.$message.error(this.customGeoValidation.url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async submitCustomGeo() {
|
||||||
|
if (!this.validateCustomGeoForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const f = this.customGeoModal.form;
|
||||||
|
this.customGeoModal.saving = true;
|
||||||
|
try {
|
||||||
|
let msg;
|
||||||
|
if (this.customGeoModal.editId) {
|
||||||
|
msg = await HttpUtil.post(`/panel/api/custom-geo/update/${this.customGeoModal.editId}`, f);
|
||||||
|
} else {
|
||||||
|
msg = await HttpUtil.post('/panel/api/custom-geo/add', f);
|
||||||
|
}
|
||||||
|
if (msg && msg.success) {
|
||||||
|
this.customGeoModal.visible = false;
|
||||||
|
await this.loadCustomGeo();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.customGeoModal.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmDeleteCustomGeo(record) {
|
||||||
|
this.$confirm({
|
||||||
|
title: '{{ i18n "pages.index.customGeoDelete" }}',
|
||||||
|
content: '{{ i18n "pages.index.customGeoDeleteConfirm" }}',
|
||||||
|
okText: '{{ i18n "confirm"}}',
|
||||||
|
cancelText: '{{ i18n "cancel"}}',
|
||||||
|
class: themeSwitcher.currentTheme,
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
|
||||||
|
if (msg.success) {
|
||||||
|
await this.loadCustomGeo();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async downloadCustomGeo(id) {
|
||||||
|
this.customGeoActionId = id;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
|
||||||
|
if (msg.success) {
|
||||||
|
await this.loadCustomGeo();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.customGeoActionId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async updateAllCustomGeo() {
|
||||||
|
this.customGeoUpdatingAll = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
|
||||||
|
if (msg.success || (msg.obj && Array.isArray(msg.obj.succeeded) && msg.obj.succeeded.length > 0)) {
|
||||||
|
await this.loadCustomGeo();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.customGeoUpdatingAll = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
switchV2rayVersion(version) {
|
switchV2rayVersion(version) {
|
||||||
this.$confirm({
|
this.$confirm({
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
<a-input v-model.trim="clientsBulkModal.emailPostfix"></a-input>
|
<a-input v-model.trim="clientsBulkModal.emailPostfix"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "pages.client.clientCount" }}' v-if="clientsBulkModal.emailMethod < 2">
|
<a-form-item label='{{ i18n "pages.client.clientCount" }}' v-if="clientsBulkModal.emailMethod < 2">
|
||||||
<a-input-number v-model.number="clientsBulkModal.quantity" :min="1" :max="100"></a-input-number>
|
<a-input-number v-model.number="clientsBulkModal.quantity" :min="1" :max="500"></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "security" }}' v-if="inbound.protocol === Protocols.VMESS">
|
<a-form-item label='{{ i18n "security" }}' v-if="inbound.protocol === Protocols.VMESS">
|
||||||
<a-select v-model="clientsBulkModal.security" :dropdown-class-name="themeSwitcher.currentTheme">
|
<a-select v-model="clientsBulkModal.security" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
|
|
@ -204,7 +204,7 @@
|
||||||
this.security = "auto";
|
this.security = "auto";
|
||||||
this.flow = "";
|
this.flow = "";
|
||||||
this.dbInbound = new DBInbound(dbInbound);
|
this.dbInbound = new DBInbound(dbInbound);
|
||||||
this.inbound = dbInbound.toInbound();
|
this.inbound = Inbound.fromJson(dbInbound.toInbound().toJson());
|
||||||
this.delayedStart = false;
|
this.delayedStart = false;
|
||||||
this.reset = 0;
|
this.reset = 0;
|
||||||
},
|
},
|
||||||
|
|
@ -247,4 +247,4 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
this.okText = okText;
|
this.okText = okText;
|
||||||
this.isEdit = isEdit;
|
this.isEdit = isEdit;
|
||||||
this.dbInbound = new DBInbound(dbInbound);
|
this.dbInbound = new DBInbound(dbInbound);
|
||||||
this.inbound = dbInbound.toInbound();
|
this.inbound = Inbound.fromJson(dbInbound.toInbound().toJson());
|
||||||
this.clients = this.inbound.clients;
|
this.clients = this.inbound.clients;
|
||||||
this.index = index === null ? this.clients.length : index;
|
this.index = index === null ? this.clients.length : index;
|
||||||
this.delayedStart = false;
|
this.delayedStart = false;
|
||||||
|
|
@ -98,9 +98,9 @@
|
||||||
return app.datepicker;
|
return app.datepicker;
|
||||||
},
|
},
|
||||||
get isTrafficExhausted() {
|
get isTrafficExhausted() {
|
||||||
if (!clientStats) return false
|
if (!this.clientStats) return false
|
||||||
if (clientStats.total <= 0) return false
|
if (this.clientStats.total <= 0) return false
|
||||||
if (clientStats.up + clientStats.down < clientStats.total) return false
|
if (this.clientStats.up + this.clientStats.down < this.clientStats.total) return false
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
get isExpiry() {
|
get isExpiry() {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<a-sidebar></a-sidebar>
|
<a-sidebar></a-sidebar>
|
||||||
<a-layout id="content-layout">
|
<a-layout id="content-layout">
|
||||||
<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"}}' size="large">
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<a-alert type="error" v-if="confAlerts.length>0 && loadingStates.fetched" :style="{ marginBottom: '10px' }"
|
<a-alert type="error" v-if="confAlerts.length>0 && loadingStates.fetched" :style="{ marginBottom: '10px' }"
|
||||||
message='{{ i18n "secAlertTitle" }}' color="red" show-icon closable>
|
message='{{ i18n "secAlertTitle" }}' color="red" show-icon closable>
|
||||||
|
|
@ -21,10 +21,7 @@
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<template>
|
<template>
|
||||||
<a-row v-if="!loadingStates.fetched">
|
<a-row v-if="!loadingStates.fetched">
|
||||||
<a-card
|
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
|
||||||
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
|
|
||||||
<a-spin tip='{{ i18n "loading" }}'></a-spin>
|
|
||||||
</a-card>
|
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
||||||
<a-col>
|
<a-col>
|
||||||
|
|
@ -82,10 +79,10 @@
|
||||||
</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.subJsonEnable" :style="{ paddingTop: '20px' }">
|
<a-tab-pane key="5" v-if="allSetting.subJsonEnable || allSetting.subClashEnable" :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" }} (Formats)</span>
|
||||||
</template>
|
</template>
|
||||||
{{ template "settings/panel/subscription/json" . }}
|
{{ template "settings/panel/subscription/json" . }}
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
|
||||||
|
|
@ -3,43 +3,58 @@
|
||||||
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subEnable"}}</template>
|
<template #title>{{ i18n "pages.settings.subEnable"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subEnableDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subEnableDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<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">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>JSON Subscription</template>
|
<template #title>JSON Subscription</template>
|
||||||
<template #description>{{ i18n "pages.settings.subJsonEnable"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subJsonEnable"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-switch v-model="allSetting.subJsonEnable"></a-switch>
|
<a-switch v-model="allSetting.subJsonEnable"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>Clash / Mihomo Subscription</template>
|
||||||
|
<template #description>Enable direct Clash and Mihomo YAML
|
||||||
|
subscriptions.</template>
|
||||||
|
<template #control>
|
||||||
|
<a-switch v-model="allSetting.subClashEnable"></a-switch>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subListen"}}</template>
|
<template #title>{{ i18n "pages.settings.subListen"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subListenDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subListenDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subListen"></a-input>
|
<a-input type="text" v-model="allSetting.subListen"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subDomain"}}</template>
|
<template #title>{{ i18n "pages.settings.subDomain"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subDomainDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subDomainDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subDomain"></a-input>
|
<a-input type="text" v-model="allSetting.subDomain"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<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="65535"
|
<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>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<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 type="text" v-model="allSetting.subPath"
|
||||||
@input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
|
@input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
|
||||||
|
|
@ -49,9 +64,11 @@
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subURI"}}</template>
|
<template #title>{{ i18n "pages.settings.subURI"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subURIDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
<a-input type="text"
|
||||||
|
placeholder="(http|https)://domain[:port]/path/"
|
||||||
v-model="allSetting.subURI"></a-input>
|
v-model="allSetting.subURI"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
|
|
@ -59,14 +76,16 @@
|
||||||
<a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
|
<a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subEncrypt"}}</template>
|
<template #title>{{ i18n "pages.settings.subEncrypt"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subEncryptDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subEncryptDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-switch v-model="allSetting.subEncrypt"></a-switch>
|
<a-switch v-model="allSetting.subEncrypt"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subShowInfo"}}</template>
|
<template #title>{{ i18n "pages.settings.subShowInfo"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subShowInfoDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subShowInfoDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-switch v-model="allSetting.subShowInfo"></a-switch>
|
<a-switch v-model="allSetting.subShowInfo"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -74,59 +93,72 @@
|
||||||
<a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider>
|
<a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider>
|
||||||
<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>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subTitle"></a-input>
|
<a-input type="text" v-model="allSetting.subTitle"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subSupportUrl"}}</template>
|
<template #title>{{ i18n "pages.settings.subSupportUrl"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subSupportUrlDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subSupportUrlDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subSupportUrl" placeholder="https://example.com"></a-input>
|
<a-input type="text" v-model="allSetting.subSupportUrl"
|
||||||
|
placeholder="https://example.com"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subProfileUrl"}}</template>
|
<template #title>{{ i18n "pages.settings.subProfileUrl"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subProfileUrlDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subProfileUrlDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subProfileUrl" placeholder="https://example.com"></a-input>
|
<a-input type="text" v-model="allSetting.subProfileUrl"
|
||||||
|
placeholder="https://example.com"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subAnnounce"}}</template>
|
<template #title>{{ i18n "pages.settings.subAnnounce"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subAnnounceDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subAnnounceDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-textarea v-model="allSetting.subAnnounce"></a-textarea>
|
<a-textarea v-model="allSetting.subAnnounce"></a-textarea>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider>
|
<a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subEnableRouting"}}</template>
|
<template #title>{{ i18n
|
||||||
<template #description>{{ i18n "pages.settings.subEnableRoutingDesc"}}</template>
|
"pages.settings.subEnableRouting"}}</template>
|
||||||
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subEnableRoutingDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-switch v-model="allSetting.subEnableRouting"></a-switch>
|
<a-switch v-model="allSetting.subEnableRouting"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subRoutingRules"}}</template>
|
<template #title>{{ i18n
|
||||||
<template #description>{{ i18n "pages.settings.subRoutingRulesDesc"}}</template>
|
"pages.settings.subRoutingRules"}}</template>
|
||||||
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subRoutingRulesDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-textarea v-model="allSetting.subRoutingRules" placeholder="happ://routing/add/..."></a-textarea>
|
<a-textarea v-model="allSetting.subRoutingRules"
|
||||||
|
placeholder="happ://routing/add/..."></a-textarea>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
<a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'>
|
<a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subCertPath"}}</template>
|
<template #title>{{ i18n "pages.settings.subCertPath"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subCertPathDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subCertPathDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subCertFile"></a-input>
|
<a-input type="text" v-model="allSetting.subCertFile"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subKeyPath"}}</template>
|
<template #title>{{ i18n "pages.settings.subKeyPath"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subKeyPathDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subKeyPathDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subKeyFile"></a-input>
|
<a-input type="text" v-model="allSetting.subKeyFile"></a-input>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -135,9 +167,11 @@
|
||||||
<a-collapse-panel key="4" header='{{ i18n "pages.settings.intervals"}}'>
|
<a-collapse-panel key="4" header='{{ i18n "pages.settings.intervals"}}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subUpdates"}}</template>
|
<template #title>{{ i18n "pages.settings.subUpdates"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subUpdatesDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subUpdatesDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input-number :min="1" v-model="allSetting.subUpdates" :style="{ width: '100%' }"></a-input-number>
|
<a-input-number :min="1" v-model="allSetting.subUpdates"
|
||||||
|
:style="{ width: '100%' }"></a-input-number>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{{define "settings/panel/subscription/json"}}
|
{{define "settings/panel/subscription/json"}}
|
||||||
<a-collapse default-active-key="1">
|
<a-collapse default-active-key="1">
|
||||||
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
|
||||||
<template #title>{{ i18n "pages.settings.subPath"}}</template>
|
<template #title>{{ i18n "pages.settings.subPath"}} (JSON)</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 type="text" v-model="allSetting.subJsonPath"
|
||||||
|
|
@ -11,14 +11,32 @@
|
||||||
placeholder="/json/"></a-input>
|
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" v-if="allSetting.subJsonEnable">
|
||||||
<template #title>{{ i18n "pages.settings.subURI"}}</template>
|
<template #title>{{ i18n "pages.settings.subURI"}} (JSON)</template>
|
||||||
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
||||||
v-model="allSetting.subJsonURI"></a-input>
|
v-model="allSetting.subJsonURI"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
|
||||||
|
<template #title>{{ i18n "pages.settings.subPath"}} (Clash)</template>
|
||||||
|
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input type="text" v-model="allSetting.subClashPath"
|
||||||
|
@input="allSetting.subClashPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
|
||||||
|
@blur="allSetting.subClashPath = (p => { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subClashPath)"
|
||||||
|
placeholder="/clash/"></a-input>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
|
||||||
|
<template #title>{{ i18n "pages.settings.subURI"}} (Clash)</template>
|
||||||
|
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
||||||
|
v-model="allSetting.subClashURI"></a-input>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
<a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'>
|
<a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<a-space direction="vertical" align="center">
|
<a-space direction="vertical" align="center">
|
||||||
<a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
|
<a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
|
||||||
<a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24" style="text-align:center;">
|
<a-col :xs="24" :sm="app.subJsonUrl || app.subClashUrl ? 12 : 24" style="text-align:center;">
|
||||||
<tr-qr-box class="qr-box">
|
<tr-qr-box class="qr-box">
|
||||||
<a-tag color="purple" class="qr-tag">
|
<a-tag color="purple" class="qr-tag">
|
||||||
<span>{{ i18n
|
<span>{{ i18n
|
||||||
|
|
@ -112,6 +112,19 @@
|
||||||
</tr-qr-bg>
|
</tr-qr-bg>
|
||||||
</tr-qr-box>
|
</tr-qr-box>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
<a-col v-if="app.subClashUrl" :xs="24" :sm="12" style="text-align:center;">
|
||||||
|
<tr-qr-box class="qr-box">
|
||||||
|
<a-tag color="purple" class="qr-tag">
|
||||||
|
<span>Clash / Mihomo</span>
|
||||||
|
</a-tag>
|
||||||
|
<tr-qr-bg class="qr-bg-sub">
|
||||||
|
<tr-qr-bg-inner class="qr-bg-sub-inner">
|
||||||
|
<canvas id="qrcode-subclash" class="qr-cv" title='{{ i18n "copy" }}'
|
||||||
|
@click="copy(app.subClashUrl)"></canvas>
|
||||||
|
</tr-qr-bg-inner>
|
||||||
|
</tr-qr-bg>
|
||||||
|
</tr-qr-box>
|
||||||
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
@ -206,7 +219,7 @@
|
||||||
<a-menu-item key="android-npvtunnel" @click="copy(app.subUrl)">NPV
|
<a-menu-item key="android-npvtunnel" @click="copy(app.subUrl)">NPV
|
||||||
Tunnel</a-menu-item>
|
Tunnel</a-menu-item>
|
||||||
<a-menu-item key="android-happ"
|
<a-menu-item key="android-happ"
|
||||||
@click="open('happ://add/' + encodeURIComponent(app.subUrl))">Happ</a-menu-item>
|
@click="open('happ://add/' + app.subUrl)">Happ</a-menu-item>
|
||||||
</a-menu>
|
</a-menu>
|
||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
@ -242,7 +255,7 @@
|
||||||
</a-layout>
|
</a-layout>
|
||||||
|
|
||||||
<!-- Bootstrap data for external JS -->
|
<!-- Bootstrap data for external JS -->
|
||||||
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
|
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}" data-subclash-url="{{ .subClashUrl }}"
|
||||||
data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
|
data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
|
||||||
data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
|
data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
|
||||||
data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
|
data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<a-layout id="content-layout">
|
<a-layout id="content-layout">
|
||||||
<a-layout-content>
|
<a-layout-content>
|
||||||
<a-spin :spinning="loadingStates.spinning" :delay="500"
|
<a-spin :spinning="loadingStates.spinning" :delay="500"
|
||||||
tip='{{ i18n "loading"}}'>
|
tip='{{ i18n "loading"}}' size="large">
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched"
|
<a-alert type="error" v-if="showAlert && loadingStates.fetched"
|
||||||
:style="{ marginBottom: '10px' }"
|
:style="{ marginBottom: '10px' }"
|
||||||
|
|
@ -24,10 +24,7 @@
|
||||||
</transition>
|
</transition>
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<a-row v-if="!loadingStates.fetched">
|
<a-row v-if="!loadingStates.fetched">
|
||||||
<a-card
|
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
|
||||||
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
|
|
||||||
<a-spin tip='{{ i18n "loading" }}'></a-spin>
|
|
||||||
</a-card>
|
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
||||||
<a-col>
|
<a-col>
|
||||||
|
|
@ -263,6 +260,7 @@
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
restartResult: '',
|
restartResult: '',
|
||||||
showAlert: false,
|
showAlert: false,
|
||||||
|
customGeoAliasLabelSuffix: '{{ i18n "pages.index.customGeoAliasLabelSuffix" }}',
|
||||||
advSettings: 'xraySetting',
|
advSettings: 'xraySetting',
|
||||||
obsSettings: '',
|
obsSettings: '',
|
||||||
cm: null,
|
cm: null,
|
||||||
|
|
@ -1061,6 +1059,30 @@
|
||||||
},
|
},
|
||||||
showNord() {
|
showNord() {
|
||||||
nordModal.show();
|
nordModal.show();
|
||||||
|
async loadCustomGeoAliases() {
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/custom-geo/aliases');
|
||||||
|
if (!msg.success) {
|
||||||
|
console.warn('Failed to load custom geo aliases:', msg.msg || 'request failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!msg.obj) return;
|
||||||
|
const { geoip = [], geosite = [] } = msg.obj;
|
||||||
|
const geoSuffix = this.customGeoAliasLabelSuffix || '';
|
||||||
|
geoip.forEach((x) => {
|
||||||
|
this.settingsData.IPsOptions.push({
|
||||||
|
label: x.alias + geoSuffix,
|
||||||
|
value: x.extExample,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
geosite.forEach((x) => {
|
||||||
|
const opt = { label: x.alias + geoSuffix, value: x.extExample };
|
||||||
|
this.settingsData.DomainsOptions.push(opt);
|
||||||
|
this.settingsData.BlockDomainsOptions.push(opt);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load custom geo aliases:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
@ -1068,6 +1090,7 @@
|
||||||
this.showAlert = true;
|
this.showAlert = true;
|
||||||
}
|
}
|
||||||
await this.getXraySetting();
|
await this.getXraySetting();
|
||||||
|
await this.loadCustomGeoAliases();
|
||||||
await this.getXrayResult();
|
await this.getXrayResult();
|
||||||
await this.getOutboundsTraffic();
|
await this.getOutboundsTraffic();
|
||||||
|
|
||||||
|
|
@ -1079,6 +1102,14 @@
|
||||||
this.$forceUpdate();
|
this.$forceUpdate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle invalidate signals (sent when payload is too large for WebSocket,
|
||||||
|
// or when traffic job notifies about data changes)
|
||||||
|
window.wsClient.on('invalidate', (payload) => {
|
||||||
|
if (payload && payload.type === 'outbounds') {
|
||||||
|
this.refreshOutboundTraffic();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package job
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -10,7 +11,6 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/database"
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
|
|
@ -33,6 +33,8 @@ type CheckClientIpJob struct {
|
||||||
|
|
||||||
var job *CheckClientIpJob
|
var job *CheckClientIpJob
|
||||||
|
|
||||||
|
const defaultXrayAPIPort = 62789
|
||||||
|
|
||||||
// NewCheckClientIpJob creates a new client IP monitoring job instance.
|
// NewCheckClientIpJob creates a new client IP monitoring job instance.
|
||||||
func NewCheckClientIpJob() *CheckClientIpJob {
|
func NewCheckClientIpJob() *CheckClientIpJob {
|
||||||
job = new(CheckClientIpJob)
|
job = new(CheckClientIpJob)
|
||||||
|
|
@ -319,13 +321,14 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert back to slice and sort by timestamp (newest first)
|
// Convert back to slice and sort by timestamp (oldest first)
|
||||||
|
// This ensures we always protect the original/current connections and ban new excess ones.
|
||||||
allIps := make([]IPWithTimestamp, 0, len(ipMap))
|
allIps := make([]IPWithTimestamp, 0, len(ipMap))
|
||||||
for ip, timestamp := range ipMap {
|
for ip, timestamp := range ipMap {
|
||||||
allIps = append(allIps, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
allIps = append(allIps, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
||||||
}
|
}
|
||||||
sort.Slice(allIps, func(i, j int) bool {
|
sort.Slice(allIps, func(i, j int) bool {
|
||||||
return allIps[i].Timestamp > allIps[j].Timestamp // Descending order (newest first)
|
return allIps[i].Timestamp < allIps[j].Timestamp // Ascending order (oldest first)
|
||||||
})
|
})
|
||||||
|
|
||||||
shouldCleanLog := false
|
shouldCleanLog := false
|
||||||
|
|
@ -345,23 +348,23 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||||
if len(allIps) > limitIp {
|
if len(allIps) > limitIp {
|
||||||
shouldCleanLog = true
|
shouldCleanLog = true
|
||||||
|
|
||||||
// Keep only the newest IPs (up to limitIp)
|
// Keep the oldest IPs (currently active connections) and ban the new excess ones.
|
||||||
keptIps := allIps[:limitIp]
|
keptIps := allIps[:limitIp]
|
||||||
disconnectedIps := allIps[limitIp:]
|
bannedIps := allIps[limitIp:]
|
||||||
|
|
||||||
// Log the disconnected IPs (old ones)
|
// Log banned IPs in the format fail2ban filters expect: [LIMIT_IP] Email = X || Disconnecting OLD IP = Y || Timestamp = Z
|
||||||
for _, ipTime := range disconnectedIps {
|
for _, ipTime := range bannedIps {
|
||||||
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
||||||
log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actually disconnect old IPs by temporarily removing and re-adding user
|
// Actually disconnect banned IPs by temporarily removing and re-adding user
|
||||||
// This forces Xray to drop existing connections from old IPs
|
// This forces Xray to drop existing connections from banned IPs
|
||||||
if len(disconnectedIps) > 0 {
|
if len(bannedIps) > 0 {
|
||||||
j.disconnectClientTemporarily(inbound, clientEmail, clients)
|
j.disconnectClientTemporarily(inbound, clientEmail, clients)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update database with only the newest IPs
|
// Update database with only the currently active (kept) IPs
|
||||||
jsonIps, _ := json.Marshal(keptIps)
|
jsonIps, _ := json.Marshal(keptIps)
|
||||||
inboundClientIps.Ips = string(jsonIps)
|
inboundClientIps.Ips = string(jsonIps)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -378,27 +381,16 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(j.disAllowedIps) > 0 {
|
if len(j.disAllowedIps) > 0 {
|
||||||
logger.Infof("[LIMIT_IP] Client %s: Kept %d newest IPs, disconnected %d old IPs", clientEmail, limitIp, len(j.disAllowedIps))
|
logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, queued %d new IPs for fail2ban", clientEmail, limitIp, len(j.disAllowedIps))
|
||||||
}
|
}
|
||||||
|
|
||||||
return shouldCleanLog
|
return shouldCleanLog
|
||||||
}
|
}
|
||||||
|
|
||||||
// disconnectClientTemporarily removes and re-adds a client to force disconnect old connections
|
// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
|
||||||
func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
|
func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
|
||||||
var xrayAPI xray.XrayAPI
|
var xrayAPI xray.XrayAPI
|
||||||
|
apiPort := j.resolveXrayAPIPort()
|
||||||
// Get panel settings for API port
|
|
||||||
db := database.GetDB()
|
|
||||||
var apiPort int
|
|
||||||
var apiPortSetting model.Setting
|
|
||||||
if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil {
|
|
||||||
apiPort, _ = strconv.Atoi(apiPortSetting.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiPort == 0 {
|
|
||||||
apiPort = 10085 // Default API port
|
|
||||||
}
|
|
||||||
|
|
||||||
err := xrayAPI.Init(apiPort)
|
err := xrayAPI.Init(apiPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -422,6 +414,29 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only perform remove/re-add for protocols supported by XrayAPI.AddUser
|
||||||
|
protocol := string(inbound.Protocol)
|
||||||
|
switch protocol {
|
||||||
|
case "vmess", "vless", "trojan", "shadowsocks":
|
||||||
|
// supported protocols, continue
|
||||||
|
default:
|
||||||
|
logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Shadowsocks, ensure the required "cipher" field is present by
|
||||||
|
// reading it from the inbound settings (e.g., settings["method"]).
|
||||||
|
if string(inbound.Protocol) == "shadowsocks" {
|
||||||
|
var inboundSettings map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(inbound.Settings), &inboundSettings); err != nil {
|
||||||
|
logger.Warningf("[LIMIT_IP] Failed to parse inbound settings for shadowsocks cipher: %v", err)
|
||||||
|
} else {
|
||||||
|
if method, ok := inboundSettings["method"].(string); ok && method != "" {
|
||||||
|
clientConfig["cipher"] = method
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove user to disconnect all connections
|
// Remove user to disconnect all connections
|
||||||
err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
|
err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -433,12 +448,69 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
// Re-add user to allow new connections
|
// Re-add user to allow new connections
|
||||||
err = xrayAPI.AddUser(string(inbound.Protocol), inbound.Tag, clientConfig)
|
err = xrayAPI.AddUser(protocol, inbound.Tag, clientConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
|
logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveXrayAPIPort returns the API inbound port from running config, then template config, then default.
|
||||||
|
func (j *CheckClientIpJob) resolveXrayAPIPort() int {
|
||||||
|
var configErr error
|
||||||
|
var templateErr error
|
||||||
|
|
||||||
|
if port, err := getAPIPortFromConfigPath(xray.GetConfigPath()); err == nil {
|
||||||
|
return port
|
||||||
|
} else {
|
||||||
|
configErr = err
|
||||||
|
}
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
var template model.Setting
|
||||||
|
if err := db.Where("key = ?", "xrayTemplateConfig").First(&template).Error; err == nil {
|
||||||
|
if port, parseErr := getAPIPortFromConfigData([]byte(template.Value)); parseErr == nil {
|
||||||
|
return port
|
||||||
|
} else {
|
||||||
|
templateErr = parseErr
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templateErr = err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Warningf(
|
||||||
|
"[LIMIT_IP] Could not determine Xray API port from config or template; falling back to default port %d (config error: %v, template error: %v)",
|
||||||
|
defaultXrayAPIPort,
|
||||||
|
configErr,
|
||||||
|
templateErr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return defaultXrayAPIPort
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAPIPortFromConfigPath(configPath string) (int, error) {
|
||||||
|
configData, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return getAPIPortFromConfigData(configData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAPIPortFromConfigData(configData []byte) (int, error) {
|
||||||
|
xrayConfig := &xray.Config{}
|
||||||
|
if err := json.Unmarshal(configData, xrayConfig); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, inboundConfig := range xrayConfig.InboundConfigs {
|
||||||
|
if inboundConfig.Tag == "api" && inboundConfig.Port > 0 {
|
||||||
|
return inboundConfig.Port, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, errors.New("api inbound port not found")
|
||||||
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
|
func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
inbound := &model.Inbound{}
|
inbound := &model.Inbound{}
|
||||||
|
|
|
||||||
|
|
@ -271,10 +271,7 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
|
||||||
|
|
||||||
// Delete in batches
|
// Delete in batches
|
||||||
for i := 0; i < len(toDelete); i += batchSize {
|
for i := 0; i < len(toDelete); i += batchSize {
|
||||||
end := i + batchSize
|
end := min(i+batchSize, len(toDelete))
|
||||||
if end > len(toDelete) {
|
|
||||||
end = len(toDelete)
|
|
||||||
}
|
|
||||||
batch := toDelete[i:end]
|
batch := toDelete[i:end]
|
||||||
|
|
||||||
for _, c := range batch {
|
for _, c := range batch {
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ func (j *PeriodicTrafficResetJob) Run() {
|
||||||
resetCount := 0
|
resetCount := 0
|
||||||
|
|
||||||
for _, inbound := range inbounds {
|
for _, inbound := range inbounds {
|
||||||
resetInboundErr := j.inboundService.ResetAllTraffics()
|
resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
|
||||||
if resetInboundErr != nil {
|
if resetInboundErr != nil {
|
||||||
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
|
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,13 @@ func (j *XrayTrafficJob) Run() {
|
||||||
j.xrayService.SetToNeedRestart()
|
j.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get online clients and last online map for real-time status updates
|
// If no frontend client is connected, skip all WebSocket broadcasting routines,
|
||||||
|
// including expensive DB queries for online clients and JSON marshaling.
|
||||||
|
if !websocket.HasClients() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update online clients list and map
|
||||||
onlineClients := j.inboundService.GetOnlineClients()
|
onlineClients := j.inboundService.GetOnlineClients()
|
||||||
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -58,8 +64,17 @@ func (j *XrayTrafficJob) Run() {
|
||||||
lastOnlineMap = make(map[string]int64)
|
lastOnlineMap = make(map[string]int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast traffic update (deltas and online stats) via WebSocket
|
||||||
|
trafficUpdate := map[string]any{
|
||||||
|
"traffics": traffics,
|
||||||
|
"clientTraffics": clientTraffics,
|
||||||
|
"onlineClients": onlineClients,
|
||||||
|
"lastOnlineMap": lastOnlineMap,
|
||||||
|
}
|
||||||
|
websocket.BroadcastTraffic(trafficUpdate)
|
||||||
|
|
||||||
// Fetch updated inbounds from database with accumulated traffic values
|
// Fetch updated inbounds from database with accumulated traffic values
|
||||||
// This ensures frontend receives the actual total traffic, not just delta values
|
// This ensures frontend receives the actual total traffic for real-time UI refresh.
|
||||||
updatedInbounds, err := j.inboundService.GetAllInbounds()
|
updatedInbounds, err := j.inboundService.GetAllInbounds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("get all inbounds for websocket failed:", err)
|
logger.Warning("get all inbounds for websocket failed:", err)
|
||||||
|
|
@ -70,16 +85,8 @@ func (j *XrayTrafficJob) Run() {
|
||||||
logger.Warning("get all outbounds for websocket failed:", err)
|
logger.Warning("get all outbounds for websocket failed:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast traffic update via WebSocket with accumulated values from database
|
// The WebSocket hub will automatically check the payload size.
|
||||||
trafficUpdate := map[string]any{
|
// If it exceeds 100MB, it sends a lightweight 'invalidate' signal instead.
|
||||||
"traffics": traffics,
|
|
||||||
"clientTraffics": clientTraffics,
|
|
||||||
"onlineClients": onlineClients,
|
|
||||||
"lastOnlineMap": lastOnlineMap,
|
|
||||||
}
|
|
||||||
websocket.BroadcastTraffic(trafficUpdate)
|
|
||||||
|
|
||||||
// Broadcast full inbounds update for real-time UI refresh
|
|
||||||
if updatedInbounds != nil {
|
if updatedInbounds != nil {
|
||||||
websocket.BroadcastInbounds(updatedInbounds)
|
websocket.BroadcastInbounds(updatedInbounds)
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +94,6 @@ func (j *XrayTrafficJob) Run() {
|
||||||
if updatedOutbounds != nil {
|
if updatedOutbounds != nil {
|
||||||
websocket.BroadcastOutbounds(updatedOutbounds)
|
websocket.BroadcastOutbounds(updatedOutbounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ type SettingService interface {
|
||||||
|
|
||||||
// InitLocalizer initializes the internationalization system with embedded translation files.
|
// 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"))
|
||||||
i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||||
|
|
||||||
|
|
|
||||||
603
web/service/custom_geo.go
Normal file
603
web/service/custom_geo.go
Normal file
|
|
@ -0,0 +1,603 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/config"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
customGeoTypeGeosite = "geosite"
|
||||||
|
customGeoTypeGeoip = "geoip"
|
||||||
|
minDatBytes = 64
|
||||||
|
customGeoProbeTimeout = 12 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
customGeoAliasPattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
|
||||||
|
reservedCustomAliases = map[string]struct{}{
|
||||||
|
"geoip": {}, "geosite": {},
|
||||||
|
"geoip_ir": {}, "geosite_ir": {},
|
||||||
|
"geoip_ru": {}, "geosite_ru": {},
|
||||||
|
}
|
||||||
|
ErrCustomGeoInvalidType = errors.New("custom_geo_invalid_type")
|
||||||
|
ErrCustomGeoAliasRequired = errors.New("custom_geo_alias_required")
|
||||||
|
ErrCustomGeoAliasPattern = errors.New("custom_geo_alias_pattern")
|
||||||
|
ErrCustomGeoAliasReserved = errors.New("custom_geo_alias_reserved")
|
||||||
|
ErrCustomGeoURLRequired = errors.New("custom_geo_url_required")
|
||||||
|
ErrCustomGeoInvalidURL = errors.New("custom_geo_invalid_url")
|
||||||
|
ErrCustomGeoURLScheme = errors.New("custom_geo_url_scheme")
|
||||||
|
ErrCustomGeoURLHost = errors.New("custom_geo_url_host")
|
||||||
|
ErrCustomGeoDuplicateAlias = errors.New("custom_geo_duplicate_alias")
|
||||||
|
ErrCustomGeoNotFound = errors.New("custom_geo_not_found")
|
||||||
|
ErrCustomGeoDownload = errors.New("custom_geo_download")
|
||||||
|
)
|
||||||
|
|
||||||
|
type CustomGeoUpdateAllItem struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomGeoUpdateAllFailure struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
Err string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomGeoUpdateAllResult struct {
|
||||||
|
Succeeded []CustomGeoUpdateAllItem `json:"succeeded"`
|
||||||
|
Failed []CustomGeoUpdateAllFailure `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomGeoService struct {
|
||||||
|
serverService *ServerService
|
||||||
|
updateAllGetAll func() ([]model.CustomGeoResource, error)
|
||||||
|
updateAllApply func(id int, onStartup bool) (string, error)
|
||||||
|
updateAllRestart func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCustomGeoService() *CustomGeoService {
|
||||||
|
s := &CustomGeoService{
|
||||||
|
serverService: &ServerService{},
|
||||||
|
}
|
||||||
|
s.updateAllGetAll = s.GetAll
|
||||||
|
s.updateAllApply = s.applyDownloadAndPersist
|
||||||
|
s.updateAllRestart = func() error { return s.serverService.RestartXrayService() }
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeAliasKey(alias string) string {
|
||||||
|
return strings.ToLower(strings.ReplaceAll(alias, "-", "_"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) fileNameFor(typ, alias string) string {
|
||||||
|
if typ == customGeoTypeGeoip {
|
||||||
|
return fmt.Sprintf("geoip_%s.dat", alias)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("geosite_%s.dat", alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) validateType(typ string) error {
|
||||||
|
if typ != customGeoTypeGeosite && typ != customGeoTypeGeoip {
|
||||||
|
return ErrCustomGeoInvalidType
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) validateAlias(alias string) error {
|
||||||
|
if alias == "" {
|
||||||
|
return ErrCustomGeoAliasRequired
|
||||||
|
}
|
||||||
|
if !customGeoAliasPattern.MatchString(alias) {
|
||||||
|
return ErrCustomGeoAliasPattern
|
||||||
|
}
|
||||||
|
if _, ok := reservedCustomAliases[NormalizeAliasKey(alias)]; ok {
|
||||||
|
return ErrCustomGeoAliasReserved
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) validateURL(raw string) error {
|
||||||
|
if raw == "" {
|
||||||
|
return ErrCustomGeoURLRequired
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ErrCustomGeoInvalidURL
|
||||||
|
}
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return ErrCustomGeoURLScheme
|
||||||
|
}
|
||||||
|
if u.Host == "" {
|
||||||
|
return ErrCustomGeoURLHost
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func localDatFileNeedsRepair(path string) bool {
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return fi.Size() < int64(minDatBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CustomGeoLocalFileNeedsRepair(path string) bool {
|
||||||
|
return localDatFileNeedsRepair(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeCustomGeoURLWithGET(rawURL string) error {
|
||||||
|
client := &http.Client{Timeout: customGeoProbeTimeout}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Range", "bytes=0-0")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 256))
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusOK, http.StatusPartialContent:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("get range status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeCustomGeoURL(rawURL string) error {
|
||||||
|
client := &http.Client{Timeout: customGeoProbeTimeout}
|
||||||
|
req, err := http.NewRequest(http.MethodHead, rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
sc := resp.StatusCode
|
||||||
|
if sc >= 200 && sc < 300 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if sc == http.StatusMethodNotAllowed || sc == http.StatusNotImplemented {
|
||||||
|
return probeCustomGeoURLWithGET(rawURL)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("head status %d", sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) EnsureOnStartup() {
|
||||||
|
list, err := s.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("custom geo startup: load list:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := len(list)
|
||||||
|
if n == 0 {
|
||||||
|
logger.Info("custom geo startup: no custom geofiles configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Infof("custom geo startup: checking %d custom geofile(s)", n)
|
||||||
|
for i := range list {
|
||||||
|
r := &list[i]
|
||||||
|
if err := s.validateURL(r.Url); err != nil {
|
||||||
|
logger.Warningf("custom geo startup id=%d: invalid url: %v", r.Id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.syncLocalPath(r)
|
||||||
|
localPath := r.LocalPath
|
||||||
|
if !localDatFileNeedsRepair(localPath) {
|
||||||
|
logger.Infof("custom geo startup id=%d alias=%s path=%s: present", r.Id, r.Alias, localPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Infof("custom geo startup id=%d alias=%s path=%s: missing or needs repair, probing source", r.Id, r.Alias, localPath)
|
||||||
|
if err := probeCustomGeoURL(r.Url); err != nil {
|
||||||
|
logger.Warningf("custom geo startup id=%d alias=%s url=%s: probe: %v (attempting download anyway)", r.Id, r.Alias, r.Url, err)
|
||||||
|
}
|
||||||
|
_, _ = s.applyDownloadAndPersist(r.Id, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) downloadToPath(resourceURL, destPath string, lastModifiedHeader string) (skipped bool, newLastModified string, err error) {
|
||||||
|
skipped, lm, err := s.downloadToPathOnce(resourceURL, destPath, lastModifiedHeader, false)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
if skipped {
|
||||||
|
if _, statErr := os.Stat(destPath); statErr == nil && !localDatFileNeedsRepair(destPath) {
|
||||||
|
return true, lm, nil
|
||||||
|
}
|
||||||
|
return s.downloadToPathOnce(resourceURL, destPath, lastModifiedHeader, true)
|
||||||
|
}
|
||||||
|
return false, lm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, lastModifiedHeader string, forceFull bool) (skipped bool, newLastModified string, err error) {
|
||||||
|
var req *http.Request
|
||||||
|
req, err = http.NewRequest(http.MethodGet, resourceURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !forceFull {
|
||||||
|
if fi, statErr := os.Stat(destPath); statErr == nil && !localDatFileNeedsRepair(destPath) {
|
||||||
|
if !fi.ModTime().IsZero() {
|
||||||
|
req.Header.Set("If-Modified-Since", fi.ModTime().UTC().Format(http.TimeFormat))
|
||||||
|
} else if lastModifiedHeader != "" {
|
||||||
|
if t, perr := time.Parse(http.TimeFormat, lastModifiedHeader); perr == nil {
|
||||||
|
req.Header.Set("If-Modified-Since", t.UTC().Format(http.TimeFormat))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Minute}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var serverModTime time.Time
|
||||||
|
if lm := resp.Header.Get("Last-Modified"); lm != "" {
|
||||||
|
if parsed, perr := time.Parse(http.TimeFormat, lm); perr == nil {
|
||||||
|
serverModTime = parsed
|
||||||
|
newLastModified = lm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateModTime := func() {
|
||||||
|
if !serverModTime.IsZero() {
|
||||||
|
_ = os.Chtimes(destPath, serverModTime, serverModTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotModified {
|
||||||
|
if forceFull {
|
||||||
|
return false, "", fmt.Errorf("%w: unexpected 304 on unconditional get", ErrCustomGeoDownload)
|
||||||
|
}
|
||||||
|
updateModTime()
|
||||||
|
return true, newLastModified, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, "", fmt.Errorf("%w: unexpected status %d", ErrCustomGeoDownload, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
binDir := filepath.Dir(destPath)
|
||||||
|
if err = os.MkdirAll(binDir, 0o755); err != nil {
|
||||||
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := destPath + ".tmp"
|
||||||
|
out, err := os.Create(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||||
|
}
|
||||||
|
n, err := io.Copy(out, resp.Body)
|
||||||
|
closeErr := out.Close()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, closeErr)
|
||||||
|
}
|
||||||
|
if n < minDatBytes {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return false, "", fmt.Errorf("%w: file too small", ErrCustomGeoDownload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = os.Rename(tmpPath, destPath); err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateModTime()
|
||||||
|
if newLastModified == "" && resp.Header.Get("Last-Modified") != "" {
|
||||||
|
newLastModified = resp.Header.Get("Last-Modified")
|
||||||
|
}
|
||||||
|
return false, newLastModified, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) resolveDestPath(r *model.CustomGeoResource) string {
|
||||||
|
if r.LocalPath != "" {
|
||||||
|
return r.LocalPath
|
||||||
|
}
|
||||||
|
return filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) syncLocalPath(r *model.CustomGeoResource) {
|
||||||
|
p := filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
|
||||||
|
r.LocalPath = p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) Create(r *model.CustomGeoResource) error {
|
||||||
|
if err := s.validateType(r.Type); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.validateAlias(r.Alias); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.validateURL(r.Url); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var existing int64
|
||||||
|
database.GetDB().Model(&model.CustomGeoResource{}).
|
||||||
|
Where("geo_type = ? AND alias = ?", r.Type, r.Alias).Count(&existing)
|
||||||
|
if existing > 0 {
|
||||||
|
return ErrCustomGeoDuplicateAlias
|
||||||
|
}
|
||||||
|
s.syncLocalPath(r)
|
||||||
|
skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
r.LastUpdatedAt = now
|
||||||
|
r.LastModified = lm
|
||||||
|
if err = database.GetDB().Create(r).Error; err != nil {
|
||||||
|
_ = os.Remove(r.LocalPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logger.Infof("custom geo created id=%d type=%s alias=%s skipped=%v", r.Id, r.Type, r.Alias, skipped)
|
||||||
|
if err = s.serverService.RestartXrayService(); err != nil {
|
||||||
|
logger.Warning("custom geo create: restart xray:", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) Update(id int, r *model.CustomGeoResource) error {
|
||||||
|
var cur model.CustomGeoResource
|
||||||
|
if err := database.GetDB().First(&cur, id).Error; err != nil {
|
||||||
|
if database.IsNotFound(err) {
|
||||||
|
return ErrCustomGeoNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.validateType(r.Type); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.validateAlias(r.Alias); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.validateURL(r.Url); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cur.Type != r.Type || cur.Alias != r.Alias {
|
||||||
|
var cnt int64
|
||||||
|
database.GetDB().Model(&model.CustomGeoResource{}).
|
||||||
|
Where("geo_type = ? AND alias = ? AND id <> ?", r.Type, r.Alias, id).
|
||||||
|
Count(&cnt)
|
||||||
|
if cnt > 0 {
|
||||||
|
return ErrCustomGeoDuplicateAlias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oldPath := s.resolveDestPath(&cur)
|
||||||
|
s.syncLocalPath(r)
|
||||||
|
r.Id = id
|
||||||
|
r.LocalPath = filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
|
||||||
|
if oldPath != r.LocalPath && oldPath != "" {
|
||||||
|
if _, err := os.Stat(oldPath); err == nil {
|
||||||
|
_ = os.Remove(oldPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, lm, err := s.downloadToPath(r.Url, r.LocalPath, cur.LastModified)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.LastUpdatedAt = time.Now().Unix()
|
||||||
|
r.LastModified = lm
|
||||||
|
err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(map[string]any{
|
||||||
|
"geo_type": r.Type,
|
||||||
|
"alias": r.Alias,
|
||||||
|
"url": r.Url,
|
||||||
|
"local_path": r.LocalPath,
|
||||||
|
"last_updated_at": r.LastUpdatedAt,
|
||||||
|
"last_modified": r.LastModified,
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logger.Infof("custom geo updated id=%d", id)
|
||||||
|
if err = s.serverService.RestartXrayService(); err != nil {
|
||||||
|
logger.Warning("custom geo update: restart xray:", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) Delete(id int) (displayName string, err error) {
|
||||||
|
var r model.CustomGeoResource
|
||||||
|
if err := database.GetDB().First(&r, id).Error; err != nil {
|
||||||
|
if database.IsNotFound(err) {
|
||||||
|
return "", ErrCustomGeoNotFound
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
displayName = s.fileNameFor(r.Type, r.Alias)
|
||||||
|
p := s.resolveDestPath(&r)
|
||||||
|
if err := database.GetDB().Delete(&model.CustomGeoResource{}, id).Error; err != nil {
|
||||||
|
return displayName, err
|
||||||
|
}
|
||||||
|
if p != "" {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
if rmErr := os.Remove(p); rmErr != nil {
|
||||||
|
logger.Warningf("custom geo delete file %s: %v", p, rmErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Infof("custom geo deleted id=%d", id)
|
||||||
|
if err := s.serverService.RestartXrayService(); err != nil {
|
||||||
|
logger.Warning("custom geo delete: restart xray:", err)
|
||||||
|
}
|
||||||
|
return displayName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) GetAll() ([]model.CustomGeoResource, error) {
|
||||||
|
var list []model.CustomGeoResource
|
||||||
|
err := database.GetDB().Order("id asc").Find(&list).Error
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) applyDownloadAndPersist(id int, onStartup bool) (displayName string, err error) {
|
||||||
|
var r model.CustomGeoResource
|
||||||
|
if err := database.GetDB().First(&r, id).Error; err != nil {
|
||||||
|
if database.IsNotFound(err) {
|
||||||
|
return "", ErrCustomGeoNotFound
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
displayName = s.fileNameFor(r.Type, r.Alias)
|
||||||
|
s.syncLocalPath(&r)
|
||||||
|
skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified)
|
||||||
|
if err != nil {
|
||||||
|
if onStartup {
|
||||||
|
logger.Warningf("custom geo startup download id=%d: %v", id, err)
|
||||||
|
} else {
|
||||||
|
logger.Warningf("custom geo manual update id=%d: %v", id, err)
|
||||||
|
}
|
||||||
|
return displayName, err
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
updates := map[string]any{
|
||||||
|
"last_modified": lm,
|
||||||
|
"local_path": r.LocalPath,
|
||||||
|
"last_updated_at": now,
|
||||||
|
}
|
||||||
|
if err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
if onStartup {
|
||||||
|
logger.Warningf("custom geo startup id=%d: persist metadata: %v", id, err)
|
||||||
|
} else {
|
||||||
|
logger.Warningf("custom geo manual update id=%d: persist metadata: %v", id, err)
|
||||||
|
}
|
||||||
|
return displayName, err
|
||||||
|
}
|
||||||
|
if skipped {
|
||||||
|
if onStartup {
|
||||||
|
logger.Infof("custom geo startup download skipped (not modified) id=%d", id)
|
||||||
|
} else {
|
||||||
|
logger.Infof("custom geo manual update skipped (not modified) id=%d", id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if onStartup {
|
||||||
|
logger.Infof("custom geo startup download ok id=%d", id)
|
||||||
|
} else {
|
||||||
|
logger.Infof("custom geo manual update ok id=%d", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return displayName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) TriggerUpdate(id int) (string, error) {
|
||||||
|
displayName, err := s.applyDownloadAndPersist(id, false)
|
||||||
|
if err != nil {
|
||||||
|
return displayName, err
|
||||||
|
}
|
||||||
|
if err = s.serverService.RestartXrayService(); err != nil {
|
||||||
|
logger.Warning("custom geo manual update: restart xray:", err)
|
||||||
|
}
|
||||||
|
return displayName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) TriggerUpdateAll() (*CustomGeoUpdateAllResult, error) {
|
||||||
|
var list []model.CustomGeoResource
|
||||||
|
var err error
|
||||||
|
if s.updateAllGetAll != nil {
|
||||||
|
list, err = s.updateAllGetAll()
|
||||||
|
} else {
|
||||||
|
list, err = s.GetAll()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res := &CustomGeoUpdateAllResult{}
|
||||||
|
if len(list) == 0 {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
for _, r := range list {
|
||||||
|
var name string
|
||||||
|
var applyErr error
|
||||||
|
if s.updateAllApply != nil {
|
||||||
|
name, applyErr = s.updateAllApply(r.Id, false)
|
||||||
|
} else {
|
||||||
|
name, applyErr = s.applyDownloadAndPersist(r.Id, false)
|
||||||
|
}
|
||||||
|
if applyErr != nil {
|
||||||
|
res.Failed = append(res.Failed, CustomGeoUpdateAllFailure{
|
||||||
|
Id: r.Id, Alias: r.Alias, FileName: name, Err: applyErr.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Succeeded = append(res.Succeeded, CustomGeoUpdateAllItem{
|
||||||
|
Id: r.Id, Alias: r.Alias, FileName: name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(res.Succeeded) > 0 {
|
||||||
|
var restartErr error
|
||||||
|
if s.updateAllRestart != nil {
|
||||||
|
restartErr = s.updateAllRestart()
|
||||||
|
} else {
|
||||||
|
restartErr = s.serverService.RestartXrayService()
|
||||||
|
}
|
||||||
|
if restartErr != nil {
|
||||||
|
logger.Warning("custom geo update all: restart xray:", restartErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomGeoAliasItem struct {
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
ExtExample string `json:"extExample"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomGeoAliasesResponse struct {
|
||||||
|
Geosite []CustomGeoAliasItem `json:"geosite"`
|
||||||
|
Geoip []CustomGeoAliasItem `json:"geoip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomGeoService) GetAliasesForUI() (CustomGeoAliasesResponse, error) {
|
||||||
|
list, err := s.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("custom geo GetAliasesForUI:", err)
|
||||||
|
return CustomGeoAliasesResponse{}, err
|
||||||
|
}
|
||||||
|
var out CustomGeoAliasesResponse
|
||||||
|
for _, r := range list {
|
||||||
|
fn := s.fileNameFor(r.Type, r.Alias)
|
||||||
|
ex := fmt.Sprintf("ext:%s:tag", fn)
|
||||||
|
item := CustomGeoAliasItem{
|
||||||
|
Alias: r.Alias,
|
||||||
|
Type: r.Type,
|
||||||
|
FileName: fn,
|
||||||
|
ExtExample: ex,
|
||||||
|
}
|
||||||
|
if r.Type == customGeoTypeGeoip {
|
||||||
|
out.Geoip = append(out.Geoip, item)
|
||||||
|
} else {
|
||||||
|
out.Geosite = append(out.Geosite, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
330
web/service/custom_geo_test.go
Normal file
330
web/service/custom_geo_test.go
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeAliasKey(t *testing.T) {
|
||||||
|
if got := NormalizeAliasKey("GeoIP-IR"); got != "geoip_ir" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
if got := NormalizeAliasKey("a-b_c"); got != "a_b_c" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewCustomGeoService(t *testing.T) {
|
||||||
|
s := NewCustomGeoService()
|
||||||
|
if err := s.validateAlias("ok_alias-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTriggerUpdateAllAllSuccess(t *testing.T) {
|
||||||
|
s := CustomGeoService{}
|
||||||
|
s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
|
||||||
|
return []model.CustomGeoResource{
|
||||||
|
{Id: 1, Alias: "a"},
|
||||||
|
{Id: 2, Alias: "b"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
s.updateAllApply = func(id int, onStartup bool) (string, error) {
|
||||||
|
return fmt.Sprintf("geo_%d.dat", id), nil
|
||||||
|
}
|
||||||
|
restartCalls := 0
|
||||||
|
s.updateAllRestart = func() error {
|
||||||
|
restartCalls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.TriggerUpdateAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(res.Succeeded) != 2 || len(res.Failed) != 0 {
|
||||||
|
t.Fatalf("unexpected result: %+v", res)
|
||||||
|
}
|
||||||
|
if restartCalls != 1 {
|
||||||
|
t.Fatalf("expected 1 restart, got %d", restartCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTriggerUpdateAllPartialSuccess(t *testing.T) {
|
||||||
|
s := CustomGeoService{}
|
||||||
|
s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
|
||||||
|
return []model.CustomGeoResource{
|
||||||
|
{Id: 1, Alias: "ok"},
|
||||||
|
{Id: 2, Alias: "bad"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
s.updateAllApply = func(id int, onStartup bool) (string, error) {
|
||||||
|
if id == 2 {
|
||||||
|
return "geo_2.dat", ErrCustomGeoDownload
|
||||||
|
}
|
||||||
|
return "geo_1.dat", nil
|
||||||
|
}
|
||||||
|
restartCalls := 0
|
||||||
|
s.updateAllRestart = func() error {
|
||||||
|
restartCalls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.TriggerUpdateAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(res.Succeeded) != 1 || len(res.Failed) != 1 {
|
||||||
|
t.Fatalf("unexpected result: %+v", res)
|
||||||
|
}
|
||||||
|
if restartCalls != 1 {
|
||||||
|
t.Fatalf("expected 1 restart, got %d", restartCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTriggerUpdateAllAllFailure(t *testing.T) {
|
||||||
|
s := CustomGeoService{}
|
||||||
|
s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
|
||||||
|
return []model.CustomGeoResource{
|
||||||
|
{Id: 1, Alias: "a"},
|
||||||
|
{Id: 2, Alias: "b"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
s.updateAllApply = func(id int, onStartup bool) (string, error) {
|
||||||
|
return fmt.Sprintf("geo_%d.dat", id), ErrCustomGeoDownload
|
||||||
|
}
|
||||||
|
restartCalls := 0
|
||||||
|
s.updateAllRestart = func() error {
|
||||||
|
restartCalls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.TriggerUpdateAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(res.Succeeded) != 0 || len(res.Failed) != 2 {
|
||||||
|
t.Fatalf("unexpected result: %+v", res)
|
||||||
|
}
|
||||||
|
if restartCalls != 0 {
|
||||||
|
t.Fatalf("expected 0 restart, got %d", restartCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomGeoValidateAlias(t *testing.T) {
|
||||||
|
s := CustomGeoService{}
|
||||||
|
if err := s.validateAlias(""); !errors.Is(err, ErrCustomGeoAliasRequired) {
|
||||||
|
t.Fatal("empty alias")
|
||||||
|
}
|
||||||
|
if err := s.validateAlias("Bad"); !errors.Is(err, ErrCustomGeoAliasPattern) {
|
||||||
|
t.Fatal("uppercase")
|
||||||
|
}
|
||||||
|
if err := s.validateAlias("a b"); !errors.Is(err, ErrCustomGeoAliasPattern) {
|
||||||
|
t.Fatal("space")
|
||||||
|
}
|
||||||
|
if err := s.validateAlias("ok_alias-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.validateAlias("geoip"); !errors.Is(err, ErrCustomGeoAliasReserved) {
|
||||||
|
t.Fatal("reserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomGeoValidateURL(t *testing.T) {
|
||||||
|
s := CustomGeoService{}
|
||||||
|
if err := s.validateURL(""); !errors.Is(err, ErrCustomGeoURLRequired) {
|
||||||
|
t.Fatal("empty")
|
||||||
|
}
|
||||||
|
if err := s.validateURL("ftp://x"); !errors.Is(err, ErrCustomGeoURLScheme) {
|
||||||
|
t.Fatal("ftp")
|
||||||
|
}
|
||||||
|
if err := s.validateURL("https://example.com/a.dat"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomGeoValidateType(t *testing.T) {
|
||||||
|
s := CustomGeoService{}
|
||||||
|
if err := s.validateType("geosite"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.validateType("x"); !errors.Is(err, ErrCustomGeoInvalidType) {
|
||||||
|
t.Fatal("bad type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomGeoDownloadToPath(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Test", "1")
|
||||||
|
if r.Header.Get("If-Modified-Since") != "" {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(make([]byte, minDatBytes+1))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XUI_BIN_FOLDER", dir)
|
||||||
|
dest := filepath.Join(dir, "geoip_t.dat")
|
||||||
|
s := CustomGeoService{}
|
||||||
|
skipped, _, err := s.downloadToPath(ts.URL, dest, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if skipped {
|
||||||
|
t.Fatal("expected download")
|
||||||
|
}
|
||||||
|
st, err := os.Stat(dest)
|
||||||
|
if err != nil || st.Size() < minDatBytes {
|
||||||
|
t.Fatalf("file %v", err)
|
||||||
|
}
|
||||||
|
skipped2, _, err2 := s.downloadToPath(ts.URL, dest, "")
|
||||||
|
if err2 != nil || !skipped2 {
|
||||||
|
t.Fatalf("304 expected skipped=%v err=%v", skipped2, err2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomGeoDownloadToPath_missingLocalSendsNoIMSFromDB(t *testing.T) {
|
||||||
|
lm := "Wed, 21 Oct 2015 07:28:00 GMT"
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("If-Modified-Since") != "" {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Last-Modified", lm)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(make([]byte, minDatBytes+1))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XUI_BIN_FOLDER", dir)
|
||||||
|
dest := filepath.Join(dir, "geoip_rebuild.dat")
|
||||||
|
s := CustomGeoService{}
|
||||||
|
skipped, _, err := s.downloadToPath(ts.URL, dest, lm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if skipped {
|
||||||
|
t.Fatal("must not treat as not-modified when local file is missing")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dest); err != nil {
|
||||||
|
t.Fatal("file should exist after container-style rebuild")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomGeoDownloadToPath_repairSkipsConditional(t *testing.T) {
|
||||||
|
lm := "Wed, 21 Oct 2015 07:28:00 GMT"
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("If-Modified-Since") != "" {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Last-Modified", lm)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(make([]byte, minDatBytes+1))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XUI_BIN_FOLDER", dir)
|
||||||
|
dest := filepath.Join(dir, "geoip_bad.dat")
|
||||||
|
if err := os.WriteFile(dest, make([]byte, minDatBytes-1), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := CustomGeoService{}
|
||||||
|
skipped, _, err := s.downloadToPath(ts.URL, dest, lm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if skipped {
|
||||||
|
t.Fatal("corrupt local file must be re-downloaded, not 304")
|
||||||
|
}
|
||||||
|
st, err := os.Stat(dest)
|
||||||
|
if err != nil || st.Size() < minDatBytes {
|
||||||
|
t.Fatalf("file repaired: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomGeoFileNameFor(t *testing.T) {
|
||||||
|
s := CustomGeoService{}
|
||||||
|
if s.fileNameFor("geoip", "a") != "geoip_a.dat" {
|
||||||
|
t.Fatal("geoip name")
|
||||||
|
}
|
||||||
|
if s.fileNameFor("geosite", "b") != "geosite_b.dat" {
|
||||||
|
t.Fatal("geosite name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalDatFileNeedsRepair(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if !localDatFileNeedsRepair(filepath.Join(dir, "missing.dat")) {
|
||||||
|
t.Fatal("missing")
|
||||||
|
}
|
||||||
|
smallPath := filepath.Join(dir, "small.dat")
|
||||||
|
if err := os.WriteFile(smallPath, make([]byte, minDatBytes-1), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !localDatFileNeedsRepair(smallPath) {
|
||||||
|
t.Fatal("small")
|
||||||
|
}
|
||||||
|
okPath := filepath.Join(dir, "ok.dat")
|
||||||
|
if err := os.WriteFile(okPath, make([]byte, minDatBytes), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if localDatFileNeedsRepair(okPath) {
|
||||||
|
t.Fatal("ok size")
|
||||||
|
}
|
||||||
|
dirPath := filepath.Join(dir, "isdir.dat")
|
||||||
|
if err := os.Mkdir(dirPath, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !localDatFileNeedsRepair(dirPath) {
|
||||||
|
t.Fatal("dir should need repair")
|
||||||
|
}
|
||||||
|
if !CustomGeoLocalFileNeedsRepair(dirPath) {
|
||||||
|
t.Fatal("exported wrapper dir")
|
||||||
|
}
|
||||||
|
if CustomGeoLocalFileNeedsRepair(okPath) {
|
||||||
|
t.Fatal("exported wrapper ok file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeCustomGeoURL_HEADOK(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
if err := probeCustomGeoURL(ts.URL); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeCustomGeoURL_HEAD405GETRange(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodGet && r.Header.Get("Range") != "" {
|
||||||
|
w.WriteHeader(http.StatusPartialContent)
|
||||||
|
_, _ = w.Write([]byte{0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
if err := probeCustomGeoURL(ts.URL); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1891,6 +1891,16 @@ func (s *InboundService) ResetAllTraffics() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) ResetInboundTraffic(id int) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
result := db.Model(model.Inbound{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]any{"up": 0, "down": 0})
|
||||||
|
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
func (s *InboundService) DelDepletedClients(id int) (err error) {
|
func (s *InboundService) DelDepletedClients(id int) (err error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
|
|
@ -2032,7 +2042,6 @@ func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.Cl
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if t != nil && client != nil {
|
if t != nil && client != nil {
|
||||||
t.Enable = client.Enable
|
|
||||||
t.UUID = client.ID
|
t.UUID = client.ID
|
||||||
t.SubId = client.SubID
|
t.SubId = client.SubID
|
||||||
return t, nil
|
return t, nil
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ import (
|
||||||
"github.com/shirou/gopsutil/v4/load"
|
"github.com/shirou/gopsutil/v4/load"
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
"github.com/shirou/gopsutil/v4/net"
|
"github.com/shirou/gopsutil/v4/net"
|
||||||
|
"github.com/xtls/xray-core/app/router"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProcessState represents the current state of a system process.
|
// ProcessState represents the current state of a system process.
|
||||||
|
|
@ -1055,6 +1057,48 @@ func (s *ServerService) IsValidGeofileName(filename string) bool {
|
||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeGeositeCountryCodes reads a geosite .dat file, uppercases all
|
||||||
|
// country_code fields, and writes it back. This works around a case-sensitivity
|
||||||
|
// mismatch in Xray-core: the router normalizes codes to uppercase before lookup,
|
||||||
|
// but the find() function compares bytes case-sensitively. Some geosite.dat
|
||||||
|
// providers (e.g. Loyalsoldier) store codes in lowercase, causing lookup failures.
|
||||||
|
func NormalizeGeositeCountryCodes(path string) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read geosite file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var list router.GeoSiteList
|
||||||
|
if err := proto.Unmarshal(data, &list); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse geosite file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
for _, entry := range list.Entry {
|
||||||
|
upper := strings.ToUpper(entry.CountryCode)
|
||||||
|
if entry.CountryCode != upper {
|
||||||
|
entry.CountryCode = upper
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := proto.Marshal(&list)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to serialize normalized geosite file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, normalized, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write normalized geosite file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("Normalized country codes to uppercase in %s (%d entries)", path, len(list.Entry))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ServerService) UpdateGeofile(fileName string) error {
|
func (s *ServerService) UpdateGeofile(fileName string) error {
|
||||||
type geofileEntry struct {
|
type geofileEntry struct {
|
||||||
URL string
|
URL string
|
||||||
|
|
@ -1146,12 +1190,22 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
|
||||||
|
|
||||||
var errorMessages []string
|
var errorMessages []string
|
||||||
|
|
||||||
|
normalizeIfGeosite := func(destPath, name string) {
|
||||||
|
if strings.Contains(name, "geosite") {
|
||||||
|
if err := NormalizeGeositeCountryCodes(destPath); err != nil {
|
||||||
|
logger.Warningf("Failed to normalize geosite country codes in %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if fileName == "" {
|
if fileName == "" {
|
||||||
// Download all geofiles
|
// Download all geofiles
|
||||||
for _, entry := range geofileAllowlist {
|
for _, entry := range geofileAllowlist {
|
||||||
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
|
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
|
||||||
if err := downloadFile(entry.URL, destPath); err != nil {
|
if err := downloadFile(entry.URL, destPath); err != nil {
|
||||||
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
|
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
|
||||||
|
} else {
|
||||||
|
normalizeIfGeosite(destPath, entry.FileName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1159,6 +1213,8 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
|
||||||
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
|
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
|
||||||
if err := downloadFile(entry.URL, destPath); err != nil {
|
if err := downloadFile(entry.URL, destPath); err != nil {
|
||||||
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
|
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
|
||||||
|
} else {
|
||||||
|
normalizeIfGeosite(destPath, entry.FileName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@ var defaultValueMap = map[string]string{
|
||||||
"subURI": "",
|
"subURI": "",
|
||||||
"subJsonPath": "/json/",
|
"subJsonPath": "/json/",
|
||||||
"subJsonURI": "",
|
"subJsonURI": "",
|
||||||
|
"subClashEnable": "true",
|
||||||
|
"subClashPath": "/clash/",
|
||||||
|
"subClashURI": "",
|
||||||
"subJsonFragment": "",
|
"subJsonFragment": "",
|
||||||
"subJsonNoises": "",
|
"subJsonNoises": "",
|
||||||
"subJsonMux": "",
|
"subJsonMux": "",
|
||||||
|
|
@ -109,7 +112,7 @@ var defaultValueMap = map[string]string{
|
||||||
// It handles configuration storage, retrieval, and validation for all system settings.
|
// 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) {
|
||||||
var jsonData any
|
var jsonData any
|
||||||
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
|
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -126,7 +129,7 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
allSetting := &entity.AllSetting{}
|
allSetting := &entity.AllSetting{}
|
||||||
t := reflect.TypeOf(allSetting).Elem()
|
t := reflect.TypeFor[entity.AllSetting]()
|
||||||
v := reflect.ValueOf(allSetting).Elem()
|
v := reflect.ValueOf(allSetting).Elem()
|
||||||
fields := reflect_util.GetFields(t)
|
fields := reflect_util.GetFields(t)
|
||||||
|
|
||||||
|
|
@ -556,6 +559,18 @@ func (s *SettingService) GetSubJsonURI() (string, error) {
|
||||||
return s.getString("subJsonURI")
|
return s.getString("subJsonURI")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetSubClashEnable() (bool, error) {
|
||||||
|
return s.getBool("subClashEnable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetSubClashPath() (string, error) {
|
||||||
|
return s.getString("subClashPath")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetSubClashURI() (string, error) {
|
||||||
|
return s.getString("subClashURI")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetSubJsonFragment() (string, error) {
|
func (s *SettingService) GetSubJsonFragment() (string, error) {
|
||||||
return s.getString("subJsonFragment")
|
return s.getString("subJsonFragment")
|
||||||
}
|
}
|
||||||
|
|
@ -616,7 +631,7 @@ func (s *SettingService) GetIpLimitEnable() (bool, error) {
|
||||||
return (accessLogPath != "none" && accessLogPath != ""), nil
|
return (accessLogPath != "none" && accessLogPath != ""), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LDAP exported getters
|
// GetLdapEnable returns whether LDAP is enabled.
|
||||||
func (s *SettingService) GetLdapEnable() (bool, error) {
|
func (s *SettingService) GetLdapEnable() (bool, error) {
|
||||||
return s.getBool("ldapEnable")
|
return s.getBool("ldapEnable")
|
||||||
}
|
}
|
||||||
|
|
@ -703,7 +718,7 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
v := reflect.ValueOf(allSetting).Elem()
|
v := reflect.ValueOf(allSetting).Elem()
|
||||||
t := reflect.TypeOf(allSetting).Elem()
|
t := reflect.TypeFor[entity.AllSetting]()
|
||||||
fields := reflect_util.GetFields(t)
|
fields := reflect_util.GetFields(t)
|
||||||
errs := make([]error, 0)
|
errs := make([]error, 0)
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
|
|
@ -759,11 +774,13 @@ 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() },
|
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
||||||
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
|
||||||
"subURI": func() (any, error) { return s.GetSubURI() },
|
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
||||||
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
|
"subURI": func() (any, error) { return s.GetSubURI() },
|
||||||
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
|
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
|
||||||
|
"subClashURI": func() (any, error) { return s.GetSubClashURI() },
|
||||||
|
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
|
||||||
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
||||||
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
||||||
}
|
}
|
||||||
|
|
@ -785,12 +802,19 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||||
subJsonEnable = b
|
subJsonEnable = b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") {
|
subClashEnable := false
|
||||||
|
if v, ok := result["subClashEnable"]; ok {
|
||||||
|
if b, ok2 := v.(bool); ok2 {
|
||||||
|
subClashEnable = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") || (subClashEnable && result["subClashURI"].(string) == "") {
|
||||||
subURI := ""
|
subURI := ""
|
||||||
subTitle, _ := s.GetSubTitle()
|
subTitle, _ := s.GetSubTitle()
|
||||||
subPort, _ := s.GetSubPort()
|
subPort, _ := s.GetSubPort()
|
||||||
subPath, _ := s.GetSubPath()
|
subPath, _ := s.GetSubPath()
|
||||||
subJsonPath, _ := s.GetSubJsonPath()
|
subJsonPath, _ := s.GetSubJsonPath()
|
||||||
|
subClashPath, _ := s.GetSubClashPath()
|
||||||
subDomain, _ := s.GetSubDomain()
|
subDomain, _ := s.GetSubDomain()
|
||||||
subKeyFile, _ := s.GetSubKeyFile()
|
subKeyFile, _ := s.GetSubKeyFile()
|
||||||
subCertFile, _ := s.GetSubCertFile()
|
subCertFile, _ := s.GetSubCertFile()
|
||||||
|
|
@ -820,6 +844,9 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||||
if subJsonEnable && result["subJsonURI"].(string) == "" {
|
if subJsonEnable && result["subJsonURI"].(string) == "" {
|
||||||
result["subJsonURI"] = subURI + subJsonPath
|
result["subJsonURI"] = subURI + subJsonPath
|
||||||
}
|
}
|
||||||
|
if subClashEnable && result["subClashURI"].(string) == "" {
|
||||||
|
result["subClashURI"] = subURI + subClashPath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
|
|
@ -15,6 +16,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -651,7 +653,7 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
|
||||||
msg += t.I18nBot("tgbot.commands.help")
|
msg += t.I18nBot("tgbot.commands.help")
|
||||||
msg += t.I18nBot("tgbot.commands.pleaseChoose")
|
msg += t.I18nBot("tgbot.commands.pleaseChoose")
|
||||||
case "start":
|
case "start":
|
||||||
msg += t.I18nBot("tgbot.commands.start", "Firstname=="+message.From.FirstName)
|
msg += t.I18nBot("tgbot.commands.start", "Firstname=="+html.EscapeString(message.From.FirstName))
|
||||||
if isAdmin {
|
if isAdmin {
|
||||||
msg += t.I18nBot("tgbot.commands.welcome", "Hostname=="+hostname)
|
msg += t.I18nBot("tgbot.commands.welcome", "Hostname=="+hostname)
|
||||||
}
|
}
|
||||||
|
|
@ -1924,6 +1926,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||||
} else {
|
} else {
|
||||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
|
||||||
|
t.sendClientIndividualLinks(chatId, client_Email)
|
||||||
|
t.sendClientQRLinks(chatId, client_Email)
|
||||||
}
|
}
|
||||||
case "add_client_submit_enable":
|
case "add_client_submit_enable":
|
||||||
client_Enable = true
|
client_Enable = true
|
||||||
|
|
@ -1934,6 +1938,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||||
} else {
|
} else {
|
||||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
|
||||||
|
t.sendClientIndividualLinks(chatId, client_Email)
|
||||||
|
t.sendClientQRLinks(chatId, client_Email)
|
||||||
}
|
}
|
||||||
case "reset_all_traffics_cancel":
|
case "reset_all_traffics_cancel":
|
||||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||||
|
|
@ -2718,7 +2724,7 @@ func (t *Tgbot) prepareServerUsageInfo() string {
|
||||||
info += t.I18nBot("tgbot.messages.ip", "IP=="+t.I18nBot("tgbot.unknown"))
|
info += t.I18nBot("tgbot.messages.ip", "IP=="+t.I18nBot("tgbot.unknown"))
|
||||||
info += "\r\n"
|
info += "\r\n"
|
||||||
} else {
|
} else {
|
||||||
for i := 0; i < len(netInterfaces); i++ {
|
for i := range netInterfaces {
|
||||||
if (netInterfaces[i].Flags & net.FlagUp) != 0 {
|
if (netInterfaces[i].Flags & net.FlagUp) != 0 {
|
||||||
addrs, _ := netInterfaces[i].Addrs()
|
addrs, _ := netInterfaces[i].Addrs()
|
||||||
|
|
||||||
|
|
@ -2787,29 +2793,29 @@ func (t *Tgbot) UserLoginNotify(username string, password string, ip string, tim
|
||||||
|
|
||||||
// getInboundUsages retrieves and formats inbound usage information.
|
// getInboundUsages retrieves and formats inbound usage information.
|
||||||
func (t *Tgbot) getInboundUsages() string {
|
func (t *Tgbot) getInboundUsages() string {
|
||||||
info := ""
|
var info strings.Builder
|
||||||
// get traffic
|
// get traffic
|
||||||
inbounds, err := t.inboundService.GetAllInbounds()
|
inbounds, err := t.inboundService.GetAllInbounds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("GetAllInbounds run failed:", err)
|
logger.Warning("GetAllInbounds run failed:", err)
|
||||||
info += t.I18nBot("tgbot.answers.getInboundsFailed")
|
info.WriteString(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||||
} else {
|
} else {
|
||||||
// NOTE:If there no any sessions here,need to notify here
|
// NOTE:If there no any sessions here,need to notify here
|
||||||
// TODO:Sub-node push, automatic conversion format
|
// TODO:Sub-node push, automatic conversion format
|
||||||
for _, inbound := range inbounds {
|
for _, inbound := range inbounds {
|
||||||
info += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
|
info.WriteString(t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark))
|
||||||
info += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
|
info.WriteString(t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port)))
|
||||||
info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))
|
info.WriteString(t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down)))
|
||||||
|
|
||||||
if inbound.ExpiryTime == 0 {
|
if inbound.ExpiryTime == 0 {
|
||||||
info += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
|
info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited")))
|
||||||
} else {
|
} else {
|
||||||
info += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
|
info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05")))
|
||||||
}
|
}
|
||||||
info += "\r\n"
|
info.WriteString("\r\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return info
|
return info.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// getInbounds creates an inline keyboard with all inbounds.
|
// getInbounds creates an inline keyboard with all inbounds.
|
||||||
|
|
@ -3059,12 +3065,9 @@ func (t *Tgbot) clientInfoMsg(
|
||||||
status := t.I18nBot("tgbot.offline")
|
status := t.I18nBot("tgbot.offline")
|
||||||
isOnline := false
|
isOnline := false
|
||||||
if p.IsRunning() {
|
if p.IsRunning() {
|
||||||
for _, online := range p.GetOnlineClients() {
|
if slices.Contains(p.GetOnlineClients(), traffic.Email) {
|
||||||
if online == traffic.Email {
|
status = t.I18nBot("tgbot.online")
|
||||||
status = t.I18nBot("tgbot.online")
|
isOnline = true
|
||||||
isOnline = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3303,6 +3306,27 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getCommonClientButtons returns the shared inline keyboard rows for client configuration
|
||||||
|
func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
|
||||||
|
return [][]telego.InlineKeyboardButton{
|
||||||
|
tu.InlineKeyboardRow(
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
||||||
|
),
|
||||||
|
tu.InlineKeyboardRow(
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"),
|
||||||
|
),
|
||||||
|
tu.InlineKeyboardRow(
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
|
||||||
|
),
|
||||||
|
tu.InlineKeyboardRow(
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// addClient handles the process of adding a new client to an inbound.
|
// 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)
|
||||||
|
|
@ -3313,91 +3337,40 @@ func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
||||||
|
|
||||||
protocol := inbound.Protocol
|
protocol := inbound.Protocol
|
||||||
|
|
||||||
|
var protocolRows [][]telego.InlineKeyboardButton
|
||||||
switch protocol {
|
switch protocol {
|
||||||
case model.VMESS, model.VLESS:
|
case model.VMESS, model.VLESS:
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
protocolRows = [][]telego.InlineKeyboardButton{
|
||||||
tu.InlineKeyboardRow(
|
tu.InlineKeyboardRow(
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
|
||||||
),
|
),
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if len(messageID) > 0 {
|
|
||||||
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
|
||||||
}
|
}
|
||||||
case model.Trojan:
|
case model.Trojan:
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
protocolRows = [][]telego.InlineKeyboardButton{
|
||||||
tu.InlineKeyboardRow(
|
tu.InlineKeyboardRow(
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_tr"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_tr"),
|
||||||
),
|
),
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
|
|
||||||
tu.InlineKeyboardButton("ip limit").WithCallbackData("add_client_ch_default_ip_limit"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if len(messageID) > 0 {
|
|
||||||
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
|
||||||
}
|
}
|
||||||
case model.Shadowsocks:
|
case model.Shadowsocks:
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
protocolRows = [][]telego.InlineKeyboardButton{
|
||||||
tu.InlineKeyboardRow(
|
tu.InlineKeyboardRow(
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_sh"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_sh"),
|
||||||
),
|
),
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
|
|
||||||
tu.InlineKeyboardButton("ip limit").WithCallbackData("add_client_ch_default_ip_limit"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(messageID) > 0 {
|
|
||||||
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonRows := t.getCommonClientButtons()
|
||||||
|
inlineKeyboard := tu.InlineKeyboard(append(protocolRows, commonRows...)...)
|
||||||
|
|
||||||
|
if len(messageID) > 0 {
|
||||||
|
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
||||||
|
} else {
|
||||||
|
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchInbound searches for inbounds by remark and sends the results.
|
// searchInbound searches for inbounds by remark and sends the results.
|
||||||
|
|
@ -3429,11 +3402,11 @@ func (t *Tgbot) searchInbound(chatId int64, remark string) {
|
||||||
t.SendMsgToTgbot(chatId, info)
|
t.SendMsgToTgbot(chatId, info)
|
||||||
|
|
||||||
if len(inbound.ClientStats) > 0 {
|
if len(inbound.ClientStats) > 0 {
|
||||||
output := ""
|
var output strings.Builder
|
||||||
for _, traffic := range inbound.ClientStats {
|
for _, traffic := range inbound.ClientStats {
|
||||||
output += t.clientInfoMsg(&traffic, true, true, true, true, true, true)
|
output.WriteString(t.clientInfoMsg(&traffic, true, true, true, true, true, true))
|
||||||
}
|
}
|
||||||
t.SendMsgToTgbot(chatId, output)
|
t.SendMsgToTgbot(chatId, output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ func (s *UserService) GetFirstUser() (*model.User, error) {
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) CheckUser(username string, password string, twoFactorCode string) *model.User {
|
func (s *UserService) CheckUser(username string, password string, twoFactorCode string) (*model.User, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
user := &model.User{}
|
user := &model.User{}
|
||||||
|
|
@ -43,17 +43,16 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
||||||
First(user).
|
First(user).
|
||||||
Error
|
Error
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return nil
|
return nil, errors.New("invalid credentials")
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
logger.Warning("check user err:", err)
|
logger.Warning("check user err:", err)
|
||||||
return nil
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If LDAP enabled and local password check fails, attempt LDAP auth
|
|
||||||
if !crypto.CheckPasswordHash(user.Password, password) {
|
if !crypto.CheckPasswordHash(user.Password, password) {
|
||||||
ldapEnabled, _ := s.settingService.GetLdapEnable()
|
ldapEnabled, _ := s.settingService.GetLdapEnable()
|
||||||
if !ldapEnabled {
|
if !ldapEnabled {
|
||||||
return nil
|
return nil, errors.New("invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
host, _ := s.settingService.GetLdapHost()
|
host, _ := s.settingService.GetLdapHost()
|
||||||
|
|
@ -77,15 +76,14 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
||||||
}
|
}
|
||||||
ok, err := ldaputil.AuthenticateUser(cfg, username, password)
|
ok, err := ldaputil.AuthenticateUser(cfg, username, password)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return nil
|
return nil, errors.New("invalid credentials")
|
||||||
}
|
}
|
||||||
// On successful LDAP auth, continue 2FA checks below
|
|
||||||
}
|
}
|
||||||
|
|
||||||
twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
|
twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("check two factor err:", err)
|
logger.Warning("check two factor err:", err)
|
||||||
return nil
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if twoFactorEnable {
|
if twoFactorEnable {
|
||||||
|
|
@ -93,15 +91,15 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("check two factor token err:", err)
|
logger.Warning("check two factor token err:", err)
|
||||||
return nil
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode {
|
if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode {
|
||||||
return nil
|
return nil, errors.New("invalid 2fa code")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return user
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) UpdateUser(id int, username string, password string) error {
|
func (s *UserService) UpdateUser(id int, username string, password string) error {
|
||||||
|
|
|
||||||
|
|
@ -118,31 +118,35 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||||
clients, ok := settings["clients"].([]any)
|
clients, ok := settings["clients"].([]any)
|
||||||
if ok {
|
if ok {
|
||||||
// check users active or not
|
// Fast O(N) lookup map for client traffic enablement
|
||||||
clientStats := inbound.ClientStats
|
clientStats := inbound.ClientStats
|
||||||
|
enableMap := make(map[string]bool, len(clientStats))
|
||||||
for _, clientTraffic := range clientStats {
|
for _, clientTraffic := range clientStats {
|
||||||
indexDecrease := 0
|
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||||
for index, client := range clients {
|
|
||||||
c := client.(map[string]any)
|
|
||||||
if c["email"] == clientTraffic.Email {
|
|
||||||
if !clientTraffic.Enable {
|
|
||||||
clients = RemoveIndex(clients, index-indexDecrease)
|
|
||||||
indexDecrease++
|
|
||||||
logger.Infof("Remove Inbound User %s due to expiration or traffic limit", c["email"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// clear client config for additional parameters
|
// filter and clean clients
|
||||||
var final_clients []any
|
var final_clients []any
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
c := client.(map[string]any)
|
c, ok := client.(map[string]any)
|
||||||
if c["enable"] != nil {
|
if !ok {
|
||||||
if enable, ok := c["enable"].(bool); ok && !enable {
|
continue
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
email, _ := c["email"].(string)
|
||||||
|
|
||||||
|
// check users active or not via stats
|
||||||
|
if enable, exists := enableMap[email]; exists && !enable {
|
||||||
|
logger.Infof("Remove Inbound User %s due to expiration or traffic limit", email)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// check manual disabled flag
|
||||||
|
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear client config for additional parameters
|
||||||
for key := range c {
|
for key := range c {
|
||||||
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" {
|
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" {
|
||||||
delete(c, key)
|
delete(c, key)
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,47 @@
|
||||||
"readDatabaseError" = "حدث خطأ أثناء قراءة قاعدة البيانات"
|
"readDatabaseError" = "حدث خطأ أثناء قراءة قاعدة البيانات"
|
||||||
"getDatabaseError" = "حدث خطأ أثناء استرجاع قاعدة البيانات"
|
"getDatabaseError" = "حدث خطأ أثناء استرجاع قاعدة البيانات"
|
||||||
"getConfigError" = "حدث خطأ أثناء استرجاع ملف الإعدادات"
|
"getConfigError" = "حدث خطأ أثناء استرجاع ملف الإعدادات"
|
||||||
|
"customGeoTitle" = "GeoSite / GeoIP مخصص"
|
||||||
|
"customGeoAdd" = "إضافة"
|
||||||
|
"customGeoType" = "النوع"
|
||||||
|
"customGeoAlias" = "الاسم المستعار"
|
||||||
|
"customGeoUrl" = "URL"
|
||||||
|
"customGeoEnabled" = "مفعّل"
|
||||||
|
"customGeoLastUpdated" = "آخر تحديث"
|
||||||
|
"customGeoExtColumn" = "التوجيه (ext:…)"
|
||||||
|
"customGeoToastUpdateAll" = "تم تحديث جميع المصادر المخصصة"
|
||||||
|
"customGeoActions" = "إجراءات"
|
||||||
|
"customGeoEdit" = "تعديل"
|
||||||
|
"customGeoDelete" = "حذف"
|
||||||
|
"customGeoDownload" = "تحديث الآن"
|
||||||
|
"customGeoModalAdd" = "إضافة geo مخصص"
|
||||||
|
"customGeoModalEdit" = "تعديل geo مخصص"
|
||||||
|
"customGeoModalSave" = "حفظ"
|
||||||
|
"customGeoDeleteConfirm" = "حذف مصدر geo المخصص هذا؟"
|
||||||
|
"customGeoRoutingHint" = "في قواعد التوجيه استخدم العمود كـ ext:file.dat:tag (استبدل tag)."
|
||||||
|
"customGeoInvalidId" = "معرّف المورد غير صالح"
|
||||||
|
"customGeoAliasesError" = "تعذّر تحميل أسماء geo المخصصة"
|
||||||
|
"customGeoValidationAlias" = "الاسم المستعار: أحرف صغيرة وأرقام و - و _ فقط"
|
||||||
|
"customGeoValidationUrl" = "يجب أن يبدأ الرابط بـ http:// أو https://"
|
||||||
|
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||||
|
"customGeoAliasLabelSuffix" = " (مخصص)"
|
||||||
|
"customGeoToastList" = "قائمة geo المخصص"
|
||||||
|
"customGeoToastAdd" = "إضافة geo مخصص"
|
||||||
|
"customGeoToastUpdate" = "تحديث geo مخصص"
|
||||||
|
"customGeoToastDelete" = "تم حذف geofile «{{ .fileName }}» المخصص"
|
||||||
|
"customGeoToastDownload" = "تم تحديث geofile «{{ .fileName }}»"
|
||||||
|
"customGeoErrInvalidType" = "يجب أن يكون النوع geosite أو geoip"
|
||||||
|
"customGeoErrAliasRequired" = "الاسم المستعار مطلوب"
|
||||||
|
"customGeoErrAliasPattern" = "الاسم المستعار يحتوي على أحرف غير مسموحة"
|
||||||
|
"customGeoErrAliasReserved" = "هذا الاسم محجوز"
|
||||||
|
"customGeoErrUrlRequired" = "الرابط مطلوب"
|
||||||
|
"customGeoErrInvalidUrl" = "الرابط غير صالح"
|
||||||
|
"customGeoErrUrlScheme" = "يجب أن يستخدم الرابط http أو https"
|
||||||
|
"customGeoErrUrlHost" = "مضيف الرابط غير صالح"
|
||||||
|
"customGeoErrDuplicateAlias" = "هذا الاسم مستخدم مسبقاً لهذا النوع"
|
||||||
|
"customGeoErrNotFound" = "مصدر geo المخصص غير موجود"
|
||||||
|
"customGeoErrDownload" = "فشل التنزيل"
|
||||||
|
"customGeoErrUpdateAllIncomplete" = "تعذر تحديث مصدر واحد أو أكثر من مصادر geo المخصصة"
|
||||||
|
|
||||||
[pages.inbounds]
|
[pages.inbounds]
|
||||||
"allTimeTraffic" = "إجمالي حركة المرور"
|
"allTimeTraffic" = "إجمالي حركة المرور"
|
||||||
|
|
@ -271,6 +312,7 @@
|
||||||
"daily" = "يومياً"
|
"daily" = "يومياً"
|
||||||
"weekly" = "أسبوعياً"
|
"weekly" = "أسبوعياً"
|
||||||
"monthly" = "شهرياً"
|
"monthly" = "شهرياً"
|
||||||
|
"hourly" = "كل ساعة"
|
||||||
|
|
||||||
[pages.inbounds.toasts]
|
[pages.inbounds.toasts]
|
||||||
"obtain" = "تم الحصول عليه"
|
"obtain" = "تم الحصول عليه"
|
||||||
|
|
@ -663,6 +705,7 @@
|
||||||
"userSaved" = "✅ حفظت بيانات مستخدم Telegram."
|
"userSaved" = "✅ حفظت بيانات مستخدم Telegram."
|
||||||
"loginSuccess" = "✅ تسجيل الدخول للبانل تم بنجاح.\r\n"
|
"loginSuccess" = "✅ تسجيل الدخول للبانل تم بنجاح.\r\n"
|
||||||
"loginFailed" = "❗️فشل محاولة تسجيل الدخول للبانل.\r\n"
|
"loginFailed" = "❗️فشل محاولة تسجيل الدخول للبانل.\r\n"
|
||||||
|
"2faFailed" = "فشل 2FA"
|
||||||
"report" = "🕰 التقارير المجدولة: {{ .RunTime }}\r\n"
|
"report" = "🕰 التقارير المجدولة: {{ .RunTime }}\r\n"
|
||||||
"datetime" = "⏰ التاريخ والوقت: {{ .DateTime }}\r\n"
|
"datetime" = "⏰ التاريخ والوقت: {{ .DateTime }}\r\n"
|
||||||
"hostname" = "💻 السيرفر: {{ .Hostname }}\r\n"
|
"hostname" = "💻 السيرفر: {{ .Hostname }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,47 @@
|
||||||
"geofilesUpdateDialogDesc" = "This will update all geofiles."
|
"geofilesUpdateDialogDesc" = "This will update all geofiles."
|
||||||
"geofilesUpdateAll" = "Update all"
|
"geofilesUpdateAll" = "Update all"
|
||||||
"geofileUpdatePopover" = "Geofile updated successfully"
|
"geofileUpdatePopover" = "Geofile updated successfully"
|
||||||
|
"customGeoTitle" = "Custom GeoSite / GeoIP"
|
||||||
|
"customGeoAdd" = "Add"
|
||||||
|
"customGeoType" = "Type"
|
||||||
|
"customGeoAlias" = "Alias"
|
||||||
|
"customGeoUrl" = "URL"
|
||||||
|
"customGeoEnabled" = "Enabled"
|
||||||
|
"customGeoLastUpdated" = "Last updated"
|
||||||
|
"customGeoExtColumn" = "Routing (ext:…)"
|
||||||
|
"customGeoToastUpdateAll" = "All custom geo sources updated"
|
||||||
|
"customGeoActions" = "Actions"
|
||||||
|
"customGeoEdit" = "Edit"
|
||||||
|
"customGeoDelete" = "Delete"
|
||||||
|
"customGeoDownload" = "Update now"
|
||||||
|
"customGeoModalAdd" = "Add custom geo"
|
||||||
|
"customGeoModalEdit" = "Edit custom geo"
|
||||||
|
"customGeoModalSave" = "Save"
|
||||||
|
"customGeoDeleteConfirm" = "Delete this custom geo source?"
|
||||||
|
"customGeoRoutingHint" = "In routing rules use the value column as ext:file.dat:tag (replace tag)."
|
||||||
|
"customGeoInvalidId" = "Invalid resource id"
|
||||||
|
"customGeoAliasesError" = "Failed to load custom geo aliases"
|
||||||
|
"customGeoValidationAlias" = "Alias may only contain lowercase letters, digits, - and _"
|
||||||
|
"customGeoValidationUrl" = "URL must start with http:// or https://"
|
||||||
|
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||||
|
"customGeoAliasLabelSuffix" = " (custom)"
|
||||||
|
"customGeoToastList" = "Custom geo list"
|
||||||
|
"customGeoToastAdd" = "Add custom geo"
|
||||||
|
"customGeoToastUpdate" = "Update custom geo"
|
||||||
|
"customGeoToastDelete" = "Custom geo file “{{ .fileName }}” deleted"
|
||||||
|
"customGeoToastDownload" = "Geofile “{{ .fileName }}” updated"
|
||||||
|
"customGeoErrInvalidType" = "Type must be geosite or geoip"
|
||||||
|
"customGeoErrAliasRequired" = "Alias is required"
|
||||||
|
"customGeoErrAliasPattern" = "Alias must match allowed characters"
|
||||||
|
"customGeoErrAliasReserved" = "This alias is reserved"
|
||||||
|
"customGeoErrUrlRequired" = "URL is required"
|
||||||
|
"customGeoErrInvalidUrl" = "URL is invalid"
|
||||||
|
"customGeoErrUrlScheme" = "URL must use http or https"
|
||||||
|
"customGeoErrUrlHost" = "URL host is invalid"
|
||||||
|
"customGeoErrDuplicateAlias" = "This alias is already used for this type"
|
||||||
|
"customGeoErrNotFound" = "Custom geo source not found"
|
||||||
|
"customGeoErrDownload" = "Download failed"
|
||||||
|
"customGeoErrUpdateAllIncomplete" = "One or more custom geo sources failed to update"
|
||||||
"dontRefresh" = "Installation is in progress, please do not refresh this page"
|
"dontRefresh" = "Installation is in progress, please do not refresh this page"
|
||||||
"logs" = "Logs"
|
"logs" = "Logs"
|
||||||
"config" = "Config"
|
"config" = "Config"
|
||||||
|
|
@ -273,6 +314,7 @@
|
||||||
"daily" = "Daily"
|
"daily" = "Daily"
|
||||||
"weekly" = "Weekly"
|
"weekly" = "Weekly"
|
||||||
"monthly" = "Monthly"
|
"monthly" = "Monthly"
|
||||||
|
"hourly" = "Hourly"
|
||||||
|
|
||||||
[pages.inbounds.toasts]
|
[pages.inbounds.toasts]
|
||||||
"obtain" = "Obtain"
|
"obtain" = "Obtain"
|
||||||
|
|
@ -675,6 +717,7 @@
|
||||||
"userSaved" = "✅ Telegram User saved."
|
"userSaved" = "✅ Telegram User saved."
|
||||||
"loginSuccess" = "✅ Logged in to the panel successfully.\r\n"
|
"loginSuccess" = "✅ Logged in to the panel successfully.\r\n"
|
||||||
"loginFailed" = "❗️Login attempt to the panel failed.\r\n"
|
"loginFailed" = "❗️Login attempt to the panel failed.\r\n"
|
||||||
|
"2faFailed" = "2FA Failed"
|
||||||
"report" = "🕰 Scheduled Reports: {{ .RunTime }}\r\n"
|
"report" = "🕰 Scheduled Reports: {{ .RunTime }}\r\n"
|
||||||
"datetime" = "⏰ Date&Time: {{ .DateTime }}\r\n"
|
"datetime" = "⏰ Date&Time: {{ .DateTime }}\r\n"
|
||||||
"hostname" = "💻 Host: {{ .Hostname }}\r\n"
|
"hostname" = "💻 Host: {{ .Hostname }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,47 @@
|
||||||
"readDatabaseError" = "Ocurrió un error al leer la base de datos"
|
"readDatabaseError" = "Ocurrió un error al leer la base de datos"
|
||||||
"getDatabaseError" = "Ocurrió un error al obtener la base de datos"
|
"getDatabaseError" = "Ocurrió un error al obtener la base de datos"
|
||||||
"getConfigError" = "Ocurrió un error al obtener el archivo de configuración"
|
"getConfigError" = "Ocurrió un error al obtener el archivo de configuración"
|
||||||
|
"customGeoTitle" = "GeoSite / GeoIP personalizados"
|
||||||
|
"customGeoAdd" = "Añadir"
|
||||||
|
"customGeoType" = "Tipo"
|
||||||
|
"customGeoAlias" = "Alias"
|
||||||
|
"customGeoUrl" = "URL"
|
||||||
|
"customGeoEnabled" = "Activado"
|
||||||
|
"customGeoLastUpdated" = "Última actualización"
|
||||||
|
"customGeoExtColumn" = "Enrutamiento (ext:…)"
|
||||||
|
"customGeoToastUpdateAll" = "Todas las fuentes personalizadas se actualizaron"
|
||||||
|
"customGeoActions" = "Acciones"
|
||||||
|
"customGeoEdit" = "Editar"
|
||||||
|
"customGeoDelete" = "Eliminar"
|
||||||
|
"customGeoDownload" = "Actualizar ahora"
|
||||||
|
"customGeoModalAdd" = "Añadir geo personalizado"
|
||||||
|
"customGeoModalEdit" = "Editar geo personalizado"
|
||||||
|
"customGeoModalSave" = "Guardar"
|
||||||
|
"customGeoDeleteConfirm" = "¿Eliminar esta fuente geo personalizada?"
|
||||||
|
"customGeoRoutingHint" = "En reglas de enrutamiento use la columna de valor como ext:archivo.dat:etiqueta (sustituya la etiqueta)."
|
||||||
|
"customGeoInvalidId" = "Id de recurso no válido"
|
||||||
|
"customGeoAliasesError" = "No se pudieron cargar los alias geo personalizados"
|
||||||
|
"customGeoValidationAlias" = "El alias solo puede contener letras minúsculas, dígitos, - y _"
|
||||||
|
"customGeoValidationUrl" = "La URL debe comenzar con http:// o https://"
|
||||||
|
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||||
|
"customGeoAliasLabelSuffix" = " (personalizado)"
|
||||||
|
"customGeoToastList" = "Lista de geo personalizado"
|
||||||
|
"customGeoToastAdd" = "Añadir geo personalizado"
|
||||||
|
"customGeoToastUpdate" = "Actualizar geo personalizado"
|
||||||
|
"customGeoToastDelete" = "Geofile personalizado «{{ .fileName }}» eliminado"
|
||||||
|
"customGeoToastDownload" = "Geofile «{{ .fileName }}» actualizado"
|
||||||
|
"customGeoErrInvalidType" = "El tipo debe ser geosite o geoip"
|
||||||
|
"customGeoErrAliasRequired" = "El alias es obligatorio"
|
||||||
|
"customGeoErrAliasPattern" = "El alias contiene caracteres no permitidos"
|
||||||
|
"customGeoErrAliasReserved" = "Este alias está reservado"
|
||||||
|
"customGeoErrUrlRequired" = "La URL es obligatoria"
|
||||||
|
"customGeoErrInvalidUrl" = "La URL no es válida"
|
||||||
|
"customGeoErrUrlScheme" = "La URL debe usar http o https"
|
||||||
|
"customGeoErrUrlHost" = "El host de la URL no es válido"
|
||||||
|
"customGeoErrDuplicateAlias" = "Este alias ya se usa para este tipo"
|
||||||
|
"customGeoErrNotFound" = "Fuente geo personalizada no encontrada"
|
||||||
|
"customGeoErrDownload" = "Error de descarga"
|
||||||
|
"customGeoErrUpdateAllIncomplete" = "No se pudieron actualizar una o más fuentes geo personalizadas"
|
||||||
|
|
||||||
[pages.inbounds]
|
[pages.inbounds]
|
||||||
"allTimeTraffic" = "Tráfico Total"
|
"allTimeTraffic" = "Tráfico Total"
|
||||||
|
|
@ -271,6 +312,7 @@
|
||||||
"daily" = "Diariamente"
|
"daily" = "Diariamente"
|
||||||
"weekly" = "Semanalmente"
|
"weekly" = "Semanalmente"
|
||||||
"monthly" = "Mensualmente"
|
"monthly" = "Mensualmente"
|
||||||
|
"hourly" = "Cada hora"
|
||||||
|
|
||||||
[pages.inbounds.toasts]
|
[pages.inbounds.toasts]
|
||||||
"obtain" = "Recibir"
|
"obtain" = "Recibir"
|
||||||
|
|
@ -663,6 +705,7 @@
|
||||||
"userSaved" = "✅ Usuario de Telegram guardado."
|
"userSaved" = "✅ Usuario de Telegram guardado."
|
||||||
"loginSuccess" = "✅ Has iniciado sesión en el panel con éxito.\r\n"
|
"loginSuccess" = "✅ Has iniciado sesión en el panel con éxito.\r\n"
|
||||||
"loginFailed" = "❗️ Falló el inicio de sesión en el panel.\r\n"
|
"loginFailed" = "❗️ Falló el inicio de sesión en el panel.\r\n"
|
||||||
|
"2faFailed" = "Error de 2FA"
|
||||||
"report" = "🕰 Informes programados: {{ .RunTime }}\r\n"
|
"report" = "🕰 Informes programados: {{ .RunTime }}\r\n"
|
||||||
"datetime" = "⏰ Fecha y Hora: {{ .DateTime }}\r\n"
|
"datetime" = "⏰ Fecha y Hora: {{ .DateTime }}\r\n"
|
||||||
"hostname" = "💻 Nombre del Host: {{ .Hostname }}\r\n"
|
"hostname" = "💻 Nombre del Host: {{ .Hostname }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,47 @@
|
||||||
"readDatabaseError" = "خطا در خواندن پایگاه داده"
|
"readDatabaseError" = "خطا در خواندن پایگاه داده"
|
||||||
"getDatabaseError" = "خطا در دریافت پایگاه داده"
|
"getDatabaseError" = "خطا در دریافت پایگاه داده"
|
||||||
"getConfigError" = "خطا در دریافت فایل پیکربندی"
|
"getConfigError" = "خطا در دریافت فایل پیکربندی"
|
||||||
|
"customGeoTitle" = "GeoSite / GeoIP سفارشی"
|
||||||
|
"customGeoAdd" = "افزودن"
|
||||||
|
"customGeoType" = "نوع"
|
||||||
|
"customGeoAlias" = "نام مستعار"
|
||||||
|
"customGeoUrl" = "URL"
|
||||||
|
"customGeoEnabled" = "فعال"
|
||||||
|
"customGeoLastUpdated" = "آخرین بهروزرسانی"
|
||||||
|
"customGeoExtColumn" = "مسیریابی (ext:…)"
|
||||||
|
"customGeoToastUpdateAll" = "همه منابع سفارشی بهروزرسانی شدند"
|
||||||
|
"customGeoActions" = "اقدامات"
|
||||||
|
"customGeoEdit" = "ویرایش"
|
||||||
|
"customGeoDelete" = "حذف"
|
||||||
|
"customGeoDownload" = "بهروزرسانی اکنون"
|
||||||
|
"customGeoModalAdd" = "افزودن geo سفارشی"
|
||||||
|
"customGeoModalEdit" = "ویرایش geo سفارشی"
|
||||||
|
"customGeoModalSave" = "ذخیره"
|
||||||
|
"customGeoDeleteConfirm" = "این منبع geo سفارشی حذف شود؟"
|
||||||
|
"customGeoRoutingHint" = "در قوانین مسیریابی مقدار را به صورت ext:file.dat:tag استفاده کنید (tag را جایگزین کنید)."
|
||||||
|
"customGeoInvalidId" = "شناسه منبع نامعتبر است"
|
||||||
|
"customGeoAliasesError" = "بارگذاری نام مستعارهای geo سفارشی ناموفق بود"
|
||||||
|
"customGeoValidationAlias" = "نام مستعار فقط حروف کوچک، اعداد، - و _"
|
||||||
|
"customGeoValidationUrl" = "URL باید با http:// یا https:// شروع شود"
|
||||||
|
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||||
|
"customGeoAliasLabelSuffix" = " (سفارشی)"
|
||||||
|
"customGeoToastList" = "فهرست geo سفارشی"
|
||||||
|
"customGeoToastAdd" = "افزودن geo سفارشی"
|
||||||
|
"customGeoToastUpdate" = "بهروزرسانی geo سفارشی"
|
||||||
|
"customGeoToastDelete" = "geofile سفارشی «{{ .fileName }}» حذف شد"
|
||||||
|
"customGeoToastDownload" = "geofile «{{ .fileName }}» بهروزرسانی شد"
|
||||||
|
"customGeoErrInvalidType" = "نوع باید geosite یا geoip باشد"
|
||||||
|
"customGeoErrAliasRequired" = "نام مستعار لازم است"
|
||||||
|
"customGeoErrAliasPattern" = "نام مستعار دارای نویسه نامجاز است"
|
||||||
|
"customGeoErrAliasReserved" = "این نام مستعار رزرو است"
|
||||||
|
"customGeoErrUrlRequired" = "URL لازم است"
|
||||||
|
"customGeoErrInvalidUrl" = "URL نامعتبر است"
|
||||||
|
"customGeoErrUrlScheme" = "URL باید http یا https باشد"
|
||||||
|
"customGeoErrUrlHost" = "میزبان URL نامعتبر است"
|
||||||
|
"customGeoErrDuplicateAlias" = "این نام مستعار برای این نوع قبلاً استفاده شده است"
|
||||||
|
"customGeoErrNotFound" = "منبع geo سفارشی یافت نشد"
|
||||||
|
"customGeoErrDownload" = "بارگیری ناموفق بود"
|
||||||
|
"customGeoErrUpdateAllIncomplete" = "بهروزرسانی یک یا چند منبع geo سفارشی ناموفق بود"
|
||||||
|
|
||||||
[pages.inbounds]
|
[pages.inbounds]
|
||||||
"allTimeTraffic" = "کل ترافیک"
|
"allTimeTraffic" = "کل ترافیک"
|
||||||
|
|
@ -273,6 +314,7 @@
|
||||||
"daily" = "روزانه"
|
"daily" = "روزانه"
|
||||||
"weekly" = "هفتگی"
|
"weekly" = "هفتگی"
|
||||||
"monthly" = "ماهانه"
|
"monthly" = "ماهانه"
|
||||||
|
"hourly" = "هر ساعت"
|
||||||
|
|
||||||
[pages.inbounds.toasts]
|
[pages.inbounds.toasts]
|
||||||
"obtain" = "فراهمسازی"
|
"obtain" = "فراهمسازی"
|
||||||
|
|
@ -673,6 +715,7 @@
|
||||||
"userSaved" = "✅ کاربر تلگرام ذخیره شد."
|
"userSaved" = "✅ کاربر تلگرام ذخیره شد."
|
||||||
"loginSuccess" = "✅ با موفقیت به پنل وارد شدید.\r\n"
|
"loginSuccess" = "✅ با موفقیت به پنل وارد شدید.\r\n"
|
||||||
"loginFailed" = "❗️ ورود به پنل ناموفقبود \r\n"
|
"loginFailed" = "❗️ ورود به پنل ناموفقبود \r\n"
|
||||||
|
"2faFailed" = "خطای 2FA"
|
||||||
"report" = "🕰 گزارشاتزمانبندیشده: {{ .RunTime }}\r\n"
|
"report" = "🕰 گزارشاتزمانبندیشده: {{ .RunTime }}\r\n"
|
||||||
"datetime" = "⏰ تاریخوزمان: {{ .DateTime }}\r\n"
|
"datetime" = "⏰ تاریخوزمان: {{ .DateTime }}\r\n"
|
||||||
"hostname" = "💻 ناممیزبان: {{ .Hostname }}\r\n"
|
"hostname" = "💻 ناممیزبان: {{ .Hostname }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,47 @@
|
||||||
"readDatabaseError" = "Terjadi kesalahan saat membaca database"
|
"readDatabaseError" = "Terjadi kesalahan saat membaca database"
|
||||||
"getDatabaseError" = "Terjadi kesalahan saat mengambil database"
|
"getDatabaseError" = "Terjadi kesalahan saat mengambil database"
|
||||||
"getConfigError" = "Terjadi kesalahan saat mengambil file konfigurasi"
|
"getConfigError" = "Terjadi kesalahan saat mengambil file konfigurasi"
|
||||||
|
"customGeoTitle" = "GeoSite / GeoIP kustom"
|
||||||
|
"customGeoAdd" = "Tambah"
|
||||||
|
"customGeoType" = "Jenis"
|
||||||
|
"customGeoAlias" = "Alias"
|
||||||
|
"customGeoUrl" = "URL"
|
||||||
|
"customGeoEnabled" = "Aktif"
|
||||||
|
"customGeoLastUpdated" = "Terakhir diperbarui"
|
||||||
|
"customGeoExtColumn" = "Routing (ext:…)"
|
||||||
|
"customGeoToastUpdateAll" = "Semua sumber kustom telah diperbarui"
|
||||||
|
"customGeoActions" = "Aksi"
|
||||||
|
"customGeoEdit" = "Edit"
|
||||||
|
"customGeoDelete" = "Hapus"
|
||||||
|
"customGeoDownload" = "Perbarui sekarang"
|
||||||
|
"customGeoModalAdd" = "Tambah geo kustom"
|
||||||
|
"customGeoModalEdit" = "Edit geo kustom"
|
||||||
|
"customGeoModalSave" = "Simpan"
|
||||||
|
"customGeoDeleteConfirm" = "Hapus sumber geo kustom ini?"
|
||||||
|
"customGeoRoutingHint" = "Pada aturan routing gunakan kolom nilai sebagai ext:file.dat:tag (ganti tag)."
|
||||||
|
"customGeoInvalidId" = "ID sumber tidak valid"
|
||||||
|
"customGeoAliasesError" = "Gagal memuat alias geo kustom"
|
||||||
|
"customGeoValidationAlias" = "Alias hanya huruf kecil, angka, - dan _"
|
||||||
|
"customGeoValidationUrl" = "URL harus diawali http:// atau https://"
|
||||||
|
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||||
|
"customGeoAliasLabelSuffix" = " (kustom)"
|
||||||
|
"customGeoToastList" = "Daftar geo kustom"
|
||||||
|
"customGeoToastAdd" = "Tambah geo kustom"
|
||||||
|
"customGeoToastUpdate" = "Perbarui geo kustom"
|
||||||
|
"customGeoToastDelete" = "Geofile kustom “{{ .fileName }}” dihapus"
|
||||||
|
"customGeoToastDownload" = "Geofile “{{ .fileName }}” diperbarui"
|
||||||
|
"customGeoErrInvalidType" = "Jenis harus geosite atau geoip"
|
||||||
|
"customGeoErrAliasRequired" = "Alias wajib diisi"
|
||||||
|
"customGeoErrAliasPattern" = "Alias berisi karakter yang tidak diizinkan"
|
||||||
|
"customGeoErrAliasReserved" = "Alias ini dicadangkan"
|
||||||
|
"customGeoErrUrlRequired" = "URL wajib diisi"
|
||||||
|
"customGeoErrInvalidUrl" = "URL tidak valid"
|
||||||
|
"customGeoErrUrlScheme" = "URL harus memakai http atau https"
|
||||||
|
"customGeoErrUrlHost" = "Host URL tidak valid"
|
||||||
|
"customGeoErrDuplicateAlias" = "Alias ini sudah dipakai untuk jenis ini"
|
||||||
|
"customGeoErrNotFound" = "Sumber geo kustom tidak ditemukan"
|
||||||
|
"customGeoErrDownload" = "Unduh gagal"
|
||||||
|
"customGeoErrUpdateAllIncomplete" = "Satu atau lebih sumber geo kustom gagal diperbarui"
|
||||||
|
|
||||||
[pages.inbounds]
|
[pages.inbounds]
|
||||||
"allTimeTraffic" = "Total Lalu Lintas"
|
"allTimeTraffic" = "Total Lalu Lintas"
|
||||||
|
|
@ -271,6 +312,7 @@
|
||||||
"daily" = "Harian"
|
"daily" = "Harian"
|
||||||
"weekly" = "Mingguan"
|
"weekly" = "Mingguan"
|
||||||
"monthly" = "Bulanan"
|
"monthly" = "Bulanan"
|
||||||
|
"hourly" = "Setiap jam"
|
||||||
|
|
||||||
[pages.inbounds.toasts]
|
[pages.inbounds.toasts]
|
||||||
"obtain" = "Dapatkan"
|
"obtain" = "Dapatkan"
|
||||||
|
|
@ -663,6 +705,7 @@
|
||||||
"userSaved" = "✅ Pengguna Telegram tersimpan."
|
"userSaved" = "✅ Pengguna Telegram tersimpan."
|
||||||
"loginSuccess" = "✅ Berhasil masuk ke panel.\r\n"
|
"loginSuccess" = "✅ Berhasil masuk ke panel.\r\n"
|
||||||
"loginFailed" = "❗️ Gagal masuk ke panel.\r\n"
|
"loginFailed" = "❗️ Gagal masuk ke panel.\r\n"
|
||||||
|
"2faFailed" = "2FA Gagal"
|
||||||
"report" = "🕰 Laporan Terjadwal: {{ .RunTime }}\r\n"
|
"report" = "🕰 Laporan Terjadwal: {{ .RunTime }}\r\n"
|
||||||
"datetime" = "⏰ Tanggal & Waktu: {{ .DateTime }}\r\n"
|
"datetime" = "⏰ Tanggal & Waktu: {{ .DateTime }}\r\n"
|
||||||
"hostname" = "💻 Host: {{ .Hostname }}\r\n"
|
"hostname" = "💻 Host: {{ .Hostname }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,47 @@
|
||||||
"readDatabaseError" = "データベースの読み取り中にエラーが発生しました"
|
"readDatabaseError" = "データベースの読み取り中にエラーが発生しました"
|
||||||
"getDatabaseError" = "データベースの取得中にエラーが発生しました"
|
"getDatabaseError" = "データベースの取得中にエラーが発生しました"
|
||||||
"getConfigError" = "設定ファイルの取得中にエラーが発生しました"
|
"getConfigError" = "設定ファイルの取得中にエラーが発生しました"
|
||||||
|
"customGeoTitle" = "カスタム GeoSite / GeoIP"
|
||||||
|
"customGeoAdd" = "追加"
|
||||||
|
"customGeoType" = "種類"
|
||||||
|
"customGeoAlias" = "エイリアス"
|
||||||
|
"customGeoUrl" = "URL"
|
||||||
|
"customGeoEnabled" = "有効"
|
||||||
|
"customGeoLastUpdated" = "最終更新"
|
||||||
|
"customGeoExtColumn" = "ルーティング (ext:…)"
|
||||||
|
"customGeoToastUpdateAll" = "すべてのカスタムソースを更新しました"
|
||||||
|
"customGeoActions" = "操作"
|
||||||
|
"customGeoEdit" = "編集"
|
||||||
|
"customGeoDelete" = "削除"
|
||||||
|
"customGeoDownload" = "今すぐ更新"
|
||||||
|
"customGeoModalAdd" = "カスタム geo を追加"
|
||||||
|
"customGeoModalEdit" = "カスタム geo を編集"
|
||||||
|
"customGeoModalSave" = "保存"
|
||||||
|
"customGeoDeleteConfirm" = "このカスタム geo ソースを削除しますか?"
|
||||||
|
"customGeoRoutingHint" = "ルーティングでは値を ext:ファイル.dat:タグ(タグを置換)として使います。"
|
||||||
|
"customGeoInvalidId" = "無効なリソース ID"
|
||||||
|
"customGeoAliasesError" = "カスタム geo エイリアスの読み込みに失敗しました"
|
||||||
|
"customGeoValidationAlias" = "エイリアスは小文字・数字・- と _ のみ使用できます"
|
||||||
|
"customGeoValidationUrl" = "URL は http:// または https:// で始めてください"
|
||||||
|
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||||
|
"customGeoAliasLabelSuffix" = "(カスタム)"
|
||||||
|
"customGeoToastList" = "カスタム geo 一覧"
|
||||||
|
"customGeoToastAdd" = "カスタム geo を追加"
|
||||||
|
"customGeoToastUpdate" = "カスタム geo を更新"
|
||||||
|
"customGeoToastDelete" = "カスタム geofile「{{ .fileName }}」を削除しました"
|
||||||
|
"customGeoToastDownload" = "geofile「{{ .fileName }}」を更新しました"
|
||||||
|
"customGeoErrInvalidType" = "種類は geosite または geoip である必要があります"
|
||||||
|
"customGeoErrAliasRequired" = "エイリアスが必要です"
|
||||||
|
"customGeoErrAliasPattern" = "エイリアスに使用できない文字が含まれています"
|
||||||
|
"customGeoErrAliasReserved" = "このエイリアスは予約されています"
|
||||||
|
"customGeoErrUrlRequired" = "URL が必要です"
|
||||||
|
"customGeoErrInvalidUrl" = "URL が無効です"
|
||||||
|
"customGeoErrUrlScheme" = "URL は http または https を使用してください"
|
||||||
|
"customGeoErrUrlHost" = "URL のホストが無効です"
|
||||||
|
"customGeoErrDuplicateAlias" = "この種類ですでにこのエイリアスが使われています"
|
||||||
|
"customGeoErrNotFound" = "カスタム geo ソースが見つかりません"
|
||||||
|
"customGeoErrDownload" = "ダウンロードに失敗しました"
|
||||||
|
"customGeoErrUpdateAllIncomplete" = "カスタム geo ソースの 1 件以上を更新できませんでした"
|
||||||
|
|
||||||
[pages.inbounds]
|
[pages.inbounds]
|
||||||
"allTimeTraffic" = "総トラフィック"
|
"allTimeTraffic" = "総トラフィック"
|
||||||
|
|
@ -271,6 +312,7 @@
|
||||||
"daily" = "毎日"
|
"daily" = "毎日"
|
||||||
"weekly" = "毎週"
|
"weekly" = "毎週"
|
||||||
"monthly" = "毎月"
|
"monthly" = "毎月"
|
||||||
|
"hourly" = "毎時"
|
||||||
|
|
||||||
[pages.inbounds.toasts]
|
[pages.inbounds.toasts]
|
||||||
"obtain" = "取得"
|
"obtain" = "取得"
|
||||||
|
|
@ -663,6 +705,7 @@
|
||||||
"userSaved" = "✅ Telegramユーザーが保存されました。"
|
"userSaved" = "✅ Telegramユーザーが保存されました。"
|
||||||
"loginSuccess" = "✅ パネルに正常にログインしました。\r\n"
|
"loginSuccess" = "✅ パネルに正常にログインしました。\r\n"
|
||||||
"loginFailed" = "❗️ パネルのログインに失敗しました。\r\n"
|
"loginFailed" = "❗️ パネルのログインに失敗しました。\r\n"
|
||||||
|
"2faFailed" = "2FAエラー"
|
||||||
"report" = "🕰 定期報告:{{ .RunTime }}\r\n"
|
"report" = "🕰 定期報告:{{ .RunTime }}\r\n"
|
||||||
"datetime" = "⏰ 日時:{{ .DateTime }}\r\n"
|
"datetime" = "⏰ 日時:{{ .DateTime }}\r\n"
|
||||||
"hostname" = "💻 ホスト名:{{ .Hostname }}\r\n"
|
"hostname" = "💻 ホスト名:{{ .Hostname }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,47 @@
|
||||||
"readDatabaseError" = "Ocorreu um erro ao ler o banco de dados"
|
"readDatabaseError" = "Ocorreu um erro ao ler o banco de dados"
|
||||||
"getDatabaseError" = "Ocorreu um erro ao recuperar o banco de dados"
|
"getDatabaseError" = "Ocorreu um erro ao recuperar o banco de dados"
|
||||||
"getConfigError" = "Ocorreu um erro ao recuperar o arquivo de configuração"
|
"getConfigError" = "Ocorreu um erro ao recuperar o arquivo de configuração"
|
||||||
|
"customGeoTitle" = "GeoSite / GeoIP personalizados"
|
||||||
|
"customGeoAdd" = "Adicionar"
|
||||||
|
"customGeoType" = "Tipo"
|
||||||
|
"customGeoAlias" = "Alias"
|
||||||
|
"customGeoUrl" = "URL"
|
||||||
|
"customGeoEnabled" = "Ativado"
|
||||||
|
"customGeoLastUpdated" = "Última atualização"
|
||||||
|
"customGeoExtColumn" = "Roteamento (ext:…)"
|
||||||
|
"customGeoToastUpdateAll" = "Todas as fontes personalizadas foram atualizadas"
|
||||||
|
"customGeoActions" = "Ações"
|
||||||
|
"customGeoEdit" = "Editar"
|
||||||
|
"customGeoDelete" = "Excluir"
|
||||||
|
"customGeoDownload" = "Atualizar agora"
|
||||||
|
"customGeoModalAdd" = "Adicionar geo personalizado"
|
||||||
|
"customGeoModalEdit" = "Editar geo personalizado"
|
||||||
|
"customGeoModalSave" = "Salvar"
|
||||||
|
"customGeoDeleteConfirm" = "Excluir esta fonte geo personalizada?"
|
||||||
|
"customGeoRoutingHint" = "Nas regras de roteamento use a coluna de valor como ext:arquivo.dat:tag (substitua a tag)."
|
||||||
|
"customGeoInvalidId" = "ID de recurso inválido"
|
||||||
|
"customGeoAliasesError" = "Falha ao carregar aliases geo personalizados"
|
||||||
|
"customGeoValidationAlias" = "O alias só pode conter letras minúsculas, dígitos, - e _"
|
||||||
|
"customGeoValidationUrl" = "A URL deve começar com http:// ou https://"
|
||||||
|
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||||
|
"customGeoAliasLabelSuffix" = " (personalizado)"
|
||||||
|
"customGeoToastList" = "Lista de geo personalizado"
|
||||||
|
"customGeoToastAdd" = "Adicionar geo personalizado"
|
||||||
|
"customGeoToastUpdate" = "Atualizar geo personalizado"
|
||||||
|
"customGeoToastDelete" = "Geofile personalizado “{{ .fileName }}” excluído"
|
||||||
|
"customGeoToastDownload" = "Geofile “{{ .fileName }}” atualizado"
|
||||||
|
"customGeoErrInvalidType" = "O tipo deve ser geosite ou geoip"
|
||||||
|
"customGeoErrAliasRequired" = "Alias é obrigatório"
|
||||||
|
"customGeoErrAliasPattern" = "O alias contém caracteres não permitidos"
|
||||||
|
"customGeoErrAliasReserved" = "Este alias é reservado"
|
||||||
|
"customGeoErrUrlRequired" = "URL é obrigatória"
|
||||||
|
"customGeoErrInvalidUrl" = "URL inválida"
|
||||||
|
"customGeoErrUrlScheme" = "A URL deve usar http ou https"
|
||||||
|
"customGeoErrUrlHost" = "Host da URL inválido"
|
||||||
|
"customGeoErrDuplicateAlias" = "Este alias já está em uso para este tipo"
|
||||||
|
"customGeoErrNotFound" = "Fonte geo personalizada não encontrada"
|
||||||
|
"customGeoErrDownload" = "Falha no download"
|
||||||
|
"customGeoErrUpdateAllIncomplete" = "Falha ao atualizar uma ou mais fontes geo personalizadas"
|
||||||
|
|
||||||
[pages.inbounds]
|
[pages.inbounds]
|
||||||
"allTimeTraffic" = "Tráfego Total"
|
"allTimeTraffic" = "Tráfego Total"
|
||||||
|
|
@ -271,6 +312,7 @@
|
||||||
"daily" = "Diariamente"
|
"daily" = "Diariamente"
|
||||||
"weekly" = "Semanalmente"
|
"weekly" = "Semanalmente"
|
||||||
"monthly" = "Mensalmente"
|
"monthly" = "Mensalmente"
|
||||||
|
"hourly" = "A cada hora"
|
||||||
|
|
||||||
[pages.inbounds.toasts]
|
[pages.inbounds.toasts]
|
||||||
"obtain" = "Obter"
|
"obtain" = "Obter"
|
||||||
|
|
@ -663,6 +705,7 @@
|
||||||
"userSaved" = "✅ Usuário do Telegram salvo."
|
"userSaved" = "✅ Usuário do Telegram salvo."
|
||||||
"loginSuccess" = "✅ Conectado ao painel com sucesso.\r\n"
|
"loginSuccess" = "✅ Conectado ao painel com sucesso.\r\n"
|
||||||
"loginFailed" = "❗️Tentativa de login no painel falhou.\r\n"
|
"loginFailed" = "❗️Tentativa de login no painel falhou.\r\n"
|
||||||
|
"2faFailed" = "Falha no 2FA"
|
||||||
"report" = "🕰 Relatórios agendados: {{ .RunTime }}\r\n"
|
"report" = "🕰 Relatórios agendados: {{ .RunTime }}\r\n"
|
||||||
"datetime" = "⏰ Data&Hora: {{ .DateTime }}\r\n"
|
"datetime" = "⏰ Data&Hora: {{ .DateTime }}\r\n"
|
||||||
"hostname" = "💻 Host: {{ .Hostname }}\r\n"
|
"hostname" = "💻 Host: {{ .Hostname }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,48 @@
|
||||||
"geofileUpdateDialogDesc" = "Это обновит файл #filename#."
|
"geofileUpdateDialogDesc" = "Это обновит файл #filename#."
|
||||||
"geofilesUpdateDialogDesc" = "Это обновит все геофайлы."
|
"geofilesUpdateDialogDesc" = "Это обновит все геофайлы."
|
||||||
"geofilesUpdateAll" = "Обновить все"
|
"geofilesUpdateAll" = "Обновить все"
|
||||||
"geofileUpdatePopover" = "Геофайл успешно обновлён"
|
"geofileUpdatePopover" = "Геофайлы успешно обновлены"
|
||||||
|
"customGeoTitle" = "Пользовательские GeoSite / GeoIP"
|
||||||
|
"customGeoAdd" = "Добавить"
|
||||||
|
"customGeoType" = "Тип"
|
||||||
|
"customGeoAlias" = "Псевдоним"
|
||||||
|
"customGeoUrl" = "URL"
|
||||||
|
"customGeoEnabled" = "Включено"
|
||||||
|
"customGeoLastUpdated" = "Обновлено"
|
||||||
|
"customGeoExtColumn" = "Маршрутизация (ext:…)"
|
||||||
|
"customGeoToastUpdateAll" = "Все пользовательские источники обновлены"
|
||||||
|
"customGeoActions" = "Действия"
|
||||||
|
"customGeoEdit" = "Изменить"
|
||||||
|
"customGeoDelete" = "Удалить"
|
||||||
|
"customGeoDownload" = "Обновить сейчас"
|
||||||
|
"customGeoModalAdd" = "Добавить источник"
|
||||||
|
"customGeoModalEdit" = "Изменить источник"
|
||||||
|
"customGeoModalSave" = "Сохранить"
|
||||||
|
"customGeoDeleteConfirm" = "Удалить этот пользовательский источник?"
|
||||||
|
"customGeoRoutingHint" = "В правилах маршрутизации используйте значение как ext:файл.dat:тег (замените тег)."
|
||||||
|
"customGeoInvalidId" = "Некорректный идентификатор"
|
||||||
|
"customGeoAliasesError" = "Не удалось загрузить список пользовательских geo"
|
||||||
|
"customGeoValidationAlias" = "Псевдоним: только a-z, цифры, - и _"
|
||||||
|
"customGeoValidationUrl" = "URL должен начинаться с http:// или https://"
|
||||||
|
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||||
|
"customGeoAliasLabelSuffix" = " (свой)"
|
||||||
|
"customGeoToastList" = "Список пользовательских geo"
|
||||||
|
"customGeoToastAdd" = "Добавить пользовательский geo"
|
||||||
|
"customGeoToastUpdate" = "Изменить пользовательский geo"
|
||||||
|
"customGeoToastDelete" = "Пользовательский geo-файл «{{ .fileName }}» удалён"
|
||||||
|
"customGeoToastDownload" = "Geofile «{{ .fileName }}» обновлен"
|
||||||
|
"customGeoErrInvalidType" = "Тип должен быть geosite или geoip"
|
||||||
|
"customGeoErrAliasRequired" = "Укажите псевдоним"
|
||||||
|
"customGeoErrAliasPattern" = "Псевдоним содержит недопустимые символы"
|
||||||
|
"customGeoErrAliasReserved" = "Этот псевдоним зарезервирован"
|
||||||
|
"customGeoErrUrlRequired" = "Укажите URL"
|
||||||
|
"customGeoErrInvalidUrl" = "Некорректный URL"
|
||||||
|
"customGeoErrUrlScheme" = "URL должен использовать http или https"
|
||||||
|
"customGeoErrUrlHost" = "Некорректный хост URL"
|
||||||
|
"customGeoErrDuplicateAlias" = "Такой псевдоним уже используется для этого типа"
|
||||||
|
"customGeoErrNotFound" = "Источник не найден"
|
||||||
|
"customGeoErrDownload" = "Ошибка загрузки"
|
||||||
|
"customGeoErrUpdateAllIncomplete" = "Не удалось обновить один или несколько пользовательских источников"
|
||||||
"dontRefresh" = "Установка в процессе. Не обновляйте страницу"
|
"dontRefresh" = "Установка в процессе. Не обновляйте страницу"
|
||||||
"logs" = "Журнал"
|
"logs" = "Журнал"
|
||||||
"config" = "Конфигурация"
|
"config" = "Конфигурация"
|
||||||
|
|
@ -271,6 +312,7 @@
|
||||||
"daily" = "Ежедневно"
|
"daily" = "Ежедневно"
|
||||||
"weekly" = "Еженедельно"
|
"weekly" = "Еженедельно"
|
||||||
"monthly" = "Ежемесячно"
|
"monthly" = "Ежемесячно"
|
||||||
|
"hourly" = "Ежечасно"
|
||||||
|
|
||||||
[pages.inbounds.toasts]
|
[pages.inbounds.toasts]
|
||||||
"obtain" = "Получить"
|
"obtain" = "Получить"
|
||||||
|
|
@ -663,6 +705,7 @@
|
||||||
"userSaved" = "✅ Пользователь Telegram сохранен."
|
"userSaved" = "✅ Пользователь Telegram сохранен."
|
||||||
"loginSuccess" = "✅ Успешный вход в панель.\r\n"
|
"loginSuccess" = "✅ Успешный вход в панель.\r\n"
|
||||||
"loginFailed" = "❗️ Ошибка входа в панель.\r\n"
|
"loginFailed" = "❗️ Ошибка входа в панель.\r\n"
|
||||||
|
"2faFailed" = "Ошибка 2FA"
|
||||||
"report" = "🕰 Запланированные отчеты: {{ .RunTime }}\r\n"
|
"report" = "🕰 Запланированные отчеты: {{ .RunTime }}\r\n"
|
||||||
"datetime" = "⏰ Дата и время: {{ .DateTime }}\r\n"
|
"datetime" = "⏰ Дата и время: {{ .DateTime }}\r\n"
|
||||||
"hostname" = "💻 Имя хоста: {{ .Hostname }}\r\n"
|
"hostname" = "💻 Имя хоста: {{ .Hostname }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,47 @@
|
||||||
"readDatabaseError" = "Veritabanı okunurken bir hata oluştu"
|
"readDatabaseError" = "Veritabanı okunurken bir hata oluştu"
|
||||||
"getDatabaseError" = "Veritabanı alınırken bir hata oluştu"
|
"getDatabaseError" = "Veritabanı alınırken bir hata oluştu"
|
||||||
"getConfigError" = "Yapılandırma dosyası alınırken bir hata oluştu"
|
"getConfigError" = "Yapılandırma dosyası alınırken bir hata oluştu"
|
||||||
|
"customGeoTitle" = "Özel GeoSite / GeoIP"
|
||||||
|
"customGeoAdd" = "Ekle"
|
||||||
|
"customGeoType" = "Tür"
|
||||||
|
"customGeoAlias" = "Takma ad"
|
||||||
|
"customGeoUrl" = "URL"
|
||||||
|
"customGeoEnabled" = "Etkin"
|
||||||
|
"customGeoLastUpdated" = "Son güncelleme"
|
||||||
|
"customGeoExtColumn" = "Yönlendirme (ext:…)"
|
||||||
|
"customGeoToastUpdateAll" = "Tüm özel kaynaklar güncellendi"
|
||||||
|
"customGeoActions" = "İşlemler"
|
||||||
|
"customGeoEdit" = "Düzenle"
|
||||||
|
"customGeoDelete" = "Sil"
|
||||||
|
"customGeoDownload" = "Şimdi güncelle"
|
||||||
|
"customGeoModalAdd" = "Özel geo ekle"
|
||||||
|
"customGeoModalEdit" = "Özel geo düzenle"
|
||||||
|
"customGeoModalSave" = "Kaydet"
|
||||||
|
"customGeoDeleteConfirm" = "Bu özel geo kaynağını silinsin mi?"
|
||||||
|
"customGeoRoutingHint" = "Yönlendirme kurallarında değer sütununu ext:dosya.dat:etiket olarak kullanın (etiketi değiştirin)."
|
||||||
|
"customGeoInvalidId" = "Geçersiz kaynak kimliği"
|
||||||
|
"customGeoAliasesError" = "Özel geo takma adları yüklenemedi"
|
||||||
|
"customGeoValidationAlias" = "Takma ad yalnızca küçük harf, rakam, - ve _ içerebilir"
|
||||||
|
"customGeoValidationUrl" = "URL http:// veya https:// ile başlamalıdır"
|
||||||
|
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||||
|
"customGeoAliasLabelSuffix" = " (özel)"
|
||||||
|
"customGeoToastList" = "Özel geo listesi"
|
||||||
|
"customGeoToastAdd" = "Özel geo ekle"
|
||||||
|
"customGeoToastUpdate" = "Özel geo güncelle"
|
||||||
|
"customGeoToastDelete" = "Özel geofile \"{{ .fileName }}\" silindi"
|
||||||
|
"customGeoToastDownload" = "\"{{ .fileName }}\" geofile güncellendi"
|
||||||
|
"customGeoErrInvalidType" = "Tür geosite veya geoip olmalıdır"
|
||||||
|
"customGeoErrAliasRequired" = "Takma ad gerekli"
|
||||||
|
"customGeoErrAliasPattern" = "Takma ad izin verilmeyen karakterler içeriyor"
|
||||||
|
"customGeoErrAliasReserved" = "Bu takma ad ayrılmış"
|
||||||
|
"customGeoErrUrlRequired" = "URL gerekli"
|
||||||
|
"customGeoErrInvalidUrl" = "URL geçersiz"
|
||||||
|
"customGeoErrUrlScheme" = "URL http veya https kullanmalıdır"
|
||||||
|
"customGeoErrUrlHost" = "URL ana bilgisayarı geçersiz"
|
||||||
|
"customGeoErrDuplicateAlias" = "Bu takma ad bu tür için zaten kullanılıyor"
|
||||||
|
"customGeoErrNotFound" = "Özel geo kaynağı bulunamadı"
|
||||||
|
"customGeoErrDownload" = "İndirme başarısız"
|
||||||
|
"customGeoErrUpdateAllIncomplete" = "Bir veya daha fazla özel geo kaynağı güncellenemedi"
|
||||||
|
|
||||||
[pages.inbounds]
|
[pages.inbounds]
|
||||||
"allTimeTraffic" = "Toplam Trafik"
|
"allTimeTraffic" = "Toplam Trafik"
|
||||||
|
|
@ -271,6 +312,7 @@
|
||||||
"daily" = "Günlük"
|
"daily" = "Günlük"
|
||||||
"weekly" = "Haftalık"
|
"weekly" = "Haftalık"
|
||||||
"monthly" = "Aylık"
|
"monthly" = "Aylık"
|
||||||
|
"hourly" = "Saatlik"
|
||||||
|
|
||||||
[pages.inbounds.toasts]
|
[pages.inbounds.toasts]
|
||||||
"obtain" = "Elde Et"
|
"obtain" = "Elde Et"
|
||||||
|
|
@ -663,6 +705,7 @@
|
||||||
"userSaved" = "✅ Telegram Kullanıcısı kaydedildi."
|
"userSaved" = "✅ Telegram Kullanıcısı kaydedildi."
|
||||||
"loginSuccess" = "✅ Panele başarıyla giriş yapıldı.\r\n"
|
"loginSuccess" = "✅ Panele başarıyla giriş yapıldı.\r\n"
|
||||||
"loginFailed" = "❗️Panele giriş denemesi başarısız oldu.\r\n"
|
"loginFailed" = "❗️Panele giriş denemesi başarısız oldu.\r\n"
|
||||||
|
"2faFailed" = "2FA Hatası"
|
||||||
"report" = "🕰 Planlanmış Raporlar: {{ .RunTime }}\r\n"
|
"report" = "🕰 Planlanmış Raporlar: {{ .RunTime }}\r\n"
|
||||||
"datetime" = "⏰ Tarih&Zaman: {{ .DateTime }}\r\n"
|
"datetime" = "⏰ Tarih&Zaman: {{ .DateTime }}\r\n"
|
||||||
"hostname" = "💻 Sunucu: {{ .Hostname }}\r\n"
|
"hostname" = "💻 Sunucu: {{ .Hostname }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,47 @@
|
||||||
"readDatabaseError" = "Виникла помилка під час читання бази даних"
|
"readDatabaseError" = "Виникла помилка під час читання бази даних"
|
||||||
"getDatabaseError" = "Виникла помилка під час отримання бази даних"
|
"getDatabaseError" = "Виникла помилка під час отримання бази даних"
|
||||||
"getConfigError" = "Виникла помилка під час отримання файлу конфігурації"
|
"getConfigError" = "Виникла помилка під час отримання файлу конфігурації"
|
||||||
|
"customGeoTitle" = "Користувацькі GeoSite / GeoIP"
|
||||||
|
"customGeoAdd" = "Додати"
|
||||||
|
"customGeoType" = "Тип"
|
||||||
|
"customGeoAlias" = "Псевдонім"
|
||||||
|
"customGeoUrl" = "URL"
|
||||||
|
"customGeoEnabled" = "Увімкнено"
|
||||||
|
"customGeoLastUpdated" = "Оновлено"
|
||||||
|
"customGeoExtColumn" = "Маршрутизація (ext:…)"
|
||||||
|
"customGeoToastUpdateAll" = "Усі користувацькі джерела оновлено"
|
||||||
|
"customGeoActions" = "Дії"
|
||||||
|
"customGeoEdit" = "Змінити"
|
||||||
|
"customGeoDelete" = "Видалити"
|
||||||
|
"customGeoDownload" = "Оновити зараз"
|
||||||
|
"customGeoModalAdd" = "Додати користувацький geo"
|
||||||
|
"customGeoModalEdit" = "Змінити користувацький geo"
|
||||||
|
"customGeoModalSave" = "Зберегти"
|
||||||
|
"customGeoDeleteConfirm" = "Видалити це джерело geo?"
|
||||||
|
"customGeoRoutingHint" = "У правилах маршрутизації використовуйте значення як ext:файл.dat:тег (замініть тег)."
|
||||||
|
"customGeoInvalidId" = "Некоректний ідентифікатор ресурсу"
|
||||||
|
"customGeoAliasesError" = "Не вдалося завантажити псевдоніми geo"
|
||||||
|
"customGeoValidationAlias" = "Псевдонім: лише a-z, цифри, - і _"
|
||||||
|
"customGeoValidationUrl" = "URL має починатися з http:// або https://"
|
||||||
|
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||||
|
"customGeoAliasLabelSuffix" = " (власний)"
|
||||||
|
"customGeoToastList" = "Список користувацьких geo"
|
||||||
|
"customGeoToastAdd" = "Додати користувацький geo"
|
||||||
|
"customGeoToastUpdate" = "Оновити користувацький geo"
|
||||||
|
"customGeoToastDelete" = "Користувацький geofile «{{ .fileName }}» видалено"
|
||||||
|
"customGeoToastDownload" = "Geofile «{{ .fileName }}» оновлено"
|
||||||
|
"customGeoErrInvalidType" = "Тип має бути geosite або geoip"
|
||||||
|
"customGeoErrAliasRequired" = "Потрібен псевдонім"
|
||||||
|
"customGeoErrAliasPattern" = "Псевдонім містить недопустимі символи"
|
||||||
|
"customGeoErrAliasReserved" = "Цей псевдонім зарезервовано"
|
||||||
|
"customGeoErrUrlRequired" = "Потрібен URL"
|
||||||
|
"customGeoErrInvalidUrl" = "Некоректний URL"
|
||||||
|
"customGeoErrUrlScheme" = "URL має використовувати http або https"
|
||||||
|
"customGeoErrUrlHost" = "Некоректний хост URL"
|
||||||
|
"customGeoErrDuplicateAlias" = "Цей псевдонім уже використовується для цього типу"
|
||||||
|
"customGeoErrNotFound" = "Джерело geo не знайдено"
|
||||||
|
"customGeoErrDownload" = "Помилка завантаження"
|
||||||
|
"customGeoErrUpdateAllIncomplete" = "Не вдалося оновити один або кілька користувацьких джерел"
|
||||||
|
|
||||||
[pages.inbounds]
|
[pages.inbounds]
|
||||||
"allTimeTraffic" = "Загальний трафік"
|
"allTimeTraffic" = "Загальний трафік"
|
||||||
|
|
@ -271,6 +312,7 @@
|
||||||
"daily" = "Щодня"
|
"daily" = "Щодня"
|
||||||
"weekly" = "Щотижня"
|
"weekly" = "Щотижня"
|
||||||
"monthly" = "Щомісяця"
|
"monthly" = "Щомісяця"
|
||||||
|
"hourly" = "Щогодини"
|
||||||
|
|
||||||
[pages.inbounds.toasts]
|
[pages.inbounds.toasts]
|
||||||
"obtain" = "Отримати"
|
"obtain" = "Отримати"
|
||||||
|
|
@ -663,6 +705,7 @@
|
||||||
"userSaved" = "✅ Користувача Telegram збережено."
|
"userSaved" = "✅ Користувача Telegram збережено."
|
||||||
"loginSuccess" = "✅ Успішно ввійшли в панель\r\n"
|
"loginSuccess" = "✅ Успішно ввійшли в панель\r\n"
|
||||||
"loginFailed" = "❗️ Помилка входу в панель.\r\n"
|
"loginFailed" = "❗️ Помилка входу в панель.\r\n"
|
||||||
|
"2faFailed" = "Помилка 2FA"
|
||||||
"report" = "🕰 Заплановані звіти: {{ .RunTime }}\r\n"
|
"report" = "🕰 Заплановані звіти: {{ .RunTime }}\r\n"
|
||||||
"datetime" = "⏰ Дата й час: {{ .DateTime }}\r\n"
|
"datetime" = "⏰ Дата й час: {{ .DateTime }}\r\n"
|
||||||
"hostname" = "💻 Хост: {{ .Hostname }}\r\n"
|
"hostname" = "💻 Хост: {{ .Hostname }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,47 @@
|
||||||
"readDatabaseError" = "Lỗi xảy ra khi đọc cơ sở dữ liệu"
|
"readDatabaseError" = "Lỗi xảy ra khi đọc cơ sở dữ liệu"
|
||||||
"getDatabaseError" = "Lỗi xảy ra khi truy xuất cơ sở dữ liệu"
|
"getDatabaseError" = "Lỗi xảy ra khi truy xuất cơ sở dữ liệu"
|
||||||
"getConfigError" = "Lỗi xảy ra khi truy xuất tệp cấu hình"
|
"getConfigError" = "Lỗi xảy ra khi truy xuất tệp cấu hình"
|
||||||
|
"customGeoTitle" = "GeoSite / GeoIP tùy chỉnh"
|
||||||
|
"customGeoAdd" = "Thêm"
|
||||||
|
"customGeoType" = "Loại"
|
||||||
|
"customGeoAlias" = "Bí danh"
|
||||||
|
"customGeoUrl" = "URL"
|
||||||
|
"customGeoEnabled" = "Bật"
|
||||||
|
"customGeoLastUpdated" = "Cập nhật lần cuối"
|
||||||
|
"customGeoExtColumn" = "Định tuyến (ext:…)"
|
||||||
|
"customGeoToastUpdateAll" = "Đã cập nhật tất cả nguồn tùy chỉnh"
|
||||||
|
"customGeoActions" = "Thao tác"
|
||||||
|
"customGeoEdit" = "Sửa"
|
||||||
|
"customGeoDelete" = "Xóa"
|
||||||
|
"customGeoDownload" = "Cập nhật ngay"
|
||||||
|
"customGeoModalAdd" = "Thêm geo tùy chỉnh"
|
||||||
|
"customGeoModalEdit" = "Sửa geo tùy chỉnh"
|
||||||
|
"customGeoModalSave" = "Lưu"
|
||||||
|
"customGeoDeleteConfirm" = "Xóa nguồn geo tùy chỉnh này?"
|
||||||
|
"customGeoRoutingHint" = "Trong quy tắc định tuyến dùng cột giá trị dạng ext:file.dat:tag (thay tag)."
|
||||||
|
"customGeoInvalidId" = "ID tài nguyên không hợp lệ"
|
||||||
|
"customGeoAliasesError" = "Không tải được bí danh geo tùy chỉnh"
|
||||||
|
"customGeoValidationAlias" = "Bí danh chỉ gồm chữ thường, số, - và _"
|
||||||
|
"customGeoValidationUrl" = "URL phải bắt đầu bằng http:// hoặc https://"
|
||||||
|
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||||
|
"customGeoAliasLabelSuffix" = " (tùy chỉnh)"
|
||||||
|
"customGeoToastList" = "Danh sách geo tùy chỉnh"
|
||||||
|
"customGeoToastAdd" = "Thêm geo tùy chỉnh"
|
||||||
|
"customGeoToastUpdate" = "Cập nhật geo tùy chỉnh"
|
||||||
|
"customGeoToastDelete" = "Đã xóa geofile tùy chỉnh “{{ .fileName }}”"
|
||||||
|
"customGeoToastDownload" = "Đã cập nhật geofile “{{ .fileName }}”"
|
||||||
|
"customGeoErrInvalidType" = "Loại phải là geosite hoặc geoip"
|
||||||
|
"customGeoErrAliasRequired" = "Cần bí danh"
|
||||||
|
"customGeoErrAliasPattern" = "Bí danh có ký tự không hợp lệ"
|
||||||
|
"customGeoErrAliasReserved" = "Bí danh này được dành riêng"
|
||||||
|
"customGeoErrUrlRequired" = "Cần URL"
|
||||||
|
"customGeoErrInvalidUrl" = "URL không hợp lệ"
|
||||||
|
"customGeoErrUrlScheme" = "URL phải dùng http hoặc https"
|
||||||
|
"customGeoErrUrlHost" = "Máy chủ URL không hợp lệ"
|
||||||
|
"customGeoErrDuplicateAlias" = "Bí danh này đã dùng cho loại này"
|
||||||
|
"customGeoErrNotFound" = "Không tìm thấy nguồn geo tùy chỉnh"
|
||||||
|
"customGeoErrDownload" = "Tải xuống thất bại"
|
||||||
|
"customGeoErrUpdateAllIncomplete" = "Một hoặc nhiều nguồn geo tùy chỉnh không cập nhật được"
|
||||||
|
|
||||||
[pages.inbounds]
|
[pages.inbounds]
|
||||||
"allTimeTraffic" = "Tổng Lưu Lượng"
|
"allTimeTraffic" = "Tổng Lưu Lượng"
|
||||||
|
|
@ -271,6 +312,7 @@
|
||||||
"daily" = "Hàng ngày"
|
"daily" = "Hàng ngày"
|
||||||
"weekly" = "Hàng tuần"
|
"weekly" = "Hàng tuần"
|
||||||
"monthly" = "Hàng tháng"
|
"monthly" = "Hàng tháng"
|
||||||
|
"hourly" = "Hàng giờ"
|
||||||
|
|
||||||
[pages.inbounds.toasts]
|
[pages.inbounds.toasts]
|
||||||
"obtain" = "Nhận"
|
"obtain" = "Nhận"
|
||||||
|
|
@ -663,6 +705,7 @@
|
||||||
"userSaved" = "✅ Người dùng Telegram đã được lưu."
|
"userSaved" = "✅ Người dùng Telegram đã được lưu."
|
||||||
"loginSuccess" = "✅ Đăng nhập thành công vào bảng điều khiển.\r\n"
|
"loginSuccess" = "✅ Đăng nhập thành công vào bảng điều khiển.\r\n"
|
||||||
"loginFailed" = "❗️ Đăng nhập vào bảng điều khiển thất bại.\r\n"
|
"loginFailed" = "❗️ Đăng nhập vào bảng điều khiển thất bại.\r\n"
|
||||||
|
"2faFailed" = "Lỗi 2FA"
|
||||||
"report" = "🕰 Báo cáo định kỳ: {{ .RunTime }}\r\n"
|
"report" = "🕰 Báo cáo định kỳ: {{ .RunTime }}\r\n"
|
||||||
"datetime" = "⏰ Ngày-Giờ: {{ .DateTime }}\r\n"
|
"datetime" = "⏰ Ngày-Giờ: {{ .DateTime }}\r\n"
|
||||||
"hostname" = "💻 Tên máy chủ: {{ .Hostname }}\r\n"
|
"hostname" = "💻 Tên máy chủ: {{ .Hostname }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,47 @@
|
||||||
"readDatabaseError" = "读取数据库时出错"
|
"readDatabaseError" = "读取数据库时出错"
|
||||||
"getDatabaseError" = "检索数据库时出错"
|
"getDatabaseError" = "检索数据库时出错"
|
||||||
"getConfigError" = "检索配置文件时出错"
|
"getConfigError" = "检索配置文件时出错"
|
||||||
|
"customGeoTitle" = "自定义 GeoSite / GeoIP"
|
||||||
|
"customGeoAdd" = "添加"
|
||||||
|
"customGeoType" = "类型"
|
||||||
|
"customGeoAlias" = "别名"
|
||||||
|
"customGeoUrl" = "URL"
|
||||||
|
"customGeoEnabled" = "启用"
|
||||||
|
"customGeoLastUpdated" = "上次更新"
|
||||||
|
"customGeoExtColumn" = "路由 (ext:…)"
|
||||||
|
"customGeoToastUpdateAll" = "所有自定义来源已更新"
|
||||||
|
"customGeoActions" = "操作"
|
||||||
|
"customGeoEdit" = "编辑"
|
||||||
|
"customGeoDelete" = "删除"
|
||||||
|
"customGeoDownload" = "立即更新"
|
||||||
|
"customGeoModalAdd" = "添加自定义 geo"
|
||||||
|
"customGeoModalEdit" = "编辑自定义 geo"
|
||||||
|
"customGeoModalSave" = "保存"
|
||||||
|
"customGeoDeleteConfirm" = "删除此自定义 geo 源?"
|
||||||
|
"customGeoRoutingHint" = "在路由规则中将值列写为 ext:文件.dat:标签(替换标签)。"
|
||||||
|
"customGeoInvalidId" = "无效的资源 ID"
|
||||||
|
"customGeoAliasesError" = "加载自定义 geo 别名失败"
|
||||||
|
"customGeoValidationAlias" = "别名只能包含小写字母、数字、- 和 _"
|
||||||
|
"customGeoValidationUrl" = "URL 必须以 http:// 或 https:// 开头"
|
||||||
|
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||||
|
"customGeoAliasLabelSuffix" = "(自定义)"
|
||||||
|
"customGeoToastList" = "自定义 geo 列表"
|
||||||
|
"customGeoToastAdd" = "添加自定义 geo"
|
||||||
|
"customGeoToastUpdate" = "更新自定义 geo"
|
||||||
|
"customGeoToastDelete" = "自定义 geofile「{{ .fileName }}」已删除"
|
||||||
|
"customGeoToastDownload" = "geofile「{{ .fileName }}」已更新"
|
||||||
|
"customGeoErrInvalidType" = "类型必须是 geosite 或 geoip"
|
||||||
|
"customGeoErrAliasRequired" = "请填写别名"
|
||||||
|
"customGeoErrAliasPattern" = "别名包含不允许的字符"
|
||||||
|
"customGeoErrAliasReserved" = "该别名已保留"
|
||||||
|
"customGeoErrUrlRequired" = "请填写 URL"
|
||||||
|
"customGeoErrInvalidUrl" = "URL 无效"
|
||||||
|
"customGeoErrUrlScheme" = "URL 必须使用 http 或 https"
|
||||||
|
"customGeoErrUrlHost" = "URL 主机无效"
|
||||||
|
"customGeoErrDuplicateAlias" = "此类型下已使用该别名"
|
||||||
|
"customGeoErrNotFound" = "未找到自定义 geo 源"
|
||||||
|
"customGeoErrDownload" = "下载失败"
|
||||||
|
"customGeoErrUpdateAllIncomplete" = "有一个或多个自定义 geo 源更新失败"
|
||||||
|
|
||||||
[pages.inbounds]
|
[pages.inbounds]
|
||||||
"allTimeTraffic" = "累计总流量"
|
"allTimeTraffic" = "累计总流量"
|
||||||
|
|
@ -273,6 +314,7 @@
|
||||||
"daily" = "每日"
|
"daily" = "每日"
|
||||||
"weekly" = "每周"
|
"weekly" = "每周"
|
||||||
"monthly" = "每月"
|
"monthly" = "每月"
|
||||||
|
"hourly" = "每小时"
|
||||||
|
|
||||||
[pages.inbounds.toasts]
|
[pages.inbounds.toasts]
|
||||||
"obtain" = "获取"
|
"obtain" = "获取"
|
||||||
|
|
@ -672,6 +714,7 @@
|
||||||
"userSaved" = "✅ 电报用户已保存。"
|
"userSaved" = "✅ 电报用户已保存。"
|
||||||
"loginSuccess" = "✅ 成功登录到面板。\r\n"
|
"loginSuccess" = "✅ 成功登录到面板。\r\n"
|
||||||
"loginFailed" = "❗️ 面板登录失败。\r\n"
|
"loginFailed" = "❗️ 面板登录失败。\r\n"
|
||||||
|
"2faFailed" = "2FA 失败"
|
||||||
"report" = "🕰 定时报告:{{ .RunTime }}\r\n"
|
"report" = "🕰 定时报告:{{ .RunTime }}\r\n"
|
||||||
"datetime" = "⏰ 日期时间:{{ .DateTime }}\r\n"
|
"datetime" = "⏰ 日期时间:{{ .DateTime }}\r\n"
|
||||||
"hostname" = "💻 主机名:{{ .Hostname }}\r\n"
|
"hostname" = "💻 主机名:{{ .Hostname }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,47 @@
|
||||||
"readDatabaseError" = "讀取資料庫時發生錯誤"
|
"readDatabaseError" = "讀取資料庫時發生錯誤"
|
||||||
"getDatabaseError" = "檢索資料庫時發生錯誤"
|
"getDatabaseError" = "檢索資料庫時發生錯誤"
|
||||||
"getConfigError" = "檢索設定檔時發生錯誤"
|
"getConfigError" = "檢索設定檔時發生錯誤"
|
||||||
|
"customGeoTitle" = "自訂 GeoSite / GeoIP"
|
||||||
|
"customGeoAdd" = "新增"
|
||||||
|
"customGeoType" = "類型"
|
||||||
|
"customGeoAlias" = "別名"
|
||||||
|
"customGeoUrl" = "URL"
|
||||||
|
"customGeoEnabled" = "啟用"
|
||||||
|
"customGeoLastUpdated" = "上次更新"
|
||||||
|
"customGeoExtColumn" = "路由 (ext:…)"
|
||||||
|
"customGeoToastUpdateAll" = "所有自訂來源已更新"
|
||||||
|
"customGeoActions" = "操作"
|
||||||
|
"customGeoEdit" = "編輯"
|
||||||
|
"customGeoDelete" = "刪除"
|
||||||
|
"customGeoDownload" = "立即更新"
|
||||||
|
"customGeoModalAdd" = "新增自訂 geo"
|
||||||
|
"customGeoModalEdit" = "編輯自訂 geo"
|
||||||
|
"customGeoModalSave" = "儲存"
|
||||||
|
"customGeoDeleteConfirm" = "刪除此自訂 geo 來源?"
|
||||||
|
"customGeoRoutingHint" = "在路由規則中將值欄寫為 ext:檔案.dat:標籤(替換標籤)。"
|
||||||
|
"customGeoInvalidId" = "無效的資源 ID"
|
||||||
|
"customGeoAliasesError" = "載入自訂 geo 別名失敗"
|
||||||
|
"customGeoValidationAlias" = "別名只能包含小寫字母、數字、- 和 _"
|
||||||
|
"customGeoValidationUrl" = "URL 必須以 http:// 或 https:// 開頭"
|
||||||
|
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||||
|
"customGeoAliasLabelSuffix" = "(自訂)"
|
||||||
|
"customGeoToastList" = "自訂 geo 清單"
|
||||||
|
"customGeoToastAdd" = "新增自訂 geo"
|
||||||
|
"customGeoToastUpdate" = "更新自訂 geo"
|
||||||
|
"customGeoToastDelete" = "自訂 geofile「{{ .fileName }}」已刪除"
|
||||||
|
"customGeoToastDownload" = "geofile「{{ .fileName }}」已更新"
|
||||||
|
"customGeoErrInvalidType" = "類型必須是 geosite 或 geoip"
|
||||||
|
"customGeoErrAliasRequired" = "請填寫別名"
|
||||||
|
"customGeoErrAliasPattern" = "別名包含不允許的字元"
|
||||||
|
"customGeoErrAliasReserved" = "此別名已保留"
|
||||||
|
"customGeoErrUrlRequired" = "請填寫 URL"
|
||||||
|
"customGeoErrInvalidUrl" = "URL 無效"
|
||||||
|
"customGeoErrUrlScheme" = "URL 必須使用 http 或 https"
|
||||||
|
"customGeoErrUrlHost" = "URL 主機無效"
|
||||||
|
"customGeoErrDuplicateAlias" = "此類型已使用該別名"
|
||||||
|
"customGeoErrNotFound" = "找不到自訂 geo 來源"
|
||||||
|
"customGeoErrDownload" = "下載失敗"
|
||||||
|
"customGeoErrUpdateAllIncomplete" = "有一個或多個自訂 geo 來源更新失敗"
|
||||||
|
|
||||||
[pages.inbounds]
|
[pages.inbounds]
|
||||||
"allTimeTraffic" = "累計總流量"
|
"allTimeTraffic" = "累計總流量"
|
||||||
|
|
@ -271,6 +312,7 @@
|
||||||
"daily" = "每日"
|
"daily" = "每日"
|
||||||
"weekly" = "每週"
|
"weekly" = "每週"
|
||||||
"monthly" = "每月"
|
"monthly" = "每月"
|
||||||
|
"hourly" = "每小時"
|
||||||
|
|
||||||
[pages.inbounds.toasts]
|
[pages.inbounds.toasts]
|
||||||
"obtain" = "獲取"
|
"obtain" = "獲取"
|
||||||
|
|
@ -663,6 +705,7 @@
|
||||||
"userSaved" = "✅ 電報使用者已儲存。"
|
"userSaved" = "✅ 電報使用者已儲存。"
|
||||||
"loginSuccess" = "✅ 成功登入到面板。\r\n"
|
"loginSuccess" = "✅ 成功登入到面板。\r\n"
|
||||||
"loginFailed" = "❗️ 面板登入失敗。\r\n"
|
"loginFailed" = "❗️ 面板登入失敗。\r\n"
|
||||||
|
"2faFailed" = "2FA 失敗"
|
||||||
"report" = "🕰 定時報告:{{ .RunTime }}\r\n"
|
"report" = "🕰 定時報告:{{ .RunTime }}\r\n"
|
||||||
"datetime" = "⏰ 日期時間:{{ .DateTime }}\r\n"
|
"datetime" = "⏰ 日期時間:{{ .DateTime }}\r\n"
|
||||||
"hostname" = "💻 主機名:{{ .Hostname }}\r\n"
|
"hostname" = "💻 主機名:{{ .Hostname }}\r\n"
|
||||||
|
|
|
||||||
38
web/web.go
38
web/web.go
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -101,9 +102,10 @@ type Server struct {
|
||||||
api *controller.APIController
|
api *controller.APIController
|
||||||
ws *controller.WebSocketController
|
ws *controller.WebSocketController
|
||||||
|
|
||||||
xrayService service.XrayService
|
xrayService service.XrayService
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
tgbotService service.Tgbot
|
tgbotService service.Tgbot
|
||||||
|
customGeoService *service.CustomGeoService
|
||||||
|
|
||||||
wsHub *websocket.Hub
|
wsHub *websocket.Hub
|
||||||
|
|
||||||
|
|
@ -200,7 +202,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
engine.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{basePath + "panel/api/"})))
|
engine.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||||
assetsBasePath := basePath + "assets/"
|
assetsBasePath := basePath + "assets/"
|
||||||
|
|
||||||
store := cookie.NewStore(secret)
|
store := cookie.NewStore(secret)
|
||||||
|
|
@ -268,7 +270,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
|
|
||||||
s.index = controller.NewIndexController(g)
|
s.index = controller.NewIndexController(g)
|
||||||
s.panel = controller.NewXUIController(g)
|
s.panel = controller.NewXUIController(g)
|
||||||
s.api = controller.NewAPIController(g)
|
s.api = controller.NewAPIController(g, s.customGeoService)
|
||||||
|
|
||||||
// Initialize WebSocket hub
|
// Initialize WebSocket hub
|
||||||
s.wsHub = websocket.NewHub()
|
s.wsHub = websocket.NewHub()
|
||||||
|
|
@ -292,9 +294,27 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
return engine, nil
|
return engine, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeExistingGeositeFiles normalizes country codes in all geosite .dat
|
||||||
|
// files found in the bin directory so Xray-core can locate entries correctly.
|
||||||
|
func normalizeExistingGeositeFiles() {
|
||||||
|
binDir := config.GetBinFolderPath()
|
||||||
|
matches, err := filepath.Glob(filepath.Join(binDir, "geosite*.dat"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("Failed to glob geosite files: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, path := range matches {
|
||||||
|
if err := service.NormalizeGeositeCountryCodes(path); err != nil {
|
||||||
|
logger.Warningf("Failed to normalize geosite country codes in %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// startTask schedules background jobs (Xray checks, traffic jobs, cron
|
// startTask schedules background jobs (Xray checks, traffic jobs, cron
|
||||||
// jobs) which the panel relies on for periodic maintenance and monitoring.
|
// jobs) which the panel relies on for periodic maintenance and monitoring.
|
||||||
func (s *Server) startTask() {
|
func (s *Server) startTask() {
|
||||||
|
normalizeExistingGeositeFiles()
|
||||||
|
s.customGeoService.EnsureOnStartup()
|
||||||
err := s.xrayService.RestartXray(true)
|
err := s.xrayService.RestartXray(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("start xray failed:", err)
|
logger.Warning("start xray failed:", err)
|
||||||
|
|
@ -325,6 +345,8 @@ func (s *Server) startTask() {
|
||||||
s.cron.AddJob("@daily", job.NewClearLogsJob())
|
s.cron.AddJob("@daily", job.NewClearLogsJob())
|
||||||
|
|
||||||
// Inbound traffic reset jobs
|
// Inbound traffic reset jobs
|
||||||
|
// Run every hour
|
||||||
|
s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly"))
|
||||||
// Run once a day, midnight
|
// Run once a day, midnight
|
||||||
s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
|
s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
|
||||||
// Run once a week, midnight between Sat/Sun
|
// Run once a week, midnight between Sat/Sun
|
||||||
|
|
@ -388,6 +410,8 @@ func (s *Server) Start() (err error) {
|
||||||
s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds())
|
s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds())
|
||||||
s.cron.Start()
|
s.cron.Start()
|
||||||
|
|
||||||
|
s.customGeoService = service.NewCustomGeoService()
|
||||||
|
|
||||||
engine, err := s.initRouter()
|
engine, err := s.initRouter()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -490,3 +514,7 @@ func (s *Server) GetCron() *cron.Cron {
|
||||||
func (s *Server) GetWSHub() any {
|
func (s *Server) GetWSHub() any {
|
||||||
return s.wsHub
|
return s.wsHub
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) RestartXray() error {
|
||||||
|
return s.xrayService.RestartXray(true)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const (
|
||||||
MessageTypeNotification MessageType = "notification" // System notification
|
MessageTypeNotification MessageType = "notification" // System notification
|
||||||
MessageTypeXrayState MessageType = "xray_state" // Xray state change
|
MessageTypeXrayState MessageType = "xray_state" // Xray state change
|
||||||
MessageTypeOutbounds MessageType = "outbounds" // Outbounds list update
|
MessageTypeOutbounds MessageType = "outbounds" // Outbounds list update
|
||||||
|
MessageTypeInvalidate MessageType = "invalidate" // Lightweight signal telling frontend to re-fetch data via REST
|
||||||
)
|
)
|
||||||
|
|
||||||
// Message represents a WebSocket message
|
// Message represents a WebSocket message
|
||||||
|
|
@ -32,10 +33,11 @@ type Message struct {
|
||||||
|
|
||||||
// Client represents a WebSocket client connection
|
// Client represents a WebSocket client connection
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ID string
|
ID string
|
||||||
Send chan []byte
|
Send chan []byte
|
||||||
Hub *Hub
|
Hub *Hub
|
||||||
Topics map[MessageType]bool // Subscribed topics
|
Topics map[MessageType]bool // Subscribed topics
|
||||||
|
closeOnce sync.Once // Ensures Send channel is closed exactly once
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hub maintains the set of active clients and broadcasts messages to them
|
// Hub maintains the set of active clients and broadcasts messages to them
|
||||||
|
|
@ -61,7 +63,6 @@ type Hub struct {
|
||||||
|
|
||||||
// Worker pool for parallel broadcasting
|
// Worker pool for parallel broadcasting
|
||||||
workerPoolSize int
|
workerPoolSize int
|
||||||
broadcastWg sync.WaitGroup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHub creates a new WebSocket hub
|
// NewHub creates a new WebSocket hub
|
||||||
|
|
@ -104,20 +105,12 @@ func (h *Hub) Run() {
|
||||||
// Graceful shutdown: close all clients
|
// Graceful shutdown: close all clients
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
for client := range h.clients {
|
for client := range h.clients {
|
||||||
// Safely close channel (avoid double close panic)
|
client.closeOnce.Do(func() {
|
||||||
select {
|
|
||||||
case _, stillOpen := <-client.Send:
|
|
||||||
if stillOpen {
|
|
||||||
close(client.Send)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
close(client.Send)
|
close(client.Send)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
h.clients = make(map[*Client]bool)
|
h.clients = make(map[*Client]bool)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
// Wait for all broadcast workers to finish
|
|
||||||
h.broadcastWg.Wait()
|
|
||||||
logger.Info("WebSocket hub stopped gracefully")
|
logger.Info("WebSocket hub stopped gracefully")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -138,19 +131,9 @@ func (h *Hub) Run() {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
if _, ok := h.clients[client]; ok {
|
if _, ok := h.clients[client]; ok {
|
||||||
delete(h.clients, client)
|
delete(h.clients, client)
|
||||||
// Safely close channel (avoid double close panic)
|
client.closeOnce.Do(func() {
|
||||||
// Check if channel is already closed by trying to read from it
|
|
||||||
select {
|
|
||||||
case _, stillOpen := <-client.Send:
|
|
||||||
if stillOpen {
|
|
||||||
// Channel was open and had data, now it's empty, safe to close
|
|
||||||
close(client.Send)
|
|
||||||
}
|
|
||||||
// If stillOpen is false, channel was already closed, do nothing
|
|
||||||
default:
|
|
||||||
// Channel is empty and open, safe to close
|
|
||||||
close(client.Send)
|
close(client.Send)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
count := len(h.clients)
|
count := len(h.clients)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
@ -220,11 +203,12 @@ func (h *Hub) broadcastParallel(clients []*Client, message []byte) {
|
||||||
}
|
}
|
||||||
close(clientChan)
|
close(clientChan)
|
||||||
|
|
||||||
// Start workers for parallel processing
|
// Use a local WaitGroup to avoid blocking hub shutdown
|
||||||
h.broadcastWg.Add(h.workerPoolSize)
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(h.workerPoolSize)
|
||||||
for i := 0; i < h.workerPoolSize; i++ {
|
for i := 0; i < h.workerPoolSize; i++ {
|
||||||
go func() {
|
go func() {
|
||||||
defer h.broadcastWg.Done()
|
defer wg.Done()
|
||||||
for client := range clientChan {
|
for client := range clientChan {
|
||||||
func() {
|
func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
@ -246,7 +230,7 @@ func (h *Hub) broadcastParallel(clients []*Client, message []byte) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all workers to finish
|
// Wait for all workers to finish
|
||||||
h.broadcastWg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast sends a message to all connected clients
|
// Broadcast sends a message to all connected clients
|
||||||
|
|
@ -259,6 +243,11 @@ func (h *Hub) Broadcast(messageType MessageType, payload any) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip all work if no clients are connected
|
||||||
|
if h.GetClientCount() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
msg := Message{
|
msg := Message{
|
||||||
Type: messageType,
|
Type: messageType,
|
||||||
Payload: payload,
|
Payload: payload,
|
||||||
|
|
@ -271,10 +260,12 @@ func (h *Hub) Broadcast(messageType MessageType, payload any) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit message size to prevent memory issues
|
// If message exceeds size limit, send a lightweight invalidate notification
|
||||||
const maxMessageSize = 1024 * 1024 // 1MB
|
// instead of dropping it entirely — the frontend will re-fetch via REST API
|
||||||
|
const maxMessageSize = 10 * 1024 * 1024 // 10MB
|
||||||
if len(data) > maxMessageSize {
|
if len(data) > maxMessageSize {
|
||||||
logger.Warningf("WebSocket message too large: %d bytes, dropping", len(data))
|
logger.Debugf("WebSocket message too large (%d bytes) for type %s, sending invalidate signal", len(data), messageType)
|
||||||
|
h.broadcastInvalidate(messageType)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -298,6 +289,11 @@ func (h *Hub) BroadcastToTopic(messageType MessageType, payload any) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip all work if no clients are connected
|
||||||
|
if h.GetClientCount() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
msg := Message{
|
msg := Message{
|
||||||
Type: messageType,
|
Type: messageType,
|
||||||
Payload: payload,
|
Payload: payload,
|
||||||
|
|
@ -310,10 +306,11 @@ func (h *Hub) BroadcastToTopic(messageType MessageType, payload any) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit message size to prevent memory issues
|
// If message exceeds size limit, send a lightweight invalidate notification
|
||||||
const maxMessageSize = 1024 * 1024 // 1MB
|
const maxMessageSize = 10 * 1024 * 1024 // 10MB
|
||||||
if len(data) > maxMessageSize {
|
if len(data) > maxMessageSize {
|
||||||
logger.Warningf("WebSocket message too large: %d bytes, dropping", len(data))
|
logger.Debugf("WebSocket message too large (%d bytes) for type %s, sending invalidate signal", len(data), messageType)
|
||||||
|
h.broadcastInvalidate(messageType)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,6 +371,31 @@ func (h *Hub) Stop() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// broadcastInvalidate sends a lightweight invalidate message to all clients,
|
||||||
|
// telling them to re-fetch the specified data type via REST API.
|
||||||
|
// This is used when the full payload exceeds the WebSocket message size limit.
|
||||||
|
func (h *Hub) broadcastInvalidate(originalType MessageType) {
|
||||||
|
msg := Message{
|
||||||
|
Type: MessageTypeInvalidate,
|
||||||
|
Payload: map[string]string{"type": string(originalType)},
|
||||||
|
Time: getCurrentTimestamp(),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to marshal invalidate message:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-blocking send with timeout
|
||||||
|
select {
|
||||||
|
case h.broadcast <- data:
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
logger.Warning("WebSocket broadcast channel is full, dropping invalidate message")
|
||||||
|
case <-h.ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getCurrentTimestamp returns current Unix timestamp in milliseconds
|
// getCurrentTimestamp returns current Unix timestamp in milliseconds
|
||||||
func getCurrentTimestamp() int64 {
|
func getCurrentTimestamp() int64 {
|
||||||
return time.Now().UnixMilli()
|
return time.Now().UnixMilli()
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,16 @@ func GetHub() *Hub {
|
||||||
return wsHub
|
return wsHub
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasClients returns true if there are any WebSocket clients connected.
|
||||||
|
// Use this to skip expensive work (DB queries, serialization) when no browser is open.
|
||||||
|
func HasClients() bool {
|
||||||
|
hub := GetHub()
|
||||||
|
if hub == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return hub.GetClientCount() > 0
|
||||||
|
}
|
||||||
|
|
||||||
// BroadcastStatus broadcasts server status update to all connected clients
|
// BroadcastStatus broadcasts server status update to all connected clients
|
||||||
func BroadcastStatus(status any) {
|
func BroadcastStatus(status any) {
|
||||||
hub := GetHub()
|
hub := GetHub()
|
||||||
|
|
@ -80,3 +90,14 @@ func BroadcastXrayState(state string, errorMsg string) {
|
||||||
hub.Broadcast(MessageTypeXrayState, stateUpdate)
|
hub.Broadcast(MessageTypeXrayState, stateUpdate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BroadcastInvalidate sends a lightweight invalidate signal for the given data type,
|
||||||
|
// telling connected frontends to re-fetch data via REST API.
|
||||||
|
// Use this instead of BroadcastInbounds/BroadcastOutbounds when you know the payload
|
||||||
|
// will be too large, to avoid wasting resources on serialization.
|
||||||
|
func BroadcastInvalidate(dataType MessageType) {
|
||||||
|
hub := GetHub()
|
||||||
|
if hub != nil {
|
||||||
|
hub.broadcastInvalidate(dataType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
5
x-ui.rc
5
x-ui.rc
|
|
@ -10,4 +10,9 @@ depend() {
|
||||||
}
|
}
|
||||||
start_pre(){
|
start_pre(){
|
||||||
cd /usr/local/x-ui
|
cd /usr/local/x-ui
|
||||||
|
}
|
||||||
|
reload() {
|
||||||
|
ebegin "Reloading ${RC_SVCNAME}"
|
||||||
|
kill -USR1 $pidfile
|
||||||
|
eend $?
|
||||||
}
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ Environment="XRAY_VMESS_AEAD_FORCED=false"
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=/usr/lib/x-ui/
|
WorkingDirectory=/usr/lib/x-ui/
|
||||||
ExecStart=/usr/lib/x-ui/x-ui
|
ExecStart=/usr/lib/x-ui/x-ui
|
||||||
|
ExecReload=kill -USR1 $MAINPID
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ Environment="XRAY_VMESS_AEAD_FORCED=false"
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=/usr/local/x-ui/
|
WorkingDirectory=/usr/local/x-ui/
|
||||||
ExecStart=/usr/local/x-ui/x-ui
|
ExecStart=/usr/local/x-ui/x-ui
|
||||||
|
ExecReload=kill -USR1 $MAINPID
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ Environment="XRAY_VMESS_AEAD_FORCED=false"
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=/usr/local/x-ui/
|
WorkingDirectory=/usr/local/x-ui/
|
||||||
ExecStart=/usr/local/x-ui/x-ui
|
ExecStart=/usr/local/x-ui/x-ui
|
||||||
|
ExecReload=kill -USR1 $MAINPID
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
|
|
||||||
|
|
|
||||||
140
x-ui.sh
140
x-ui.sh
|
|
@ -243,8 +243,9 @@ reset_user() {
|
||||||
|
|
||||||
gen_random_string() {
|
gen_random_string() {
|
||||||
local length="$1"
|
local length="$1"
|
||||||
local random_string=$(LC_ALL=C tr -dc 'a-zA-Z0-9' </dev/urandom | fold -w "$length" | head -n 1)
|
openssl rand -base64 $(( length * 2 )) \
|
||||||
echo "$random_string"
|
| tr -dc 'a-zA-Z0-9' \
|
||||||
|
| head -c "$length"
|
||||||
}
|
}
|
||||||
|
|
||||||
reset_webbasepath() {
|
reset_webbasepath() {
|
||||||
|
|
@ -317,12 +318,12 @@ check_config() {
|
||||||
start >/dev/null 2>&1
|
start >/dev/null 2>&1
|
||||||
else
|
else
|
||||||
LOGE "IP certificate setup failed."
|
LOGE "IP certificate setup failed."
|
||||||
echo -e "${yellow}You can try again via option 18 (SSL Certificate Management).${plain}"
|
echo -e "${yellow}You can try again via option 19 (SSL Certificate Management).${plain}"
|
||||||
start >/dev/null 2>&1
|
start >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${yellow}Access URL: http://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
echo -e "${yellow}Access URL: http://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
||||||
echo -e "${yellow}For security, please configure SSL certificate using option 18 (SSL Certificate Management)${plain}"
|
echo -e "${yellow}For security, please configure SSL certificate using option 19 (SSL Certificate Management)${plain}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
@ -408,6 +409,16 @@ restart() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restart_xray() {
|
||||||
|
systemctl reload x-ui
|
||||||
|
LOGI "xray-core Restart signal sent successfully, Please check the log information to confirm whether xray restarted successfully"
|
||||||
|
sleep 2
|
||||||
|
show_xray_status
|
||||||
|
if [[ $# == 0 ]]; then
|
||||||
|
before_show_menu
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
status() {
|
status() {
|
||||||
if [[ $release == "alpine" ]]; then
|
if [[ $release == "alpine" ]]; then
|
||||||
rc-service x-ui status
|
rc-service x-ui status
|
||||||
|
|
@ -421,7 +432,7 @@ status() {
|
||||||
|
|
||||||
enable() {
|
enable() {
|
||||||
if [[ $release == "alpine" ]]; then
|
if [[ $release == "alpine" ]]; then
|
||||||
rc-update add x-ui
|
rc-update add x-ui default
|
||||||
else
|
else
|
||||||
systemctl enable x-ui
|
systemctl enable x-ui
|
||||||
fi
|
fi
|
||||||
|
|
@ -1360,14 +1371,15 @@ ssl_cert_issue() {
|
||||||
break
|
break
|
||||||
done
|
done
|
||||||
LOGD "Your domain is: ${domain}, checking it..."
|
LOGD "Your domain is: ${domain}, checking it..."
|
||||||
|
SSL_ISSUED_DOMAIN="${domain}"
|
||||||
|
|
||||||
# check if there already exists a certificate
|
# detect existing certificate and reuse it if present
|
||||||
local currentCert=$(~/.acme.sh/acme.sh --list | tail -1 | awk '{print $1}')
|
local cert_exists=0
|
||||||
if [ "${currentCert}" == "${domain}" ]; then
|
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||||
local certInfo=$(~/.acme.sh/acme.sh --list)
|
cert_exists=1
|
||||||
LOGE "System already has certificates for this domain. Cannot issue again. Current certificate details:"
|
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
|
||||||
LOGI "$certInfo"
|
LOGI "Existing certificate found for ${domain}, will reuse it."
|
||||||
exit 1
|
[[ -n "${certInfo}" ]] && LOGI "${certInfo}"
|
||||||
else
|
else
|
||||||
LOGI "Your domain is ready for issuing certificates now..."
|
LOGI "Your domain is ready for issuing certificates now..."
|
||||||
fi
|
fi
|
||||||
|
|
@ -1390,15 +1402,19 @@ ssl_cert_issue() {
|
||||||
fi
|
fi
|
||||||
LOGI "Will use port: ${WebPort} to issue certificates. Please make sure this port is open."
|
LOGI "Will use port: ${WebPort} to issue certificates. Please make sure this port is open."
|
||||||
|
|
||||||
# issue the certificate
|
if [[ ${cert_exists} -eq 0 ]]; then
|
||||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
# issue the certificate
|
||||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
||||||
if [ $? -ne 0 ]; then
|
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||||
LOGE "Issuing certificate failed, please check logs."
|
if [ $? -ne 0 ]; then
|
||||||
rm -rf ~/.acme.sh/${domain}
|
LOGE "Issuing certificate failed, please check logs."
|
||||||
exit 1
|
rm -rf ~/.acme.sh/${domain}
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
LOGE "Issuing certificate succeeded, installing certificates..."
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
LOGE "Issuing certificate succeeded, installing certificates..."
|
LOGI "Using existing certificate, installing certificates..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
reloadCmd="x-ui restart"
|
reloadCmd="x-ui restart"
|
||||||
|
|
@ -1428,16 +1444,26 @@ ssl_cert_issue() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# install the certificate
|
# install the certificate
|
||||||
~/.acme.sh/acme.sh --installcert -d ${domain} \
|
local installOutput=""
|
||||||
|
installOutput=$(~/.acme.sh/acme.sh --installcert -d ${domain} \
|
||||||
--key-file /root/cert/${domain}/privkey.pem \
|
--key-file /root/cert/${domain}/privkey.pem \
|
||||||
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}"
|
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
|
||||||
|
local installRc=$?
|
||||||
|
echo "${installOutput}"
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
local installWroteFiles=0
|
||||||
LOGE "Installing certificate failed, exiting."
|
if echo "${installOutput}" | grep -q "Installing key to:" && echo "${installOutput}" | grep -q "Installing full chain to:"; then
|
||||||
rm -rf ~/.acme.sh/${domain}
|
installWroteFiles=1
|
||||||
exit 1
|
fi
|
||||||
else
|
|
||||||
|
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && ( ${installRc} -eq 0 || ${installWroteFiles} -eq 1 ) ]]; then
|
||||||
LOGI "Installing certificate succeeded, enabling auto renew..."
|
LOGI "Installing certificate succeeded, enabling auto renew..."
|
||||||
|
else
|
||||||
|
LOGE "Installing certificate failed, exiting."
|
||||||
|
if [[ ${cert_exists} -eq 0 ]]; then
|
||||||
|
rm -rf ~/.acme.sh/${domain}
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# enable auto-renew
|
# enable auto-renew
|
||||||
|
|
@ -2002,7 +2028,7 @@ EOF
|
||||||
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
|
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
|
||||||
[Definition]
|
[Definition]
|
||||||
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
|
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
|
||||||
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*SRC\s*=\s*<ADDR>
|
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||||
ignoreregex =
|
ignoreregex =
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|
@ -2154,6 +2180,7 @@ show_usage() {
|
||||||
│ ${blue}x-ui start${plain} - Start │
|
│ ${blue}x-ui start${plain} - Start │
|
||||||
│ ${blue}x-ui stop${plain} - Stop │
|
│ ${blue}x-ui stop${plain} - Stop │
|
||||||
│ ${blue}x-ui restart${plain} - Restart │
|
│ ${blue}x-ui restart${plain} - Restart │
|
||||||
|
| ${blue}x-ui restart-xray${plain} - Restart Xray │
|
||||||
│ ${blue}x-ui status${plain} - Current Status │
|
│ ${blue}x-ui status${plain} - Current Status │
|
||||||
│ ${blue}x-ui settings${plain} - Current Settings │
|
│ ${blue}x-ui settings${plain} - Current Settings │
|
||||||
│ ${blue}x-ui enable${plain} - Enable Autostart on OS Startup │
|
│ ${blue}x-ui enable${plain} - Enable Autostart on OS Startup │
|
||||||
|
|
@ -2189,25 +2216,26 @@ show_menu() {
|
||||||
│ ${green}11.${plain} Start │
|
│ ${green}11.${plain} Start │
|
||||||
│ ${green}12.${plain} Stop │
|
│ ${green}12.${plain} Stop │
|
||||||
│ ${green}13.${plain} Restart │
|
│ ${green}13.${plain} Restart │
|
||||||
│ ${green}14.${plain} Check Status │
|
| ${green}14.${plain} Restart Xray │
|
||||||
│ ${green}15.${plain} Logs Management │
|
│ ${green}15.${plain} Check Status │
|
||||||
|
│ ${green}16.${plain} Logs Management │
|
||||||
│────────────────────────────────────────────────│
|
│────────────────────────────────────────────────│
|
||||||
│ ${green}16.${plain} Enable Autostart │
|
│ ${green}17.${plain} Enable Autostart │
|
||||||
│ ${green}17.${plain} Disable Autostart │
|
│ ${green}18.${plain} Disable Autostart │
|
||||||
│────────────────────────────────────────────────│
|
│────────────────────────────────────────────────│
|
||||||
│ ${green}18.${plain} SSL Certificate Management │
|
│ ${green}19.${plain} SSL Certificate Management │
|
||||||
│ ${green}19.${plain} Cloudflare SSL Certificate │
|
│ ${green}20.${plain} Cloudflare SSL Certificate │
|
||||||
│ ${green}20.${plain} IP Limit Management │
|
│ ${green}21.${plain} IP Limit Management │
|
||||||
│ ${green}21.${plain} Firewall Management │
|
│ ${green}22.${plain} Firewall Management │
|
||||||
│ ${green}22.${plain} SSH Port Forwarding Management │
|
│ ${green}23.${plain} SSH Port Forwarding Management │
|
||||||
│────────────────────────────────────────────────│
|
│────────────────────────────────────────────────│
|
||||||
│ ${green}23.${plain} Enable BBR │
|
│ ${green}24.${plain} Enable BBR │
|
||||||
│ ${green}24.${plain} Update Geo Files │
|
│ ${green}25.${plain} Update Geo Files │
|
||||||
│ ${green}25.${plain} Speedtest by Ookla │
|
│ ${green}26.${plain} Speedtest by Ookla │
|
||||||
╚────────────────────────────────────────────────╝
|
╚────────────────────────────────────────────────╝
|
||||||
"
|
"
|
||||||
show_status
|
show_status
|
||||||
echo && read -rp "Please enter your selection [0-25]: " num
|
echo && read -rp "Please enter your selection [0-26]: " num
|
||||||
|
|
||||||
case "${num}" in
|
case "${num}" in
|
||||||
0)
|
0)
|
||||||
|
|
@ -2253,43 +2281,46 @@ show_menu() {
|
||||||
check_install && restart
|
check_install && restart
|
||||||
;;
|
;;
|
||||||
14)
|
14)
|
||||||
check_install && status
|
check_install && restart_xray
|
||||||
;;
|
;;
|
||||||
15)
|
15)
|
||||||
check_install && show_log
|
check_install && status
|
||||||
;;
|
;;
|
||||||
16)
|
16)
|
||||||
check_install && enable
|
check_install && show_log
|
||||||
;;
|
;;
|
||||||
17)
|
17)
|
||||||
check_install && disable
|
check_install && enable
|
||||||
;;
|
;;
|
||||||
18)
|
18)
|
||||||
ssl_cert_issue_main
|
check_install && disable
|
||||||
;;
|
;;
|
||||||
19)
|
19)
|
||||||
ssl_cert_issue_CF
|
ssl_cert_issue_main
|
||||||
;;
|
;;
|
||||||
20)
|
20)
|
||||||
iplimit_main
|
ssl_cert_issue_CF
|
||||||
;;
|
;;
|
||||||
21)
|
21)
|
||||||
firewall_menu
|
iplimit_main
|
||||||
;;
|
;;
|
||||||
22)
|
22)
|
||||||
SSH_port_forwarding
|
firewall_menu
|
||||||
;;
|
;;
|
||||||
23)
|
23)
|
||||||
bbr_menu
|
SSH_port_forwarding
|
||||||
;;
|
;;
|
||||||
24)
|
24)
|
||||||
update_geo
|
bbr_menu
|
||||||
;;
|
;;
|
||||||
25)
|
25)
|
||||||
|
update_geo
|
||||||
|
;;
|
||||||
|
26)
|
||||||
run_speedtest
|
run_speedtest
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
LOGE "Please enter the correct number [0-25]"
|
LOGE "Please enter the correct number [0-26]"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
@ -2305,6 +2336,9 @@ if [[ $# > 0 ]]; then
|
||||||
"restart")
|
"restart")
|
||||||
check_install 0 && restart 0
|
check_install 0 && restart 0
|
||||||
;;
|
;;
|
||||||
|
"restart-xray")
|
||||||
|
check_install 0 && restart_xray 0
|
||||||
|
;;
|
||||||
"status")
|
"status")
|
||||||
check_install 0 && status 0
|
check_install 0 && status 0
|
||||||
;;
|
;;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue